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/orient.py ADDED
@@ -0,0 +1,334 @@
1
+ """
2
+ Which way up to print it.
3
+
4
+ Orientation decides three things on a layered printer, and they pull
5
+ against each other:
6
+
7
+ strength - FDM layers bond at 50-70% of the in-plane strength, so a part
8
+ whose main stress runs across its layers is a weaker part.
9
+ supports - every overhang past 45 degrees costs support material, time
10
+ and a scarred surface.
11
+ the bed - the first layer has to hold: enough contact, no tipping.
12
+
13
+ Two entry points, for the two moments the choice is made:
14
+
15
+ choose_build_direction(study)
16
+ Before designing. Designs the study for each candidate direction on a
17
+ coarse grid at a fixed volume fraction, each with that direction's
18
+ overhang control, by successive halving (all of them half way and
19
+ checked cheaply, the leaders finished), and keeps the direction whose
20
+ verified safety factor per gram (layered stiffness and interlayer bond
21
+ included) is highest; support volume and height break ties. Because the
22
+ design is grown for its direction it is self-supporting in all of them,
23
+ so this is mostly a strength choice: which way can material carry the
24
+ loads in its layers' plane. A study opts in with
25
+ "process": {"type": "fdm", "buildDirection": "auto"}.
26
+
27
+ rank_orientations(mesh, study=None)
28
+ After designing, or for any mesh. Tries the six axis directions and the
29
+ part's own large flat faces (convex hull facets, the ways it can rest on
30
+ the bed), slices each (printcheck.check_print) and, given the study, reads
31
+ the interlayer tension each direction would see from one stress solve.
32
+ Ranks by: meets the interlayer safety factor, then support volume,
33
+ height and bed risk.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import dataclasses
38
+
39
+ import numpy as np
40
+
41
+ from .am import build_vector, direction_label, parse_build_direction
42
+ from .printcheck import check_print
43
+
44
+ __all__ = ["choose_build_direction", "rank_orientations", "AXIS_DIRECTIONS"]
45
+
46
+ AXIS_DIRECTIONS = [(2, 1), (2, -1), (0, 1), (0, -1), (1, 1), (1, -1)]
47
+ # Coarse comparison budget per direction (cells) and the fraction tried
48
+ # when the goal has none of its own.
49
+ _COMPARE_CELLS = 12_000
50
+ _COMPARE_VF = 0.25
51
+ # Successive halving (choose_build_direction): every direction runs this
52
+ # many iterations and is checked on a grid of _SCREEN_CELLS without
53
+ # hotspot refinement; at most _FINALISTS go on to the full count, and none
54
+ # below _FINALIST_FLOOR of the leader's strength per gram.
55
+ _SCREEN_ITERATIONS = 40
56
+ _SCREEN_CELLS = 15_000
57
+ _FINALISTS = 3
58
+ _FINALIST_FLOOR = 0.35
59
+
60
+
61
+ def _cost(pc: dict) -> float:
62
+ """Print cost of one orientation, dimensionless: support as a fraction of
63
+ the part's volume, height over the part's size (time), bed risks."""
64
+ vol = max(pc["volume"], 1e-9)
65
+ size = vol ** (1.0 / 3.0)
66
+ bed = pc["bed"]
67
+ c = pc["supportVolume"] / vol + 0.15 * pc["height"] / size
68
+ if bed["comMargin"] < 0:
69
+ c += 1.0
70
+ if bed["feet"] and bed["smallestFoot"] < 9.0:
71
+ c += 0.2
72
+ c += 0.1 * max(0.0, bed["aspect"] - 3.0)
73
+ return float(c)
74
+
75
+
76
+ def _stress_nodes(study, mesh, max_cells=60_000):
77
+ """Nodal stress tensors of the delivered part, per load case, from one
78
+ isotropic voxel solve (as verify_part runs it)."""
79
+ from scipy import ndimage
80
+
81
+ from .domain import boundary_conditions, gravity_force
82
+ from .fem import HexMesh, Solver, Stiffness
83
+ from .verify import nodal_stress, voxelize
84
+
85
+ mat = study.material
86
+ vol = float(np.prod(mesh.extents))
87
+ pitch = (vol / max_cells) ** (1.0 / 3.0)
88
+ solid, origin = voxelize(mesh, pitch)
89
+ lbl, _ = ndimage.label(solid, structure=np.ones((3, 3, 3), bool))
90
+ ijk = np.argwhere(solid)
91
+ hm = HexMesh(ijk, pitch, origin)
92
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=pitch)
93
+ keep_lbl = np.unique(lbl[tuple(ijk[bc.support_nodes[hm.enode].any(axis=1)].T)])
94
+ keep = np.isin(lbl[tuple(ijk.T)], keep_lbl)
95
+ if not keep.all():
96
+ hm = HexMesh(ijk[keep], pitch, origin)
97
+ bc = boundary_conditions(study, hm, check_loads=False, tol_floor=pitch)
98
+ st = Stiffness(hm, mat.nu, bc.fixed)
99
+ k = st.assemble(np.full(hm.n_elem, mat.E))
100
+ if bc.springs is not None:
101
+ k = (k + mat.E * bc.springs).tocsr()
102
+ fg = gravity_force(hm, np.ones(hm.n_elem), bc.gravity, bc.fixed) if bc.gravity is not None else 0.0
103
+ us = Solver(hm, rtol=1e-6, maxiter=3000).solve_many(k, [f + fg for f in bc.loads])
104
+ from scipy.spatial import cKDTree
105
+ contact = np.zeros(hm.n_nodes, bool)
106
+ contact |= bc.support_nodes
107
+ for ln in bc.case_load_nodes:
108
+ contact |= ln
109
+ band = 2.0 * pitch
110
+ body = cKDTree(hm.node_xyz[contact]).query(hm.node_xyz, k=1, distance_upper_bound=band)[0] == np.inf
111
+ return [nodal_stress(st, u, mat.E)[body] for u in us]
112
+
113
+
114
+ def _normal_stress(sig, n):
115
+ return (sig[:, :3] @ (n * n)
116
+ + 2.0 * (sig[:, 3] * n[0] * n[1] + sig[:, 4] * n[1] * n[2] + sig[:, 5] * n[2] * n[0]))
117
+
118
+
119
+ def _facet_directions(mesh, count=8, min_area_frac=0.03):
120
+ """Build directions that put a large flat face of the convex hull on the
121
+ bed (up = minus that face's outward normal)."""
122
+ hull = mesh.convex_hull
123
+ normals = hull.face_normals
124
+ areas = hull.area_faces
125
+ # merge coplanar hull triangles by rounded normal
126
+ key = np.round(normals, 3)
127
+ uniq, inv = np.unique(key, axis=0, return_inverse=True)
128
+ tot = np.bincount(inv.ravel(), weights=areas)
129
+ order = np.argsort(-tot)
130
+ out = []
131
+ for i in order[:count]:
132
+ if tot[i] < min_area_frac * hull.area:
133
+ break
134
+ n = uniq[i] / np.linalg.norm(uniq[i])
135
+ out.append(parse_build_direction(list(-n)))
136
+ return out
137
+
138
+
139
+ def _same(a, b):
140
+ return float(build_vector(a) @ build_vector(b)) > 1 - 1e-6
141
+
142
+
143
+ def rank_orientations(mesh, study=None, candidates=None, process: str = "fdm", interlayer: float | None = None,
144
+ safety_factor: float | None = None, pitch: float | None = None,
145
+ stress_cells: int = 60_000) -> list[dict]:
146
+ """Every candidate build direction with its print check, interlayer
147
+ reading and cost, best first. `study` (optional) supplies loads,
148
+ material, interlayer ratio and goal; without it only printability is
149
+ ranked."""
150
+ cands = list(candidates) if candidates is not None else AXIS_DIRECTIONS + _facet_directions(mesh)
151
+ uniq = []
152
+ for c in cands:
153
+ c = parse_build_direction(c) if not isinstance(c, tuple) or len(c) not in (2, 3) else c
154
+ if not any(_same(c, u) for u in uniq):
155
+ uniq.append(c)
156
+ sigs = None
157
+ mat = None
158
+ if study is not None:
159
+ mat = study.material
160
+ proc = study.process
161
+ if proc is not None:
162
+ process = proc.kind
163
+ interlayer = proc.interlayer if interlayer is None else interlayer
164
+ safety_factor = safety_factor or study.goal.safety_factor
165
+ if study.load_cases:
166
+ sigs = _stress_nodes(study, mesh, stress_cells)
167
+ from .verify import STRESS_ALLOWANCE
168
+
169
+ rows = []
170
+ for c in uniq:
171
+ pc = check_print(mesh, c, process=process, pitch=pitch, material=mat)
172
+ row = {"direction": direction_label(c), "build": c, "cost": _cost(pc), "print": pc}
173
+ if sigs is not None and interlayer is not None and interlayer < 1.0:
174
+ n = build_vector(c)
175
+ tens = max(float(np.maximum(_normal_stress(s, n), 0.0).max(initial=0.0)) for s in sigs)
176
+ row["interlayerStress"] = tens
177
+ row["interlayerSafetyFactor"] = (interlayer * mat.strength / (tens * STRESS_ALLOWANCE)
178
+ if tens > 0 else float("inf"))
179
+ rows.append(row)
180
+ sf_ok = [r.get("interlayerSafetyFactor", np.inf) >= (safety_factor or 0.0) for r in rows]
181
+ for r, ok in zip(rows, sf_ok):
182
+ r["meetsInterlayer"] = bool(ok)
183
+ rows.sort(key=lambda r: (not r["meetsInterlayer"], r["cost"],
184
+ -r.get("interlayerSafetyFactor", 0.0)))
185
+ return rows
186
+
187
+
188
+ def _candidate(args):
189
+ """One build direction's design (a process pool worker; module level so
190
+ it pickles): (study, x0, beta0, iterations, verify_cells, refine)."""
191
+ from .domain import build_domain
192
+ from .simp import filter_radius
193
+ from .study import design_at
194
+
195
+ st, x0, beta0, iters, cells, refine = args
196
+ domain = build_domain(st)
197
+ o = design_at(st, domain, filter_radius(domain, st.length_scale, st.min_hole), float(st.goal.volume_fraction),
198
+ x0=x0, beta0=beta0, max_iterations=iters, verify_cells=cells, refine=refine)
199
+ o.xphys = None # not needed back; keep the pickle small
200
+ return o
201
+
202
+
203
+ def _row(c, o, proc, study):
204
+ rep = o.report
205
+ pc = check_print(o.mesh, c, process=proc.kind, material=study.material, bridge_span=proc.bridge_span)
206
+ inter = [cs.get("interlayerStress") for cs in rep.get("cases", []) if cs.get("interlayerStress") is not None]
207
+ return {"direction": direction_label(c), "build": c, "safetyFactor": rep["safetyFactor"],
208
+ "mass": rep["mass"], "interlayerStress": max(inter) if inter else None,
209
+ "supportVolume": pc["supportVolume"], "supportArea": pc["supportArea"],
210
+ "height": pc["height"], "bed": pc["bed"], "cost": _cost(pc),
211
+ "efficiency": rep["safetyFactor"] / max(rep["mass"], 1e-9)}
212
+
213
+
214
+ def _run_all(jobs, workers):
215
+ if workers <= 1 or len(jobs) == 1:
216
+ return [_candidate(j) for j in jobs]
217
+ import multiprocessing as mp
218
+ from concurrent.futures import ProcessPoolExecutor
219
+ with ProcessPoolExecutor(min(workers, len(jobs)), mp_context=mp.get_context("spawn")) as ex:
220
+ return list(ex.map(_candidate, jobs))
221
+
222
+
223
+ def choose_build_direction(study, candidates=None, max_elements: int = _COMPARE_CELLS, on_progress=None,
224
+ exhaustive: bool = False) -> dict:
225
+ """Compare build directions for `study` on a coarse grid (see the module
226
+ note). Returns {"study": the study with the winner set as its build
227
+ direction, "chosen": label, "candidates": [{direction, safetyFactor,
228
+ mass, supportVolume, ...}]}. The ranking is stored on the returned study
229
+ too (study.raw["orientation"]).
230
+
231
+ With overhang control, by successive halving: every direction is
232
+ designed for _SCREEN_ITERATIONS and checked cheaply (a coarser analysis
233
+ grid, no hotspot refinement); only the best few by strength per gram
234
+ (_FINALISTS) are carried on from where they stopped to the full count
235
+ and verified in full. On the PETG arm link at 12k cells the half-way
236
+ reading put the full search's winner (-x, SF 10.3 against 8.8 for the
237
+ runner-up) first by a factor of two; the full search, every direction
238
+ to 80 iterations, took 44 minutes on 4 cores. (Two cheaper screens did
239
+ not hold up: stress read off one shared unconstrained design ranked -x
240
+ among the worst, since the overhang filter reshapes each direction's
241
+ part; and a grid of an eighth of the cells lost the 4 mm bearing ring.)
242
+
243
+ Without overhang control (and with isotropic design stiffness) every
244
+ direction's design is the same one, so it is designed once and verified
245
+ once per build axis (opposite directions load the layers alike).
246
+
247
+ `exhaustive=True` runs every direction to the full count."""
248
+ from .domain import build_domain
249
+ from .simp import filter_radius
250
+ from .spec import Goal
251
+ from .study import _COARSE_MIN_CELLS, _WARM_BETA, _coarse_start, _workers, design_at
252
+
253
+ proc = study.process
254
+ if proc is None:
255
+ raise ValueError("choose_build_direction needs a layered process (\"process\": {\"type\": \"fdm\"})")
256
+ cands = [parse_build_direction(c) if not isinstance(c, tuple) else c for c in (candidates or AXIS_DIRECTIONS)]
257
+ vf = float(study.goal.volume_fraction or _COMPARE_VF)
258
+
259
+ def variant(c, coarse=True):
260
+ p = dataclasses.replace(proc, build=c)
261
+ kw = dict(process=p, print_direction=c if proc.overhangs else study.print_direction)
262
+ if coarse:
263
+ kw.update(goal=Goal(volume_fraction=vf), materials=[study.material],
264
+ max_elements=min(study.max_elements, max_elements), pitch=None,
265
+ max_iterations=min(study.max_iterations, 80))
266
+ return dataclasses.replace(study, **kw)
267
+
268
+ def emit(ev):
269
+ if on_progress is not None:
270
+ on_progress({"stage": "orient", **ev}, None)
271
+
272
+ emit({"candidates": [direction_label(c) for c in cands]})
273
+ rows = []
274
+ if not proc.overhangs and not proc.anisotropic:
275
+ # one design serves every direction: verify it once per build axis
276
+ # and print-check each
277
+ from .verify import verify_part
278
+
279
+ st0 = variant(cands[0])
280
+ domain = build_domain(st0)
281
+ radius = filter_radius(domain, st0.length_scale, st0.min_hole)
282
+ big = domain.n_elem >= _COARSE_MIN_CELLS
283
+ x0 = _coarse_start(st0, domain, vf, radius, None) if big else None
284
+ shared = design_at(st0, domain, radius, vf, x0=x0, beta0=_WARM_BETA if big else 1.0, verify=False)
285
+ by_axis = {}
286
+ for c in cands:
287
+ key = tuple(np.round(np.abs(build_vector(c)), 6))
288
+ if key not in by_axis:
289
+ by_axis[key] = verify_part(variant(c), shared.mesh, min_pitch=0.5 * domain.pitch,
290
+ contact_band=domain.pitch)
291
+ rows.append(_row(c, dataclasses.replace(shared, report=by_axis[key]), proc, study) | {"designed": True})
292
+ else:
293
+ full = min(study.max_iterations, 80)
294
+ workers = _workers(len(cands), cells=min(study.max_elements, max_elements))
295
+ if exhaustive or full <= _SCREEN_ITERATIONS:
296
+ outs = _run_all([(variant(c), None, 1.0, full, 60_000, 3) for c in cands], workers)
297
+ rows = [_row(c, o, proc, study) | {"designed": True} for c, o in zip(cands, outs)]
298
+ else:
299
+ first = _run_all([(variant(c), None, 1.0, _SCREEN_ITERATIONS, _SCREEN_CELLS, 0) for c in cands],
300
+ workers)
301
+ screen = [_row(c, o, proc, study) for c, o in zip(cands, first)]
302
+ emit({"screen": [(r["direction"], round(r["safetyFactor"], 2)) for r in screen]})
303
+ lead = max(r["efficiency"] for r in screen)
304
+ order = sorted(range(len(cands)), key=lambda i: -screen[i]["efficiency"])
305
+ go = [i for i in order[:_FINALISTS] if screen[i]["efficiency"] >= _FINALIST_FLOOR * lead]
306
+ # carry each finalist on from where it stopped: its design and
307
+ # its projection sharpness
308
+ jobs = [(variant(cands[i]), first[i].x, first[i].optimizer.get("beta", _WARM_BETA),
309
+ full - _SCREEN_ITERATIONS, 60_000, 3) for i in go]
310
+ final = dict(zip(go, _run_all(jobs, workers)))
311
+ for i, c in enumerate(cands):
312
+ half = {"screenSafetyFactor": screen[i]["safetyFactor"], "screenMass": screen[i]["mass"]}
313
+ if i in final:
314
+ rows.append(_row(c, final[i], proc, study) | {"designed": True} | half)
315
+ else:
316
+ rows.append({k: v for k, v in screen[i].items() if k not in ("safetyFactor", "efficiency")}
317
+ | {"designed": False, "safetyFactor": None} | half)
318
+ designed = [r for r in rows if r["designed"]]
319
+ if not designed:
320
+ raise RuntimeError("no build direction produced a design")
321
+ # strength per gram decides; within 5% of the best, print cost does
322
+ top = max(r["efficiency"] for r in designed)
323
+ designed.sort(key=lambda r: (r["efficiency"] < 0.95 * top, r["cost"], -r["efficiency"]))
324
+ dropped = sorted((r for r in rows if not r["designed"]), key=lambda r: -r["screenSafetyFactor"])
325
+ rows = designed + dropped
326
+ best = rows[0]
327
+ chosen = variant(best["build"], coarse=False)
328
+ raw = dict(chosen.raw)
329
+ raw["orientation"] = [{k: v for k, v in r.items() if k != "build"} for r in rows]
330
+ chosen = dataclasses.replace(chosen, raw=raw)
331
+ emit({"chosen": best["direction"],
332
+ "ranking": [(r["direction"], round(r["safetyFactor"], 2)) for r in designed],
333
+ "dropped": [r["direction"] for r in dropped]})
334
+ return {"study": chosen, "chosen": best["direction"], "candidates": raw["orientation"]}