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/study.py ADDED
@@ -0,0 +1,1937 @@
1
+ """
2
+ Run a study end to end: voxelize, optimize, mesh, verify.
3
+
4
+ Two kinds of goal:
5
+
6
+ - `volumeFraction`: keep that share of the design space (plus any extra
7
+ `outcomes` fractions, for a trade-off gallery).
8
+ - `safetyFactor` and/or `maxDeflection`: as light as possible while every
9
+ load case keeps at least that safety factor against the material's
10
+ strength and deflects at most that much at its loads. Neither is something
11
+ compliance optimization controls directly, so this searches over volume
12
+ fractions, verifying the delivered mesh at each step, and returns the
13
+ lightest outcome that passes. Each step warm-starts from the nearest
14
+ fraction above it already run.
15
+ With `durability.constrain` the search also holds the fatigue and creep
16
+ safety factor to `durability.safetyFactor` (and a held load's crept
17
+ deflection to `maxDeflection`); see durability.py.
18
+
19
+ The search works on one number, the margin: the worst of (safety factor /
20
+ target) and (deflection limit / deflection). Both scale roughly linearly with
21
+ the amount of material in an optimized part, so the next volume fraction is
22
+ the current one divided by the margin.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import time
28
+ from dataclasses import dataclass, field
29
+
30
+ import numpy as np
31
+
32
+ from .domain import Domain, build_domain, cell_cap
33
+ from .manufacture import report as manufacturing_report
34
+ from .reconstruct import ReconstructOptions, reconstruct
35
+ from .simp import SimpOptions, filter_radius, optimize
36
+ from .spec import Study
37
+ from .verify import verify_part
38
+
39
+ __all__ = ["Outcome", "StudyResult", "run_study", "design_at"]
40
+
41
+ # Bounds for the safety-factor search. (A floor of 0.04 stopped GRIP's upper
42
+ # arm there with SF 5.4 against a goal of 2: large design spaces around
43
+ # small parts end at a few percent.)
44
+ _VF_MIN, _VF_MAX = 0.02, 0.7
45
+ _MAX_SEARCH_RUNS = 7
46
+ # Coarse-to-fine: below this many cells the fine solve is cheap enough alone.
47
+ _COARSE_MIN_CELLS = 12_000
48
+ # Projection sharpness a warm-started run resumes at.
49
+ _WARM_BETA = 4.0
50
+ # runs of the constrained method's limit search (study.run, method constrained)
51
+ _MAX_CONSTRAINED_RUNS = 6
52
+ # goal.buckling in the constrained method: hold the design's buckling load
53
+ # factor in the optimizer itself (bucklingopt.py), starting at the safety
54
+ # factor times _BUCKLING_START (recalibrated against the verified part after
55
+ # each run). LOADPATH_BUCKLING_OPT=0 leaves buckling to the check alone.
56
+ _BUCKLING_IN_OPTIMIZER = os.environ.get("LOADPATH_BUCKLING_OPT", "1") != "0"
57
+ _BUCKLING_START = 1.2
58
+ # The search (volume fractions, simp.optimize) can hold buckling too: an
59
+ # adaptive penalty on its step, turned on once a part's buckling factor is
60
+ # under _BUCKLING_SEARCH_ON times the safety factor. OPT-IN
61
+ # (LOADPATH_BUCKLING_SEARCH=1): on the full-tier printed hook, the one eval
62
+ # part whose search buckling binds (2.61 for 2.5 at 29.3 g), it gave 29.8 g
63
+ # (buckling 2.50, SF 3.22) in 1.8x the time; the lighter fractions failed on
64
+ # stress (voxel SF 0.1-0.6 at 24-29 g), not buckling. MMA with buckling as a
65
+ # hard constraint built weaker layouts still (SF 0.3-2.0 at 28-31 g).
66
+ _BUCKLING_SEARCH = os.environ.get("LOADPATH_BUCKLING_SEARCH", "0") == "1"
67
+ _BUCKLING_SEARCH_ON = 1.5
68
+ # method auto: run the constrained method after the search when the search's
69
+ # part idles (verify's stressUtilization, mean von Mises over the near peak,
70
+ # under this) and is not deflection-bound. Search picks' utilization, and
71
+ # tet-confirmed search / auto masses (2026-11-01): fastener bracket 0.10
72
+ # (41.0 / 26.4 g), wall bracket 0.11 (64.4 / 26.6 g), where constrained wins;
73
+ # MBB 0.19, bell crank 0.20 and GRIP yoke 0.21, where it found nothing
74
+ # lighter in 2-5x the search's time; hook 0.30 and arm link 0.33, where it
75
+ # was 10-40% heavier.
76
+ _AUTO_UTILIZATION = 0.15
77
+ # Cut cells (verify's default since 2026-11-05) read the surface stress
78
+ # without the staircase's spurious corner peaks, so the same part reads
79
+ # utilization 7-16% higher (wrist yoke 0.168 -> 0.184, motor mount 0.160 ->
80
+ # 0.186, hook 0.328 -> 0.350): at 0.15 the yoke skipped the constrained
81
+ # method and came out 18.6 g against 14.1; at 0.19, 13.9.
82
+ _AUTO_UTILIZATION_CUT = 0.19
83
+ # and gives it at most this many times the search's own time
84
+ _AUTO_BUDGET = 3.5
85
+ # The pick of a safety-factor search is checked again on a grid this many
86
+ # cells finer before it is delivered. The in-loop check resolves a fillet
87
+ # only as well as its grid: GRIP's turret read SF 2.26 there and 1.65 at
88
+ # 120k cells, its peak at a corner the coarser grid had averaged away.
89
+ _CONFIRM_CELLS = 150_000
90
+ _CONFIRM_RUNS = 3
91
+ # With goal.confirm "tet", at most this many picks are confirmed on the
92
+ # tetrahedral mesh, and the search runs at most this many more fractions
93
+ # after its first pick (the step down the calibration allows, and back).
94
+ _TET_CONFIRMS = 6
95
+ _TET_EXTRA_RUNS = 4
96
+ # A tet-confirmed pick within this of its target is close enough.
97
+ _TET_CLOSE = 1.08
98
+ # Optimizations the layout search below a confirmed pick may spend.
99
+ _LAYOUT_RUNS = 3
100
+
101
+
102
+ def _making(study: Study) -> dict:
103
+ """The optimizer options that say how the part is made."""
104
+ return dict(build_direction=study.print_direction, extrusion=study.extrusion, access=study.access,
105
+ pattern=study.pattern, max_member=study.max_member,
106
+ dilate=_DILATE if study.min_hole else 0.0, escape=study.drains)
107
+
108
+
109
+ # simp.SimpOptions.dilate when the study has a minimum hole: the erosion's twin
110
+ _DILATE = 0.15
111
+
112
+
113
+ def _coarse_start(study: Study, domain: Domain, vf: float, radius: float, should_stop) -> np.ndarray | None:
114
+ """Find the layout on a grid twice as coarse (an eighth of the cells, so
115
+ each iteration is roughly ten times cheaper), stopping while it is still
116
+ soft (beta 4), then interpolate it onto the fine grid as the starting
117
+ design. The fine run only has to sharpen and refine it."""
118
+ from scipy import ndimage
119
+
120
+ try:
121
+ coarse = build_domain(study, pitch=2.0 * domain.pitch)
122
+ except ValueError:
123
+ # The coarse grid can lose what the fine one resolves (a fastener's
124
+ # seat and face rings landing on the same nodes, a thin load pad):
125
+ # then start cold on the fine grid, which `check` validated.
126
+ return None
127
+ res = optimize(coarse, study.elastic(design=True),
128
+ SimpOptions(volume_fraction=vf, filter_radius=max(radius, 1.5 * coarse.pitch),
129
+ max_iterations=60, beta_max=_WARM_BETA, beta_every=12,
130
+ **{**_making(study), "escape": None}),
131
+ should_stop=should_stop)
132
+ grid = coarse.full_grid(res.x)
133
+ coords = (domain.mesh.elem_centers() - coarse.origin) / coarse.pitch - 0.5
134
+ x0 = ndimage.map_coordinates(grid, coords.T, order=1, mode="nearest")
135
+ return np.clip(x0, 0.0, 1.0)
136
+
137
+
138
+ @dataclass
139
+ class Outcome:
140
+ volume_fraction: float
141
+ mesh: object
142
+ report: dict
143
+ optimizer: dict
144
+ reconstruction: dict
145
+ passes: bool | None = None
146
+ x: object = None
147
+ # the optimized density field this mesh was drawn from (per domain cell),
148
+ # so a part can be re-meshed without optimizing again
149
+ xphys: object = None
150
+ # infill.grade_infill's result (modifier meshes for the graded 3MF)
151
+ grading: object = None
152
+ # buildsim.compensate_mesh's pre-distorted part (buildSim.compensate)
153
+ compensated: object = None
154
+
155
+ def summary(self) -> dict:
156
+ r = self.report
157
+ return {
158
+ "material": r.get("material", {}).get("name"),
159
+ "volumeFraction": self.volume_fraction,
160
+ "mass": r.get("mass"),
161
+ "massReduction": r.get("massReduction"),
162
+ "safetyFactor": r.get("safetyFactor"),
163
+ **({"voxelSafetyFactor": r["voxelSafetyFactor"]} if "voxelSafetyFactor" in r else {}),
164
+ **({"bucklingFactor": r["bucklingFactor"]} if r.get("bucklingFactor") is not None else {}),
165
+ **({"confirm": r["confirm"]} if "confirm" in r else {}),
166
+ **({"calibration": r["calibration"]} if "calibration" in r else {}),
167
+ **({"tetCheck": r["tetCheck"]} if "tetCheck" in r else {}),
168
+ "cases": r.get("cases"),
169
+ "minMember": r.get("minMember"),
170
+ "overhangArea": r.get("overhangArea"),
171
+ "keepOutIntrusion": r.get("keepOutIntrusion"),
172
+ "preserveMissing": r.get("preserveMissing"),
173
+ "bodies": r.get("bodies"),
174
+ "boundaryCutArea": r.get("boundaryCutArea"),
175
+ **({"thermal": r["thermal"]} if "thermal" in r else {}),
176
+ **({"durability": r["durability"]} if "durability" in r else {}),
177
+ **({"print": {k: v for k, v in r["print"].items() if k != "bridgeList"}} if "print" in r else {}),
178
+ **({"process": r["process"]} if "process" in r else {}),
179
+ **({"slicer": r["slicer"]} if "slicer" in r else {}),
180
+ **({"buildSim": r["buildSim"]} if "buildSim" in r else {}),
181
+ **({"graded": r["graded"]} if "graded" in r else {}),
182
+ "warnings": r.get("warnings", []),
183
+ "margin": r.get("margin"),
184
+ "passes": self.passes,
185
+ "start": r.get("start"),
186
+ "alternative": r.get("alternative"),
187
+ "optimizer": self.optimizer,
188
+ "reconstruction": self.reconstruction,
189
+ }
190
+
191
+
192
+ @dataclass
193
+ class StudyResult:
194
+ study: Study
195
+ domain: dict
196
+ outcomes: list[Outcome]
197
+ best: int | None
198
+ seconds: float
199
+ warnings: list = field(default_factory=list)
200
+ # method auto: which method made `best` and why
201
+ choice: dict | None = None
202
+ # resolution.pitchStarts: each grid's pitch, pick and whether it passed
203
+ starts: list | None = None
204
+
205
+ def to_dict(self) -> dict:
206
+ return {
207
+ **({"pitchStarts": self.starts} if self.starts else {}),
208
+ "name": self.study.name,
209
+ "domain": self.domain,
210
+ "outcomes": [o.summary() for o in self.outcomes],
211
+ "best": self.best,
212
+ "seconds": self.seconds,
213
+ "warnings": self.warnings,
214
+ **({"methodChoice": self.choice} if self.choice else {}),
215
+ **({"orientation": self.study.raw["orientation"]} if "orientation" in self.study.raw else {}),
216
+ }
217
+
218
+
219
+ def _durability_drives(study: Study) -> bool:
220
+ return study.durability is not None and study.durability.constrain
221
+
222
+
223
+ def _margin(study: Study, report: dict) -> float | None:
224
+ g = study.goal
225
+ ms = []
226
+ if g.safety_factor is not None:
227
+ ms.append(report["safetyFactor"] / g.safety_factor)
228
+ if g.buckling and report.get("bucklingFactor") is not None:
229
+ # a part that buckles under safetyFactor x its loads fails as one
230
+ # that yields there does (verify._buckling)
231
+ ms.append(report["bucklingFactor"] / g.safety_factor)
232
+ dur = report.get("durability") if _durability_drives(study) else None
233
+ if g.max_deflection is not None:
234
+ worst = max((c["deflection"] for c in report["cases"]), default=float("inf"))
235
+ if dur:
236
+ # a held load's deflection after creep, at its temperature
237
+ worst = max([worst] + [c["creepDeflection"] for c in dur["cases"] if "creepDeflection" in c])
238
+ ms.append(g.max_deflection / worst if worst > 0 else float("inf"))
239
+ if dur and dur.get("safetyFactor") is not None:
240
+ ms.append(dur["safetyFactor"] / dur["target"])
241
+ warp = report.get("warp")
242
+ if warp:
243
+ # buildSim warp limits: the build of the delivered part when it was
244
+ # re-simulated (_warp_delivered), else the optimizer's on its field
245
+ got = warp.get("delivered") or warp
246
+ lims = warp.get("userLimits") or warp["limits"]
247
+ for key, lim in (("maxDistortion", "maxDistortion"), ("recoaterLift", "maxRecoaterLift")):
248
+ if lims.get(lim) and got.get(key) is not None:
249
+ ms.append(lims[lim] / max(got[key], 1e-12))
250
+ return min(ms) if ms else None
251
+
252
+
253
+ def _whole(report: dict) -> bool:
254
+ """A part that can be delivered at all, whatever its margin: one body,
255
+ and every bore open at both ends. A capped bore takes no screw (the
256
+ wall bracket with fasteners at 5.6%, tets 3.52 for 2.5, came out with
257
+ one closed over; the search delivered it), so the search counts it
258
+ failing and steps on to a fraction that keeps the bore."""
259
+ return report.get("bodies", 1) <= 1 and not report.get("closedBores")
260
+
261
+
262
+ def _warp_delivered(study: Study, mesh) -> dict | None:
263
+ """The as-built measures of the delivered part (buildsim.simulate_part
264
+ at buildSim.cells, on the plate or bed at the end of the build, as the
265
+ optimizer's limits are): the largest movement (along the build for
266
+ FDM, |u| otherwise) and the recoater lift, mm. The optimizer's build
267
+ model runs on its coarse density grid, which under-resolves thin
268
+ members (FDM warp on the PA12 beam read 2-3.5x low there), so FDM warp
269
+ limits are checked on this one."""
270
+ import dataclasses as _dc
271
+ from .buildsim import simulate_part
272
+ bs = study.build_sim
273
+ build = study.process.build if study.process is not None and study.process.build not in (None, "auto") else "+z"
274
+ if study.print_direction is not None:
275
+ build = study.print_direction
276
+ setup = _dc.replace(bs["setup"], release="none")
277
+ t0 = time.time()
278
+ try:
279
+ r = simulate_part(mesh, setup, build=build, cells=min(int(bs.get("cells") or 8000), 30_000))
280
+ except Exception as exc: # noqa: BLE001 - leave the optimizer's numbers standing
281
+ return {"error": f"{type(exc).__name__}: {exc}"}
282
+ m = r.mesh
283
+ on = np.zeros(m.n_nodes, bool)
284
+ on[m.enode[r.part].ravel()] = True
285
+ u = r.u_built[on]
286
+ d = np.abs(u[:, 2]) if setup.process.lower() == "fdm" else np.linalg.norm(u, axis=1)
287
+ return {"maxDistortion": float(d.max()),
288
+ "recoaterLift": (r.report.get("recoater") or {}).get("maxLift"),
289
+ "pitch": r.report.get("pitch"), "seconds": round(time.time() - t0, 1)}
290
+
291
+
292
+ def design_at(study: Study, domain: Domain, radius: float, vf: float, x0=None, beta0: float = 1.0,
293
+ max_iterations: int | None = None, on_iter=None, should_stop=None, emit=None,
294
+ verify: bool = True, verify_cells: int = 60_000, refine: int = 3) -> Outcome:
295
+ """One optimize -> mesh -> verify pass at volume fraction `vf`, from
296
+ design `x0` (None: uniform) with projection starting at `beta0`. The
297
+ outcome carries its margin and pass/fail against the study's goal
298
+ (verify=False skips the check: an empty report, for a design that is
299
+ only a starting point; verify_cells and refine set how closely it is
300
+ checked, see verify_part)."""
301
+ def note(event):
302
+ if emit is not None:
303
+ emit(event)
304
+
305
+ res = optimize(domain, study.elastic(design=True),
306
+ SimpOptions(volume_fraction=vf, filter_radius=radius,
307
+ max_iterations=max_iterations or study.max_iterations, x0=x0, beta_start=beta0,
308
+ **_making(study)),
309
+ on_iter=on_iter, should_stop=should_stop)
310
+ note({"stage": "mesh", "volumeFraction": vf})
311
+ mesh, rinfo = reconstruct(study, domain, res.xphys, ReconstructOptions())
312
+ opt = {"iterations": res.iterations, "converged": res.converged, "seconds": res.seconds,
313
+ "compliance": res.compliance, "caseCompliance": res.case_compliance,
314
+ **({"caseDirection": res.case_direction} if any(d is not None for d in res.case_direction) else {}),
315
+ "achievedVolumeFraction": res.volume_fraction,
316
+ "beta": res.history[-1]["beta"] if res.history else beta0,
317
+ **({"escape": res.escape} if res.escape else {})}
318
+ o = Outcome(vf, mesh, {}, opt, rinfo)
319
+ o.xphys = res.xphys
320
+ o.x = res.x
321
+ if not verify:
322
+ return o
323
+ note({"stage": "verify", "volumeFraction": vf})
324
+ try:
325
+ report = verify_part(study, mesh, max_cells=verify_cells, min_pitch=0.5 * domain.pitch,
326
+ contact_band=domain.pitch, refine=refine)
327
+ except (RuntimeError, ValueError) as exc:
328
+ # one outcome verification cannot solve (a piece the supports do
329
+ # not hold) is a failed outcome, not a failed study: the search
330
+ # goes on past it (GRIP's wrist link died here)
331
+ mat = study.material
332
+ report = {"mass": float(mesh.volume) * mat.density / 1000.0, "safetyFactor": 0.0,
333
+ "cases": [], "warnings": [f"verification failed: {exc}"]}
334
+ making = manufacturing_report(study, domain, mesh)
335
+ if making:
336
+ report["manufacturing"] = making
337
+ if res.warp:
338
+ report["warp"] = res.warp
339
+ o.report = report
340
+ o.voxel_sf = report.get("safetyFactor")
341
+ m = _margin(study, report)
342
+ if m is not None:
343
+ report["margin"] = m
344
+ o.passes = bool(m >= 1.0)
345
+ if report.get("bodies", 1) > 1:
346
+ # A preserved piece no load path reaches (a mount with no load
347
+ # through it) comes out as a loose part. It is not a design that
348
+ # meets the brief, and the fix is in the study: say so.
349
+ o.passes = False
350
+ report.setdefault("warnings", []).append(
351
+ f"the part came out in {report['bodies']} pieces: either each piece has its own supports and "
352
+ "load path and nothing ties them together (two mirror halves, each on its own bolts), or some "
353
+ "preserved geometry is on no load path. For one part, preserve a link between the pieces (a "
354
+ "ring, a web, a plate) or add a load case that runs between them; for a floating preserve, "
355
+ "load it or drop it.")
356
+ if report.get("closedBores"):
357
+ o.passes = False
358
+ report.setdefault("warnings", []).append(
359
+ f"the part closes over {len(report['closedBores'])} bore end(s) "
360
+ f"({', '.join(b['id'] + ' ' + b['end'] for b in report['closedBores'])}): no fastener goes through, "
361
+ "so it does not pass. Extend the keep-out over the head and the driver's reach if this repeats.")
362
+ return o
363
+
364
+
365
+ def _confirm_on_tets(study: Study, outcomes: list) -> bool:
366
+ """goal.confirm says tet (or auto, and the cross-check covers it)."""
367
+ mode = study.goal.confirm
368
+ if mode == "voxel":
369
+ return False
370
+ from .crosscheck import unsupported
371
+
372
+ why = unsupported(study)
373
+ if why is None:
374
+ return True
375
+ if outcomes and (mode == "tet" or why.startswith("no tetrahedral mesher")):
376
+ # A missing mesher is the install, not the study: say so on every
377
+ # part it changes. The voxel grid reads peak stress high (the MBB
378
+ # beam 0.86 on voxels, 1.94 on tets at 30%), so without the tet
379
+ # confirm a search stops heavier: the MBB beam's eval went from
380
+ # 14.5 g to 40.4 g and failed its mass reduction, silently.
381
+ outcomes[0].report.setdefault("warnings", []).append(
382
+ f"the tetrahedral cross-check does not cover this study ({why}): confirmed on voxels instead, "
383
+ "which read peak stress high, so the part comes out heavier than it needs to be")
384
+ return False
385
+
386
+
387
+ def _domain_info(d: Domain, radius: float) -> dict:
388
+ return {
389
+ "pitch": d.pitch,
390
+ "cells": d.n_elem,
391
+ "dims": list(d.dims),
392
+ "preservedCells": int(d.preserve.sum()),
393
+ "filterRadius": radius,
394
+ "loadCases": d.case_names,
395
+ }
396
+
397
+
398
+ # Peak memory of one worker, roughly, by the most cells it solves at once
399
+ # (study._study_cells): GRIP's turret, a safety-factor search confirmed at
400
+ # 150k cells, peaked at 8.4 GB.
401
+ _RUN_BASE_BYTES = 0.6e9
402
+ _RUN_CELL_BYTES = 55e3
403
+ def _run_bytes(cells: int) -> float:
404
+ return _RUN_BASE_BYTES + _RUN_CELL_BYTES * int(cells)
405
+
406
+
407
+ def _cgroup_limit() -> float | None:
408
+ """The memory limit of the container this runs in, if any (cgroup v2,
409
+ then v1): MemAvailable reports the host's, and the cloud box's 14.3 GB
410
+ limit is what the kernel enforces."""
411
+ paths = []
412
+ try:
413
+ with open("/proc/self/cgroup") as f:
414
+ for line in f:
415
+ _, ctrl, rel = line.strip().split(":", 2)
416
+ if ctrl == "":
417
+ paths.append(f"/sys/fs/cgroup{rel}/memory.max")
418
+ elif "memory" in ctrl.split(","):
419
+ paths.append(f"/sys/fs/cgroup/memory{rel}/memory.limit_in_bytes")
420
+ except (OSError, ValueError):
421
+ pass
422
+ for path in paths + ["/sys/fs/cgroup/memory.max", "/sys/fs/cgroup/memory/memory.limit_in_bytes"]:
423
+ try:
424
+ with open(path) as f:
425
+ v = f.read().strip()
426
+ if v.isdigit() and int(v) < 1 << 60:
427
+ return float(v)
428
+ except OSError:
429
+ pass
430
+ return None
431
+
432
+
433
+ def _cgroup_free() -> float | None:
434
+ """The container's limit less what it holds now (reclaimable page cache
435
+ not counted): the limit alone let eight brief-eval agents' runs each
436
+ count the whole 14 GB as theirs, and the kernel killed them."""
437
+ lim = _cgroup_limit()
438
+ if not lim:
439
+ return None
440
+ paths = []
441
+ try:
442
+ with open("/proc/self/cgroup") as f:
443
+ for line in f:
444
+ _, ctrl, rel = line.strip().split(":", 2)
445
+ if ctrl == "":
446
+ paths.append((f"/sys/fs/cgroup{rel}/memory.current", f"/sys/fs/cgroup{rel}/memory.stat",
447
+ "inactive_file"))
448
+ elif "memory" in ctrl.split(","):
449
+ paths.append((f"/sys/fs/cgroup/memory{rel}/memory.usage_in_bytes",
450
+ f"/sys/fs/cgroup/memory{rel}/memory.stat", "total_inactive_file"))
451
+ except (OSError, ValueError):
452
+ return lim
453
+ for used_p, stat_p, key in paths:
454
+ try:
455
+ with open(used_p) as f:
456
+ used = float(f.read().strip())
457
+ except (OSError, ValueError):
458
+ continue
459
+ cache = 0.0
460
+ try:
461
+ with open(stat_p) as f:
462
+ for line in f:
463
+ k, _, v = line.partition(" ")
464
+ if k == key:
465
+ cache = float(v)
466
+ except (OSError, ValueError):
467
+ pass
468
+ return max(0.0, lim - (used - cache))
469
+ return lim
470
+
471
+
472
+ def _available_bytes() -> float | None:
473
+ avail = _meminfo_available()
474
+ cap = _cgroup_free()
475
+ if avail is None:
476
+ return cap
477
+ return min(avail, cap) if cap is not None else avail
478
+
479
+
480
+ def _meminfo_available() -> float | None:
481
+ try:
482
+ with open("/proc/meminfo") as f:
483
+ for line in f:
484
+ if line.startswith("MemAvailable:"):
485
+ return float(line.split()[1]) * 1024.0
486
+ except OSError:
487
+ pass
488
+ try:
489
+ import psutil # optional
490
+ return float(psutil.virtual_memory().available)
491
+ except Exception: # noqa: BLE001
492
+ pass
493
+ try:
494
+ # macOS has no /proc: count most of physical memory as available
495
+ return 0.6 * os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
496
+ except (ValueError, OSError, AttributeError):
497
+ return None
498
+
499
+
500
+ def _workers(n: int, cells: int | None = None) -> int:
501
+ """Processes for independent outcomes: LOADPATH_WORKERS, else one per
502
+ core, and no more than fit in the memory available when each solves
503
+ `cells` cells (the 4-core, 15 GB cloud box ran out at two GRIP cases at
504
+ once). (This was half the cores, leaving room for the thread per load
505
+ case each solve runs; but Gauss-Seidel holds the GIL, so those threads
506
+ mostly take turns, and with one BLAS thread four arm-link directions
507
+ took 121 s on four workers against 194 s on two.)"""
508
+ env = os.environ.get("LOADPATH_WORKERS")
509
+ if env:
510
+ return max(1, min(n, int(env)))
511
+ w = max(1, min(n, os.cpu_count() or 1))
512
+ avail = _available_bytes() if cells else None
513
+ if avail:
514
+ w = min(w, max(1, int(0.8 * avail // _run_bytes(cells))))
515
+ return w
516
+
517
+
518
+ def _save_partial(o) -> None:
519
+ """With LOADPATH_PARTIAL_DIR set (`loadpath run` sets it to out/partial),
520
+ write each outcome's STL and summary as the search finds it: four
521
+ brief-eval runs were killed in their last stage and kept nothing of
522
+ half an hour of verified outcomes."""
523
+ d = os.environ.get("LOADPATH_PARTIAL_DIR")
524
+ if not d or o.mesh is None:
525
+ return
526
+ try:
527
+ import json
528
+
529
+ os.makedirs(d, exist_ok=True)
530
+ stem = f"vf{o.volume_fraction:.4f}".replace(".", "p")
531
+ o.mesh.export(os.path.join(d, stem + ".stl"))
532
+ with open(os.path.join(d, "outcomes.jsonl"), "a") as fh:
533
+ fh.write(json.dumps({"stl": stem + ".stl", **o.summary()}, default=str) + "\n")
534
+ except Exception: # noqa: BLE001 (a convenience: never fail the run for it)
535
+ pass
536
+
537
+
538
+ def _confirm_cells(st: Study | None = None) -> int:
539
+ """Cells for the confirming re-analysis: LOADPATH_CONFIRM_CELLS, else
540
+ 150k, but no more than fit in the memory free right now. A brief-eval
541
+ agent's search on a shared 15 GB box was OOM-killed at this stage after
542
+ 35 minutes, losing every verified outcome; a coarser confirm (never
543
+ under the design grid) is better than none."""
544
+ env = os.environ.get("LOADPATH_CONFIRM_CELLS")
545
+ if env:
546
+ return max(1000, int(env))
547
+ floor = max(st.max_elements if st is not None else 0, 20_000)
548
+ avail = _available_bytes()
549
+ if not avail:
550
+ return _CONFIRM_CELLS
551
+ fit = int((0.6 * avail - _RUN_BASE_BYTES) // _RUN_CELL_BYTES)
552
+ return int(max(floor, min(_CONFIRM_CELLS, fit)))
553
+
554
+
555
+ def _solve_alone(study: Study) -> StudyResult:
556
+ return _run_single(study)
557
+
558
+
559
+ def _study_cells(st: Study) -> int:
560
+ """The most cells one run of `st` solves at once."""
561
+ g = st.goal
562
+ search = g.safety_factor is not None or g.max_deflection is not None
563
+ return max(cell_cap(st), _CONFIRM_CELLS if search else 60_000)
564
+
565
+
566
+ def _parallel(studies: list, on_progress, should_stop) -> list:
567
+ """Run independent single-goal studies, in processes when it helps.
568
+ Progress inside a worker is not streamed; each finished outcome is."""
569
+ workers = _workers(len(studies), cells=max(_study_cells(s) for s in studies)) if studies else 1
570
+ if workers <= 1:
571
+ return [_run_single(s, on_progress, should_stop) for s in studies]
572
+ import multiprocessing as mp
573
+ from concurrent.futures import ProcessPoolExecutor
574
+
575
+ results = [None] * len(studies)
576
+ with ProcessPoolExecutor(workers, mp_context=mp.get_context("spawn")) as ex:
577
+ futs = {ex.submit(_solve_alone, s): i for i, s in enumerate(studies)}
578
+ from concurrent.futures import as_completed
579
+ for f in as_completed(futs):
580
+ i = futs[f]
581
+ results[i] = f.result()
582
+ if on_progress is not None:
583
+ for o in results[i].outcomes:
584
+ on_progress({"stage": "outcome", **o.summary()}, None)
585
+ return results
586
+
587
+
588
+ # resolution.pitchStarts: the extra grids are this much coarser each (coarser
589
+ # so maxElements still holds), and "auto" runs 3 up to this many cells
590
+ PITCH_STEP = 0.01
591
+ PITCH_STARTS_MAX_CELLS = 30_000
592
+
593
+
594
+ def pitch_starts(study: Study) -> int:
595
+ """How many grids the study's search runs on (resolution.pitchStarts).
596
+
597
+ One grid's answer is a sample, not the answer: the search's mass swings
598
+ with sub-percent changes of the pitch, because the optimizer settles in
599
+ a different layout and the verified stress, read near supports and
600
+ sharp junctions, differs between layouts. Over six pitches 0-5% apart,
601
+ the 16 coarse-tier safety-factor cases ranged 24% from lightest to
602
+ heaviest (median; 138% at worst, corpus-bracket-4 82-196 g) and 3 of 96
603
+ runs failed. Paired runs of the coarse tier 3% apart in pitch, one grid
604
+ against three (the lightest passing of grids 1% apart): massAtTarget
605
+ spread mean 17% -> 10%, worst 80% -> 46%, delivered mass 8% lower, no
606
+ lost passes, 2.7x the time; the median spread did not move
607
+ (bench/results/pitch_starts.json, docs/BENCHMARKS.md)."""
608
+ v = (study.raw.get("resolution") or {}).get("pitchStarts", "auto")
609
+ if v == "auto" and os.environ.get("LOADPATH_PITCH_STARTS"):
610
+ v = int(os.environ["LOADPATH_PITCH_STARTS"]) # the default, for a whole run (evals)
611
+ if v != "auto":
612
+ return int(v)
613
+ g = study.goal
614
+ if g.safety_factor is None and g.max_deflection is None and not _durability_drives(study):
615
+ return 1
616
+ if study.pitch:
617
+ return 1
618
+ return 3 if cell_cap(study) <= PITCH_STARTS_MAX_CELLS else 1
619
+
620
+
621
+ def _run_pitches(study: Study, starts: int, on_progress, should_stop) -> StudyResult:
622
+ """The search on `starts` grids, PITCH_STEP coarser each; the result of
623
+ the grid whose lightest part passes (or of the first grid when none
624
+ does), with every grid's pick in result.starts."""
625
+ import dataclasses
626
+
627
+ p0 = float(study.pitch) if study.pitch else build_domain(study).pitch
628
+ studies = [dataclasses.replace(study, pitch=p0 * (1.0 + PITCH_STEP * k)) for k in range(starts)]
629
+ t0 = time.time()
630
+ results = _parallel(studies, on_progress, should_stop)
631
+ rows = []
632
+ for st, r in zip(studies, results):
633
+ o = r.outcomes[r.best] if r.best is not None else None
634
+ rows.append({"pitch": st.pitch, "mass": o.report["mass"] if o is not None else None,
635
+ "passes": bool(o is not None and o.passes), "outcomes": len(r.outcomes)})
636
+ passing = [i for i, x in enumerate(rows) if x["passes"]]
637
+ k = min(passing, key=lambda i: rows[i]["mass"]) if passing else 0
638
+ r = results[k]
639
+ rows[k]["picked"] = True
640
+ r.starts = rows
641
+ r.study = study
642
+ r.seconds = time.time() - t0
643
+ return r
644
+
645
+
646
+ def run_study(study: Study, on_progress=None, should_stop=None) -> StudyResult:
647
+ """on_progress(event: dict, xphys | None) is called with
648
+ {"stage": "domain"|"optimize"|"mesh"|"verify"|"outcome", ...}.
649
+
650
+ With several materials, the goal is solved for each (a stiffer material
651
+ meets a deflection limit with less of it, a denser one weighs more for
652
+ the same shape) and the outcomes are pooled; `best` is the lightest that
653
+ passes, or the lightest overall when the goal has no pass/fail."""
654
+ import dataclasses
655
+
656
+ if study.process is not None and study.process.build == "auto":
657
+ # opt-in: compare build directions on a coarse grid and keep the best
658
+ from .orient import choose_build_direction
659
+
660
+ study = choose_build_direction(study, on_progress=on_progress)["study"]
661
+ mats = study.materials or [study.material]
662
+ if len(mats) == 1:
663
+ if study.goal.method == "auto":
664
+ # the search (with its restarts and pitch starts), then perhaps
665
+ # the constrained method once
666
+ r = _run_auto(study, on_progress, should_stop,
667
+ search=lambda st: _run_restarts(st, on_progress, should_stop))
668
+ else:
669
+ r = _run_restarts(study, on_progress, should_stop)
670
+ return _build_sim(_slice(_process_checks(r, on_progress), on_progress), on_progress)
671
+ t0 = time.time()
672
+ outcomes, warnings, domain = [], [], None
673
+ for r in _parallel([dataclasses.replace(study, material=m, materials=[m]) for m in mats],
674
+ on_progress, should_stop):
675
+ outcomes.extend(r.outcomes)
676
+ warnings.extend(w for w in r.warnings if w not in warnings)
677
+ domain = domain or r.domain
678
+ passing = [i for i, o in enumerate(outcomes) if o.passes]
679
+ pool = passing if passing else ([] if any(o.passes is False for o in outcomes) else range(len(outcomes)))
680
+ best = min(pool, key=lambda i: outcomes[i].report["mass"]) if pool else None
681
+ return _build_sim(_slice(_process_checks(StudyResult(study=study, domain=domain, outcomes=outcomes, best=best,
682
+ seconds=time.time() - t0, warnings=warnings), on_progress),
683
+ on_progress), on_progress)
684
+
685
+
686
+ # goal.restarts: the k-th search starts at the goal's volumeFraction times
687
+ # RESTART_STARTS[k] (the first is the study as written; then below, above,
688
+ # further below, further above)
689
+ RESTART_STARTS = (1.0, 0.8, 1.25, 0.65, 1.5, 0.55, 1.75, 0.45)
690
+
691
+
692
+ def _run_restarts(study: Study, on_progress=None, should_stop=None) -> StudyResult:
693
+ """goal.restarts searches from different starting fractions, pooled: the
694
+ outcomes of all, `best` the lightest of the searches' own (confirmed)
695
+ picks. Each search's other outcomes keep their in-loop readings only, so
696
+ they are not candidates."""
697
+ import dataclasses
698
+
699
+ def search(st):
700
+ starts = pitch_starts(st)
701
+ return (_run_single(st, on_progress, should_stop) if starts <= 1 else
702
+ _run_pitches(st, starts, on_progress, should_stop))
703
+
704
+ g = study.goal
705
+ n = min(g.restarts, len(RESTART_STARTS))
706
+ if n <= 1 or (g.safety_factor is None and g.max_deflection is None):
707
+ return search(study)
708
+ t0 = time.time()
709
+ vf0 = float(g.volume_fraction or 0.3)
710
+ outcomes, picks, warnings, domain = [], [], [], None
711
+ for k in range(n):
712
+ if k and should_stop is not None and should_stop():
713
+ break
714
+ vf = float(np.clip(vf0 * RESTART_STARTS[k], _VF_MIN, _VF_MAX))
715
+ if on_progress is not None:
716
+ on_progress({"stage": "restart", "run": k + 1, "of": n, "volumeFraction": vf}, None)
717
+ st = dataclasses.replace(study, goal=dataclasses.replace(g, volume_fraction=vf))
718
+ r = search(st)
719
+ for o in r.outcomes:
720
+ o.report["restart"] = k + 1
721
+ if r.best is not None:
722
+ picks.append(len(outcomes) + r.best)
723
+ outcomes.extend(r.outcomes)
724
+ warnings.extend(w for w in r.warnings if w not in warnings)
725
+ domain = domain or r.domain
726
+ ok = [i for i in picks if outcomes[i].passes]
727
+ pool = ok or picks
728
+ best = min(pool, key=lambda i: outcomes[i].report["mass"]) if pool else None
729
+ if domain is not None:
730
+ domain = dict(domain, restarts=[{"run": outcomes[i].report.get("restart"),
731
+ "volumeFraction": outcomes[i].volume_fraction,
732
+ "mass": outcomes[i].report.get("mass"), "passes": outcomes[i].passes}
733
+ for i in picks])
734
+ return StudyResult(study=study, domain=domain, outcomes=outcomes, best=best,
735
+ seconds=time.time() - t0, warnings=warnings)
736
+
737
+
738
+ def _build_sim(result: StudyResult, on_progress=None) -> StudyResult:
739
+ """buildSim: simulate printing the delivered outcome (buildsim.py) into
740
+ its report as "buildSim" (distortion, residual stress, recoater lift,
741
+ bed peel), with its warnings; with compensate, also a pre-distorted
742
+ mesh on the outcome (`compensated`, written as <name>-compensated.stl)."""
743
+ bs = result.study.build_sim
744
+ if bs is None:
745
+ return result
746
+ if result.best is None:
747
+ if bs.get("printSetup"):
748
+ return _print_setup_rescue(result, on_progress)
749
+ return result
750
+ o = result.outcomes[result.best]
751
+ if getattr(o, "mesh", None) is None:
752
+ return result
753
+ import dataclasses as _dc
754
+ from .buildsim import compensate_mesh, simulate_part
755
+
756
+ st = result.study
757
+ mat = (o.report.get("material") or {}).get("name") or bs["setup"].material
758
+ setup = _dc.replace(bs["setup"], material=mat)
759
+ build = st.process.build if st.process is not None and st.process.build not in (None, "auto") else "+z"
760
+ if st.print_direction is not None:
761
+ build = st.print_direction
762
+ if on_progress is not None:
763
+ on_progress({"stage": "buildSim", "process": setup.process}, None)
764
+ try:
765
+ if bs["compensate"]:
766
+ comp, crep = compensate_mesh(o.mesh, setup, build=build, iterations=bs["iterations"], cells=bs["cells"])
767
+ rep = simulate_part(o.mesh, setup, pitch=crep["pitch"], build=build).report
768
+ rep["compensation"] = crep
769
+ o.compensated = comp
770
+ else:
771
+ rep = simulate_part(o.mesh, setup, build=build, cells=bs["cells"]).report
772
+ except Exception as exc: # noqa: BLE001 - a failed simulation leaves the part's report standing
773
+ rep = {"process": setup.process, "error": f"{type(exc).__name__}: {exc}", "warnings": []}
774
+ o.report["buildSim"] = rep
775
+ o.report.setdefault("warnings", []).extend(rep.get("warnings", []))
776
+ if bs.get("printSetup"):
777
+ # FDM: rank orientation, material, bed temperature and brim for this part
778
+ from .printsetup import search_print_setup
779
+ if on_progress is not None:
780
+ on_progress({"stage": "printSetup"}, None)
781
+ try:
782
+ ps = search_print_setup(st, o.mesh, cells=min(bs["cells"], 4000), **bs["printSetup"])
783
+ o.report["printSetup"] = ps
784
+ except Exception as exc: # noqa: BLE001 - the search is advice; the part's report stands
785
+ o.report["printSetup"] = {"error": f"{type(exc).__name__}: {exc}"}
786
+ return result
787
+
788
+
789
+ def _build_label(study):
790
+ from .printsetup import _build_name
791
+ return _build_name(study)
792
+
793
+
794
+ def _print_setup_rescue(result: StudyResult, on_progress=None) -> StudyResult:
795
+ """No outcome passed (an FDM warp limit the shape cannot meet): run the
796
+ print setup search on the lightest outcome that meets the rest of the
797
+ goal, so the report says which orientation, material, bed temperature
798
+ and brim would make it pass."""
799
+ from .printsetup import search_print_setup
800
+ st, bs = result.study, result.study.build_sim
801
+ sf = st.goal.safety_factor
802
+
803
+ def ok(o):
804
+ return sf is None or (o.report.get("safetyFactor") or 0.0) >= sf
805
+
806
+ pool = [o for o in result.outcomes if getattr(o, "mesh", None) is not None and ok(o)]
807
+ if not pool:
808
+ return result
809
+ o = min(pool, key=lambda q: q.report.get("mass", float("inf")))
810
+ if on_progress is not None:
811
+ on_progress({"stage": "printSetup"}, None)
812
+ try:
813
+ ps = search_print_setup(st, o.mesh, cells=min(bs["cells"], 4000), **bs["printSetup"])
814
+ except Exception as exc: # noqa: BLE001
815
+ ps = {"error": f"{type(exc).__name__}: {exc}"}
816
+ o.report["printSetup"] = ps
817
+ b = ps.get("best")
818
+ re = ps.get("rerun")
819
+ if not (b and b.get("passes")) and re and bs["printSetup"].get("reoptimize", True):
820
+ # no setup makes this shape pass: re-optimize for the setup with
821
+ # room under the warp limit and the most strength per gram
822
+ from .printsetup import study_for
823
+ if on_progress is not None:
824
+ on_progress({"stage": "printSetup", "rerun": {k: re[k] for k in ("material", "build", "bed", "brim")}},
825
+ None)
826
+ again = run_study(study_for(st, re), on_progress)
827
+ again.warnings.insert(0, f"no outcome met the warp limit as {st.material.name}, build "
828
+ f"{_build_label(st)}; re-optimized for {re['material']}, build {re['build']}, "
829
+ f"bed {re['bed']:g} C" + (f", {re['brim']:g} mm brim" if re["brim"] else "")
830
+ + " (report printSetup has the search)")
831
+ if again.best is not None:
832
+ again.outcomes[again.best].report["printSetup"] = {**ps, "reoptimizedFor": re}
833
+ return again
834
+ if b and b.get("passes"):
835
+ result.warnings.append(
836
+ f"no outcome met the warp limit as set up; the {o.volume_fraction:.0%} outcome does printed as "
837
+ f"{b['material']}, build {b['build']}, bed {b['bed']:g} C"
838
+ + (f", {b['brim']:g} mm brim" if b["brim"] else "") + f" ({b['mass']:.1f} g, lifts {b['lift']:.2f} mm, "
839
+ f"SF {b['safetyFactor']:.2f}): set that in the study and run it again (report printSetup)")
840
+ return result
841
+
842
+
843
+ def _process_checks(result: StudyResult, on_progress=None) -> StudyResult:
844
+ """Powder-bed polymer and resin parts: the process checks
845
+ (processcheck.check_process) on the delivered outcome, into its report
846
+ as "process", with its warnings added to the outcome's."""
847
+ proc = result.study.process
848
+ if proc is None or result.best is None:
849
+ return result
850
+ from .processcheck import RULES, check_process
851
+
852
+ if proc.kind not in RULES:
853
+ return result
854
+ o = result.outcomes[result.best]
855
+ if getattr(o, "mesh", None) is None:
856
+ return result
857
+ if on_progress is not None:
858
+ on_progress({"stage": "process", "process": proc.kind}, None)
859
+ try:
860
+ mat = _material_named((o.report.get("material") or {}).get("name"), result.study)
861
+ rep = check_process(o.mesh, proc, material=mat)
862
+ except Exception as exc: # noqa: BLE001 - a failed check leaves the part's report standing
863
+ rep = {"process": proc.kind, "error": f"{type(exc).__name__}: {exc}", "ok": None, "warnings": []}
864
+ o.report["process"] = rep
865
+ o.report.setdefault("warnings", []).extend(rep.get("warnings", []))
866
+ return result
867
+
868
+
869
+ # goal.minimize -> the slicer report's number it minimizes
870
+ _SLICED_COST = {"printTime": "printTime", "filament": "filamentVolume"}
871
+ # graded infill is sized for at most this many passing outcomes when the
872
+ # goal picks by print time (each is a few FEA solves)
873
+ _GRADE_CANDIDATES = 3
874
+
875
+
876
+ def _slice(result: StudyResult, on_progress=None) -> StudyResult:
877
+ """FDM: slice every outcome (slicer.slice_mesh) for print time, filament
878
+ and support, into its report as "slicer"; size graded infill
879
+ (infill.py) when `slicer.grade` asks; and with goal.minimize printTime or
880
+ filament, deliver the passing outcome that is cheapest by it rather than
881
+ the lightest."""
882
+ study = result.study
883
+ opts = study.slicer
884
+ if opts is None or opts.enabled is False or not result.outcomes:
885
+ return result
886
+ from .slicer import find_slicer, slice_mesh
887
+
888
+ goal = study.goal
889
+ if find_slicer(opts.executable) is None:
890
+ if opts.enabled is True or goal.minimize != "mass" or opts.grade is not None:
891
+ result.warnings.append("no slicer found, so print time and filament were not measured (and the "
892
+ "outcome is the lightest): install PrusaSlicer, or set LOADPATH_SLICER")
893
+ return result
894
+ build = study.process.build if study.process.build != "auto" else None
895
+ for i, o in enumerate(result.outcomes):
896
+ if getattr(o, "mesh", None) is None:
897
+ continue
898
+ if on_progress is not None:
899
+ on_progress({"stage": "slice", "volumeFraction": o.volume_fraction}, None)
900
+ mat = _material_named((o.report.get("material") or {}).get("name"), study)
901
+ try:
902
+ o.report["slicer"] = slice_mesh(o.mesh, build, mat, opts, name=f"{study.name}-{i}")
903
+ except Exception as exc: # noqa: BLE001 - a failed slice leaves the part's report standing
904
+ o.report["slicer"] = {"error": f"{type(exc).__name__}: {exc}"}
905
+ cost = _SLICED_COST.get(goal.minimize)
906
+ pool = [i for i, o in enumerate(result.outcomes) if o.passes] if any(o.passes is not None
907
+ for o in result.outcomes) else []
908
+ if opts.grade is not None:
909
+ if cost is not None and pool:
910
+ light = sorted(pool, key=lambda i: result.outcomes[i].report["mass"])[:_GRADE_CANDIDATES]
911
+ todo = sorted(set(light) | ({result.best} if result.best is not None else set()))
912
+ else:
913
+ todo = [result.best] if result.best is not None else []
914
+ for i in todo:
915
+ _grade(result, i, opts, on_progress)
916
+ if cost is not None and pool:
917
+ def sliced(i):
918
+ # the cheaper of its solid and graded prints: both are checked,
919
+ # and a thin part can print slower graded (region boundaries)
920
+ r = result.outcomes[i].report
921
+ vals = [(r.get(k) or {}).get(cost) for k in ("slicer",)] + \
922
+ [((r.get("graded") or {}).get("slicer") or {}).get(cost)]
923
+ vals = [v for v in vals if v is not None]
924
+ return min(vals) if vals else float("inf")
925
+ pick = min(pool, key=lambda i: (sliced(i), result.outcomes[i].report["mass"]))
926
+ if sliced(pick) < float("inf") and pick != result.best:
927
+ prev = result.best
928
+ result.best = pick
929
+ if opts.grade is not None and result.outcomes[pick].grading is None:
930
+ _grade(result, pick, opts, on_progress)
931
+ result.warnings.append(
932
+ f"goal.minimize {goal.minimize}: delivered outcome {pick} (vf {result.outcomes[pick].volume_fraction:.3f}, "
933
+ f"{result.outcomes[pick].report['mass']:.1f} g) over the lightest passing, outcome {prev}")
934
+ return result
935
+
936
+
937
+ def _grade(result: StudyResult, i: int, opts, on_progress=None) -> None:
938
+ """Graded infill for outcome i: report["graded"] (sizing, and its slice
939
+ next to the solid one) and o.grading (the modifier meshes)."""
940
+ from .infill import GradeOptions, grade_infill
941
+ from .slicer import slice_mesh
942
+
943
+ study = result.study
944
+ o = result.outcomes[i]
945
+ mat = _material_named((o.report.get("material") or {}).get("name"), study)
946
+ import dataclasses
947
+ st = dataclasses.replace(study, material=mat, materials=[mat])
948
+ if on_progress is not None:
949
+ on_progress({"stage": "grade", "volumeFraction": o.volume_fraction}, None)
950
+ try:
951
+ g = grade_infill(st, o.mesh, o.report, GradeOptions.from_spec(opts.grade, study.goal.safety_factor))
952
+ except Exception as exc: # noqa: BLE001
953
+ o.report["graded"] = {"error": f"{type(exc).__name__}: {exc}"}
954
+ return
955
+ o.grading = g
956
+ rep = {k: v for k, v in g.items() if k not in ("modifiers", "history")}
957
+ rep["modifiers"] = [{"name": m["name"], "settings": m["settings"], "faces": int(len(m["mesh"].faces))}
958
+ for m in g["modifiers"]]
959
+ build = study.process.build if study.process.build != "auto" else None
960
+ try:
961
+ rep["slicer"] = slice_mesh(o.mesh, build, mat, opts, name=f"{study.name}-{i}-graded",
962
+ object_settings=g["objectSettings"], modifiers=g["modifiers"])
963
+ solid = o.report.get("slicer") or {}
964
+ if solid.get("printTime") and rep["slicer"].get("printTime"):
965
+ rep["printTimeSaved"] = 1.0 - rep["slicer"]["printTime"] / solid["printTime"]
966
+ rep["filamentSaved"] = 1.0 - rep["slicer"]["filamentVolume"] / solid["filamentVolume"]
967
+ except Exception as exc: # noqa: BLE001
968
+ rep["slicer"] = {"error": f"{type(exc).__name__}: {exc}"}
969
+ goal = study.goal.safety_factor
970
+ if goal and g["safetyFactor"] < goal:
971
+ o.report.setdefault("warnings", []).append(
972
+ f"graded infill reads SF {g['safetyFactor']:.2f} under the goal's {goal:g}: print the solid 3MF")
973
+ o.report["graded"] = rep
974
+
975
+
976
+
977
+ def mesh_fidelity(domain, xphys, mesh) -> dict | None:
978
+ """How much of the optimized field the delivered mesh kept: the share of
979
+ solid cells (xphys > 0.5) whose centre lies outside the mesh, overall, on
980
+ the surface of the solid, and in thin members (gone under a one-cell
981
+ opening). The camera boom (2026-11-01) lost 21% of its solid cells: 44%
982
+ of its thin members and 34% of its surface cells, against 8% inside.
983
+ Meshed, it read 16x softer than the optimizer's own model on the same
984
+ grid and supports, so the run reported SF 0.99 for a field that carries
985
+ the load with room to spare."""
986
+ from scipy import ndimage
987
+
988
+ from .voxels import mesh_solid
989
+
990
+ solid = np.asarray(xphys) > 0.5
991
+ if not solid.any() or mesh is None or len(getattr(mesh, "faces", ())) == 0:
992
+ return None
993
+ step = domain.pitch / 2.0
994
+ lo = np.asarray(domain.origin, float)
995
+ hi = lo + np.asarray(domain.dims) * domain.pitch
996
+ try:
997
+ g, org, *_ = mesh_solid(mesh, step, pad=2, bounds=(lo, hi))
998
+ except (MemoryError, ValueError):
999
+ return None
1000
+ c = domain.mesh.elem_centers()
1001
+ ijk = np.clip(np.floor((c - np.asarray(org)) / step).astype(int), 0, np.array(g.shape) - 1)
1002
+ inside = g[tuple(ijk.T)]
1003
+ at = tuple(domain.mesh.elem_ijk.T)
1004
+ grid = np.zeros(domain.dims, bool)
1005
+ grid[at] = solid
1006
+ cross = ndimage.generate_binary_structure(3, 1)
1007
+ thin = (grid & ~ndimage.binary_opening(grid, structure=cross))[at]
1008
+ surface = (grid & ~ndimage.binary_erosion(grid, structure=cross))[at] & ~thin
1009
+ lost = solid & ~inside
1010
+
1011
+ def share(m):
1012
+ return float((lost & m).sum() / max(int(m.sum()), 1))
1013
+
1014
+ return {"lostSolidCells": share(solid), "lostThinCells": share(thin), "lostSurfaceCells": share(surface),
1015
+ "thinCells": float(thin.sum() / solid.sum())}
1016
+
1017
+
1018
+ MESH_LOSS_WARN = 0.10
1019
+
1020
+ def _material_named(name, study):
1021
+ """The study's Material called `name` (a study can try several)."""
1022
+ for m in study.materials or [study.material]:
1023
+ if m.name == name:
1024
+ return m
1025
+ return study.material
1026
+
1027
+
1028
+ def _constrained_applies(study: Study) -> bool:
1029
+ goal = study.goal
1030
+ return (goal.safety_factor is not None or goal.max_deflection is not None) \
1031
+ and study.gravity is None and not _durability_drives(study) \
1032
+ and study.access is None and study.max_member is None \
1033
+ and not any(ld.direction for c in study.load_cases for ld in c.loads)
1034
+
1035
+
1036
+ def _run_auto(study: Study, on_progress=None, should_stop=None, search=None) -> StudyResult:
1037
+ """goal.method auto: the search, then the constrained method only when the
1038
+ search's part idles (stressUtilization under _AUTO_UTILIZATION), keeping
1039
+ the lighter passing part. The constrained method costs about as much as
1040
+ the search again, so it runs only where it tends to win."""
1041
+ import dataclasses
1042
+
1043
+ goal = study.goal
1044
+ st = dataclasses.replace(study, goal=dataclasses.replace(goal, method="search"))
1045
+ # `search` runs the search side (run_study: restarts and pitch starts)
1046
+ r = search(st) if search is not None else _run_single(st, on_progress, should_stop)
1047
+ choice = {"method": "search"}
1048
+ pick = r.outcomes[r.best] if r.best is not None else None
1049
+ u = pick.report.get("stressUtilization") if pick is not None else None
1050
+ if u is not None:
1051
+ choice["stressUtilization"] = u
1052
+ if pick is None or not pick.passes or u is None or not _constrained_applies(study):
1053
+ choice["reason"] = "the constrained method does not apply" if pick is not None and pick.passes \
1054
+ else "no passing search part to compare"
1055
+ r.choice = choice
1056
+ return r
1057
+ rep = pick.report
1058
+ sf = rep.get("safetyFactor")
1059
+ if goal.max_deflection and rep.get("cases") \
1060
+ and max(c.get("deflection") or 0.0 for c in rep["cases"]) >= 0.85 * goal.max_deflection \
1061
+ and (goal.safety_factor is None or (sf is not None and sf >= 1.3 * goal.safety_factor)):
1062
+ # stiffness sets the mass (the arm link, the GRIP upper arm at SF
1063
+ # 11 for 2): the compliance search is already the right design
1064
+ choice["reason"] = "deflection-bound: the search's compliance design is the right one"
1065
+ r.choice = choice
1066
+ return r
1067
+ from .verify import use_cut_cells
1068
+ limit = _AUTO_UTILIZATION_CUT if use_cut_cells(study) else _AUTO_UTILIZATION
1069
+ if u >= limit:
1070
+ choice["reason"] = f"stress utilization {u:.2f} >= {limit}: the search part is worked evenly"
1071
+ r.choice = choice
1072
+ return r
1073
+ if should_stop is not None and should_stop():
1074
+ r.choice = choice
1075
+ return r
1076
+ if on_progress is not None:
1077
+ on_progress({"stage": "method", "method": "constrained", "stressUtilization": u}, None)
1078
+ # time box: the constrained method runs on a finer grid and took about
1079
+ # 2.9x the search on the yoke (2.5x cut it before its 16 g run); on the
1080
+ # wrist link it found no passing part in 90 minutes (the search: 24).
1081
+ # Past the budget the runs it has stand.
1082
+ # The buckling constraint's eigensolve roughly doubles a constrained
1083
+ # iteration on a full-size grid (10-25 s of 25-50 at 35k cells on the
1084
+ # wall bracket), so it gets twice the time.
1085
+ budget = _AUTO_BUDGET * (2.0 if goal.buckling and goal.safety_factor and _BUCKLING_IN_OPTIMIZER else 1.0)
1086
+ deadline = time.time() + max(600.0, budget * r.seconds)
1087
+
1088
+ def stop():
1089
+ return time.time() > deadline or (should_stop is not None and should_stop())
1090
+
1091
+ try:
1092
+ rc = _run_single(dataclasses.replace(study, goal=dataclasses.replace(goal, method="constrained")),
1093
+ on_progress, stop, fallback=False)
1094
+ except Exception as exc: # noqa: BLE001 - the constrained run is a bonus: any failure keeps the search's part
1095
+ choice["reason"] = f"the constrained method failed ({exc})"
1096
+ r.choice = choice
1097
+ return r
1098
+ ms, mc = pick.report["mass"], None
1099
+ if rc.best is not None and rc.outcomes[rc.best].passes:
1100
+ mc = rc.outcomes[rc.best].report["mass"]
1101
+ choice.update({"searchMass": ms, "constrainedMass": mc})
1102
+ if mc is not None and mc < ms:
1103
+ choice["method"] = "constrained"
1104
+ choice["reason"] = (f"stress utilization {u:.2f}: constrained {mc:.1f} g against "
1105
+ f"search {ms:.1f} g")
1106
+ rc.choice = choice
1107
+ rc.seconds += r.seconds
1108
+ return rc
1109
+ choice["reason"] = (f"stress utilization {u:.2f}: constrained "
1110
+ + (f"{mc:.1f} g" if mc is not None else "found no passing part")
1111
+ + f" against search {ms:.1f} g")
1112
+ r.choice = choice
1113
+ r.seconds += rc.seconds
1114
+ return r
1115
+
1116
+
1117
+ def _run_single(study: Study, on_progress=None, should_stop=None, fallback: bool = True) -> StudyResult:
1118
+ if study.goal.method == "auto":
1119
+ return _run_auto(study, on_progress, should_stop)
1120
+ t0 = time.time()
1121
+
1122
+ def emit(event, field_=None):
1123
+ if on_progress is not None:
1124
+ on_progress(event, field_)
1125
+
1126
+ domain = build_domain(study)
1127
+ radius = filter_radius(domain, study.length_scale, study.min_hole)
1128
+ info = _domain_info(domain, radius)
1129
+ emit({"stage": "domain", **info, "warnings": domain.warnings})
1130
+ outcomes: list[Outcome] = []
1131
+ started = False
1132
+ # what earlier runs on this domain found (recall.py), when turned on
1133
+ from .recall import Recall
1134
+ memory = Recall.open(study, domain, radius, _making(study))
1135
+ # FDM warp limits are checked on each outcome's delivered part,
1136
+ # re-simulated (the optimizer's coarse grid reads FDM warp 2-3.5x low,
1137
+ # and the ratio moves from design to design). The optimizer carries
1138
+ # them only with buildSim.warpInOptimizer; then its limits for the next
1139
+ # attempt are scaled by how far it read off on the last one. Metal
1140
+ # limits stay in the optimizer and are checked on its full grid.
1141
+ ws = domain.warp
1142
+ bsim = study.build_sim or {}
1143
+ limits0 = (bsim.get("maxDistortion"), bsim.get("maxRecoaterLift")) \
1144
+ if bsim and (bsim.get("maxDistortion") or bsim.get("maxRecoaterLift")) \
1145
+ and bsim["setup"].process.lower() == "fdm" else None
1146
+
1147
+ def _check_warp(warp, mesh):
1148
+ warp["userLimits"] = {"maxDistortion": limits0[0], "maxRecoaterLift": limits0[1]}
1149
+ d = _warp_delivered(study, mesh)
1150
+ warp["delivered"] = d
1151
+ if not d or "error" in d or ws is None:
1152
+ return
1153
+ for key, attr, lim in (("maxDistortion", "max_distortion", limits0[0]),
1154
+ ("recoaterLift", "max_lift", limits0[1])):
1155
+ if lim and d.get(key) and warp.get(key):
1156
+ ratio = d[key] / warp[key]
1157
+ setattr(ws, attr, lim / ratio)
1158
+ warp.setdefault("deliveredOverOptimizer", {})[key] = round(ratio, 3)
1159
+
1160
+ goal_ = study.goal
1161
+ buck_state = {"on": False, "kb": 1.0,
1162
+ "sf": goal_.safety_factor if (goal_.buckling and goal_.safety_factor and _BUCKLING_SEARCH
1163
+ and study.gravity is None and study.thermal is None) else None}
1164
+
1165
+ def attempt(vf: float, x0, beta0, replay=None) -> Outcome:
1166
+ def on_iter(rec, xp):
1167
+ emit({"stage": "optimize", "volumeFraction": vf, **rec}, xp)
1168
+
1169
+ if replay is None:
1170
+ breq = buck_state["sf"] * buck_state["kb"] if buck_state["on"] else None
1171
+ res = optimize(domain, study.elastic(design=True),
1172
+ SimpOptions(volume_fraction=vf, filter_radius=radius,
1173
+ max_iterations=study.max_iterations, x0=x0, beta_start=beta0,
1174
+ buckling=breq, buckling_modulus=study.material.E,
1175
+ **_making(study)),
1176
+ on_iter=on_iter, should_stop=should_stop)
1177
+ x, xphys = res.x, res.xphys
1178
+ warp = res.warp
1179
+ opt = {"iterations": res.iterations, "converged": res.converged, "seconds": res.seconds,
1180
+ "compliance": res.compliance, "caseCompliance": res.case_compliance,
1181
+ **({"caseDirection": res.case_direction} if any(d is not None for d in res.case_direction) else {}),
1182
+ "achievedVolumeFraction": res.volume_fraction,
1183
+ **({"escape": res.escape} if res.escape else {})}
1184
+ else:
1185
+ # a stored design for this very problem: mesh and verify it, no optimizing
1186
+ x, xphys = replay[0], replay[1]
1187
+ warp = None
1188
+ opt = {"iterations": 0, "converged": True, "seconds": 0.0, "recalled": True,
1189
+ "achievedVolumeFraction": float(xphys.mean())}
1190
+ emit({"stage": "mesh", "volumeFraction": vf})
1191
+ mesh, rinfo = reconstruct(study, domain, xphys, ReconstructOptions())
1192
+ emit({"stage": "verify", "volumeFraction": vf})
1193
+ try:
1194
+ report = verify_part(study, mesh, min_pitch=0.5 * domain.pitch, contact_band=domain.pitch)
1195
+ except (RuntimeError, ValueError) as exc:
1196
+ # one outcome verification cannot solve (or a load pad the
1197
+ # design let go of: GRIP's wrist link at 5% after 43 g at 6%) (a piece the supports do
1198
+ # not hold) is a failed outcome, not a failed study: the search
1199
+ # goes on past it (GRIP's wrist link died here)
1200
+ mat = study.material
1201
+ report = {"mass": float(mesh.volume) * mat.density / 1000.0, "safetyFactor": 0.0,
1202
+ "cases": [], "warnings": [f"verification failed: {exc}"]}
1203
+ if buck_state["sf"] is not None and report.get("bucklingFactor") is not None:
1204
+ # Buckling in the search (simp.optimize's buckling constraint):
1205
+ # on from the first part whose buckling factor comes within
1206
+ # _BUCKLING_SEARCH_ON of the safety factor, at sf * kb, kb the
1207
+ # optimizer's own reading over the verified one (last run)
1208
+ lv = report["bucklingFactor"]
1209
+ lo = res.history[-1].get("buckling") if replay is None and res.history else None
1210
+ if lo is not None and np.isfinite(lo) and lo > 0 and lv > 0:
1211
+ kb = buck_state["kb"]
1212
+ buck_state["kb"] = float(np.clip(lo / lv, 0.5 * kb, 2.0 * kb))
1213
+ opt["bucklingFactor"] = float(lo)
1214
+ if lv < _BUCKLING_SEARCH_ON * buck_state["sf"]:
1215
+ buck_state["on"] = True
1216
+ making = manufacturing_report(study, domain, mesh)
1217
+ if making:
1218
+ report["manufacturing"] = making
1219
+ fid = mesh_fidelity(domain, xphys, mesh)
1220
+ if fid is not None:
1221
+ report["meshFidelity"] = fid
1222
+ if fid["lostSolidCells"] > MESH_LOSS_WARN:
1223
+ report.setdefault("warnings", []).append(
1224
+ f"the mesh lost {fid['lostSolidCells']:.0%} of the optimized solid cells "
1225
+ f"({fid['lostSurfaceCells']:.0%} of the surface cells, {fid['lostThinCells']:.0%} of the thin "
1226
+ "members): the meshed part is softer and weaker than the design the optimizer found, so "
1227
+ "the outcome search goes heavier than the design needs. The verified numbers are the "
1228
+ "ones that hold.")
1229
+ if limits0 is not None and not warp:
1230
+ warp = {"limits": {"maxDistortion": limits0[0], "maxRecoaterLift": limits0[1]}}
1231
+ if warp:
1232
+ report["warp"] = warp
1233
+ if limits0 is not None:
1234
+ _check_warp(warp, mesh)
1235
+ o = Outcome(vf, mesh, report, opt, rinfo)
1236
+ o.xphys = xphys
1237
+ o.x = x
1238
+ o.voxel_sf = report.get("safetyFactor")
1239
+ if memory is not None:
1240
+ memory.add(vf, x, xphys, report)
1241
+ m = _margin(study, report)
1242
+ if m is not None:
1243
+ report["margin"] = m
1244
+ o.passes = bool(m >= 1.0)
1245
+ if report.get("bodies", 1) > 1:
1246
+ # A preserved piece no load path reaches (a mount with no load
1247
+ # through it) comes out as a loose part. It is not a design that
1248
+ # meets the brief, and the fix is in the study: say so.
1249
+ o.passes = False
1250
+ report.setdefault("warnings", []).append(
1251
+ f"the part came out in {report['bodies']} pieces: either each piece has its own supports and "
1252
+ "load path and nothing ties them together (two mirror halves, each on its own bolts), or some "
1253
+ "preserved geometry is on no load path. For one part, preserve a link between the pieces (a "
1254
+ "ring, a web, a plate) or add a load case that runs between them; for a floating preserve, "
1255
+ "load it or drop it.")
1256
+ if report.get("closedBores"):
1257
+ o.passes = False
1258
+ report.setdefault("warnings", []).append(
1259
+ f"the part closes over {len(report['closedBores'])} bore end(s) "
1260
+ f"({', '.join(b['id'] + ' ' + b['end'] for b in report['closedBores'])}): no fastener goes through, "
1261
+ "so it does not pass. Extend the keep-out over the head and the driver's reach if this repeats.")
1262
+ return o
1263
+
1264
+ def run_one(vf: float) -> Outcome:
1265
+ nonlocal started
1266
+ emit({"stage": "optimize", "volumeFraction": vf})
1267
+ big = domain.n_elem >= _COARSE_MIN_CELLS
1268
+
1269
+ def cold():
1270
+ if big:
1271
+ x0 = _coarse_start(study, domain, vf, radius, should_stop)
1272
+ return attempt(vf, x0, _WARM_BETA if x0 is not None else 1.0)
1273
+ return attempt(vf, None, 1.0)
1274
+
1275
+ rp = memory.replay(vf) if memory is not None else None
1276
+ w = memory.warm_start(vf) if memory is not None and rp is None and not started else None
1277
+ if rp is not None:
1278
+ o = attempt(vf, None, None, replay=rp)
1279
+ o.report["start"] = f"recalled {vf:.3f}"
1280
+ elif w is not None:
1281
+ try:
1282
+ o = attempt(vf, w[0], _WARM_BETA)
1283
+ o.report["start"] = w[1]
1284
+ except RuntimeError:
1285
+ o = cold()
1286
+ o.report["start"] = "cold (the recalled start did not solve)"
1287
+ elif not any(q.x is not None for q in outcomes):
1288
+ o = cold()
1289
+ o.report["start"] = "cold"
1290
+ else:
1291
+ # Each fraction warm-starts from a design already found, which is
1292
+ # what lets a stiffness-bound part follow its layout down (GRIP's
1293
+ # upper arm: 7.0% passed warm at margin 1.06 and failed cold at
1294
+ # 0.68). From the nearest fraction above, never the last one run:
1295
+ # the wall bracket chained 0.150 -> 0.104 -> 0.127 -> 0.115 (up,
1296
+ # then down again) read SF 1.69 at 0.115 and 2.34 at 0.110 carried
1297
+ # straight down from 0.150, so its mass swung with the order the
1298
+ # fractions came in (57-68 g). Carried down, the verified SF rises
1299
+ # smoothly with the fraction (0.88, 2.00, 2.34, 2.83, 3.11, 3.36
1300
+ # at 9-14%). Not always from the first design either: the turret
1301
+ # carried 0.120 -> 0.029 in one jump read SF 1.56, and 2.54 at
1302
+ # 0.025 stepped down through 0.068 and 0.042.
1303
+ above = [q for q in outcomes if q.x is not None and q.volume_fraction > vf]
1304
+ src = (min(above, key=lambda q: q.volume_fraction) if above else
1305
+ max((q for q in outcomes if q.x is not None), key=lambda q: q.volume_fraction))
1306
+ try:
1307
+ o = attempt(vf, src.x, _WARM_BETA)
1308
+ o.report["start"] = f"warm from {src.volume_fraction:.3f}"
1309
+ except RuntimeError:
1310
+ # A carried layout can leave a support hanging on void
1311
+ # (GRIP's wrist link: a pin-held idler with its material
1312
+ # gone, so the stiffness solve never converged). Start this
1313
+ # fraction afresh instead.
1314
+ o = cold()
1315
+ o.report["start"] = "cold (the warm start did not solve)"
1316
+ if big and len(outcomes) == 1 and vf < outcomes[0].volume_fraction \
1317
+ and "recalled" not in str(outcomes[0].report.get("start")) \
1318
+ and not (should_stop is not None and should_stop()):
1319
+ # ...but carried from a fraction well above, the layout can
1320
+ # be one the new fraction no longer suits: the wall bracket
1321
+ # read SF 1.0-1.5 at 8-14% chained down from 15%, and 3.2 at
1322
+ # 10% started fresh. So the first step down is also run from
1323
+ # its own coarse layout, and the better margin kept; one
1324
+ # extra run per search.
1325
+ c = cold()
1326
+ c.report["start"] = "cold"
1327
+ if (c.report.get("margin") or 0.0) > (o.report.get("margin") or 0.0):
1328
+ o, c = c, o
1329
+ o.report["alternative"] = {k: c.report.get(k) for k in
1330
+ ("start", "mass", "safetyFactor", "margin")} | {"passes": c.passes}
1331
+ started = True
1332
+ emit({"stage": "outcome", **o.summary()})
1333
+ _save_partial(o)
1334
+ return o
1335
+
1336
+ def _confirm(o: Outcome) -> float:
1337
+ # a replayed design's mesh is the one confirmed before: rescale that reading
1338
+ rep = memory.confirm_for(o.volume_fraction) if memory is not None and o.optimizer.get("recalled") else None
1339
+ recalled = rep is not None
1340
+ if rep is None:
1341
+ try:
1342
+ cc = _confirm_cells(study)
1343
+ rep = verify_part(study, o.mesh, max_cells=cc, min_pitch=0.3 * domain.pitch,
1344
+ contact_band=domain.pitch)
1345
+ except RuntimeError as exc:
1346
+ o.report.setdefault("warnings", []).append(f"confirmation failed: {exc}")
1347
+ o.report["margin"] = 0.0
1348
+ return 0.0
1349
+ if memory is not None:
1350
+ memory.confirmed(o.volume_fraction, rep)
1351
+ mc = _margin(study, rep)
1352
+ o.report["confirm"] = {"safetyFactor": rep["safetyFactor"], "margin": mc,
1353
+ "analysisPitch": rep["analysisPitch"], "analysisCells": rep["analysisCells"],
1354
+ **({"cellsCapped": "by free memory (LOADPATH_CONFIRM_CELLS sets it)"}
1355
+ if not recalled and cc < _CONFIRM_CELLS and not os.environ.get("LOADPATH_CONFIRM_CELLS")
1356
+ else {}),
1357
+ "cases": [{k: c[k] for k in ("name", "peakStress", "peakAt", "deflection")}
1358
+ for c in rep["cases"]]}
1359
+ # recorded, not adopted: the finer grid also resolves small features
1360
+ # the analysis grid smooths over, so its percentile is not comparable
1361
+ o.report["confirm"].update(p5Member=rep["p5Member"], minMember=rep["minMember"])
1362
+ if recalled:
1363
+ o.report["confirm"]["recalled"] = True
1364
+ if mc < o.report["margin"]:
1365
+ # report the finer (lower) reading: it is the one to trust
1366
+ o.report["margin"], o.report["safetyFactor"], o.report["cases"] = mc, rep["safetyFactor"], rep["cases"]
1367
+ for k in ("thermal", "durability"):
1368
+ if k in rep:
1369
+ o.report[k] = rep[k]
1370
+ return mc
1371
+
1372
+ def tet_confirm(o: Outcome) -> float | None:
1373
+ """Confirm `o` on the tetrahedral cross-check (crosscheck.py). Its
1374
+ safety factor replaces the voxel one, and o.tet_k (tet / voxel) is
1375
+ the calibration for the rest of the search. Returns the confirmed
1376
+ margin, or None when the cross-check could not run."""
1377
+ from .crosscheck import crosscheck, element_size
1378
+
1379
+ emit({"stage": "confirm", "volumeFraction": o.volume_fraction, "method": "tet"})
1380
+ # Stress is left out within two elements of a load or support, and
1381
+ # no less than the voxel analysis leaves out: nearer, the peak is the
1382
+ # edge of an idealized rigid support, not the part. (The MBB beam's
1383
+ # read 23-26 MPa there on three meshes of one part, and 13.7-13.9
1384
+ # MPa at its real peak mid-span beyond the band.)
1385
+ band = max(2.0 * element_size(study), 1.5 * domain.pitch)
1386
+ try:
1387
+ cc = crosscheck(study, o.mesh, contact_band=band)
1388
+ except Exception as exc: # noqa: BLE001 - a mesher or solver failure means: confirm on voxels instead
1389
+ o.report.setdefault("warnings", []).append(f"tetrahedral confirmation failed: {exc}")
1390
+ return None
1391
+ r = o.report
1392
+ o.tet_k = cc["safetyFactor"] / o.voxel_sf if o.voxel_sf else 1.0
1393
+ r["voxelSafetyFactor"] = o.voxel_sf
1394
+ r["safetyFactor"] = cc["safetyFactor"]
1395
+ r.pop("calibration", None)
1396
+ mt = _margin(study, r)
1397
+ r["margin"] = mt
1398
+ r["confirm"] = {"method": "tet", "safetyFactor": cc["safetyFactor"], "margin": mt,
1399
+ "voxelSafetyFactor": o.voxel_sf, "tetOverVoxel": o.tet_k,
1400
+ "elementSize": cc["elementSize"], "contactBand": band, "nodes": cc["nodes"], "elements": cc["elements"],
1401
+ "seconds": cc["seconds"],
1402
+ "cases": [{k: c[k] for k in ("name", "peakStress", "peakAt", "deflection")}
1403
+ for c in cc["cases"]]}
1404
+ o.passes = bool(mt >= 1.0) and _whole(r)
1405
+ emit({"stage": "confirmed", "volumeFraction": o.volume_fraction, "margin": mt, "method": "tet"})
1406
+ return mt
1407
+
1408
+ def untet(o: Outcome):
1409
+ """Put `o` back on its voxel reading, keeping the tet one as a
1410
+ warning (where, how far below target)."""
1411
+ r = o.report
1412
+ c = r.pop("confirm")
1413
+ o.tet_k = None
1414
+ r.pop("voxelSafetyFactor", None)
1415
+ r["safetyFactor"] = o.voxel_sf
1416
+ r["margin"] = _margin(study, r)
1417
+ o.passes = bool(r["margin"] >= 1.0) and _whole(r)
1418
+ r["tetCheck"] = c
1419
+ worst = min(c["cases"], key=lambda q: -q["peakStress"])
1420
+ r.setdefault("warnings", []).append(
1421
+ f"the tetrahedral cross-check reads SF {c['safetyFactor']:.2f} (voxel {o.voxel_sf:.2f}), its peak "
1422
+ f"{worst['peakStress']:.1f} MPa in {worst['name']!r} at "
1423
+ f"({', '.join(f'{v:.1f}' for v in worst['peakAt'])}): a stress the voxel grid does not resolve. "
1424
+ "Check it there (a fillet, a thicker wall, or how the part is really held).")
1425
+
1426
+ def layout_search(k: float):
1427
+ """Other layouts below a tet-confirmed pick with margin to spare.
1428
+
1429
+ The fraction chain follows one layout down, warm-starting each step
1430
+ from the last, and a part whose strength is not monotone in the
1431
+ fraction stops on it: the wall bracket passed at 12.6% (tets 4.95
1432
+ for 2.5) with 10.2% and 11.4% failing (2.29, 2.12), all three
1433
+ descendants of the 15% design. So a few differently started designs
1434
+ are tried between the lightest confirmed design and the fraction
1435
+ its margin points at: warm from it, then a fresh coarse layout.
1436
+ Each that passes on its scaled voxel reading, or misses by under
1437
+ 15%, is confirmed on tets; one that confirms becomes the design the
1438
+ next try starts below. (The bell crank went 24.3 -> 22.4 g this
1439
+ way, warm from its pick. A 1.5x wider filter, fresh or warm, came
1440
+ apart at these fractions, SF 0 on four parts, so it is not tried;
1441
+ fresh layouts confirmed nowhere either, but are the one start not
1442
+ descended from the chain.)"""
1443
+ confirmed = [i for i, q in enumerate(outcomes) if getattr(q, "tet_k", None) and q.passes]
1444
+ if not confirmed:
1445
+ return
1446
+ o = outcomes[min(confirmed, key=lambda i: outcomes[i].report["mass"])]
1447
+ mt = o.report["margin"]
1448
+ if mt < _TET_CLOSE:
1449
+ return
1450
+ floor = max(o.volume_fraction * 1.03 / mt, 0.5 * o.volume_fraction, _VF_MIN)
1451
+ big = domain.n_elem >= _COARSE_MIN_CELLS
1452
+ starts = [("warm from the pick", lambda vf, o: attempt(vf, o.x, _WARM_BETA)),
1453
+ ("cold", lambda vf, o: attempt(vf, _coarse_start(study, domain, vf, radius, should_stop)
1454
+ if big else None, _WARM_BETA if big else 1.0))]
1455
+ runs = 0
1456
+ for name, start in starts:
1457
+ while runs < _LAYOUT_RUNS and not (should_stop is not None and should_stop()):
1458
+ top = o.volume_fraction
1459
+ if o.report["margin"] < _TET_CLOSE:
1460
+ return
1461
+ vf = 0.5 * (floor + top)
1462
+ retry = False
1463
+ if name == "cold":
1464
+ # a fraction the chain failed warm, between the floor
1465
+ # and the pick, first: its own fresh layout is the one
1466
+ # the chain lost (GRIP's MJF wrist link: 5.25% warm from
1467
+ # 6% read tets 0.99, fresh 4.00 for 2, 37.2 g against
1468
+ # 41.0; the midpoint, 4.2%, read 0.41)
1469
+ fresh = [q.volume_fraction for q in outcomes
1470
+ if str(q.report.get("start", "")).startswith(("cold", "layout search: cold"))]
1471
+ lost = [q.volume_fraction for q in outcomes
1472
+ if not q.passes and floor < q.volume_fraction < top and q.voxel_sf
1473
+ and q.report.get("bodies", 1) <= 1 and str(q.report.get("start", "")).startswith("warm")
1474
+ and not any(abs(q.volume_fraction - v) < 0.003 for v in fresh)]
1475
+ if lost:
1476
+ vf, retry = max(lost), True
1477
+ if top - vf < max(0.005, 0.05 * top) and not retry:
1478
+ break
1479
+ if name.startswith("warm") and any(abs(q.volume_fraction - vf) < 0.003 for q in outcomes):
1480
+ break # the chain already ran warm there
1481
+ emit({"stage": "optimize", "volumeFraction": vf, "start": name})
1482
+ try:
1483
+ q = start(vf, o)
1484
+ except RuntimeError:
1485
+ break
1486
+ runs += 1
1487
+ q.report["start"] = f"layout search: {name}"
1488
+ outcomes.append(q)
1489
+ calibrate(q, k)
1490
+ emit({"stage": "outcome", **q.summary()})
1491
+ if q.report.get("bodies", 1) > 1 or (q.report.get("margin") or 0.0) < 0.85:
1492
+ break
1493
+ mq = tet_confirm(q)
1494
+ if mq is None or mq < 1.0:
1495
+ if mq is None:
1496
+ q.tet_k, q.passes = 0.0, False
1497
+ break
1498
+ o = q # it held: try once more, below it
1499
+ if runs >= _LAYOUT_RUNS:
1500
+ break
1501
+
1502
+ def calibrate(o: Outcome, k: float):
1503
+ """Read `o`'s voxel safety factor through the tet calibration k (1
1504
+ restores the voxel reading). A tet-confirmed outcome keeps its own."""
1505
+ if getattr(o, "tet_k", None) is not None or not o.voxel_sf:
1506
+ return
1507
+ r = o.report
1508
+ if k == 1.0:
1509
+ r.pop("calibration", None)
1510
+ m = _margin(study, r)
1511
+ else:
1512
+ r["calibration"] = {"tetOverVoxel": k, "safetyFactor": o.voxel_sf * k}
1513
+ m = _margin(study, {**r, "safetyFactor": o.voxel_sf * k})
1514
+ r["margin"] = m
1515
+ o.passes = bool(m >= 1.0) and _whole(r)
1516
+
1517
+ def run_constrained(limits, x0) -> Outcome:
1518
+ from .constrained import optimize_constrained
1519
+
1520
+ emit({"stage": "optimize", "limits": {"stress": limits.stress, "deflection": limits.deflection}})
1521
+
1522
+ def on_iter(rec, xp):
1523
+ emit({"stage": "optimize", **rec}, xp)
1524
+
1525
+ beta0 = _WARM_BETA if x0 is not None else 1.0
1526
+ if x0 is None and domain.n_elem >= _COARSE_MIN_CELLS:
1527
+ x0 = _coarse_start(study, domain, float(goal.volume_fraction or 0.3), radius, should_stop)
1528
+ beta0 = _WARM_BETA if x0 is not None else 1.0
1529
+ res = optimize_constrained(
1530
+ domain, study.elastic(),
1531
+ SimpOptions(volume_fraction=float(goal.volume_fraction or 0.3), filter_radius=radius,
1532
+ max_iterations=study.max_iterations, x0=x0, beta_start=beta0,
1533
+ extrusion=study.extrusion, pattern=study.pattern,
1534
+ build_direction=study.print_direction),
1535
+ limits, on_iter=on_iter, should_stop=should_stop)
1536
+ mesh, rinfo = reconstruct(study, domain, res.xphys, ReconstructOptions())
1537
+ report = verify_part(study, mesh, min_pitch=0.5 * domain.pitch, contact_band=domain.pitch)
1538
+ opt = {"iterations": res.iterations, "converged": res.converged, "seconds": res.seconds,
1539
+ "achievedVolumeFraction": res.volume_fraction, "method": "constrained",
1540
+ "limits": {"stress": limits.stress, "deflection": limits.deflection,
1541
+ **({"buckling": limits.buckling} if limits.buckling is not None else {})}}
1542
+ last = res.history[-1] if res.history else {}
1543
+ bks = [v for kk, v in last.items() if kk.startswith("buckling.")]
1544
+ if bks:
1545
+ opt["bucklingFactor"] = float(min(bks)) # the optimizer's own reading, eroded grey design
1546
+ o = Outcome(res.volume_fraction, mesh, report, opt, rinfo)
1547
+ o.voxel_sf = report.get("safetyFactor")
1548
+ m = _margin(study, report)
1549
+ report["margin"] = m
1550
+ o.passes = bool(m >= 1.0)
1551
+ o.x = res.x
1552
+ emit({"stage": "outcome", **o.summary()})
1553
+ return o
1554
+
1555
+ goal = study.goal
1556
+ if goal.method == "constrained" and _constrained_applies(study):
1557
+ from .constrained import Limits
1558
+ from .verify import STRESS_ALLOWANCE
1559
+
1560
+ mat = study.material
1561
+ stress = mat.strength / (goal.safety_factor * STRESS_ALLOWANCE) if goal.safety_factor else None
1562
+ # The verified mesh reads differently from the optimizer's model
1563
+ # (finer grid, refined hotspots, members a cell thick that mesh
1564
+ # thinner), so the limits are scaled by one factor t until the
1565
+ # verified part lands on the target. Rescaling by each run's ratio
1566
+ # alone swung: the wall bracket's first run reads 17 MPa in the
1567
+ # optimizer and 377 MPa verified, the next limit (0.8 MPa) built
1568
+ # 100 g at SF 13, and four runs ended anywhere from 43 g to 174 g
1569
+ # with the target nudged by 2%. Once one run passes and one fails,
1570
+ # bisect t between them (geometrically), and warm-start only from a
1571
+ # passing design, never from a collapsed one.
1572
+ # With the tet cross-check (goal.confirm), each run is read on tets
1573
+ # and t is steered by that reading: the voxel one left the wall
1574
+ # brackets at SF 2.7-2.8 on voxels and 3.6 on tets for 2.5.
1575
+ tets = goal.safety_factor is not None and _confirm_on_tets(study, outcomes)
1576
+ # Buckling (goal.buckling): the optimizer holds the design's smallest
1577
+ # buckling load factor at sf * kb / t (bucklingopt.py), so t relaxes
1578
+ # or tightens stress and stability together and the bisection below
1579
+ # stays one-dimensional. kb is the optimizer's reading over the
1580
+ # verified part's (the grey eroded design on the fine grid against
1581
+ # the meshed part on the check's coarser grid), taken from the last
1582
+ # run: the two differ by a factor that holds roughly steady from one
1583
+ # design to the next.
1584
+ sf_b = goal.safety_factor if (goal.buckling and goal.safety_factor and _BUCKLING_IN_OPTIMIZER) else None
1585
+ kb = _BUCKLING_START
1586
+ fails = []
1587
+ t, t_pass, t_fail, x_pass = 1.0, None, None, None
1588
+ for _ in range(_MAX_CONSTRAINED_RUNS):
1589
+ if should_stop is not None and should_stop():
1590
+ break
1591
+ limits = Limits(stress * t if stress is not None else None,
1592
+ goal.max_deflection * t if goal.max_deflection is not None else None, mat.E,
1593
+ buckling=sf_b * kb / t if sf_b is not None else None)
1594
+ try:
1595
+ o = run_constrained(limits, x_pass)
1596
+ except (RuntimeError, ValueError) as exc:
1597
+ # (ValueError: the design let go of a load's region, which
1598
+ # then touches no material in the verify)
1599
+ # a run whose solve broke down (the design let go of a
1600
+ # support: GRIP's yoke, wrist link and upper arm) is a
1601
+ # failed run; one that fails first ends the constrained
1602
+ # attempt, and the search below takes over
1603
+ fails.append(f"t {t:.3g}: {exc}")
1604
+ if not any(q.optimizer.get("method") == "constrained" for q in outcomes):
1605
+ break
1606
+ t_fail = t if t_fail is None else min(t_fail, t)
1607
+ t = (t_pass * t_fail) ** 0.5 if t_pass is not None else 0.5 * t
1608
+ continue
1609
+ outcomes.append(o)
1610
+ if tets and o.voxel_sf and o.report.get("bodies", 1) <= 1 and tet_confirm(o) is None:
1611
+ tets = False # the mesher failed: voxels from here on
1612
+ r = o.report
1613
+ rs = r["safetyFactor"] / goal.safety_factor if goal.safety_factor else None
1614
+ rd = goal.max_deflection / max(c["deflection"] for c in r["cases"]) if goal.max_deflection else None
1615
+ rb = None
1616
+ if sf_b is not None and r.get("bucklingFactor") is not None:
1617
+ rb = r["bucklingFactor"] / sf_b
1618
+ lo = o.optimizer.get("bucklingFactor")
1619
+ if lo is not None and np.isfinite(lo) and lo > 0:
1620
+ kb = float(np.clip(lo / r["bucklingFactor"], 0.5 * kb, 2.0 * kb))
1621
+ ratio = min(v for v in (rs, rd, rb) if v is not None)
1622
+ if ratio >= 1.0:
1623
+ if ratio < 1.12:
1624
+ break
1625
+ if t_pass is None or t > t_pass:
1626
+ t_pass, x_pass = t, o.x
1627
+ elif t_fail is None or t < t_fail:
1628
+ t_fail = t
1629
+ if t_pass is not None and t_fail is not None:
1630
+ if t_fail / t_pass < 1.15:
1631
+ break
1632
+ t = (t_pass * t_fail) ** 0.5
1633
+ elif t_fail is not None:
1634
+ # a stress limit acts roughly in proportion; never more than
1635
+ # 4x a step, so one wild reading cannot overshoot far
1636
+ t *= min(max(ratio, 0.25), 0.9)
1637
+ else:
1638
+ t *= min(ratio ** 0.5, 4.0)
1639
+ passing = [i for i, o in enumerate(outcomes) if o.passes]
1640
+ best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
1641
+ if best is None and fallback:
1642
+ # Search instead, on the search's own grid: the constrained
1643
+ # method runs finer (minimum member 2.5 cells across), and the
1644
+ # search on that grid broke down on the GRIP yoke too.
1645
+ import dataclasses
1646
+
1647
+ why = ("the constrained method found no passing design"
1648
+ + (f" ({'; '.join(fails)})" if fails else "") + ": searched over volume fractions instead")
1649
+ r = _run_single(dataclasses.replace(study, goal=dataclasses.replace(goal, method="search")),
1650
+ on_progress, should_stop)
1651
+ r.warnings.insert(0, why)
1652
+ return r
1653
+ elif goal.safety_factor is not None or goal.max_deflection is not None or _durability_drives(study):
1654
+ vf = float(goal.volume_fraction or 0.3)
1655
+ if memory is not None:
1656
+ vf, why = memory.first_fraction(vf)
1657
+ if why:
1658
+ emit({"stage": "recall", "volumeFraction": vf, "reason": why})
1659
+ lo_fail, hi_pass = None, None # (vf, margin) brackets
1660
+ for _ in range(_MAX_SEARCH_RUNS):
1661
+ if should_stop is not None and should_stop():
1662
+ break
1663
+ o = run_one(vf)
1664
+ outcomes.append(o)
1665
+ m = o.report["margin"]
1666
+ if m >= 1.0 and o.report.get("bodies", 1) > 1:
1667
+ # strong enough but in pieces (a fastener's rings or an
1668
+ # unloaded pad left floating) is a failed outcome: step up
1669
+ # about a third, more material reconnects it. It used to
1670
+ # count as passing and end the search with no passing part.
1671
+ m = 0.8
1672
+ if m >= 1.0:
1673
+ hi_pass = (vf, m) if hi_pass is None or vf < hi_pass[0] else hi_pass
1674
+ else:
1675
+ lo_fail = (vf, m) if lo_fail is None or vf > lo_fail[0] else lo_fail
1676
+ # bracket closed to within 10% of the passing fraction (an
1677
+ # absolute 0.02 was 30% at the low fractions large design spaces
1678
+ # end at: the GRIP upper arm stopped at 0.075 with 0.058 failing,
1679
+ # 222 g where ~0.066 would have passed lighter)
1680
+ if hi_pass and lo_fail and hi_pass[0] - lo_fail[0] < max(0.005, 0.1 * hi_pass[0]):
1681
+ break
1682
+ if hi_pass and 1.0 <= hi_pass[1] < 1.12:
1683
+ break # passing with under 12% to spare: close enough
1684
+ if hi_pass and lo_fail:
1685
+ # Bracketed: bisect. The margin is not smooth in the volume
1686
+ # fraction (a slightly different fraction can settle in a
1687
+ # different layout: the arm link read 0.49 at 30% and 1.30 at
1688
+ # 38%), so interpolating between brackets kept landing on the
1689
+ # wrong side of a jump; halving the bracket always closes it.
1690
+ (v1, m1), (v2, m2) = lo_fail, hi_pass
1691
+ nxt = 0.5 * (v1 + v2)
1692
+ if memory is not None:
1693
+ # any fraction inside the bracket closes it; a stored one is free
1694
+ nxt = memory.snap(nxt, v1, v2, skip=[q.volume_fraction for q in outcomes])
1695
+ else:
1696
+ # Aim a little past the target; never more than halve or
1697
+ # double in one step, where the linear model stops holding.
1698
+ nxt = vf * 1.05 / max(m, 1e-9)
1699
+ nxt = float(np.clip(nxt, vf * 0.5, vf * 2.0))
1700
+ if memory is not None:
1701
+ nxt = memory.snap(nxt, skip=[q.volume_fraction for q in outcomes])
1702
+ nxt = float(np.clip(nxt, _VF_MIN, _VF_MAX))
1703
+ # A step under 0.005 stops an unbracketed search. A bracket is
1704
+ # left to the 10% rule above: its midpoint sits half the gap from
1705
+ # either end, and at low fractions that is under 0.005 while the
1706
+ # gap is still worth closing (GRIP's MJF wrist link passed at 6%,
1707
+ # SF 4.36 for 2, failed at 5.2%, and stopped there at 43.5 g
1708
+ # without trying 5.6%).
1709
+ if abs(nxt - vf) < 0.005 and not (hi_pass and lo_fail):
1710
+ break
1711
+ vf = nxt
1712
+ passing = [i for i, o in enumerate(outcomes) if o.passes]
1713
+ best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
1714
+ voxel_confirm = True
1715
+ if goal.safety_factor is not None and _confirm_on_tets(study, outcomes):
1716
+ # The voxel analysis reads peak stress high against a body-fitted
1717
+ # mesh (24-49% on the motor mount and bell crank), so a pick
1718
+ # that meets the target on voxels carries that much extra
1719
+ # material. Confirm the pick on tetrahedra instead, scale every
1720
+ # voxel safety factor by what that showed (tet / voxel), and let
1721
+ # the search step down to the fraction that meets the target on
1722
+ # the tet mesh; each new pick is confirmed there in turn.
1723
+ voxel_confirm = False
1724
+ k, tet_fail, confirms, extra = 1.0, None, 0, 0
1725
+ while not (should_stop is not None and should_stop()):
1726
+ passing = [i for i, q in enumerate(outcomes) if q.passes
1727
+ and (tet_fail is None or q.volume_fraction > tet_fail)]
1728
+ best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
1729
+ if best is None:
1730
+ # nothing passes: step up from the largest fraction tried
1731
+ top = max(outcomes, key=lambda q: q.volume_fraction)
1732
+ if extra >= _TET_EXTRA_RUNS or top.volume_fraction >= _VF_MAX:
1733
+ break
1734
+ vf = float(np.clip(top.volume_fraction * 1.05 / max(top.report["margin"], 1e-9),
1735
+ top.volume_fraction + 0.01, _VF_MAX))
1736
+ outcomes.append(run_one(vf))
1737
+ calibrate(outcomes[-1], k)
1738
+ extra += 1
1739
+ continue
1740
+ o = outcomes[best]
1741
+ if getattr(o, "tet_k", None) is None:
1742
+ if confirms >= _TET_CONFIRMS:
1743
+ break
1744
+ confirms += 1
1745
+ mt = tet_confirm(o)
1746
+ if mt is None:
1747
+ voxel_confirm = True
1748
+ break
1749
+ if mt < 1.0 and confirms == 1:
1750
+ # The tet mesh reads the voxel search's own pick
1751
+ # below target: the voxel grid missed a peak, so the
1752
+ # calibration has nothing to step down on. Such a
1753
+ # peak has sat in geometry the search cannot change
1754
+ # (GRIP's turret: SF 1.20 on the wall of a bolt's
1755
+ # driver access, 1.3 mm from the bore that holds it,
1756
+ # and three steps up to 0.24 left it at 230 g), so
1757
+ # it is not stepped up on that reading either: the
1758
+ # pick is confirmed on voxels, as without the tet
1759
+ # mesh, and the tet reading goes out as a warning.
1760
+ untet(o)
1761
+ voxel_confirm = True
1762
+ break
1763
+ k = o.tet_k
1764
+ for q in outcomes:
1765
+ calibrate(q, k)
1766
+ if mt < 1.0:
1767
+ tet_fail = max(tet_fail or 0.0, o.volume_fraction)
1768
+ continue # re-pick: the calibration may pass a lighter one
1769
+ # o is confirmed and the lightest that passes: step down
1770
+ # toward the fraction the confirmed margin points at, unless
1771
+ # it is already close, or a failing fraction sits within 10%
1772
+ mt = o.report["margin"]
1773
+ # The fraction just below that failed only on its scaled voxel
1774
+ # reading is confirmed on tets directly before the search
1775
+ # stops on it: the voxel reading can fall off a cliff the tet
1776
+ # mesh does not see (the wall bracket: voxel SF 2.65 at 7.8%
1777
+ # and 0.81 at 7.1%, tets 4.06 at 7.8%), and the ratio of one
1778
+ # design says little about the next.
1779
+ near = [i for i, q in enumerate(outcomes)
1780
+ if getattr(q, "tet_k", None) is None and not q.passes and q.voxel_sf
1781
+ and q.volume_fraction < o.volume_fraction and q.report.get("bodies", 1) <= 1
1782
+ and (tet_fail is None or q.volume_fraction > tet_fail)]
1783
+ if mt >= _TET_CLOSE and near and confirms < _TET_CONFIRMS:
1784
+ q = outcomes[max(near, key=lambda i: outcomes[i].volume_fraction)]
1785
+ confirms += 1
1786
+ mq = tet_confirm(q)
1787
+ if mq is None or mq < 1.0:
1788
+ if mq is None:
1789
+ q.tet_k = 0.0 # do not try it again
1790
+ q.passes = False
1791
+ tet_fail = max(tet_fail or 0.0, q.volume_fraction)
1792
+ continue
1793
+ lo = max((q.volume_fraction for q in outcomes
1794
+ if not q.passes and q.volume_fraction < o.volume_fraction), default=None)
1795
+ if mt < _TET_CLOSE or extra >= _TET_EXTRA_RUNS or \
1796
+ (lo is not None and o.volume_fraction - lo < max(0.005, 0.1 * o.volume_fraction)):
1797
+ break
1798
+ nxt = max(o.volume_fraction * 1.03 / mt, 0.5 * o.volume_fraction)
1799
+ if lo is not None:
1800
+ nxt = max(nxt, 0.5 * (lo + o.volume_fraction))
1801
+ nxt = float(np.clip(nxt, _VF_MIN, _VF_MAX))
1802
+ # already tried there; inside a bracket the bracket sets the
1803
+ # scale (the bell crank confirmed 7.03%, SF 4.85 for 3, with
1804
+ # 6.25% failing at 2.93, and its midpoint 6.64% sat under
1805
+ # 0.005 from 6.25%, so nothing in between was tried)
1806
+ tol = 0.005 if lo is None else min(0.005, 0.25 * (o.volume_fraction - lo))
1807
+ if any(abs(nxt - q.volume_fraction) < tol for q in outcomes):
1808
+ break
1809
+ outcomes.append(run_one(nxt))
1810
+ calibrate(outcomes[-1], k)
1811
+ extra += 1
1812
+ if not voxel_confirm and not (should_stop is not None and should_stop()):
1813
+ layout_search(k)
1814
+ confirmed_ok = [i for i, q in enumerate(outcomes) if getattr(q, "tet_k", None) is not None and q.passes]
1815
+ if not voxel_confirm:
1816
+ # deliver only what the tet mesh confirmed; the rest keep
1817
+ # their calibrated reading, for the record
1818
+ for i, q in enumerate(outcomes):
1819
+ if i not in confirmed_ok:
1820
+ q.passes = False
1821
+ best = min(confirmed_ok, key=lambda i: outcomes[i].report["mass"]) if confirmed_ok else None
1822
+ else:
1823
+ for q in outcomes:
1824
+ if getattr(q, "tet_k", None) is None:
1825
+ calibrate(q, 1.0)
1826
+ passing = [i for i, q in enumerate(outcomes) if q.passes]
1827
+ best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
1828
+ # Confirm the pick on a finer grid; if it fails there, it no longer
1829
+ # passes, and the search steps up from it by the confirmed margin.
1830
+ # Once a pick confirms, bisect back down into the gap to the fraction
1831
+ # that failed confirmation while that gap is over 10%: the step up
1832
+ # overshoots (GRIP's wrist yoke failed at 0.080 and confirmed at
1833
+ # 0.102, 27.5 g, with SF 2 near 0.09).
1834
+ confirmed, conf_fail = set(), None
1835
+ for _ in range(2 * _CONFIRM_RUNS if voxel_confirm else 0):
1836
+ if best is None or (should_stop is not None and should_stop()):
1837
+ break
1838
+ o = outcomes[best]
1839
+ if best not in confirmed:
1840
+ emit({"stage": "confirm", "volumeFraction": o.volume_fraction})
1841
+ mc = _confirm(o)
1842
+ emit({"stage": "confirmed", "volumeFraction": o.volume_fraction, "margin": mc})
1843
+ if mc < 1.0:
1844
+ o.passes = False
1845
+ conf_fail = max(conf_fail or 0.0, o.volume_fraction)
1846
+ up = [i for i in confirmed if outcomes[i].volume_fraction > o.volume_fraction]
1847
+ if up:
1848
+ best = min(up, key=lambda i: outcomes[i].report["mass"])
1849
+ continue
1850
+ vf = float(np.clip(o.volume_fraction * 1.05 / max(mc, 1e-9), o.volume_fraction + 0.01, _VF_MAX))
1851
+ if any(abs(vf - q.volume_fraction) < 0.005 for q in outcomes):
1852
+ vf = min(vf + 0.02, _VF_MAX)
1853
+ if memory is not None:
1854
+ vf = memory.snap(vf, skip=[q.volume_fraction for q in outcomes])
1855
+ outcomes.append(run_one(vf))
1856
+ passing = [i for i, q in enumerate(outcomes) if q.passes]
1857
+ best = min(passing, key=lambda i: outcomes[i].report["mass"]) if passing else None
1858
+ continue
1859
+ confirmed.add(best)
1860
+ if conf_fail is None or o.volume_fraction - conf_fail < max(0.005, 0.1 * o.volume_fraction):
1861
+ break
1862
+ q = run_one(0.5 * (conf_fail + o.volume_fraction))
1863
+ outcomes.append(q)
1864
+ if q.passes:
1865
+ best = len(outcomes) - 1
1866
+ else:
1867
+ conf_fail = q.volume_fraction
1868
+ # The voxel-confirmed counterpart of layout_search: a fraction below
1869
+ # the pick that failed warm, carried down from the pick's layout, is
1870
+ # run once more from its own coarse layout. The warm chain can lose
1871
+ # the layout on the way down: GRIP's MJF wrist link (no tet confirm,
1872
+ # a layered process) read SF 1.42 at 5.25% warm from 6% (tets 1.16)
1873
+ # and 2.06 started fresh (tets 4.49), 37.2 g against the 41.0 g pick.
1874
+ # Each fresh design that passes is confirmed on the finer grid like
1875
+ # any pick; one that holds becomes the pick, and the next failed
1876
+ # fraction below it is tried the same way.
1877
+ cold_runs = 0
1878
+ while voxel_confirm and best is not None and best in confirmed and cold_runs < _LAYOUT_RUNS \
1879
+ and not (should_stop is not None and should_stop()):
1880
+ o = outcomes[best]
1881
+ if o.report["margin"] < _TET_CLOSE:
1882
+ break
1883
+ fresh = [q.volume_fraction for q in outcomes if str(q.report.get("start", "")).startswith(
1884
+ ("cold", "layout search: cold"))]
1885
+ lost = [q.volume_fraction for q in outcomes
1886
+ if not q.passes and q.volume_fraction < o.volume_fraction and q.voxel_sf
1887
+ and q.report.get("bodies", 1) <= 1 and str(q.report.get("start", "")).startswith("warm")
1888
+ and q.volume_fraction > o.volume_fraction * 1.03 / max(o.report["margin"], 1e-9)
1889
+ and not any(abs(q.volume_fraction - v) < 0.003 for v in fresh)]
1890
+ if not lost:
1891
+ break
1892
+ vf = max(lost)
1893
+ emit({"stage": "optimize", "volumeFraction": vf, "start": "layout search: cold"})
1894
+ try:
1895
+ if domain.n_elem >= _COARSE_MIN_CELLS:
1896
+ x0 = _coarse_start(study, domain, vf, radius, should_stop)
1897
+ q = attempt(vf, x0, _WARM_BETA if x0 is not None else 1.0)
1898
+ else:
1899
+ q = attempt(vf, None, 1.0)
1900
+ except RuntimeError:
1901
+ break
1902
+ cold_runs += 1
1903
+ q.report["start"] = "layout search: cold"
1904
+ outcomes.append(q)
1905
+ emit({"stage": "outcome", **q.summary()})
1906
+ if not q.passes:
1907
+ break # fresh fails too: the fractions below it would not do better
1908
+ emit({"stage": "confirm", "volumeFraction": q.volume_fraction})
1909
+ mc = _confirm(q)
1910
+ emit({"stage": "confirmed", "volumeFraction": q.volume_fraction, "margin": mc})
1911
+ if mc < 1.0:
1912
+ q.passes = False
1913
+ break
1914
+ best = len(outcomes) - 1
1915
+ confirmed.add(best)
1916
+ else:
1917
+ fractions = [float(goal.volume_fraction)] + [v for v in goal.outcomes if v != goal.volume_fraction]
1918
+ if len(fractions) > 1 and _workers(len(fractions), cells=_study_cells(study)) > 1:
1919
+ import dataclasses
1920
+ singles = [dataclasses.replace(study, goal=dataclasses.replace(goal, volume_fraction=vf, outcomes=[]))
1921
+ for vf in fractions]
1922
+ for r in _parallel(singles, None, should_stop):
1923
+ outcomes.extend(r.outcomes)
1924
+ for o in r.outcomes:
1925
+ emit({"stage": "outcome", **o.summary()})
1926
+ else:
1927
+ for vf in fractions:
1928
+ if should_stop is not None and should_stop():
1929
+ break
1930
+ outcomes.append(run_one(vf))
1931
+ best = 0 if outcomes else None
1932
+
1933
+ if memory is not None:
1934
+ memory.save()
1935
+ info["recall"] = dict(memory.used)
1936
+ return StudyResult(study=study, domain=info, outcomes=outcomes, best=best,
1937
+ seconds=time.time() - t0, warnings=list(domain.warnings))