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/nonlinear.py
ADDED
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Large deflection and buckling, for parts that are meant to bend.
|
|
3
|
+
|
|
4
|
+
Everything in fem.py is linear: stiffness fixed at the undeformed shape,
|
|
5
|
+
strains small. That is right for a bracket and wrong for a TPU finger that
|
|
6
|
+
curls 60 degrees, a flexure hinge, a snap fit or a slender strut near
|
|
7
|
+
buckling. This module adds the two analyses those parts need, on the same
|
|
8
|
+
voxel hex mesh:
|
|
9
|
+
|
|
10
|
+
- `solve_nonlinear`: geometrically nonlinear static analysis. Total
|
|
11
|
+
Lagrangian 8-node hexahedra (2x2x2 Gauss), a hyperelastic material
|
|
12
|
+
(compressible neo-Hookean by default, St. Venant-Kirchhoff on request:
|
|
13
|
+
both reduce to fem.py's linear element at small strain), and Newton's
|
|
14
|
+
method on the residual with the full tangent (material + geometric
|
|
15
|
+
stiffness). Load is applied in increments that grow when Newton converges
|
|
16
|
+
quickly and halve when it does not; a Newton update that would turn a
|
|
17
|
+
cell inside out is halved instead of failing the step. Elements are
|
|
18
|
+
built by a compiled kernel on all cores (nlkernel.py). Loads are dead (they keep their
|
|
19
|
+
direction as the part deforms). Supports can be pushed instead of loaded
|
|
20
|
+
(`prescribed`), which follows a part through a snap-through that load
|
|
21
|
+
control cannot pass, and reports the force it takes (`reaction`).
|
|
22
|
+
- `linear_buckling`: the classic eigenvalue estimate. Solve the linear
|
|
23
|
+
problem, build the geometric stiffness of its stresses, and find load
|
|
24
|
+
factors lambda with (K + lambda K_sigma) phi = 0. lambda < 1 means the
|
|
25
|
+
load buckles the part; the modes say how.
|
|
26
|
+
|
|
27
|
+
Contact (contact.py: rigid planes, spheres, cylinders and boxes, fixed or
|
|
28
|
+
moving with the load, and the part's surface on itself) and tendons
|
|
29
|
+
(cables through guides at a tension, or wound in by a servo) go in as
|
|
30
|
+
`contact=` and `tendons=`; both follow the deformed part.
|
|
31
|
+
`large_deflection_report` runs all of it from a field graph's
|
|
32
|
+
`largeDeflection` block.
|
|
33
|
+
|
|
34
|
+
`verify_mechanism` puts both behind one call for a compliant mechanism
|
|
35
|
+
design from mechanism.py: the solid cells of the result, the real input
|
|
36
|
+
force, and the output's travel compared with what the linear model
|
|
37
|
+
promised.
|
|
38
|
+
|
|
39
|
+
Plain linear hexahedra are stiff in bending (shear locking): one or two
|
|
40
|
+
cells across a flexure overestimate its stiffness badly. As in fem.py, the
|
|
41
|
+
element carries Wilson's nine incompatible bubble modes, here as an enhanced
|
|
42
|
+
displacement gradient (Simo-Armero) condensed element by element at each
|
|
43
|
+
Newton iteration, which removes the locking; at small strain it is fem.py's
|
|
44
|
+
linear element exactly. `element="plain"` (or LOADPATH_HEX8=plain) gives the
|
|
45
|
+
old trilinear element. mechanism_nl.py's optimizer still uses the plain one. Validation against the elastica and Euler's column is in
|
|
46
|
+
tests/test_nonlinear.py.
|
|
47
|
+
|
|
48
|
+
Units as everywhere in loadpath: mm, N, MPa.
|
|
49
|
+
"""
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
import scipy.sparse as sp
|
|
56
|
+
from scipy.sparse.linalg import LinearOperator, eigsh, splu
|
|
57
|
+
|
|
58
|
+
from .fem import CORNERS, HexMesh, Solver, Stiffness, _hex8_parts, hex8_element, von_mises
|
|
59
|
+
|
|
60
|
+
__all__ = ["NonlinearResult", "BucklingResult", "Tendon", "nodes_near", "solve_nonlinear", "linear_buckling", "stroke_mechanism",
|
|
61
|
+
"large_deflection_report",
|
|
62
|
+
"solid_submesh", "verify_mechanism"]
|
|
63
|
+
|
|
64
|
+
# Above this many dofs the Newton steps use CG + AMG instead of a direct
|
|
65
|
+
# factorization (SuperLU's fill on 3D hex meshes grows fast: 1.5 s at 20k
|
|
66
|
+
# dofs, 9 s at 40k). CG needs the tangent positive definite, so there a load
|
|
67
|
+
# step that lands on an unstable state is cut back, and the stability check
|
|
68
|
+
# runs LOBPCG with the multigrid preconditioner instead of shift-invert.
|
|
69
|
+
DIRECT_MAX_DOF = 30_000
|
|
70
|
+
# Step control. A step that converged in at most GROW_NEWTON iterations
|
|
71
|
+
# (assemblies, the converged one included) lets the next one grow 1.5x.
|
|
72
|
+
# A retry with line search (after plain Newton failed a contact step) that
|
|
73
|
+
# has not halved its best residual in STALL_NEWTON iterations is chattering
|
|
74
|
+
# (contacts switching on and off) and gives up the step: a shorter step is
|
|
75
|
+
# cheaper than more iterations. (Plain Newton gets its full max_newton: a
|
|
76
|
+
# contact plateau of ten or more iterations often ends in convergence.)
|
|
77
|
+
GROW_NEWTON = 6
|
|
78
|
+
STALL_NEWTON = 8
|
|
79
|
+
_CHUNK = 1024 # elements per batch: big enough to vectorize, small enough to stay in cache
|
|
80
|
+
_VOIGT = [(0, 0), (1, 1), (2, 2), (0, 1), (1, 2), (2, 0)] # fem.py order: xx yy zz xy yz zx
|
|
81
|
+
_VI = np.array([p[0] for p in _VOIGT])
|
|
82
|
+
_VJ = np.array([p[1] for p in _VOIGT])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _dn(xi, eta, zeta):
|
|
86
|
+
"""Shape function gradients (8, 3) of the unit cube at a natural point."""
|
|
87
|
+
s = 2.0 * CORNERS - 1.0
|
|
88
|
+
dn = np.empty((8, 3))
|
|
89
|
+
dn[:, 0] = 0.25 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2])
|
|
90
|
+
dn[:, 1] = 0.25 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2])
|
|
91
|
+
dn[:, 2] = 0.25 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1])
|
|
92
|
+
return dn
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_G = 1.0 / np.sqrt(3.0)
|
|
96
|
+
_GAUSS = [_dn(a, b, c) for a in (-_G, _G) for b in (-_G, _G) for c in (-_G, _G)]
|
|
97
|
+
_CENTER = _dn(0.0, 0.0, 0.0)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _lame(nu):
|
|
101
|
+
return nu / ((1 + nu) * (1 - 2 * nu)), 1.0 / (2 * (1 + nu)) # at E = 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_I3 = np.eye(3)
|
|
105
|
+
# Products of shape function gradients at each Gauss point, for the tangent
|
|
106
|
+
# as one matrix product: W[g][(l, n), (a, b)] = dN_a/dX_l dN_b/dX_n (unit cube).
|
|
107
|
+
_W = np.stack([np.einsum("al,bn->lnab", g, g).reshape(9, 64) for g in _GAUSS])
|
|
108
|
+
# The incompatible-mode element: three more "nodes" whose rows are the
|
|
109
|
+
# gradients of the bubbles 1 - xi_m^2 on the unit cube (-4 xi_m along axis m;
|
|
110
|
+
# fem._b_bubble's modes), so their amplitudes alpha (3 modes x 3 components)
|
|
111
|
+
# enter F like nodal displacements.
|
|
112
|
+
_GAUSS_IM = [np.vstack([dn, np.diag(-4.0 * np.array(p))]) for dn, p in
|
|
113
|
+
zip(_GAUSS, [(a, b, c) for a in (-_G, _G) for b in (-_G, _G) for c in (-_G, _G)])]
|
|
114
|
+
_W_IM = np.stack([np.einsum("al,bn->lnab", g, g).reshape(9, 121) for g in _GAUSS_IM])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _material(F, nu, kind):
|
|
118
|
+
"""2nd Piola-Kirchhoff stress S (m, 3, 3) and the material tangent
|
|
119
|
+
dS/dE as a 4th-order tensor (m, 3, 3, 3, 3), at unit modulus."""
|
|
120
|
+
lam, mu = _lame(nu)
|
|
121
|
+
C = np.einsum("mki,mkj->mij", F, F)
|
|
122
|
+
if kind == "svk":
|
|
123
|
+
E = 0.5 * (C - _I3)
|
|
124
|
+
tr = np.trace(E, axis1=1, axis2=2)
|
|
125
|
+
S = lam * tr[:, None, None] * _I3 + 2 * mu * E
|
|
126
|
+
A = (lam * np.einsum("ij,kl->ijkl", _I3, _I3)
|
|
127
|
+
+ mu * (np.einsum("ik,jl->ijkl", _I3, _I3) + np.einsum("il,jk->ijkl", _I3, _I3)))
|
|
128
|
+
return S, np.broadcast_to(A, (len(F), 3, 3, 3, 3))
|
|
129
|
+
J = np.linalg.det(F)
|
|
130
|
+
lnJ = np.log(np.maximum(J, 1e-12))
|
|
131
|
+
Ci = np.linalg.inv(C)
|
|
132
|
+
S = mu * (_I3 - Ci) + (lam * lnJ)[:, None, None] * Ci
|
|
133
|
+
a = Ci[:, :, None, :, None] * Ci[:, None, :, None, :] # Ci_ik Ci_jl
|
|
134
|
+
A = lam * Ci[:, :, :, None, None] * Ci[:, None, None, :, :] \
|
|
135
|
+
+ (mu - lam * lnJ)[:, None, None, None, None] * (a + a.transpose(0, 1, 2, 4, 3))
|
|
136
|
+
return S, A
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _gauss_terms(ux, grads, wmat, h, nu, kind, tangent):
|
|
140
|
+
"""Internal force (m, 3n) and tangent (m, 3n, 3n) at unit modulus for
|
|
141
|
+
elements with generalized displacements ux (m, n, 3), whose deformation
|
|
142
|
+
gradient at Gauss point g is I + sum_a ux_a (x) grads[g][a] / h, and the
|
|
143
|
+
smallest volume ratio J seen at a Gauss point.
|
|
144
|
+
|
|
145
|
+
With P = F S the first Piola-Kirchhoff stress, f_ai = sum_g w P_il G_al,
|
|
146
|
+
and the tangent is K_aibj = sum_g w G_al Q_iljn G_bn with
|
|
147
|
+
Q_iljn = F_ik A_klpn F_jp + S_ln delta_ij (material + geometric): a
|
|
148
|
+
(m*9, 9) x (9, n^2) matrix product per Gauss point instead of 3n x 3n
|
|
149
|
+
products per element (all eight stacked into one)."""
|
|
150
|
+
m, n = ux.shape[:2]
|
|
151
|
+
w = h ** 3 / 8.0
|
|
152
|
+
fint = np.zeros((m, n, 3))
|
|
153
|
+
qall = np.empty((m, 9, 8, 9)) if tangent else None
|
|
154
|
+
jmin = np.inf
|
|
155
|
+
for g, dn0 in enumerate(grads):
|
|
156
|
+
dn = dn0 / h
|
|
157
|
+
F = _I3 + np.einsum("mai,aj->mij", ux, dn)
|
|
158
|
+
jmin = min(jmin, float(np.linalg.det(F).min()))
|
|
159
|
+
S, A = _material(F, nu, kind)
|
|
160
|
+
P = F @ S
|
|
161
|
+
fint += w * (P @ dn.T).transpose(0, 2, 1)
|
|
162
|
+
if tangent:
|
|
163
|
+
fa = (F @ A.reshape(m, 3, 27)).reshape(m, 3, 3, 3, 3)
|
|
164
|
+
Q = np.einsum("milpn,mjp->mijln", fa, F, optimize=True)
|
|
165
|
+
Q += _I3[None, :, :, None, None] * S[:, None, None, :, :]
|
|
166
|
+
qall[:, :, g, :] = Q.reshape(m, 9, 9)
|
|
167
|
+
kt = None
|
|
168
|
+
if tangent:
|
|
169
|
+
kq = qall.reshape(m * 9, 72) @ (wmat.reshape(72, n * n) * (w / h ** 2))
|
|
170
|
+
kt = kq.reshape(m, 3, 3, n, n).transpose(0, 3, 1, 4, 2).reshape(m, 3 * n, 3 * n)
|
|
171
|
+
return fint.reshape(m, 3 * n), kt, jmin
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _element(ue, h, nu, kind, tangent, alpha=None):
|
|
175
|
+
"""Internal force (m, 24) and tangent (m, 24, 24) at unit modulus for
|
|
176
|
+
elements with nodal displacements ue (m, 8, 3), and the smallest volume
|
|
177
|
+
ratio J seen at a Gauss point (<= 0 means an element turned inside out).
|
|
178
|
+
|
|
179
|
+
alpha None: the plain trilinear hexahedron. Otherwise (m, 3, 3), the
|
|
180
|
+
amplitudes of the nine incompatible modes (fem.hex8_element's bubbles,
|
|
181
|
+
here as an enhanced displacement gradient, Simo-Armero): the element
|
|
182
|
+
takes one local Newton step on them, condenses them out, and returns
|
|
183
|
+
(fint, kt, jmin, alpha_new, R_alpha (m, 9)). fint is the condensed
|
|
184
|
+
residual R_u - K_ua K_aa^-1 R_a, i.e. R_u at alpha_new to first order,
|
|
185
|
+
so the global Newton on u stays quadratic. At u = 0 the condensed
|
|
186
|
+
tangent is fem.py's linear incompatible-mode element exactly."""
|
|
187
|
+
if alpha is None:
|
|
188
|
+
return _gauss_terms(ue, _GAUSS, _W, h, nu, kind, tangent)
|
|
189
|
+
m = len(ue)
|
|
190
|
+
ux = np.concatenate([ue, alpha], axis=1) # (m, 11, 3)
|
|
191
|
+
fx, kx, jmin = _gauss_terms(ux, _GAUSS_IM, _W_IM, h, nu, kind, True)
|
|
192
|
+
ru, ra = fx[:, :24], fx[:, 24:]
|
|
193
|
+
kua, kaa = kx[:, :24, 24:], kx[:, 24:, 24:]
|
|
194
|
+
sol = np.linalg.solve(kaa, np.concatenate([ra[:, :, None], kua.transpose(0, 2, 1)], axis=2))
|
|
195
|
+
da, kaa_kau = sol[:, :, 0], sol[:, :, 1:]
|
|
196
|
+
fint = ru - np.einsum("mij,mj->mi", kua, da)
|
|
197
|
+
kt = kx[:, :24, :24] - kua @ kaa_kau if tangent else None
|
|
198
|
+
alpha_new = alpha - da.reshape(m, 3, 3)
|
|
199
|
+
return fint, kt, jmin, alpha_new, ra, kaa_kau
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _assemble(stiff, u, mod, nu, kind, tangent=True, alpha=None, coupling=None):
|
|
203
|
+
"""Global internal force and tangent; with alpha (n_elem, 3, 3), the
|
|
204
|
+
incompatible-mode element, also (alpha_new, |R_alpha|) with R_alpha
|
|
205
|
+
weighted by the element moduli as the forces are."""
|
|
206
|
+
mesh = stiff.mesh
|
|
207
|
+
from . import nlkernel
|
|
208
|
+
if nlkernel.available():
|
|
209
|
+
grads = np.stack(_GAUSS_IM if alpha is not None else _GAUSS)
|
|
210
|
+
fe, ke, jm, a_new, ra, cpl = nlkernel.elements(u, mesh.edof, alpha, mod, mesh.pitch, nu, kind, tangent, grads)
|
|
211
|
+
fint, data = nlkernel.scatter(mesh.n_dof, stiff.nnz, stiff._slots, mesh.edof, fe, ke, tangent)
|
|
212
|
+
k = stiff.finish(data) if tangent else None
|
|
213
|
+
jmin = float(jm.min()) if len(jm) else np.inf
|
|
214
|
+
if alpha is None:
|
|
215
|
+
return fint, k, jmin
|
|
216
|
+
if coupling is not None:
|
|
217
|
+
coupling[:] = cpl
|
|
218
|
+
return fint, k, jmin, a_new, float(np.sqrt(((ra * mod[:, None]) ** 2).sum()))
|
|
219
|
+
fint = np.zeros(mesh.n_dof)
|
|
220
|
+
data = np.zeros(stiff.nnz) if tangent else None
|
|
221
|
+
jmin = np.inf
|
|
222
|
+
a_new = None if alpha is None else np.empty_like(alpha)
|
|
223
|
+
ra2 = 0.0
|
|
224
|
+
for s in range(0, mesh.n_elem, _CHUNK):
|
|
225
|
+
el = np.arange(s, min(s + _CHUNK, mesh.n_elem))
|
|
226
|
+
edof = mesh.edof[el]
|
|
227
|
+
ue = u[edof].reshape(-1, 8, 3)
|
|
228
|
+
me = mod[el]
|
|
229
|
+
if alpha is None:
|
|
230
|
+
fe, ke, j = _element(ue, mesh.pitch, nu, kind, tangent)
|
|
231
|
+
else:
|
|
232
|
+
fe, ke, j, a_new[el], ra, cpl = _element(ue, mesh.pitch, nu, kind, tangent, alpha[el])
|
|
233
|
+
if coupling is not None:
|
|
234
|
+
coupling[el] = cpl
|
|
235
|
+
ra2 += float(((ra * me[:, None]) ** 2).sum())
|
|
236
|
+
jmin = min(jmin, j)
|
|
237
|
+
np.add.at(fint, edof.ravel(), (fe * me[:, None]).ravel())
|
|
238
|
+
if tangent:
|
|
239
|
+
stiff.add_elements(data, ke * me[:, None, None], el)
|
|
240
|
+
k = stiff.finish(data) if tangent else None
|
|
241
|
+
if alpha is None:
|
|
242
|
+
return fint, k, jmin
|
|
243
|
+
return fint, k, jmin, a_new, np.sqrt(ra2)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _splu(k):
|
|
247
|
+
# symmetric pattern: minimum degree on A^T + A, no pivoting off the diagonal
|
|
248
|
+
return splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0, options={"SymmetricMode": True})
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class _Linear:
|
|
252
|
+
"""Direct factorization for small and medium problems, CG + AMG above
|
|
253
|
+
(the hierarchy is reused across Newton iterations until it goes stale)."""
|
|
254
|
+
|
|
255
|
+
def __init__(self, mesh):
|
|
256
|
+
self.direct = mesh.n_dof <= DIRECT_MAX_DOF
|
|
257
|
+
self.iter = None if self.direct else Solver(mesh, rtol=1e-9, maxiter=2000)
|
|
258
|
+
|
|
259
|
+
def solve(self, k, r):
|
|
260
|
+
if self.direct:
|
|
261
|
+
return _splu(k).solve(r)
|
|
262
|
+
return self.iter.solve(k, r)
|
|
263
|
+
|
|
264
|
+
def lowest_mode(self, k):
|
|
265
|
+
"""Smallest eigenvalue of a (symmetric) tangent and its mode."""
|
|
266
|
+
n = k.shape[0]
|
|
267
|
+
if self.direct:
|
|
268
|
+
lu = _splu(k)
|
|
269
|
+
op = LinearOperator((n, n), matvec=lu.solve, dtype=float)
|
|
270
|
+
# a fixed start vector: ARPACK's random one made the branch
|
|
271
|
+
# a perfect column takes (and so whether it found it) vary run to run
|
|
272
|
+
v0 = np.random.default_rng(0).standard_normal(n)
|
|
273
|
+
vals, vecs = eigsh(k, k=1, sigma=0.0, which="LM", OPinv=op, tol=1e-6, v0=v0)
|
|
274
|
+
return float(vals[0]), vecs[:, 0]
|
|
275
|
+
from scipy.sparse.linalg import lobpcg
|
|
276
|
+
m = self.iter._precond(k)
|
|
277
|
+
x0 = np.random.default_rng(0).standard_normal((n, 1))
|
|
278
|
+
import warnings
|
|
279
|
+
with warnings.catch_warnings():
|
|
280
|
+
# only the sign matters here; a loose eigenvalue is fine
|
|
281
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
282
|
+
vals, vecs = lobpcg(k, x0, M=m, largest=False, tol=1e-6, maxiter=300)
|
|
283
|
+
return float(vals[0]), vecs[:, 0]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@dataclass
|
|
287
|
+
class NonlinearResult:
|
|
288
|
+
u: np.ndarray # displacement per dof at the last converged load
|
|
289
|
+
load_factor: float # fraction of the load (or stroke) reached; 1.0 when complete
|
|
290
|
+
converged: bool # reached the full load
|
|
291
|
+
history: list = field(default_factory=list) # per converged step
|
|
292
|
+
stress: np.ndarray | None = None # element-center Cauchy von Mises, MPa
|
|
293
|
+
strain: np.ndarray | None = None # element-center largest principal stretch - 1
|
|
294
|
+
message: str = ""
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _post(mesh, u, mod, nu, kind):
|
|
298
|
+
ue = u[mesh.edof].reshape(-1, 8, 3)
|
|
299
|
+
F = np.eye(3) + np.einsum("mai,aj->mij", ue, _CENTER / mesh.pitch)
|
|
300
|
+
S, _ = _material(F, nu, kind)
|
|
301
|
+
J = np.linalg.det(F)
|
|
302
|
+
sig = np.einsum("mik,mkl,mjl->mij", F, S, F) / J[:, None, None] * mod[:, None, None]
|
|
303
|
+
vm = von_mises(sig[:, _VI, _VJ])
|
|
304
|
+
C = np.einsum("mki,mkj->mij", F, F)
|
|
305
|
+
stretch = np.sqrt(np.maximum(np.linalg.eigvalsh(C)[:, -1], 0.0))
|
|
306
|
+
return vm, stretch - 1.0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class Tendon:
|
|
311
|
+
"""A cable at constant tension through guides on the part, like a
|
|
312
|
+
tendon in its channel: each point of `path` is either a fixed point in
|
|
313
|
+
space (a float array of 3, e.g. the servo's spool or a palm pulley) or
|
|
314
|
+
a guide on the part (an int array of node ids; the guide sits at their
|
|
315
|
+
mean and moves with them). The cable runs straight between consecutive
|
|
316
|
+
points and slides freely through the guides, so each guide is pulled
|
|
317
|
+
toward both its neighbours along the deformed cable and the last point
|
|
318
|
+
(the knot) toward the one before it. The load follows the part as it
|
|
319
|
+
curls, which a dead force does not: on a curling finger a fixed-
|
|
320
|
+
direction force gains moment arm, a tendon's stays in its channel.
|
|
321
|
+
|
|
322
|
+
Constant tension makes it conservative (potential T * cable length), so
|
|
323
|
+
the load stiffness is symmetric and Newton keeps its tangent symmetric.
|
|
324
|
+
Tension scales with the load factor like `force`. Each step records the
|
|
325
|
+
tendon's travel (how much cable the actuator has pulled in, mm)."""
|
|
326
|
+
tension: float
|
|
327
|
+
path: list
|
|
328
|
+
|
|
329
|
+
def _setup(self, mesh):
|
|
330
|
+
pts = []
|
|
331
|
+
for p in self.path:
|
|
332
|
+
a = np.asarray(p)
|
|
333
|
+
if a.dtype.kind in "iu":
|
|
334
|
+
ids = np.unique(a.ravel()).astype(np.int64)
|
|
335
|
+
if len(ids) == 0:
|
|
336
|
+
raise ValueError("Tendon: a guide with no nodes")
|
|
337
|
+
w = np.full(len(ids), 1.0 / len(ids))
|
|
338
|
+
rows = np.repeat(np.arange(3), len(ids))
|
|
339
|
+
cols = (3 * ids[None, :] + np.arange(3)[:, None]).ravel()
|
|
340
|
+
g = sp.csr_matrix((np.tile(w, 3), (rows, cols)), shape=(3, mesh.n_dof))
|
|
341
|
+
pts.append((mesh.node_xyz[ids].mean(axis=0), g))
|
|
342
|
+
else:
|
|
343
|
+
if a.shape != (3,):
|
|
344
|
+
raise ValueError("Tendon: a fixed point is 3 floats; a guide is an int array of node ids")
|
|
345
|
+
pts.append((a.astype(float), None))
|
|
346
|
+
if len(pts) < 2:
|
|
347
|
+
raise ValueError("Tendon: the path needs at least two points")
|
|
348
|
+
segs = []
|
|
349
|
+
for (xa, ga), (xb, gb) in zip(pts[:-1], pts[1:]):
|
|
350
|
+
gd = (gb if gb is not None else 0) - (ga if ga is not None else 0)
|
|
351
|
+
if isinstance(gd, int):
|
|
352
|
+
continue # between two fixed points: no load on the part
|
|
353
|
+
segs.append((xb - xa, sp.csr_matrix(gd)))
|
|
354
|
+
self._segs = segs
|
|
355
|
+
self._len0 = sum(np.linalg.norm(x0) for x0, _ in segs)
|
|
356
|
+
# all segments at once: d = d0 + G u, (S, 3)
|
|
357
|
+
self._d0 = np.array([x0 for x0, _ in segs]).reshape(-1, 3)
|
|
358
|
+
self._G = sp.vstack([gd for _, gd in segs]).tocsr() if segs else sp.csr_matrix((0, mesh.n_dof))
|
|
359
|
+
S = len(segs)
|
|
360
|
+
self._bi = (3 * np.arange(S)[:, None, None] + np.arange(3)[None, :, None] + 0 * np.arange(3)[None, None, :]).ravel()
|
|
361
|
+
self._bj = (3 * np.arange(S)[:, None, None] + 0 * np.arange(3)[None, :, None] + np.arange(3)[None, None, :]).ravel()
|
|
362
|
+
|
|
363
|
+
def _load(self, u, scale):
|
|
364
|
+
"""Force on the part (per dof), load stiffness, and cable length
|
|
365
|
+
(over the segments that touch the part)."""
|
|
366
|
+
t = self.tension * scale
|
|
367
|
+
n = u.shape[0]
|
|
368
|
+
S = len(self._d0)
|
|
369
|
+
if S == 0:
|
|
370
|
+
return np.zeros(n), sp.csr_matrix((n, n)), 0.0
|
|
371
|
+
d = self._d0 + (self._G @ u).reshape(S, 3)
|
|
372
|
+
ell = np.linalg.norm(d, axis=1)
|
|
373
|
+
e = d / ell[:, None]
|
|
374
|
+
f = -t * (self._G.T @ e.ravel())
|
|
375
|
+
blocks = t * (np.eye(3)[None] - np.einsum("si,sj->sij", e, e)) / ell[:, None, None]
|
|
376
|
+
mb = sp.csr_matrix((blocks.ravel(), (self._bi, self._bj)), shape=(3 * S, 3 * S))
|
|
377
|
+
k = (self._G.T @ mb @ self._G).tocsr()
|
|
378
|
+
return f, k, float(ell.sum())
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def nodes_near(mesh: HexMesh, point, radius: float) -> np.ndarray:
|
|
382
|
+
"""Node ids within `radius` of `point`: a tendon guide or knot."""
|
|
383
|
+
d = np.linalg.norm(mesh.node_xyz - np.asarray(point, float), axis=1)
|
|
384
|
+
ids = np.nonzero(d <= radius)[0]
|
|
385
|
+
if len(ids) == 0:
|
|
386
|
+
raise ValueError(f"no nodes within {radius} of {point}")
|
|
387
|
+
return ids
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def solve_nonlinear(mesh: HexMesh, nu: float, modulus, fixed: np.ndarray, force: np.ndarray | None = None,
|
|
391
|
+
prescribed: tuple | None = None, *, material: str = "neo-hookean", steps: int = 10,
|
|
392
|
+
max_step: float = 0.25, min_step: float = 1e-4, rtol: float = 1e-7, max_newton: int = 25,
|
|
393
|
+
springs: np.ndarray | None = None, tendons: list | None = None, contact: list | None = None,
|
|
394
|
+
stability: bool = True, probes: dict | None = None,
|
|
395
|
+
on_step=None, element: str | None = None) -> NonlinearResult:
|
|
396
|
+
"""Geometrically nonlinear static solve.
|
|
397
|
+
|
|
398
|
+
modulus: E, scalar or per element (MPa). fixed: bool per dof, held at 0.
|
|
399
|
+
force: force per dof (N) at full load. prescribed: (dofs, values) moved to
|
|
400
|
+
`values` (mm) at full load, e.g. an actuator's stroke. probes: name ->
|
|
401
|
+
weight per dof; each step records weight . u (with unit-sum weights over
|
|
402
|
+
a face along a direction, the face's mean travel along it). tendons:
|
|
403
|
+
Tendon loads (cables through guides) that follow the deformed part, at
|
|
404
|
+
constant tension, or mechanism_nl.ServoTendon (a servo winding in a
|
|
405
|
+
stroke of cable through a compliant cable: displacement driven).
|
|
406
|
+
contact: contact.RigidObstacle (a rigid plane, sphere, cylinder or box,
|
|
407
|
+
fixed or moving with the load) and contact.SelfContact (the part's
|
|
408
|
+
surface on itself), penalty, frictionless or with Coulomb friction
|
|
409
|
+
(`friction=mu`: sticks until the tangential force reaches mu times the
|
|
410
|
+
normal force, then slides; history dependent, updated at each converged
|
|
411
|
+
step). Each step records each contact's force, nodes touching and
|
|
412
|
+
deepest penetration, with friction the friction force and the nodes
|
|
413
|
+
sliding.
|
|
414
|
+
springs:
|
|
415
|
+
grounded linear springs, N/mm per dof (an actuator's compliance, a
|
|
416
|
+
workpiece pushing back).
|
|
417
|
+
|
|
418
|
+
stability: after each step, check the tangent's lowest eigenvalue. Newton
|
|
419
|
+
happily converges to an equilibrium that is unstable (a column kept
|
|
420
|
+
straight past its buckling load, a snap-through that hasn't snapped);
|
|
421
|
+
when the tangent has a negative eigenvalue the solve kicks the part
|
|
422
|
+
along that mode and finds the stable state nearby, as a real part would
|
|
423
|
+
(a print is never perfectly straight).
|
|
424
|
+
|
|
425
|
+
element: "im" (fem.hex8_element's default) or "plain". The incompatible
|
|
426
|
+
modes take bending without shear locking, so one or two cells across a
|
|
427
|
+
flexure no longer read it too stiff.
|
|
428
|
+
|
|
429
|
+
Each history entry has the load factor, Newton iterations, the probes,
|
|
430
|
+
with prescribed dofs the reaction there (the force the actuator pushes
|
|
431
|
+
with, N, as a 3-vector summed over the prescribed nodes), and with
|
|
432
|
+
stability the tangent's lowest eigenvalue and whether the step had to
|
|
433
|
+
leave an unstable branch ("branchSwitch").
|
|
434
|
+
"""
|
|
435
|
+
n = mesh.n_dof
|
|
436
|
+
mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)).copy()
|
|
437
|
+
f = np.zeros(n) if force is None else np.asarray(force, float)
|
|
438
|
+
cons = np.asarray(fixed, bool).copy()
|
|
439
|
+
pd, pv = (np.zeros(0, np.int64), np.zeros(0)) if prescribed is None else \
|
|
440
|
+
(np.asarray(prescribed[0], np.int64), np.asarray(prescribed[1], float))
|
|
441
|
+
cons[pd] = True
|
|
442
|
+
element = element or hex8_element()
|
|
443
|
+
stiff = Stiffness(mesh, nu, cons, element=element)
|
|
444
|
+
lin = _Linear(mesh)
|
|
445
|
+
# incompatible-mode amplitudes per element at the last converged state
|
|
446
|
+
alpha = np.zeros((mesh.n_elem, 3, 3)) if element == "im" else None
|
|
447
|
+
probes = probes or {}
|
|
448
|
+
pnodes = np.unique(pd // 3)
|
|
449
|
+
size = float(np.linalg.norm(np.ptp(mesh.node_xyz, axis=0)))
|
|
450
|
+
# Don't let one Newton update move any node more than a fraction of the
|
|
451
|
+
# part's size (a wild first iterate after a big step).
|
|
452
|
+
cap = 0.25 * size
|
|
453
|
+
kick_step = min(0.02, 0.25 / steps)
|
|
454
|
+
seeded = False
|
|
455
|
+
|
|
456
|
+
spr = None
|
|
457
|
+
if springs is not None:
|
|
458
|
+
sd = np.where(cons, 0.0, np.asarray(springs, float))
|
|
459
|
+
spr = sp.diags(sd).tocsr()
|
|
460
|
+
|
|
461
|
+
tendons = tendons or []
|
|
462
|
+
keep_free = sp.diags((~cons).astype(float))
|
|
463
|
+
for t in tendons:
|
|
464
|
+
t._setup(mesh)
|
|
465
|
+
from .contact import SelfContact
|
|
466
|
+
contact = contact or []
|
|
467
|
+
for c in contact:
|
|
468
|
+
if isinstance(c, SelfContact):
|
|
469
|
+
c._setup(mesh, mod, fixed=cons)
|
|
470
|
+
else:
|
|
471
|
+
c._setup(mesh, mod)
|
|
472
|
+
loads = tendons + contact
|
|
473
|
+
if contact:
|
|
474
|
+
# active sets settle over a few iterations, and a node sliding from
|
|
475
|
+
# one face to the next leaves a residual of about 1e-7 of the
|
|
476
|
+
# contact force: converge to 1e-5 of the load (the penalty itself
|
|
477
|
+
# is a far bigger approximation)
|
|
478
|
+
max_newton = max(max_newton, 40)
|
|
479
|
+
rtol = max(rtol, 1e-5)
|
|
480
|
+
# Coulomb friction's sliding tangent is not symmetric: the direct LU
|
|
481
|
+
# takes it as it is; CG and the stability check take its symmetric part
|
|
482
|
+
# (and so is self contact's, with the faces' turning in it)
|
|
483
|
+
def _unsym(c):
|
|
484
|
+
return getattr(c, "friction", 0.0) > 0 or getattr(c, "rotation", False)
|
|
485
|
+
asym = any(_unsym(c) for c in contact)
|
|
486
|
+
|
|
487
|
+
def sym(k):
|
|
488
|
+
return (0.5 * (k + k.T)).tocsr() if asym else k
|
|
489
|
+
|
|
490
|
+
def stab(k, v, target):
|
|
491
|
+
"""The tangent for the stability check: with friction, without the
|
|
492
|
+
sliding force's growth with the normal force, and self contact's
|
|
493
|
+
without the faces' turning (both unsymmetric, and their symmetric
|
|
494
|
+
parts are indefinite: not a buckle)."""
|
|
495
|
+
if not asym:
|
|
496
|
+
return k
|
|
497
|
+
for c in contact:
|
|
498
|
+
if _unsym(c):
|
|
499
|
+
kf = c._load(v, target)[1]
|
|
500
|
+
c._symmetric = True
|
|
501
|
+
try:
|
|
502
|
+
ks = c._load(v, target)[1]
|
|
503
|
+
finally:
|
|
504
|
+
c._symmetric = False
|
|
505
|
+
k = k + keep_free @ (ks - kf) @ keep_free
|
|
506
|
+
return sym(k)
|
|
507
|
+
|
|
508
|
+
# the smallest force worth resolving: a strain of 1e-6 on one cell face
|
|
509
|
+
# (a part only carried along rigidly, e.g. towards a contact it has not
|
|
510
|
+
# reached yet, has no load to measure its residual against)
|
|
511
|
+
fscale = 1e-6 * float(mod.max()) * mesh.pitch ** 2
|
|
512
|
+
|
|
513
|
+
def newton(v, target, a=None, search=False):
|
|
514
|
+
rnorm0 = None
|
|
515
|
+
a = alpha if a is None else a
|
|
516
|
+
# K_aa^-1 K_au per element (incompatible modes), two buffers so a
|
|
517
|
+
# backtrack can still use the last good iterate's
|
|
518
|
+
bufs = None if a is None else [np.empty((mesh.n_elem, 9, 24)) for _ in range(2)]
|
|
519
|
+
cur, prev, cuts, best = 0, None, 0, None
|
|
520
|
+
|
|
521
|
+
def follow(a_lin, c, d):
|
|
522
|
+
# the bubbles follow the nodes: alpha += -K_aa^-1 K_au du
|
|
523
|
+
if c is None:
|
|
524
|
+
return a_lin
|
|
525
|
+
return a_lin - np.einsum("mij,mj->mi", c, d[mesh.edof]).reshape(-1, 3, 3)
|
|
526
|
+
|
|
527
|
+
for its in range(1, max_newton + 1):
|
|
528
|
+
ra = 0.0
|
|
529
|
+
cpl = None if bufs is None else bufs[cur]
|
|
530
|
+
if a is None:
|
|
531
|
+
fint, k, jmin = _assemble(stiff, v, mod, nu, material)
|
|
532
|
+
a_lin = None
|
|
533
|
+
else:
|
|
534
|
+
fint, k, jmin, a_lin, ra = _assemble(stiff, v, mod, nu, material, alpha=a, coupling=cpl)
|
|
535
|
+
if not np.isfinite(jmin) or jmin <= 0:
|
|
536
|
+
# the update turned a cell inside out: halve it (a damped
|
|
537
|
+
# Newton step), rather than give up the whole load step
|
|
538
|
+
if prev is None or cuts >= 8:
|
|
539
|
+
return None
|
|
540
|
+
v0, a0, d, c0 = prev
|
|
541
|
+
d = 0.5 * d
|
|
542
|
+
prev = v0, a0, d, c0
|
|
543
|
+
v, a = v0 + d, follow(a0, c0, d)
|
|
544
|
+
cuts += 1
|
|
545
|
+
continue
|
|
546
|
+
if spr is not None:
|
|
547
|
+
fint = fint + spr @ v
|
|
548
|
+
k = (k + spr).tocsr()
|
|
549
|
+
fext = target * f
|
|
550
|
+
for t in loads:
|
|
551
|
+
ft, kt, _ = t._load(v, target)
|
|
552
|
+
fext = fext + ft
|
|
553
|
+
# constrained rows and columns stay identity, as Stiffness leaves them
|
|
554
|
+
k = (k + keep_free @ kt @ keep_free).tocsr()
|
|
555
|
+
r = fext - fint
|
|
556
|
+
r[cons] = 0.0
|
|
557
|
+
ref = max(np.linalg.norm(fext), np.linalg.norm(fint[pd]) if len(pd) else 0.0, fscale)
|
|
558
|
+
rnorm = np.hypot(np.linalg.norm(r), ra)
|
|
559
|
+
if rnorm <= rtol * ref:
|
|
560
|
+
return v, its, fint, k, a_lin
|
|
561
|
+
if rnorm0 is None:
|
|
562
|
+
rnorm0 = rnorm
|
|
563
|
+
elif its > 6 and rnorm > rnorm0:
|
|
564
|
+
return None # diverging
|
|
565
|
+
if best is None or rnorm < 0.5 * best[0]:
|
|
566
|
+
best = rnorm, its
|
|
567
|
+
elif search and its - best[1] >= STALL_NEWTON:
|
|
568
|
+
return None # stalled (contact chatter): a shorter step is cheaper
|
|
569
|
+
try:
|
|
570
|
+
d = lin.solve(k if lin.direct else sym(k), r)
|
|
571
|
+
except RuntimeError:
|
|
572
|
+
return None
|
|
573
|
+
if not np.all(np.isfinite(d)):
|
|
574
|
+
return None
|
|
575
|
+
d[cons] = 0.0
|
|
576
|
+
big = np.abs(d).max()
|
|
577
|
+
if big > cap:
|
|
578
|
+
d *= cap / big
|
|
579
|
+
if search:
|
|
580
|
+
# contact switches on and off between iterations, and its
|
|
581
|
+
# tangent leaves out the faces' rotation, so Newton can
|
|
582
|
+
# cycle: on a retry, backtrack a step that makes the
|
|
583
|
+
# residual worse
|
|
584
|
+
for _ in range(5):
|
|
585
|
+
rt = trial(v + d, target, follow(a_lin, cpl, d) if a_lin is not None else None)
|
|
586
|
+
if rt is not None and rt < rnorm:
|
|
587
|
+
break
|
|
588
|
+
d *= 0.5
|
|
589
|
+
prev = v, a_lin, d, cpl
|
|
590
|
+
v, a = v + d, (follow(a_lin, cpl, d) if a_lin is not None else None)
|
|
591
|
+
cur = 1 - cur
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
def trial(v, target, a):
|
|
595
|
+
"""Residual norm at v without the tangent (None if a cell inverts)."""
|
|
596
|
+
if a is None:
|
|
597
|
+
fint, _, jmin = _assemble(stiff, v, mod, nu, material, tangent=False)
|
|
598
|
+
ra = 0.0
|
|
599
|
+
else:
|
|
600
|
+
fint, _, jmin, _, ra = _assemble(stiff, v, mod, nu, material, tangent=False, alpha=a)
|
|
601
|
+
if not np.isfinite(jmin) or jmin <= 0:
|
|
602
|
+
return None
|
|
603
|
+
if spr is not None:
|
|
604
|
+
fint = fint + spr @ v
|
|
605
|
+
fext = target * f
|
|
606
|
+
for t in loads:
|
|
607
|
+
fext = fext + t._load(v, target)[0]
|
|
608
|
+
r = fext - fint
|
|
609
|
+
r[cons] = 0.0
|
|
610
|
+
return float(np.hypot(np.linalg.norm(r), ra))
|
|
611
|
+
|
|
612
|
+
u = np.zeros(n)
|
|
613
|
+
lam, dlam = 0.0, min(1.0 / steps, max_step)
|
|
614
|
+
du_prev, dlam_prev = None, None
|
|
615
|
+
history = []
|
|
616
|
+
message = ""
|
|
617
|
+
touch_step = max(4 * min_step, 1.0 / 128)
|
|
618
|
+
while lam < 1.0 - 1e-12:
|
|
619
|
+
dlam = min(dlam, 1.0 - lam)
|
|
620
|
+
target = lam + dlam
|
|
621
|
+
# Predictor: extrapolate the last step's increment (the prescribed
|
|
622
|
+
# dofs move exactly by it).
|
|
623
|
+
v = u.copy()
|
|
624
|
+
if du_prev is not None:
|
|
625
|
+
v += du_prev * (dlam / dlam_prev)
|
|
626
|
+
v[pd] = target * pv
|
|
627
|
+
out = newton(v, target)
|
|
628
|
+
if out is None and contact:
|
|
629
|
+
out = newton(v, target, search=True)
|
|
630
|
+
if out is None:
|
|
631
|
+
if dlam <= min_step:
|
|
632
|
+
message = (f"stopped at {lam:.3f} of the load: no equilibrium past it under load control "
|
|
633
|
+
"(a limit point, i.e. buckling or snap-through, or the part folded). "
|
|
634
|
+
"Try a prescribed displacement instead of a force.")
|
|
635
|
+
break
|
|
636
|
+
dlam *= 0.5
|
|
637
|
+
continue
|
|
638
|
+
v, its, fint, k, a_conv = out
|
|
639
|
+
if asym and dlam > touch_step:
|
|
640
|
+
# friction: a contact that touches during a step measures its
|
|
641
|
+
# slip from the touch along a straight path through the step,
|
|
642
|
+
# which the part did not take (it moved rigidly up to the touch
|
|
643
|
+
# and deformed after; the stiff penalty turns the difference
|
|
644
|
+
# into a spurious stick force). When contacts carrying a good
|
|
645
|
+
# share of the load touched during the step, bisect towards the
|
|
646
|
+
# touch so the step that makes it is short.
|
|
647
|
+
for c in contact:
|
|
648
|
+
if getattr(c, "friction", 0.0) > 0:
|
|
649
|
+
c._load(v, target)
|
|
650
|
+
share = max((c._early[0] for c in contact if getattr(c, "friction", 0.0) > 0), default=0.0)
|
|
651
|
+
if share > 0.25:
|
|
652
|
+
dlam = max(0.5 * dlam, touch_step)
|
|
653
|
+
continue
|
|
654
|
+
rec = {"loadFactor": target, "newton": its}
|
|
655
|
+
if stability:
|
|
656
|
+
lmin, phi = lin.lowest_mode(stab(k, v, target))
|
|
657
|
+
rec["lowestEigenvalue"] = lmin
|
|
658
|
+
tol = 1e-9 * abs(k.diagonal()).max()
|
|
659
|
+
if lmin < -tol:
|
|
660
|
+
# An unstable equilibrium. First creep up on it: with any
|
|
661
|
+
# imperfection the stable path bends away smoothly and small
|
|
662
|
+
# steps follow it. At the bifurcation itself (a perfect
|
|
663
|
+
# part), kick along the unstable mode until Newton lands on
|
|
664
|
+
# a stable state. CG can't solve with an indefinite tangent,
|
|
665
|
+
# so on large meshes seed a small imperfection instead (a
|
|
666
|
+
# force along the unstable mode, 0.1% of the load) and
|
|
667
|
+
# redo the step from the last stable state.
|
|
668
|
+
if not lin.direct and not seeded:
|
|
669
|
+
scale = max(np.linalg.norm(f), np.linalg.norm(fint[pd]) / max(target, 1e-12) if len(pd) else 0.0)
|
|
670
|
+
f = f + 1e-3 * scale * np.where(cons, 0.0, phi) / np.linalg.norm(phi)
|
|
671
|
+
seeded = True
|
|
672
|
+
dlam *= 0.5
|
|
673
|
+
continue
|
|
674
|
+
if dlam > kick_step:
|
|
675
|
+
dlam *= 0.5
|
|
676
|
+
continue
|
|
677
|
+
phi = phi / np.abs(phi).max()
|
|
678
|
+
for amp in ((0.01, 0.03, 0.1, 0.3) if lin.direct else ()):
|
|
679
|
+
found = None
|
|
680
|
+
for sgn in (1.0, -1.0):
|
|
681
|
+
o = newton(v + sgn * amp * size * phi, target)
|
|
682
|
+
if o is not None:
|
|
683
|
+
l2 = lin.lowest_mode(stab(o[3], o[0], target))[0]
|
|
684
|
+
if l2 >= -tol:
|
|
685
|
+
found = o, l2
|
|
686
|
+
break
|
|
687
|
+
if found is not None:
|
|
688
|
+
(v, its2, fint, k, a_conv), l2 = found
|
|
689
|
+
rec["newton"] += its2
|
|
690
|
+
rec["branchSwitch"] = True
|
|
691
|
+
rec["lowestEigenvalue"] = l2
|
|
692
|
+
break
|
|
693
|
+
else:
|
|
694
|
+
rec["unstable"] = True
|
|
695
|
+
du_prev, dlam_prev = (None if rec.get("branchSwitch") else v - u), dlam
|
|
696
|
+
u, lam = v, target
|
|
697
|
+
alpha = a_conv
|
|
698
|
+
for name, wv in probes.items():
|
|
699
|
+
rec[name] = float(wv @ u)
|
|
700
|
+
if len(pd):
|
|
701
|
+
rec["reaction"] = fint.reshape(-1, 3)[pnodes].sum(axis=0).tolist()
|
|
702
|
+
for i, t in enumerate(tendons):
|
|
703
|
+
rec[f"tendon{i}Travel"] = t._len0 - t._load(u, lam)[2]
|
|
704
|
+
if hasattr(t, "pull"):
|
|
705
|
+
rec[f"tendon{i}Tension"] = float(t.pull(u, lam)[0])
|
|
706
|
+
for i, c in enumerate(contact):
|
|
707
|
+
info = c._load(u, lam)[2]
|
|
708
|
+
rec[f"contact{i}"] = {k: v for k, v in info.items()}
|
|
709
|
+
if hasattr(c, "_commit"):
|
|
710
|
+
c._commit(u, lam) # friction: where each contact sticks from here on
|
|
711
|
+
history.append(rec)
|
|
712
|
+
if on_step is not None:
|
|
713
|
+
on_step(rec, u)
|
|
714
|
+
if rec["newton"] <= GROW_NEWTON:
|
|
715
|
+
dlam = min(dlam * 1.5, max_step)
|
|
716
|
+
vm, strain = _post(mesh, u, mod, nu, material)
|
|
717
|
+
done = lam >= 1.0 - 1e-12
|
|
718
|
+
if done:
|
|
719
|
+
message = ""
|
|
720
|
+
if seeded:
|
|
721
|
+
message = (message + " " if message else "") + \
|
|
722
|
+
"The part went unstable (buckled); a 0.1% imperfection along the buckling mode was added to follow it."
|
|
723
|
+
return NonlinearResult(u=u, load_factor=lam, converged=done, history=history, stress=vm, strain=strain,
|
|
724
|
+
message=message)
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
@dataclass
|
|
728
|
+
class BucklingResult:
|
|
729
|
+
factors: np.ndarray # load multipliers that buckle the part, smallest first
|
|
730
|
+
modes: np.ndarray # (n_modes, n_dof), each scaled to a largest nodal travel of 1
|
|
731
|
+
u: np.ndarray # the linear displacement under the reference load
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _geometric(stiff, u, mod, nu):
|
|
735
|
+
"""Geometric stiffness of the linear stresses in displacement u (fixed
|
|
736
|
+
dofs cleared, zero on their diagonal). With the incompatible-mode
|
|
737
|
+
element the bubbles carry their linear amplitudes (R u) both in the
|
|
738
|
+
stresses and in the mode shapes: Kg = T^T Kg_ext T, T = [I; R]."""
|
|
739
|
+
mesh = stiff.mesh
|
|
740
|
+
h, w = mesh.pitch, mesh.pitch ** 3 / 8.0
|
|
741
|
+
lam, mu = _lame(nu)
|
|
742
|
+
im = getattr(stiff, "element", "plain") == "im"
|
|
743
|
+
grads, wmat, n = (_GAUSS_IM, _W_IM, 11) if im else (_GAUSS, _W, 8)
|
|
744
|
+
if im:
|
|
745
|
+
r = _hex8_parts(nu, "im")[1] # (9, 24): amplitudes 3*mode + component
|
|
746
|
+
t = np.vstack([np.eye(24), r]) # (33, 24)
|
|
747
|
+
data = np.zeros(stiff.nnz)
|
|
748
|
+
for s in range(0, mesh.n_elem, _CHUNK):
|
|
749
|
+
el = np.arange(s, min(s + _CHUNK, mesh.n_elem))
|
|
750
|
+
m = len(el)
|
|
751
|
+
ue = u[mesh.edof[el]].reshape(m, 24)
|
|
752
|
+
ux = (ue @ t.T if im else ue).reshape(m, n, 3)
|
|
753
|
+
kq = np.zeros((m, n * n))
|
|
754
|
+
for g, dn0 in enumerate(grads):
|
|
755
|
+
grad = np.einsum("mai,aj->mij", ux, dn0 / h)
|
|
756
|
+
eps = 0.5 * (grad + grad.transpose(0, 2, 1))
|
|
757
|
+
sig = (lam * np.trace(eps, axis1=1, axis2=2)[:, None, None] * _I3 + 2 * mu * eps) * mod[el, None, None]
|
|
758
|
+
kq += (w / h ** 2) * (sig.reshape(m, 9) @ wmat[g])
|
|
759
|
+
kg = np.einsum("mab,ij->maibj", kq.reshape(m, n, n), _I3).reshape(m, 3 * n, 3 * n)
|
|
760
|
+
if im:
|
|
761
|
+
kg = t.T @ kg @ t
|
|
762
|
+
stiff.add_elements(data, kg, el)
|
|
763
|
+
return stiff.finish(data, diag=0.0)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def linear_buckling(mesh: HexMesh, nu: float, modulus, fixed: np.ndarray, force,
|
|
767
|
+
n_modes: int = 3, springs=None, element: str | None = None,
|
|
768
|
+
direct_max_dof: int | None = None, tol: float = 1e-8):
|
|
769
|
+
"""Smallest positive load factors lambda at which lambda * force buckles
|
|
770
|
+
the part (linear eigenvalue buckling: exact for a perfect column, an
|
|
771
|
+
upper bound for real parts with imperfections; take a margin of 2-3 on
|
|
772
|
+
printed slender members). Below 1, the load as given buckles it.
|
|
773
|
+
`springs` is a per-dof diagonal or a sparse matrix (absolute stiffness).
|
|
774
|
+
`direct_max_dof` (default DIRECT_MAX_DOF) is the size up to which K is
|
|
775
|
+
factored directly: the eigensolver solves with K every iteration, and a
|
|
776
|
+
factorization pays for itself many times over (a 21k-cell bracket: 17 s
|
|
777
|
+
direct, 144 s CG + AMG). A list of forces returns a list of results,
|
|
778
|
+
all on the one factorization. `tol` is the eigensolver's relative
|
|
779
|
+
tolerance."""
|
|
780
|
+
mod = np.broadcast_to(np.asarray(modulus, float), (mesh.n_elem,)).copy()
|
|
781
|
+
# solve_nonlinear's element (incompatible modes by default), so the load
|
|
782
|
+
# factor and the post-buckling path it seeds are one model's; the plain
|
|
783
|
+
# hex8 shear-locks, which put the load of a column one / two cells thick
|
|
784
|
+
# 54% / 14% above Euler's (incompatible modes: within 0.3%)
|
|
785
|
+
stiff = Stiffness(mesh, nu, fixed, element=element)
|
|
786
|
+
k = stiff.assemble(mod)
|
|
787
|
+
if springs is not None:
|
|
788
|
+
if sp.issparse(springs):
|
|
789
|
+
free = sp.diags((~np.asarray(fixed, bool)).astype(float))
|
|
790
|
+
k = (k + free @ springs @ free).tocsr()
|
|
791
|
+
else:
|
|
792
|
+
k = (k + sp.diags(np.where(fixed, 0.0, springs))).tocsr()
|
|
793
|
+
if mesh.n_dof <= (DIRECT_MAX_DOF if direct_max_dof is None else direct_max_dof):
|
|
794
|
+
kinv = _splu(k).solve
|
|
795
|
+
else:
|
|
796
|
+
sol = Solver(mesh, rtol=1e-10, maxiter=3000)
|
|
797
|
+
kinv = lambda r: sol.solve(k, r) # noqa: E731
|
|
798
|
+
n = mesh.n_dof
|
|
799
|
+
nm = min(n_modes, n - 2)
|
|
800
|
+
minv = LinearOperator((n, n), matvec=kinv, dtype=float)
|
|
801
|
+
many = isinstance(force, (list, tuple))
|
|
802
|
+
out = []
|
|
803
|
+
for fv in (force if many else [force]):
|
|
804
|
+
f = np.asarray(fv, float).copy()
|
|
805
|
+
f[np.asarray(fixed, bool)] = 0.0
|
|
806
|
+
u = kinv(f)
|
|
807
|
+
kg = _geometric(stiff, u, mod, nu)
|
|
808
|
+
# K phi = lambda (-Kg) phi <=> (-Kg) phi = (1 / lambda) K phi: the
|
|
809
|
+
# largest positive 1/lambda are the smallest positive buckling loads.
|
|
810
|
+
mu, vec = eigsh(-kg, k=nm, M=k, Minv=minv, which="LA", tol=tol)
|
|
811
|
+
order = np.argsort(-mu)
|
|
812
|
+
mu, vec = mu[order], vec[:, order]
|
|
813
|
+
pos = mu > 1e-12
|
|
814
|
+
factors = np.full(len(mu), np.inf)
|
|
815
|
+
factors[pos] = 1.0 / mu[pos]
|
|
816
|
+
modes = vec.T.copy()
|
|
817
|
+
for m in modes:
|
|
818
|
+
big = np.abs(m.reshape(-1, 3)).max() or 1.0
|
|
819
|
+
m /= big
|
|
820
|
+
out.append(BucklingResult(factors=factors, modes=modes, u=u))
|
|
821
|
+
return out if many else out[0]
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def solid_submesh(mesh: HexMesh, keep: np.ndarray, fixed: np.ndarray, vectors: list):
|
|
825
|
+
"""The mesh of elements `keep`, minus any piece not connected to a
|
|
826
|
+
support (it would float). Returns (submesh, element index into the old
|
|
827
|
+
mesh, fixed, vectors) with the per-dof arrays carried over to the new
|
|
828
|
+
nodes; load on nodes that fell away is dropped."""
|
|
829
|
+
from scipy.sparse.csgraph import connected_components
|
|
830
|
+
el = np.nonzero(keep)[0]
|
|
831
|
+
sub = HexMesh(mesh.elem_ijk[el], mesh.pitch, mesh.origin)
|
|
832
|
+
old = mesh.node_id(sub.node_ijk)
|
|
833
|
+
fx = np.asarray(fixed, bool).reshape(-1, 3)[old]
|
|
834
|
+
# elements joined through shared nodes
|
|
835
|
+
ne = sub.n_elem
|
|
836
|
+
inc = sp.csr_matrix((np.ones(ne * 8), (np.repeat(np.arange(ne), 8), sub.enode.ravel())), shape=(ne, sub.n_nodes))
|
|
837
|
+
_, lab = connected_components(inc @ inc.T, directed=False)
|
|
838
|
+
# a piece is held only if its supports restrain all three directions: a
|
|
839
|
+
# plate's symmetry plane (one direction) does not stop it floating
|
|
840
|
+
held = np.zeros((lab.max() + 1, 3), bool)
|
|
841
|
+
np.logical_or.at(held, lab, fx[sub.enode].any(axis=1))
|
|
842
|
+
hold = held[lab].all(axis=1)
|
|
843
|
+
if not hold.all():
|
|
844
|
+
el = el[hold]
|
|
845
|
+
sub = HexMesh(mesh.elem_ijk[el], mesh.pitch, mesh.origin)
|
|
846
|
+
old = mesh.node_id(sub.node_ijk)
|
|
847
|
+
fx = np.asarray(fixed, bool).reshape(-1, 3)[old]
|
|
848
|
+
out = [np.asarray(v, float).reshape(-1, 3)[old].ravel() for v in vectors]
|
|
849
|
+
return sub, el, fx.ravel(), out
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def verify_mechanism(domain, xphys: np.ndarray, modulus: float, nu: float, input_case: int = 0,
|
|
853
|
+
output_case: int = 1, threshold: float = 0.5, input_scale: float = 1.0,
|
|
854
|
+
steps: int = 10, buckling: bool = True, material: str = "neo-hookean",
|
|
855
|
+
k_in: float = 0.0, k_out: float = 0.0) -> dict:
|
|
856
|
+
"""Check a compliant mechanism design (mechanism.optimize_mechanism) with
|
|
857
|
+
large deflections: the solid cells (xphys > threshold) under the input
|
|
858
|
+
case's force, output travel along the output case's direction, compared
|
|
859
|
+
with the linear answer on the same solid mesh. With buckling, also the
|
|
860
|
+
load factor at which the input force buckles the design.
|
|
861
|
+
|
|
862
|
+
k_in, k_out: the optimization's springs (N/mm) at the input and output.
|
|
863
|
+
Leave them at 0 for the free stroke under the actuator's force (a finger
|
|
864
|
+
before it touches anything); give the optimization's values to check
|
|
865
|
+
the case it was designed for.
|
|
866
|
+
"""
|
|
867
|
+
from .mechanism import _spring_diag
|
|
868
|
+
mesh = domain.mesh
|
|
869
|
+
fin = domain.loads[input_case] * input_scale
|
|
870
|
+
lout = domain.loads[output_case]
|
|
871
|
+
keep = np.asarray(xphys) > threshold
|
|
872
|
+
sub, el, fx, (f, lo) = solid_submesh(mesh, keep, domain.fixed, [fin, lout])
|
|
873
|
+
dirn = lo.reshape(-1, 3).sum(axis=0)
|
|
874
|
+
nrm = np.linalg.norm(dirn)
|
|
875
|
+
if nrm == 0:
|
|
876
|
+
raise ValueError("the output region has no solid cells under it")
|
|
877
|
+
wts = np.abs(lo.reshape(-1, 3)).sum(axis=1) > 0
|
|
878
|
+
lw = (wts[:, None] * (dirn / nrm)[None, :]).ravel() / max(wts.sum(), 1)
|
|
879
|
+
|
|
880
|
+
report = {"elements": int(sub.n_elem), "inputForceKept": float(np.linalg.norm(f.reshape(-1, 3).sum(axis=0))
|
|
881
|
+
/ max(np.linalg.norm(fin.reshape(-1, 3).sum(axis=0)), 1e-30))}
|
|
882
|
+
sd = _spring_diag(f, k_in, sub.n_dof) + _spring_diag(lo, k_out, sub.n_dof)
|
|
883
|
+
sd[fx] = 0.0
|
|
884
|
+
# the linear answer on the nonlinear element's own small-strain limit
|
|
885
|
+
stiff = Stiffness(sub, nu, fx)
|
|
886
|
+
k = (stiff.assemble(np.full(sub.n_elem, modulus)) + sp.diags(sd)).tocsr()
|
|
887
|
+
ul = _Linear(sub).solve(k, np.where(fx, 0.0, f))
|
|
888
|
+
report["linearOutput"] = float(lw @ ul)
|
|
889
|
+
r = solve_nonlinear(sub, nu, modulus, fx, f, material=material, steps=steps, springs=sd, probes={"output": lw})
|
|
890
|
+
report["nonlinearOutput"] = float(lw @ r.u)
|
|
891
|
+
report["loadFactorReached"] = r.load_factor
|
|
892
|
+
report["converged"] = r.converged
|
|
893
|
+
report["maxStress"] = float(r.stress.max())
|
|
894
|
+
report["maxStrain"] = float(r.strain.max())
|
|
895
|
+
report["history"] = r.history
|
|
896
|
+
if r.message:
|
|
897
|
+
report["message"] = r.message
|
|
898
|
+
if report["linearOutput"]:
|
|
899
|
+
report["nonlinearOverLinear"] = report["nonlinearOutput"] / report["linearOutput"]
|
|
900
|
+
if buckling:
|
|
901
|
+
b = linear_buckling(sub, nu, modulus, fx, f, n_modes=1, springs=sd)
|
|
902
|
+
report["bucklingFactor"] = float(b.factors[0])
|
|
903
|
+
report["u"] = r.u
|
|
904
|
+
report["elementIndex"] = el
|
|
905
|
+
return report
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def stroke_mechanism(domain, xphys: np.ndarray, modulus: float, nu: float, stroke: float, input_case: int = 0,
|
|
909
|
+
output_case: int = 1, k_out: float = 0.0, threshold: float = 0.5, steps: int = 10,
|
|
910
|
+
material: str = "neo-hookean") -> dict:
|
|
911
|
+
"""Drive a compliant mechanism the way a servo or a lead screw does: move
|
|
912
|
+
the input region `stroke` mm along the input force's direction
|
|
913
|
+
(prescribed, so a snap-through cannot run away) and follow the output
|
|
914
|
+
with large deflections. k_out (N/mm): the workpiece, a grounded spring
|
|
915
|
+
on the output; 0 for the free stroke.
|
|
916
|
+
|
|
917
|
+
Returns the output's travel along its direction (nonlinear and linear),
|
|
918
|
+
the actuator force it took (N, along the stroke), the grip force on the
|
|
919
|
+
workpiece (k_out x travel), the geometric advantage (output travel /
|
|
920
|
+
stroke) and mechanical advantage (grip force / actuator force), the
|
|
921
|
+
peak stress and strain, and the per-step history."""
|
|
922
|
+
from .mechanism import _spring_diag
|
|
923
|
+
mesh = domain.mesh
|
|
924
|
+
keep = np.asarray(xphys) > threshold
|
|
925
|
+
sub, el, fx, (f, lo) = solid_submesh(mesh, keep, domain.fixed, [domain.loads[input_case],
|
|
926
|
+
domain.loads[output_case]])
|
|
927
|
+
fv = f.reshape(-1, 3)
|
|
928
|
+
loaded = np.flatnonzero(np.abs(fv).sum(axis=1) > 0)
|
|
929
|
+
if len(loaded) == 0:
|
|
930
|
+
raise ValueError("the input region has no solid cells under it")
|
|
931
|
+
din = fv.sum(axis=0)
|
|
932
|
+
din /= np.linalg.norm(din)
|
|
933
|
+
pd, pv = [], []
|
|
934
|
+
for c in range(3):
|
|
935
|
+
if abs(din[c]) > 1e-9:
|
|
936
|
+
dofs = 3 * loaded + c
|
|
937
|
+
dofs = dofs[~fx[dofs]]
|
|
938
|
+
pd.append(dofs)
|
|
939
|
+
pv.append(np.full(len(dofs), stroke * din[c]))
|
|
940
|
+
pd, pv = np.concatenate(pd), np.concatenate(pv)
|
|
941
|
+
dirn = lo.reshape(-1, 3).sum(axis=0)
|
|
942
|
+
nrm = np.linalg.norm(dirn)
|
|
943
|
+
if nrm == 0:
|
|
944
|
+
raise ValueError("the output region has no solid cells under it")
|
|
945
|
+
wts = np.abs(lo.reshape(-1, 3)).sum(axis=1) > 0
|
|
946
|
+
lw = (wts[:, None] * (dirn / nrm)[None, :]).ravel() / max(wts.sum(), 1)
|
|
947
|
+
sd = _spring_diag(lo, k_out, sub.n_dof)
|
|
948
|
+
sd[fx] = 0.0
|
|
949
|
+
# linear answer on the same mesh: K u = 0 with the input moved
|
|
950
|
+
cons = fx.copy()
|
|
951
|
+
cons[pd] = True
|
|
952
|
+
stiff = Stiffness(sub, nu, cons)
|
|
953
|
+
k_free = Stiffness(sub, nu, np.zeros(sub.n_dof, bool)).assemble(np.full(sub.n_elem, modulus))
|
|
954
|
+
kc = (stiff.assemble(np.full(sub.n_elem, modulus)) + sp.diags(np.where(cons, 0.0, sd))).tocsr()
|
|
955
|
+
up = np.zeros(sub.n_dof)
|
|
956
|
+
up[pd] = pv
|
|
957
|
+
rhs = np.where(cons, up, -(k_free @ up))
|
|
958
|
+
ul = _Linear(sub).solve(kc, rhs)
|
|
959
|
+
lin_force = float(((k_free @ ul).reshape(-1, 3)[np.unique(pd // 3)].sum(axis=0)) @ din)
|
|
960
|
+
r = solve_nonlinear(sub, nu, modulus, fx, None, prescribed=(pd, pv), material=material, steps=steps,
|
|
961
|
+
springs=sd, probes={"output": lw})
|
|
962
|
+
out_nl = float(lw @ r.u)
|
|
963
|
+
force = float(np.asarray(r.history[-1]["reaction"]) @ din) if r.history else float("nan")
|
|
964
|
+
rep = {"elements": int(sub.n_elem), "stroke": stroke, "kOut": k_out, "converged": r.converged,
|
|
965
|
+
"strokeReached": r.load_factor * stroke,
|
|
966
|
+
"linearOutput": float(lw @ ul), "output": out_nl, "linearActuatorForce": lin_force,
|
|
967
|
+
"actuatorForce": force, "geometricAdvantage": out_nl / (r.load_factor * stroke) if r.load_factor else 0.0,
|
|
968
|
+
"maxStress": float(r.stress.max()), "maxStrain": float(r.strain.max()),
|
|
969
|
+
"history": [{"stroke": h["loadFactor"] * stroke, "output": h.get("output"),
|
|
970
|
+
"actuatorForce": float(np.asarray(h["reaction"]) @ din) if "reaction" in h else None,
|
|
971
|
+
**({"branchSwitch": True} if h.get("branchSwitch") else {})} for h in r.history]}
|
|
972
|
+
if k_out > 0:
|
|
973
|
+
rep["gripForce"] = k_out * out_nl
|
|
974
|
+
rep["mechanicalAdvantage"] = rep["gripForce"] / force if force else None
|
|
975
|
+
if r.message:
|
|
976
|
+
rep["message"] = r.message
|
|
977
|
+
return rep
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def large_deflection_report(graph, spec: dict, material) -> dict:
|
|
981
|
+
"""The field graph's `largeDeflection` block: the `part` (a solid node,
|
|
982
|
+
default the graph's output) voxelized at `pitch`, clamped inside
|
|
983
|
+
`fixed`, under dead `loads`, prescribed `displacements` (an actuator's
|
|
984
|
+
stroke), `tendons` (cables through guides at a tension, or wound in by
|
|
985
|
+
a servo: stroke and stiffness) and `contact` (rigid obstacles, fixed or
|
|
986
|
+
moving, and the part on itself), solved with large deflections.
|
|
987
|
+
|
|
988
|
+
Reports how far the load got, peak strain and stress, each probe's
|
|
989
|
+
travel, each tendon's travel and tension, each contact's force, nodes
|
|
990
|
+
touching and penetration, and the step history."""
|
|
991
|
+
from .contact import SelfContact, obstacle_from_spec
|
|
992
|
+
from .impact import sample_core
|
|
993
|
+
from .mechanism_nl import ServoTendon
|
|
994
|
+
|
|
995
|
+
part = spec.get("part") or graph.output
|
|
996
|
+
pitch = float(spec.get("pitch", 1.0))
|
|
997
|
+
max_cells = int(spec.get("maxCells", 20000))
|
|
998
|
+
b = graph.bounds(part)
|
|
999
|
+
if b is None:
|
|
1000
|
+
raise ValueError(f"largeDeflection: node {part!r} has no bounds")
|
|
1001
|
+
for _ in range(8):
|
|
1002
|
+
inside, _, lo, h = sample_core(graph, part, None, pitch, bounds=b)
|
|
1003
|
+
if inside.sum() <= max_cells:
|
|
1004
|
+
break
|
|
1005
|
+
pitch *= 1.05 * (inside.sum() / max_cells) ** (1 / 3)
|
|
1006
|
+
ijk = np.argwhere(inside)
|
|
1007
|
+
if len(ijk) == 0:
|
|
1008
|
+
raise ValueError(f"largeDeflection: no cells inside {part!r} at pitch {pitch:g}")
|
|
1009
|
+
mesh = HexMesh(ijk, h, lo)
|
|
1010
|
+
|
|
1011
|
+
def nodes_in(node, what):
|
|
1012
|
+
ids = np.flatnonzero(graph.evaluate(mesh.node_xyz, node) <= 0.5 * h)
|
|
1013
|
+
if len(ids) == 0:
|
|
1014
|
+
raise ValueError(f"largeDeflection: no nodes inside {what} {node!r}")
|
|
1015
|
+
return ids
|
|
1016
|
+
|
|
1017
|
+
def where(item, what):
|
|
1018
|
+
if "region" in item:
|
|
1019
|
+
return nodes_in(item["region"], what)
|
|
1020
|
+
if "at" in item:
|
|
1021
|
+
return nodes_near(mesh, item["at"], float(item.get("radius", 1.5 * h)))
|
|
1022
|
+
raise ValueError(f"largeDeflection: {what} needs a region (node) or at (point)")
|
|
1023
|
+
|
|
1024
|
+
fixed = np.zeros(mesh.n_dof, bool)
|
|
1025
|
+
fn = nodes_in(spec["fixed"], "fixed")
|
|
1026
|
+
for a in range(3):
|
|
1027
|
+
fixed[3 * fn + a] = True
|
|
1028
|
+
force = np.zeros(mesh.n_dof)
|
|
1029
|
+
for ld in spec.get("loads", []):
|
|
1030
|
+
ids = where(ld, "load")
|
|
1031
|
+
force[(3 * ids[:, None] + np.arange(3)).ravel()] += np.tile(np.asarray(ld["force"], float) / len(ids), len(ids))
|
|
1032
|
+
pd, pv = [], []
|
|
1033
|
+
for dsp in spec.get("displacements", []):
|
|
1034
|
+
ids = where(dsp, "displacement")
|
|
1035
|
+
mv = np.asarray(dsp["move"], float)
|
|
1036
|
+
for c in range(3):
|
|
1037
|
+
if dsp.get("free") and abs(mv[c]) < 1e-12:
|
|
1038
|
+
continue
|
|
1039
|
+
dofs = 3 * ids + c
|
|
1040
|
+
dofs = dofs[~fixed[dofs]]
|
|
1041
|
+
pd.append(dofs)
|
|
1042
|
+
pv.append(np.full(len(dofs), mv[c]))
|
|
1043
|
+
prescribed = (np.concatenate(pd), np.concatenate(pv)) if pd else None
|
|
1044
|
+
tendons = []
|
|
1045
|
+
for t in spec.get("tendons", []):
|
|
1046
|
+
path = []
|
|
1047
|
+
for pt in t["path"]:
|
|
1048
|
+
path.append(where(pt, "tendon guide") if isinstance(pt, dict) else np.asarray(pt, float))
|
|
1049
|
+
if "stroke" in t:
|
|
1050
|
+
tendons.append(ServoTendon(path, float(t["stroke"]), float(t["stiffness"])))
|
|
1051
|
+
else:
|
|
1052
|
+
tendons.append(Tendon(float(t["tension"]), path))
|
|
1053
|
+
contact = []
|
|
1054
|
+
cs = spec.get("contact") or {}
|
|
1055
|
+
stiff = {k: cs[k] for k in ("stiffness", "friction") if k in cs}
|
|
1056
|
+
for ob in cs.get("obstacles", []):
|
|
1057
|
+
contact.append(obstacle_from_spec({**stiff, **ob}))
|
|
1058
|
+
if cs.get("self"):
|
|
1059
|
+
contact.append(SelfContact(**stiff))
|
|
1060
|
+
probes = {}
|
|
1061
|
+
for p in spec.get("probes", []):
|
|
1062
|
+
ids = where(p, "probe")
|
|
1063
|
+
d = np.asarray(p["direction"], float)
|
|
1064
|
+
d = d / np.linalg.norm(d)
|
|
1065
|
+
w = np.zeros(mesh.n_dof)
|
|
1066
|
+
for c in range(3):
|
|
1067
|
+
w[3 * ids + c] = d[c] / len(ids)
|
|
1068
|
+
probes[p.get("name") or f"probe{len(probes)}"] = w
|
|
1069
|
+
E, nu = float(material.E), float(spec.get("nu", material.nu))
|
|
1070
|
+
r = solve_nonlinear(mesh, nu, E, fixed, force if force.any() else None, prescribed,
|
|
1071
|
+
material=spec.get("model", "neo-hookean"), steps=int(spec.get("steps", 10)),
|
|
1072
|
+
tendons=tendons, contact=contact, probes=probes)
|
|
1073
|
+
last = r.history[-1] if r.history else {}
|
|
1074
|
+
out = {"cells": int(mesh.n_elem), "pitch": h, "converged": r.converged, "loadReached": r.load_factor,
|
|
1075
|
+
"maxStrain": float(r.strain.max()), "maxStress": float(r.stress.max()),
|
|
1076
|
+
"maxDisplacement": float(np.linalg.norm(r.u.reshape(-1, 3), axis=1).max())}
|
|
1077
|
+
if probes:
|
|
1078
|
+
out["probes"] = {k: last.get(k) for k in probes}
|
|
1079
|
+
if tendons:
|
|
1080
|
+
out["tendons"] = [{"travel": last.get(f"tendon{i}Travel"),
|
|
1081
|
+
"tension": last.get(f"tendon{i}Tension", r.load_factor * getattr(t, "tension", 0.0))}
|
|
1082
|
+
for i, t in enumerate(tendons)]
|
|
1083
|
+
if contact:
|
|
1084
|
+
names = [getattr(c, "name", None) or (c.shape if hasattr(c, "shape") else "self") for c in contact]
|
|
1085
|
+
out["contact"] = [{"name": nm, **(last.get(f"contact{i}") or {})} for i, nm in enumerate(names)]
|
|
1086
|
+
if prescribed is not None and "reaction" in last:
|
|
1087
|
+
out["actuatorForce"] = last["reaction"]
|
|
1088
|
+
if spec.get("maxStrain") is not None:
|
|
1089
|
+
out["ok"] = bool(r.converged and out["maxStrain"] <= float(spec["maxStrain"]))
|
|
1090
|
+
if r.message:
|
|
1091
|
+
out["message"] = r.message
|
|
1092
|
+
keep = ("loadFactor", "newton", "branchSwitch", *probes)
|
|
1093
|
+
out["history"] = [{**{k: h_[k] for k in keep if k in h_},
|
|
1094
|
+
**{k: v for k, v in h_.items() if k.startswith(("tendon", "contact"))}} for h_ in r.history]
|
|
1095
|
+
return out
|