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