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/verify.py
ADDED
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Check the part we actually deliver, not the density field we optimized.
|
|
3
|
+
|
|
4
|
+
The final mesh differs from the optimizer's field (smoothing, the exact
|
|
5
|
+
booleans, dropped fragments), so verification re-voxelizes the mesh,
|
|
6
|
+
runs a fresh linear static analysis at the material's real modulus for every
|
|
7
|
+
load case, and measures geometry with exact booleans:
|
|
8
|
+
|
|
9
|
+
- mass, and how much lighter than the solid design space it is;
|
|
10
|
+
- peak von Mises stress per load case and the safety factor against the
|
|
11
|
+
material's strength;
|
|
12
|
+
- deflection at the loads;
|
|
13
|
+
- fatigue life, creep and bolt-preload relaxation for load cases that
|
|
14
|
+
repeat or are held (durability.py);
|
|
15
|
+
- keep-out intrusion and preserve loss (mm^3, from booleans: both should be
|
|
16
|
+
zero to rounding);
|
|
17
|
+
- body count and thinnest member.
|
|
18
|
+
|
|
19
|
+
Stress next to where loads and supports are applied is excluded from the
|
|
20
|
+
peak. A load spread over a few nodes is a modeling idealization, and the
|
|
21
|
+
stress it produces there is a property of the idealization, not of the
|
|
22
|
+
part; every FEA package has the same caveat. Everything else counts.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
from scipy import ndimage
|
|
31
|
+
|
|
32
|
+
from .domain import boundary_conditions, gravity_force
|
|
33
|
+
from .fem import HexMesh, Solver, Stiffness, von_mises
|
|
34
|
+
from .spec import Study
|
|
35
|
+
|
|
36
|
+
__all__ = ["voxelize", "verify_part"]
|
|
37
|
+
|
|
38
|
+
STRESS_ALLOWANCE = 1.15
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def voxelize(mesh, pitch: float, box=None, fractional=False):
|
|
42
|
+
"""Solid cells of a closed mesh by ray parity along +z through each
|
|
43
|
+
column of cell centers. Returns (bool grid, origin = min corner of cell
|
|
44
|
+
(0,0,0)). `box` = (lo, hi) voxelizes only that region (the rays still
|
|
45
|
+
start below the whole mesh, so parity is right). `fractional` returns
|
|
46
|
+
each cell's filled share instead (float grid): the z overlap is exact
|
|
47
|
+
from the ray's crossings, averaged over 3 x 3 sub-columns per cell."""
|
|
48
|
+
if fractional:
|
|
49
|
+
return _voxel_fractions(mesh, pitch, box)
|
|
50
|
+
mlo = np.asarray(mesh.bounds, float)[0]
|
|
51
|
+
lo, hi = (np.asarray(b, float) for b in box) if box is not None else np.asarray(mesh.bounds, float)
|
|
52
|
+
# a box a whole number of cells across (a design grid's) must come out
|
|
53
|
+
# that many: (hi - lo) / pitch rounds to just over n at some pitches
|
|
54
|
+
# (the arm link's 57 cells read 58 at 2.887 mm, and its access report
|
|
55
|
+
# failed on the shape mismatch)
|
|
56
|
+
dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-6).astype(int), 1)
|
|
57
|
+
origin = lo - 0.5 * (dims * pitch - (hi - lo))
|
|
58
|
+
xs = origin[0] + (np.arange(dims[0]) + 0.5) * pitch
|
|
59
|
+
ys = origin[1] + (np.arange(dims[1]) + 0.5) * pitch
|
|
60
|
+
gx, gy = np.meshgrid(xs, ys, indexing="ij")
|
|
61
|
+
# A hair of jitter keeps rays off exact edges and vertices, where a hit
|
|
62
|
+
# could be reported twice and flip the parity.
|
|
63
|
+
jit = pitch * 1e-4
|
|
64
|
+
ray_o = np.stack([gx.ravel() + 0.37 * jit, gy.ravel() + 0.61 * jit,
|
|
65
|
+
np.full(gx.size, mlo[2] - pitch)], axis=1)
|
|
66
|
+
ray_d = np.tile([0.0, 0.0, 1.0], (len(ray_o), 1))
|
|
67
|
+
locs, ray_idx, _ = mesh.ray.intersects_location(ray_o, ray_d, multiple_hits=True)
|
|
68
|
+
if len(ray_idx) == 0 and len(mesh.faces):
|
|
69
|
+
# A mesh handed back from a worker process (GRIP's turret) answered
|
|
70
|
+
# no ray at all, where the same mesh saved and reloaded answers
|
|
71
|
+
# 16,740: rebuild it fresh, with a fresh intersector, and ask again.
|
|
72
|
+
import trimesh
|
|
73
|
+
|
|
74
|
+
fresh = trimesh.Trimesh(np.array(mesh.vertices, float), np.array(mesh.faces), process=False)
|
|
75
|
+
locs, ray_idx, _ = fresh.ray.intersects_location(ray_o, ray_d, multiple_hits=True)
|
|
76
|
+
locs = np.asarray(locs, float).reshape(-1, 3)
|
|
77
|
+
ray_idx = np.asarray(ray_idx, int).reshape(-1)
|
|
78
|
+
zc = origin[2] + (np.arange(dims[2]) + 0.5) * pitch
|
|
79
|
+
solid = np.zeros(dims, bool)
|
|
80
|
+
order = np.lexsort((locs[:, 2], ray_idx))
|
|
81
|
+
ray_idx, zs = ray_idx[order], locs[order, 2]
|
|
82
|
+
starts = np.searchsorted(ray_idx, np.arange(len(ray_o)))
|
|
83
|
+
ends = np.searchsorted(ray_idx, np.arange(len(ray_o)), side="right")
|
|
84
|
+
for r in np.nonzero(ends > starts)[0]:
|
|
85
|
+
z = zs[starts[r]:ends[r]]
|
|
86
|
+
# Merge near-duplicate hits (shared edges) before pairing.
|
|
87
|
+
if len(z) > 1:
|
|
88
|
+
z = z[np.concatenate([[True], np.diff(z) > 1e-7])]
|
|
89
|
+
if len(z) % 2:
|
|
90
|
+
z = z[:-1]
|
|
91
|
+
inside = np.zeros(dims[2], bool)
|
|
92
|
+
for a, b in z.reshape(-1, 2):
|
|
93
|
+
inside |= (zc > a) & (zc < b)
|
|
94
|
+
i, j = np.unravel_index(r, (dims[0], dims[1]))
|
|
95
|
+
solid[i, j] = inside
|
|
96
|
+
return solid, origin
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _voxel_fractions(mesh, pitch, box=None, sub=3):
|
|
100
|
+
lo, hi = (np.asarray(b, float) for b in box) if box is not None else np.asarray(mesh.bounds, float)
|
|
101
|
+
dims = np.maximum(np.ceil((hi - lo) / pitch - 1e-6).astype(int), 1)
|
|
102
|
+
origin = lo - 0.5 * (dims * pitch - (hi - lo))
|
|
103
|
+
# the sub-columns are one bool grid at pitch/sub in x and y; each z
|
|
104
|
+
# interval's overlap with each cell is added exactly
|
|
105
|
+
mlo = np.asarray(mesh.bounds, float)[0]
|
|
106
|
+
fine = np.array([dims[0] * sub, dims[1] * sub])
|
|
107
|
+
xs = origin[0] + (np.arange(fine[0]) + 0.5) * pitch / sub
|
|
108
|
+
ys = origin[1] + (np.arange(fine[1]) + 0.5) * pitch / sub
|
|
109
|
+
gx, gy = np.meshgrid(xs, ys, indexing="ij")
|
|
110
|
+
jit = pitch * 1e-4
|
|
111
|
+
ray_o = np.stack([gx.ravel() + 0.37 * jit, gy.ravel() + 0.61 * jit,
|
|
112
|
+
np.full(gx.size, mlo[2] - pitch)], axis=1)
|
|
113
|
+
locs, ray_idx, _ = mesh.ray.intersects_location(ray_o, np.tile([0.0, 0.0, 1.0], (len(ray_o), 1)),
|
|
114
|
+
multiple_hits=True)
|
|
115
|
+
locs = np.asarray(locs, float).reshape(-1, 3)
|
|
116
|
+
ray_idx = np.asarray(ray_idx, int).reshape(-1)
|
|
117
|
+
order = np.lexsort((locs[:, 2], ray_idx))
|
|
118
|
+
ray_idx, zs = ray_idx[order], locs[order, 2]
|
|
119
|
+
starts = np.searchsorted(ray_idx, np.arange(len(ray_o)))
|
|
120
|
+
ends = np.searchsorted(ray_idx, np.arange(len(ray_o)), side="right")
|
|
121
|
+
z0 = origin[2] + np.arange(dims[2]) * pitch
|
|
122
|
+
frac = np.zeros(dims, float)
|
|
123
|
+
for r in np.nonzero(ends > starts)[0]:
|
|
124
|
+
z = zs[starts[r]:ends[r]]
|
|
125
|
+
if len(z) > 1:
|
|
126
|
+
z = z[np.concatenate([[True], np.diff(z) > 1e-7])]
|
|
127
|
+
if len(z) % 2:
|
|
128
|
+
z = z[:-1]
|
|
129
|
+
cover = np.zeros(dims[2])
|
|
130
|
+
for a, b in z.reshape(-1, 2):
|
|
131
|
+
cover += np.clip(np.minimum(b, z0 + pitch) - np.maximum(a, z0), 0.0, pitch)
|
|
132
|
+
i, j = np.unravel_index(r, (fine[0], fine[1]))
|
|
133
|
+
frac[i // sub, j // sub] += cover
|
|
134
|
+
return np.clip(frac / (pitch * sub * sub), 0.0, 1.0), origin
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _thickness(solid, pitch):
|
|
138
|
+
"""Member thickness on the medial ridge: twice the distance to void at
|
|
139
|
+
cells that are local maxima of that distance."""
|
|
140
|
+
edt = ndimage.distance_transform_edt(np.pad(solid, 1))[1:-1, 1:-1, 1:-1]
|
|
141
|
+
ridge = solid & (edt >= ndimage.maximum_filter(edt, size=3)) & (edt > 0)
|
|
142
|
+
# A ridge cell's distance to void is ceil(n/2) for a member n cells
|
|
143
|
+
# thick, so 2*edt over-reads odd and 2*edt-1 under-reads even
|
|
144
|
+
# thicknesses by a whole cell; 2*edt-0.5 is within half a cell of both.
|
|
145
|
+
vals = (2.0 * edt[ridge] - 0.5) * pitch
|
|
146
|
+
if vals.size == 0:
|
|
147
|
+
return None, None
|
|
148
|
+
return float(vals.min()), float(np.percentile(vals, 5))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def surface_thickness(mesh, samples: int = 6000, seed: int = 0, study: Study | None = None):
|
|
152
|
+
"""Member thickness without a grid: from evenly spread surface points, a
|
|
153
|
+
ray straight in along the inward normal to where it leaves the part (the
|
|
154
|
+
shape diameter). Returns (p1, p5) in mm. The voxel ridge reading
|
|
155
|
+
(_thickness) is quantized to the analysis cell, under-reading a 2.05 mm
|
|
156
|
+
wall at a 0.98 mm pitch as 1.46; this one is not, but it costs a ray
|
|
157
|
+
cast per sample, so it is for scoring a finished part, not every outcome.
|
|
158
|
+
With the study, a ray that both enters and leaves on the user's own
|
|
159
|
+
surfaces (preserved or keep-out) is left out: that thickness is the
|
|
160
|
+
study's, not the optimizer's (GRIP's wrist yoke: its J5 servo sits 1.9 mm
|
|
161
|
+
above the J4 horn, so the preserved horn plate between them is 1.9 mm)."""
|
|
162
|
+
import trimesh
|
|
163
|
+
|
|
164
|
+
# a fresh, merged copy: a part straight from the booleans can carry
|
|
165
|
+
# degenerate slivers whose bounds break the ray tree (the motor mount)
|
|
166
|
+
mesh = trimesh.Trimesh(np.array(mesh.vertices, float), np.array(mesh.faces), process=True)
|
|
167
|
+
mesh.update_faces(mesh.nondegenerate_faces())
|
|
168
|
+
pts, fi = trimesh.sample.sample_surface_even(mesh, samples, seed=seed)
|
|
169
|
+
n = mesh.face_normals[fi]
|
|
170
|
+
ok = np.isfinite(pts).all(axis=1) & (np.linalg.norm(n, axis=1) > 0.5)
|
|
171
|
+
pts, n = pts[ok], n[ok]
|
|
172
|
+
origin = pts - n * 1e-3
|
|
173
|
+
# in batches: without embree the ray tree's candidate pairs for all 6000
|
|
174
|
+
# rays at once took 7 GB on a 75k-face yoke (the cloud box OOMed)
|
|
175
|
+
hits = [mesh.ray.intersects_location(origin[k:k + 500], -n[k:k + 500], multiple_hits=False)[:2]
|
|
176
|
+
for k in range(0, len(origin), 500)]
|
|
177
|
+
loc = np.concatenate([h[0].reshape(-1, 3) for h in hits]) if hits else np.zeros((0, 3))
|
|
178
|
+
ri = np.concatenate([h[1] + k for h, k in zip(hits, range(0, len(origin), 500))]).astype(int) if hits else np.zeros(0, int)
|
|
179
|
+
if len(ri) == 0:
|
|
180
|
+
return None, None
|
|
181
|
+
d = np.linalg.norm(loc - origin[ri], axis=1)
|
|
182
|
+
if study is not None:
|
|
183
|
+
shapes = [r.shape for r in list(study.preserve) + list(study.keep_out)]
|
|
184
|
+
if shapes:
|
|
185
|
+
def on_user(q):
|
|
186
|
+
return np.min([np.abs(sh.sdf(q)) for sh in shapes], axis=0) < 0.05
|
|
187
|
+
mine = ~(on_user(pts[ri]) & on_user(loc))
|
|
188
|
+
if mine.any():
|
|
189
|
+
d = d[mine]
|
|
190
|
+
return float(np.percentile(d, 1)), float(np.percentile(d, 5))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def nodal_von_mises(stiff, u, modulus, thermal=None):
|
|
194
|
+
"""Von Mises at the nodes: each element's stress evaluated at its own
|
|
195
|
+
corners, averaged over the elements sharing a node. Element-centre stress
|
|
196
|
+
sits half a cell inside the surface, which is exactly where stress
|
|
197
|
+
concentrates; on a plate with a hole (Peterson Kt_net = 2.43, peak 81 MPa)
|
|
198
|
+
centre values read 64-74 MPa and neighbor-smoothed centres 59-68, while
|
|
199
|
+
nodal averages read 73 at 7 cells per hole radius and 81.7 from 9 cells
|
|
200
|
+
up, converged. Averaging across elements is also what keeps a single
|
|
201
|
+
voxel staircase corner from reading as a spike."""
|
|
202
|
+
return von_mises(nodal_stress(stiff, u, modulus, thermal))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def nodal_stress(stiff, u, modulus, thermal=None):
|
|
206
|
+
"""The nodal-averaged stress tensor (sx, sy, sz, txy, tyz, tzx), as
|
|
207
|
+
nodal_von_mises averages it. `modulus` is one number or one per element;
|
|
208
|
+
`thermal` (per element) is a free expansion strain, taken off the strain
|
|
209
|
+
before the stress is formed."""
|
|
210
|
+
from .fem import CORNERS, elastic_c, hex8_b
|
|
211
|
+
|
|
212
|
+
cg = getattr(stiff, "cutgrid", None)
|
|
213
|
+
if cg is not None:
|
|
214
|
+
return cg.nodal_stress(u, modulus, thermal)
|
|
215
|
+
mesh = stiff.mesh
|
|
216
|
+
c = elastic_c(1.0, stiff.nu)
|
|
217
|
+
element = getattr(stiff, "element", None)
|
|
218
|
+
mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,))[:, None]
|
|
219
|
+
ue = u[mesh.edof]
|
|
220
|
+
acc = np.zeros((mesh.n_nodes, 6))
|
|
221
|
+
for k, (a, b, cc) in enumerate(2.0 * CORNERS - 1.0):
|
|
222
|
+
strain = ue @ (hex8_b(a, b, cc, stiff.nu, element) / mesh.pitch).T
|
|
223
|
+
if thermal is not None:
|
|
224
|
+
strain[:, :3] -= np.asarray(thermal, float)[:, None]
|
|
225
|
+
np.add.at(acc, mesh.enode[:, k], (strain @ c.T) * mod)
|
|
226
|
+
cnt = np.bincount(mesh.enode.ravel(), minlength=mesh.n_nodes)
|
|
227
|
+
return acc / cnt[:, None]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# mm above the lowest point that still counts as standing on the build plate
|
|
231
|
+
_PLATE_BAND = 1.0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _overhang(mesh, direction, study=None, max_angle_deg=45.0, tolerance_deg=5.0):
|
|
235
|
+
"""Down-facing surface that needs support: faces whose normal is within
|
|
236
|
+
(max_angle - tolerance) of straight down, i.e. ceilings and slopes
|
|
237
|
+
shallower than the self-supporting limit, excluding the face on the build
|
|
238
|
+
plate and the rounded bottom edge within 1 mm of it (a few layers, which
|
|
239
|
+
print without support; marching cubes rounds every edge on the plate, and
|
|
240
|
+
on the printed hook the 0.5-1 mm band alone was a point of its 4.3%). The
|
|
241
|
+
tolerance keeps marching-cubes facets on a true 45-degree
|
|
242
|
+
slope from counting. `overhangArea` is everything; `designOverhangArea`
|
|
243
|
+
leaves out faces on preserved or keep-out geometry, which the user drew
|
|
244
|
+
(a horizontal bore's ceiling is theirs, not the optimizer's)."""
|
|
245
|
+
from .am import build_vector
|
|
246
|
+
|
|
247
|
+
up = build_vector(direction)
|
|
248
|
+
down = -(mesh.face_normals @ up)
|
|
249
|
+
centers = mesh.triangles_center
|
|
250
|
+
height = centers @ up
|
|
251
|
+
bad = (down > np.cos(np.radians(max_angle_deg - tolerance_deg))) & (height > height.min() + _PLATE_BAND)
|
|
252
|
+
area = float(mesh.area_faces[bad].sum())
|
|
253
|
+
out = {"overhangArea": area, "overhangFraction": area / float(mesh.area)}
|
|
254
|
+
if study is not None:
|
|
255
|
+
drawn = np.zeros(len(centers), bool)
|
|
256
|
+
for r in study.preserve + study.keep_out:
|
|
257
|
+
drawn |= np.abs(r.shape.sdf(centers)) < 0.6
|
|
258
|
+
d_area = float(mesh.area_faces[bad & ~drawn].sum())
|
|
259
|
+
out["designOverhangArea"] = d_area
|
|
260
|
+
out["designOverhangFraction"] = d_area / float(mesh.area)
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _boundary_cut_area(study: Study, mesh) -> float:
|
|
265
|
+
"""mm^2 of the part's surface lying flat on a face of a box design space,
|
|
266
|
+
away from every support and preserved shape: organic material sawn off
|
|
267
|
+
where the design box happened to end, not where anything mates. Zero on
|
|
268
|
+
a clean result (GRIP's wrist yoke QA, 10/28)."""
|
|
269
|
+
from .shapes import Box
|
|
270
|
+
|
|
271
|
+
boxes = [d for d in study.design_space if isinstance(d, Box)]
|
|
272
|
+
if not boxes:
|
|
273
|
+
return 0.0
|
|
274
|
+
c = mesh.triangles_center
|
|
275
|
+
nrm = mesh.face_normals
|
|
276
|
+
area = mesh.area_faces
|
|
277
|
+
keep = [r.shape for r in study.preserve]
|
|
278
|
+
for sup in study.supports:
|
|
279
|
+
try:
|
|
280
|
+
keep.append(sup.shape if sup.shape is not None else study.region_shape(sup.region)[0])
|
|
281
|
+
except ValueError:
|
|
282
|
+
pass
|
|
283
|
+
near = np.min([k.sdf(c) for k in keep], axis=0) < 1.0 if keep else np.zeros(len(c), bool)
|
|
284
|
+
cut = np.zeros(len(c), bool)
|
|
285
|
+
for b in boxes:
|
|
286
|
+
for ax in range(3):
|
|
287
|
+
for side, plane in ((-1, b.lo[ax]), (1, b.hi[ax])):
|
|
288
|
+
cut |= (np.abs(c[:, ax] - plane) < 0.05) & (nrm[:, ax] * side > 0.99)
|
|
289
|
+
return float(area[cut & ~near].sum())
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# A concave edge sharper than this (the faces turn by more than 60 degrees)
|
|
293
|
+
# is a crease: a notch that concentrates stress and that a fillet should have
|
|
294
|
+
# rounded. Marching-cubes facets on an organic surface turn by a few degrees.
|
|
295
|
+
CREASE_ANGLE_DEG = 60.0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _creases(study: Study, mesh, angle_deg: float = CREASE_ANGLE_DEG) -> dict:
|
|
299
|
+
"""Sharp concave edges the optimizer made: where an organic member meets
|
|
300
|
+
preserved geometry without a fillet, or two members meet in a notch.
|
|
301
|
+
GRIP's wrist yoke passed every number with hard creases at its bosses,
|
|
302
|
+
found only by zooming in (QA, 10/28); this measures them. Edges lying on
|
|
303
|
+
keep-out surfaces (a blind bore's floor) or between two faces of the
|
|
304
|
+
user's own preserved geometry are the user's, and are left out.
|
|
305
|
+
Returns crease length (mm) and up to five crease locations."""
|
|
306
|
+
ang = mesh.face_adjacency_angles
|
|
307
|
+
sharp = (~mesh.face_adjacency_convex) & (ang > np.radians(angle_deg))
|
|
308
|
+
out = {"creaseLength": 0.0, "creaseAt": []}
|
|
309
|
+
if not sharp.any():
|
|
310
|
+
return out
|
|
311
|
+
e = mesh.face_adjacency_edges[sharp]
|
|
312
|
+
a, b = mesh.vertices[e[:, 0]], mesh.vertices[e[:, 1]]
|
|
313
|
+
mid = 0.5 * (a + b)
|
|
314
|
+
length = np.linalg.norm(b - a, axis=1)
|
|
315
|
+
users = np.zeros(len(mid), bool)
|
|
316
|
+
if study.keep_out:
|
|
317
|
+
users |= np.min([np.abs(r.shape.sdf(mid)) for r in study.keep_out], axis=0) < 0.3
|
|
318
|
+
if study.preserve:
|
|
319
|
+
fc = mesh.triangles_center[mesh.face_adjacency[sharp]] # (n, 2, 3)
|
|
320
|
+
on = np.min([np.abs(r.shape.sdf(fc.reshape(-1, 3))) for r in study.preserve], axis=0).reshape(-1, 2)
|
|
321
|
+
users |= (on < 0.05).all(axis=1)
|
|
322
|
+
mine = ~users
|
|
323
|
+
out["creaseLength"] = float(length[mine].sum())
|
|
324
|
+
pts = mid[mine]
|
|
325
|
+
w = length[mine]
|
|
326
|
+
while len(pts) and len(out["creaseAt"]) < 5:
|
|
327
|
+
# heaviest cluster first, 5 mm across
|
|
328
|
+
i = int(np.argmax([w[np.linalg.norm(pts - p, axis=1) < 5.0].sum() for p in pts]))
|
|
329
|
+
near = np.linalg.norm(pts - pts[i], axis=1) < 5.0
|
|
330
|
+
out["creaseAt"].append({"at": pts[near].mean(axis=0).round(2).tolist(), "length": float(w[near].sum())})
|
|
331
|
+
pts, w = pts[~near], w[~near]
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _closed_bores(study: Study, mesh) -> list:
|
|
336
|
+
"""Cylindrical keep-outs (bores) with an end the part closes over.
|
|
337
|
+
|
|
338
|
+
A fastener needs its whole path clear: the shank through the bore, and
|
|
339
|
+
the head, washer and driver out past its end. A bore that stops inside
|
|
340
|
+
material is a blind hole no screw can be driven through, the way the
|
|
341
|
+
wall bracket's screw holes came out capped (and GRIP's yoke had material
|
|
342
|
+
over its bolt seats). The fix is in the study: extend the keep-out over
|
|
343
|
+
the head and the driver's reach. Probes a short way past each end cap,
|
|
344
|
+
on the axis and on a ring at 0.3 of the radius; an end is closed when
|
|
345
|
+
most probes land inside the part. A keep-out marked "bore": false (a
|
|
346
|
+
clearance volume that may end against material) is not checked."""
|
|
347
|
+
from .shapes import Cylinder
|
|
348
|
+
|
|
349
|
+
out = []
|
|
350
|
+
for r in study.keep_out:
|
|
351
|
+
if not r.bore:
|
|
352
|
+
continue
|
|
353
|
+
sh = r.shape
|
|
354
|
+
if not isinstance(sh, Cylinder) or sh.r_inner > 0:
|
|
355
|
+
continue
|
|
356
|
+
a = sh.axis
|
|
357
|
+
u = np.cross(a, [1.0, 0.0, 0.0] if abs(a[0]) < 0.9 else [0.0, 1.0, 0.0])
|
|
358
|
+
u /= np.linalg.norm(u)
|
|
359
|
+
v = np.cross(a, u)
|
|
360
|
+
# on and near the axis only: a head-clearance bore ending on its
|
|
361
|
+
# seat is closed round the rim by design, with the screw's own
|
|
362
|
+
# narrower bore running on through the middle (the motor mount's
|
|
363
|
+
# access holes); a capped hole is closed across the middle too
|
|
364
|
+
ring = [np.zeros(3)] + [0.3 * sh.r * (np.cos(t) * u + np.sin(t) * v)
|
|
365
|
+
for t in np.linspace(0, 2 * np.pi, 6, endpoint=False)]
|
|
366
|
+
for end, cap, out_dir in (("p0", sh.p0, -a), ("p1", sh.p1, a)):
|
|
367
|
+
probes = np.array([cap + d * out_dir + o for d in (1.5, 3.0) for o in ring])
|
|
368
|
+
inside = mesh.contains(probes)
|
|
369
|
+
if inside.mean() > 0.5:
|
|
370
|
+
out.append({"id": r.id, "end": end, "at": cap.round(2).tolist()})
|
|
371
|
+
return out
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _union(shapes):
|
|
375
|
+
import manifold3d as m3d
|
|
376
|
+
|
|
377
|
+
from .sharp import exact_manifold
|
|
378
|
+
# the same exact solids reconstruct.py cut and added (circles 0.002 mm true)
|
|
379
|
+
mans = [exact_manifold(s) for s in shapes]
|
|
380
|
+
if not mans:
|
|
381
|
+
return None
|
|
382
|
+
return m3d.Manifold.batch_boolean(mans, m3d.OpType.Add) if len(mans) > 1 else mans[0]
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _hotspots(xyz, vm, body, peak, radius, count=3):
|
|
386
|
+
"""Up to `count` local stress maxima at least 2*radius apart, among nodes
|
|
387
|
+
within half of the peak."""
|
|
388
|
+
order = np.argsort(-np.where(body, vm, -1.0))
|
|
389
|
+
picked = []
|
|
390
|
+
for i in order:
|
|
391
|
+
if not body[i] or vm[i] < 0.5 * peak or len(picked) >= count:
|
|
392
|
+
break
|
|
393
|
+
if all(np.linalg.norm(xyz[i] - xyz[j]) >= 2 * radius for j in picked):
|
|
394
|
+
picked.append(i)
|
|
395
|
+
return [xyz[i] for i in picked]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# A wetted wall is a voxel staircase: the stress at its nodes alternates
|
|
399
|
+
# between the step's teeth (unloaded corners, low) and its re-entrant corners
|
|
400
|
+
# (high), and refining the grid does not settle it, the staircase being the
|
|
401
|
+
# same shape at every pitch. On a closed cylinder under pressure (r 20, wall
|
|
402
|
+
# 3, Lamé 7.10 MPa at the bore) the wetted nodes read 5.6 at the median and
|
|
403
|
+
# 8.6-9.2 at the peak from 2.5 to 5 cells through the wall; averaged over the
|
|
404
|
+
# wetted nodes within 1.5 cells of each, the peak reads 7.6-7.9, the real
|
|
405
|
+
# wall's to within the discretization allowance.
|
|
406
|
+
_WET_SMOOTH = 1.5
|
|
407
|
+
# cells through the thinnest pinned wall or skin (study.shells), and the most
|
|
408
|
+
# cells verification will spend getting there
|
|
409
|
+
_SHELL_CELLS = 4.0
|
|
410
|
+
_SHELL_MAX_CELLS = 400_000
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _smooth_wetted(xyz, vm, wet, pitch):
|
|
414
|
+
"""von Mises at wetted nodes averaged over the wetted nodes near each."""
|
|
415
|
+
from scipy.spatial import cKDTree
|
|
416
|
+
|
|
417
|
+
idx = np.nonzero(wet)[0]
|
|
418
|
+
if len(idx) == 0:
|
|
419
|
+
return vm
|
|
420
|
+
import scipy.sparse as sp
|
|
421
|
+
|
|
422
|
+
tree = cKDTree(xyz[idx])
|
|
423
|
+
pairs = tree.query_pairs(_WET_SMOOTH * pitch, output_type="ndarray")
|
|
424
|
+
n = len(idx)
|
|
425
|
+
rows = np.concatenate([pairs[:, 0], pairs[:, 1], np.arange(n)])
|
|
426
|
+
cols = np.concatenate([pairs[:, 1], pairs[:, 0], np.arange(n)])
|
|
427
|
+
m = sp.csr_matrix((np.ones(len(rows)), (rows, cols)), shape=(n, n))
|
|
428
|
+
out = vm.copy()
|
|
429
|
+
out[idx] = (m @ vm[idx]) / np.asarray(m.sum(axis=1)).ravel()
|
|
430
|
+
return out
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _rigid_free(xyz, u):
|
|
434
|
+
"""u less its best-fit rigid motion (least squares over the nodes): the
|
|
435
|
+
deformation of a part held only by a 3-2-1 support."""
|
|
436
|
+
from .fem import rigid_body_modes
|
|
437
|
+
|
|
438
|
+
b = rigid_body_modes(xyz)
|
|
439
|
+
coef = np.linalg.lstsq(b, u, rcond=None)[0]
|
|
440
|
+
return u - b @ coef
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _user_edges(study: Study, band: float):
|
|
444
|
+
"""Where a keep-out's surface meets a preserved body's (a screw-head
|
|
445
|
+
counterbore's floor corner on a boss face, a bore's rim): a sharp edge
|
|
446
|
+
the user drew, which the optimizer cannot round. Its linear-elastic stress
|
|
447
|
+
grows without bound as the grid gets finer, like a clamped edge's, so it
|
|
448
|
+
is left out of the peak over the same band. (The wall bracket's 150k-cell
|
|
449
|
+
confirm check peaked there and read SF 1.6, 2.6, 2.5, 1.9, 3.7 at 11.5-15%
|
|
450
|
+
of the space, out of order, against 2.7-3.8 on the 60k check.)
|
|
451
|
+
|
|
452
|
+
Being near both surfaces is not enough: a pressure vessel's wall, or a
|
|
453
|
+
ring whose bore is the keep-out's, lines the keep-out, and the two
|
|
454
|
+
surfaces coincide over the whole cavity (a thick sphere's bore, Lame's
|
|
455
|
+
peak, was left out entirely). An edge is where the surfaces cross
|
|
456
|
+
(their normals not opposed) or where the keep-out's own surface turns
|
|
457
|
+
within the band (a tunnel's rim on the face it ends on). Returns a
|
|
458
|
+
function of points -> bool, or None when the study has no such edges."""
|
|
459
|
+
keep = [r.shape for r in study.keep_out]
|
|
460
|
+
pres = [r.shape for r in study.preserve]
|
|
461
|
+
if not keep or not pres:
|
|
462
|
+
return None
|
|
463
|
+
h = 0.05 * band
|
|
464
|
+
axes = np.eye(3)
|
|
465
|
+
|
|
466
|
+
def sdf(shapes, xyz):
|
|
467
|
+
return np.min([sh.sdf(xyz) for sh in shapes], axis=0)
|
|
468
|
+
|
|
469
|
+
def normal(shapes, xyz):
|
|
470
|
+
g = np.stack([sdf(shapes, xyz + h * a) - sdf(shapes, xyz - h * a) for a in axes], axis=1)
|
|
471
|
+
return g / np.maximum(np.linalg.norm(g, axis=1, keepdims=True), 1e-12)
|
|
472
|
+
|
|
473
|
+
def near(xyz):
|
|
474
|
+
xyz = np.asarray(xyz, float)
|
|
475
|
+
out = (np.abs(sdf(keep, xyz)) < band) & (np.abs(sdf(pres, xyz)) < band)
|
|
476
|
+
if not out.any():
|
|
477
|
+
return out
|
|
478
|
+
x = xyz[out]
|
|
479
|
+
nk = normal(keep, x)
|
|
480
|
+
edge = np.einsum("ij,ij->i", nk, normal(pres, x)) > -0.9
|
|
481
|
+
for o in np.concatenate([axes, -axes]) * band:
|
|
482
|
+
edge |= np.einsum("ij,ij->i", nk, normal(keep, x + o)) < 0.9
|
|
483
|
+
out[out] = edge
|
|
484
|
+
return out
|
|
485
|
+
return near
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# trimesh's ray/contains queries share the mesh's rtree, which is not
|
|
489
|
+
# thread-safe (concurrent queries corrupt its node reads: "RTreeError:
|
|
490
|
+
# readNode"); the submodels' voxelizing takes turns, their solves overlap
|
|
491
|
+
_MESH_QUERY = threading.Lock()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _submodel_peak(study, mesh, hm, u, center, half, pitch, mat, contact_xyz, band, case=None, edges=None,
|
|
495
|
+
cut=False):
|
|
496
|
+
"""Re-solve a small box around a global hotspot at a finer pitch, driven by
|
|
497
|
+
the global displacement field on the box's cut faces (classic FE
|
|
498
|
+
submodeling). Voxel stress converges only with about 8 cells across a
|
|
499
|
+
feature's radius; the global grid rarely affords that, the box does.
|
|
500
|
+
Returns (peak MPa, position) or None."""
|
|
501
|
+
from scipy.spatial import cKDTree
|
|
502
|
+
|
|
503
|
+
lo, hi = center - half, center + half
|
|
504
|
+
cgrid = None
|
|
505
|
+
with _MESH_QUERY:
|
|
506
|
+
if cut:
|
|
507
|
+
from .cutcell import cut_from_mesh
|
|
508
|
+
|
|
509
|
+
cgrid = cut_from_mesh(mesh, pitch, study.elastic(mat), box=(lo, hi))
|
|
510
|
+
solid, origin = cgrid.solid_grid()
|
|
511
|
+
else:
|
|
512
|
+
solid, origin = voxelize(mesh, pitch, box=(lo, hi))
|
|
513
|
+
if solid.sum() < 64:
|
|
514
|
+
return None
|
|
515
|
+
sm = cgrid.mesh if cgrid is not None else HexMesh(np.argwhere(solid), pitch, origin)
|
|
516
|
+
blo = origin
|
|
517
|
+
bhi = origin + np.asarray(solid.shape) * pitch
|
|
518
|
+
eps = 0.25 * pitch
|
|
519
|
+
cut = np.any((sm.node_xyz < blo + eps) | (sm.node_xyz > bhi - eps), axis=1)
|
|
520
|
+
near_contact = np.zeros(sm.n_nodes, bool)
|
|
521
|
+
if len(contact_xyz):
|
|
522
|
+
near_contact = cKDTree(contact_xyz).query(sm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
|
|
523
|
+
driven = cut | near_contact
|
|
524
|
+
if driven.all() or not driven.any():
|
|
525
|
+
return None
|
|
526
|
+
# Global displacement at the driven nodes: inverse-distance weights over
|
|
527
|
+
# the nearest global nodes (the field is smooth at this scale).
|
|
528
|
+
gd, gi = cKDTree(hm.node_xyz).query(sm.node_xyz[driven], k=8)
|
|
529
|
+
w = 1.0 / np.maximum(gd, 1e-9) ** 2
|
|
530
|
+
w /= w.sum(axis=1, keepdims=True)
|
|
531
|
+
ug = u.reshape(-1, 3)[gi]
|
|
532
|
+
up = (w[..., None] * ug).sum(axis=1).ravel()
|
|
533
|
+
pd = np.repeat(driven, 3)
|
|
534
|
+
st = Stiffness(sm, study.elastic(mat), np.zeros(sm.n_dof, bool))
|
|
535
|
+
k = (cgrid.assemble(st, mat.E) if cgrid is not None else st.assemble(np.full(sm.n_elem, mat.E))).tocsr()
|
|
536
|
+
free = ~pd
|
|
537
|
+
kff = k[free][:, free]
|
|
538
|
+
rhs = -(k[free][:, pd] @ up)
|
|
539
|
+
if case is not None:
|
|
540
|
+
# a pressure inside the box pushes on its walls here too
|
|
541
|
+
from .pressure import pressure_force
|
|
542
|
+
|
|
543
|
+
fp = np.zeros(sm.n_dof)
|
|
544
|
+
for ld in case.loads:
|
|
545
|
+
if ld.pressure:
|
|
546
|
+
fp += pressure_force(sm, study.region_shape(ld.region)[0], ld.pressure)[0]
|
|
547
|
+
rhs = rhs + fp[free]
|
|
548
|
+
solver = Solver(sm, rtol=1e-7, maxiter=4000)
|
|
549
|
+
# Rigid-body modes are pinned by the driven nodes; AMG still wants a
|
|
550
|
+
# near-null space sized to the free unknowns, so use plain CG+AMG on kff.
|
|
551
|
+
from scipy.sparse.linalg import cg
|
|
552
|
+
|
|
553
|
+
from .fem import amg_solver
|
|
554
|
+
ml = amg_solver(kff, symmetry="hermitian", strength="symmetric", max_coarse=400, coarse_solver="splu")
|
|
555
|
+
uf, info = cg(kff, rhs, rtol=1e-7, maxiter=4000, M=ml.aspreconditioner(cycle="V"))
|
|
556
|
+
del solver
|
|
557
|
+
if info != 0:
|
|
558
|
+
return None
|
|
559
|
+
us = np.zeros(sm.n_dof)
|
|
560
|
+
us[pd] = up
|
|
561
|
+
us[free] = uf
|
|
562
|
+
vm = von_mises(cgrid.nodal_stress(us, mat.E)) if cgrid is not None else nodal_von_mises(st, us, mat.E)
|
|
563
|
+
if case is not None and fp.any():
|
|
564
|
+
# over the global grid's reach: a finer staircase is no smoother
|
|
565
|
+
vm = _smooth_wetted(sm.node_xyz, vm, np.abs(fp.reshape(-1, 3)).sum(axis=1) > 0, hm.pitch)
|
|
566
|
+
# Trust the inside of the box only: two global cells from the cut faces.
|
|
567
|
+
margin = 2.0 * hm.pitch
|
|
568
|
+
inner = np.all((sm.node_xyz > blo + margin) & (sm.node_xyz < bhi - margin), axis=1) & ~near_contact
|
|
569
|
+
if edges is not None:
|
|
570
|
+
inner &= ~edges(sm.node_xyz)
|
|
571
|
+
if not inner.any():
|
|
572
|
+
return None
|
|
573
|
+
i = int(np.argmax(np.where(inner, vm, -1.0)))
|
|
574
|
+
return float(vm[i]), sm.node_xyz[i].tolist()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _inside(mesh, pts, rng):
|
|
579
|
+
"""Point-in-solid by ray-crossing parity (odd = inside): a ray along +x,
|
|
580
|
+
tilted a little at random so edge and vertex grazes are measure-zero,
|
|
581
|
+
tested exactly (Moller-Trumbore) against the triangles whose y-z box it
|
|
582
|
+
can pass through. trimesh's own test (no embree) took 80 s of a 90 s
|
|
583
|
+
verify on the arm link."""
|
|
584
|
+
tri = np.asarray(mesh.triangles, float)
|
|
585
|
+
v0, e1, e2 = tri[:, 0], tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]
|
|
586
|
+
lo, hi = tri.min(axis=1), tri.max(axis=1)
|
|
587
|
+
span = float(np.ptp(tri[..., 0])) + 1.0
|
|
588
|
+
out = np.zeros(len(pts), bool)
|
|
589
|
+
for i, o in enumerate(np.asarray(pts, float)):
|
|
590
|
+
tilt = rng.uniform(-1e-3, 1e-3, 2)
|
|
591
|
+
d = np.array([1.0, *tilt]) / np.sqrt(1.0 + tilt @ tilt)
|
|
592
|
+
g = np.abs(tilt) * span + 1e-9
|
|
593
|
+
c = np.flatnonzero((hi[:, 0] >= o[0]) & (lo[:, 1] <= o[1] + g[0]) & (hi[:, 1] >= o[1] - g[0])
|
|
594
|
+
& (lo[:, 2] <= o[2] + g[1]) & (hi[:, 2] >= o[2] - g[1]))
|
|
595
|
+
if not len(c):
|
|
596
|
+
continue
|
|
597
|
+
p = np.cross(d, e2[c])
|
|
598
|
+
det = np.einsum("ij,ij->i", e1[c], p)
|
|
599
|
+
ok = np.abs(det) > 1e-12
|
|
600
|
+
inv = np.where(ok, 1.0 / np.where(ok, det, 1.0), 0.0)
|
|
601
|
+
tv = o - v0[c]
|
|
602
|
+
u = np.einsum("ij,ij->i", tv, p) * inv
|
|
603
|
+
q = np.cross(tv, e1[c])
|
|
604
|
+
v = (q @ d) * inv
|
|
605
|
+
t = np.einsum("ij,ij->i", q, e2[c]) * inv
|
|
606
|
+
out[i] = int((ok & (u >= 0) & (v >= 0) & (u + v <= 1) & (t > 1e-9)).sum()) % 2 == 1
|
|
607
|
+
return out
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _preserve_missing(pres_eff, part, mesh, samples: int = 400) -> float:
|
|
611
|
+
"""Volume of preserved geometry the part lacks (mm^3). The boolean finds
|
|
612
|
+
candidate pieces; each is then confirmed by sampling points inside it and
|
|
613
|
+
asking the mesh itself (by ray parity) whether they are solid. Where a
|
|
614
|
+
keep-out shares a preserved surface (a bore the size of a boss's inner
|
|
615
|
+
wall), manifold3d can report slivers of the preserved shape missing from
|
|
616
|
+
a part that, point by point, has them: the grip upper arm's elbow socket
|
|
617
|
+
read 300 mm^3 short with every sampled point inside."""
|
|
618
|
+
from .shapes import trimesh_from_manifold
|
|
619
|
+
|
|
620
|
+
rng = np.random.default_rng(0)
|
|
621
|
+
total = 0.0
|
|
622
|
+
for b in (pres_eff - part).decompose():
|
|
623
|
+
v = b.volume()
|
|
624
|
+
if v <= 1e-3:
|
|
625
|
+
continue
|
|
626
|
+
tb = trimesh_from_manifold(b)
|
|
627
|
+
lo, hi = tb.bounds
|
|
628
|
+
pts = np.empty((0, 3))
|
|
629
|
+
for n in (samples, 20 * samples): # rejection sampling in the piece's box
|
|
630
|
+
cand = lo + rng.random((n, 3)) * (hi - lo)
|
|
631
|
+
pts = cand[_inside(tb, cand, rng)][:samples]
|
|
632
|
+
if len(pts) >= 10:
|
|
633
|
+
break
|
|
634
|
+
if len(pts) < 10:
|
|
635
|
+
# too thin a sliver for sampling to land in: missing only if it
|
|
636
|
+
# is thicker than a print resolves (thickness ~ 2 V / A)
|
|
637
|
+
total += v if 2.0 * v / max(b.surface_area(), 1e-9) > 0.1 else 0.0
|
|
638
|
+
continue
|
|
639
|
+
total += v * float((~_inside(mesh, pts, rng)).mean())
|
|
640
|
+
return total
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def use_cut_cells(study, cut_cells=None) -> bool:
|
|
644
|
+
"""Whether verification analyzes cut cells: the argument, else
|
|
645
|
+
LOADPATH_CUT_CELLS (1/0), else the study's resolution.cutCells, else
|
|
646
|
+
yes unless the study carries a pressure."""
|
|
647
|
+
import os
|
|
648
|
+
|
|
649
|
+
if cut_cells is not None:
|
|
650
|
+
return bool(cut_cells)
|
|
651
|
+
env = os.environ.get("LOADPATH_CUT_CELLS", "").strip().lower()
|
|
652
|
+
if env in ("1", "true", "yes", "on"):
|
|
653
|
+
return True
|
|
654
|
+
if env in ("0", "false", "no", "off"):
|
|
655
|
+
return False
|
|
656
|
+
asked = (getattr(study, "raw", None) or {}).get("resolution", {}).get("cutCells")
|
|
657
|
+
if asked is not None:
|
|
658
|
+
return bool(asked)
|
|
659
|
+
# Default on, but not under pressure: a pressure's wetted nodes come
|
|
660
|
+
# from the whole-cell staircase, and on a cut grid they sit off the
|
|
661
|
+
# true wall (the thick sphere read Lame's 21 MPa as 105).
|
|
662
|
+
return not any(ld.pressure for case in getattr(study, "load_cases", ()) for ld in case.loads)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _solve_cut(cg, nu, fixed, springs, e_mat, moduli, rhs):
|
|
666
|
+
"""Solve the cut-cell model (cutcell.CutGrid) for each right-hand side:
|
|
667
|
+
the shared-matrix cells as usual, the cut cells' own matrices added
|
|
668
|
+
(assembled), or as the multigrid's extra operator, which its Galerkin
|
|
669
|
+
coarsening carries down exactly. Returns (stiffness object with
|
|
670
|
+
.cutgrid, displacements)."""
|
|
671
|
+
from .simp import _use_mg
|
|
672
|
+
|
|
673
|
+
hm = cg.mesh
|
|
674
|
+
if _use_mg(hm.n_elem):
|
|
675
|
+
from .mg import GridElasticity
|
|
676
|
+
|
|
677
|
+
extra = cg.cut_operator(moduli)
|
|
678
|
+
if springs is not None:
|
|
679
|
+
extra = (extra + e_mat * springs).tocsr()
|
|
680
|
+
stiff = GridElasticity(hm, nu, fixed, springs=extra, rtol=1e-6, maxiter=3000)
|
|
681
|
+
stiff.set_moduli(cg.full_moduli(moduli))
|
|
682
|
+
else:
|
|
683
|
+
stiff = Stiffness(hm, nu, fixed)
|
|
684
|
+
k = cg.assemble(stiff, moduli)
|
|
685
|
+
if springs is not None:
|
|
686
|
+
k = (k + e_mat * springs).tocsr()
|
|
687
|
+
solver = Solver(hm, rtol=1e-6, maxiter=3000, rebuild_after_iters=10_000)
|
|
688
|
+
stiff.cutgrid = cg
|
|
689
|
+
return stiff, solver.solve_many(k, rhs)
|
|
690
|
+
stiff.cutgrid = cg
|
|
691
|
+
return stiff, stiff.solve_many(rhs)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# cells through a member below which it gets a submodel (cut cells)
|
|
695
|
+
_THIN_CELLS = 3.0
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _thin_spots(hm, solid, origin, pitch, vm, body, peak, radius, taken, count=3):
|
|
699
|
+
"""Hotspots on members under _THIN_CELLS cells thick reading at least
|
|
700
|
+
0.8 of the peak, at least 2 * radius from each other and from `taken`."""
|
|
701
|
+
edt = ndimage.distance_transform_edt(np.pad(solid, 1))[1:-1, 1:-1, 1:-1]
|
|
702
|
+
# a cell's member thickness: twice the largest distance to void nearby
|
|
703
|
+
thick = 2.0 * ndimage.maximum_filter(edt, size=5) - 0.5
|
|
704
|
+
thin_el = thick[tuple(hm.elem_ijk.T)] < _THIN_CELLS
|
|
705
|
+
thin = np.zeros(hm.n_nodes, bool)
|
|
706
|
+
thin[hm.enode[thin_el].ravel()] = True
|
|
707
|
+
cand = body & thin & (vm >= 0.8 * peak)
|
|
708
|
+
out = []
|
|
709
|
+
for i in np.argsort(-np.where(cand, vm, -1.0)):
|
|
710
|
+
if not cand[i] or len(out) >= count:
|
|
711
|
+
break
|
|
712
|
+
x = hm.node_xyz[i]
|
|
713
|
+
if all(np.linalg.norm(x - y) >= 2 * radius for y in list(taken) + out):
|
|
714
|
+
out.append(x)
|
|
715
|
+
return out
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# Buckling is read on a grid of at most this many cells. The eigensolver
|
|
719
|
+
# factors the stiffness once and solves with it every iteration, so the cost
|
|
720
|
+
# grows fast with size: the 23 g wall bracket took 11 s at 5k cells, 17 s at
|
|
721
|
+
# 21k and 144 s at 71k (5.7 GB). The coarse grid reads low, the safe side:
|
|
722
|
+
# 1.44 / 1.54 / 1.37 at 5k / 11k / 21k cells against 1.81-1.83 at 42-71k.
|
|
723
|
+
# The search verifies every outcome, so this is per outcome: at 20k cells
|
|
724
|
+
# the arm link and turret spent 37-52 s each.
|
|
725
|
+
BUCKLING_CELLS = 12_000
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _buckling(study: Study, mesh, vol: float, pitch: float, contact_band: float | None,
|
|
729
|
+
directions: list) -> dict | None:
|
|
730
|
+
"""The part's linear buckling load factor per load case: the multiple of
|
|
731
|
+
the case's loads at which a perfect version of it buckles (nonlinear.
|
|
732
|
+
linear_buckling, incompatible-mode hex8: a column one cell thick reads
|
|
733
|
+
Euler's load within 1%). Real parts buckle below it (imperfections), and
|
|
734
|
+
the goal's safety factor is the margin asked of it. Gravity counts; heat
|
|
735
|
+
does not; the material is read isotropic (a layered process's softer
|
|
736
|
+
across-layer stiffness is not in it). `directions` are the cases' worst force directions from the
|
|
737
|
+
stress check (None for a fixed force). None when nothing holds the part."""
|
|
738
|
+
from .directions import expand
|
|
739
|
+
from .nonlinear import linear_buckling, solid_submesh
|
|
740
|
+
|
|
741
|
+
t0 = time.time()
|
|
742
|
+
mat = study.material
|
|
743
|
+
pb = max(pitch, (vol / BUCKLING_CELLS) ** (1.0 / 3.0))
|
|
744
|
+
solid, origin = voxelize(mesh, pb)
|
|
745
|
+
hm = HexMesh(np.argwhere(solid), pb, origin)
|
|
746
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * (contact_band or 0.0) + 0.5 * pb)
|
|
747
|
+
if not bc.fixed.any() and bc.springs is None:
|
|
748
|
+
return None
|
|
749
|
+
fg = gravity_force(hm, np.ones(hm.n_elem), bc.gravity, bc.fixed) if bc.gravity is not None else None
|
|
750
|
+
spreads = bc.spreads or [None] * len(study.load_cases)
|
|
751
|
+
rhs, layout = expand(bc.loads, spreads, fg)
|
|
752
|
+
forces = []
|
|
753
|
+
for ci, case in enumerate(study.load_cases):
|
|
754
|
+
idx, d = layout[ci], directions[ci]
|
|
755
|
+
if len(idx) == 1:
|
|
756
|
+
f = rhs[idx[0]]
|
|
757
|
+
else:
|
|
758
|
+
f = sum(d[j] * rhs[i] for j, i in enumerate(idx[1:]))
|
|
759
|
+
if idx[0] is not None:
|
|
760
|
+
f = f + rhs[idx[0]]
|
|
761
|
+
forces.append(f)
|
|
762
|
+
springs = mat.E * bc.springs if bc.springs is not None else None
|
|
763
|
+
# pieces no support holds would float: left out (a pin's springs hold)
|
|
764
|
+
hold = bc.fixed.copy()
|
|
765
|
+
if springs is not None:
|
|
766
|
+
hold |= np.asarray(abs(springs).sum(axis=1)).ravel() > 0
|
|
767
|
+
sub, el, _, vecs = solid_submesh(hm, np.ones(hm.n_elem, bool), hold, [bc.fixed.astype(float)] + forces)
|
|
768
|
+
fixed, forces = vecs[0] > 0.5, vecs[1:]
|
|
769
|
+
loaded = [i for i, f in enumerate(forces) if np.any(f)]
|
|
770
|
+
cases = [{"name": c.name, "factor": None} for c in study.load_cases]
|
|
771
|
+
if sub.n_elem and loaded:
|
|
772
|
+
ks = None
|
|
773
|
+
if springs is not None:
|
|
774
|
+
dofs = (3 * hm.node_id(sub.node_ijk)[:, None] + np.arange(3)).ravel()
|
|
775
|
+
ks = springs[dofs][:, dofs].tocsr()
|
|
776
|
+
try:
|
|
777
|
+
# one factorization for every case; three to four digits of the
|
|
778
|
+
# factor are plenty (tol)
|
|
779
|
+
res = linear_buckling(sub, mat.nu, mat.E, fixed, [forces[i] for i in loaded], n_modes=1,
|
|
780
|
+
springs=ks, direct_max_dof=10 ** 6, tol=1e-5)
|
|
781
|
+
except (RuntimeError, ValueError, ArithmeticError) as exc:
|
|
782
|
+
for i in loaded:
|
|
783
|
+
cases[i]["error"] = str(exc)
|
|
784
|
+
res = []
|
|
785
|
+
for i, r in zip(loaded, res):
|
|
786
|
+
lam = float(r.factors[0])
|
|
787
|
+
if np.isfinite(lam):
|
|
788
|
+
mode = r.modes[0].reshape(-1, 3)
|
|
789
|
+
cases[i].update(factor=lam, modeAt=sub.node_xyz[int(np.argmax(np.linalg.norm(mode, axis=1)))].tolist())
|
|
790
|
+
got = [c["factor"] for c in cases if c["factor"] is not None]
|
|
791
|
+
return {"factor": min(got) if got else None, "cases": cases, "pitch": pb,
|
|
792
|
+
"cells": int(hm.n_elem), "seconds": time.time() - t0}
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def verify_part(study: Study, mesh, max_cells: int = 60_000, min_pitch: float | None = None,
|
|
796
|
+
contact_band: float | None = None, refine: int = 3, fields: bool = False,
|
|
797
|
+
fractional: bool = False, cut_cells: bool | None = None) -> dict:
|
|
798
|
+
"""Analyze a finished part (trimesh) against the study. Returns a JSON-able
|
|
799
|
+
report. `contact_band` (mm) is how far from applied loads and supports
|
|
800
|
+
stress is left out of the peak; it defaults to 1.5 analysis cells, and the
|
|
801
|
+
study runner sets it to the optimization cell size so the excluded band
|
|
802
|
+
does not shrink toward zero as the analysis grid gets finer (a clamped
|
|
803
|
+
edge's stress grows without bound under refinement). `refine` is how many
|
|
804
|
+
hotspots per load case get a 3x finer submodel (see _submodel_peak).
|
|
805
|
+
`fields` adds report["fields"]: the analysis nodes and, per load case,
|
|
806
|
+
nodal von Mises stress and displacement (numpy arrays, not JSON) for
|
|
807
|
+
stress maps (render.render_stress); with a thermal block, also the
|
|
808
|
+
temperature per node at the hot moment. `fractional` analyzes the part's
|
|
809
|
+
surface cells at their filled share of the modulus (the stress formed at
|
|
810
|
+
the full modulus), so the readings move smoothly with the geometry
|
|
811
|
+
rather than in whole-cell steps: for searches and live previews, whose
|
|
812
|
+
neighbouring candidates must compare fairly; the delivered check is
|
|
813
|
+
whole cells. `cut_cells` (default: use_cut_cells, which is on unless
|
|
814
|
+
the study says resolution.cutCells false, LOADPATH_CUT_CELLS=0, or
|
|
815
|
+
it has a pressure load) analyzes the true boundary inside each cell
|
|
816
|
+
(cutcell.py): sub-cell stiffness, stress read on the surface, and
|
|
817
|
+
finer submodels on members under 3 cells thick; member thickness no
|
|
818
|
+
longer rounds to whole cells (GRIP's wrist link struts: whole cells
|
|
819
|
+
0.35-3.5x of fine tets, cut cells 0.74-1.13x; docs/BENCHMARKS.md)."""
|
|
820
|
+
from .shapes import manifold_from_trimesh
|
|
821
|
+
|
|
822
|
+
t0 = time.time()
|
|
823
|
+
mat = study.material
|
|
824
|
+
part = manifold_from_trimesh(mesh)
|
|
825
|
+
vol = float(part.volume())
|
|
826
|
+
report = {"volume": vol, "mass": vol * mat.density / 1000.0, "material": mat.to_dict()}
|
|
827
|
+
|
|
828
|
+
design = _union(study.design_space + [r.shape for r in study.preserve])
|
|
829
|
+
keep = _union([r.shape for r in study.keep_out])
|
|
830
|
+
pres = _union([r.shape for r in study.preserve])
|
|
831
|
+
envelope = design - keep if keep is not None else design
|
|
832
|
+
env_vol = float(envelope.volume())
|
|
833
|
+
report["designSpaceMass"] = env_vol * mat.density / 1000.0
|
|
834
|
+
report["massReduction"] = 1.0 - vol / env_vol if env_vol > 0 else None
|
|
835
|
+
report["keepOutIntrusion"] = float((part ^ keep).volume()) if keep is not None else 0.0
|
|
836
|
+
if pres is not None:
|
|
837
|
+
# Preserve minus keep-out is what must survive: a boss keeps its bore.
|
|
838
|
+
pres_eff = pres - keep if keep is not None else pres
|
|
839
|
+
report["preserveMissing"] = _preserve_missing(pres_eff, part, mesh)
|
|
840
|
+
else:
|
|
841
|
+
report["preserveMissing"] = 0.0
|
|
842
|
+
report["outsideDesignSpace"] = float((part - design).volume())
|
|
843
|
+
report["boundaryCutArea"] = _boundary_cut_area(study, mesh)
|
|
844
|
+
report.update(_creases(study, mesh))
|
|
845
|
+
report["closedBores"] = _closed_bores(study, mesh)
|
|
846
|
+
# decompose() lists each enclosed cavity too, as a negative volume
|
|
847
|
+
pieces = [b.volume() for b in part.decompose()]
|
|
848
|
+
report["bodies"] = sum(1 for v in pieces if v > 0)
|
|
849
|
+
report["enclosedCavityVolume"] = float(-sum(v for v in pieces if v < 0))
|
|
850
|
+
|
|
851
|
+
if study.print_direction is not None:
|
|
852
|
+
report.update(_overhang(mesh, study.print_direction, study))
|
|
853
|
+
proc = study.process
|
|
854
|
+
build = study.print_direction if study.print_direction is not None else \
|
|
855
|
+
(proc.build if proc is not None and proc.build != "auto" else None)
|
|
856
|
+
if build is not None:
|
|
857
|
+
# sliced as a printer would: bridges, islands, support volume, bed
|
|
858
|
+
from .printcheck import check_print
|
|
859
|
+
|
|
860
|
+
report["print"] = check_print(mesh, build, process=proc.kind if proc is not None else "fdm",
|
|
861
|
+
material=mat, bridge_span=getattr(proc, "bridge_span", None))
|
|
862
|
+
|
|
863
|
+
pitch = (vol / max_cells) ** (1.0 / 3.0)
|
|
864
|
+
if min_pitch:
|
|
865
|
+
pitch = max(pitch, min_pitch)
|
|
866
|
+
if study.shells:
|
|
867
|
+
# A wall voxelizes to a whole number of cells, so at two or three
|
|
868
|
+
# cells through it reads up to a cell thick or thin: bending stress
|
|
869
|
+
# goes as 1/t^2, and a plenum's 2 mm wall read 2.4 mm and 30% under
|
|
870
|
+
# its stress at 0.8 mm cells. Four cells through the thinnest wall or
|
|
871
|
+
# skin, within a cell budget.
|
|
872
|
+
thin = min(t for _, t in study.shells)
|
|
873
|
+
pitch = min(pitch, max(thin / _SHELL_CELLS, (vol / _SHELL_MAX_CELLS) ** (1.0 / 3.0)))
|
|
874
|
+
share = None
|
|
875
|
+
cut = use_cut_cells(study, cut_cells) and not fractional
|
|
876
|
+
cg = None
|
|
877
|
+
if cut:
|
|
878
|
+
from .cutcell import cut_from_mesh
|
|
879
|
+
|
|
880
|
+
cg = cut_from_mesh(mesh, pitch, study.elastic(mat))
|
|
881
|
+
solid, origin = cg.solid_grid()
|
|
882
|
+
share = np.zeros(solid.shape)
|
|
883
|
+
share[tuple(cg.mesh.elem_ijk.T)] = cg.fill
|
|
884
|
+
report["cutCells"] = int(cg.cut.sum())
|
|
885
|
+
elif fractional:
|
|
886
|
+
share, origin = voxelize(mesh, pitch, fractional=True)
|
|
887
|
+
# a sliver under a twentieth of a cell adds nothing but conditioning
|
|
888
|
+
solid = share >= 0.05
|
|
889
|
+
else:
|
|
890
|
+
solid, origin = voxelize(mesh, pitch)
|
|
891
|
+
report["analysisPitch"] = pitch
|
|
892
|
+
report["minMember"], report["p5Member"] = _thickness(solid if share is None else share >= 0.5, pitch)
|
|
893
|
+
|
|
894
|
+
# Face connectivity: cells that meet only at an edge or a corner share
|
|
895
|
+
# two nodes or one, a hinge the stiffness matrix cannot hold, and CG
|
|
896
|
+
# never converges (GRIP's upper arm: 44 such islands of 4-6 cells at a
|
|
897
|
+
# 1.04 mm pitch stalled verification for over an hour).
|
|
898
|
+
lbl, nlab = ndimage.label(solid)
|
|
899
|
+
elem_ijk = np.argwhere(solid)
|
|
900
|
+
hm = HexMesh(elem_ijk, pitch, origin)
|
|
901
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * (contact_band or 0.0) + 0.5 * pitch)
|
|
902
|
+
# Material not connected to a support would float; it is reported as a
|
|
903
|
+
# separate body above and left out of the stress analysis.
|
|
904
|
+
supported_lbls = np.unique(lbl[tuple(elem_ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
|
|
905
|
+
keep_el = np.isin(lbl[tuple(elem_ijk.T)], supported_lbls)
|
|
906
|
+
if not keep_el.all():
|
|
907
|
+
hm = HexMesh(elem_ijk[keep_el], pitch, origin)
|
|
908
|
+
bc = boundary_conditions(study, hm, check_loads=False, tol_floor=0.5 * (contact_band or 0.0) + 0.5 * pitch)
|
|
909
|
+
if cg is not None:
|
|
910
|
+
cg = cg.restrict(keep_el)
|
|
911
|
+
hm = cg.mesh
|
|
912
|
+
support_nodes, fixed = bc.support_nodes, bc.fixed
|
|
913
|
+
kept_ijk = elem_ijk if keep_el.all() else elem_ijk[keep_el]
|
|
914
|
+
fill = None if share is None else share[tuple(kept_ijk.T)]
|
|
915
|
+
|
|
916
|
+
band = max(1.5 * pitch, contact_band or 0.0)
|
|
917
|
+
report["contactBand"] = band
|
|
918
|
+
edges = _user_edges(study, band)
|
|
919
|
+
on_edge = edges(hm.node_xyz) if edges is not None else None
|
|
920
|
+
fg = gravity_force(hm, np.ones(hm.n_elem) if fill is None else fill, bc.gravity, fixed) if bc.gravity is not None else None
|
|
921
|
+
# Heat (the study's thermal block): the part's own temperature, solved on
|
|
922
|
+
# the part as delivered; each cell's modulus derated by it, the
|
|
923
|
+
# expansion its neighbours and the supports stop as a load in every
|
|
924
|
+
# case, and the stress formed from the strain less the free expansion.
|
|
925
|
+
# Strength falls with the modulus (the same retention curve). The
|
|
926
|
+
# submodels re-solve without heat, so they are skipped.
|
|
927
|
+
moduli, eps, g_node = mat.E, None, None
|
|
928
|
+
if study.thermal is not None:
|
|
929
|
+
from .thermal import StudyHeat, retention
|
|
930
|
+
|
|
931
|
+
heat = StudyHeat(study, hm, study.elastic(mat), fixed, modulus=mat.E)
|
|
932
|
+
heat.temperature()
|
|
933
|
+
moduli = mat.E * heat.g
|
|
934
|
+
if heat.expands:
|
|
935
|
+
eps = heat.eps
|
|
936
|
+
fth = heat.load(np.ones(hm.n_elem), mat.E)
|
|
937
|
+
fg = fth if fg is None else fg + fth
|
|
938
|
+
g_node = retention(heat.curve, heat.t_node)[0] if heat.curve is not None else np.ones(hm.n_nodes)
|
|
939
|
+
refine = 0
|
|
940
|
+
report["thermal"] = {"maxTemperature": float(heat.t_node.max()),
|
|
941
|
+
"minRetention": float(heat.g.min()),
|
|
942
|
+
"expansion": heat.alpha if heat.expands else 0.0,
|
|
943
|
+
"stressFree": heat.t_ref,
|
|
944
|
+
"heatIn": float(heat.bc.power)}
|
|
945
|
+
cyc = heat.cycle_result
|
|
946
|
+
if cyc is not None:
|
|
947
|
+
report["thermal"].update(
|
|
948
|
+
maxTemperature=float(cyc.node_max.max()), minTemperature=float(cyc.node_min.min()),
|
|
949
|
+
swing=float((cyc.node_max - cyc.node_min).max()), hotAt=cyc.t_hot, coldAt=cyc.t_cold,
|
|
950
|
+
meanHot=float(cyc.mean.max()), meanCold=float(cyc.mean.min()), periods=cyc.periods,
|
|
951
|
+
converged=cyc.converged, period=heat.cycle.period, on=heat.cycle.on)
|
|
952
|
+
# A force of unknown direction is solved along each axis and judged in
|
|
953
|
+
# the direction that stresses the part most (directions.py).
|
|
954
|
+
from .directions import expand, worst_stress_direction
|
|
955
|
+
spreads = bc.spreads or [None] * len(study.load_cases)
|
|
956
|
+
rhs, layout = expand(bc.loads, spreads, fg)
|
|
957
|
+
from .simp import _use_mg
|
|
958
|
+
|
|
959
|
+
def solve(moduli, rhs):
|
|
960
|
+
if cg is not None:
|
|
961
|
+
return _solve_cut(cg, study.elastic(mat), fixed, bc.springs, mat.E, moduli, rhs)
|
|
962
|
+
if fill is not None:
|
|
963
|
+
# a part-filled cell is that share as stiff; its stress stays
|
|
964
|
+
# the material's (nodal_stress at the full modulus)
|
|
965
|
+
moduli = np.broadcast_to(moduli, (hm.n_elem,)) * fill
|
|
966
|
+
if _use_mg(hm.n_elem):
|
|
967
|
+
# From simp.MG_MIN_CELLS up, matrix-free multigrid (as the optimizer
|
|
968
|
+
# uses): no assembled matrix, a fraction of the memory and time. It
|
|
969
|
+
# raises RuntimeError on non-convergence like Solver does. It also
|
|
970
|
+
# carries .mesh and .nu, which is all the stress recovery reads.
|
|
971
|
+
from .mg import GridElasticity
|
|
972
|
+
|
|
973
|
+
stiff = GridElasticity(hm, study.elastic(mat), fixed,
|
|
974
|
+
springs=mat.E * bc.springs if bc.springs is not None else None,
|
|
975
|
+
rtol=1e-6, maxiter=3000)
|
|
976
|
+
stiff.set_moduli(np.broadcast_to(moduli, (hm.n_elem,)).astype(float))
|
|
977
|
+
return stiff, stiff.solve_many(rhs)
|
|
978
|
+
stiff = Stiffness(hm, study.elastic(mat), fixed)
|
|
979
|
+
k = stiff.assemble(np.broadcast_to(moduli, (hm.n_elem,)).astype(float))
|
|
980
|
+
if bc.springs is not None:
|
|
981
|
+
k = (k + mat.E * bc.springs).tocsr()
|
|
982
|
+
# 1e-6 is three-plus significant figures of stress; 1e-7 cost a third
|
|
983
|
+
# more iterations. All cases share one multigrid hierarchy and run on
|
|
984
|
+
# threads.
|
|
985
|
+
solver = Solver(hm, rtol=1e-6, maxiter=3000, rebuild_after_iters=10_000)
|
|
986
|
+
return stiff, solver.solve_many(k, rhs)
|
|
987
|
+
|
|
988
|
+
stiff, displacements = solve(moduli, rhs)
|
|
989
|
+
cold = None
|
|
990
|
+
if study.thermal is not None and heat.cold is not None:
|
|
991
|
+
# The cycle's coldest moment, solved the same way: a part held at a
|
|
992
|
+
# hot stress-free temperature is stressed cold, and its swing is what
|
|
993
|
+
# thermal cycling fatigues (each case keeps its hot worst direction).
|
|
994
|
+
t_hot = heat.t_node
|
|
995
|
+
heat.use(heat.cold)
|
|
996
|
+
mod_c = mat.E * heat.g
|
|
997
|
+
eps_c = heat.eps if heat.expands else None
|
|
998
|
+
g_c = retention(heat.curve, heat.cold)[0] if heat.curve is not None else np.ones(hm.n_nodes)
|
|
999
|
+
fg_c = gravity_force(hm, np.ones(hm.n_elem) if fill is None else fill, bc.gravity, fixed) if bc.gravity is not None else None
|
|
1000
|
+
if heat.expands:
|
|
1001
|
+
fth_c = heat.load(np.ones(hm.n_elem), mat.E)
|
|
1002
|
+
fg_c = fth_c if fg_c is None else fg_c + fth_c
|
|
1003
|
+
rhs_c = expand(bc.loads, spreads, fg_c)[0]
|
|
1004
|
+
stiff_c, disp_c = solve(mod_c, rhs_c)
|
|
1005
|
+
cold = (stiff_c, disp_c, mod_c, eps_c, g_c)
|
|
1006
|
+
from scipy.spatial import cKDTree
|
|
1007
|
+
cases = []
|
|
1008
|
+
worst_sf = np.inf
|
|
1009
|
+
worst_dirs = []
|
|
1010
|
+
env = None # von Mises per node, the worst over the cases
|
|
1011
|
+
for ci, case in enumerate(study.load_cases):
|
|
1012
|
+
load_nodes = bc.case_load_nodes[ci]
|
|
1013
|
+
# Exclude nodes within the contact band of an applied load or support.
|
|
1014
|
+
contact = support_nodes | load_nodes
|
|
1015
|
+
near = cKDTree(hm.node_xyz[contact]).query(hm.node_xyz, k=1, distance_upper_bound=band)[0] < np.inf
|
|
1016
|
+
if on_edge is not None:
|
|
1017
|
+
near |= on_edge
|
|
1018
|
+
body = ~near
|
|
1019
|
+
idx = layout[ci]
|
|
1020
|
+
direction = None
|
|
1021
|
+
if len(idx) == 1:
|
|
1022
|
+
f, u = rhs[idx[0]], displacements[idx[0]]
|
|
1023
|
+
else:
|
|
1024
|
+
sp = spreads[ci]
|
|
1025
|
+
# the fixed part carries the expansion, the uncertain force none;
|
|
1026
|
+
# stress over retention, so the worst direction is the one
|
|
1027
|
+
# nearest failure where hot material is weak
|
|
1028
|
+
s0 = nodal_stress(stiff, displacements[idx[0]], moduli, eps) if idx[0] is not None else None
|
|
1029
|
+
sb = [nodal_stress(stiff, displacements[i], moduli) for i in idx[1:]]
|
|
1030
|
+
if g_node is not None:
|
|
1031
|
+
kn = 1.0 / np.maximum(g_node, 1e-3)[:, None]
|
|
1032
|
+
s0 = s0 * kn if s0 is not None else None
|
|
1033
|
+
sb = [b * kn for b in sb]
|
|
1034
|
+
direction, _ = worst_stress_direction(sp, s0, sb, body)
|
|
1035
|
+
f = sum(direction[j] * rhs[i] for j, i in enumerate(idx[1:]))
|
|
1036
|
+
u = sum(direction[j] * displacements[i] for j, i in enumerate(idx[1:]))
|
|
1037
|
+
if idx[0] is not None:
|
|
1038
|
+
f, u = f + rhs[idx[0]], u + displacements[idx[0]]
|
|
1039
|
+
worst_dirs.append(direction)
|
|
1040
|
+
vm = nodal_von_mises(stiff, u, moduli, eps)
|
|
1041
|
+
env = vm if env is None else np.maximum(env, vm)
|
|
1042
|
+
if bc.case_pressure_nodes[ci].any():
|
|
1043
|
+
vm = _smooth_wetted(hm.node_xyz, vm, bc.case_pressure_nodes[ci], pitch)
|
|
1044
|
+
# hot material is weaker: the peak is where stress / retention is
|
|
1045
|
+
util = vm if g_node is None else vm / np.maximum(g_node, 1e-3)
|
|
1046
|
+
pk = int(np.argmax(np.where(body, util, -1))) if body.any() else int(np.argmax(util))
|
|
1047
|
+
peak = float(vm[pk])
|
|
1048
|
+
at = hm.node_xyz[pk].tolist()
|
|
1049
|
+
keep = 1.0 if g_node is None else float(g_node[pk])
|
|
1050
|
+
global_peak = peak
|
|
1051
|
+
refined = []
|
|
1052
|
+
if refine and body.any():
|
|
1053
|
+
half = max(8.0 * pitch, 6.0)
|
|
1054
|
+
spots = list(_hotspots(hm.node_xyz, vm, body, peak, half, count=refine))
|
|
1055
|
+
if cg is not None:
|
|
1056
|
+
# members under 3 cells thick read up to ~15% low even cut
|
|
1057
|
+
# (two cells across a diagonal strip are too few to bend
|
|
1058
|
+
# in): the highest of them get a submodel too
|
|
1059
|
+
spots += _thin_spots(hm, solid, origin, pitch, vm, body, peak, half, spots, count=refine)
|
|
1060
|
+
|
|
1061
|
+
def sub(c, u=u, case=case, half=half, contact=contact):
|
|
1062
|
+
return _submodel_peak(study, mesh, hm, u, c, half, pitch / 2.5, mat,
|
|
1063
|
+
hm.node_xyz[contact], band, case, edges=edges, cut=cg is not None)
|
|
1064
|
+
if len(spots) > 1:
|
|
1065
|
+
# independent boxes, on threads (the solves release the GIL;
|
|
1066
|
+
# amg_solver sets up one at a time, so the same numbers)
|
|
1067
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
1068
|
+
|
|
1069
|
+
with ThreadPoolExecutor(min(len(spots), 3)) as ex:
|
|
1070
|
+
subs = list(ex.map(sub, spots))
|
|
1071
|
+
else:
|
|
1072
|
+
subs = [sub(c) for c in spots]
|
|
1073
|
+
for r in subs:
|
|
1074
|
+
if r is None:
|
|
1075
|
+
continue
|
|
1076
|
+
refined.append({"at": r[1], "peakStress": r[0]})
|
|
1077
|
+
if r[0] > peak:
|
|
1078
|
+
peak, at = r
|
|
1079
|
+
disp = u.reshape(-1, 3)
|
|
1080
|
+
if fields:
|
|
1081
|
+
if "fields" not in report and study.thermal is not None:
|
|
1082
|
+
# C per node at the hot moment (the cycle's hottest, or the steady state)
|
|
1083
|
+
report["fields"] = {"nodes": hm.node_xyz, "cases": [],
|
|
1084
|
+
"temperature": t_hot if cold is not None else heat.t_node}
|
|
1085
|
+
report.setdefault("fields", {"nodes": hm.node_xyz, "cases": []})["cases"].append(
|
|
1086
|
+
{"name": case.name, "vonMises": vm, "displacement": disp, "wetted": bc.case_pressure_nodes[ci]})
|
|
1087
|
+
wet = bc.case_pressure_nodes[ci]
|
|
1088
|
+
if load_nodes.any():
|
|
1089
|
+
defl = float(np.linalg.norm(disp[load_nodes].mean(axis=0)))
|
|
1090
|
+
elif wet.any():
|
|
1091
|
+
# a pressure's deflection is how far its walls bulge: the most any
|
|
1092
|
+
# wetted node moves (relative to the part, when nothing holds it)
|
|
1093
|
+
dw = _rigid_free(hm.node_xyz, u).reshape(-1, 3) if study.self_equilibrated else disp
|
|
1094
|
+
defl = float(np.linalg.norm(dw[wet], axis=1).max())
|
|
1095
|
+
else:
|
|
1096
|
+
defl = 0.0
|
|
1097
|
+
if bc.pressure_imbalance[ci] > 0.02 and study.self_equilibrated:
|
|
1098
|
+
report.setdefault("warnings", []).append(
|
|
1099
|
+
f"{case.name}: the pressure no longer balances on the delivered part "
|
|
1100
|
+
f"({bc.pressure_imbalance[ci]:.1%} net): a wall the cavity relies on is open (a leak)")
|
|
1101
|
+
# The safety factor carries a discretization allowance: on the
|
|
1102
|
+
# plate-with-hole benchmark (tests) refined voxel peaks land within
|
|
1103
|
+
# -12%..+13% of Peterson's Kt, depending on how the grid falls on the
|
|
1104
|
+
# hole. Under-reading stress is the unsafe direction, so the factor
|
|
1105
|
+
# is computed against peak * STRESS_ALLOWANCE.
|
|
1106
|
+
sf = keep * mat.strength / (peak * STRESS_ALLOWANCE) if peak > 0 else np.inf
|
|
1107
|
+
inter = None
|
|
1108
|
+
proc = study.process
|
|
1109
|
+
if proc is not None and proc.build not in (None, "auto") and proc.interlayer < 1.0:
|
|
1110
|
+
# Layered parts part along their layers: the tension pulling
|
|
1111
|
+
# layers apart (the stress normal to them) against the bond's
|
|
1112
|
+
# strength, a fraction of the in-plane one.
|
|
1113
|
+
from .am import build_vector
|
|
1114
|
+
|
|
1115
|
+
nb = build_vector(proc.build)
|
|
1116
|
+
sig = nodal_stress(stiff, u, moduli, eps)
|
|
1117
|
+
sn = sig[:, :3] @ (nb * nb) + 2.0 * (sig[:, 3] * nb[0] * nb[1] + sig[:, 4] * nb[1] * nb[2]
|
|
1118
|
+
+ sig[:, 5] * nb[2] * nb[0])
|
|
1119
|
+
tens = np.where(body, np.maximum(sn, 0.0), 0.0)
|
|
1120
|
+
ti = int(np.argmax(tens if g_node is None else tens / np.maximum(g_node, 1e-3)))
|
|
1121
|
+
inter = float(tens[ti])
|
|
1122
|
+
k_in = 1.0 if g_node is None else float(g_node[ti])
|
|
1123
|
+
sf_inter = k_in * proc.interlayer * mat.strength / (inter * STRESS_ALLOWANCE) if inter > 0 else np.inf
|
|
1124
|
+
sf = min(sf, sf_inter)
|
|
1125
|
+
cyc_row = {}
|
|
1126
|
+
if cold is not None:
|
|
1127
|
+
stiff_c, disp_c, mod_c, eps_c, g_c = cold
|
|
1128
|
+
if len(idx) == 1:
|
|
1129
|
+
u_c = disp_c[idx[0]]
|
|
1130
|
+
else:
|
|
1131
|
+
u_c = sum(direction[j] * disp_c[i] for j, i in enumerate(idx[1:]))
|
|
1132
|
+
if idx[0] is not None:
|
|
1133
|
+
u_c = u_c + disp_c[idx[0]]
|
|
1134
|
+
s_h = nodal_stress(stiff, u, moduli, eps) / np.maximum(g_node, 1e-3)[:, None]
|
|
1135
|
+
s_c = nodal_stress(stiff_c, u_c, mod_c, eps_c) / np.maximum(g_c, 1e-3)[:, None]
|
|
1136
|
+
util_c = von_mises(s_c)
|
|
1137
|
+
pk_c = int(np.argmax(np.where(body, util_c, -1))) if body.any() else int(np.argmax(util_c))
|
|
1138
|
+
sf_c = mat.strength / (util_c[pk_c] * STRESS_ALLOWANCE) if util_c[pk_c] > 0 else np.inf
|
|
1139
|
+
sf = min(sf, sf_c)
|
|
1140
|
+
# Thermal-cycling fatigue: durability's design curves read the
|
|
1141
|
+
# cycle's maximum stress (which carries the mean-stress effect for
|
|
1142
|
+
# R up to ~0.3); a stress that barely moves over the cycle is
|
|
1143
|
+
# creep, not fatigue, so above that the swing counts: the
|
|
1144
|
+
# equivalent is min(max, range / 0.7). Stresses over retention,
|
|
1145
|
+
# so they read against room-temperature strength.
|
|
1146
|
+
smax = np.maximum(von_mises(s_h), util_c)
|
|
1147
|
+
rng = von_mises(s_h - s_c)
|
|
1148
|
+
seq = np.where(body, np.minimum(smax, rng / 0.7), 0.0)
|
|
1149
|
+
pc = int(np.argmax(seq))
|
|
1150
|
+
cyc_row = {"coldSafetyFactor": sf_c, "coldPeakStress": float(util_c[pk_c] * g_c[pk_c]),
|
|
1151
|
+
"cycleStress": float(seq[pc]), "cycleStressRange": float(rng[pc]),
|
|
1152
|
+
"cycleAt": hm.node_xyz[pc].tolist()}
|
|
1153
|
+
worst_sf = min(worst_sf, sf)
|
|
1154
|
+
cases.append({
|
|
1155
|
+
"name": case.name,
|
|
1156
|
+
"peakStress": peak,
|
|
1157
|
+
"peakAt": at,
|
|
1158
|
+
"globalPeakStress": global_peak,
|
|
1159
|
+
"refined": refined,
|
|
1160
|
+
"p99Stress": float(np.percentile(vm[body] if body.any() else vm, 99)),
|
|
1161
|
+
"contactPeakStress": float(vm.max()),
|
|
1162
|
+
"safetyFactor": sf,
|
|
1163
|
+
**({"peakTemperature": float((t_hot if cold is not None else heat.t_node)[pk]),
|
|
1164
|
+
"strengthRetention": keep}
|
|
1165
|
+
if g_node is not None else {}),
|
|
1166
|
+
**cyc_row,
|
|
1167
|
+
"interlayerStress": inter,
|
|
1168
|
+
"deflection": defl,
|
|
1169
|
+
"maxDisplacement": float(np.linalg.norm(disp, axis=1).max()),
|
|
1170
|
+
"compliance": float(f @ u),
|
|
1171
|
+
**({"pressureImbalance": bc.pressure_imbalance[ci]} if wet.any() else {}),
|
|
1172
|
+
# the uncertain force's most stressful direction, which the
|
|
1173
|
+
# numbers above are for (unit vector)
|
|
1174
|
+
**({"worstDirection": [float(v) for v in direction]} if direction is not None else {}),
|
|
1175
|
+
})
|
|
1176
|
+
report["cases"] = cases
|
|
1177
|
+
report["safetyFactor"] = worst_sf
|
|
1178
|
+
goal_sf = study.goal.safety_factor
|
|
1179
|
+
if goal_sf and not study.self_equilibrated:
|
|
1180
|
+
# the safety factor holds against buckling too (study._margin). Read
|
|
1181
|
+
# for every part, however far under its stress safety factor: the
|
|
1182
|
+
# tet confirm can lift a voxel reading 2.5x (the MBB beam, 0.78 to
|
|
1183
|
+
# 1.97), and a part it lifts must not pass unchecked.
|
|
1184
|
+
try:
|
|
1185
|
+
bk = _buckling(study, mesh, vol, pitch, contact_band, worst_dirs)
|
|
1186
|
+
except (RuntimeError, ValueError, MemoryError) as exc:
|
|
1187
|
+
bk = None
|
|
1188
|
+
report.setdefault("warnings", []).append(f"buckling check failed: {exc}")
|
|
1189
|
+
if bk is not None:
|
|
1190
|
+
report["buckling"] = bk
|
|
1191
|
+
if bk["factor"] is not None:
|
|
1192
|
+
report["bucklingFactor"] = bk["factor"]
|
|
1193
|
+
if bk["factor"] < goal_sf:
|
|
1194
|
+
worst = min((c for c in bk["cases"] if c["factor"] is not None), key=lambda c: c["factor"])
|
|
1195
|
+
report.setdefault("warnings", []).append(
|
|
1196
|
+
f"{worst['name']}: buckles at {bk['factor']:.2f}x its loads (linear buckling), under "
|
|
1197
|
+
f"the safety factor {goal_sf:g}; the mode is largest near "
|
|
1198
|
+
f"({', '.join(f'{v:.1f}' for v in worst.get('modeAt', []))}). Thicken or brace the "
|
|
1199
|
+
"slender member carrying compression there.")
|
|
1200
|
+
if env is not None and len(env):
|
|
1201
|
+
# how evenly the material is worked: mean von Mises over the near
|
|
1202
|
+
# peak (99.9th percentile, so one singular node does not set it).
|
|
1203
|
+
# Low (about 0.1, the wall brackets) means most of the part idles
|
|
1204
|
+
# at a few percent of the peak, which the stress-constrained method
|
|
1205
|
+
# trims (study.py, method auto)
|
|
1206
|
+
pk = float(np.quantile(env, 0.999))
|
|
1207
|
+
report["stressUtilization"] = float(env.mean() / pk) if pk > 0 else None
|
|
1208
|
+
report["stressAllowance"] = STRESS_ALLOWANCE
|
|
1209
|
+
from .durability import active, assess
|
|
1210
|
+
if active(study):
|
|
1211
|
+
# fatigue (cases with cycles), creep and relaxation (hours, preload)
|
|
1212
|
+
report["durability"] = assess(study, cases, STRESS_ALLOWANCE)
|
|
1213
|
+
report["analysisCells"] = int(hm.n_elem)
|
|
1214
|
+
report["seconds"] = time.time() - t0
|
|
1215
|
+
return report
|