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.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. 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)