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/mg.py
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Matrix-free geometric multigrid for the voxel elasticity problem, so the
|
|
3
|
+
optimizer can run on millions of cells instead of ~100k.
|
|
4
|
+
|
|
5
|
+
The assembled path (fem.Stiffness + pyamg) stores 81 x 3 stiffness entries
|
|
6
|
+
per node plus their slot tables: about 2 KB per cell, and pyamg's setup on
|
|
7
|
+
top of that. Here nothing per cell is stored but the cell's modulus:
|
|
8
|
+
|
|
9
|
+
- The fine operator is applied element by element on the dense node grid:
|
|
10
|
+
gather the 8 corner displacements of every cell (8 slice copies), one GEMM
|
|
11
|
+
with the shared 24x24 unit-cube stiffness, scale by the cell's modulus,
|
|
12
|
+
scatter back. ~100 ns per cell, ~16 bytes of state per cell.
|
|
13
|
+
- Coarse levels are exact Galerkin operators P^T A P built element by
|
|
14
|
+
element. With trilinear prolongation on a grid coarsened by two, a fine
|
|
15
|
+
cell's nodes interpolate only from the coarse cell that contains it, so
|
|
16
|
+
the coarse cell's matrix is the sum over its eight children of
|
|
17
|
+
P_k^T K_child P_k. On the first coarse level that is sum_k E_child * M_k
|
|
18
|
+
with eight fixed 24x24 matrices M_k: the level is stored as the fine
|
|
19
|
+
moduli themselves (a reshape, no setup). Deeper levels store their 24x24
|
|
20
|
+
matrices outright (1/64 of the cells and fewer).
|
|
21
|
+
- Supports: prolongation is masked to free dofs (P' = D P), so a coarse
|
|
22
|
+
correction never moves a held dof and the coarse operator is the exact
|
|
23
|
+
Galerkin product of the held system; cells touching a held node are the
|
|
24
|
+
only ones whose coarse contribution is not a weight times M_k, and they
|
|
25
|
+
are kept as an explicit patch.
|
|
26
|
+
- Smoothing is Chebyshev on the Jacobi-scaled operator (symmetric, no
|
|
27
|
+
ordering, vectorizes), the coarsest level is assembled and solved by
|
|
28
|
+
sparse LU or, if still large, one pyamg V-cycle. The V-cycle runs in
|
|
29
|
+
float32; the outer conjugate gradient and its operator run in float64,
|
|
30
|
+
so the converged answer is a float64 one.
|
|
31
|
+
|
|
32
|
+
Galerkin coarsening is what keeps this robust to SIMP's million-to-one
|
|
33
|
+
stiffness contrast: a coarse cell that straddles a strut and void sees the
|
|
34
|
+
strut's real stiffness, not an average modulus.
|
|
35
|
+
|
|
36
|
+
`GridElasticity` takes a fem.HexMesh, a Poisson's ratio (or fem.Layered), the
|
|
37
|
+
held dofs and optional springs, and solves for per-cell moduli with
|
|
38
|
+
displacements in the HexMesh's own dof numbering, so callers swap it in for
|
|
39
|
+
Stiffness + Solver.
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
|
|
45
|
+
import numpy as np
|
|
46
|
+
import scipy.sparse as sp
|
|
47
|
+
|
|
48
|
+
from .fem import CORNERS, hex8_element, hex8_ke, rigid_body_modes
|
|
49
|
+
|
|
50
|
+
__all__ = ["GridElasticity", "prolong", "restrict"]
|
|
51
|
+
|
|
52
|
+
# Corner k of a cell sits at (k & 1, k >> 1 & 1, k >> 2 & 1), as in fem.CORNERS.
|
|
53
|
+
_CORNER_OFF = [tuple(int(v) for v in c) for c in CORNERS]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _child_prolongations() -> np.ndarray:
|
|
57
|
+
"""P_k (8, 24, 24): coarse-cell dofs -> dofs of child k (child k is the
|
|
58
|
+
fine cell at offset CORNERS[k] inside the coarse cell)."""
|
|
59
|
+
out = np.zeros((8, 24, 24))
|
|
60
|
+
for k in range(8):
|
|
61
|
+
for q in range(8):
|
|
62
|
+
p = CORNERS[k] + CORNERS[q] # fine node in fine-cell units, 0..2
|
|
63
|
+
for r in range(8):
|
|
64
|
+
w = 1.0
|
|
65
|
+
for d in range(3):
|
|
66
|
+
t = p[d] / 2.0
|
|
67
|
+
w *= t if CORNERS[r][d] else 1.0 - t
|
|
68
|
+
if w:
|
|
69
|
+
for i in range(3):
|
|
70
|
+
out[k, 3 * q + i, 3 * r + i] = w
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_PK = _child_prolongations()
|
|
75
|
+
|
|
76
|
+
# Chebyshev needs an upper bound on the spectrum of D^-1 A. A dozen power
|
|
77
|
+
# iterations land ~10% under it, and an underestimate lets the top modes
|
|
78
|
+
# grow: on a uniform 500k-cell bar 1.1x took 16 CG iterations, the converged
|
|
79
|
+
# bound 6. Overestimating costs far less than underestimating.
|
|
80
|
+
LMAX_SAFETY = 1.3
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def prolong(c: np.ndarray) -> np.ndarray:
|
|
84
|
+
"""Trilinear prolongation of a node-grid field (..., nx+1, ny+1, nz+1) to
|
|
85
|
+
the grid with twice the cells (..., 2nx+1, 2ny+1, 2nz+1)."""
|
|
86
|
+
out = c
|
|
87
|
+
for ax in (-3, -2, -1):
|
|
88
|
+
n = out.shape[ax]
|
|
89
|
+
shape = list(out.shape)
|
|
90
|
+
shape[ax] = 2 * n - 1
|
|
91
|
+
f = np.empty(shape, out.dtype)
|
|
92
|
+
sl_even = [slice(None)] * f.ndim
|
|
93
|
+
sl_even[ax] = slice(0, None, 2)
|
|
94
|
+
sl_odd = [slice(None)] * f.ndim
|
|
95
|
+
sl_odd[ax] = slice(1, None, 2)
|
|
96
|
+
lo = [slice(None)] * f.ndim
|
|
97
|
+
lo[ax] = slice(0, n - 1)
|
|
98
|
+
hi = [slice(None)] * f.ndim
|
|
99
|
+
hi[ax] = slice(1, n)
|
|
100
|
+
f[tuple(sl_even)] = out
|
|
101
|
+
np.add(out[tuple(lo)], out[tuple(hi)], out=f[tuple(sl_odd)])
|
|
102
|
+
f[tuple(sl_odd)] *= 0.5
|
|
103
|
+
out = f
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def restrict(f: np.ndarray) -> np.ndarray:
|
|
108
|
+
"""Transpose of `prolong`."""
|
|
109
|
+
out = f
|
|
110
|
+
for ax in (-3, -2, -1):
|
|
111
|
+
n = (out.shape[ax] + 1) // 2
|
|
112
|
+
sl_even = [slice(None)] * out.ndim
|
|
113
|
+
sl_even[ax] = slice(0, None, 2)
|
|
114
|
+
sl_odd = [slice(None)] * out.ndim
|
|
115
|
+
sl_odd[ax] = slice(1, None, 2)
|
|
116
|
+
c = out[tuple(sl_even)].copy()
|
|
117
|
+
half = 0.5 * out[tuple(sl_odd)]
|
|
118
|
+
lo = [slice(None)] * c.ndim
|
|
119
|
+
lo[ax] = slice(0, n - 1)
|
|
120
|
+
hi = [slice(None)] * c.ndim
|
|
121
|
+
hi[ax] = slice(1, n)
|
|
122
|
+
c[tuple(lo)] += half
|
|
123
|
+
c[tuple(hi)] += half
|
|
124
|
+
out = c
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _slab(cells_yz: int, dtype) -> int:
|
|
129
|
+
"""x-planes of cells per chunk: keep the (24, m, ny, nz) gather buffer
|
|
130
|
+
near 32 MB."""
|
|
131
|
+
per_plane = 24 * max(cells_yz, 1) * np.dtype(dtype).itemsize
|
|
132
|
+
return max(1, int(32e6 // per_plane))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _gather(u, r, out):
|
|
136
|
+
x0, x1, y0, y1, z0, z1 = r
|
|
137
|
+
for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
|
|
138
|
+
out[3 * c:3 * c + 3] = u[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _scatter(f, r, out):
|
|
142
|
+
x0, x1, y0, y1, z0, z1 = r
|
|
143
|
+
for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
|
|
144
|
+
out[:, x0 + dx:x1 + dx, y0 + dy:y1 + dy, z0 + dz:z1 + dz] += f[3 * c:3 * c + 3]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# Tiles the operator is applied over: TILE_X x-planes by TILE_Y y-rows, cut
|
|
148
|
+
# to the z-range of cells that carry stiffness, and skipped when none do.
|
|
149
|
+
# A converged design fills a third of its box or less, and the box itself
|
|
150
|
+
# includes keep-outs, so most of the grid is never touched.
|
|
151
|
+
TILE_X, TILE_Y = 8, 16
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _tiles(active: np.ndarray) -> list:
|
|
155
|
+
nx, ny, nz = active.shape
|
|
156
|
+
out = []
|
|
157
|
+
for x0 in range(0, nx, TILE_X):
|
|
158
|
+
x1 = min(x0 + TILE_X, nx)
|
|
159
|
+
for y0 in range(0, ny, TILE_Y):
|
|
160
|
+
y1 = min(y0 + TILE_Y, ny)
|
|
161
|
+
zs = np.flatnonzero(active[x0:x1, y0:y1].any(axis=(0, 1)))
|
|
162
|
+
if len(zs):
|
|
163
|
+
out.append((x0, x1, y0, y1, int(zs[0]), int(zs[-1]) + 1))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class _Level:
|
|
168
|
+
"""One grid level: cells (nx, ny, nz), node grid (3, nx+1, ny+1, nz+1).
|
|
169
|
+
|
|
170
|
+
kind "scaled": cell matrix = E[cell] * ke (the fine level).
|
|
171
|
+
kind "weights": cell matrix = sum_k W[k, cell] * M_k, plus an explicit
|
|
172
|
+
patch for cells listed in patch_ijk (their W is zero).
|
|
173
|
+
kind "explicit": cell matrix stored, K (24, 24, nx, ny, nz).
|
|
174
|
+
`mask` (3, nodes...) is 1 on dofs that are free and carry stiffness;
|
|
175
|
+
the operator is mask * K * mask + (1 - mask) (identity on the rest)."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, kind, dims, dtype):
|
|
178
|
+
self.kind = kind
|
|
179
|
+
self.dims = tuple(int(d) for d in dims)
|
|
180
|
+
self.dtype = dtype
|
|
181
|
+
self.ke = None
|
|
182
|
+
self.e = None
|
|
183
|
+
self.mcat = None
|
|
184
|
+
self.w = None
|
|
185
|
+
self.patch_ijk = None
|
|
186
|
+
self.patch_k = None
|
|
187
|
+
self.k = None
|
|
188
|
+
self.extra = None # sparse on the flattened (3, nodes) layout, or None
|
|
189
|
+
self.mask = None
|
|
190
|
+
self.dinv = None
|
|
191
|
+
self.lmax = None
|
|
192
|
+
self.tiles = None
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def node_shape(self):
|
|
196
|
+
nx, ny, nz = self.dims
|
|
197
|
+
return (3, nx + 1, ny + 1, nz + 1)
|
|
198
|
+
|
|
199
|
+
def active(self) -> np.ndarray:
|
|
200
|
+
"""Cells with a nonzero matrix outside the patch."""
|
|
201
|
+
if self.kind == "scaled":
|
|
202
|
+
return self.e != 0
|
|
203
|
+
if self.kind == "weights":
|
|
204
|
+
return self.w.any(axis=0)
|
|
205
|
+
return np.einsum("iixyz->xyz", self.k) > 0
|
|
206
|
+
|
|
207
|
+
def _cell_kernel(self, buf, r):
|
|
208
|
+
"""f (24, cells of region r) = the cells' matrices times buf."""
|
|
209
|
+
x0, x1, y0, y1, z0, z1 = r
|
|
210
|
+
shp = (x1 - x0, y1 - y0, z1 - z0)
|
|
211
|
+
flat = buf.reshape(24, -1)
|
|
212
|
+
if self.kind == "scaled":
|
|
213
|
+
f = (self.ke @ flat).reshape((24,) + shp)
|
|
214
|
+
f *= self.e[x0:x1, y0:y1, z0:z1][None]
|
|
215
|
+
return f
|
|
216
|
+
if self.kind == "weights":
|
|
217
|
+
g = (self.mcat @ flat).reshape((8, 24) + shp)
|
|
218
|
+
w = self.w[:, x0:x1, y0:y1, z0:z1]
|
|
219
|
+
f = g[0] * w[0][None]
|
|
220
|
+
for k in range(1, 8):
|
|
221
|
+
f += g[k] * w[k][None]
|
|
222
|
+
return f
|
|
223
|
+
kk = self.k[:, :, x0:x1, y0:y1, z0:z1].reshape(24, 24, -1)
|
|
224
|
+
return np.einsum("ijn,jn->in", kk, flat).reshape((24,) + shp)
|
|
225
|
+
|
|
226
|
+
def apply_k(self, u):
|
|
227
|
+
"""Unmasked K u on the node grid."""
|
|
228
|
+
if self.tiles is None:
|
|
229
|
+
self.tiles = _tiles(self.active())
|
|
230
|
+
out = np.zeros(self.node_shape, u.dtype)
|
|
231
|
+
for r in self.tiles:
|
|
232
|
+
buf = np.empty((24, r[1] - r[0], r[3] - r[2], r[5] - r[4]), u.dtype)
|
|
233
|
+
_gather(u, r, buf)
|
|
234
|
+
_scatter(self._cell_kernel(buf, r), r, out)
|
|
235
|
+
if self.patch_ijk is not None and len(self.patch_ijk):
|
|
236
|
+
ue = self._patch_gather(u)
|
|
237
|
+
fe = np.matmul(self.patch_k.astype(u.dtype, copy=False), ue[:, :, None])[:, :, 0]
|
|
238
|
+
self._patch_scatter(fe, out)
|
|
239
|
+
if self.extra is not None:
|
|
240
|
+
out += (self.extra @ u.reshape(-1).astype(np.float64)).astype(u.dtype).reshape(out.shape)
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
def _patch_index(self):
|
|
244
|
+
if getattr(self, "_pidx", None) is None:
|
|
245
|
+
nshape = self.node_shape[1:]
|
|
246
|
+
idx = np.empty((len(self.patch_ijk), 24), np.int64)
|
|
247
|
+
nn = int(np.prod(nshape))
|
|
248
|
+
for c, off in enumerate(_CORNER_OFF):
|
|
249
|
+
node = np.ravel_multi_index(tuple((self.patch_ijk + np.array(off)).T), nshape)
|
|
250
|
+
for i in range(3):
|
|
251
|
+
idx[:, 3 * c + i] = i * nn + node
|
|
252
|
+
self._pidx = idx
|
|
253
|
+
return self._pidx
|
|
254
|
+
|
|
255
|
+
def _patch_gather(self, u):
|
|
256
|
+
return u.reshape(-1)[self._patch_index()]
|
|
257
|
+
|
|
258
|
+
def _patch_scatter(self, fe, out):
|
|
259
|
+
flat = out.reshape(-1)
|
|
260
|
+
np.add.at(flat, self._patch_index().ravel(), fe.ravel())
|
|
261
|
+
|
|
262
|
+
def apply(self, u):
|
|
263
|
+
"""The level's operator: identity on masked-out dofs."""
|
|
264
|
+
m = self.mask.astype(u.dtype, copy=False)
|
|
265
|
+
out = self.apply_k(u * m)
|
|
266
|
+
out *= m
|
|
267
|
+
out += (1 - m) * u
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
def diagonal(self):
|
|
271
|
+
"""Diagonal of the unmasked K."""
|
|
272
|
+
nx, ny, nz = self.dims
|
|
273
|
+
d = np.zeros(self.node_shape)
|
|
274
|
+
if self.kind == "scaled":
|
|
275
|
+
dk = np.diag(self.ke)
|
|
276
|
+
for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
|
|
277
|
+
d[:, dx:dx + nx, dy:dy + ny, dz:dz + nz] += dk[3 * c:3 * c + 3, None, None, None] * self.e[None]
|
|
278
|
+
elif self.kind == "weights":
|
|
279
|
+
dm = np.einsum("kii->ki", self.mcat.reshape(8, 24, 24).astype(np.float64))
|
|
280
|
+
dc = (dm.T @ self.w.reshape(8, -1).astype(np.float64)).reshape((24,) + self.dims)
|
|
281
|
+
for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
|
|
282
|
+
d[:, dx:dx + nx, dy:dy + ny, dz:dz + nz] += dc[3 * c:3 * c + 3]
|
|
283
|
+
else:
|
|
284
|
+
dc = np.einsum("iixyz->ixyz", self.k).astype(np.float64)
|
|
285
|
+
for c, (dx, dy, dz) in enumerate(_CORNER_OFF):
|
|
286
|
+
d[:, dx:dx + nx, dy:dy + ny, dz:dz + nz] += dc[3 * c:3 * c + 3]
|
|
287
|
+
if self.patch_ijk is not None and len(self.patch_ijk):
|
|
288
|
+
flat = d.reshape(-1)
|
|
289
|
+
np.add.at(flat, self._patch_index().ravel(),
|
|
290
|
+
np.einsum("nii->ni", self.patch_k.astype(np.float64)).ravel())
|
|
291
|
+
if self.extra is not None:
|
|
292
|
+
d += self.extra.diagonal().reshape(d.shape)
|
|
293
|
+
return d
|
|
294
|
+
|
|
295
|
+
def cell_matrices(self, a, b):
|
|
296
|
+
"""Explicit matrices (24, 24, m, ny, nz) of x-slab [a, b), float64."""
|
|
297
|
+
ny, nz = self.dims[1:]
|
|
298
|
+
if self.kind == "explicit":
|
|
299
|
+
return self.k[:, :, a:b].astype(np.float64)
|
|
300
|
+
if self.kind == "scaled":
|
|
301
|
+
return self.ke.astype(np.float64)[:, :, None, None, None] * self.e[a:b][None, None].astype(np.float64)
|
|
302
|
+
mk = self.mcat.reshape(8, 576).astype(np.float64)
|
|
303
|
+
w = self.w[:, a:b]
|
|
304
|
+
out = (mk.T @ w.reshape(8, -1).astype(np.float64)).reshape((24, 24) + w.shape[1:])
|
|
305
|
+
if self.patch_ijk is not None and len(self.patch_ijk):
|
|
306
|
+
sel = (self.patch_ijk[:, 0] >= a) & (self.patch_ijk[:, 0] < b)
|
|
307
|
+
p = self.patch_ijk[sel]
|
|
308
|
+
out[:, :, p[:, 0] - a, p[:, 1], p[:, 2]] += np.moveaxis(self.patch_k[sel].astype(np.float64), 0, -1)
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _pad_even(a, axes_from=0, fill=0.0):
|
|
313
|
+
pad = [(0, 0)] * a.ndim
|
|
314
|
+
for i in range(3):
|
|
315
|
+
n = a.shape[axes_from + i]
|
|
316
|
+
pad[axes_from + i] = (0, n % 2)
|
|
317
|
+
if any(p[1] for p in pad):
|
|
318
|
+
return np.pad(a, pad, constant_values=fill)
|
|
319
|
+
return a
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _prolong_rows(fine_nodes_ijk: np.ndarray, coarse_node_shape) -> sp.csr_matrix:
|
|
323
|
+
"""Scalar trilinear prolongation rows for the given fine nodes."""
|
|
324
|
+
n = len(fine_nodes_ijk)
|
|
325
|
+
rows, cols, vals = [], [], []
|
|
326
|
+
for corner in range(8):
|
|
327
|
+
w = np.ones(n)
|
|
328
|
+
ci = np.empty((n, 3), np.int64)
|
|
329
|
+
for d in range(3):
|
|
330
|
+
i = fine_nodes_ijk[:, d]
|
|
331
|
+
odd = i % 2 == 1
|
|
332
|
+
hi = (corner >> d) & 1
|
|
333
|
+
ci[:, d] = i // 2 + (hi & odd)
|
|
334
|
+
w *= np.where(odd, 0.5, 1.0 if not hi else 0.0)
|
|
335
|
+
ok = w > 0
|
|
336
|
+
rows.append(np.nonzero(ok)[0])
|
|
337
|
+
cols.append(np.ravel_multi_index(tuple(ci[ok].T), coarse_node_shape))
|
|
338
|
+
vals.append(w[ok])
|
|
339
|
+
nc = int(np.prod(coarse_node_shape))
|
|
340
|
+
return sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, nc))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
class GridElasticity:
|
|
344
|
+
"""Matrix-free multigrid-preconditioned CG for K(E) u = f on a HexMesh.
|
|
345
|
+
|
|
346
|
+
mesh: fem.HexMesh; nu: Poisson's ratio or fem.Layered; fixed: bool per
|
|
347
|
+
mesh dof; springs: sparse (n_dof, n_dof) in mesh dof numbering, added to
|
|
348
|
+
the operator unscaled, or None.
|
|
349
|
+
|
|
350
|
+
`set_moduli(e)` takes per-element moduli (0 removes a cell outright; a
|
|
351
|
+
node left with no stiffness is dropped from the solve and comes back as
|
|
352
|
+
zero) and rebuilds the hierarchy; `solve_many(fs, x0s)` then solves.
|
|
353
|
+
|
|
354
|
+
coarse_cells: stop coarsening geometrically once a level has fewer
|
|
355
|
+
active cells than this, and assemble that level. degree: Chebyshev
|
|
356
|
+
smoothing degree (pre and post). dtype: precision of the V-cycle.
|
|
357
|
+
device: "auto", "cuda", "mps", "torch" or "cpu" (see gpu.py); None reads
|
|
358
|
+
LOADPATH_DEVICE."""
|
|
359
|
+
|
|
360
|
+
def __init__(self, mesh, nu, fixed, springs=None, rtol=1e-6, maxiter=500, coarse_cells=2000,
|
|
361
|
+
degree=2, dtype=np.float32, lu_max_dofs=60_000, device=None):
|
|
362
|
+
self.mesh = mesh
|
|
363
|
+
self.nu = nu
|
|
364
|
+
self.rtol = rtol
|
|
365
|
+
self.maxiter = maxiter
|
|
366
|
+
self.coarse_cells = int(coarse_cells)
|
|
367
|
+
self.degree = int(degree)
|
|
368
|
+
self.dtype = np.dtype(dtype)
|
|
369
|
+
self.lu_max_dofs = int(lu_max_dofs)
|
|
370
|
+
cells = mesh.elem_ijk.max(axis=0) + 1
|
|
371
|
+
cells = cells + cells % 2
|
|
372
|
+
self.cells = tuple(int(c) for c in cells)
|
|
373
|
+
self.node_shape = tuple(int(c) + 1 for c in cells)
|
|
374
|
+
self.nn = int(np.prod(self.node_shape))
|
|
375
|
+
self.node_flat = np.ravel_multi_index(tuple(mesh.node_ijk.T), self.node_shape)
|
|
376
|
+
self.elem_ijk = mesh.elem_ijk
|
|
377
|
+
self.element = hex8_element()
|
|
378
|
+
self.ke = hex8_ke(nu, self.element) * mesh.pitch
|
|
379
|
+
fixed = np.asarray(fixed, bool)
|
|
380
|
+
fg = np.zeros((3, self.nn), bool)
|
|
381
|
+
for i in range(3):
|
|
382
|
+
fg[i, self.node_flat] = fixed[i::3]
|
|
383
|
+
self.fixed_grid = fg.reshape((3,) + self.node_shape)
|
|
384
|
+
self.springs = None
|
|
385
|
+
if springs is not None:
|
|
386
|
+
self.springs = self._to_grid_sparse(sp.csr_matrix(springs))
|
|
387
|
+
self.levels = []
|
|
388
|
+
self.strict = False
|
|
389
|
+
self.last_iters = 0
|
|
390
|
+
self._coarse = None
|
|
391
|
+
self._lmax_vec = {}
|
|
392
|
+
# torch copy of the hierarchy on a GPU (gpu.py), or None for numpy
|
|
393
|
+
from .gpu import resolve_device
|
|
394
|
+
|
|
395
|
+
dev = resolve_device(device)
|
|
396
|
+
self._dev = None
|
|
397
|
+
if dev is not None:
|
|
398
|
+
from .gpu import TorchMG
|
|
399
|
+
|
|
400
|
+
self._dev = TorchMG(self, dev)
|
|
401
|
+
|
|
402
|
+
# --- layout conversion -------------------------------------------------
|
|
403
|
+
|
|
404
|
+
def _to_grid_sparse(self, s):
|
|
405
|
+
g = np.empty(s.shape[0], np.int64)
|
|
406
|
+
for i in range(3):
|
|
407
|
+
g[i::3] = i * self.nn + self.node_flat
|
|
408
|
+
s = s.tocoo()
|
|
409
|
+
return sp.csr_matrix((s.data, (g[s.row], g[s.col])), shape=(3 * self.nn, 3 * self.nn))
|
|
410
|
+
|
|
411
|
+
def to_grid(self, v, dtype=np.float64):
|
|
412
|
+
out = np.zeros((3, self.nn), dtype)
|
|
413
|
+
for i in range(3):
|
|
414
|
+
out[i, self.node_flat] = v[i::3]
|
|
415
|
+
return out.reshape((3,) + self.node_shape)
|
|
416
|
+
|
|
417
|
+
def from_grid(self, g):
|
|
418
|
+
flat = g.reshape(3, -1)
|
|
419
|
+
out = np.empty(3 * len(self.node_flat))
|
|
420
|
+
for i in range(3):
|
|
421
|
+
out[i::3] = flat[i, self.node_flat]
|
|
422
|
+
return out
|
|
423
|
+
|
|
424
|
+
def cell_grid(self, e, dtype=np.float64):
|
|
425
|
+
g = np.zeros(self.cells, dtype)
|
|
426
|
+
g[tuple(self.elem_ijk.T)] = e
|
|
427
|
+
return g
|
|
428
|
+
|
|
429
|
+
# --- hierarchy ---------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
def set_moduli(self, e):
|
|
432
|
+
"""Per-element moduli (mesh order). Rebuilds every level.
|
|
433
|
+
|
|
434
|
+
Thin supports: masked prolongation (P' = D P) keeps a coarse dof whose
|
|
435
|
+
fine neighbours are held all but a thin skin, and a support face a
|
|
436
|
+
cell or two from the part's edge leaves several of them interpolating
|
|
437
|
+
onto the same few free fine dofs. Their columns are dependent and the
|
|
438
|
+
Galerkin operator is singular: an L-bracket held by its top 8 mm
|
|
439
|
+
factored "exactly singular" at pitch 0.9 and 1.12, and CG never
|
|
440
|
+
converged at 1.127, while 1.14 took 9 iterations. The strict
|
|
441
|
+
hierarchy (`self.strict`) holds and drops those coarse dofs and
|
|
442
|
+
converges on all of them in about 21, but takes 22 against 9 where
|
|
443
|
+
the plain one works, so it is the fallback: taken when the coarse
|
|
444
|
+
factor is singular or CG stalls (solve_many), and kept from then on."""
|
|
445
|
+
e = np.asarray(e, float)
|
|
446
|
+
self._e = e
|
|
447
|
+
dt = self.dtype
|
|
448
|
+
l0 = _Level("scaled", self.cells, np.float64)
|
|
449
|
+
l0.ke = self.ke.astype(np.float64)
|
|
450
|
+
l0.e = self.cell_grid(e)
|
|
451
|
+
l0.extra = self.springs
|
|
452
|
+
d0 = l0.diagonal()
|
|
453
|
+
alive = d0 > 1e-300
|
|
454
|
+
l0.mask = (alive & ~self.fixed_grid).astype(np.float64)
|
|
455
|
+
l0.dinv = np.where(l0.mask > 0, 1.0 / np.where(alive, d0, 1.0), 1.0)
|
|
456
|
+
self.alive = alive
|
|
457
|
+
self.levels = [l0]
|
|
458
|
+
# The V-cycle's copy of the fine level, in its precision.
|
|
459
|
+
self._l0s = _Level("scaled", self.cells, dt)
|
|
460
|
+
self._l0s.ke = self.ke.astype(dt)
|
|
461
|
+
self._l0s.e = l0.e.astype(dt)
|
|
462
|
+
self._l0s.extra = self.springs
|
|
463
|
+
self._l0s.mask = l0.mask.astype(dt)
|
|
464
|
+
self._l0s.dinv = l0.dinv.astype(dt)
|
|
465
|
+
fine = l0
|
|
466
|
+
n_active = int((e > 0).sum())
|
|
467
|
+
depth = 0
|
|
468
|
+
# Held dofs, carried down by injection, for the strict hierarchy.
|
|
469
|
+
held = alive & self.fixed_grid
|
|
470
|
+
while n_active > self.coarse_cells and min(fine.dims) >= 2:
|
|
471
|
+
coarse = self._coarsen(fine, first=(depth == 0))
|
|
472
|
+
depth += 1
|
|
473
|
+
d = coarse.diagonal()
|
|
474
|
+
dmax = float(d.max()) if d.size else 0.0
|
|
475
|
+
live = d > 1e-12 * max(dmax, 1e-300)
|
|
476
|
+
if self.strict:
|
|
477
|
+
# A coarse dof sitting on a held one is held too, and one that
|
|
478
|
+
# keeps less than an eighth of its interpolation on dofs the
|
|
479
|
+
# finer level solves for (8 inside the part) is dropped: it
|
|
480
|
+
# interpolates onto a sliver its neighbours cover too.
|
|
481
|
+
hc = np.zeros((3,) + coarse.node_shape[1:], bool)
|
|
482
|
+
inj = held[:, ::2, ::2, ::2]
|
|
483
|
+
hc[:, :inj.shape[1], :inj.shape[2], :inj.shape[3]] = inj[:, :hc.shape[1], :hc.shape[2], :hc.shape[3]]
|
|
484
|
+
held = hc
|
|
485
|
+
kept = np.zeros((3,) + coarse.node_shape[1:])
|
|
486
|
+
r = restrict(np.asarray(fine.mask, np.float64))
|
|
487
|
+
kept[:, :r.shape[1], :r.shape[2], :r.shape[3]] = r[:, :kept.shape[1], :kept.shape[2], :kept.shape[3]]
|
|
488
|
+
live &= ~held & (kept >= 1.0)
|
|
489
|
+
coarse.mask = live.astype(dt)
|
|
490
|
+
coarse.dinv = np.where(live, 1.0 / np.where(live, d, 1.0), 1.0).astype(dt)
|
|
491
|
+
n_active = self._active_cells(coarse)
|
|
492
|
+
self.levels.append(coarse)
|
|
493
|
+
fine = coarse
|
|
494
|
+
try:
|
|
495
|
+
self._build_coarse(self.levels[-1])
|
|
496
|
+
except RuntimeError: # "Factor is exactly singular"
|
|
497
|
+
if self.strict:
|
|
498
|
+
raise
|
|
499
|
+
self.strict = True
|
|
500
|
+
return self.set_moduli(e)
|
|
501
|
+
if self._dev is not None:
|
|
502
|
+
self._dev.load()
|
|
503
|
+
return
|
|
504
|
+
for i in range(len(self.levels)):
|
|
505
|
+
self._op(i).lmax = self._estimate_lmax(i)
|
|
506
|
+
|
|
507
|
+
def _active_cells(self, lv):
|
|
508
|
+
if lv.kind == "weights":
|
|
509
|
+
act = lv.w.any(axis=0)
|
|
510
|
+
if lv.patch_ijk is not None and len(lv.patch_ijk):
|
|
511
|
+
act[tuple(lv.patch_ijk.T)] = True
|
|
512
|
+
return int(act.sum())
|
|
513
|
+
return int(np.abs(lv.k).max(axis=(0, 1)).astype(bool).sum())
|
|
514
|
+
|
|
515
|
+
def _coarsen(self, fine, first):
|
|
516
|
+
dt = self.dtype
|
|
517
|
+
nx, ny, nz = fine.dims
|
|
518
|
+
half = (nx // 2, ny // 2, nz // 2)
|
|
519
|
+
# pad to even so the next level can halve it again (padding cells
|
|
520
|
+
# carry no stiffness, their nodes are masked out)
|
|
521
|
+
cdims = tuple(h + h % 2 if h > 1 else h for h in half)
|
|
522
|
+
mask_fine = fine.mask if first else None
|
|
523
|
+
if first:
|
|
524
|
+
# children: E of fine cell (2I + CORNERS[k]); cells touching a held
|
|
525
|
+
# dof go to the patch, with their masked matrices
|
|
526
|
+
held = (1 - fine.mask) * self.alive # free=0 & alive => held
|
|
527
|
+
held_node = held.any(axis=0)
|
|
528
|
+
touch = np.zeros(fine.dims, bool)
|
|
529
|
+
for (dx, dy, dz) in _CORNER_OFF:
|
|
530
|
+
touch |= held_node[dx:dx + nx, dy:dy + ny, dz:dz + nz]
|
|
531
|
+
touch &= fine.e > 0
|
|
532
|
+
w = np.zeros((8,) + cdims, np.float64)
|
|
533
|
+
hx, hy, hz = half
|
|
534
|
+
for k, (a, b, c) in enumerate(_CORNER_OFF):
|
|
535
|
+
w[k, :hx, :hy, :hz] = fine.e[a::2, b::2, c::2]
|
|
536
|
+
ptouch = np.zeros(cdims, bool)
|
|
537
|
+
for k, (a, b, c) in enumerate(_CORNER_OFF):
|
|
538
|
+
ptouch[:hx, :hy, :hz] |= touch[a::2, b::2, c::2]
|
|
539
|
+
lv = _Level("weights", cdims, dt)
|
|
540
|
+
mk = np.einsum("kai,ab,kbj->kij", _PK, fine.ke, _PK)
|
|
541
|
+
lv.mcat = mk.reshape(8 * 24, 24).astype(dt)
|
|
542
|
+
pij = np.argwhere(ptouch)
|
|
543
|
+
pk = np.zeros((len(pij), 24, 24))
|
|
544
|
+
fm = fine.mask
|
|
545
|
+
for k, (a, b, c) in enumerate(_CORNER_OFF):
|
|
546
|
+
fi = 2 * pij + np.array([a, b, c])
|
|
547
|
+
ec = fine.e[tuple(fi.T)]
|
|
548
|
+
dm = np.empty((len(pij), 24))
|
|
549
|
+
for q, (qx, qy, qz) in enumerate(_CORNER_OFF):
|
|
550
|
+
dm[:, 3 * q:3 * q + 3] = fm[:, fi[:, 0] + qx, fi[:, 1] + qy, fi[:, 2] + qz].T
|
|
551
|
+
kc = ec[:, None, None] * fine.ke[None] * dm[:, :, None] * dm[:, None, :]
|
|
552
|
+
pk += np.matmul(np.matmul(_PK[k].T, kc), _PK[k])
|
|
553
|
+
w[:, ptouch] = 0.0
|
|
554
|
+
lv.w = w.astype(dt)
|
|
555
|
+
lv.patch_ijk = pij
|
|
556
|
+
lv.patch_k = pk.astype(dt)
|
|
557
|
+
else:
|
|
558
|
+
lv = _Level("explicit", cdims, dt)
|
|
559
|
+
k = np.zeros((24, 24) + cdims, dt)
|
|
560
|
+
step = max(1, _slab(ny * nz * 24, np.float64) // 2) * 2
|
|
561
|
+
for a in range(0, nx, step):
|
|
562
|
+
b = min(a + step, nx)
|
|
563
|
+
kf = fine.cell_matrices(a, b) # (24, 24, m, ny, nz)
|
|
564
|
+
if self.strict and fine.mask is not None:
|
|
565
|
+
# the fine level's own masked operator (D K D): a dof it
|
|
566
|
+
# holds is not a direction the next level may use either
|
|
567
|
+
fm = fine.mask
|
|
568
|
+
dm = np.empty((24,) + kf.shape[2:])
|
|
569
|
+
for q, (qx, qy, qz) in enumerate(_CORNER_OFF):
|
|
570
|
+
dm[3 * q:3 * q + 3] = fm[:, a + qx:b + qx, qy:qy + ny, qz:qz + nz]
|
|
571
|
+
kf = kf * dm[:, None] * dm[None, :]
|
|
572
|
+
for ch, (ox, oy, oz) in enumerate(_CORNER_OFF):
|
|
573
|
+
sub = kf[:, :, ox::2, oy::2, oz::2]
|
|
574
|
+
shp = sub.shape[2:]
|
|
575
|
+
# P^T K P as two GEMMs
|
|
576
|
+
t = (_PK[ch].T @ sub.reshape(24, -1)).reshape(24, 24, -1) # (i, b, n)
|
|
577
|
+
t = np.tensordot(t, _PK[ch], axes=([1], [0])) # (i, n, j)
|
|
578
|
+
k[:, :, a // 2:a // 2 + shp[0], :shp[1], :shp[2]] += \
|
|
579
|
+
np.moveaxis(t, 2, 1).reshape((24, 24) + shp).astype(dt)
|
|
580
|
+
lv.k = k
|
|
581
|
+
lv.extra = self._coarse_extra(fine, cdims, mask_fine)
|
|
582
|
+
return lv
|
|
583
|
+
|
|
584
|
+
def _coarse_extra(self, fine, cdims, mask_fine):
|
|
585
|
+
s = fine.extra
|
|
586
|
+
if s is None or s.nnz == 0:
|
|
587
|
+
return None
|
|
588
|
+
nshape_f = fine.node_shape[1:]
|
|
589
|
+
nshape_c = tuple(c + 1 for c in cdims)
|
|
590
|
+
nn_f = int(np.prod(nshape_f))
|
|
591
|
+
nn_c = int(np.prod(nshape_c))
|
|
592
|
+
s = s.tocsr()
|
|
593
|
+
dofs = np.unique(np.concatenate([s.tocoo().row, s.tocoo().col]))
|
|
594
|
+
comp, node = np.divmod(dofs, nn_f)
|
|
595
|
+
ijk = np.stack(np.unravel_index(node, nshape_f), axis=1)
|
|
596
|
+
pr = _prolong_rows(ijk, nshape_c).tocoo()
|
|
597
|
+
cols = comp[pr.row] * nn_c + pr.col
|
|
598
|
+
vals = pr.data
|
|
599
|
+
if mask_fine is not None:
|
|
600
|
+
vals = vals * mask_fine.reshape(-1)[dofs[pr.row]]
|
|
601
|
+
p = sp.csr_matrix((vals, (pr.row, cols)), shape=(len(dofs), 3 * nn_c))
|
|
602
|
+
ss = s[dofs][:, dofs]
|
|
603
|
+
return (p.T @ ss @ p).tocsr()
|
|
604
|
+
|
|
605
|
+
def _build_coarse(self, lv):
|
|
606
|
+
"""Assemble the coarsest level and factor it (or build pyamg on it)."""
|
|
607
|
+
from .fem import HexMesh, Stiffness
|
|
608
|
+
|
|
609
|
+
nx, ny, nz = lv.dims
|
|
610
|
+
if lv is self.levels[0]:
|
|
611
|
+
self._coarse = None
|
|
612
|
+
return
|
|
613
|
+
kall = lv.cell_matrices(0, nx) # (24, 24, nx, ny, nz)
|
|
614
|
+
act = np.abs(kall).max(axis=(0, 1)) > 0
|
|
615
|
+
ijk = np.argwhere(act)
|
|
616
|
+
if len(ijk) == 0:
|
|
617
|
+
self._coarse = None
|
|
618
|
+
return
|
|
619
|
+
kes = np.moveaxis(kall[:, :, act], -1, 0) # (n, 24, 24)
|
|
620
|
+
hm = HexMesh(ijk, 1.0, np.zeros(3))
|
|
621
|
+
st = Stiffness(hm, 0.3, np.zeros(hm.n_dof, bool))
|
|
622
|
+
data = np.zeros(st.nnz)
|
|
623
|
+
st.add_elements(data, kes, np.arange(len(ijk)))
|
|
624
|
+
k = sp.csr_matrix((data, st.indices, st.indptr), shape=(hm.n_dof, hm.n_dof))
|
|
625
|
+
nshape = lv.node_shape[1:]
|
|
626
|
+
nn = int(np.prod(nshape))
|
|
627
|
+
gnode = np.ravel_multi_index(tuple(hm.node_ijk.T), nshape)
|
|
628
|
+
gdof = np.empty(hm.n_dof, np.int64)
|
|
629
|
+
for i in range(3):
|
|
630
|
+
gdof[i::3] = i * nn + gnode
|
|
631
|
+
if lv.extra is not None:
|
|
632
|
+
k = (k + lv.extra[gdof][:, gdof]).tocsr()
|
|
633
|
+
m = lv.mask.reshape(-1)[gdof].astype(np.float64)
|
|
634
|
+
dm = sp.diags(m)
|
|
635
|
+
k = (dm @ k @ dm + sp.diags(1.0 - m)).tocsr()
|
|
636
|
+
k = 0.5 * (k + k.T)
|
|
637
|
+
self._coarse = {"gdof": gdof, "n": hm.n_dof}
|
|
638
|
+
if hm.n_dof <= self.lu_max_dofs:
|
|
639
|
+
from scipy.sparse.linalg import splu
|
|
640
|
+
|
|
641
|
+
# SPD: symmetric mode without pivoting. Partial pivoting on these
|
|
642
|
+
# high-contrast matrices quadrupled the fill (6 s against 0.5 s
|
|
643
|
+
# for 7k dofs).
|
|
644
|
+
self._coarse["lu"] = splu(k.tocsc(), permc_spec="MMD_AT_PLUS_A", diag_pivot_thresh=0.0,
|
|
645
|
+
options={"SymmetricMode": True})
|
|
646
|
+
else:
|
|
647
|
+
from .fem import amg_solver
|
|
648
|
+
|
|
649
|
+
ml = amg_solver(k, B=rigid_body_modes(hm.node_ijk.astype(float)), symmetry="hermitian",
|
|
650
|
+
strength="symmetric", max_coarse=400, coarse_solver="splu")
|
|
651
|
+
self._coarse["ml"] = ml
|
|
652
|
+
|
|
653
|
+
def _coarse_solve(self, b):
|
|
654
|
+
c = self._coarse
|
|
655
|
+
flat = b.reshape(-1)
|
|
656
|
+
rhs = flat[c["gdof"]].astype(np.float64)
|
|
657
|
+
if "lu" in c:
|
|
658
|
+
x = c["lu"].solve(rhs)
|
|
659
|
+
else:
|
|
660
|
+
x = c["ml"].solve(rhs, x0=np.zeros_like(rhs), tol=1e-30, maxiter=1, cycle="V")
|
|
661
|
+
out = np.zeros_like(flat)
|
|
662
|
+
out[c["gdof"]] = x
|
|
663
|
+
return out.reshape(b.shape)
|
|
664
|
+
|
|
665
|
+
def _op(self, i):
|
|
666
|
+
return self._l0s if i == 0 else self.levels[i]
|
|
667
|
+
|
|
668
|
+
def _estimate_lmax(self, i, its=12):
|
|
669
|
+
"""Largest eigenvalue of D^-1 A at level i, by power iteration (a
|
|
670
|
+
Chebyshev smoother needs an upper bound; see LMAX_SAFETY)."""
|
|
671
|
+
lv = self._op(i)
|
|
672
|
+
prev = self._lmax_vec.get(i)
|
|
673
|
+
if prev is not None and prev.shape == lv.node_shape:
|
|
674
|
+
# warm start from the last hierarchy's vector. Not fewer
|
|
675
|
+
# iterations: cutting them to 4 underestimated the bound once
|
|
676
|
+
# cells dropped out of the design, and the CG count tripled.
|
|
677
|
+
v = prev.astype(lv.dinv.dtype) * lv.mask
|
|
678
|
+
else:
|
|
679
|
+
rng = np.random.default_rng(i)
|
|
680
|
+
v = (rng.random(lv.node_shape) - 0.5).astype(lv.dinv.dtype) * lv.mask
|
|
681
|
+
lam = 1.0
|
|
682
|
+
for _ in range(its):
|
|
683
|
+
nv = np.linalg.norm(v)
|
|
684
|
+
if nv == 0:
|
|
685
|
+
return 2.0
|
|
686
|
+
v /= nv
|
|
687
|
+
av = lv.dinv * lv.apply(v) * lv.mask
|
|
688
|
+
lam = float(np.vdot(v, av))
|
|
689
|
+
v = av
|
|
690
|
+
self._lmax_vec[i] = v
|
|
691
|
+
return LMAX_SAFETY * max(lam, 1e-12)
|
|
692
|
+
|
|
693
|
+
def _smooth(self, lv, b, x):
|
|
694
|
+
"""Chebyshev iteration on D^-1 A over [lmax/30, lmax]."""
|
|
695
|
+
hi = lv.lmax
|
|
696
|
+
lo = hi / 30.0
|
|
697
|
+
theta = 0.5 * (hi + lo)
|
|
698
|
+
delta = 0.5 * (hi - lo)
|
|
699
|
+
sigma = theta / delta
|
|
700
|
+
rho = 1.0 / sigma
|
|
701
|
+
r = b - lv.apply(x) if x is not None else b.copy()
|
|
702
|
+
if x is None:
|
|
703
|
+
x = np.zeros_like(b)
|
|
704
|
+
d = lv.dinv * r / theta
|
|
705
|
+
for it in range(self.degree):
|
|
706
|
+
x += d
|
|
707
|
+
if it + 1 == self.degree:
|
|
708
|
+
break
|
|
709
|
+
r -= lv.apply(d)
|
|
710
|
+
rho_new = 1.0 / (2.0 * sigma - rho)
|
|
711
|
+
d = rho_new * rho * d + (2.0 * rho_new / delta) * (lv.dinv * r)
|
|
712
|
+
rho = rho_new
|
|
713
|
+
return x
|
|
714
|
+
|
|
715
|
+
def _vcycle(self, i, b):
|
|
716
|
+
lv = self._op(i)
|
|
717
|
+
if i == len(self.levels) - 1:
|
|
718
|
+
if self._coarse is not None:
|
|
719
|
+
return self._coarse_solve(b)
|
|
720
|
+
x = self._smooth(lv, b, None)
|
|
721
|
+
return self._smooth(lv, b, x)
|
|
722
|
+
x = self._smooth(lv, b, None)
|
|
723
|
+
r = b - lv.apply(x)
|
|
724
|
+
cs = self._op(i + 1).node_shape
|
|
725
|
+
rf = np.zeros((3,) + tuple(2 * n - 1 for n in cs[1:]), r.dtype)
|
|
726
|
+
sx, sy, sz = lv.node_shape[1:]
|
|
727
|
+
rf[:, :sx, :sy, :sz] = r * lv.mask
|
|
728
|
+
# the coarse level's masked dofs are identity rows: kept out of the
|
|
729
|
+
# residual it solves and of the correction it sends back
|
|
730
|
+
cm = self._op(i + 1).mask
|
|
731
|
+
xc = self._vcycle(i + 1, (restrict(rf) * cm).astype(self.dtype, copy=False))
|
|
732
|
+
x += prolong(xc * cm)[:, :sx, :sy, :sz] * lv.mask
|
|
733
|
+
return self._smooth(lv, b, x)
|
|
734
|
+
|
|
735
|
+
def precondition(self, r):
|
|
736
|
+
z = self._vcycle(0, r.astype(self.dtype))
|
|
737
|
+
return z.astype(np.float64)
|
|
738
|
+
|
|
739
|
+
# --- solve -------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
def apply(self, u):
|
|
742
|
+
"""The float64 operator on a node-grid field."""
|
|
743
|
+
return self.levels[0].apply(u)
|
|
744
|
+
|
|
745
|
+
def _pcg(self, b, x0):
|
|
746
|
+
a = self.levels[0]
|
|
747
|
+
m = a.mask
|
|
748
|
+
x = np.zeros(a.node_shape) if x0 is None else x0.copy()
|
|
749
|
+
bnorm = float(np.linalg.norm(b))
|
|
750
|
+
if bnorm == 0:
|
|
751
|
+
return np.zeros_like(b), 0, True
|
|
752
|
+
r = b.copy()
|
|
753
|
+
if x0 is not None:
|
|
754
|
+
# Scale the warm start to its energy-optimal multiple, and drop
|
|
755
|
+
# it if even that is worse than zero: after a projection step or
|
|
756
|
+
# a cut of the live cells the last displacement can start 8x
|
|
757
|
+
# further off than zero does (35 CG iterations against 15).
|
|
758
|
+
ax = a.apply(x)
|
|
759
|
+
xax = float(np.vdot(x, ax))
|
|
760
|
+
s = float(np.vdot(b, x)) / xax if xax > 0 else 0.0
|
|
761
|
+
if s > 0:
|
|
762
|
+
rs = b - s * ax
|
|
763
|
+
if np.linalg.norm(rs) < bnorm:
|
|
764
|
+
x *= s
|
|
765
|
+
r = rs
|
|
766
|
+
else:
|
|
767
|
+
x[:] = 0
|
|
768
|
+
else:
|
|
769
|
+
x[:] = 0
|
|
770
|
+
z = self.precondition(r)
|
|
771
|
+
p = z.copy()
|
|
772
|
+
rz = float(np.vdot(r, z))
|
|
773
|
+
tol = self.rtol * bnorm
|
|
774
|
+
for it in range(1, self.maxiter + 1):
|
|
775
|
+
ap = a.apply(p)
|
|
776
|
+
pap = float(np.vdot(p, ap))
|
|
777
|
+
if pap <= 0:
|
|
778
|
+
return x, it, False
|
|
779
|
+
alpha = rz / pap
|
|
780
|
+
x += alpha * p
|
|
781
|
+
r -= alpha * ap
|
|
782
|
+
if float(np.linalg.norm(r)) <= tol:
|
|
783
|
+
return x, it, True
|
|
784
|
+
z = self.precondition(r)
|
|
785
|
+
rz_new = float(np.vdot(r, z))
|
|
786
|
+
p *= rz_new / rz
|
|
787
|
+
p += z
|
|
788
|
+
rz = rz_new
|
|
789
|
+
del m
|
|
790
|
+
return x, self.maxiter, False
|
|
791
|
+
|
|
792
|
+
# CG iterations the plain hierarchy gets before solve_many falls back to
|
|
793
|
+
# the strict one (it takes 9-40 where it works; the strict one about 21)
|
|
794
|
+
STALL = 100
|
|
795
|
+
|
|
796
|
+
def solve_many(self, fs, x0s=None):
|
|
797
|
+
"""Solve for each right-hand side (mesh dof numbering). Rows of dofs
|
|
798
|
+
that carry no stiffness are returned as zero."""
|
|
799
|
+
x0s = x0s or [None] * len(fs)
|
|
800
|
+
out = []
|
|
801
|
+
iters = 0
|
|
802
|
+
for f, x0 in zip(fs, x0s):
|
|
803
|
+
b = self.to_grid(f)
|
|
804
|
+
b *= self.levels[0].mask
|
|
805
|
+
g0 = self.to_grid(x0) * self.levels[0].mask if x0 is not None else None
|
|
806
|
+
x, it, ok = self._solve(b, g0)
|
|
807
|
+
iters = max(iters, it)
|
|
808
|
+
if not ok:
|
|
809
|
+
raise RuntimeError(f"linear solve did not converge ({it} iterations); "
|
|
810
|
+
"the structure may be a mechanism (check supports)")
|
|
811
|
+
out.append(self.from_grid(x * self.levels[0].mask))
|
|
812
|
+
self.last_iters = iters
|
|
813
|
+
return out
|
|
814
|
+
|
|
815
|
+
def _solve(self, b, g0):
|
|
816
|
+
if self.strict or len(self.levels) < 2:
|
|
817
|
+
return self._dev.solve(b, g0) if self._dev is not None else self._pcg(b, g0)
|
|
818
|
+
full = self.maxiter
|
|
819
|
+
self.maxiter = min(full, self.STALL)
|
|
820
|
+
try:
|
|
821
|
+
x, it, ok = self._dev.solve(b, g0) if self._dev is not None else self._pcg(b, g0)
|
|
822
|
+
finally:
|
|
823
|
+
self.maxiter = full
|
|
824
|
+
if ok:
|
|
825
|
+
return x, it, ok
|
|
826
|
+
self.strict = True
|
|
827
|
+
self.set_moduli(self._e)
|
|
828
|
+
x, it2, ok = self._dev.solve(b, g0) if self._dev is not None else self._pcg(b, g0)
|
|
829
|
+
return x, it + it2, ok
|
|
830
|
+
|
|
831
|
+
def live_dofs(self):
|
|
832
|
+
"""Bool per mesh dof: carries stiffness in the current moduli."""
|
|
833
|
+
a = self.alive.reshape(3, -1)
|
|
834
|
+
out = np.empty(3 * len(self.node_flat), bool)
|
|
835
|
+
for i in range(3):
|
|
836
|
+
out[i::3] = a[i, self.node_flat]
|
|
837
|
+
return out
|
|
838
|
+
|
|
839
|
+
def strain_energy(self, u):
|
|
840
|
+
"""Per element u_e^T Ke u_e at unit modulus (mesh order)."""
|
|
841
|
+
g = self.to_grid(u)
|
|
842
|
+
nx, ny, nz = self.cells
|
|
843
|
+
se = np.zeros(self.cells)
|
|
844
|
+
step = _slab(ny * nz, np.float64)
|
|
845
|
+
ke = self.ke
|
|
846
|
+
for a in range(0, nx, step):
|
|
847
|
+
b = min(a + step, nx)
|
|
848
|
+
buf = np.empty((24, b - a, ny, nz))
|
|
849
|
+
_gather(g, (a, b, 0, ny, 0, nz), buf)
|
|
850
|
+
flat = buf.reshape(24, -1)
|
|
851
|
+
se[a:b] = np.einsum("in,in->n", flat, ke @ flat).reshape(b - a, ny, nz)
|
|
852
|
+
return se[tuple(self.elem_ijk.T)]
|
|
853
|
+
|