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/directions.py ADDED
@@ -0,0 +1,262 @@
1
+ """
2
+ Loads whose direction is not known: a force of known size that may point
3
+ anywhere ("direction": "any"), or anywhere within a cone round its nominal
4
+ direction ("cone": degrees).
5
+
6
+ Compliance is quadratic in the force, so for f(d) = f0 + sum_i d_i b_i (f0
7
+ the load case's fixed loads, b_i the uncertain force pointing along axis i,
8
+ d a unit vector) it is
9
+
10
+ c(d) = c00 + 2 g.d + d' A d, c00 = f0.u0, g_i = b_i.u0, A_ij = b_i.u_j
11
+
12
+ with u0, u_i the displacements under f0 and b_i. Four solves (three when
13
+ nothing else loads the case) give the compliance in every direction, and
14
+ the worst one is a 3x3 problem: with no fixed load it is the largest
15
+ eigenvalue of A, exactly. By the envelope theorem the worst case's
16
+ sensitivity is the ordinary compliance sensitivity of the displacement
17
+ under the worst force, u0 + sum_i d_i u_i, so the optimizer's update is
18
+ unchanged (robust compliance, Ben-Tal and Nemirovski's worst case over an
19
+ ellipsoid of loads, here the sphere or a cap of it).
20
+
21
+ A von Mises stress at a point is the same shape (a quadratic in d through
22
+ the six stress components), which is how verification finds the worst
23
+ direction for stress.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass
28
+
29
+ import numpy as np
30
+
31
+ __all__ = ["Spread", "Worst", "worst_direction", "expand", "collapse", "fibonacci_sphere", "worst_stress_direction"]
32
+
33
+
34
+ @dataclass
35
+ class Spread:
36
+ """One load case's uncertain force, on a mesh's dofs."""
37
+ base: np.ndarray # the case's fixed loads (the rest of the case)
38
+ basis: list # 3 force vectors: the uncertain force along x, y, z
39
+ nominal: np.ndarray # unit vector: the force as written
40
+ cos_half: float = -1.0 # cone: cos of the half-angle round `nominal`; -1 is any direction
41
+
42
+ @property
43
+ def kind(self) -> str:
44
+ return "any" if self.cos_half <= -1.0 else "cone"
45
+
46
+ def load(self, d) -> np.ndarray:
47
+ d = np.asarray(d, float)
48
+ return self.base + d[0] * self.basis[0] + d[1] * self.basis[1] + d[2] * self.basis[2]
49
+
50
+ def with_base(self, extra) -> "Spread":
51
+ return Spread(self.base + extra, self.basis, self.nominal, self.cos_half)
52
+
53
+
54
+ def fibonacci_sphere(n: int) -> np.ndarray:
55
+ i = np.arange(n) + 0.5
56
+ z = 1.0 - 2.0 * i / n
57
+ r = np.sqrt(np.maximum(0.0, 1.0 - z * z))
58
+ t = np.pi * (1.0 + 5 ** 0.5) * i
59
+ return np.column_stack([r * np.cos(t), r * np.sin(t), z])
60
+
61
+
62
+ _SAMPLES = fibonacci_sphere(600)
63
+
64
+
65
+ def _to_cap(d, axis, cos_half):
66
+ """Nearest point of the cap {|d| = 1, d.axis >= cos_half} to unit d."""
67
+ if axis is None or cos_half <= -1.0:
68
+ return d
69
+ c = d @ axis
70
+ if c >= cos_half:
71
+ return d
72
+ perp = d - c * axis
73
+ n = np.linalg.norm(perp)
74
+ if n < 1e-12:
75
+ perp = np.cross(axis, [1.0, 0, 0] if abs(axis[0]) < 0.9 else [0, 1.0, 0])
76
+ n = np.linalg.norm(perp)
77
+ return cos_half * axis + np.sqrt(max(0.0, 1.0 - cos_half ** 2)) * perp / n
78
+
79
+
80
+ def worst_direction(c00: float, g, a, axis=None, cos_half: float = -1.0):
81
+ """Maximize c00 + 2 g.d + d'Ad over unit d (within the cap round `axis`
82
+ when cos_half > -1). Returns (d, value)."""
83
+ g = np.asarray(g, float)
84
+ a = 0.5 * (np.asarray(a, float) + np.asarray(a, float).T)
85
+
86
+ def q(d):
87
+ return float(c00 + 2.0 * g @ d + d @ a @ d)
88
+
89
+ capped = axis is not None and cos_half > -1.0
90
+ lam, vec = np.linalg.eigh(a)
91
+ gn = float(np.linalg.norm(g))
92
+ if not capped and gn <= 1e-12 * max(abs(lam[-1]), 1e-300):
93
+ d = vec[:, -1]
94
+ return d, q(d)
95
+ # Candidates: the eigenvectors, a dense spread of directions, and the
96
+ # nominal axis; then a monotone ascent from the best of them. The
97
+ # quadratic on a sphere has at most a couple of local maxima, which 600
98
+ # samples (7 degrees apart) separate.
99
+ cands = np.vstack([vec.T, -vec.T, _SAMPLES] + ([axis[None]] if capped else []))
100
+ if capped:
101
+ cands = np.array([_to_cap(c, axis, cos_half) for c in cands])
102
+ vals = c00 + 2.0 * cands @ g + np.einsum("ij,jk,ik->i", cands, a, cands)
103
+ d = cands[int(np.argmax(vals))]
104
+ # minorize-maximize: d <- normalize((A + sI) d + g) never lowers q on the
105
+ # sphere for s >= -lambda_min (a shifted power step)
106
+ s = max(0.0, -lam[0]) + 1e-12 * max(abs(lam[-1]), 1.0)
107
+ best, bq = d, q(d)
108
+ for _ in range(500):
109
+ n = (a + s * np.eye(3)) @ d + g
110
+ nn = np.linalg.norm(n)
111
+ if nn < 1e-300:
112
+ break
113
+ dn = _to_cap(n / nn, axis, cos_half) if capped else n / nn
114
+ if np.linalg.norm(dn - d) < 1e-12:
115
+ d = dn
116
+ break
117
+ d = dn
118
+ qd = q(d)
119
+ if qd > bq:
120
+ best, bq = d, qd
121
+ return best, bq
122
+
123
+
124
+ def expand(loads, spreads, extra=None):
125
+ """The right-hand sides to solve for every case, and per case the
126
+ indices of its fixed part and basis among them (None for a zero fixed
127
+ part, which needs no solve). `extra` (self-weight) adds to every case's
128
+ fixed part."""
129
+ rhs, layout = [], []
130
+ for ci, f in enumerate(loads):
131
+ s = spreads[ci] if spreads else None
132
+ if s is None:
133
+ layout.append((len(rhs),))
134
+ rhs.append(f + extra if extra is not None else f)
135
+ continue
136
+ base = s.base + extra if extra is not None else s.base
137
+ if np.any(base):
138
+ i0 = len(rhs)
139
+ rhs.append(base)
140
+ else:
141
+ i0 = None
142
+ layout.append((i0, len(rhs), len(rhs) + 1, len(rhs) + 2))
143
+ rhs.extend(s.basis)
144
+ return rhs, layout
145
+
146
+
147
+ @dataclass
148
+ class Worst:
149
+ """One load case judged in its worst direction."""
150
+ u: np.ndarray # displacement under the worst force
151
+ compliance: float # f.u there, the exact worst case
152
+ direction: np.ndarray | None
153
+ force: np.ndarray
154
+ # the optimizer's sensitivity: sum of w * strain energy of v over
155
+ # (w, v), and the mean displacement under the same weights (self-weight)
156
+ energy: list
157
+ u_mean: np.ndarray
158
+
159
+
160
+ # Smoothing of the worst case for the optimizer. The exact worst direction
161
+ # jumps when two directions tie (a square section is as weak sideways as
162
+ # down), and following it alone the design swings between them. The
163
+ # optimizer instead
164
+ # follows a soft maximum over directions, each weighted by
165
+ # exp(SMOOTH * (c - c_max) / c_max): directions within a few percent of the
166
+ # worst share the gradient, as for a repeated eigenvalue. (With the
167
+ # optimizer's sensitivity damping, simp.optimize, 10 and 40 land within 3%.)
168
+ SMOOTH = 40.0
169
+
170
+
171
+ def collapse(rhs, layout, us, spreads, smooth: float = SMOOTH):
172
+ """Per case, a Worst: the exact worst direction and its compliance, and
173
+ the smoothed sensitivity terms."""
174
+ out = []
175
+ for ci, idx in enumerate(layout):
176
+ if len(idx) == 1:
177
+ u, f = us[idx[0]], rhs[idx[0]]
178
+ out.append(Worst(u, float(f @ u), None, f, [(1.0, u)], u))
179
+ continue
180
+ s = spreads[ci]
181
+ i0, ib = idx[0], idx[1:]
182
+ ub = [us[i] for i in ib]
183
+ bb = [rhs[i] for i in ib]
184
+ a = np.array([[bb[i] @ ub[j] for j in range(3)] for i in range(3)])
185
+ a = 0.5 * (a + a.T)
186
+ if i0 is None:
187
+ c00, g, u0, f0 = 0.0, np.zeros(3), np.zeros_like(ub[0]), 0.0
188
+ else:
189
+ u0, f0 = us[i0], rhs[i0]
190
+ c00 = float(f0 @ u0)
191
+ g = np.array([f0 @ ub[j] for j in range(3)])
192
+ d, c = worst_direction(c00, g, a, s.nominal, s.cos_half)
193
+ u = u0 + d[0] * ub[0] + d[1] * ub[1] + d[2] * ub[2]
194
+ f = f0 + d[0] * bb[0] + d[1] * bb[1] + d[2] * bb[2]
195
+ # soft maximum over a spread of directions (and the worst itself):
196
+ # sum_k w_k (1, d_k)(1, d_k)' = W, then strain energy of U q for W's
197
+ # eigenpairs (mu, q), U = [u0, u1, u2, u3]
198
+ dirs = np.vstack([_cap_samples(s), d[None]])
199
+ vals = c00 + 2.0 * dirs @ g + np.einsum("ij,jk,ik->i", dirs, a, dirs)
200
+ w = np.exp(smooth * (vals - c) / max(abs(c), 1e-300))
201
+ w /= w.sum()
202
+ dd = np.column_stack([np.ones(len(dirs)), dirs])
203
+ wm = (dd * w[:, None]).T @ dd
204
+ mu, q = np.linalg.eigh(wm)
205
+ uu = [u0] + ub
206
+ energy = [(float(m), sum(q[k, j] * uu[k] for k in range(4)))
207
+ for j, m in enumerate(mu) if m > 1e-9 * mu[-1]]
208
+ mean = dd.T @ w
209
+ u_mean = sum(mean[k] * uu[k] for k in range(4))
210
+ out.append(Worst(u, c, d, f, energy, u_mean))
211
+ return out
212
+
213
+
214
+ # von Mises squared as a quadratic form on (sx, sy, sz, txy, tyz, tzx)
215
+ _VM = np.array([[1, -.5, -.5, 0, 0, 0], [-.5, 1, -.5, 0, 0, 0], [-.5, -.5, 1, 0, 0, 0],
216
+ [0, 0, 0, 3, 0, 0], [0, 0, 0, 0, 3, 0], [0, 0, 0, 0, 0, 3]], float)
217
+
218
+
219
+ def _cap_samples(spread: Spread, n: int = 600) -> np.ndarray:
220
+ if spread.cos_half <= -1.0:
221
+ return fibonacci_sphere(n)
222
+ # a Fibonacci spread over the cap only: z uniform on [cos_half, 1]
223
+ i = np.arange(n) + 0.5
224
+ z = 1.0 - (1.0 - spread.cos_half) * i / n
225
+ r = np.sqrt(np.maximum(0.0, 1.0 - z * z))
226
+ t = np.pi * (1.0 + 5 ** 0.5) * i
227
+ local = np.column_stack([r * np.cos(t), r * np.sin(t), z])
228
+ a = spread.nominal
229
+ e1 = np.cross(a, [1.0, 0, 0] if abs(a[0]) < 0.9 else [0, 1.0, 0])
230
+ e1 /= np.linalg.norm(e1)
231
+ e2 = np.cross(a, e1)
232
+ return np.vstack([local @ np.array([e1, e2, a]), a[None]])
233
+
234
+
235
+ def worst_stress_direction(spread: Spread, s0, sb, mask, top: int = 40):
236
+ """The direction of the uncertain force that raises the peak von Mises
237
+ over the nodes in `mask` most. s0: nodal stress (n, 6) under the case's
238
+ fixed loads (or None); sb: the three (n, 6) stresses under the basis.
239
+ Samples directions for the peak, then solves the exact 3x3 problem at
240
+ the nodes that came out on top. Returns (d, peak von Mises)."""
241
+ idx = np.nonzero(mask)[0]
242
+ if idx.size == 0:
243
+ idx = np.arange(sb[0].shape[0])
244
+ s = np.stack([b[idx] for b in sb], axis=1) # (m, 3, 6)
245
+ z = s0[idx] if s0 is not None else np.zeros((idx.size, 6))
246
+ dirs = _cap_samples(spread)
247
+ best = np.zeros(idx.size)
248
+ for k in range(0, len(dirs), 32):
249
+ sig = z[:, None, :] + np.einsum("kd,mdj->mkj", dirs[k:k + 32], s)
250
+ vm2 = np.einsum("mkj,jl,mkl->mk", sig, _VM, sig)
251
+ best = np.maximum(best, vm2.max(axis=1))
252
+ order = np.argsort(best)[::-1][:top]
253
+ bd, bv = spread.nominal, -1.0
254
+ for m in order:
255
+ sq = s[m] @ _VM # (3, 6)
256
+ a = sq @ s[m].T
257
+ g = sq @ z[m]
258
+ c00 = float(z[m] @ _VM @ z[m])
259
+ d, v = worst_direction(c00, g, a, spread.nominal, spread.cos_half)
260
+ if v > bv:
261
+ bd, bv = d, v
262
+ return bd, float(np.sqrt(max(bv, 0.0)))