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/simp.py
ADDED
|
@@ -0,0 +1,1170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SIMP topology optimization: minimize weighted compliance (maximize
|
|
3
|
+
stiffness) over every load case, for a fixed volume of material.
|
|
4
|
+
|
|
5
|
+
The pieces, each standard and each here for a reason:
|
|
6
|
+
|
|
7
|
+
- Density per cell x in [0, 1], stiffness E(x) = Emin + x^p (1 - Emin), p = 3,
|
|
8
|
+
so intermediate densities are inefficient and the optimizer avoids them.
|
|
9
|
+
- A cone density filter of radius R (mm). It is what gives the result a
|
|
10
|
+
minimum member size and stops checkerboarding; `minMember` sets R.
|
|
11
|
+
- A Heaviside projection with continuation (beta 1 -> 32) that pushes the
|
|
12
|
+
filtered field to crisp 0/1, so the part we mesh is the part we analyzed
|
|
13
|
+
rather than a grey cloud thresholded after the fact.
|
|
14
|
+
- Optimality-criteria updates with a bisection on the volume multiplier,
|
|
15
|
+
measured on the projected densities.
|
|
16
|
+
- Symmetry by averaging sensitivities and densities across mirror cells.
|
|
17
|
+
|
|
18
|
+
Preserved cells are pinned to 1 and still feed the filter, which is what
|
|
19
|
+
makes members grow into bosses instead of stopping a cell short of them.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import time
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import scipy.sparse as sp
|
|
28
|
+
|
|
29
|
+
from scipy import ndimage
|
|
30
|
+
|
|
31
|
+
from .directions import collapse, expand
|
|
32
|
+
from .domain import Domain
|
|
33
|
+
from .fem import HexMesh, Solver, Stiffness, hex8_ke
|
|
34
|
+
from .manufacture import AccessFilter, MaxMember, machinable, tie_groups
|
|
35
|
+
|
|
36
|
+
import os
|
|
37
|
+
|
|
38
|
+
__all__ = ["SimpOptions", "SimpResult", "filter_radius", "density_filter", "optimize"]
|
|
39
|
+
|
|
40
|
+
E_MIN = 1e-6
|
|
41
|
+
# The search's buckling penalty acts on load cases whose buckling factor is
|
|
42
|
+
# under 1 / (1 - BUCKLE_MMA) times the limit, from projection sharpness
|
|
43
|
+
# BUCKLE_BETA on (a grey design reads far below its sharpened self: the hook
|
|
44
|
+
# 0.65 at its first iteration, 4.7 at its last).
|
|
45
|
+
BUCKLE_MMA = 0.15
|
|
46
|
+
BUCKLE_BETA = 8.0
|
|
47
|
+
PENALTY = 3.0
|
|
48
|
+
# Cells below this density are left out of the linear solve (see _Analysis).
|
|
49
|
+
ALIVE = 1e-3
|
|
50
|
+
# ...but only once the design has emptied out enough for it to pay.
|
|
51
|
+
REDUCE_BELOW = 0.8
|
|
52
|
+
_CONN26 = np.ones((3, 3, 3), bool)
|
|
53
|
+
# From this many cells up the analysis is matrix-free geometric multigrid
|
|
54
|
+
# (mg.GridElasticity) and the density filter a grid stencil (GridFilter):
|
|
55
|
+
# the assembled stiffness and sparse filter cost ~3 KB per cell and pyamg's
|
|
56
|
+
# setup grows faster than the grid. Below it the assembled path stays, so
|
|
57
|
+
# every eval case (all at 80k cells or fewer) runs exactly as before.
|
|
58
|
+
# LOADPATH_SOLVER=mg|assembled forces one or the other.
|
|
59
|
+
MG_MIN_CELLS = 100_000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _use_mg(n_cells: int) -> bool:
|
|
63
|
+
force = os.environ.get("LOADPATH_SOLVER", "").lower()
|
|
64
|
+
if force in ("mg", "multigrid"):
|
|
65
|
+
return True
|
|
66
|
+
if force in ("assembled", "amg"):
|
|
67
|
+
return False
|
|
68
|
+
return n_cells >= MG_MIN_CELLS
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _Analysis:
|
|
72
|
+
"""The FE solves inside the loop, on the cells that still carry material.
|
|
73
|
+
|
|
74
|
+
Once a design has settled, most of the domain is void at E_MIN: those
|
|
75
|
+
cells add unknowns, make the system badly conditioned (stiffness ratios
|
|
76
|
+
of a million to one), and contribute nothing. Each iteration we solve on
|
|
77
|
+
the cells above ALIVE, plus anything touching a load or support, keeping
|
|
78
|
+
only the pieces connected to a support (a floating island would make the
|
|
79
|
+
matrix singular; its zero sensitivity lets it fade away). Void cells
|
|
80
|
+
bordering the structure still get sensitivities from the nodes they share
|
|
81
|
+
with it, which is what lets the boundary move. If a load would end up
|
|
82
|
+
disconnected, we fall back to the full system for that iteration."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, domain: Domain, nu: float, rtol: float):
|
|
85
|
+
self.domain = domain
|
|
86
|
+
self.nu = nu
|
|
87
|
+
self.rtol = rtol
|
|
88
|
+
mesh = domain.mesh
|
|
89
|
+
self.ke = hex8_ke(nu) * mesh.pitch
|
|
90
|
+
self.support_touch = domain.support_nodes[mesh.enode].any(axis=1)
|
|
91
|
+
self.anchor = self.support_touch | domain.load_nodes[mesh.enode].any(axis=1) | domain.preserve
|
|
92
|
+
self._full = None
|
|
93
|
+
self._sub = None
|
|
94
|
+
self.last_iters = 0
|
|
95
|
+
self.last_cells = mesh.n_elem
|
|
96
|
+
self._mg = None
|
|
97
|
+
if _use_mg(mesh.n_elem):
|
|
98
|
+
from .mg import GridElasticity
|
|
99
|
+
|
|
100
|
+
self._mg = GridElasticity(mesh, nu, domain.fixed, springs=domain.springs, rtol=rtol, maxiter=2000)
|
|
101
|
+
|
|
102
|
+
def _full_system(self):
|
|
103
|
+
if self._full is None:
|
|
104
|
+
mesh = self.domain.mesh
|
|
105
|
+
self._full = (Stiffness(mesh, self.nu, self.domain.fixed), Solver(mesh, rtol=self.rtol))
|
|
106
|
+
return self._full
|
|
107
|
+
|
|
108
|
+
def _connected(self, alive):
|
|
109
|
+
mesh = self.domain.mesh
|
|
110
|
+
g = np.zeros(self.domain.dims, bool)
|
|
111
|
+
ijk = mesh.elem_ijk
|
|
112
|
+
g[tuple(ijk[alive].T)] = True
|
|
113
|
+
lbl, _ = ndimage.label(g, structure=_CONN26)
|
|
114
|
+
el = lbl[tuple(ijk.T)]
|
|
115
|
+
keep_l = np.unique(el[alive & self.support_touch])
|
|
116
|
+
return alive & np.isin(el, keep_l[keep_l > 0])
|
|
117
|
+
|
|
118
|
+
# Reuse one reduced system (its CSR layout and its multigrid hierarchy)
|
|
119
|
+
# while the live cells stay inside it, for at most REUSE iterations.
|
|
120
|
+
# Measured on the arm link: rebuilding is not the bottleneck (the CG
|
|
121
|
+
# iterations are), and dilating the live set so it can be reused more
|
|
122
|
+
# often made each iteration slower (bigger system), so DILATE is off.
|
|
123
|
+
REUSE = 6
|
|
124
|
+
DILATE = False
|
|
125
|
+
|
|
126
|
+
def _subsystem(self, alive, rtol):
|
|
127
|
+
d = self.domain
|
|
128
|
+
mesh = d.mesh
|
|
129
|
+
c = self._sub
|
|
130
|
+
if c is not None and c["age"] < self.REUSE and not np.any(alive & ~c["keep"]):
|
|
131
|
+
c["age"] += 1
|
|
132
|
+
return c["keep"], c["fd"], c["stiff"], c["solver"]
|
|
133
|
+
g = np.zeros(d.dims, bool)
|
|
134
|
+
ijk = tuple(mesh.elem_ijk.T)
|
|
135
|
+
g[ijk] = alive
|
|
136
|
+
grown = ndimage.binary_dilation(g, structure=_CONN26)[ijk] if self.DILATE else alive
|
|
137
|
+
keep = self._connected(grown)
|
|
138
|
+
sub = HexMesh(mesh.elem_ijk[keep], mesh.pitch, mesh.origin)
|
|
139
|
+
full_node = mesh.node_id(sub.node_ijk)
|
|
140
|
+
fd = (3 * full_node[:, None] + np.arange(3)).ravel()
|
|
141
|
+
in_sub = np.zeros(mesh.n_dof, bool)
|
|
142
|
+
in_sub[fd] = True
|
|
143
|
+
if any(np.any(f[~in_sub]) for f in d.loads):
|
|
144
|
+
self._sub = None
|
|
145
|
+
return None
|
|
146
|
+
# (Self-weight on cells left out is negligible: they are below ALIVE
|
|
147
|
+
# density, so it is dropped with them.)
|
|
148
|
+
self._sub = {"keep": keep, "fd": fd, "stiff": Stiffness(sub, self.nu, d.fixed[fd]),
|
|
149
|
+
"solver": Solver(sub, rtol=rtol), "age": 0}
|
|
150
|
+
return keep, fd, self._sub["stiff"], self._sub["solver"]
|
|
151
|
+
|
|
152
|
+
def solve(self, e, xp, us, rtol=None, loads=None):
|
|
153
|
+
"""Solve every load case for moduli e. Returns per-case displacement on
|
|
154
|
+
the full node set. `loads` overrides the domain's (self-weight makes
|
|
155
|
+
them depend on the design)."""
|
|
156
|
+
d = self.domain
|
|
157
|
+
loads = loads if loads is not None else d.loads
|
|
158
|
+
mesh = d.mesh
|
|
159
|
+
rtol = rtol or self.rtol
|
|
160
|
+
alive = (xp > ALIVE) | self.anchor
|
|
161
|
+
if self._mg is not None:
|
|
162
|
+
return self._solve_mg(e, alive, us, rtol, loads)
|
|
163
|
+
self._last_e = e
|
|
164
|
+
if alive.mean() < REDUCE_BELOW:
|
|
165
|
+
sub = self._subsystem(alive, rtol)
|
|
166
|
+
subs = None
|
|
167
|
+
if sub is not None:
|
|
168
|
+
keep, fd, stiff, solver = sub
|
|
169
|
+
solver.rtol = rtol
|
|
170
|
+
k = stiff.assemble(e[keep])
|
|
171
|
+
if d.springs is not None:
|
|
172
|
+
k = (k + d.springs[fd][:, fd]).tocsr()
|
|
173
|
+
try:
|
|
174
|
+
subs = solver.solve_many(k, [f[fd] for f in loads],
|
|
175
|
+
[u[fd] if u is not None else None for u in us])
|
|
176
|
+
except RuntimeError:
|
|
177
|
+
# The live cells are joined to the rest by an edge or a
|
|
178
|
+
# corner somewhere (26-connected but a hinge): singular.
|
|
179
|
+
# The full system, void at E_MIN, holds the hinge.
|
|
180
|
+
self._sub = None
|
|
181
|
+
if subs is not None:
|
|
182
|
+
out = []
|
|
183
|
+
for us_sub in subs:
|
|
184
|
+
u = np.zeros(mesh.n_dof)
|
|
185
|
+
u[fd] = us_sub
|
|
186
|
+
out.append(u)
|
|
187
|
+
self.last_iters, self.last_cells = solver.last_iters, int(keep.sum())
|
|
188
|
+
self._last = (k, fd, solver)
|
|
189
|
+
return out
|
|
190
|
+
stiff, solver = self._full_system()
|
|
191
|
+
solver.rtol = rtol
|
|
192
|
+
k = stiff.assemble(e)
|
|
193
|
+
if d.springs is not None:
|
|
194
|
+
k = (k + d.springs).tocsr()
|
|
195
|
+
out = solver.solve_many(k, list(loads), list(us))
|
|
196
|
+
self.last_iters, self.last_cells = solver.last_iters, mesh.n_elem
|
|
197
|
+
self._last = (k, None, solver)
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
def _solve_mg(self, e, alive, us, rtol, loads):
|
|
201
|
+
"""The multigrid path: the same reduction (cells below ALIVE and
|
|
202
|
+
islands left out), done by zeroing their moduli on the fixed grid
|
|
203
|
+
rather than building a smaller mesh."""
|
|
204
|
+
mg = self._mg
|
|
205
|
+
mg.rtol = rtol
|
|
206
|
+
keep = self._connected(alive) if alive.mean() < REDUCE_BELOW else np.ones(len(e), bool)
|
|
207
|
+
try:
|
|
208
|
+
mg.set_moduli(np.where(keep, e, 0.0))
|
|
209
|
+
if not keep.all() and any(np.any(f[~mg.live_dofs()]) for f in loads):
|
|
210
|
+
keep = np.ones(len(e), bool)
|
|
211
|
+
mg.set_moduli(e)
|
|
212
|
+
out = mg.solve_many(list(loads), list(us))
|
|
213
|
+
except RuntimeError:
|
|
214
|
+
if keep.all():
|
|
215
|
+
raise
|
|
216
|
+
# The live cells hang on the rest by an edge or a corner (a hinge),
|
|
217
|
+
# or a coarse level lost a thin support: singular or stalled. The
|
|
218
|
+
# full grid, void at E_MIN, holds it (as in the direct path).
|
|
219
|
+
keep = np.ones(len(e), bool)
|
|
220
|
+
mg.set_moduli(e)
|
|
221
|
+
out = mg.solve_many(list(loads), [None] * len(loads))
|
|
222
|
+
self.last_iters, self.last_cells = mg.last_iters, int(keep.sum())
|
|
223
|
+
self._last = None
|
|
224
|
+
self._mg_reduced = not keep.all()
|
|
225
|
+
self._last_e = e
|
|
226
|
+
return out
|
|
227
|
+
|
|
228
|
+
def resolve(self, rhss, x0s=None):
|
|
229
|
+
"""Solve more right-hand sides (adjoints) against the matrix of the
|
|
230
|
+
last solve(); full-size in, full-size out."""
|
|
231
|
+
if self._mg is not None:
|
|
232
|
+
try:
|
|
233
|
+
return self._mg.solve_many(list(rhss), x0s)
|
|
234
|
+
except RuntimeError:
|
|
235
|
+
if not getattr(self, "_mg_reduced", False):
|
|
236
|
+
raise
|
|
237
|
+
# a hinge the loads did not excite but an adjoint does (see
|
|
238
|
+
# solve): solve on the full grid, void at E_MIN
|
|
239
|
+
self._mg_reduced = False
|
|
240
|
+
self._mg.set_moduli(self._last_e)
|
|
241
|
+
return self._mg.solve_many(list(rhss), None)
|
|
242
|
+
k, fd, solver = self._last
|
|
243
|
+
n_dof = self.domain.mesh.n_dof
|
|
244
|
+
if fd is None:
|
|
245
|
+
return solver.solve_many(k, list(rhss), x0s)
|
|
246
|
+
try:
|
|
247
|
+
sub = solver.solve_many(k, [r[fd] for r in rhss],
|
|
248
|
+
[x[fd] for x in x0s] if x0s else None)
|
|
249
|
+
except RuntimeError:
|
|
250
|
+
# a hinge the loads did not excite but an adjoint does (see
|
|
251
|
+
# solve): solve on the full system instead
|
|
252
|
+
self._sub = None
|
|
253
|
+
stiff, solver = self._full_system()
|
|
254
|
+
k = stiff.assemble(self._last_e)
|
|
255
|
+
if self.domain.springs is not None:
|
|
256
|
+
k = (k + self.domain.springs).tocsr()
|
|
257
|
+
self._last = (k, None, solver)
|
|
258
|
+
return solver.solve_many(k, list(rhss), x0s)
|
|
259
|
+
out = []
|
|
260
|
+
for v in sub:
|
|
261
|
+
u = np.zeros(n_dof)
|
|
262
|
+
u[fd] = v
|
|
263
|
+
out.append(u)
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
def strain_energy(self, u):
|
|
267
|
+
if self._mg is not None:
|
|
268
|
+
return self._mg.strain_energy(u)
|
|
269
|
+
from . import femops
|
|
270
|
+
|
|
271
|
+
if femops.available():
|
|
272
|
+
return femops.element_energy(u, None, self.domain.mesh.edof, self.ke)
|
|
273
|
+
ue = u[self.domain.mesh.edof]
|
|
274
|
+
return np.einsum("ij,jk,ik->i", ue, self.ke, ue)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass
|
|
278
|
+
class SimpOptions:
|
|
279
|
+
volume_fraction: float
|
|
280
|
+
filter_radius: float # mm
|
|
281
|
+
max_iterations: int = 120
|
|
282
|
+
beta_max: float = 32.0
|
|
283
|
+
beta_every: int = 10 # iterations per beta doubling at most (15 cost 20% more iterations for the same design on the arm link)
|
|
284
|
+
# Iterations per doubling by projection level (beta 1->2, 2->4, ...),
|
|
285
|
+
# then beta_every past its end; None uses beta_every throughout. (20, 20)
|
|
286
|
+
# is stiffer on every benchmark (docs/BENCHMARKS.md: MBB -2.5%, bridge
|
|
287
|
+
# -11%, 1.4x the iterations) but not lighter at a safety factor: on the
|
|
288
|
+
# evals it was 2-14% heavier at the target SF on the small parts, so it
|
|
289
|
+
# is not the default.
|
|
290
|
+
beta_schedule: tuple | None = None
|
|
291
|
+
move: float = 0.2
|
|
292
|
+
tol: float = 0.01
|
|
293
|
+
# Sensitivities only need a loose solve; the final analysis is tight.
|
|
294
|
+
solve_rtol: float = 1e-3
|
|
295
|
+
adaptive_rtol: bool = True
|
|
296
|
+
# Stop once beta is at max and compliance has moved less than this
|
|
297
|
+
# (relative) over the last `stall_window` iterations. Projected designs
|
|
298
|
+
# can chatter at the move limit forever on a handful of boundary cells
|
|
299
|
+
# while the compliance is flat, so `change` alone never settles.
|
|
300
|
+
stall_rtol: float = 2e-3
|
|
301
|
+
stall_window: int = 6
|
|
302
|
+
eta: float = 0.5
|
|
303
|
+
# Stiffness is credited to the ERODED design (projected at eta + this),
|
|
304
|
+
# while volume is counted on the blueprint (eta) we deliver. A member
|
|
305
|
+
# that vanishes under erosion earns nothing, so the optimizer stops
|
|
306
|
+
# building sub-minimum sheets: the eroded-design half of the robust
|
|
307
|
+
# formulation (Wang, Lazarov & Sigmund 2011), at no extra solve per
|
|
308
|
+
# iteration. Round 2 of the evals still grew 0.8mm skins without it.
|
|
309
|
+
erode: float = 0.15
|
|
310
|
+
# The other half, for holes: the delivered part is CLOSED (dilated at
|
|
311
|
+
# eta - this, eroded back) with the same filter, so a hole or gap
|
|
312
|
+
# narrower than ~0.94 R comes out filled. 0 = off (holes of any size);
|
|
313
|
+
# minHole turns it on (see filter_radius). Counting the volume on the
|
|
314
|
+
# dilated design inside the loop instead (the textbook robust
|
|
315
|
+
# formulation, target rescaled to the blueprint) left a sealed-tube test
|
|
316
|
+
# with a quarter of its blueprint in members thinner than the erosion:
|
|
317
|
+
# 0.185 delivered for 0.25 asked and 2.7x the compliance (2026-10-30).
|
|
318
|
+
# Closing at delivery adds a little mass the verification then weighs.
|
|
319
|
+
dilate: float = 0.0
|
|
320
|
+
# bool per element pinned void (escape holes drilled into a sealed void),
|
|
321
|
+
# or None; see escape.py
|
|
322
|
+
void: np.ndarray | None = None
|
|
323
|
+
# escape.EscapeOptions: drill escape holes into the voids the design
|
|
324
|
+
# seals, then re-converge round them; or None
|
|
325
|
+
escape: object = None
|
|
326
|
+
x0: np.ndarray | None = None # warm start (design densities)
|
|
327
|
+
# (axis, sign) layers stack along, or None: see am.py. Every density the
|
|
328
|
+
# optimizer sees is then the printable one.
|
|
329
|
+
build_direction: tuple[int, int] | None = None
|
|
330
|
+
# Axis index the part is extruded along (2.5D: cut from plate), or None.
|
|
331
|
+
extrusion: int | None = None
|
|
332
|
+
# Milling / casting access (manufacture.Access), a circular pattern
|
|
333
|
+
# (manufacture.Pattern), and the thickest member (mm): see manufacture.py.
|
|
334
|
+
access: object = None
|
|
335
|
+
pattern: object = None
|
|
336
|
+
max_member: float | None = None
|
|
337
|
+
# A warm start is already a design; restarting projection at beta 1
|
|
338
|
+
# would blur it back to grey before sharpening it again.
|
|
339
|
+
beta_start: float = 1.0
|
|
340
|
+
# Remove delivered material that carries no load in any case (see
|
|
341
|
+
# _prune_dead).
|
|
342
|
+
prune: bool = True
|
|
343
|
+
# Normalize the filter by the full kernel (outside the design space reads
|
|
344
|
+
# as void, see density_filter), or None for the default: padded unless
|
|
345
|
+
# the part is extruded. The benchmarks turn it off to match top88/top3d.
|
|
346
|
+
pad_filter: bool | None = None
|
|
347
|
+
# Deflation (after Papadopoulos, Farrell & Surowiec 2021): delivered
|
|
348
|
+
# designs (xphys per element) to repel this run from. The objective is
|
|
349
|
+
# compliance times prod_k (deflate_weight * d_k^-deflate_power + 1), d_k
|
|
350
|
+
# the RMS density difference to design k over the design cells, so a run
|
|
351
|
+
# that heads back into a basin already found pays for it and settles in
|
|
352
|
+
# another. Empty: off.
|
|
353
|
+
deflate: tuple = ()
|
|
354
|
+
deflate_weight: float = 0.05
|
|
355
|
+
deflate_power: float = 2.0
|
|
356
|
+
# Linear buckling as a constraint (bucklingopt.py): the smallest load
|
|
357
|
+
# factor the eroded design may buckle at, or None; buckling_modulus (MPa)
|
|
358
|
+
# turns the unit-modulus model's factor into this one's. Off the limit
|
|
359
|
+
# (see BUCKLE_MMA) nothing changes; near it the compliance takes an
|
|
360
|
+
# adaptive buckling penalty the optimality criteria step on.
|
|
361
|
+
buckling: float | None = None
|
|
362
|
+
buckling_modulus: float = 1.0
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@dataclass
|
|
366
|
+
class SimpResult:
|
|
367
|
+
x: np.ndarray # design variables
|
|
368
|
+
xphys: np.ndarray # projected densities, what we mesh
|
|
369
|
+
compliance: float
|
|
370
|
+
case_compliance: list
|
|
371
|
+
volume_fraction: float
|
|
372
|
+
iterations: int
|
|
373
|
+
converged: bool
|
|
374
|
+
seconds: float
|
|
375
|
+
history: list = field(default_factory=list)
|
|
376
|
+
# per case: the worst direction of a force of unknown direction
|
|
377
|
+
# (directions.py), or None
|
|
378
|
+
case_direction: list = field(default_factory=list)
|
|
379
|
+
# per element: strain energy density of the delivered design (weighted
|
|
380
|
+
# over cases), for siting escape holes where the part is calm
|
|
381
|
+
energy: np.ndarray | None = None
|
|
382
|
+
# escape holes drilled (escape.drill's report), or None
|
|
383
|
+
escape: dict | None = None
|
|
384
|
+
# buildSim warp design: the delivered field's as-built measures, or None
|
|
385
|
+
warp: dict | None = None
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def filter_radius(domain: Domain, min_member: float | None, min_hole: float | None = None) -> float:
|
|
389
|
+
"""Filter radius in mm, never below 1.5 cells (or the checkerboard comes
|
|
390
|
+
back).
|
|
391
|
+
|
|
392
|
+
With minMember t, the radius is chosen so a sheet thinner than t does not
|
|
393
|
+
survive erosion: for this cone filter a slab of thickness 0.72 R reaches
|
|
394
|
+
a filtered density of 0.65 (the eroded threshold, eta + erode), so
|
|
395
|
+
R = 1.3 t puts the cut at 0.94 t. The earlier R = t/2 + pitch/2 let
|
|
396
|
+
2-cell sheets through and they meshed thinner still (eval round 4:
|
|
397
|
+
a 0.9mm web in the bell crank against a 2.5mm minimum).
|
|
398
|
+
|
|
399
|
+
The dilated design (eta - dilate, minHole) mirrors it: a gap narrower
|
|
400
|
+
than 0.94 R closes, so R = 1.3 x the larger of the two."""
|
|
401
|
+
r = 1.5 * domain.pitch
|
|
402
|
+
if min_member:
|
|
403
|
+
r = max(r, 1.3 * float(min_member))
|
|
404
|
+
if min_hole:
|
|
405
|
+
r = max(r, 1.3 * float(min_hole))
|
|
406
|
+
return r
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class GridFilter:
|
|
410
|
+
"""The cone filter's matrix H (unnormalized, symmetric) as a stencil on
|
|
411
|
+
the domain grid: `h @ x` gives what the sparse matrix would, without
|
|
412
|
+
storing ~100 weights per cell. Used from MG_MIN_CELLS up."""
|
|
413
|
+
|
|
414
|
+
def __init__(self, domain: Domain, radius: float):
|
|
415
|
+
self.ijk = tuple(domain.mesh.elem_ijk.T)
|
|
416
|
+
self.dims = tuple(domain.dims)
|
|
417
|
+
self.n = domain.mesh.n_elem
|
|
418
|
+
self.shape = (self.n, self.n)
|
|
419
|
+
reach = int(np.floor(radius / domain.pitch))
|
|
420
|
+
self.reach = reach
|
|
421
|
+
self.taps = []
|
|
422
|
+
for dx in range(-reach, reach + 1):
|
|
423
|
+
for dy in range(-reach, reach + 1):
|
|
424
|
+
for dz in range(-reach, reach + 1):
|
|
425
|
+
w = radius - np.sqrt(dx * dx + dy * dy + dz * dz) * domain.pitch
|
|
426
|
+
if w > 0:
|
|
427
|
+
self.taps.append(((dx, dy, dz), float(w)))
|
|
428
|
+
self.T = self
|
|
429
|
+
|
|
430
|
+
def __matmul__(self, v):
|
|
431
|
+
r = self.reach
|
|
432
|
+
g = np.zeros(tuple(d + 2 * r for d in self.dims))
|
|
433
|
+
nx, ny, nz = self.dims
|
|
434
|
+
g[r:r + nx, r:r + ny, r:r + nz][self.ijk] = v
|
|
435
|
+
out = np.zeros(self.dims)
|
|
436
|
+
for (dx, dy, dz), w in self.taps:
|
|
437
|
+
out += w * g[r + dx:r + dx + nx, r + dy:r + dy + ny, r + dz:r + dz + nz]
|
|
438
|
+
return out[self.ijk]
|
|
439
|
+
|
|
440
|
+
def tocsr(self):
|
|
441
|
+
return self
|
|
442
|
+
|
|
443
|
+
def sum(self, axis=1):
|
|
444
|
+
return self @ np.ones(self.n)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def density_filter(domain: Domain, radius: float, pad: bool = True):
|
|
448
|
+
"""Row-normalized cone filter over active cells as a sparse matrix
|
|
449
|
+
(a GridFilter stencil from MG_MIN_CELLS cells up)."""
|
|
450
|
+
mesh = domain.mesh
|
|
451
|
+
if _use_mg(mesh.n_elem):
|
|
452
|
+
h = GridFilter(domain, radius)
|
|
453
|
+
full = sum(w for _, w in h.taps)
|
|
454
|
+
hs = np.full(mesh.n_elem, full) if pad else h.sum()
|
|
455
|
+
return h, hs
|
|
456
|
+
n = mesh.n_elem
|
|
457
|
+
reach = int(np.floor(radius / domain.pitch))
|
|
458
|
+
lut = np.full(domain.dims, -1, np.int64)
|
|
459
|
+
lut[tuple(mesh.elem_ijk.T)] = np.arange(n)
|
|
460
|
+
rows, cols, vals = [], [], []
|
|
461
|
+
dims = np.asarray(domain.dims)
|
|
462
|
+
for dx in range(-reach, reach + 1):
|
|
463
|
+
for dy in range(-reach, reach + 1):
|
|
464
|
+
for dz in range(-reach, reach + 1):
|
|
465
|
+
dist = np.sqrt(dx * dx + dy * dy + dz * dz) * domain.pitch
|
|
466
|
+
w = radius - dist
|
|
467
|
+
if w <= 0:
|
|
468
|
+
continue
|
|
469
|
+
nb = mesh.elem_ijk + np.array([dx, dy, dz])
|
|
470
|
+
ok = np.all((nb >= 0) & (nb < dims), axis=1)
|
|
471
|
+
j = np.full(n, -1, np.int64)
|
|
472
|
+
j[ok] = lut[tuple(nb[ok].T)]
|
|
473
|
+
hit = j >= 0
|
|
474
|
+
rows.append(np.nonzero(hit)[0])
|
|
475
|
+
cols.append(j[hit])
|
|
476
|
+
vals.append(np.full(int(hit.sum()), w))
|
|
477
|
+
h = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))
|
|
478
|
+
# Normalize by the FULL kernel, not just the active neighbors: cells
|
|
479
|
+
# outside the design space (and inside keep-outs) count as void. With the
|
|
480
|
+
# usual truncated normalization a member lying along the domain boundary
|
|
481
|
+
# is filtered as if it were twice as thick, so the minimum member size
|
|
482
|
+
# does not hold there: round 1 of the evals grew 0.8mm skins on the faces
|
|
483
|
+
# of the arm link's design box with minMember = 3mm.
|
|
484
|
+
full = sum(max(radius - np.sqrt(dx * dx + dy * dy + dz * dz) * domain.pitch, 0.0)
|
|
485
|
+
for dx in range(-reach, reach + 1) for dy in range(-reach, reach + 1)
|
|
486
|
+
for dz in range(-reach, reach + 1))
|
|
487
|
+
hs = np.full(n, full) if pad else np.asarray(h.sum(axis=1)).ravel()
|
|
488
|
+
return h, hs
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# A cell is dead when its strain energy density, in every load case, is
|
|
492
|
+
# below this fraction of that case's median over the solid. A member that
|
|
493
|
+
# ends in free space carries no stress at all (nothing pulls on its tip), so
|
|
494
|
+
# it reads many orders below this; a member that matters to even the
|
|
495
|
+
# lightest case reads far above it, because each case is judged on its own
|
|
496
|
+
# scale.
|
|
497
|
+
PRUNE_BELOW = 1e-3
|
|
498
|
+
PRUNE_PASSES = 3
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _prune_dead(fe, domain, xp, us, loads, design, pres):
|
|
502
|
+
"""Delete dead-end material from the delivered design.
|
|
503
|
+
|
|
504
|
+
Projection continuation freezes the layout once beta is high: a member
|
|
505
|
+
the optimizer stopped needing can no longer shrink away (its gradient
|
|
506
|
+
lives only in the thin transition band), and compliance does not notice
|
|
507
|
+
a strut that carries nothing. GRIP's wrist link came out with several
|
|
508
|
+
such tendrils hanging in free space. They are pure deadweight and look
|
|
509
|
+
unfinished, so after the final solve, cells that carry essentially no
|
|
510
|
+
energy in any case are removed, the design re-solved, and repeated,
|
|
511
|
+
because removing a tendril's tip leaves the next stretch of it dead too.
|
|
512
|
+
Returns (xp, us)."""
|
|
513
|
+
for _ in range(PRUNE_PASSES):
|
|
514
|
+
solid = design & (xp > 0.5) & ~pres
|
|
515
|
+
if not solid.any():
|
|
516
|
+
break
|
|
517
|
+
live = np.zeros(len(xp), bool)
|
|
518
|
+
for u in us:
|
|
519
|
+
w = fe.strain_energy(u) * (xp ** PENALTY)
|
|
520
|
+
ref = np.median(w[xp > 0.5])
|
|
521
|
+
if ref <= 0:
|
|
522
|
+
live[:] = True
|
|
523
|
+
break
|
|
524
|
+
live |= w >= PRUNE_BELOW * ref
|
|
525
|
+
dead = solid & ~live
|
|
526
|
+
# skip specks: a lone low-energy cell inside a member is a numerical
|
|
527
|
+
# neutral axis, not a dead end
|
|
528
|
+
if dead.sum() < 0.002 * solid.sum():
|
|
529
|
+
break
|
|
530
|
+
g = np.zeros(domain.dims, bool)
|
|
531
|
+
ijk = tuple(domain.mesh.elem_ijk.T)
|
|
532
|
+
g[ijk] = dead
|
|
533
|
+
lbl, n = ndimage.label(g, structure=_CONN26)
|
|
534
|
+
if n == 0:
|
|
535
|
+
break
|
|
536
|
+
sizes = np.bincount(lbl[ijk][dead], minlength=n + 1)
|
|
537
|
+
el = lbl[ijk]
|
|
538
|
+
dead &= sizes[el] >= 4
|
|
539
|
+
if not dead.any():
|
|
540
|
+
break
|
|
541
|
+
xp = xp.copy()
|
|
542
|
+
xp[dead] = 0.0
|
|
543
|
+
# and whatever that cut loose from the supports (a tip whose root
|
|
544
|
+
# was a dead cluster under the size floor)
|
|
545
|
+
loose = (xp > 0.5) & ~fe._connected((xp > 0.5) | fe.anchor) & ~pres
|
|
546
|
+
xp[loose] = 0.0
|
|
547
|
+
e = E_MIN + xp ** PENALTY * (1 - E_MIN)
|
|
548
|
+
us = fe.solve(e, xp, us, rtol=1e-6, loads=loads)
|
|
549
|
+
return xp, us
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _heat_state(heat, x, fg, reuse=False):
|
|
553
|
+
"""Solve the temperature at densities x and return (self-weight plus
|
|
554
|
+
expansion load, expansion load or None), in the optimizer's units: forces
|
|
555
|
+
in N against unit-modulus stiffness, so the expansion's E0 is in the
|
|
556
|
+
load."""
|
|
557
|
+
heat.temperature(x, reuse=reuse)
|
|
558
|
+
if not heat.expands:
|
|
559
|
+
return fg, None
|
|
560
|
+
fth = heat.load(np.where(x > ALIVE, x ** PENALTY * (1 - E_MIN), 0.0), heat.E0)
|
|
561
|
+
return (fth if fg is None else fg + fth), fth
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _pad(opts) -> bool:
|
|
565
|
+
return opts.extrusion is None if opts.pad_filter is None else bool(opts.pad_filter)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _beta_hold(opts, beta):
|
|
569
|
+
"""Most iterations to spend at projection sharpness beta."""
|
|
570
|
+
s = opts.beta_schedule
|
|
571
|
+
k = int(round(np.log2(max(beta, 1.0))))
|
|
572
|
+
return s[k] if s is not None and k < len(s) else opts.beta_every
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _project(xt, beta, eta):
|
|
576
|
+
den = np.tanh(beta * eta) + np.tanh(beta * (1 - eta))
|
|
577
|
+
return (np.tanh(beta * eta) + np.tanh(beta * (xt - eta))) / den
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _dproject(xt, beta, eta):
|
|
581
|
+
den = np.tanh(beta * eta) + np.tanh(beta * (1 - eta))
|
|
582
|
+
return beta * (1 - np.tanh(beta * (xt - eta)) ** 2) / den
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _extrude(v, columns, design):
|
|
586
|
+
"""Average over each extrusion column's design cells, so every column is
|
|
587
|
+
one value along the extrusion axis (a 2.5D part, cut from plate)."""
|
|
588
|
+
if columns is None:
|
|
589
|
+
return v
|
|
590
|
+
col = columns[design]
|
|
591
|
+
n = col.max() + 1
|
|
592
|
+
mean = np.bincount(col, weights=v[design], minlength=n) / np.maximum(np.bincount(col, minlength=n), 1)
|
|
593
|
+
out = v.copy()
|
|
594
|
+
out[design] = mean[col]
|
|
595
|
+
return out
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _machined(domain, xp, access):
|
|
599
|
+
"""Fill every design cell a cutter of the access's radius (and 2.5-axis
|
|
600
|
+
floors) cannot reach: the exact delivery step (manufacture.machinable)."""
|
|
601
|
+
ijk = tuple(domain.mesh.elem_ijk.T)
|
|
602
|
+
blocked = machinable(domain.full_grid(xp) > 0.5, access, domain.pitch)[ijk]
|
|
603
|
+
xp = xp.copy()
|
|
604
|
+
xp[blocked] = np.maximum(xp[blocked], 1.0)
|
|
605
|
+
return xp
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _symmetrize(v, mirror):
|
|
609
|
+
for m in mirror.values():
|
|
610
|
+
has = m >= 0
|
|
611
|
+
out = v.copy()
|
|
612
|
+
out[has] = 0.5 * (v[has] + v[m[has]])
|
|
613
|
+
v = out
|
|
614
|
+
return v
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _warp_report(domain: Domain, xp):
|
|
618
|
+
"""The delivered field's as-built measures (buildsim): max distortion
|
|
619
|
+
and recoater lift, mm, on the optimizer's build model and, when that
|
|
620
|
+
one was coarsened, on the full grid too (one forward build, which is
|
|
621
|
+
what the limits are checked on)."""
|
|
622
|
+
ws = domain.warp
|
|
623
|
+
e = E_MIN + xp ** PENALTY * (1 - E_MIN)
|
|
624
|
+
v, _ = ws.term.measures(e)
|
|
625
|
+
out = {"maxDistortion": v["max"], "recoaterLift": v["lift"], "meanSquare": v["mean"],
|
|
626
|
+
"limits": {"maxDistortion": ws.max_distortion, "maxRecoaterLift": ws.max_lift},
|
|
627
|
+
"weight": ws.weight, "coarsen": ws.coarsen}
|
|
628
|
+
if ws.coarsen > 1:
|
|
629
|
+
vf, _ = ws.term.fine().measures(e)
|
|
630
|
+
out["optimizerGrid"] = {"maxDistortion": v["max"], "recoaterLift": v["lift"],
|
|
631
|
+
"fullOverCoarse": dict(ws.term.ratio)}
|
|
632
|
+
out.update(maxDistortion=vf["max"], recoaterLift=vf["lift"], meanSquare=vf["mean"])
|
|
633
|
+
return out
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def optimize(domain: Domain, nu: float, opts: SimpOptions, on_iter=None, should_stop=None) -> SimpResult:
|
|
637
|
+
"""Optimize, then (opts.escape) open every void the design sealed: drill
|
|
638
|
+
escape holes into it (escape.drill), pin them void, and run on from the
|
|
639
|
+
design for a few more iterations so the part carries its load round
|
|
640
|
+
them. Up to three rounds, as the re-converged design can seal a new one."""
|
|
641
|
+
if opts.escape is None or opts.extrusion is not None or opts.access is not None:
|
|
642
|
+
# a profile cut from plate or a milled or cast part has no sealed
|
|
643
|
+
# voids to drain (machining fills them)
|
|
644
|
+
return _optimize(domain, nu, opts, on_iter, should_stop)
|
|
645
|
+
from dataclasses import replace
|
|
646
|
+
|
|
647
|
+
from .escape import drill
|
|
648
|
+
|
|
649
|
+
t0 = time.time()
|
|
650
|
+
res = _optimize(domain, nu, replace(opts, escape=None), on_iter, should_stop)
|
|
651
|
+
void = None if opts.void is None else opts.void.copy()
|
|
652
|
+
log = {"holes": [], "filled": 0.0, "rounds": 0, "left": None}
|
|
653
|
+
for _ in range(3):
|
|
654
|
+
rep = drill(domain, res.xphys, res.energy, opts.escape, void)
|
|
655
|
+
log.setdefault("sealed", rep["sealed"]) # what the optimizer sealed
|
|
656
|
+
log["left"] = rep["sealed"] if not rep["holes"] and not rep["fill"].any() else None
|
|
657
|
+
log["unopened"] = rep.get("unopened", 0)
|
|
658
|
+
if rep.get("maxPath") is not None:
|
|
659
|
+
log["maxPath"] = rep["maxPath"]
|
|
660
|
+
if not rep["holes"] and not rep["fill"].any():
|
|
661
|
+
break
|
|
662
|
+
log["rounds"] += 1
|
|
663
|
+
log["holes"] += rep["holes"]
|
|
664
|
+
log["filled"] += rep["filledVolume"]
|
|
665
|
+
void = rep["void"] if void is None else (void | rep["void"])
|
|
666
|
+
x0 = res.x.copy()
|
|
667
|
+
x0[rep["fill"]] = 1.0
|
|
668
|
+
x0[void] = 0.0
|
|
669
|
+
if should_stop is not None and should_stop():
|
|
670
|
+
break
|
|
671
|
+
hist = res.history
|
|
672
|
+
res = _optimize(domain, nu, replace(opts, escape=None, void=void, x0=x0,
|
|
673
|
+
beta_start=float(opts.beta_max),
|
|
674
|
+
max_iterations=opts.escape.iterations), on_iter, should_stop)
|
|
675
|
+
res.history = hist + res.history
|
|
676
|
+
if log["left"] is None:
|
|
677
|
+
# the last round drilled: check what it delivered
|
|
678
|
+
log["left"] = drill(domain, res.xphys, None, opts.escape, void)["sealed"]
|
|
679
|
+
res.escape = log
|
|
680
|
+
res.seconds = time.time() - t0
|
|
681
|
+
return res
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _optimize(domain: Domain, nu: float, opts: SimpOptions, on_iter=None, should_stop=None) -> SimpResult:
|
|
685
|
+
t0 = time.time()
|
|
686
|
+
mesh = domain.mesh
|
|
687
|
+
n = mesh.n_elem
|
|
688
|
+
pres = domain.preserve
|
|
689
|
+
design = ~pres
|
|
690
|
+
n_design = int(design.sum())
|
|
691
|
+
if n_design == 0:
|
|
692
|
+
raise ValueError("nothing to optimize: every cell is preserved")
|
|
693
|
+
fe = _Analysis(domain, nu, opts.solve_rtol)
|
|
694
|
+
columns = tie_groups(domain, opts.extrusion, opts.pattern)
|
|
695
|
+
# An extruded part's top and bottom are the stock's faces: padding the
|
|
696
|
+
# filter there (outside = void) would thin them, so pad only free designs.
|
|
697
|
+
h, hs = density_filter(domain, opts.filter_radius, pad=_pad(opts))
|
|
698
|
+
ht = h.T.tocsr()
|
|
699
|
+
|
|
700
|
+
vf = float(opts.volume_fraction)
|
|
701
|
+
x = np.full(n, vf)
|
|
702
|
+
if opts.x0 is not None:
|
|
703
|
+
x = np.clip(np.asarray(opts.x0, float), 0.0, 1.0)
|
|
704
|
+
x[pres] = 1.0
|
|
705
|
+
void = opts.void if opts.void is not None and opts.void.any() else None
|
|
706
|
+
if void is not None:
|
|
707
|
+
x[void] = 0.0
|
|
708
|
+
target = vf * n_design
|
|
709
|
+
|
|
710
|
+
beta = float(opts.beta_start)
|
|
711
|
+
since_beta = 0
|
|
712
|
+
spreads = domain.spreads or [None] * len(domain.loads)
|
|
713
|
+
us = [None] * len(expand(domain.loads, spreads)[0])
|
|
714
|
+
robust = any(s is not None for s in spreads)
|
|
715
|
+
dc_prev = None
|
|
716
|
+
weights = np.asarray(domain.case_weights, float)
|
|
717
|
+
history = []
|
|
718
|
+
c0 = None
|
|
719
|
+
converged = False
|
|
720
|
+
it = 0
|
|
721
|
+
change = 1.0
|
|
722
|
+
|
|
723
|
+
heat = domain.thermal
|
|
724
|
+
heat_state = {}
|
|
725
|
+
mma_s = None
|
|
726
|
+
warp_state = {}
|
|
727
|
+
buck = None
|
|
728
|
+
if opts.buckling is not None and not robust and domain.gravity is None and heat is None:
|
|
729
|
+
from .bucklingopt import BucklingModel
|
|
730
|
+
|
|
731
|
+
buck = BucklingModel(domain, nu)
|
|
732
|
+
buck_last, buck_age, buck_nu = None, 0, 0.0
|
|
733
|
+
|
|
734
|
+
ijk = tuple(mesh.elem_ijk.T)
|
|
735
|
+
if opts.build_direction is not None:
|
|
736
|
+
from .am import AMFilter
|
|
737
|
+
|
|
738
|
+
pinned = domain.full_grid(pres.astype(float)) > 0.5
|
|
739
|
+
am_b = AMFilter(domain.dims, opts.build_direction, pinned) # blueprint
|
|
740
|
+
am_e = AMFilter(domain.dims, opts.build_direction, pinned) # eroded
|
|
741
|
+
am_v = AMFilter(domain.dims, opts.build_direction, pinned) # volume search
|
|
742
|
+
|
|
743
|
+
def printed(am, v):
|
|
744
|
+
return am.forward(domain.full_grid(v))[ijk]
|
|
745
|
+
|
|
746
|
+
def printed_grad(am, g):
|
|
747
|
+
return am.backward(domain.full_grid(g))[ijk]
|
|
748
|
+
elif opts.access is not None:
|
|
749
|
+
# milling / casting: material that would leave an undercut is
|
|
750
|
+
# credited as filled (manufacture.AccessFilter)
|
|
751
|
+
dirs = opts.access.directions
|
|
752
|
+
am_b, am_e = AccessFilter(domain.dims, dirs), AccessFilter(domain.dims, dirs)
|
|
753
|
+
am_v = AccessFilter(domain.dims, dirs, track=False)
|
|
754
|
+
|
|
755
|
+
def printed(am, v):
|
|
756
|
+
return am.forward(domain.full_grid(v))[ijk]
|
|
757
|
+
|
|
758
|
+
def printed_grad(am, g):
|
|
759
|
+
return am.backward(domain.full_grid(g))[ijk]
|
|
760
|
+
else:
|
|
761
|
+
am_b = am_e = am_v = None
|
|
762
|
+
|
|
763
|
+
def printed(am, v):
|
|
764
|
+
return v
|
|
765
|
+
|
|
766
|
+
def printed_grad(am, g):
|
|
767
|
+
return g
|
|
768
|
+
|
|
769
|
+
eta_d = opts.eta - opts.dilate
|
|
770
|
+
|
|
771
|
+
def physical(xv, b, am=None, eta=opts.eta):
|
|
772
|
+
xt = (h @ xv) / hs
|
|
773
|
+
xp = _project(xt, b, eta)
|
|
774
|
+
xp[pres] = 1.0
|
|
775
|
+
if void is not None:
|
|
776
|
+
xp[void] = 0.0
|
|
777
|
+
return xt, printed(am if am is not None else am_v, xp)
|
|
778
|
+
|
|
779
|
+
eta_e = opts.eta + opts.erode
|
|
780
|
+
thick = MaxMember(domain, opts.max_member, design) if opts.max_member else None
|
|
781
|
+
mu = 0.0
|
|
782
|
+
|
|
783
|
+
def eroded(xt, b):
|
|
784
|
+
xe = _project(xt, b, eta_e)
|
|
785
|
+
xe[pres] = 1.0
|
|
786
|
+
if void is not None:
|
|
787
|
+
xe[void] = 0.0
|
|
788
|
+
return printed(am_e, xe)
|
|
789
|
+
|
|
790
|
+
for it in range(1, opts.max_iterations + 1):
|
|
791
|
+
if should_stop is not None and should_stop():
|
|
792
|
+
break
|
|
793
|
+
xt, xp = physical(x, beta, am_b)
|
|
794
|
+
xe = eroded(xt, beta)
|
|
795
|
+
e = E_MIN + xe ** PENALTY * (1 - E_MIN)
|
|
796
|
+
fg = None
|
|
797
|
+
if domain.gravity is not None:
|
|
798
|
+
from .domain import gravity_force
|
|
799
|
+
fg = gravity_force(mesh, xe, domain.gravity, domain.fixed)
|
|
800
|
+
fth = None
|
|
801
|
+
if heat is not None:
|
|
802
|
+
# the design's own temperature, softening and expansion load
|
|
803
|
+
# (thermal.StudyHeat)
|
|
804
|
+
fg, fth = _heat_state(heat, xe, fg, reuse=True)
|
|
805
|
+
e = e * heat.g
|
|
806
|
+
# A force of unknown direction is three solves, one per axis, and
|
|
807
|
+
# each case is judged in its worst direction (directions.py).
|
|
808
|
+
rhs, layout = expand(domain.loads, spreads, fg)
|
|
809
|
+
if len(us) != len(rhs):
|
|
810
|
+
# an uncertain force alone in its case gains a fixed part (the
|
|
811
|
+
# expansion load) once the design is warm
|
|
812
|
+
us = [None] * len(rhs)
|
|
813
|
+
# Sensitivities only need to point the right way while the design is
|
|
814
|
+
# still soft; tighten the solve as the projection sharpens.
|
|
815
|
+
rtol = opts.solve_rtol * (10.0 if beta <= 2 else 3.0 if beta <= 8 else 1.0) if opts.adaptive_rtol \
|
|
816
|
+
else opts.solve_rtol
|
|
817
|
+
us = fe.solve(e, xe, us, rtol=rtol, loads=rhs)
|
|
818
|
+
dc_xp = np.zeros(n)
|
|
819
|
+
comp = 0.0
|
|
820
|
+
case_c = []
|
|
821
|
+
if heat is not None:
|
|
822
|
+
comp, case_c, dc_xp, _ = heat.sensitivities(fe, xe, rhs, us, fth, weights, domain.gravity, heat_state,
|
|
823
|
+
layout, spreads)
|
|
824
|
+
for ci, wc in enumerate(collapse(rhs, layout, us, spreads) if heat is None else ()):
|
|
825
|
+
# the worst direction's sensitivity is its own (envelope theorem),
|
|
826
|
+
# softened over near-ties (directions.SMOOTH)
|
|
827
|
+
cc = wc.compliance
|
|
828
|
+
ce = sum(m * fe.strain_energy(v) for m, v in wc.energy)
|
|
829
|
+
case_c.append(cc)
|
|
830
|
+
comp += weights[ci] * cc
|
|
831
|
+
dc_xp -= weights[ci] * PENALTY * xe ** (PENALTY - 1) * (1 - E_MIN) * ce
|
|
832
|
+
if domain.gravity is not None:
|
|
833
|
+
# d(f.u)/dx with design-dependent weight: + 2 u . df/dx.
|
|
834
|
+
ue = wc.u_mean.reshape(-1, 3)[mesh.enode].sum(axis=1)
|
|
835
|
+
dc_xp += weights[ci] * 2.0 * (ue @ domain.gravity) * mesh.pitch ** 3 / 8.0
|
|
836
|
+
warp_v = None
|
|
837
|
+
if domain.warp is not None:
|
|
838
|
+
# buildSim warp design (buildsim.WarpTerm on the build axis):
|
|
839
|
+
# compliance * (1 + w D / D0) for warpWeight, plus each limit
|
|
840
|
+
# (maxDistortion, maxRecoaterLift) held by an adaptive weight,
|
|
841
|
+
# raised while the design is over it and eased off under it (a
|
|
842
|
+
# penalty the optimality-criteria step can take, as maxMember's)
|
|
843
|
+
ws = domain.warp
|
|
844
|
+
lam = warp_state.setdefault("lam", {"max": 0.0, "lift": 0.0})
|
|
845
|
+
c_now = comp
|
|
846
|
+
|
|
847
|
+
def warp_weights(v):
|
|
848
|
+
if warp_state.get("d0") is None:
|
|
849
|
+
warp_state["d0"], warp_state["c0"] = max(v["mean"], 1e-30), c_now
|
|
850
|
+
c0w = warp_state["c0"]
|
|
851
|
+
w = {"mean": ws.weight * c0w / warp_state["d0"]}
|
|
852
|
+
for key, lim in (("max", ws.max_distortion), ("lift", ws.max_lift)):
|
|
853
|
+
if lim:
|
|
854
|
+
over = v[key] / lim
|
|
855
|
+
lam[key] = min(max(lam[key] * 1.3, 0.05), 50.0) if over > 1.0 else lam[key] * 0.85
|
|
856
|
+
w[key] = lam[key] * c0w / lim
|
|
857
|
+
return w
|
|
858
|
+
|
|
859
|
+
n_warp = warp_state["n"] = warp_state.get("n", -1) + 1
|
|
860
|
+
dxe = PENALTY * xe ** (PENALTY - 1) * (1 - E_MIN)
|
|
861
|
+
if ws.coarsen > 1:
|
|
862
|
+
# re-read the coarse grid's bias on the full grid every 10
|
|
863
|
+
warp_v, dd = ws.term.measures(e, dxe, warp_weights, refresh=n_warp % 10 == 0)
|
|
864
|
+
else:
|
|
865
|
+
warp_v, dd = ws.term.measures(e, dxe, warp_weights)
|
|
866
|
+
c0w = warp_state["c0"]
|
|
867
|
+
extra = ws.weight * c0w * warp_v["mean"] / warp_state["d0"]
|
|
868
|
+
if ws.max_distortion:
|
|
869
|
+
extra += lam["max"] * c0w * warp_v["max"] / ws.max_distortion
|
|
870
|
+
if ws.max_lift:
|
|
871
|
+
extra += lam["lift"] * c0w * warp_v["lift"] / ws.max_lift
|
|
872
|
+
dc_xp = dc_xp + dd
|
|
873
|
+
comp = comp + extra
|
|
874
|
+
if c0 is None:
|
|
875
|
+
c0 = comp
|
|
876
|
+
dproj_e = _dproject(xt, beta, eta_e)
|
|
877
|
+
dproj_e[pres] = 0.0
|
|
878
|
+
dproj = _dproject(xt, beta, opts.eta)
|
|
879
|
+
dproj[pres] = 0.0
|
|
880
|
+
if void is not None:
|
|
881
|
+
dproj[void] = 0.0
|
|
882
|
+
dproj_e[void] = 0.0
|
|
883
|
+
dc = ht @ (printed_grad(am_e, dc_xp) * dproj_e / hs)
|
|
884
|
+
if opts.deflate:
|
|
885
|
+
# d log M / d xp, scaled by compliance (J = c M, divided by M so
|
|
886
|
+
# the step size stays the plain compliance one), carried back
|
|
887
|
+
# through the blueprint's projection and the filter
|
|
888
|
+
g = np.zeros(n)
|
|
889
|
+
for xk in opts.deflate:
|
|
890
|
+
diff = np.where(design, xp - xk, 0.0)
|
|
891
|
+
d2 = float(diff @ diff) / n_design
|
|
892
|
+
dk = max(d2, 1e-12) ** 0.5
|
|
893
|
+
a = opts.deflate_weight * dk ** -opts.deflate_power
|
|
894
|
+
g += (-opts.deflate_power * a / (a + 1.0)) * diff / (d2 * n_design)
|
|
895
|
+
dc = dc + ht @ (printed_grad(am_b, comp * g) * dproj / hs)
|
|
896
|
+
dv = ht @ (printed_grad(am_b, design.astype(float)) * dproj / hs)
|
|
897
|
+
if heat is not None and heat.swing_weight > 0:
|
|
898
|
+
# The thermal-cycling swing's stress rises where material is
|
|
899
|
+
# added near the heat (a stiffer part holds more of its own
|
|
900
|
+
# expansion), so its sensitivities are positive there, and the
|
|
901
|
+
# clip below threw that half away: the optimizer never saw why
|
|
902
|
+
# to remove material, and at 45k cells its own swing measure
|
|
903
|
+
# rose 4.3 -> 6.8 with fatigueWeight 0.3. Shifting dc by s dv
|
|
904
|
+
# keeps every sensitivity negative without moving the optimum
|
|
905
|
+
# (the volume multiplier absorbs s: -dc/dv = l becomes
|
|
906
|
+
# s - dc/dv = l + s).
|
|
907
|
+
live = design & (dv > 1e-12)
|
|
908
|
+
if live.any():
|
|
909
|
+
s_sh = float(np.max(dc[live] / dv[live]))
|
|
910
|
+
if s_sh > 0:
|
|
911
|
+
dc = dc - 1.05 * s_sh * dv - 1e-12
|
|
912
|
+
buck_rec = {}
|
|
913
|
+
if buck is not None and beta >= BUCKLE_BETA:
|
|
914
|
+
# (the eroded design, as stiffness reads it; an inactive reading
|
|
915
|
+
# stands for up to BUCKLING_SKIP iterations, see constrained.py)
|
|
916
|
+
from .constrained import BUCKLING_SKIP
|
|
917
|
+
|
|
918
|
+
req = opts.buckling / opts.buckling_modulus
|
|
919
|
+
if buck_last is None or buck_age >= BUCKLING_SKIP or max(v for v, _ in buck_last) > -0.5:
|
|
920
|
+
try:
|
|
921
|
+
got = buck.evaluate(xe, PENALTY)
|
|
922
|
+
except Exception as exc: # noqa: BLE001 - a failed eigensolve must not end the run
|
|
923
|
+
got = None
|
|
924
|
+
buck_rec["bucklingError"] = f"{type(exc).__name__}: {exc}"
|
|
925
|
+
if got is not None:
|
|
926
|
+
buck_last = []
|
|
927
|
+
for mu_ks, dmu, _mus in got:
|
|
928
|
+
if mu_ks <= 0:
|
|
929
|
+
continue
|
|
930
|
+
g_b = ht @ (printed_grad(am_e, req * dmu) * dproj_e / hs)
|
|
931
|
+
buck_last.append((req * mu_ks - 1.0, g_b))
|
|
932
|
+
buck_rec["bucklingSeconds"] = buck.last.get("seconds")
|
|
933
|
+
buck_age = 0
|
|
934
|
+
else:
|
|
935
|
+
buck_age += 1
|
|
936
|
+
if buck_last:
|
|
937
|
+
worst_b = max(v for v, _ in buck_last)
|
|
938
|
+
buck_rec["buckling"] = opts.buckling / (worst_b + 1.0) if worst_b > -1.0 else float("inf")
|
|
939
|
+
# a penalty the optimality criteria can take (as the warp
|
|
940
|
+
# limits): compliance + nu c sum(max(g, 0)), nu raised while
|
|
941
|
+
# the design buckles under the limit and eased off over it.
|
|
942
|
+
# (MMA with buckling as a constraint built weaker layouts on
|
|
943
|
+
# the hook, SF 0.3-2.0 at 28-31 g against 3.4 at 32 g.)
|
|
944
|
+
if buck_age == 0:
|
|
945
|
+
if worst_b > 0:
|
|
946
|
+
buck_nu = min(max(buck_nu * 1.3, 0.2), 50.0)
|
|
947
|
+
elif worst_b < -BUCKLE_MMA:
|
|
948
|
+
buck_nu *= 0.8
|
|
949
|
+
if buck_nu > 0:
|
|
950
|
+
for v, g in buck_last:
|
|
951
|
+
if v > -BUCKLE_MMA:
|
|
952
|
+
dc = dc + buck_nu * comp * g
|
|
953
|
+
buck_rec["bucklingWeight"] = buck_nu
|
|
954
|
+
dc_raw = dc
|
|
955
|
+
dc = np.minimum(dc, 0.0)
|
|
956
|
+
if robust:
|
|
957
|
+
# A worst case over directions swings: stiffening the weakest
|
|
958
|
+
# direction makes another the weakest, and the full step of the
|
|
959
|
+
# optimality criteria overshoots into it every iteration (a
|
|
960
|
+
# square cantilever's webs flipped diagonal to diagonal and its
|
|
961
|
+
# worst case came out 4.6 times its tuned-for-down twin's). The
|
|
962
|
+
# mean of this iteration's and the last one's sensitivities
|
|
963
|
+
# damps the swing: 5 times stiffer in its worst direction than
|
|
964
|
+
# the twin, where undamped it was barely better.
|
|
965
|
+
if dc_prev is not None:
|
|
966
|
+
dc = 0.5 * (dc + dc_prev)
|
|
967
|
+
dc_prev = dc
|
|
968
|
+
dv = np.maximum(dv, 1e-12)
|
|
969
|
+
excess = None
|
|
970
|
+
if thick is not None:
|
|
971
|
+
# max member: a cost on top of volume where a ball is too full,
|
|
972
|
+
# weighted up while the solid's 95th percentile is over and
|
|
973
|
+
# eased off when it is not (manufacture.MaxMember). The worst
|
|
974
|
+
# ball sits at a junction and stays over whatever the weight:
|
|
975
|
+
# steering on it ran the weight to 1000 and broke the beam.
|
|
976
|
+
# Measured on the delivered (projected) field, but carried back
|
|
977
|
+
# through the filter only, as if the projection were the
|
|
978
|
+
# identity: deep in a thick member the projection's slope is
|
|
979
|
+
# nil, so the true gradient only ever shaved the member's skin,
|
|
980
|
+
# and measured on the filtered field the penalty let go as soon
|
|
981
|
+
# as the design variables sank to 0.7, still projecting solid.
|
|
982
|
+
excess, over, dp = thick(xp)
|
|
983
|
+
mu = min(max(mu * 1.15, 0.1), 100.0) if over > 0.05 else mu * 0.9
|
|
984
|
+
if mu > 0:
|
|
985
|
+
dv = dv + mu * (ht @ (dp / hs))
|
|
986
|
+
if domain.mirror:
|
|
987
|
+
dc = _symmetrize(dc, domain.mirror)
|
|
988
|
+
dv = _symmetrize(dv, domain.mirror)
|
|
989
|
+
dc = _extrude(dc, columns, design)
|
|
990
|
+
dv = _extrude(dv, columns, design)
|
|
991
|
+
|
|
992
|
+
move = opts.move if beta <= 4 else opts.move / 2
|
|
993
|
+
lo_x = np.maximum(0.0, x - move)
|
|
994
|
+
hi_x = np.minimum(1.0, x + move)
|
|
995
|
+
ratio = np.sqrt(-dc / dv)
|
|
996
|
+
l1, l2 = 1e-12, 1e12
|
|
997
|
+
x_new = x
|
|
998
|
+
swing_step = heat is not None and heat.swing_limit is not None and "swing_grad" in heat_state
|
|
999
|
+
if swing_step:
|
|
1000
|
+
# Thermal cycling as a constraint (thermal.cycle.
|
|
1001
|
+
# fatigueSafetyFactor): MMA on the hot objective with two
|
|
1002
|
+
# constraints, volume and the swing's peak stress range. The
|
|
1003
|
+
# peak is the p-norm scaled to this iteration's max (Le et al.
|
|
1004
|
+
# 2010), its gradient the p-norm's times that scale.
|
|
1005
|
+
from .mma import MMA
|
|
1006
|
+
|
|
1007
|
+
free = design & ~pres if void is None else design & ~pres & ~void
|
|
1008
|
+
dgs = ht @ (printed_grad(am_e, heat_state["swing_grad"]) * dproj_e / hs)
|
|
1009
|
+
dco = dc_raw
|
|
1010
|
+
if domain.mirror:
|
|
1011
|
+
dgs = _symmetrize(dgs, domain.mirror)
|
|
1012
|
+
dco = _symmetrize(dco, domain.mirror)
|
|
1013
|
+
dgs = _extrude(dgs, columns, design)
|
|
1014
|
+
dco = _extrude(dco, columns, design)
|
|
1015
|
+
scale = heat_state["swing_max"] / max(heat_state["swing"], 1e-300)
|
|
1016
|
+
g_s = scale * heat_state["swing"] / heat.swing_limit - 1.0
|
|
1017
|
+
vol = float(physical(x, beta)[1][design].sum())
|
|
1018
|
+
if mma_s is None:
|
|
1019
|
+
mma_s = MMA(int(free.sum()), 2, move=opts.move)
|
|
1020
|
+
mma_s.move = move
|
|
1021
|
+
x_new = x.copy()
|
|
1022
|
+
with np.errstate(all="ignore"):
|
|
1023
|
+
x_new[free] = mma_s.update(x[free], comp / c0, dco[free] / c0,
|
|
1024
|
+
np.array([vol / target - 1.0, g_s]),
|
|
1025
|
+
np.array([dv[free] / target, scale * dgs[free] / heat.swing_limit]))
|
|
1026
|
+
if not np.all(np.isfinite(x_new)):
|
|
1027
|
+
# the MMA subproblem broke down (seen once at 45k cells):
|
|
1028
|
+
# take this step with the optimality criteria, restart MMA
|
|
1029
|
+
swing_step, mma_s, x_new = False, None, x
|
|
1030
|
+
x_new[pres] = 1.0
|
|
1031
|
+
if void is not None:
|
|
1032
|
+
x_new[void] = 0.0
|
|
1033
|
+
if domain.mirror:
|
|
1034
|
+
x_new = _symmetrize(x_new, domain.mirror)
|
|
1035
|
+
x_new = _extrude(x_new, columns, design)
|
|
1036
|
+
if swing_step and "swing_max" in heat_state:
|
|
1037
|
+
swing_rec = {"swingPeak": float(heat_state["swing_max"]), "swingLimit": float(heat.swing_limit)}
|
|
1038
|
+
for _ in range(80 if not swing_step else 0):
|
|
1039
|
+
lm = np.sqrt(l1 * l2)
|
|
1040
|
+
x_new = np.clip(x * ratio / np.sqrt(lm), lo_x, hi_x)
|
|
1041
|
+
x_new[pres] = 1.0
|
|
1042
|
+
if void is not None:
|
|
1043
|
+
x_new[void] = 0.0
|
|
1044
|
+
if domain.mirror:
|
|
1045
|
+
x_new = _symmetrize(x_new, domain.mirror)
|
|
1046
|
+
x_new = _extrude(x_new, columns, design)
|
|
1047
|
+
if void is not None:
|
|
1048
|
+
x_new[void] = 0.0
|
|
1049
|
+
vol = float(physical(x_new, beta)[1][design].sum())
|
|
1050
|
+
if vol > target:
|
|
1051
|
+
l1 = lm
|
|
1052
|
+
else:
|
|
1053
|
+
l2 = lm
|
|
1054
|
+
if l2 / l1 < 1 + 1e-4:
|
|
1055
|
+
break
|
|
1056
|
+
change = float(np.abs(x_new - x)[design].max())
|
|
1057
|
+
x = x_new
|
|
1058
|
+
since_beta += 1
|
|
1059
|
+
|
|
1060
|
+
rec = {"iteration": it, "compliance": comp, "relative": comp / c0 if c0 else 1.0,
|
|
1061
|
+
"volume": float(xp[design].mean()), "beta": beta, "change": change,
|
|
1062
|
+
"seconds": time.time() - t0, "cgIterations": fe.last_iters,
|
|
1063
|
+
"solvedCells": fe.last_cells}
|
|
1064
|
+
if warp_v is not None:
|
|
1065
|
+
rec["warpDistortion"] = warp_v["mean"]
|
|
1066
|
+
rec["warpMax"] = warp_v["max"]
|
|
1067
|
+
rec["warpLift"] = warp_v["lift"]
|
|
1068
|
+
if domain.warp.max_distortion or domain.warp.max_lift:
|
|
1069
|
+
rec["warpMultipliers"] = dict(warp_state["lam"])
|
|
1070
|
+
if swing_step and "swing_max" in heat_state:
|
|
1071
|
+
rec.update(swing_rec)
|
|
1072
|
+
rec.update(buck_rec)
|
|
1073
|
+
if excess is not None:
|
|
1074
|
+
rec["maxMemberExcess"] = excess
|
|
1075
|
+
rec["maxMemberWeight"] = mu
|
|
1076
|
+
history.append(rec)
|
|
1077
|
+
if on_iter is not None:
|
|
1078
|
+
on_iter(rec, xp)
|
|
1079
|
+
|
|
1080
|
+
if beta < opts.beta_max and (change < opts.tol * 2 or since_beta >= _beta_hold(opts, beta)):
|
|
1081
|
+
beta = min(beta * 2, opts.beta_max)
|
|
1082
|
+
since_beta = 0
|
|
1083
|
+
elif beta >= opts.beta_max and it >= 3:
|
|
1084
|
+
if change < opts.tol:
|
|
1085
|
+
converged = True
|
|
1086
|
+
break
|
|
1087
|
+
w = opts.stall_window
|
|
1088
|
+
# a max member still over its limit is not a stall: its weight
|
|
1089
|
+
# is still climbing (with the incompatible-mode element the
|
|
1090
|
+
# test beam's compliance went flat at iteration 65 with the
|
|
1091
|
+
# flanges still 8 mm against a 5 mm limit)
|
|
1092
|
+
mm_over = excess is not None and (over > 0.05 or excess > 0.05)
|
|
1093
|
+
if since_beta >= w and len(history) > w and not mm_over:
|
|
1094
|
+
old = history[-1 - w]["compliance"]
|
|
1095
|
+
if abs(comp - old) <= opts.stall_rtol * abs(old):
|
|
1096
|
+
converged = True
|
|
1097
|
+
break
|
|
1098
|
+
|
|
1099
|
+
xt, xp = physical(x, beta)
|
|
1100
|
+
if opts.erode > 0:
|
|
1101
|
+
# Deliver the OPENING of the blueprint: erode, then dilate back by the
|
|
1102
|
+
# same amount with the same filter. Everything the optimizer was
|
|
1103
|
+
# credited for survives at full size; a sheet thinner than the
|
|
1104
|
+
# erosion (the membrane round 3 of the evals left inside the bell
|
|
1105
|
+
# crank's frame) does not come back.
|
|
1106
|
+
xe = eroded(xt, beta)
|
|
1107
|
+
xp = _project((h @ xe) / hs, beta, opts.eta - opts.erode)
|
|
1108
|
+
xp[pres] = 1.0
|
|
1109
|
+
xp = printed(am_v, xp)
|
|
1110
|
+
if opts.dilate > 0:
|
|
1111
|
+
# ...then CLOSE it: dilate, erode back. A hole or gap narrower than
|
|
1112
|
+
# the dilation comes out filled. Only ever adds: with this filter a
|
|
1113
|
+
# member near R wide erodes by more than it dilated (a sealed-tube
|
|
1114
|
+
# test lost 6% of its volume and tripled its compliance), so the
|
|
1115
|
+
# closing is the union with what it closed.
|
|
1116
|
+
xd = _project((h @ xp) / hs, beta, eta_d)
|
|
1117
|
+
xd[pres] = 1.0
|
|
1118
|
+
xp = np.maximum(xp, _project((h @ xd) / hs, beta, opts.eta + opts.dilate))
|
|
1119
|
+
xp[pres] = 1.0
|
|
1120
|
+
xp = printed(am_v, xp)
|
|
1121
|
+
if void is not None:
|
|
1122
|
+
xp = xp.copy()
|
|
1123
|
+
xp[void] = 0.0
|
|
1124
|
+
if opts.access is not None:
|
|
1125
|
+
xp = _machined(domain, xp, opts.access)
|
|
1126
|
+
# Final analysis on the delivered field (it moved since the last solve).
|
|
1127
|
+
e = E_MIN + xp ** PENALTY * (1 - E_MIN)
|
|
1128
|
+
fg = None
|
|
1129
|
+
if domain.gravity is not None:
|
|
1130
|
+
from .domain import gravity_force
|
|
1131
|
+
fg = gravity_force(mesh, xp, domain.gravity, domain.fixed)
|
|
1132
|
+
fth = None
|
|
1133
|
+
if heat is not None:
|
|
1134
|
+
fg, fth = _heat_state(heat, xp, fg)
|
|
1135
|
+
e = e * heat.g
|
|
1136
|
+
loads, layout = expand(domain.loads, spreads, fg)
|
|
1137
|
+
us = fe.solve(e, xp, us, rtol=1e-6, loads=loads)
|
|
1138
|
+
if opts.prune:
|
|
1139
|
+
xp, us = _prune_dead(fe, domain, xp, us, loads, design, pres)
|
|
1140
|
+
if opts.access is not None:
|
|
1141
|
+
# pruning a dead end can leave a void under material: refill
|
|
1142
|
+
xq = _machined(domain, xp, opts.access)
|
|
1143
|
+
if (xq != xp).any():
|
|
1144
|
+
xp = xq
|
|
1145
|
+
us = fe.solve(E_MIN + xp ** PENALTY * (1 - E_MIN), xp, us, rtol=1e-6, loads=loads)
|
|
1146
|
+
case_c, case_d, comp = [], [], 0.0
|
|
1147
|
+
energy = np.zeros(n)
|
|
1148
|
+
stiff = E_MIN + xp ** PENALTY * (1 - E_MIN)
|
|
1149
|
+
if heat is not None:
|
|
1150
|
+
if opts.prune:
|
|
1151
|
+
# pruning moved the design: its temperature and load with it
|
|
1152
|
+
fg = gravity_force(mesh, xp, domain.gravity, domain.fixed) if domain.gravity is not None else None
|
|
1153
|
+
fg, fth = _heat_state(heat, xp, fg)
|
|
1154
|
+
loads, layout = expand(domain.loads, spreads, fg)
|
|
1155
|
+
us = fe.solve(stiff * heat.g, xp, us, rtol=1e-6, loads=loads)
|
|
1156
|
+
comp, case_c, _, heat_d = heat.sensitivities(fe, xp, loads, us, fth, weights, domain.gravity, heat_state,
|
|
1157
|
+
layout, spreads)
|
|
1158
|
+
stiff = stiff * heat.g
|
|
1159
|
+
for ci, wc in enumerate(collapse(loads, layout, us, spreads)):
|
|
1160
|
+
d = wc.direction if heat is None else heat_d[ci]
|
|
1161
|
+
case_d.append(None if d is None else [float(v) for v in d])
|
|
1162
|
+
if heat is None:
|
|
1163
|
+
case_c.append(wc.compliance)
|
|
1164
|
+
comp += weights[ci] * wc.compliance
|
|
1165
|
+
energy += weights[ci] * stiff * sum(m * fe.strain_energy(v) for m, v in wc.energy)
|
|
1166
|
+
warp = _warp_report(domain, xp) if domain.warp is not None else None
|
|
1167
|
+
return SimpResult(x=x, xphys=xp, compliance=comp, case_compliance=case_c,
|
|
1168
|
+
volume_fraction=float(xp[design].mean()), iterations=it,
|
|
1169
|
+
converged=converged, seconds=time.time() - t0, history=history,
|
|
1170
|
+
case_direction=case_d, energy=energy / mesh.pitch ** 3, warp=warp)
|