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
@@ -0,0 +1,172 @@
1
+ """
2
+ One evaluator for every design, whoever made it.
3
+
4
+ Reference codes report the compliance of a grey, penalized density field;
5
+ loadpath delivers an almost black-and-white one. Every design is scored here
6
+ the same way, on the problem's own boundary conditions with void at 1e-9:
7
+ its penalized (p = 3) compliance as delivered, and its compliance once
8
+ thresholded to solid/void at the target volume (keeping the densest cells).
9
+ The headline `compliance` is the lower of the two, the most generous reading
10
+ of either kind of design.
11
+
12
+ Plane problems use the 4-node plane-stress element of top88 (so a reference
13
+ design scores what top88 itself would compute); 3D ones use loadpath's 8-node
14
+ hexahedron, the same element as top3d.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import numpy as np
19
+ import scipy.sparse as sp
20
+ import scipy.sparse.linalg as spla
21
+
22
+ from .problems import Problem
23
+
24
+ __all__ = ["Evaluator", "binarize"]
25
+
26
+ E_VOID = 1e-9
27
+ NU = 0.3
28
+ _AX = {"x": 0, "y": 1, "z": 2}
29
+
30
+
31
+ def _direct(k, f):
32
+ """Sparse direct solve: CHOLMOD when scikit-sparse is installed (what
33
+ MATLAB's backslash uses for these matrices; much faster than SuperLU in
34
+ 3D), SuperLU otherwise."""
35
+ try:
36
+ from sksparse.cholmod import cho_solve
37
+ except ImportError:
38
+ return spla.spsolve(k.tocsc(), f)
39
+ return cho_solve(k.tocsc(), f)
40
+
41
+
42
+ def binarize(x: np.ndarray, active: np.ndarray, volfrac: float | None) -> np.ndarray:
43
+ """Solid where x is highest, `volfrac` of the active cells; None
44
+ thresholds at 0.5 instead."""
45
+ out = np.zeros_like(x, dtype=float)
46
+ xa = x[active]
47
+ if volfrac is None:
48
+ out[active] = (xa > 0.5).astype(float)
49
+ return out
50
+ k = int(round(volfrac * xa.size))
51
+ order = np.argsort(-xa, kind="stable")
52
+ b = np.zeros(xa.size)
53
+ b[order[:k]] = 1.0
54
+ out[active] = b
55
+ return out
56
+
57
+
58
+ def _q4_ke(nu=NU):
59
+ a11 = np.array([[12, 3, -6, -3], [3, 12, 3, 0], [-6, 3, 12, -3], [-3, 0, -3, 12]], float)
60
+ a12 = np.array([[-6, -3, 0, 3], [-3, -6, -3, -6], [0, -3, -6, 3], [3, -6, 3, -6]], float)
61
+ b11 = np.array([[-4, 3, -2, 9], [3, -4, -9, 4], [-2, -9, -4, -3], [9, 4, -3, -4]], float)
62
+ b12 = np.array([[2, -3, 4, -9], [-3, 2, 9, -2], [4, 9, 2, 3], [-9, -2, 3, 2]], float)
63
+ return 1 / (1 - nu ** 2) / 24 * (np.block([[a11, a12], [a12.T, a11]]) + nu * np.block([[b11, b12], [b12.T, b11]]))
64
+
65
+
66
+ class Evaluator:
67
+ def __init__(self, p: Problem):
68
+ self.p = p
69
+ nx, ny, nz = p.dims
70
+ if p.plane:
71
+ # top88 numbering in grid terms: node (i, k) -> i*(nz+1) + (nz-k),
72
+ # dofs 2n (x), 2n+1 (z up); element nodes BL, BR, TR, TL
73
+ def node(i, k):
74
+ return i * (nz + 1) + (nz - k)
75
+ ii, kk = np.meshgrid(np.arange(nx), np.arange(nz), indexing="ij")
76
+ ii, kk = ii.ravel(), kk.ravel() # element order: i major, k minor
77
+ nodes = np.stack([node(ii, kk), node(ii + 1, kk), node(ii + 1, kk + 1), node(ii, kk + 1)], axis=1)
78
+ self.edof = np.stack([2 * nodes, 2 * nodes + 1], axis=2).reshape(-1, 8)
79
+ self.ke = _q4_ke()
80
+ self.n_dof = 2 * (nx + 1) * (nz + 1)
81
+ self.cell = (ii, np.zeros_like(ii), kk)
82
+ comp = {"x": 0, "z": 1}
83
+ dof = lambda n, c: 2 * node(n[:, 0], n[:, 2]) + comp[c]
84
+ else:
85
+ from ..fem import HexMesh, hex8_ke
86
+ ii, jj, kk = np.meshgrid(np.arange(nx), np.arange(ny), np.arange(nz), indexing="ij")
87
+ ijk = np.stack([ii.ravel(), jj.ravel(), kk.ravel()], axis=1)
88
+ mesh = HexMesh(ijk, 1.0, np.zeros(3))
89
+ self.edof = mesh.edof
90
+ self.ke = hex8_ke(NU)
91
+ self.n_dof = mesh.n_dof
92
+ self.cell = tuple(ijk.T)
93
+ dof = lambda n, c: 3 * mesh.node_id(n) + _AX[c]
94
+ fixed = np.zeros(self.n_dof, bool)
95
+ for nodes, comps in p.fixed:
96
+ for c in comps:
97
+ fixed[dof(nodes, c)] = True
98
+ f = np.zeros(self.n_dof)
99
+ for nodes, c, v in p.loads:
100
+ np.add.at(f, dof(nodes, c), v)
101
+ self.fixed, self.f = fixed, f
102
+ self.free = np.flatnonzero(~fixed)
103
+ ne = len(self.edof)
104
+ self._rows = np.repeat(self.edof, self.edof.shape[1], axis=1).ravel()
105
+ self._cols = np.tile(self.edof, (1, self.edof.shape[1])).ravel()
106
+ self.active = p.active[self.cell]
107
+
108
+ def cells(self, grid: np.ndarray) -> np.ndarray:
109
+ """Grid (nx, ny, nz) -> this evaluator's element order."""
110
+ return np.asarray(grid, float)[self.cell]
111
+
112
+ def solve(self, e: np.ndarray) -> np.ndarray:
113
+ data = (e[:, None, None] * self.ke[None]).ravel()
114
+ k = sp.csr_matrix((data, (self._rows, self._cols)), shape=(self.n_dof, self.n_dof))
115
+ kf = k[self.free][:, self.free]
116
+ u = np.zeros(self.n_dof)
117
+ u[self.free] = _direct(kf, self.f[self.free])
118
+ return u
119
+
120
+ def compliance(self, grid: np.ndarray, penal: float = 3.0) -> float:
121
+ x = self.cells(grid)
122
+ u = self.solve(E_VOID + x ** penal * (1 - E_VOID))
123
+ return float(self.f @ u)
124
+
125
+ def von_mises(self, grid: np.ndarray) -> np.ndarray:
126
+ """Peak-relevant von Mises per element of a black-and-white design
127
+ (solid cells only; void reads 0). Plane problems only."""
128
+ if not self.p.plane:
129
+ raise NotImplementedError("von Mises scoring is for plane problems")
130
+ x = self.cells(grid)
131
+ u = self.solve(E_VOID + x ** 3 * (1 - E_VOID))
132
+ b = 0.5 * np.array([[-1, 0, 1, 0, 1, 0, -1, 0],
133
+ [0, -1, 0, -1, 0, 1, 0, 1],
134
+ [-1, -1, -1, 1, 1, 1, 1, -1]], float)
135
+ d = 1 / (1 - NU ** 2) * np.array([[1, NU, 0], [NU, 1, 0], [0, 0, (1 - NU) / 2]])
136
+ s = u[self.edof] @ (d @ b).T
137
+ vm = np.sqrt(s[:, 0] ** 2 + s[:, 1] ** 2 - s[:, 0] * s[:, 1] + 3 * s[:, 2] ** 2)
138
+ return np.where(x > 0.5, vm, 0.0)
139
+
140
+ def load_cells(self, layers: int = 2) -> np.ndarray:
141
+ """Elements within `layers` cells of a loaded node, which every
142
+ stress score leaves out (point loads make a singular stress there)."""
143
+ nodes = np.concatenate([n for n, _, _ in self.p.loads])
144
+ ijk = np.stack(self.cell, axis=1)
145
+ near = np.zeros(len(ijk), bool)
146
+ for n in nodes:
147
+ near |= np.all(np.abs(ijk + 0.5 - n) <= layers, axis=1)
148
+ return near
149
+
150
+ def score(self, grid: np.ndarray, target: float) -> dict:
151
+ """Everything the report compares, for one design grid."""
152
+ x = self.cells(grid)
153
+ act = self.active
154
+ full = np.zeros(self.p.dims)
155
+ out = {"volume": float(x[act].mean()), "greyness": float((4 * x * (1 - x))[act].mean()),
156
+ "compliance_grey": self.compliance(grid)}
157
+ for key, vf in (("matched", target), ("threshold", None)):
158
+ b = binarize(x, act, vf)
159
+ full[self.cell] = b
160
+ out[f"compliance_{key}"] = self.compliance(full)
161
+ out[f"volume_{key}"] = float(b[act].mean())
162
+ if key == "matched":
163
+ bin_matched = full.copy()
164
+ # the most generous reading of each design: its own penalized
165
+ # compliance, or black and white at the target volume, whichever is
166
+ # lower (thresholding a grey design can cut its thin members off)
167
+ out["compliance"] = min(out["compliance_grey"], out["compliance_matched"])
168
+ if self.p.stress:
169
+ vm = self.von_mises(bin_matched)
170
+ vm[self.load_cells()] = 0.0
171
+ out["peak_von_mises"] = float(vm.max())
172
+ return out
@@ -0,0 +1,416 @@
1
+ """
2
+ Compliant-mechanism benchmarks: the textbook force inverter and a gripper,
3
+ run through the published reference code and loadpath's mechanism.py, and
4
+ scored by one independent evaluator.
5
+
6
+ python -m loadpath.benchmarks.mechanisms [--out benchmark-out/]
7
+
8
+ The reference is a line-for-line NumPy port of the compliant-mechanism
9
+ version of Sigmund's 99-line code, as printed in Bendsøe & Sigmund,
10
+ "Topology Optimization" (2003), sec. 2.6 / appendix: 4-node plane-stress
11
+ squares, SIMP p = 3 with x >= 0.001, the mesh-independency sensitivity
12
+ filter, and optimality criteria damped with exponent 0.3 and move 0.1. The
13
+ half inverter is its own example: 40 x 20 cells, volume 0.3, rmin 1.2,
14
+ springs of 0.1 at the input and the output, unit input force.
15
+
16
+ Every design is scored on the plane-stress model the reference uses
17
+ (benchmarks/evaluate.py's element) with the same springs: the output
18
+ displacement in the wanted direction, as delivered (penalized grey) and
19
+ thresholded black and white at the target volume. The black-and-white
20
+ reading is what a printed part does. loadpath's design comes from its 3D
21
+ optimizer on a one-cell slab (as the compliance benchmarks run), so the
22
+ comparison is of designs, not solvers.
23
+
24
+ Hinges: a mechanism optimum on a voxel grid likes one-node hinges (two
25
+ cells meeting at a corner), which carry no load and print as nothing.
26
+ `hinges` counts them in each black-and-white design.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import time
34
+ from dataclasses import dataclass, field
35
+
36
+ import numpy as np
37
+ import scipy.sparse as sp
38
+
39
+ from .evaluate import _direct, _q4_ke, binarize
40
+ from .problems import _nodes
41
+
42
+ __all__ = ["MechProblem", "MECHANISMS", "MechEvaluator", "optimize_reference_mechanism", "run_loadpath_mechanism",
43
+ "hinges", "bench_mechanism"]
44
+
45
+ NU = 0.3
46
+
47
+
48
+ @dataclass
49
+ class MechProblem:
50
+ name: str
51
+ title: str
52
+ source: str
53
+ dims: tuple # (nx, 1, nz) plane problems
54
+ volfrac: float
55
+ rmin: float
56
+ fixed: list # [(nodes, comps)] clamps (pinned solid in loadpath, as a study would)
57
+ sym: list # [(nodes, comps)] symmetry-line conditions (not pinned)
58
+ inp: tuple # (nodes, comp, force per node)
59
+ out: tuple # (nodes, comp, sign): the wanted motion is sign * u_comp
60
+ k_in: float = 0.1
61
+ k_out: float = 0.1
62
+ void: np.ndarray | None = None
63
+ notes: str = ""
64
+ extra: dict = field(default_factory=dict)
65
+
66
+
67
+ def inverter(nx=40, nz=20):
68
+ """Half force inverter, Bendsøe & Sigmund (2003) sec. 2.6: the top edge is
69
+ the symmetry line; push right at its left end, and its right end should
70
+ move left. Clamped at the bottom left."""
71
+ return MechProblem(
72
+ name="inverter-2d", title=f"Force inverter, half model ({nx}x{nz})",
73
+ source="Bendsøe & Sigmund 2003, 'Topology Optimization', sec. 2.6 (Sigmund 1997); "
74
+ "the mechanism version of the 99-line code: nelx 40, nely 20, volfrac 0.3, penal 3, rmin 1.2, "
75
+ "springs 0.1",
76
+ dims=(nx, 1, nz), volfrac=0.3, rmin=1.2,
77
+ fixed=[(_nodes(0, 0, [0, 1]), "xz")],
78
+ sym=[(_nodes(range(nx + 1), 0, nz), "z")],
79
+ inp=(_nodes(0, 0, nz), "x", 1.0), out=(_nodes(nx, 0, nz), "x", -1.0))
80
+
81
+
82
+ def gripper(nx=60, nz=30, jaw=(10, 6)):
83
+ """Half gripper after Sigmund 1997: the bottom edge is the symmetry line;
84
+ push right at its left end, and the jaw (the top of a cut-out at the
85
+ bottom right, where the workpiece sits) should close down onto it.
86
+ Clamped at the top left."""
87
+ jx, jz = jaw
88
+ void = np.zeros((nx, 1, nz), bool)
89
+ void[nx - jx:, :, :jz] = True
90
+ return MechProblem(
91
+ name="gripper-2d", title=f"Gripper, half model ({nx}x{nz}, {jx}x{jz} jaw gap)",
92
+ source="Sigmund 1997, 'On the design of compliant mechanisms using topology optimization' (layout); "
93
+ "reference design from the same 99-line mechanism code",
94
+ dims=(nx, 1, nz), volfrac=0.3, rmin=1.2,
95
+ fixed=[(_nodes(0, 0, [nz - 1, nz]), "xz")],
96
+ sym=[(_nodes(range(nx - jx + 1), 0, 0), "z")],
97
+ inp=(_nodes(0, 0, 0), "x", 1.0), out=(_nodes(range(nx - jx, nx + 1), 0, jz), "z", -1.0),
98
+ void=void)
99
+
100
+
101
+ MECHANISMS = {p.name: p for p in (inverter(), gripper())}
102
+
103
+
104
+ class MechEvaluator:
105
+ """top88's plane-stress element on the problem's grid, with the springs."""
106
+
107
+ def __init__(self, p: MechProblem):
108
+ self.p = p
109
+ nx, _, nz = p.dims
110
+
111
+ def node(i, k):
112
+ return i * (nz + 1) + (nz - k)
113
+ ii, kk = np.meshgrid(np.arange(nx), np.arange(nz), indexing="ij")
114
+ ii, kk = ii.ravel(), kk.ravel()
115
+ nodes = np.stack([node(ii, kk), node(ii + 1, kk), node(ii + 1, kk + 1), node(ii, kk + 1)], axis=1)
116
+ self.edof = np.stack([2 * nodes, 2 * nodes + 1], axis=2).reshape(-1, 8)
117
+ self.ke = _q4_ke(NU)
118
+ self.n_dof = 2 * (nx + 1) * (nz + 1)
119
+ self.cell = (ii, np.zeros_like(ii), kk)
120
+ comp = {"x": 0, "z": 1}
121
+ self.dof = lambda n, c: 2 * node(n[:, 0], n[:, 2]) + comp[c]
122
+ fixed = np.zeros(self.n_dof, bool)
123
+ for nodes_, comps in p.fixed + p.sym:
124
+ for c in comps:
125
+ fixed[self.dof(nodes_, c)] = True
126
+ self.fixed = fixed
127
+ self.free = np.flatnonzero(~fixed)
128
+ n, c, v = p.inp
129
+ self.f = np.zeros(self.n_dof)
130
+ np.add.at(self.f, self.dof(n, c), v)
131
+ n, c, sgn = p.out
132
+ self.l = np.zeros(self.n_dof)
133
+ np.add.at(self.l, self.dof(n, c), sgn / len(n)) # mean wanted motion of the output nodes
134
+ self.springs = np.zeros(self.n_dof)
135
+ din = self.dof(p.inp[0], p.inp[1])
136
+ dout = self.dof(p.out[0], p.out[1])
137
+ np.add.at(self.springs, din, p.k_in / len(din))
138
+ np.add.at(self.springs, dout, p.k_out / len(dout))
139
+ self._rows = np.repeat(self.edof, 8, axis=1).ravel()
140
+ self._cols = np.tile(self.edof, (1, 8)).ravel()
141
+ self.active = np.ones(len(ii), bool) if p.void is None else ~p.void[self.cell]
142
+
143
+ def cells(self, grid):
144
+ return np.asarray(grid, float)[self.cell]
145
+
146
+ def matrix(self, e):
147
+ data = (e[:, None, None] * self.ke[None]).ravel()
148
+ k = sp.csr_matrix((data, (self._rows, self._cols)), shape=(self.n_dof, self.n_dof))
149
+ return (k + sp.diags(self.springs))[self.free][:, self.free]
150
+
151
+ def solve(self, e, rhs):
152
+ u = np.zeros(self.n_dof)
153
+ u[self.free] = _direct(self.matrix(e), rhs[self.free])
154
+ return u
155
+
156
+ def output(self, grid, penal=3.0, emin=1e-9):
157
+ x = self.cells(grid)
158
+ u = self.solve(emin + x ** penal * (1 - emin), self.f)
159
+ return float(self.l @ u)
160
+
161
+ def peak_stress(self, grid, layers=2) -> float:
162
+ """Peak von Mises (solid cells of a black-and-white design, under the
163
+ input force with the springs), leaving out cells within `layers` of a
164
+ loaded, sprung or clamped node. Thin hinges show up here: they carry
165
+ the whole mechanism's bending in a cell or two."""
166
+ x = self.cells(grid)
167
+ u = self.solve(1e-9 + x ** 3 * (1 - 1e-9), self.f)
168
+ b = 0.5 * np.array([[-1, 0, 1, 0, 1, 0, -1, 0],
169
+ [0, -1, 0, -1, 0, 1, 0, 1],
170
+ [-1, -1, -1, 1, 1, 1, 1, -1]], float)
171
+ d = 1 / (1 - NU ** 2) * np.array([[1, NU, 0], [NU, 1, 0], [0, 0, (1 - NU) / 2]])
172
+ st = u[self.edof] @ (d @ b).T
173
+ vm = np.sqrt(st[:, 0] ** 2 + st[:, 1] ** 2 - st[:, 0] * st[:, 1] + 3 * st[:, 2] ** 2)
174
+ vm = np.where(x > 0.5, vm, 0.0)
175
+ p = self.p
176
+ nodes = np.concatenate([p.inp[0], p.out[0]] + [n for n, _ in p.fixed])
177
+ ijk = np.stack(self.cell, axis=1)
178
+ for n in nodes:
179
+ vm[np.all(np.abs(ijk + 0.5 - n) <= layers, axis=1)] = 0.0
180
+ return float(vm.max())
181
+
182
+ def score(self, grid) -> dict:
183
+ x = self.cells(grid)
184
+ act = self.active
185
+ out = {"volume": float(x[act].mean()), "greyness": float((4 * x * (1 - x))[act].mean()),
186
+ "output_grey": self.output(grid)}
187
+ b = np.zeros(self.p.dims)
188
+ b[self.cell] = binarize(x, act, self.p.volfrac * x.size / act.sum())
189
+ out["output_bw"] = self.output(b)
190
+ out["hinges"] = hinges(b)
191
+ out["peak_stress_bw"] = self.peak_stress(b)
192
+ out["_bw"] = b
193
+ return out
194
+
195
+
196
+ def hinges(grid) -> int:
197
+ """One-node hinges in a black-and-white plane design: 2x2 windows with
198
+ exactly two solid cells on a diagonal."""
199
+ g = np.asarray(grid)[:, 0, :] > 0.5
200
+ a, b, c, d = g[:-1, :-1], g[1:, :-1], g[:-1, 1:], g[1:, 1:]
201
+ return int(((a & d & ~b & ~c) | (b & c & ~a & ~d)).sum())
202
+
203
+
204
+ def _check(nx, nz, rmin, x, dc):
205
+ """The 99-line code's mesh-independency filter, with x and dc on the
206
+ (nx, nz) grid."""
207
+ out = np.zeros_like(dc)
208
+ r = int(np.floor(rmin))
209
+ for i in range(nx):
210
+ for k in range(nz):
211
+ s = 0.0
212
+ for i2 in range(max(i - r, 0), min(i + r + 1, nx)):
213
+ for k2 in range(max(k - r, 0), min(k + r + 1, nz)):
214
+ fac = rmin - np.hypot(i - i2, k - k2)
215
+ if fac > 0:
216
+ s += fac
217
+ out[i, k] += fac * x[i2, k2] * dc[i2, k2]
218
+ out[i, k] /= x[i, k] * s
219
+ return out
220
+
221
+
222
+ def optimize_reference_mechanism(p: MechProblem, maxit=300, on_iter=None) -> dict:
223
+ """The 99-line code's mechanism version: minimize the output dof's
224
+ displacement (minus the wanted motion), OC
225
+ with sensitivities clipped positive and damping 0.3, move 0.1. (Its
226
+ dummy load F(dout, 2) = -1 points the way an inverter's output should
227
+ move, so with c = u_out the adjoint is that load's solution.)"""
228
+ ev = MechEvaluator(p)
229
+ nx, _, nz = p.dims
230
+ penal, move = 3.0, 0.1
231
+ act = ev.active.reshape(nx, nz)
232
+ x = np.where(act, p.volfrac * nx * nz / act.sum(), 0.001)
233
+ t0 = time.time()
234
+ loop, change = 0, 1.0
235
+ while change > 0.01 and loop < maxit:
236
+ loop += 1
237
+ xe = x.ravel()
238
+ e = xe ** penal
239
+ u1 = ev.solve(e, ev.f)
240
+ u2 = ev.solve(e, ev.l) # the book's dummy load: -1 on the output dof, i.e. along the wanted motion
241
+ c = float(-ev.l @ u1) # minimized: minus the wanted motion
242
+ # d c / d x = p x^(p-1) u2' ke u1 (the book's sign convention)
243
+ dc = penal * xe ** (penal - 1) * np.einsum("ij,jk,ik->i", u2[ev.edof], ev.ke, u1[ev.edof])
244
+ dc = _check(nx, nz, p.rmin, x, dc.reshape(nx, nz))
245
+ l1, l2 = 0.0, 1e5
246
+ # MATLAB's 0/0 is NaN, which ends the bisection when every move
247
+ # shrinks the volume (early on, few cells can help the output)
248
+ while l1 + l2 > 0 and (l2 - l1) / (l1 + l2) > 1e-4:
249
+ lmid = 0.5 * (l2 + l1)
250
+ xnew = np.maximum(0.001, np.maximum(x - move, np.minimum(1.0, np.minimum(
251
+ x + move, x * np.maximum(1e-10, -dc / lmid) ** 0.3))))
252
+ xnew[~act] = 0.001
253
+ if xnew.sum() - p.volfrac * nx * nz > 0:
254
+ l1 = lmid
255
+ else:
256
+ l2 = lmid
257
+ change = float(np.abs(xnew - x).max())
258
+ x = xnew
259
+ if on_iter is not None:
260
+ on_iter(loop, -c, change)
261
+ grid = np.zeros(p.dims)
262
+ grid[:, 0, :] = x
263
+ return dict(x=grid, iterations=loop, output_reported=-c, seconds=time.time() - t0, solver="sigmund-mech")
264
+
265
+
266
+ def _box(nodes):
267
+ lo = nodes.min(axis=0) - 0.25
268
+ hi = nodes.max(axis=0) + 0.25
269
+ lo[1], hi[1] = -0.25, 1.25
270
+ return {"type": "box", "min": lo.tolist(), "max": hi.tolist()}
271
+
272
+
273
+ def _slab(nodes):
274
+ other = nodes.copy()
275
+ other[:, 1] = 1
276
+ return np.concatenate([nodes, other])
277
+
278
+
279
+ def mech_domain(p: MechProblem):
280
+ """loadpath's Domain for a mechanism benchmark, built as a study would
281
+ (clamp, input and output regions pinned solid; the jaw gap a keep-out),
282
+ then with supports and loads set exactly to the problem's. The input
283
+ case carries the force, the output case the unit wanted direction."""
284
+ from ..domain import build_domain
285
+ from ..spec import study_from_dict
286
+
287
+ nx, ny, nz = p.dims
288
+ n_in, c_in, v_in = p.inp
289
+ n_out, c_out, sgn = p.out
290
+ d = {
291
+ "designSpace": [{"type": "box", "min": [0, 0, 0], "max": [nx, ny, nz]}],
292
+ "supports": [{"shape": _box(n), "fix": "".join(sorted(c))} for n, c in p.fixed],
293
+ "loadCases": [
294
+ {"name": "in", "loads": [{"shape": _box(n_in), "force": [v_in * len(n_in) if a == c_in else 0.0
295
+ for a in "xyz"]}]},
296
+ {"name": "out", "loads": [{"shape": _box(n_out), "force": [sgn if a == c_out else 0.0 for a in "xyz"]}]}],
297
+ "material": {"name": "unit", "E": 1.0, "nu": NU, "strength": 1.0, "density": 1.0},
298
+ "goal": {"volumeFraction": p.volfrac},
299
+ "resolution": {"pitch": 1.0},
300
+ "extrusion": "y",
301
+ }
302
+ if p.void is not None:
303
+ ii, _, kk = np.nonzero(p.void)
304
+ d["keepOut"] = [{"id": "gap", "shape": {"type": "box", "min": [float(ii.min()), -1, float(kk.min()) - 1],
305
+ "max": [float(ii.max() + 1) + 1, ny + 1, float(kk.max() + 1)]},
306
+ "bore": False}]
307
+ dom = build_domain(study_from_dict(d), pitch=1.0)
308
+ if tuple(dom.dims) != tuple(p.dims) or np.abs(dom.origin).max() > 1e-9:
309
+ raise RuntimeError(f"{p.name}: loadpath grid {dom.dims} at {dom.origin} does not match {p.dims}")
310
+ mesh = dom.mesh
311
+ fixed = np.zeros(mesh.n_dof, bool)
312
+ for nodes, comps in p.fixed + p.sym:
313
+ ids = mesh.node_id(_slab(nodes))
314
+ for c in comps:
315
+ fixed[3 * ids + "xyz".index(c)] = True
316
+ fixed[3 * np.flatnonzero(mesh.node_ijk[:, 1] == 0) + 1] = True # half of a plate symmetric about y = 0
317
+ f = np.zeros(mesh.n_dof)
318
+ np.add.at(f, 3 * mesh.node_id(_slab(n_in)) + "xyz".index(c_in), v_in * len(n_in) / (2 * len(n_in)))
319
+ lo = np.zeros(mesh.n_dof)
320
+ np.add.at(lo, 3 * mesh.node_id(_slab(n_out)) + "xyz".index(c_out), sgn)
321
+ dom.fixed, dom.loads, dom.springs = fixed, [f, lo], None
322
+ return dom
323
+
324
+
325
+ def run_loadpath_mechanism(p: MechProblem, filter_radius=None, log=print, **kw) -> dict:
326
+ from ..mechanism import MechanismOptions, optimize_mechanism
327
+
328
+ dom = mech_domain(p)
329
+ n, pres = dom.mesh.n_elem, int(dom.preserve.sum())
330
+ active = n # the jaw gap is a keep-out: not in the mesh
331
+ vf = max(0.01, (p.volfrac * np.prod(p.dims) - pres) / (active - pres))
332
+ opts = MechanismOptions(volume_fraction=vf, filter_radius=filter_radius or max(p.rmin, 1.5), modulus=1.0,
333
+ k_in=p.k_in, k_out=p.k_out, extrusion=1, **kw)
334
+ t0 = time.time()
335
+ r = optimize_mechanism(dom, NU, opts)
336
+ log(f" loadpath: {r.iterations} iterations, {time.time() - t0:.0f}s, output {r.output_displacement:.4g}")
337
+ g = np.zeros(dom.dims)
338
+ g[tuple(dom.mesh.elem_ijk.T)] = r.xphys
339
+ return dict(x=g, iterations=r.iterations, seconds=time.time() - t0, output_reported=r.output_displacement)
340
+
341
+
342
+ VARIANTS = {
343
+ "loadpath r1.5": {"filter_radius": 1.5},
344
+ "loadpath r2": {"filter_radius": 2.0},
345
+ # eroded / blueprint / dilated: no one-node hinges
346
+ "loadpath robust": {"filter_radius": 2.0, "robust": 0.15, "max_iterations": 200},
347
+ }
348
+
349
+
350
+ def bench_mechanism(p: MechProblem, log=print, variants=None) -> dict:
351
+ ev = MechEvaluator(p)
352
+ designs = {"sigmund-mech": optimize_reference_mechanism(p)}
353
+ log(f" reference: {designs['sigmund-mech']['iterations']} iterations")
354
+ for name, kw in (variants or VARIANTS).items():
355
+ designs[name] = run_loadpath_mechanism(p, log=log, **kw)
356
+ rows = []
357
+ for name, rec in designs.items():
358
+ s = ev.score(rec["x"])
359
+ rec["bw"] = s.pop("_bw")
360
+ rows.append({"design": name, "iterations": rec["iterations"], "seconds": round(rec["seconds"], 1),
361
+ "output_reported": rec["output_reported"], **s})
362
+ ref = rows[0]
363
+ for r in rows:
364
+ r["bw_over_reference_bw"] = r["output_bw"] / ref["output_bw"] if ref["output_bw"] else None
365
+ return {"problem": p.name, "title": p.title, "source": p.source, "dims": list(p.dims), "volfrac": p.volfrac,
366
+ "rows": rows, "_designs": designs}
367
+
368
+
369
+ def render(res: dict, path: str):
370
+ import matplotlib
371
+ matplotlib.use("Agg")
372
+ import matplotlib.pyplot as plt
373
+
374
+ designs = res["_designs"]
375
+ names = list(designs)
376
+ fig, axes = plt.subplots(2, len(names), figsize=(3.4 * len(names), 4.6), gridspec_kw={"hspace": 0.45})
377
+ for j, name in enumerate(names):
378
+ row = next(r for r in res["rows"] if r["design"] == name)
379
+ for i, (key, title) in enumerate((("x", f"{name}\ngrey: out {row['output_grey']:.3g}"),
380
+ ("bw", f"black/white: out {row['output_bw']:.3g}\n"
381
+ f"peak stress {row['peak_stress_bw']:.2g}, "
382
+ f"{row['hinges']} corner hinges"))):
383
+ ax = axes[i, j]
384
+ ax.imshow(1 - designs[name][key][:, 0, :].T, cmap="gray", origin="lower", vmin=0, vmax=1)
385
+ ax.set_title(title, fontsize=8)
386
+ ax.set_xticks([])
387
+ ax.set_yticks([])
388
+ fig.suptitle(res["title"], fontsize=10)
389
+ fig.tight_layout()
390
+ fig.savefig(path, dpi=130)
391
+ plt.close(fig)
392
+
393
+
394
+ def main(argv=None):
395
+ ap = argparse.ArgumentParser()
396
+ ap.add_argument("--out", default="benchmark-out")
397
+ ap.add_argument("--problems", nargs="*", default=list(MECHANISMS))
398
+ a = ap.parse_args(argv)
399
+ os.makedirs(a.out, exist_ok=True)
400
+ summary = []
401
+ for name in a.problems:
402
+ p = MECHANISMS[name]
403
+ print(p.title, flush=True)
404
+ res = bench_mechanism(p)
405
+ render(res, os.path.join(a.out, f"{p.name}.png"))
406
+ for r in res["rows"]:
407
+ print(f" {r['design']:16s} grey {r['output_grey']:.4g} bw {r['output_bw']:.4g} "
408
+ f"hinges {r['hinges']} peak vm {r['peak_stress_bw']:.3g} vol {r['volume']:.3f} "
409
+ f"grey {r['greyness']:.3f}", flush=True)
410
+ summary.append({k: v for k, v in res.items() if not k.startswith("_")})
411
+ with open(os.path.join(a.out, "mechanisms.json"), "w") as fh:
412
+ json.dump(summary, fh, indent=1, default=float)
413
+
414
+
415
+ if __name__ == "__main__":
416
+ main()