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,140 @@
1
+ """
2
+ The benchmark problems: the textbook topology-optimization cases, each
3
+ defined once on a unit voxel grid so the same boundary conditions drive the
4
+ published reference code (top88, top3d, run in Octave), loadpath, and the
5
+ evaluator that scores every design.
6
+
7
+ Grid convention is loadpath's: cell (i, j, k) along x, y, z with z up, unit
8
+ cells, unit Young's modulus, nu = 0.3. A plane (2D) problem has one cell in y
9
+ and is solved in plane stress by the reference and the evaluator; loadpath
10
+ optimizes it as a one-cell slab extruded along y.
11
+
12
+ Boundary conditions are node sets in grid coordinates:
13
+ fixed: [(nodes (N, 3) int, "xz")] components held at zero
14
+ loads: [(nodes (N, 3) int, "z", value)] force per node
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ import numpy as np
21
+
22
+ __all__ = ["Problem", "PROBLEMS", "get"]
23
+
24
+
25
+ def _nodes(i, j, k):
26
+ """Every node (i, j, k) of the given index ranges (ints or iterables)."""
27
+ ii, jj, kk = np.meshgrid(np.atleast_1d(i), np.atleast_1d(j), np.atleast_1d(k), indexing="ij")
28
+ return np.stack([ii.ravel(), jj.ravel(), kk.ravel()], axis=1).astype(np.int64)
29
+
30
+
31
+ @dataclass
32
+ class Problem:
33
+ name: str
34
+ title: str
35
+ source: str
36
+ dims: tuple # cells (nx, ny, nz)
37
+ volfrac: float # of the whole box, as top88/top3d count it
38
+ rmin: float # filter radius, cells
39
+ fixed: list
40
+ loads: list
41
+ references: list # reference solver ids (reference.SOLVERS)
42
+ plane: bool = False
43
+ void: np.ndarray | None = None # bool (nx, ny, nz): passive void cells
44
+ # The reference model is the half of a symmetric part cut at x = 0 (the
45
+ # half MBB beam). loadpath models the whole part with mirror symmetry, as
46
+ # a study would; `full` is that problem and the delivered design's
47
+ # x >= nx half is the one scored.
48
+ full: "Problem | None" = None
49
+ symmetry: list = field(default_factory=list)
50
+ # Also score peak von Mises and run loadpath's stress-constrained
51
+ # minimum-mass formulation against the reference's peak.
52
+ stress: bool = False
53
+ notes: str = ""
54
+
55
+ @property
56
+ def active(self) -> np.ndarray:
57
+ a = np.ones(self.dims, bool)
58
+ if self.void is not None:
59
+ a &= ~self.void
60
+ return a
61
+
62
+ @property
63
+ def active_volfrac(self) -> float:
64
+ """The same amount of material as a share of the cells that exist."""
65
+ return self.volfrac * float(np.prod(self.dims)) / float(self.active.sum())
66
+
67
+
68
+ def mbb_2d(nx=150, nz=50, volfrac=0.5, rmin=2.0):
69
+ """Half MBB beam exactly as top88 ships it: symmetry line on the left,
70
+ roller at the bottom right, unit load down at the top left."""
71
+ half = Problem(
72
+ name="mbb-2d", title=f"MBB beam, 2D half model ({nx}x{nz})",
73
+ source="Andreassen et al. 2011, 'Efficient topology optimization in MATLAB using 88 lines of code' "
74
+ "(top88.m, default load case)",
75
+ dims=(nx, 1, nz), volfrac=volfrac, rmin=rmin, plane=True,
76
+ fixed=[(_nodes(0, 0, range(nz + 1)), "x"), (_nodes(nx, 0, 0), "z")],
77
+ loads=[(_nodes(0, 0, nz), "z", -1.0)],
78
+ references=["top88-ft1", "top88-ft2"])
79
+ # whole beam: x in [0, 2nx], the half above sits at x >= nx
80
+ half.full = Problem(
81
+ name="mbb-2d-full", title="MBB beam, whole", source=half.source,
82
+ dims=(2 * nx, 1, nz), volfrac=volfrac, rmin=rmin, plane=True,
83
+ fixed=[(_nodes(0, 0, 0), "xz"), (_nodes(2 * nx, 0, 0), "z")],
84
+ loads=[(_nodes(nx, 0, nz), "z", -2.0)],
85
+ references=[], symmetry=["x"])
86
+ return half
87
+
88
+
89
+ def lbracket_2d(n=100, cut=0.6, volfrac_active=0.4, rmin=2.0):
90
+ """L-bracket: the 2D stress benchmark (Le et al. 2010 and most stress
91
+ papers since). Clamped along the top edge, loaded down on the top of the
92
+ free arm's tip, the re-entrant corner is where compliance designs
93
+ concentrate stress."""
94
+ c = int(round(n * (1 - cut))) # arm width, cells
95
+ void = np.zeros((n, 1, n), bool)
96
+ void[c:, :, c:] = True
97
+ tip = 6
98
+ active = n * n - (n - c) ** 2
99
+ return Problem(
100
+ name="lbracket-2d", title=f"L-bracket, 2D ({n}x{n}, {n - c}x{n - c} cut out)",
101
+ source="Le, Norato, Bruns, Ha & Tortorelli 2010, 'Stress-based topology optimization for continua' "
102
+ "(geometry); reference design from top88.m with passive voids (Andreassen et al. 2011, sec. 5.2)",
103
+ dims=(n, 1, n), volfrac=volfrac_active * active / (n * n), rmin=rmin, plane=True, void=void,
104
+ fixed=[(_nodes(range(c + 1), 0, n), "xz")],
105
+ loads=[(_nodes(n, 0, range(c - tip + 1, c + 1)), "z", -1.0 / tip)],
106
+ references=["top88-ft2"], stress=True)
107
+
108
+
109
+ def cantilever_3d(nx=60, ny=4, nz=20, volfrac=0.3, rmin=1.5):
110
+ """top3d's default problem: clamped at x = 0, a unit load per node down
111
+ along the bottom edge of the free end."""
112
+ return Problem(
113
+ name="cantilever-3d", title=f"Cantilever beam, 3D ({nx}x{nz}x{ny})",
114
+ source="Liu & Tovar 2014, 'An efficient 3D topology optimization code written in Matlab' "
115
+ "(top3d.m, default load case, top3d(60,20,4,0.3,3,1.5))",
116
+ dims=(nx, ny, nz), volfrac=volfrac, rmin=rmin,
117
+ fixed=[(_nodes(0, range(ny + 1), range(nz + 1)), "xyz")],
118
+ loads=[(_nodes(nx, range(ny + 1), 0), "z", -1.0)],
119
+ references=["top3d"])
120
+
121
+
122
+ def bridge_3d(nx=48, ny=12, nz=16, volfrac=0.15, rmin=1.5):
123
+ """A deck bridge: pinned along the bottom edges at both ends, a uniform
124
+ load over the whole top face."""
125
+ top = _nodes(range(nx + 1), range(ny + 1), nz)
126
+ return Problem(
127
+ name="bridge-3d", title=f"Bridge, 3D ({nx}x{ny}x{nz}, deck load)",
128
+ source="Classic 3D bridge benchmark (e.g. Aage et al. 2017 giga-voxel bridge, coarse); "
129
+ "reference design from top3d.m with these supports and loads",
130
+ dims=(nx, ny, nz), volfrac=volfrac, rmin=rmin,
131
+ fixed=[(_nodes([0, nx], range(ny + 1), 0), "xyz")],
132
+ loads=[(top, "z", -100.0 / len(top))],
133
+ references=["top3d"], symmetry=["x", "y"])
134
+
135
+
136
+ PROBLEMS = {p.name: p for p in (mbb_2d(), lbracket_2d(), cantilever_3d(), bridge_3d())}
137
+
138
+
139
+ def get(name: str) -> Problem:
140
+ return PROBLEMS[name]
@@ -0,0 +1,138 @@
1
+ """
2
+ The reference optimizers: line-for-line NumPy ports of the published
3
+ educational codes, run on each benchmark's own supports and loads.
4
+
5
+ - top88: Andreassen, Clausen, Schevenels, Lazarov & Sigmund 2011, "Efficient
6
+ topology optimization in MATLAB using 88 lines of code". 2D, SIMP p = 3,
7
+ Emin = 1e-9, OC with move 0.2, sensitivity filter (ft=1) or density filter
8
+ (ft=2), stops when no density moves more than 0.01. Passive void cells as in
9
+ the paper's section 5.2.
10
+ - top3d: Liu & Tovar 2014, "An efficient 3D topology optimization code
11
+ written in Matlab". The same scheme in 3D with the density filter and at
12
+ most 200 iterations.
13
+
14
+ What is ported: the filter (truncated cone, normalized by its row sums),
15
+ the sensitivities, the OC bisection (l2 = 1e9, tolerance 1e-3 relative), the
16
+ volume measured on physical densities and the stopping rule. The finite
17
+ elements are the evaluator's, which are the same elements (top88's 4-node
18
+ plane-stress square, top3d's 8-node brick).
19
+
20
+ The designs are stored under reference/ as grids in loadpath's layout, so the
21
+ suite only re-runs a reference when asked to.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import time
27
+
28
+ import numpy as np
29
+ import scipy.sparse as sp
30
+
31
+ from .evaluate import E_VOID, Evaluator
32
+ from .problems import Problem
33
+
34
+ __all__ = ["SOLVERS", "run_reference", "load_reference", "reference_path"]
35
+
36
+ HERE = os.path.dirname(os.path.abspath(__file__))
37
+ SOLVERS = {
38
+ "top88-ft1": "top88, sensitivity filter (Andreassen et al. 2011)",
39
+ "top88-ft2": "top88, density filter (Andreassen et al. 2011)",
40
+ "top3d": "top3d, density filter (Liu & Tovar 2014)",
41
+ }
42
+ # top88 itself has no iteration cap; this one only keeps a chattering run
43
+ # bounded. top3d ships with 200.
44
+ TOP88_MAXIT = 1000
45
+ TOP3D_MAXIT = 200
46
+
47
+
48
+ def _filter(ev: Evaluator, rmin: float):
49
+ """top88's H and Hs: weights rmin - distance to every cell within
50
+ ceil(rmin) - 1 cells per axis, normalized by the row sum."""
51
+ ijk = np.stack(ev.cell, axis=1)
52
+ dims = np.asarray(ev.p.dims)
53
+ lut = np.full(ev.p.dims, -1, np.int64)
54
+ lut[tuple(ijk.T)] = np.arange(len(ijk))
55
+ reach = int(np.ceil(rmin)) - 1
56
+ span = [range(-reach, reach + 1) if d > 1 else range(1) for d in dims]
57
+ rows, cols, vals = [], [], []
58
+ for dx in span[0]:
59
+ for dy in span[1]:
60
+ for dz in span[2]:
61
+ w = rmin - np.sqrt(dx * dx + dy * dy + dz * dz)
62
+ if w <= 0:
63
+ continue
64
+ nb = ijk + (dx, dy, dz)
65
+ ok = np.all((nb >= 0) & (nb < dims), axis=1)
66
+ rows.append(np.flatnonzero(ok))
67
+ cols.append(lut[tuple(nb[ok].T)])
68
+ vals.append(np.full(int(ok.sum()), w))
69
+ n = len(ijk)
70
+ h = sp.csr_matrix((np.concatenate(vals), (np.concatenate(rows), np.concatenate(cols))), shape=(n, n))
71
+ return h, np.asarray(h.sum(axis=1)).ravel()
72
+
73
+
74
+ def optimize_reference(p: Problem, solver: str, on_iter=None) -> dict:
75
+ ev = Evaluator(p)
76
+ ft = 1 if solver == "top88-ft1" else 2
77
+ maxit = TOP3D_MAXIT if solver == "top3d" else TOP88_MAXIT
78
+ penal, move = 3.0, 0.2
79
+ h, hs = _filter(ev, p.rmin)
80
+ passive = ~ev.active
81
+ n = len(hs)
82
+ x = np.full(n, p.volfrac)
83
+ x[passive] = 0.0
84
+ xphys = x.copy()
85
+ t0 = time.time()
86
+ change, loop, c = 1.0, 0, 0.0
87
+ while change > 0.01 and loop < maxit:
88
+ loop += 1
89
+ u = ev.solve(E_VOID + xphys ** penal * (1 - E_VOID))
90
+ ue = u[ev.edof]
91
+ ce = np.einsum("ij,jk,ik->i", ue, ev.ke, ue)
92
+ c = float(np.sum((E_VOID + xphys ** penal * (1 - E_VOID)) * ce))
93
+ dc = -penal * (1 - E_VOID) * xphys ** (penal - 1) * ce
94
+ dv = np.ones(n)
95
+ if ft == 1:
96
+ dc = (h @ (x * dc)) / hs / np.maximum(1e-3, x)
97
+ else:
98
+ dc = h @ (dc / hs)
99
+ dv = h @ (dv / hs)
100
+ l1, l2 = 0.0, 1e9
101
+ while (l2 - l1) / (l1 + l2) > 1e-3:
102
+ lmid = 0.5 * (l2 + l1)
103
+ xnew = np.maximum(0, np.maximum(x - move, np.minimum(1, np.minimum(x + move, x * np.sqrt(-dc / dv / lmid)))))
104
+ xnew[passive] = 0.0
105
+ xphys = xnew if ft == 1 else (h @ xnew) / hs
106
+ xphys = np.where(passive, 0.0, xphys)
107
+ if xphys.sum() > p.volfrac * n:
108
+ l1 = lmid
109
+ else:
110
+ l2 = lmid
111
+ change = float(np.abs(xnew - x).max())
112
+ x = xnew
113
+ if on_iter is not None:
114
+ on_iter(loop, c, change)
115
+ grid = np.zeros(p.dims)
116
+ grid[ev.cell] = xphys
117
+ return dict(x=grid, iterations=loop, compliance_reported=c, seconds=time.time() - t0, solver=solver)
118
+
119
+
120
+ def reference_path(p: Problem, solver: str) -> str:
121
+ return os.path.join(HERE, "reference", f"{p.name}.{solver}.npz")
122
+
123
+
124
+ def run_reference(p: Problem, solver: str, on_iter=None) -> dict:
125
+ rec = optimize_reference(p, solver, on_iter)
126
+ os.makedirs(os.path.dirname(reference_path(p, solver)), exist_ok=True)
127
+ np.savez_compressed(reference_path(p, solver), x=rec["x"].astype(np.float32), iterations=rec["iterations"],
128
+ compliance_reported=rec["compliance_reported"], seconds=rec["seconds"])
129
+ return rec
130
+
131
+
132
+ def load_reference(p: Problem, solver: str) -> dict | None:
133
+ path = reference_path(p, solver)
134
+ if not os.path.exists(path):
135
+ return None
136
+ z = np.load(path)
137
+ return dict(x=z["x"].astype(float), iterations=int(z["iterations"]),
138
+ compliance_reported=float(z["compliance_reported"]), seconds=float(z["seconds"]), solver=solver)
@@ -0,0 +1,301 @@
1
+ """
2
+ Benchmark suite: loadpath against the published topology-optimization codes
3
+ on the textbook problems (problems.py), scored by one evaluator
4
+ (evaluate.py). Separate from the GRIP evals: those ask "is this part good
5
+ enough to print", this asks "how close to the optimum does the optimizer get".
6
+
7
+ For each problem:
8
+ - the reference designs (top88 / top3d ports, reference.py), stored, rerun
9
+ with --refresh;
10
+ - loadpath's optimizer as a study would run it (robust erosion, Heaviside
11
+ projection, the padded filter for 3D, pruning, pinned load cells), and
12
+ "plain" with erosion, padding and pruning off: the same formulation as the
13
+ references, which separates what the solver does from what loadpath's
14
+ printability choices cost;
15
+ - stress problems also run loadpath's minimum-mass stress-constrained
16
+ formulation (constrained.py) with the reference design's peak as the limit.
17
+
18
+ Scores (evaluate.py): compliance as delivered (penalized, p = 3) and
19
+ black-and-white at the target volume; the headline is the lower of the two
20
+ (lower is stiffer). loadpath's volume fraction is lowered by its pinned load
21
+ and support cells so every design uses the same total material. Stress
22
+ problems add the peak von Mises of the black-and-white design. The ratio
23
+ column is each design over the best reference.
24
+
25
+ Run: python -m loadpath.benchmarks.run [problem ...] --out bench-out/
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import json
31
+ import os
32
+ import sys
33
+ import time
34
+
35
+ import numpy as np
36
+
37
+ from ..constrained import Limits, optimize_constrained
38
+ from ..domain import build_domain
39
+ from ..simp import SimpOptions, optimize
40
+ from ..spec import study_from_dict
41
+ from .evaluate import Evaluator, binarize
42
+ from .problems import PROBLEMS, Problem
43
+ from .reference import SOLVERS, load_reference, run_reference
44
+
45
+ NU = 0.3
46
+ VARIANTS = {
47
+ "loadpath": {},
48
+ "loadpath-plain": {"erode": 0.0, "prune": False, "pad_filter": False},
49
+ }
50
+
51
+
52
+ def _box(nodes, plane):
53
+ lo = nodes.min(axis=0) - 0.25
54
+ hi = nodes.max(axis=0) + 0.25
55
+ if plane:
56
+ lo[1], hi[1] = -0.25, 1.25
57
+ return {"type": "box", "min": lo.tolist(), "max": hi.tolist()}
58
+
59
+
60
+ def _slab(nodes, plane):
61
+ """A plane problem's node set on both faces of the one-cell slab."""
62
+ if not plane:
63
+ return nodes
64
+ other = nodes.copy()
65
+ other[:, 1] = 1
66
+ return np.concatenate([nodes, other])
67
+
68
+
69
+ def domain_for(p: Problem):
70
+ """loadpath's Domain for a benchmark, built the way a study builds it (so
71
+ load and support cells are pinned and symmetry is mirrored as in real
72
+ use), with the supports and loads then set exactly to the problem's."""
73
+ nx, ny, nz = p.dims
74
+ d = {
75
+ "designSpace": [{"type": "box", "min": [0, 0, 0], "max": [nx, ny, nz]}],
76
+ "supports": [{"shape": _box(n, p.plane), "fix": "".join(sorted(c))} for n, c in p.fixed],
77
+ "loadCases": [{"name": "load", "loads": [
78
+ {"shape": _box(n, p.plane), "force": [v * len(n) if a == c else 0.0 for a in "xyz"]}
79
+ for n, c, v in p.loads]}],
80
+ "material": {"name": "unit", "E": 1.0, "nu": NU, "strength": 1.0, "density": 1.0},
81
+ "goal": {"volumeFraction": p.active_volfrac},
82
+ "resolution": {"pitch": 1.0},
83
+ }
84
+ if p.symmetry:
85
+ d["symmetry"] = list(p.symmetry)
86
+ if p.plane:
87
+ d["extrusion"] = "y"
88
+ if p.void is not None:
89
+ # the cut-out as keep-out boxes, one per void column run
90
+ ii, _, kk = np.nonzero(p.void)
91
+ d["keepOut"] = [{"id": "cut", "shape": {"type": "box", "min": [float(ii.min()), -1, float(kk.min())],
92
+ "max": [float(ii.max() + 1) + 1, ny + 1, float(kk.max() + 1) + 1]},
93
+ "bore": False}]
94
+ study = study_from_dict(d)
95
+ dom = build_domain(study, pitch=1.0)
96
+ if tuple(dom.dims) != tuple(p.dims) or np.abs(dom.origin).max() > 1e-9:
97
+ raise RuntimeError(f"{p.name}: loadpath grid {dom.dims} at {dom.origin} does not match {p.dims}")
98
+ mesh = dom.mesh
99
+ fixed = np.zeros(mesh.n_dof, bool)
100
+ f = np.zeros(mesh.n_dof)
101
+ for nodes, comps in p.fixed:
102
+ ids = mesh.node_id(_slab(nodes, p.plane))
103
+ for c in comps:
104
+ fixed[3 * ids + "xyz".index(c)] = True
105
+ for nodes, c, v in p.loads:
106
+ all_n = _slab(nodes, p.plane)
107
+ share = v * len(nodes) / len(all_n)
108
+ np.add.at(f, 3 * mesh.node_id(all_n) + "xyz".index(c), share)
109
+ if p.plane:
110
+ # the slab is half of a plate symmetric about y = 0: holds y there
111
+ fixed[3 * np.flatnonzero(mesh.node_ijk[:, 1] == 0) + 1] = True
112
+ dom.fixed, dom.loads, dom.springs = fixed, [f], None
113
+ return dom, study
114
+
115
+
116
+ def _grid(dom, values):
117
+ g = np.zeros(dom.dims)
118
+ g[tuple(dom.mesh.elem_ijk.T)] = values
119
+ return g
120
+
121
+
122
+ def _scored_grid(p: Problem, g):
123
+ """The part of a loadpath design the problem scores (the half of a
124
+ mirrored whole)."""
125
+ if p.full is not None:
126
+ return g[p.dims[0]:]
127
+ return g
128
+
129
+
130
+ def design_volfrac(q: Problem, dom) -> float:
131
+ """loadpath's volume fraction counts design cells only; pinned (load,
132
+ support) cells come on top. This is the fraction of the rest that gives
133
+ the same total material as the references."""
134
+ n, pres = dom.mesh.n_elem, int(dom.preserve.sum())
135
+ return max(0.01, (q.active_volfrac * n - pres) / (n - pres))
136
+
137
+
138
+ def run_loadpath(p: Problem, variant: str, log=print):
139
+ q = p.full or p
140
+ dom, study = domain_for(q)
141
+ opts = SimpOptions(volume_fraction=design_volfrac(q, dom), filter_radius=q.rmin,
142
+ extrusion=1 if q.plane else None, **VARIANTS[variant])
143
+ t0 = time.time()
144
+ res = optimize(dom, NU, opts)
145
+ log(f" {variant}: {res.iterations} iterations, {time.time() - t0:.0f}s, c {res.compliance:.4g}")
146
+ return dict(x=_scored_grid(p, _grid(dom, res.xphys)), iterations=res.iterations, seconds=time.time() - t0,
147
+ compliance_reported=res.compliance)
148
+
149
+
150
+ def run_constrained(p: Problem, limit: float, log=print):
151
+ q = p.full or p
152
+ dom, _ = domain_for(q)
153
+ # Started from (nearly) solid, a feasible design, as Le et al. 2010 do.
154
+ # Since the fixes in constrained.py (q = 1, a guarded MMA step, the
155
+ # blueprint delivered) it also converges from the reference's volume.
156
+ opts = SimpOptions(volume_fraction=0.95, filter_radius=q.rmin,
157
+ extrusion=1 if q.plane else None, max_iterations=200)
158
+ t0 = time.time()
159
+ res = optimize_constrained(dom, NU, opts, Limits(stress=limit, deflection=None, modulus=1.0))
160
+ log(f" stress-constrained: {res.iterations} iterations, {time.time() - t0:.0f}s, vol {res.volume_fraction:.3f}")
161
+ return dict(x=_scored_grid(p, _grid(dom, res.xphys)), iterations=res.iterations, seconds=time.time() - t0,
162
+ compliance_reported=res.compliance)
163
+
164
+
165
+ def bench_problem(p: Problem, refresh=False, log=print) -> dict:
166
+ ev = Evaluator(p)
167
+ target = p.active_volfrac
168
+ designs, rows = {}, []
169
+ for solver in p.references:
170
+ rec = None if refresh else load_reference(p, solver)
171
+ if rec is None:
172
+ log(f" running {solver} ...")
173
+ rec = run_reference(p, solver)
174
+ designs[solver] = rec
175
+ for v in VARIANTS:
176
+ designs[v] = run_loadpath(p, v, log)
177
+ for name, rec in designs.items():
178
+ s = ev.score(rec["x"], target)
179
+ s.update(design=name, iterations=rec["iterations"], seconds=round(rec["seconds"], 1),
180
+ compliance_reported=rec["compliance_reported"])
181
+ rows.append(s)
182
+ refs = [r for r in rows if r["design"] in p.references]
183
+ best = min(refs, key=lambda r: r["compliance"])
184
+ for r in rows:
185
+ r["ratio"] = r["compliance"] / best["compliance"]
186
+ out = {"problem": p.name, "title": p.title, "source": p.source, "dims": list(p.dims),
187
+ "volfrac": target, "rmin": p.rmin, "rows": rows, "best_reference": best["design"]}
188
+ if p.stress:
189
+ limit = best["peak_von_mises"]
190
+ try:
191
+ rec = run_constrained(p, limit, log)
192
+ except Exception as exc: # noqa: BLE001 - a failing formulation is a result
193
+ log(f" stress-constrained failed: {type(exc).__name__}: {exc}")
194
+ out["stress"] = {"limit": limit, "reference_volume": target, "error": f"{type(exc).__name__}: {exc}"}
195
+ out["_designs"] = designs
196
+ return out
197
+ designs["loadpath-stress"] = rec
198
+ x = ev.cells(rec["x"])
199
+ vol = float(x[ev.active].mean())
200
+ b = np.zeros(p.dims)
201
+ b[ev.cell] = binarize(x, ev.active, vol)
202
+ vm = ev.von_mises(b)
203
+ vm[ev.load_cells()] = 0.0
204
+ out["stress"] = {"limit": limit, "reference_volume": target, "volume": vol,
205
+ "peak_von_mises": float(vm.max()), "compliance": ev.compliance(b),
206
+ "iterations": rec["iterations"], "seconds": round(rec["seconds"], 1)}
207
+ out["_designs"] = designs
208
+ return out
209
+
210
+
211
+ def _render(res: dict, path: str):
212
+ import matplotlib
213
+ matplotlib.use("Agg")
214
+ import matplotlib.pyplot as plt
215
+
216
+ designs = res["_designs"]
217
+ names = list(designs)
218
+ plane = res["dims"][1] == 1
219
+ fig = plt.figure(figsize=(3.6 * len(names), 2.6 if plane else 3.2))
220
+ for k, name in enumerate(names):
221
+ g = designs[name]["x"]
222
+ row = next((r for r in res["rows"] if r["design"] == name), None)
223
+ label = name if row is None else f"{name}\nc = {row['compliance']:.4g}"
224
+ if name == "loadpath-stress":
225
+ label = f"{name}\nvol {res['stress']['volume']:.3f}, peak {res['stress']['peak_von_mises']:.3g}"
226
+ if plane:
227
+ ax = fig.add_subplot(1, len(names), k + 1)
228
+ ax.imshow(1 - g[:, 0, :].T, cmap="gray", origin="lower", vmin=0, vmax=1, interpolation="nearest")
229
+ ax.set_axis_off()
230
+ else:
231
+ from scipy import ndimage
232
+ from skimage.measure import marching_cubes
233
+ ax = fig.add_subplot(1, len(names), k + 1, projection="3d")
234
+ # light smoothing so the voxel steps do not read as the design
235
+ pad = ndimage.gaussian_filter(np.pad(g, 1), 0.6)
236
+ v, f, _, _ = marching_cubes(pad, 0.5)
237
+ ax.plot_trisurf(v[:, 0], v[:, 1], f, v[:, 2], color="#c9c4bc", edgecolor="none", shade=True)
238
+ ax.set_box_aspect(np.array(g.shape) + 2)
239
+ ax.view_init(24, -58)
240
+ ax.set_axis_off()
241
+ ax.set_title(label, fontsize=9)
242
+ fig.tight_layout()
243
+ fig.savefig(path, dpi=110)
244
+ plt.close(fig)
245
+
246
+
247
+ def markdown(results: list) -> str:
248
+ lines = ["| problem | design | compliance | vs best reference | as delivered (p=3) | black/white at target "
249
+ "volume | volume | greyness | iterations | seconds |",
250
+ "|---|---|---|---|---|---|---|---|---|---|"]
251
+ for res in results:
252
+ for r in res["rows"]:
253
+ lines.append(f"| {res['problem']} | {r['design']} | {r['compliance']:.4g} | {r['ratio']:.3f} | "
254
+ f"{r['compliance_grey']:.4g} | {r['compliance_matched']:.4g} | "
255
+ f"{r['volume']:.3f} | {r['greyness']:.3f} | {r['iterations']} | {r['seconds']:.0f} |")
256
+ stress = [r for r in results if "stress" in r]
257
+ if stress:
258
+ lines += ["", "| problem | reference peak von Mises (limit) | reference volume | "
259
+ "loadpath stress-constrained volume | its peak von Mises |", "|---|---|---|---|---|"]
260
+ for res in stress:
261
+ s = res["stress"]
262
+ if "error" in s:
263
+ lines.append(f"| {res['problem']} | {s['limit']:.4g} | {s['reference_volume']:.3f} | "
264
+ f"failed: {s['error']} | |")
265
+ continue
266
+ lines.append(f"| {res['problem']} | {s['limit']:.4g} | {s['reference_volume']:.3f} | {s['volume']:.3f} | "
267
+ f"{s['peak_von_mises']:.4g} |")
268
+ return "\n".join(lines) + "\n"
269
+
270
+
271
+ def main(argv=None):
272
+ ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
273
+ ap.add_argument("problems", nargs="*", help=f"subset of {sorted(PROBLEMS)}")
274
+ ap.add_argument("--out", default="bench-out")
275
+ ap.add_argument("--refresh", action="store_true", help="rerun the reference codes")
276
+ ap.add_argument("--no-render", action="store_true")
277
+ a = ap.parse_args(argv)
278
+ names = a.problems or list(PROBLEMS)
279
+ os.makedirs(a.out, exist_ok=True)
280
+ results = []
281
+ for name in names:
282
+ p = PROBLEMS[name]
283
+ print(f"{name}: {p.title}", flush=True)
284
+ res = bench_problem(p, refresh=a.refresh, log=lambda s: print(s, flush=True))
285
+ np.savez_compressed(os.path.join(a.out, f"{name}.designs.npz"),
286
+ **{k: v["x"].astype(np.float32) for k, v in res["_designs"].items()})
287
+ if not a.no_render:
288
+ _render(res, os.path.join(a.out, f"{name}.png"))
289
+ results.append(res)
290
+ clean = [{k: v for k, v in r.items() if not k.startswith("_")} for r in results]
291
+ with open(os.path.join(a.out, "benchmarks.json"), "w") as fh:
292
+ json.dump(clean, fh, indent=1)
293
+ md = markdown(clean)
294
+ with open(os.path.join(a.out, "benchmarks.md"), "w") as fh:
295
+ fh.write(md)
296
+ print(md)
297
+ return 0
298
+
299
+
300
+ if __name__ == "__main__":
301
+ sys.exit(main())