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/fem.py
ADDED
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Linear elastic finite elements on a voxel grid: 8-node hexahedra, all the
|
|
3
|
+
same cube, so one element stiffness matrix serves every element and a
|
|
4
|
+
density (or a material modulus) just scales it.
|
|
5
|
+
|
|
6
|
+
Two things make this fast enough to sit inside an optimization loop that
|
|
7
|
+
solves it a hundred times:
|
|
8
|
+
|
|
9
|
+
- Assembly never sorts. The grid is structured, so every stiffness entry
|
|
10
|
+
lands in a CSR slot we can compute up front from (node, neighbor offset).
|
|
11
|
+
Re-assembling for new densities is 64 vectorized scatter-adds.
|
|
12
|
+
- The solve is conjugate gradient preconditioned by smoothed-aggregation
|
|
13
|
+
algebraic multigrid (pyamg) with the six rigid-body modes as the near
|
|
14
|
+
null space. The hierarchy is expensive to build and densities change
|
|
15
|
+
slowly, so callers reuse it across iterations (`Solver`).
|
|
16
|
+
|
|
17
|
+
Units are whatever the caller uses consistently; loadpath uses mm, N, MPa.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import scipy.sparse as sp
|
|
25
|
+
from scipy.sparse.linalg import cg
|
|
26
|
+
|
|
27
|
+
__all__ = ["Layered", "amg_solver", "elastic_c", "hex8_ke", "hex8_b", "hex8_element", "hex8_b_center", "hex8_fth", "thermal_load", "HexMesh", "Stiffness", "Solver", "von_mises"]
|
|
28
|
+
|
|
29
|
+
# Local corner order: bit 0 = +x, bit 1 = +y, bit 2 = +z.
|
|
30
|
+
CORNERS = np.array([[(c >> 0) & 1, (c >> 1) & 1, (c >> 2) & 1] for c in range(8)])
|
|
31
|
+
# The 27 neighbor offsets, lexicographic in (dx, dy, dz). With nodes numbered
|
|
32
|
+
# in C order over (i, j, k), that is also ascending node index, so CSR rows
|
|
33
|
+
# come out with sorted column indices.
|
|
34
|
+
OFFSETS = np.array([[dx, dy, dz] for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1)])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _offset_index(d):
|
|
38
|
+
return (d[..., 0] + 1) * 9 + (d[..., 1] + 1) * 3 + (d[..., 2] + 1)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_VOIGT = ((0, 0), (1, 1), (2, 2), (0, 1), (1, 2), (2, 0))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Layered:
|
|
45
|
+
"""Transversely isotropic elasticity for a part built in layers: the
|
|
46
|
+
same in every direction within a layer, softer across layers. Stands in
|
|
47
|
+
for a Poisson's ratio wherever one is passed (Stiffness, hex8_ke,
|
|
48
|
+
elastic_c), so a layered material threads through the solver unchanged.
|
|
49
|
+
|
|
50
|
+
nu: in-plane Poisson's ratio; ez: modulus across layers / in-plane
|
|
51
|
+
modulus; gz: shear modulus between layers / in-plane E/(2(1+nu));
|
|
52
|
+
normal: the build direction (unit vector, the axis layers stack along).
|
|
53
|
+
The Poisson's ratio of in-plane stress onto the layer normal is taken as
|
|
54
|
+
nu, so nu_zp = nu * ez, which keeps C positive definite for ez in (0, 1]
|
|
55
|
+
and nu < 0.5."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, nu: float, ez: float = 1.0, gz: float = 1.0, normal=(0.0, 0.0, 1.0)):
|
|
58
|
+
n = np.asarray(normal, float)
|
|
59
|
+
if not (0.0 < ez <= 1.0 and 0.0 < gz <= 1.0):
|
|
60
|
+
raise ValueError("Layered: ez and gz are fractions in (0, 1]")
|
|
61
|
+
self.nu = float(nu)
|
|
62
|
+
self.ez = float(ez)
|
|
63
|
+
self.gz = float(gz)
|
|
64
|
+
self.normal = tuple(n / np.linalg.norm(n))
|
|
65
|
+
|
|
66
|
+
def __repr__(self):
|
|
67
|
+
return f"Layered(nu={self.nu}, ez={self.ez}, gz={self.gz}, normal={self.normal})"
|
|
68
|
+
|
|
69
|
+
def __float__(self):
|
|
70
|
+
# code that only wants a Poisson's ratio (tet cross-check decks) gets one
|
|
71
|
+
return self.nu
|
|
72
|
+
|
|
73
|
+
def c(self) -> np.ndarray:
|
|
74
|
+
"""6x6 at unit in-plane modulus, in the solver's frame."""
|
|
75
|
+
nu, ez = self.nu, self.ez
|
|
76
|
+
g = 1.0 / (2.0 * (1.0 + nu))
|
|
77
|
+
s = np.zeros((6, 6))
|
|
78
|
+
s[0, 0] = s[1, 1] = 1.0
|
|
79
|
+
s[0, 1] = s[1, 0] = -nu
|
|
80
|
+
s[0, 2] = s[2, 0] = s[1, 2] = s[2, 1] = -nu
|
|
81
|
+
s[2, 2] = 1.0 / ez
|
|
82
|
+
s[3, 3] = 1.0 / g
|
|
83
|
+
s[4, 4] = s[5, 5] = 1.0 / (self.gz * g)
|
|
84
|
+
local = np.linalg.inv(s)
|
|
85
|
+
# rotate from the frame whose z is the layer normal
|
|
86
|
+
z = np.asarray(self.normal)
|
|
87
|
+
a = np.array([1.0, 0.0, 0.0]) if abs(z[0]) < 0.9 else np.array([0.0, 1.0, 0.0])
|
|
88
|
+
x = np.cross(a, z)
|
|
89
|
+
x /= np.linalg.norm(x)
|
|
90
|
+
rot = np.stack([x, np.cross(z, x), z], axis=1) # columns: local axes in solver frame
|
|
91
|
+
return _rotate_c(local, rot)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _to_tensor(c):
|
|
95
|
+
t = np.zeros((3, 3, 3, 3))
|
|
96
|
+
for a, (i, j) in enumerate(_VOIGT):
|
|
97
|
+
for b, (k, l) in enumerate(_VOIGT):
|
|
98
|
+
for (p, q) in {(i, j), (j, i)}:
|
|
99
|
+
for (r, s_) in {(k, l), (l, k)}:
|
|
100
|
+
t[p, q, r, s_] = c[a, b]
|
|
101
|
+
return t
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _rotate_c(c, rot):
|
|
105
|
+
t = np.einsum("pi,qj,rk,sl,ijkl->pqrs", rot, rot, rot, rot, _to_tensor(c))
|
|
106
|
+
out = np.empty((6, 6))
|
|
107
|
+
for a, (i, j) in enumerate(_VOIGT):
|
|
108
|
+
for b, (k, l) in enumerate(_VOIGT):
|
|
109
|
+
out[a, b] = t[i, j, k, l]
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def elastic_c(e: float, nu) -> np.ndarray:
|
|
114
|
+
"""Elasticity matrix (6x6, engineering shear strains): isotropic for a
|
|
115
|
+
Poisson's ratio, layered for a `Layered`."""
|
|
116
|
+
if isinstance(nu, Layered):
|
|
117
|
+
return e * nu.c()
|
|
118
|
+
lam = e * nu / ((1 + nu) * (1 - 2 * nu))
|
|
119
|
+
mu = e / (2 * (1 + nu))
|
|
120
|
+
c = np.zeros((6, 6))
|
|
121
|
+
c[:3, :3] = lam
|
|
122
|
+
c[np.arange(3), np.arange(3)] = lam + 2 * mu
|
|
123
|
+
c[np.arange(3, 6), np.arange(3, 6)] = mu
|
|
124
|
+
return c
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _b_matrix(xi, eta, zeta):
|
|
128
|
+
"""Strain-displacement matrix (6x24) of the unit cube at a natural point
|
|
129
|
+
(xi, eta, zeta in [-1, 1]). For side h, divide by h."""
|
|
130
|
+
s = 2.0 * CORNERS - 1.0
|
|
131
|
+
dn = np.empty((8, 3))
|
|
132
|
+
dn[:, 0] = 0.25 * s[:, 0] * (1 + eta * s[:, 1]) * (1 + zeta * s[:, 2])
|
|
133
|
+
dn[:, 1] = 0.25 * s[:, 1] * (1 + xi * s[:, 0]) * (1 + zeta * s[:, 2])
|
|
134
|
+
dn[:, 2] = 0.25 * s[:, 2] * (1 + xi * s[:, 0]) * (1 + eta * s[:, 1])
|
|
135
|
+
b = np.zeros((6, 24))
|
|
136
|
+
for i in range(8):
|
|
137
|
+
dx, dy, dz = dn[i]
|
|
138
|
+
col = 3 * i
|
|
139
|
+
b[0, col] = dx
|
|
140
|
+
b[1, col + 1] = dy
|
|
141
|
+
b[2, col + 2] = dz
|
|
142
|
+
b[3, col], b[3, col + 1] = dy, dx
|
|
143
|
+
b[4, col + 1], b[4, col + 2] = dz, dy
|
|
144
|
+
b[5, col], b[5, col + 2] = dz, dx
|
|
145
|
+
return b
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# The element: "im" (default) is the trilinear hex8 enriched with Wilson's
|
|
149
|
+
# nine incompatible bubble modes (1 - xi^2, ... per displacement component),
|
|
150
|
+
# condensed out element by element (Taylor's form: on a cube the bubbles'
|
|
151
|
+
# strains integrate to zero, so the patch test holds). The plain hex8
|
|
152
|
+
# shear-locks in bending: on a grid-aligned cantilever it read the root
|
|
153
|
+
# stress 38/16/9/4% low and the tip deflection 37/14/8/3% low at 1/2/3/5
|
|
154
|
+
# cells through the depth (moonshots/round2, unsafe on thin members); with
|
|
155
|
+
# the bubbles both are exact to under 1%. Same 24 dofs, same sparsity, so
|
|
156
|
+
# every solver path (assembled AMG, matrix-free multigrid, torch) takes it
|
|
157
|
+
# unchanged. LOADPATH_HEX8=plain switches back for A/B comparisons.
|
|
158
|
+
_ELEMENTS = ("im", "plain")
|
|
159
|
+
_PARTS = {}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def hex8_element() -> str:
|
|
163
|
+
"""The element in force: LOADPATH_HEX8 ("im" or "plain"), default "im"."""
|
|
164
|
+
import os
|
|
165
|
+
|
|
166
|
+
e = os.environ.get("LOADPATH_HEX8", "im").strip().lower() or "im"
|
|
167
|
+
if e not in _ELEMENTS:
|
|
168
|
+
raise ValueError(f"LOADPATH_HEX8 must be one of {_ELEMENTS}, not {e!r}")
|
|
169
|
+
return e
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _b_bubble(xi, eta, zeta):
|
|
173
|
+
"""Strain (6x9) of the nine bubble modes of the unit cube at a natural
|
|
174
|
+
point; column 3*m + c is mode (1 - x_m^2) in component c."""
|
|
175
|
+
x = (xi, eta, zeta)
|
|
176
|
+
b = np.zeros((6, 9))
|
|
177
|
+
for m in range(3):
|
|
178
|
+
d = np.zeros(3)
|
|
179
|
+
d[m] = -4.0 * x[m] # d/dx of (1 - xi^2) on the unit cube
|
|
180
|
+
dx, dy, dz = d
|
|
181
|
+
col = 3 * m
|
|
182
|
+
b[0, col] = dx
|
|
183
|
+
b[1, col + 1] = dy
|
|
184
|
+
b[2, col + 2] = dz
|
|
185
|
+
b[3, col], b[3, col + 1] = dy, dx
|
|
186
|
+
b[4, col + 1], b[4, col + 2] = dz, dy
|
|
187
|
+
b[5, col], b[5, col + 2] = dz, dx
|
|
188
|
+
return b
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _hex8_parts(nu, element):
|
|
192
|
+
"""(ke 24x24, R 9x24 or None) of the unit cube at E = 1: R maps the
|
|
193
|
+
element's nodal displacements to its condensed bubble amplitudes."""
|
|
194
|
+
key = (repr(nu), element)
|
|
195
|
+
if key in _PARTS:
|
|
196
|
+
return _PARTS[key]
|
|
197
|
+
c = elastic_c(1.0, nu)
|
|
198
|
+
g = 1.0 / np.sqrt(3.0)
|
|
199
|
+
kuu = np.zeros((24, 24))
|
|
200
|
+
kua = np.zeros((24, 9))
|
|
201
|
+
kaa = np.zeros((9, 9))
|
|
202
|
+
for xi in (-g, g):
|
|
203
|
+
for eta in (-g, g):
|
|
204
|
+
for zeta in (-g, g):
|
|
205
|
+
b = _b_matrix(xi, eta, zeta)
|
|
206
|
+
ba = _b_bubble(xi, eta, zeta)
|
|
207
|
+
kuu += b.T @ c @ b * 0.125 # det J of the unit cube map = 1/8
|
|
208
|
+
kua += b.T @ c @ ba * 0.125
|
|
209
|
+
kaa += ba.T @ c @ ba * 0.125
|
|
210
|
+
if element == "plain":
|
|
211
|
+
out = (0.5 * (kuu + kuu.T), None)
|
|
212
|
+
else:
|
|
213
|
+
r = -np.linalg.solve(kaa, kua.T)
|
|
214
|
+
ke = kuu + kua @ r
|
|
215
|
+
out = (0.5 * (ke + ke.T), r)
|
|
216
|
+
_PARTS[key] = out
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def hex8_ke(nu, element: str | None = None) -> np.ndarray:
|
|
221
|
+
"""Stiffness (24x24) of a unit cube with E = 1, 2x2x2 Gauss, bubbles
|
|
222
|
+
condensed (see hex8_element). For side h and modulus E the element
|
|
223
|
+
matrix is E * h * this."""
|
|
224
|
+
return _hex8_parts(nu, element or hex8_element())[0].copy()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def hex8_b(xi, eta, zeta, nu, element: str | None = None) -> np.ndarray:
|
|
228
|
+
"""Strain-displacement matrix (6x24) of the unit cube at a natural point
|
|
229
|
+
for the element in force: the trilinear field plus the condensed
|
|
230
|
+
bubbles' strain (zero at the centre). For side h, divide by h. Corner
|
|
231
|
+
stress for nodal averaging must use this, not _b_matrix: in bending the
|
|
232
|
+
bending strain the element carries lives partly in the bubbles."""
|
|
233
|
+
b = _b_matrix(xi, eta, zeta)
|
|
234
|
+
r = _hex8_parts(nu, element or hex8_element())[1]
|
|
235
|
+
if r is not None:
|
|
236
|
+
b = b + _b_bubble(xi, eta, zeta) @ r
|
|
237
|
+
return b
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
_GAUSS_B = {}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _gauss_b():
|
|
244
|
+
"""(B (8, 6, 24), bubble B (8, 6, 9)) at the 2x2x2 Gauss points of the
|
|
245
|
+
unit cube."""
|
|
246
|
+
if not _GAUSS_B:
|
|
247
|
+
g = 1.0 / np.sqrt(3.0)
|
|
248
|
+
pts = [(a, b, c) for a in (-g, g) for b in (-g, g) for c in (-g, g)]
|
|
249
|
+
_GAUSS_B["b"] = np.array([_b_matrix(*p) for p in pts])
|
|
250
|
+
_GAUSS_B["ba"] = np.array([_b_bubble(*p) for p in pts])
|
|
251
|
+
return _GAUSS_B["b"], _GAUSS_B["ba"]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def hex8_ke_batch(c: np.ndarray, element: str | None = None):
|
|
255
|
+
"""Per-element stiffness of unit cubes with per-element elasticity
|
|
256
|
+
matrices `c` (ne, 6, 6): (ke (ne, 24, 24), R (ne, 9, 24) or None), the
|
|
257
|
+
bubbles condensed element by element as hex8_ke does for one material
|
|
258
|
+
(the condensation depends on each element's C, so an anisotropic cell
|
|
259
|
+
cannot share one matrix). For side h the element matrix is h * ke; R
|
|
260
|
+
maps nodal displacements to bubble amplitudes (strain recovery)."""
|
|
261
|
+
element = element or hex8_element()
|
|
262
|
+
c = np.asarray(c, float)
|
|
263
|
+
b, ba = _gauss_b()
|
|
264
|
+
cb = np.einsum("eij,gjk->egik", c, b) # (ne, 8, 6, 24)
|
|
265
|
+
kuu = 0.125 * np.einsum("gia,egib->eab", b, cb)
|
|
266
|
+
if element == "plain":
|
|
267
|
+
return 0.5 * (kuu + np.swapaxes(kuu, 1, 2)), None
|
|
268
|
+
kua = 0.125 * np.einsum("egia,gib->eab", cb, ba) # (ne, 24, 9)
|
|
269
|
+
kaa = 0.125 * np.einsum("gia,eij,gjb->eab", ba, c, ba)
|
|
270
|
+
r = -np.linalg.solve(kaa, np.swapaxes(kua, 1, 2)) # (ne, 9, 24)
|
|
271
|
+
ke = kuu + kua @ r
|
|
272
|
+
return 0.5 * (ke + np.swapaxes(ke, 1, 2)), r
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def hex8_fth(nu) -> np.ndarray:
|
|
276
|
+
"""Thermal load (24,) of a unit cube with E = 1 and a free expansion
|
|
277
|
+
strain of 1 in every direction (alpha * dT = 1): the integral of
|
|
278
|
+
B^T C m, m = (1, 1, 1, 0, 0, 0), 2x2x2 Gauss. For side h, modulus E and
|
|
279
|
+
expansion strain eps the element's load is E * eps * h^2 * this: the
|
|
280
|
+
nodal forces that hold the cube at its size when it wants to grow."""
|
|
281
|
+
c = elastic_c(1.0, nu)
|
|
282
|
+
m = np.array([1.0, 1.0, 1.0, 0.0, 0.0, 0.0])
|
|
283
|
+
g = 1.0 / np.sqrt(3.0)
|
|
284
|
+
f = np.zeros(24)
|
|
285
|
+
for xi in (-g, g):
|
|
286
|
+
for eta in (-g, g):
|
|
287
|
+
for zeta in (-g, g):
|
|
288
|
+
f += _b_matrix(xi, eta, zeta).T @ (c @ m) * 0.125
|
|
289
|
+
return f
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def thermal_load(mesh, nu, modulus, strain, fixed=None) -> np.ndarray:
|
|
293
|
+
"""Global load vector (n_dof,) of per-element expansion strains
|
|
294
|
+
(`strain` = alpha * (T - T_ref), a scalar or per element) at per-element
|
|
295
|
+
`modulus` (E_e, density interpolation included). Solving K u = this
|
|
296
|
+
(plus any mechanical load) gives the thermo-elastic displacement; the
|
|
297
|
+
stress is then Stiffness.stress(u, modulus, strain). Entries on `fixed`
|
|
298
|
+
dofs are zeroed (Dirichlet rows are identity rows here)."""
|
|
299
|
+
w = (np.asarray(modulus, float) * np.asarray(strain, float)) * np.ones(mesh.n_elem)
|
|
300
|
+
fe = hex8_fth(nu) * mesh.pitch ** 2
|
|
301
|
+
f = np.bincount(mesh.edof.ravel(), weights=(w[:, None] * fe[None, :]).ravel(), minlength=mesh.n_dof)
|
|
302
|
+
if fixed is not None:
|
|
303
|
+
f[np.asarray(fixed, bool)] = 0.0
|
|
304
|
+
return f
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def hex8_b_center() -> np.ndarray:
|
|
308
|
+
return _b_matrix(0.0, 0.0, 0.0)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def von_mises(stress: np.ndarray) -> np.ndarray:
|
|
312
|
+
sx, sy, sz, txy, tyz, tzx = stress.T
|
|
313
|
+
return np.sqrt(0.5 * ((sx - sy) ** 2 + (sy - sz) ** 2 + (sz - sx) ** 2)
|
|
314
|
+
+ 3.0 * (txy ** 2 + tyz ** 2 + tzx ** 2))
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class HexMesh:
|
|
318
|
+
"""The active elements of a voxel grid and the nodes they use.
|
|
319
|
+
|
|
320
|
+
elem_ijk: (ne, 3) integer cell indices; pitch: cell size; origin: world
|
|
321
|
+
position of node (0, 0, 0), i.e. the min corner of cell (0, 0, 0)."""
|
|
322
|
+
|
|
323
|
+
def __init__(self, elem_ijk: np.ndarray, pitch: float, origin: np.ndarray):
|
|
324
|
+
self.elem_ijk = np.asarray(elem_ijk, np.int64)
|
|
325
|
+
self.pitch = float(pitch)
|
|
326
|
+
self.origin = np.asarray(origin, float)
|
|
327
|
+
ne = len(self.elem_ijk)
|
|
328
|
+
if ne == 0:
|
|
329
|
+
raise ValueError("no active elements")
|
|
330
|
+
dims = self.elem_ijk.max(axis=0) + 2
|
|
331
|
+
self.node_dims = dims
|
|
332
|
+
# Node ids in ascending grid order (what np.unique over the corner
|
|
333
|
+
# indices gave), by marking the node grid instead of sorting 8 ids
|
|
334
|
+
# per cell: linear time and memory, which matters from ~1M cells.
|
|
335
|
+
e_flat = (self.elem_ijk[:, 0] * dims[1] + self.elem_ijk[:, 1]) * dims[2] + self.elem_ijk[:, 2]
|
|
336
|
+
offs = (CORNERS[:, 0] * dims[1] + CORNERS[:, 1]) * dims[2] + CORNERS[:, 2]
|
|
337
|
+
mark = np.zeros(int(np.prod(dims)), bool)
|
|
338
|
+
for o in offs:
|
|
339
|
+
mark[e_flat + o] = True
|
|
340
|
+
used = np.flatnonzero(mark)
|
|
341
|
+
del mark
|
|
342
|
+
self._lookup = np.full(int(np.prod(dims)), -1, np.int64)
|
|
343
|
+
self._lookup[used] = np.arange(len(used))
|
|
344
|
+
self.enode = self._lookup[e_flat[:, None] + offs[None, :]]
|
|
345
|
+
self.node_ijk = np.stack(np.unravel_index(used, tuple(dims)), axis=1).astype(np.int64)
|
|
346
|
+
self.n_nodes = len(used)
|
|
347
|
+
self.node_xyz = self.origin + self.node_ijk * self.pitch
|
|
348
|
+
self.n_dof = 3 * self.n_nodes
|
|
349
|
+
self._edof = None
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def n_elem(self):
|
|
353
|
+
return len(self.elem_ijk)
|
|
354
|
+
|
|
355
|
+
@property
|
|
356
|
+
def edof(self):
|
|
357
|
+
# built on first use: 192 bytes per cell, and the multigrid path
|
|
358
|
+
# never needs it
|
|
359
|
+
if self._edof is None:
|
|
360
|
+
self._edof = (3 * self.enode[:, :, None] + np.arange(3)).reshape(self.n_elem, 24)
|
|
361
|
+
return self._edof
|
|
362
|
+
|
|
363
|
+
def elem_centers(self):
|
|
364
|
+
return self.origin + (self.elem_ijk + 0.5) * self.pitch
|
|
365
|
+
|
|
366
|
+
def node_id(self, ijk: np.ndarray) -> np.ndarray:
|
|
367
|
+
"""Node id at integer node coords, -1 if absent or out of range."""
|
|
368
|
+
ijk = np.asarray(ijk, np.int64)
|
|
369
|
+
d = self.node_dims
|
|
370
|
+
ok = np.all((ijk >= 0) & (ijk < d), axis=-1)
|
|
371
|
+
flat = (ijk[..., 0] * d[1] + ijk[..., 1]) * d[2] + ijk[..., 2]
|
|
372
|
+
out = np.full(ijk.shape[:-1], -1, np.int64)
|
|
373
|
+
out[ok] = self._lookup[flat[ok]]
|
|
374
|
+
return out
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
class Stiffness:
|
|
378
|
+
"""Precomputed CSR layout for a HexMesh; `assemble(scale)` returns the
|
|
379
|
+
global stiffness for per-element moduli `scale` (E_e, already including
|
|
380
|
+
any density interpolation), with Dirichlet conditions applied as
|
|
381
|
+
identity rows so the matrix keeps one fixed shape."""
|
|
382
|
+
|
|
383
|
+
def __init__(self, mesh: HexMesh, nu, fixed: np.ndarray, element: str | None = None):
|
|
384
|
+
self.mesh = mesh
|
|
385
|
+
# element=None takes hex8_element(); "plain" for the trilinear hex8
|
|
386
|
+
# the geometrically nonlinear element (nonlinear.py) is built on
|
|
387
|
+
self.element = element or hex8_element()
|
|
388
|
+
self.ke = hex8_ke(nu, self.element) * mesh.pitch # E = 1, side h
|
|
389
|
+
self.nu = nu
|
|
390
|
+
nn = mesh.n_nodes
|
|
391
|
+
enode = mesh.enode
|
|
392
|
+
nijk = mesh.node_ijk
|
|
393
|
+
|
|
394
|
+
# Which (node, offset) pairs actually share an element.
|
|
395
|
+
adj = np.zeros((nn, 27), bool)
|
|
396
|
+
pair_off = np.empty((8, 8), np.int64)
|
|
397
|
+
for a in range(8):
|
|
398
|
+
for b in range(8):
|
|
399
|
+
pair_off[a, b] = _offset_index(CORNERS[b] - CORNERS[a])
|
|
400
|
+
adj[enode[:, a], pair_off[a, b]] = True
|
|
401
|
+
cnt = adj.sum(axis=1)
|
|
402
|
+
rank = np.cumsum(adj, axis=1) - 1
|
|
403
|
+
row_len = np.repeat(3 * cnt, 3)
|
|
404
|
+
self.indptr = np.concatenate([[0], np.cumsum(row_len)]).astype(np.int64)
|
|
405
|
+
nnz = int(self.indptr[-1])
|
|
406
|
+
indices = np.empty(nnz, np.int64)
|
|
407
|
+
n_idx, o_idx = np.nonzero(adj) # sorted by node, then offset
|
|
408
|
+
nbr = mesh.node_id(nijk[n_idx] + OFFSETS[o_idx])
|
|
409
|
+
for i in range(3):
|
|
410
|
+
base = self.indptr[3 * n_idx + i] + 3 * rank[n_idx, o_idx]
|
|
411
|
+
for j in range(3):
|
|
412
|
+
indices[base + j] = 3 * nbr + j
|
|
413
|
+
self.indices = indices
|
|
414
|
+
self.nnz = nnz
|
|
415
|
+
|
|
416
|
+
# Per corner pair: the slot of entry (3a+i, 3b+j) is base + i*row + j.
|
|
417
|
+
# int32 slots when they fit: these 64 arrays are the bulk of this
|
|
418
|
+
# object's memory (9 slots per element per corner pair).
|
|
419
|
+
idx_t = np.int32 if nnz < 2 ** 31 - 1 else np.int64
|
|
420
|
+
# one (64, elements, 9) array, the pairs views of it (femops.scatter
|
|
421
|
+
# runs over all 64 in one call)
|
|
422
|
+
self._slots = np.empty((64, mesh.n_elem, 9), idx_t)
|
|
423
|
+
self._blks = np.empty((64, 9))
|
|
424
|
+
self._pairs = []
|
|
425
|
+
for a in range(8):
|
|
426
|
+
na = enode[:, a]
|
|
427
|
+
row = 3 * cnt[na]
|
|
428
|
+
for b in range(8):
|
|
429
|
+
base = self.indptr[3 * na] + 3 * rank[na, pair_off[a, b]]
|
|
430
|
+
blk = self.ke[3 * a:3 * a + 3, 3 * b:3 * b + 3].ravel()
|
|
431
|
+
p = 8 * a + b
|
|
432
|
+
self._slots[p] = (base[:, None] + np.repeat(np.arange(3), 3)[None, :] * row[:, None]
|
|
433
|
+
+ np.tile(np.arange(3), 3)[None, :])
|
|
434
|
+
self._blks[p] = blk
|
|
435
|
+
self._pairs.append((self._slots[p], self._blks[p]))
|
|
436
|
+
self.indptr = self.indptr.astype(idx_t)
|
|
437
|
+
self.indices = self.indices.astype(idx_t)
|
|
438
|
+
|
|
439
|
+
fixed = np.asarray(fixed, bool)
|
|
440
|
+
if fixed.shape != (mesh.n_dof,):
|
|
441
|
+
raise ValueError("fixed must be a bool per dof")
|
|
442
|
+
self.fixed = fixed
|
|
443
|
+
row_of_slot = np.repeat(np.arange(mesh.n_dof), row_len)
|
|
444
|
+
self._keep = ~(fixed[row_of_slot] | fixed[indices])
|
|
445
|
+
diag = np.nonzero((row_of_slot == indices) & fixed[row_of_slot])[0]
|
|
446
|
+
self._diag_fixed = diag
|
|
447
|
+
self.free = ~fixed
|
|
448
|
+
|
|
449
|
+
def assemble(self, scale: np.ndarray) -> sp.csr_matrix:
|
|
450
|
+
data = np.zeros(self.nnz)
|
|
451
|
+
from . import femops
|
|
452
|
+
|
|
453
|
+
if femops.available():
|
|
454
|
+
femops.scatter(data, self._slots, self._blks, scale)
|
|
455
|
+
else:
|
|
456
|
+
s = np.asarray(scale, float)[:, None]
|
|
457
|
+
for slots, blk in self._pairs:
|
|
458
|
+
data[slots] += s * blk
|
|
459
|
+
data *= self._keep
|
|
460
|
+
data[self._diag_fixed] = 1.0
|
|
461
|
+
k = sp.csr_matrix((data, self.indices, self.indptr), shape=(self.mesh.n_dof, self.mesh.n_dof))
|
|
462
|
+
k.has_sorted_indices = True
|
|
463
|
+
return k
|
|
464
|
+
|
|
465
|
+
def add_elements(self, data: np.ndarray, kes: np.ndarray, elems: np.ndarray) -> None:
|
|
466
|
+
"""Scatter per-element 24x24 matrices (a tangent that differs element
|
|
467
|
+
to element, not a scaled copy of one) for elements `elems` into a CSR
|
|
468
|
+
data array of length nnz. Finish with `finish(data)`."""
|
|
469
|
+
for idx, (slots, _) in enumerate(self._pairs):
|
|
470
|
+
a, b = divmod(idx, 8)
|
|
471
|
+
data[slots[elems]] += kes[:, 3 * a:3 * a + 3, 3 * b:3 * b + 3].reshape(len(elems), 9)
|
|
472
|
+
|
|
473
|
+
def finish(self, data: np.ndarray, diag: float = 1.0) -> sp.csr_matrix:
|
|
474
|
+
"""CSR matrix from scattered data, Dirichlet rows and columns cleared
|
|
475
|
+
and `diag` on their diagonal (1 for a stiffness, 0 for a geometric
|
|
476
|
+
stiffness in an eigenproblem)."""
|
|
477
|
+
data = data * self._keep
|
|
478
|
+
data[self._diag_fixed] = diag
|
|
479
|
+
k = sp.csr_matrix((data, self.indices, self.indptr), shape=(self.mesh.n_dof, self.mesh.n_dof))
|
|
480
|
+
k.has_sorted_indices = True
|
|
481
|
+
return k
|
|
482
|
+
|
|
483
|
+
def strain_energy(self, u: np.ndarray) -> np.ndarray:
|
|
484
|
+
"""Per element u_e^T Ke u_e at unit modulus (multiply by E_e)."""
|
|
485
|
+
from . import femops
|
|
486
|
+
|
|
487
|
+
if femops.available():
|
|
488
|
+
return femops.element_energy(u, None, self.mesh.edof, self.ke)
|
|
489
|
+
ue = u[self.mesh.edof]
|
|
490
|
+
return np.einsum("ij,jk,ik->i", ue, self.ke, ue)
|
|
491
|
+
|
|
492
|
+
def stress(self, u: np.ndarray, modulus: np.ndarray | float, thermal=None) -> np.ndarray:
|
|
493
|
+
"""Element-center stress (ne, 6) for per-element modulus; `thermal`
|
|
494
|
+
is the free expansion strain alpha * dT (scalar or per element),
|
|
495
|
+
which does not stress the cell, so it comes off the normal strains."""
|
|
496
|
+
b = hex8_b_center() / self.mesh.pitch
|
|
497
|
+
c = elastic_c(1.0, self.nu)
|
|
498
|
+
strain = u[self.mesh.edof] @ b.T
|
|
499
|
+
if thermal is not None:
|
|
500
|
+
strain[:, :3] -= (np.asarray(thermal, float) * np.ones(len(strain)))[:, None]
|
|
501
|
+
return (strain @ c.T) * np.asarray(modulus, float).reshape(-1, 1)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def rigid_body_modes(xyz: np.ndarray) -> np.ndarray:
|
|
505
|
+
n = len(xyz)
|
|
506
|
+
b = np.zeros((3 * n, 6))
|
|
507
|
+
x, y, z = (xyz - xyz.mean(axis=0)).T
|
|
508
|
+
for i in range(3):
|
|
509
|
+
b[i::3, i] = 1.0
|
|
510
|
+
b[0::3, 3], b[1::3, 3] = -y, x
|
|
511
|
+
b[1::3, 4], b[2::3, 4] = -z, y
|
|
512
|
+
b[0::3, 5], b[2::3, 5] = z, -x
|
|
513
|
+
return b
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
_AMG_SETUP = threading.Lock()
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def amg_solver(a, **kw):
|
|
520
|
+
"""pyamg.smoothed_aggregation_solver, reproducibly. pyamg estimates
|
|
521
|
+
spectral radii (the prolongation smoother's weight) from np.random's
|
|
522
|
+
global stream, so the hierarchy, and with it every iterate, changed from
|
|
523
|
+
run to run: the printed hook's overhang read 0.040 or 0.042 on the same
|
|
524
|
+
code. Seed the stream for the setup and give the caller's back."""
|
|
525
|
+
import pyamg
|
|
526
|
+
|
|
527
|
+
from . import amgfast
|
|
528
|
+
|
|
529
|
+
amgfast.install() # GIL-free Gauss-Seidel, same bits: solve_many's threads overlap
|
|
530
|
+
# one setup at a time: the seeded global stream is shared, and threads
|
|
531
|
+
# setting up at once (verify's submodels) would draw from each other's
|
|
532
|
+
with _AMG_SETUP:
|
|
533
|
+
state = np.random.get_state()
|
|
534
|
+
np.random.seed(0)
|
|
535
|
+
try:
|
|
536
|
+
return pyamg.smoothed_aggregation_solver(a, **kw)
|
|
537
|
+
finally:
|
|
538
|
+
np.random.set_state(state)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
class Solver:
|
|
542
|
+
"""CG + AMG with a reusable hierarchy. `solve(K, f, x0)` rebuilds the
|
|
543
|
+
multigrid hierarchy when asked to, or when the last solve needed too many
|
|
544
|
+
iterations (the old hierarchy no longer matches K well)."""
|
|
545
|
+
|
|
546
|
+
# Refresh (rather than rebuild) the hierarchy for a new matrix at most
|
|
547
|
+
# this many times in a row.
|
|
548
|
+
REFRESH_AGE = 8
|
|
549
|
+
|
|
550
|
+
def __init__(self, mesh: HexMesh, rtol=1e-6, maxiter=800, rebuild_after_iters=25, refresh=True):
|
|
551
|
+
self.rtol = rtol
|
|
552
|
+
self.refresh = refresh
|
|
553
|
+
self.max_age = self.REFRESH_AGE
|
|
554
|
+
self.maxiter = maxiter
|
|
555
|
+
self.rebuild_after_iters = rebuild_after_iters
|
|
556
|
+
self._b = rigid_body_modes(mesh.node_xyz)
|
|
557
|
+
self._ml = None
|
|
558
|
+
self._age = 0
|
|
559
|
+
self.last_iters = 0
|
|
560
|
+
self.use_amg = True
|
|
561
|
+
try:
|
|
562
|
+
import pyamg # noqa: F401
|
|
563
|
+
except ImportError: # pragma: no cover - pyamg is a declared dependency
|
|
564
|
+
self.use_amg = False
|
|
565
|
+
|
|
566
|
+
def invalidate(self):
|
|
567
|
+
self._ml = None
|
|
568
|
+
|
|
569
|
+
def _precond(self, k):
|
|
570
|
+
if not self.use_amg:
|
|
571
|
+
d = k.diagonal()
|
|
572
|
+
d[d == 0] = 1.0
|
|
573
|
+
return sp.diags(1.0 / d)
|
|
574
|
+
if self._ml is not None and self._ml.levels[0].A is not k:
|
|
575
|
+
if self.refresh and self._age < self.max_age:
|
|
576
|
+
# A new matrix on the same mesh (the optimizer's next design):
|
|
577
|
+
# keep the hierarchy but smooth with the matrix being solved.
|
|
578
|
+
# Smoothing with the previous design's matrix (as this did
|
|
579
|
+
# until 2026-10-30) took 2-5x the CG iterations of a fresh
|
|
580
|
+
# hierarchy, so every other step paid for a rebuild; this
|
|
581
|
+
# cut an arm-link step from 3.1 s to 2.3 s. (Recomputing the
|
|
582
|
+
# coarse operators too, R A P, was slower: the old
|
|
583
|
+
# prolongators span void and solid alike, and on a design
|
|
584
|
+
# that had just moved they converged worse than these.)
|
|
585
|
+
self._ml.levels[0].A = k
|
|
586
|
+
self._age += 1
|
|
587
|
+
else:
|
|
588
|
+
self._ml = None
|
|
589
|
+
if self._ml is None:
|
|
590
|
+
# coarse_solver: pyamg's default is a dense pseudo-inverse (an SVD)
|
|
591
|
+
# of the coarsest level. Aggregation on these stiffness matrices
|
|
592
|
+
# often stops coarsening at a few thousand unknowns, where that SVD
|
|
593
|
+
# alone took ~3s per rebuild; a sparse LU of the same level is
|
|
594
|
+
# milliseconds.
|
|
595
|
+
self._ml = amg_solver(k, B=self._b, symmetry="hermitian", strength="symmetric", max_coarse=400,
|
|
596
|
+
coarse_solver="splu")
|
|
597
|
+
self._age = 0
|
|
598
|
+
return self._ml.aspreconditioner(cycle="V")
|
|
599
|
+
|
|
600
|
+
def _maxiter(self):
|
|
601
|
+
"""A refreshed hierarchy that stops working (the design moved too far
|
|
602
|
+
from the one it was aggregated on) gives up early and is rebuilt,
|
|
603
|
+
rather than running to maxiter."""
|
|
604
|
+
if self._age == 0:
|
|
605
|
+
return self.maxiter
|
|
606
|
+
return min(self.maxiter, 3 * self.rebuild_after_iters)
|
|
607
|
+
|
|
608
|
+
def solve(self, k, f, x0=None):
|
|
609
|
+
count = [0]
|
|
610
|
+
|
|
611
|
+
def cb(_):
|
|
612
|
+
count[0] += 1
|
|
613
|
+
|
|
614
|
+
m = self._precond(k)
|
|
615
|
+
u, info = cg(k, f, x0=x0, rtol=self.rtol, maxiter=self._maxiter(), M=m, callback=cb)
|
|
616
|
+
if info != 0 and self._ml is not None:
|
|
617
|
+
# Stale hierarchy: rebuild once and retry from where we got to.
|
|
618
|
+
self._ml = None
|
|
619
|
+
count[0] = 0
|
|
620
|
+
u, info = cg(k, f, x0=u, rtol=self.rtol, maxiter=self.maxiter, M=self._precond(k), callback=cb)
|
|
621
|
+
self.last_iters = count[0]
|
|
622
|
+
if count[0] > self.rebuild_after_iters:
|
|
623
|
+
self._ml = None
|
|
624
|
+
if info != 0:
|
|
625
|
+
raise RuntimeError(f"linear solve did not converge ({count[0]} iterations); "
|
|
626
|
+
"the structure may be a mechanism (check supports)")
|
|
627
|
+
return u
|
|
628
|
+
|
|
629
|
+
def solve_many(self, k, fs, x0s=None):
|
|
630
|
+
"""Solve several right-hand sides (load cases) against one matrix.
|
|
631
|
+
The preconditioner is built once and the solves run on threads: the
|
|
632
|
+
sparse kernels release the GIL, and so does the smoother since
|
|
633
|
+
amgfast (pyamg's own holds it); 3 GRIP upper-arm cases 1.35 -> 1.07 s."""
|
|
634
|
+
x0s = x0s or [None] * len(fs)
|
|
635
|
+
if len(fs) == 1:
|
|
636
|
+
return [self.solve(k, fs[0], x0s[0])]
|
|
637
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
638
|
+
|
|
639
|
+
m = self._precond(k)
|
|
640
|
+
# pyamg factors the coarsest level (splu) lazily, on the first
|
|
641
|
+
# V-cycle: started from threads, every case factored it at once and
|
|
642
|
+
# the extra factors stayed allocated (the GRIP wrist yoke's coarsest
|
|
643
|
+
# level is a 60-125 MB LU; resident memory grew ~0.5 GB per design,
|
|
644
|
+
# 3 to 7 GB over a search). One cycle here factors it once.
|
|
645
|
+
m.matvec(np.zeros(k.shape[0]))
|
|
646
|
+
maxiter = self._maxiter()
|
|
647
|
+
counts = [0] * len(fs)
|
|
648
|
+
|
|
649
|
+
def one(i):
|
|
650
|
+
def cb(_):
|
|
651
|
+
counts[i] += 1
|
|
652
|
+
u, info = cg(k, fs[i], x0=x0s[i], rtol=self.rtol, maxiter=maxiter, M=m, callback=cb)
|
|
653
|
+
return u, info
|
|
654
|
+
|
|
655
|
+
with ThreadPoolExecutor(min(len(fs), 4)) as ex:
|
|
656
|
+
res = list(ex.map(one, range(len(fs))))
|
|
657
|
+
out = []
|
|
658
|
+
for i, (u, info) in enumerate(res):
|
|
659
|
+
if info != 0:
|
|
660
|
+
# Fall back to the single-solve path, which rebuilds a stale
|
|
661
|
+
# hierarchy and retries.
|
|
662
|
+
self._ml = None
|
|
663
|
+
u = self.solve(k, fs[i], u)
|
|
664
|
+
out.append(u)
|
|
665
|
+
self.last_iters = max(counts)
|
|
666
|
+
if self.last_iters > self.rebuild_after_iters:
|
|
667
|
+
self._ml = None
|
|
668
|
+
return out
|