loadpath 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. loadpath/README.md +294 -0
  2. loadpath/SKILL.md +584 -0
  3. loadpath/__init__.py +17 -0
  4. loadpath/__main__.py +1202 -0
  5. loadpath/am.py +316 -0
  6. loadpath/amgfast.py +326 -0
  7. loadpath/assembly.py +985 -0
  8. loadpath/benchmarks/__init__.py +0 -0
  9. loadpath/benchmarks/beams.py +266 -0
  10. loadpath/benchmarks/cases/ge-bracket.json +380 -0
  11. loadpath/benchmarks/evaluate.py +172 -0
  12. loadpath/benchmarks/mechanisms.py +416 -0
  13. loadpath/benchmarks/problems.py +140 -0
  14. loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
  15. loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
  16. loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
  17. loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
  18. loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
  19. loadpath/benchmarks/reference.py +138 -0
  20. loadpath/benchmarks/run.py +301 -0
  21. loadpath/brep.py +1470 -0
  22. loadpath/bucklingopt.py +300 -0
  23. loadpath/buildsim.py +1490 -0
  24. loadpath/cadimport.py +904 -0
  25. loadpath/calibration.py +885 -0
  26. loadpath/cavities.py +211 -0
  27. loadpath/checks/__init__.py +0 -0
  28. loadpath/checks/dfm.py +354 -0
  29. loadpath/checks/exchanger.py +7 -0
  30. loadpath/checks/fea.py +212 -0
  31. loadpath/checks/fit.py +475 -0
  32. loadpath/checks/networks.py +260 -0
  33. loadpath/checks/opening.py +67 -0
  34. loadpath/checks/sdf_kit.py +7 -0
  35. loadpath/conjugate.py +267 -0
  36. loadpath/constrained.py +329 -0
  37. loadpath/contact.py +691 -0
  38. loadpath/coopt.py +342 -0
  39. loadpath/cores.py +163 -0
  40. loadpath/crease.py +1138 -0
  41. loadpath/critic.py +212 -0
  42. loadpath/crosscheck.py +1107 -0
  43. loadpath/cutcell.py +510 -0
  44. loadpath/data/lattice_buckling.json +439 -0
  45. loadpath/data/lattice_tensors.json +27449 -0
  46. loadpath/data/published_experiments.json +71 -0
  47. loadpath/decimate.py +177 -0
  48. loadpath/delivery.py +190 -0
  49. loadpath/directions.py +262 -0
  50. loadpath/domain.py +630 -0
  51. loadpath/drains.py +615 -0
  52. loadpath/durability.py +413 -0
  53. loadpath/editor.html +3135 -0
  54. loadpath/editor.py +610 -0
  55. loadpath/escape.py +354 -0
  56. loadpath/evals/__init__.py +0 -0
  57. loadpath/evals/baselines/coarse.json +1543 -0
  58. loadpath/evals/baselines/full.json +1497 -0
  59. loadpath/evals/brief_kinds/__init__.py +1 -0
  60. loadpath/evals/brief_kinds/heatx.py +387 -0
  61. loadpath/evals/brief_kinds/lattice.py +571 -0
  62. loadpath/evals/brief_kinds/multimat.py +593 -0
  63. loadpath/evals/briefs/assets/bracket.step +1201 -0
  64. loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
  65. loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
  66. loadpath/evals/briefs/assets/motor-body.stl +0 -0
  67. loadpath/evals/briefs/assets/sketch.png +0 -0
  68. loadpath/evals/briefs/base-adapter-fdm.json +492 -0
  69. loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
  70. loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
  71. loadpath/evals/briefs/camera-boom-mjf.json +339 -0
  72. loadpath/evals/briefs/camera-mount-pla.json +203 -0
  73. loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
  74. loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
  75. loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
  76. loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
  77. loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
  78. loadpath/evals/briefs/forearm-fdm.json +146 -0
  79. loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
  80. loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
  81. loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
  82. loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
  83. loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
  84. loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
  85. loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
  86. loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
  87. loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
  88. loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
  89. loadpath/evals/briefs/lighten-step-bracket.json +220 -0
  90. loadpath/evals/briefs/nema17-mount-stl.json +429 -0
  91. loadpath/evals/briefs/pillow-block-sls.json +220 -0
  92. loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
  93. loadpath/evals/briefs/rod-hook-pla.json +154 -0
  94. loadpath/evals/briefs/sensor-arm-resin.json +150 -0
  95. loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
  96. loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
  97. loadpath/evals/briefs/sketch-clevis.json +229 -0
  98. loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
  99. loadpath/evals/briefs/tool-flange-ti64.json +280 -0
  100. loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
  101. loadpath/evals/briefs/turret-fdm.json +281 -0
  102. loadpath/evals/briefs/upper-arm-sls.json +179 -0
  103. loadpath/evals/briefs/urdf-forearm.json +233 -0
  104. loadpath/evals/briefs/wrist-link-mjf.json +278 -0
  105. loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
  106. loadpath/evals/briefs.py +393 -0
  107. loadpath/evals/cases/arm-link-cast.json +157 -0
  108. loadpath/evals/cases/arm-link-driven.json +171 -0
  109. loadpath/evals/cases/arm-link-sandwich.json +315 -0
  110. loadpath/evals/cases/arm-link.json +111 -0
  111. loadpath/evals/cases/bell-crank.json +116 -0
  112. loadpath/evals/cases/grip-turret.json +479 -0
  113. loadpath/evals/cases/grip-upper-arm.json +253 -0
  114. loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
  115. loadpath/evals/cases/grip-wrist-yoke.json +831 -0
  116. loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
  117. loadpath/evals/cases/mbb-beam.json +61 -0
  118. loadpath/evals/cases/motor-body.stl +0 -0
  119. loadpath/evals/cases/motor-mount.json +265 -0
  120. loadpath/evals/cases/printed-hook.json +77 -0
  121. loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
  122. loadpath/evals/cases/wall-bracket.json +126 -0
  123. loadpath/evals/corpus.py +179 -0
  124. loadpath/evals/pareto.py +90 -0
  125. loadpath/evals/run.py +860 -0
  126. loadpath/examples/arm-link.json +25 -0
  127. loadpath/examples/bumpered-bracket-compression.json +225 -0
  128. loadpath/examples/bumpered-bracket-lattice.json +204 -0
  129. loadpath/examples/bumpered-bracket.json +34 -0
  130. loadpath/examples/gripper-finger-mm.json +185 -0
  131. loadpath/examples/heat-exchanger-counterflow.json +21 -0
  132. loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
  133. loadpath/examples/heat-exchanger-crossflow.json +20 -0
  134. loadpath/examples/pedal-lattice.json +23 -0
  135. loadpath/examples/pressure-plenum.json +12 -0
  136. loadpath/examples/shelf-bracket.json +27 -0
  137. loadpath/exchanger.py +831 -0
  138. loadpath/fasteners.py +199 -0
  139. loadpath/fem.py +668 -0
  140. loadpath/femops.py +70 -0
  141. loadpath/fieldjit.py +981 -0
  142. loadpath/fieldops.py +119 -0
  143. loadpath/fields.py +3697 -0
  144. loadpath/fieldtorch.py +464 -0
  145. loadpath/flow.py +984 -0
  146. loadpath/flowtopo.py +300 -0
  147. loadpath/gpu.py +521 -0
  148. loadpath/heatx.py +1067 -0
  149. loadpath/heatx_thermal.py +432 -0
  150. loadpath/homogenized.py +886 -0
  151. loadpath/ibrep.py +552 -0
  152. loadpath/impact.py +693 -0
  153. loadpath/infill.py +376 -0
  154. loadpath/interface_library.py +340 -0
  155. loadpath/interlock.py +186 -0
  156. loadpath/isolator.py +149 -0
  157. loadpath/knn.py +204 -0
  158. loadpath/lattice.py +1065 -0
  159. loadpath/lattice3mf.py +760 -0
  160. loadpath/lattice_buckling.py +245 -0
  161. loadpath/lattice_grade.py +325 -0
  162. loadpath/lattice_orient.py +595 -0
  163. loadpath/lattice_print.py +159 -0
  164. loadpath/lattice_tensors.py +558 -0
  165. loadpath/latticestep.py +1210 -0
  166. loadpath/manufacture.py +446 -0
  167. loadpath/material_library.py +489 -0
  168. loadpath/materials.py +144 -0
  169. loadpath/mcp_server.py +786 -0
  170. loadpath/mechanism.py +218 -0
  171. loadpath/mechanism_nl.py +811 -0
  172. loadpath/meshops.py +452 -0
  173. loadpath/mg.py +853 -0
  174. loadpath/mma.py +214 -0
  175. loadpath/modal.py +210 -0
  176. loadpath/multimat.py +1788 -0
  177. loadpath/narrowband.py +418 -0
  178. loadpath/nlkernel.py +241 -0
  179. loadpath/nonlinear.py +1095 -0
  180. loadpath/orient.py +334 -0
  181. loadpath/porous.py +994 -0
  182. loadpath/pressure.py +171 -0
  183. loadpath/preview.py +335 -0
  184. loadpath/printcheck.py +263 -0
  185. loadpath/printsetup.py +207 -0
  186. loadpath/processcheck.py +376 -0
  187. loadpath/quick.py +113 -0
  188. loadpath/recall.py +406 -0
  189. loadpath/reconstruct.py +797 -0
  190. loadpath/render.py +436 -0
  191. loadpath/resolved.py +189 -0
  192. loadpath/schema.py +1701 -0
  193. loadpath/sdf_kit.py +843 -0
  194. loadpath/selfx.py +292 -0
  195. loadpath/shapes.py +584 -0
  196. loadpath/sharp.py +717 -0
  197. loadpath/simp.py +1170 -0
  198. loadpath/sizing.py +312 -0
  199. loadpath/slicer.py +321 -0
  200. loadpath/spec.py +1072 -0
  201. loadpath/step.py +806 -0
  202. loadpath/struts.py +639 -0
  203. loadpath/study.py +1937 -0
  204. loadpath/tasks.py +822 -0
  205. loadpath/thermal.py +1691 -0
  206. loadpath/threemf.py +487 -0
  207. loadpath/tune.py +437 -0
  208. loadpath/validation.py +145 -0
  209. loadpath/verify.py +1215 -0
  210. loadpath/viewer.html +694 -0
  211. loadpath/viewer.py +446 -0
  212. loadpath/viscoelastic.py +106 -0
  213. loadpath/voxelprint.py +582 -0
  214. loadpath/voxels.py +402 -0
  215. loadpath-0.1.1.dist-info/METADATA +319 -0
  216. loadpath-0.1.1.dist-info/RECORD +220 -0
  217. loadpath-0.1.1.dist-info/WHEEL +5 -0
  218. loadpath-0.1.1.dist-info/entry_points.txt +2 -0
  219. loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
  220. loadpath-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,595 @@
1
+ """
2
+ Lattice orientation as a design variable.
3
+
4
+ A strut or sheet cell is stiff and strong along some directions and soft
5
+ along others (a simple cubic cell carries a pull along its rods at about
6
+ ten times the stiffness it has along the cube diagonal; BCCz carries its
7
+ vertical struts' axis). homogenized_check reads a lattice from its full
8
+ tensors (lattice_tensors), so it can tell how the cell lies relative to the
9
+ load. `optimize_orientation` turns the cell (the lattice node's `orient`,
10
+ which rotates the cells and leaves the fields grading them where they are)
11
+ for the best worst-case safety factor, or the least compliance:
12
+
13
+ 1. One homogenized analysis with the cell as it is gives every lattice
14
+ cell's macro stress per load case.
15
+ 2. Screen: holding those macro stresses (the assumption fully stressed
16
+ sizing makes), each candidate rotation's peak wall stress (the stress
17
+ surface, as a p-norm over the cells the peak is read over) and
18
+ complementary energy are a rotation of tensors away, so thousands of
19
+ rotations cost a few seconds: rotations that put each of the cell's
20
+ stiffest directions on the dominant stress direction, spun about it,
21
+ and a random spread over all rotations.
22
+ 3. The best few go through a full homogenized analysis each (the load
23
+ moves onto what got stiffer), then Nelder-Mead on the rotation vector
24
+ refines the best of those, again on full analyses.
25
+
26
+ The orientation is uniform over the lattice. It wins most where one stress
27
+ direction dominates (a beam's skinless core, a strut, a bracket arm): see
28
+ docs/fields.md. A gyroid is close to isotropic, so it gains little.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import copy
33
+ import time
34
+
35
+ import numpy as np
36
+
37
+ __all__ = ["confirm_turn", "dominant_directions", "optimize_orientation", "optimize_zones", "screen_rotations"]
38
+
39
+ # directions the cell's stiff axes are looked for along (cube axes, face
40
+ # and body diagonals; with a stretched cell these are in its own axes)
41
+ _LATTICE_DIRS = np.array([(1, 0, 0), (0, 1, 0), (0, 0, 1),
42
+ (1, 1, 0), (1, -1, 0), (1, 0, 1), (1, 0, -1), (0, 1, 1), (0, 1, -1),
43
+ (1, 1, 1), (1, 1, -1), (1, -1, 1), (-1, 1, 1)], float)
44
+ _LATTICE_DIRS /= np.linalg.norm(_LATTICE_DIRS, axis=1, keepdims=True)
45
+
46
+ P_NORM = 8.0 # the screen's soft maximum of the wall stress
47
+
48
+ # a lattice with more than this share of its volume cut open at a bare face
49
+ # (homogenized_check's latticeExposed) is not turned on the homogenized
50
+ # reading alone, unless its cell is in homogenized.FACE_CUT_VALIDATED (one
51
+ # direction only; zones and grading keep the guard). Cut square, its cells keep struts running along the face
52
+ # and the free-surface knockdown reads them 1.5-2.8x conservative; turned,
53
+ # they are cut on the bias into stubs and read about right or too stiff. So
54
+ # the optimizer saw gains that a full-resolution check reverses: skinless
55
+ # bars claimed -23/-15/-7% mass and came out 3.2x/2.2x/1.3x weaker than as
56
+ # laid at the same wall (bench/results/lattice-orient-confirm.json).
57
+ EXPOSED_LIMIT = 0.1
58
+ TURN_MIN_DEG = 5.0
59
+
60
+
61
+ def _turn_deg(rv):
62
+ return float(np.linalg.norm(np.asarray(rv, float)))
63
+
64
+
65
+ def confirm_turn(laid_doc, turned_doc, study, objective="safetyFactor", node=None, base_dir=None,
66
+ pitch=None, sources=None, tolerance=0.02) -> dict:
67
+ """Both designs at full resolution (resolved.resolved_check): is the
68
+ turned one at least as strong (objective "safetyFactor": worst-case
69
+ peak) or as stiff ("stiffness": total compliance), within `tolerance`?
70
+ Returns {"confirmed", "asLaid", "turned" (worst peak / SF / compliance,
71
+ seconds), "ratio" (turned over as laid, of the peak or compliance)}."""
72
+ from .resolved import resolved_check
73
+
74
+ rows = {}
75
+ for tag, d in (("asLaid", laid_doc), ("turned", turned_doc)):
76
+ r = resolved_check(d, study, pitch=pitch, node=node, base_dir=base_dir, sources=sources)
77
+ rows[tag] = {"safetyFactor": r["safetyFactor"], "peakStress": max(c["peakStress"] for c in r["cases"]),
78
+ "compliance": float(sum(c["compliance"] for c in r["cases"])), "mass": r["mass"],
79
+ "cells": r["cells"], "pitch": r["pitch"], "seconds": r["seconds"]}
80
+ key = "peakStress" if objective == "safetyFactor" else "compliance"
81
+ ratio = rows["turned"][key] / max(rows["asLaid"][key], 1e-30)
82
+ return {"confirmed": bool(ratio <= 1.0 + tolerance), "ratio": float(ratio), "measure": key, **rows}
83
+
84
+
85
+ def _guard(confirm, exposed, turned, laid_doc, turned_doc, study, objective, node, base_dir, pitch=None,
86
+ validated=False):
87
+ """None when the turn stands on the homogenized reading; else a dict
88
+ with "confirmed" (False/None: keep the cells as laid) and why.
89
+ `validated`: the cell's bare-face reading holds turned against as laid
90
+ (homogenized.FACE_CUT_VALIDATED), so "auto" trusts it."""
91
+ if confirm not in ("auto", "resolved", "none"):
92
+ raise ValueError("confirm is 'auto', 'resolved' or 'none'")
93
+ if confirm == "none" or not turned or exposed <= EXPOSED_LIMIT or (confirm == "auto" and validated):
94
+ return None
95
+ if confirm == "resolved":
96
+ out = confirm_turn(laid_doc, turned_doc, study, objective, node=node, base_dir=base_dir, pitch=pitch)
97
+ out["latticeExposed"] = exposed
98
+ out["reason"] = (f"{100 * exposed:.0f}% of the lattice is cut open at bare faces; full-resolution check: "
99
+ f"turned/as laid {out['measure']} {out['ratio']:.2f}"
100
+ + ("" if out["confirmed"] else ", so the cells stay as laid"))
101
+ return out
102
+ return {"confirmed": None, "latticeExposed": exposed,
103
+ "reason": f"{100 * exposed:.0f}% of the lattice is cut open at bare faces (no skin), where the "
104
+ "homogenized check misreads turned cells of this kind against square-cut ones (skinless "
105
+ "bars and an L-bracket it read 7-41% lighter turned came out 1.3-3.4x weaker at full "
106
+ "resolution; only octet's turn has held), so the cells stay as "
107
+ "laid. Add a skin, or pass confirm='resolved' (--confirm resolved) to check the turn at "
108
+ "full resolution"}
109
+
110
+
111
+ def _voigt_tensor(s):
112
+ """(n, 6) Voigt stresses in loadpath's order (xx, yy, zz, xy, yz, zx,
113
+ lattice_tensors._VOIGT) -> (n, 3, 3)."""
114
+ t = np.empty((len(s), 3, 3))
115
+ t[:, 0, 0], t[:, 1, 1], t[:, 2, 2] = s[:, 0], s[:, 1], s[:, 2]
116
+ t[:, 0, 1] = t[:, 1, 0] = s[:, 3]
117
+ t[:, 1, 2] = t[:, 2, 1] = s[:, 4]
118
+ t[:, 2, 0] = t[:, 0, 2] = s[:, 5]
119
+ return t
120
+
121
+
122
+ def _principal(stresses):
123
+ """Per cell and case, the principal direction of the largest |stress|
124
+ and that stress: (n, cases, 3), (n, cases)."""
125
+ dirs, mags = [], []
126
+ for st in stresses:
127
+ lam, vec = np.linalg.eigh(_voigt_tensor(st))
128
+ i = np.argmax(np.abs(lam), axis=1)
129
+ dirs.append(vec[np.arange(len(st)), :, i])
130
+ mags.append(np.abs(lam[np.arange(len(st)), i]))
131
+ return np.stack(dirs, 1), np.stack(mags, 1)
132
+
133
+
134
+ def dominant_directions(stresses, weights):
135
+ """Principal axes of sum_e w_e sum_cases s1^2 n1 n1^T over cells and
136
+ cases, n1 the direction of each cell's largest principal stress s1,
137
+ strongest first (columns): the direction the lattice's stress mostly
138
+ runs along, whatever its sign. (sigma . sigma would do for an axial
139
+ stress but has no preferred axis in pure shear.)"""
140
+ n, s = _principal(stresses)
141
+ m = np.einsum("e,ec,eci,ecj->ij", weights, s ** 2, n, n)
142
+ w, v = np.linalg.eigh(m)
143
+ return v[:, ::-1], w[::-1]
144
+
145
+
146
+ def _align(a, b):
147
+ """A rotation taking unit vector a onto unit vector b."""
148
+ from scipy.spatial.transform import Rotation
149
+
150
+ c = np.cross(a, b)
151
+ s, d = np.linalg.norm(c), float(np.dot(a, b))
152
+ if s < 1e-12:
153
+ if d > 0:
154
+ return np.eye(3)
155
+ p = np.cross(a, [1, 0, 0] if abs(a[0]) < 0.9 else [0, 1, 0])
156
+ return Rotation.from_rotvec(np.pi * p / np.linalg.norm(p)).as_matrix()
157
+ return Rotation.from_rotvec(c / s * np.arctan2(s, d)).as_matrix()
158
+
159
+
160
+ def candidate_rotations(C, dirs, n_spin=12, n_random=1500, seed=0):
161
+ """Rotations (k, 3, 3), columns the cell's axes in global components:
162
+ the identity; each of the cell's stiffest lattice directions (within
163
+ 15% of the stiffest) onto the dominant stress direction, spun about it
164
+ in n_spin steps; random ones."""
165
+ from scipy.spatial.transform import Rotation
166
+
167
+ from .lattice_tensors import directional_modulus
168
+
169
+ e = np.array([directional_modulus(C, d) for d in _LATTICE_DIRS])
170
+ stiff = _LATTICE_DIRS[e >= 0.85 * e.max()]
171
+ out = [np.eye(3)]
172
+ for d in stiff:
173
+ base = _align(d, dirs[:, 0])
174
+ for k in range(n_spin):
175
+ spin = Rotation.from_rotvec(dirs[:, 0] * np.pi * k / n_spin).as_matrix()
176
+ out.append(spin @ base)
177
+ out += list(Rotation.random(n_random, random_state=seed).as_matrix())
178
+ return np.array(out)
179
+
180
+
181
+ def screen_rotations(R, fields, aspect=None, mask=None, absolute=False):
182
+ """Score rotations holding each lattice cell's macro stress. Returns
183
+ (peak, energy): per rotation the p-norm of the wall stress over the
184
+ cells the peak is read over (worst case) and the complementary energy
185
+ summed over cases, both relative to the cells' tensors in their current
186
+ frames turned by R (the cell axes are frames @ R)."""
187
+ from .lattice_tensors import stress_rotation, stress_surface, tensors_at
188
+
189
+ rho, fc, fill = fields["rho"], fields["fc"], fields["fill"]
190
+ lat = fc > 0.05
191
+ if mask is not None:
192
+ lat = lat & mask
193
+ C, P, Q, F = tensors_at(fields["kind"], rho[lat], aspect)
194
+ S = np.linalg.inv(C)
195
+ w = (fc * fill)[lat]
196
+ frames = fields["frames"]
197
+ # absolute: R is the cells' frame itself (not a turn of the current one)
198
+ f0 = np.broadcast_to(np.eye(3), (lat.sum(), 3, 3)) if frames is None or absolute else frames[lat]
199
+ peak = np.zeros(len(R))
200
+ energy = np.zeros(len(R))
201
+ for ci, sig in enumerate(fields["latticeStress"]):
202
+ sig = sig[lat]
203
+ body = fields["body"][ci][lat]
204
+ for k, r in enumerate(R):
205
+ t = stress_rotation(np.einsum("eij,jk->eik", f0, r))
206
+ sc = np.einsum("eab,eb->ea", t, sig)
207
+ s = stress_surface(sc[body], P[body], Q[body], F[body])
208
+ m = s.max() if len(s) else 0.0
209
+ pn = m * (np.sum(w[body] * (s / max(m, 1e-30)) ** P_NORM) / max(w[body].sum(), 1e-30)) ** (1 / P_NORM)
210
+ peak[k] = max(peak[k], pn)
211
+ energy[k] += float(np.einsum("e,ea,eab,eb->", w, sc, S, sc))
212
+ return peak, energy
213
+
214
+
215
+ def _cell_group(kind, aspect=None):
216
+ from .lattice_tensors import _group, symmetry_of
217
+
218
+ sym = "orthorhombic" if aspect is not None and not np.allclose(aspect, aspect[0]) else symmetry_of(kind)
219
+ return _group(sym) if sym != "none" else np.eye(3)[None]
220
+
221
+
222
+ def _angle_mod(a, b, group):
223
+ """Least rotation angle between cell frames a and b, the cell's
224
+ symmetry rotations (in its own axes) taken as no turn."""
225
+ tr = np.einsum("ji,jk,gki->g", a, b, group)
226
+ return float(np.arccos(np.clip((tr.max() - 1.0) / 2.0, -1.0, 1.0)))
227
+
228
+
229
+ def _smallest(r, group):
230
+ """The frame r @ g (g a symmetry rotation) nearest the identity."""
231
+ tr = np.einsum("ij,gji->g", r, group)
232
+ return r @ group[int(np.argmax(tr))]
233
+
234
+
235
+ def _with_orient(doc, lat, rv):
236
+ d = copy.deepcopy(doc)
237
+ nodes = d["nodes"]
238
+ spec = nodes[lat]
239
+ if np.allclose(rv, 0.0):
240
+ spec.pop("orient", None)
241
+ else:
242
+ spec["orient"] = [round(float(x), 3) for x in rv]
243
+ return d
244
+
245
+
246
+ def _rotvec_deg(R):
247
+ from scipy.spatial.transform import Rotation
248
+
249
+ return np.degrees(Rotation.from_matrix(R).as_rotvec())
250
+
251
+
252
+ def optimize_orientation(doc, study, node=None, base_dir=None, cells: int = 15_000, objective: str = "safetyFactor",
253
+ full_checks: int = 4, refine: int = 24, progress=None, confirm: str = "auto",
254
+ resolved_pitch: float | None = None) -> dict:
255
+ """Turn the graph's lattice (its `orient`) for the best worst-case
256
+ safety factor (objective "safetyFactor") or the least total compliance
257
+ ("stiffness"), on homogenized analyses of `cells` cells (module
258
+ docstring). Returns {"doc" (the graph with the best orient written),
259
+ "orient" (degrees, rotation vector), "start", "best" (homogenized
260
+ reports, trimmed), "screened", "evaluations", "history", "seconds"}.
261
+ The lattice must be cartesian with a constant cell (orient's limits);
262
+ an orient it already has is the starting point.
263
+
264
+ `confirm`: a lattice cut open at bare faces (latticeExposed over
265
+ EXPOSED_LIMIT) is misread turned against as laid, so with "auto" (the
266
+ default) its cells stay as laid and "guard" says why, unless the cell
267
+ is one whose bare-face reading was confirmed turned
268
+ (homogenized.FACE_CUT_VALIDATED: octet); "resolved" checks
269
+ the turn against as laid at full resolution (confirm_turn, minutes;
270
+ `resolved_pitch`, default resolved.default_pitch) and keeps it only if
271
+ it holds; "none" trusts the homogenized reading."""
272
+ from scipy.optimize import minimize
273
+ from scipy.spatial.transform import Rotation
274
+
275
+ from .fields import FieldGraph, expand_groups
276
+ from .homogenized import homogenized_check, lattice_nodes
277
+ from .lattice_tensors import has_tensors, tensors_at
278
+
279
+ if objective not in ("safetyFactor", "stiffness"):
280
+ raise ValueError("objective is 'safetyFactor' or 'stiffness'")
281
+ t0 = time.time()
282
+ full = expand_groups(doc)
283
+ name = node or full.get("output") or list(full["nodes"])[-1]
284
+ g = FieldGraph({**full, "output": name}, base_dir=base_dir)
285
+ lats = lattice_nodes(g, name)
286
+ if len(lats) != 1:
287
+ raise ValueError(f"orientation needs one lattice node in {name!r} (found {len(lats)})")
288
+ lat = lats[0]
289
+ spec = full["nodes"][lat]
290
+ kind = spec.get("type", "gyroid")
291
+ aspect = tuple(spec["aspect"]) if "aspect" in spec else None
292
+ if not has_tensors(kind, aspect):
293
+ raise ValueError(f"lattice {lat!r} ({kind}) has no cell tensors to orient by")
294
+ say = progress or (lambda *_: None)
295
+ r_start = (Rotation.from_rotvec(np.radians(np.asarray(spec["orient"], float))).as_matrix()
296
+ if isinstance(spec.get("orient"), list) else np.eye(3))
297
+ if isinstance(spec.get("orient"), dict):
298
+ from .fields import lattice_orientation
299
+
300
+ r_start = lattice_orientation(g.nodes[lat])
301
+ r_start = np.eye(3) if r_start is None else r_start
302
+ base = _with_orient(full, lat, np.zeros(3))
303
+ history = []
304
+
305
+ def score(rep):
306
+ if objective == "safetyFactor":
307
+ return -float(rep["safetyFactor"])
308
+ return float(sum(c["compliance"] for c in rep["cases"]))
309
+
310
+ def trim(rep, rv):
311
+ return {"orient": [float(x) for x in rv], "mass": rep["mass"], "safetyFactor": rep["safetyFactor"],
312
+ "compliance": float(sum(c["compliance"] for c in rep["cases"])),
313
+ "cases": [{k: c[k] for k in ("name", "safetyFactor", "deflection", "compliance")}
314
+ for c in rep["cases"]],
315
+ "latticeTurnedExposed": rep.get("latticeTurnedExposed"), "warnings": rep.get("warnings", [])}
316
+
317
+ cache = {}
318
+
319
+ def check(rv):
320
+ key = tuple(np.round(rv, 2))
321
+ if key not in cache:
322
+ rep = homogenized_check(_with_orient(base, lat, rv), study, node=name, base_dir=base_dir, cells=cells)
323
+ cache[key] = (score(rep), trim(rep, rv))
324
+ history.append(cache[key][1])
325
+ say({"stage": "lattice-orient", "orient": np.round(rv, 1).tolist(),
326
+ "safetyFactor": rep["safetyFactor"], "compliance": history[-1]["compliance"]})
327
+ return cache[key]
328
+
329
+ # the cell as it lies (no orient) for the macro stresses and frames
330
+ ref = homogenized_check(base, study, node=name, base_dir=base_dir, cells=cells, fields=True)
331
+ fl = ref["fields"]
332
+ if fl["latticeStress"] is None:
333
+ raise ValueError("the anisotropic reading is off (LOADPATH_LATTICE_ANISO=0): orientation needs it")
334
+ lat_cells = fl["fc"] > 0.05
335
+ dirs, strength = dominant_directions([s[lat_cells] for s in fl["latticeStress"]],
336
+ (fl["fc"] * fl["fill"])[lat_cells])
337
+ rho_mid = float(np.median(fl["rho"][lat_cells]))
338
+ C = tensors_at(kind, [rho_mid], aspect)[0][0]
339
+ # candidates are the cell's rotation from its unrotated lie; the stiff
340
+ # directions are in the cell's axes, the stress directions in global
341
+ f0 = fl["frames"]
342
+ f_mean = np.eye(3) if f0 is None else f0[lat_cells].mean(axis=0)
343
+ u, _, vt = np.linalg.svd(f_mean)
344
+ f_mean = u @ vt
345
+ R = candidate_rotations(C, f_mean.T @ dirs)
346
+ R = np.concatenate([[r_start], R])
347
+ peak, energy = screen_rotations(R, fl, aspect)
348
+ rank = np.argsort(peak if objective == "safetyFactor" else energy)
349
+ group = _cell_group(kind, aspect)
350
+ picks = [np.eye(3)] # the unrotated cell is checked anyway
351
+ for k in rank:
352
+ # one of each set the cell's symmetry makes the same, written as
353
+ # its smallest turn
354
+ if min(_angle_mod(R[k], p, group) for p in picks) < np.radians(8.0):
355
+ continue
356
+ picks.append(_smallest(R[k], group))
357
+ if len(picks) > full_checks:
358
+ break
359
+ picks = [_rotvec_deg(p) for p in picks[1:]]
360
+ start_rv = _rotvec_deg(r_start)
361
+ start = check(start_rv)
362
+ check(np.zeros(3))
363
+ for rv in picks:
364
+ check(rv)
365
+ best_key = min(cache, key=lambda k: cache[k][0])
366
+ x0 = np.array(best_key, float)
367
+ if refine > 0:
368
+ simplex = [x0] + [x0 + 12.0 * e for e in np.eye(3)]
369
+ minimize(lambda x: check(x)[0], x0, method="Nelder-Mead",
370
+ options={"initial_simplex": simplex, "maxfev": refine, "xatol": 1.0, "fatol": 1e-3})
371
+ best_key = min(cache, key=lambda k: cache[k][0])
372
+ best = cache[best_key][1]
373
+ src = copy.deepcopy(doc) if "nodes" in doc and lat in doc["nodes"] else full
374
+ out_doc = _with_orient(src, lat, np.array(best_key, float))
375
+ zero = tuple(np.round(np.zeros(3), 2))
376
+ from .homogenized import FACE_CUT_VALIDATED
377
+
378
+ guard = _guard(confirm, float(ref.get("latticeExposed") or 0.0), _turn_deg(best_key) > TURN_MIN_DEG,
379
+ _with_orient(src, lat, np.zeros(3)), out_doc, study, objective, name, base_dir, resolved_pitch,
380
+ validated=kind in FACE_CUT_VALIDATED and aspect is None)
381
+ candidate = None
382
+ if guard is not None and not guard["confirmed"]:
383
+ candidate, best_key = best, zero
384
+ best = cache[zero][1]
385
+ out_doc = _with_orient(src, lat, np.zeros(3))
386
+ say({"stage": "lattice-orient-guard", "reason": guard["reason"]})
387
+ return {"doc": out_doc, "orient": [float(x) for x in best_key], "lattice": lat, "objective": objective,
388
+ "guard": guard, "candidate": candidate,
389
+ "start": start[1], "unrotated": cache[zero][1], "best": best,
390
+ "screened": int(len(R)), "dominantStress": dirs[:, 0].tolist(),
391
+ "stressAnisotropy": float(strength[0] / max(strength.sum(), 1e-30)),
392
+ "evaluations": len(history), "history": history, "seconds": time.time() - t0}
393
+
394
+
395
+ def _cell_directions(stresses, weights):
396
+ """Per cell, the axis its lattice stress mostly runs along (the top
397
+ eigenvector of sum over cases of s1^2 n1 n1^T) and its strength."""
398
+ n, s = _principal(stresses)
399
+ m = np.einsum("ec,eci,ecj->eij", s ** 2, n, n)
400
+ w, v = np.linalg.eigh(m)
401
+ return v[:, :, -1], w[:, -1]
402
+
403
+
404
+ def stress_zones(centers, stresses, weights, k, spatial=1.0, seed=0):
405
+ """Split the lattice cells into k zones whose stress runs one way:
406
+ k-means on the cells' stress axis (as n n^T, sign free) and position
407
+ (scaled by the lattice's extent, times `spatial`), weighted by stress.
408
+ Returns (labels, zone points: the stress-weighted centroids)."""
409
+ from scipy.cluster.vq import kmeans2
410
+
411
+ n, mag = _cell_directions(stresses, weights)
412
+ nn = np.einsum("ei,ej->eij", n, n).reshape(-1, 9)[:, [0, 1, 2, 4, 5, 8]]
413
+ nn[:, [1, 2, 4]] *= np.sqrt(2.0)
414
+ span = np.ptp(centers, axis=0).max()
415
+ x = np.hstack([nn, spatial * (centers - centers.mean(axis=0)) / max(span, 1e-9)])
416
+ # weight by stress: repeat the stressed cells (kmeans2 has no weights)
417
+ sw = np.sqrt(np.maximum(mag, 0.0)) * weights
418
+ keep = sw > 0.05 * sw.max()
419
+ rng = np.random.default_rng(seed)
420
+ best = None
421
+ for _ in range(6):
422
+ cen, lab = kmeans2(x[keep], k, minit="++", seed=int(rng.integers(1 << 31)))
423
+ cost = float(((x[keep] - cen[lab]) ** 2).sum())
424
+ if best is None or cost < best[0]:
425
+ best = (cost, cen)
426
+ cen = best[1]
427
+ labels = np.argmin(((x[:, None, :] - cen[None]) ** 2).sum(-1), axis=1)
428
+ at = np.array([np.average(centers[labels == i], axis=0, weights=sw[labels == i] + 1e-12)
429
+ if (labels == i).any() else centers.mean(axis=0) for i in range(k)])
430
+ return labels, at
431
+
432
+
433
+ def optimize_zones(doc, study, zones: int = 3, node=None, base_dir=None, cells: int = 15_000,
434
+ objective: str = "safetyFactor", rounds: int = 2, alternatives: int = 2, blend=None,
435
+ progress=None, spatial: float = 1.0, uniform_refine: int = 8, zone_refine: int = 6,
436
+ start=None, seam=1.0, confirm: str = "auto", resolved_pitch: float | None = None) -> dict:
437
+ """Cell direction that follows the stress: the lattice split into
438
+ `zones` zones (stress_zones), each turned on its own (orient.zones,
439
+ cross-faded over `blend` mm, default one cell, where they meet). Each
440
+ round analyzes the current design for the macro stresses, screens every
441
+ zone's rotations on its own cells (absolute frames, the stresses held)
442
+ and takes the best per zone; then for each zone in turn its next
443
+ `alternatives` screened rotations are tried with full analyses, keeping
444
+ any that improve. Returns like optimize_orientation, with "zones" (the
445
+ orient.zones written) and "uniform" (the best single orient, its own
446
+ run, for comparison) left to the caller. `seam` (mm, default 1): a
447
+ solid plate between zones (orient.seam), which the zones' cells end on
448
+ as on a skin; 0 for none (cross-faded zones, which read too stiff where
449
+ thin cells of two directions meet). `confirm`: as optimize_orientation;
450
+ a guarded result comes back with the cells as laid ("zones" None)."""
451
+ from .fields import FieldGraph, expand_groups, zone_weights
452
+ from .homogenized import homogenized_check, lattice_nodes
453
+ from .lattice_tensors import has_tensors, tensors_at
454
+
455
+ t0 = time.time()
456
+ full = expand_groups(doc)
457
+ name = node or full.get("output") or list(full["nodes"])[-1]
458
+ g = FieldGraph({**full, "output": name}, base_dir=base_dir)
459
+ lats = lattice_nodes(g, name)
460
+ if len(lats) != 1:
461
+ raise ValueError(f"orientation needs one lattice node in {name!r} (found {len(lats)})")
462
+ lat = lats[0]
463
+ spec = full["nodes"][lat]
464
+ kind = spec.get("type", "gyroid")
465
+ aspect = tuple(spec["aspect"]) if "aspect" in spec else None
466
+ if not has_tensors(kind, aspect):
467
+ raise ValueError(f"lattice {lat!r} ({kind}) has no cell tensors to orient by")
468
+ say = progress or (lambda *_: None)
469
+ blend = float(blend if blend is not None else (spec.get("cell", 8.0) if isinstance(spec.get("cell", 8.0),
470
+ (int, float)) else 8.0))
471
+ history = []
472
+
473
+ def doc_for(at, rots):
474
+ d = copy.deepcopy(full)
475
+ d["nodes"][lat]["orient"] = {"zones": [{"at": [round(float(v), 3) for v in a],
476
+ "rotate": [round(float(v), 3) for v in _rotvec_deg(r)]}
477
+ for a, r in zip(at, rots)], "blend": blend}
478
+ if seam:
479
+ d["nodes"][lat]["orient"]["seam"] = float(seam)
480
+ return d
481
+
482
+ def score(rep):
483
+ if objective == "safetyFactor":
484
+ return -float(rep["safetyFactor"])
485
+ return float(sum(c["compliance"] for c in rep["cases"]))
486
+
487
+ def check(at, rots, fields=False):
488
+ rep = homogenized_check(doc_for(at, rots), study, node=name, base_dir=base_dir, cells=cells, fields=fields)
489
+ row = {"zones": doc_for(at, rots)["nodes"][lat]["orient"]["zones"], "mass": rep["mass"],
490
+ "safetyFactor": rep["safetyFactor"], "compliance": float(sum(c["compliance"] for c in rep["cases"])),
491
+ "latticeTurnedExposed": rep.get("latticeTurnedExposed"), "warnings": rep.get("warnings", [])}
492
+ history.append(row)
493
+ say({"stage": "lattice-zones", "safetyFactor": rep["safetyFactor"], "compliance": row["compliance"]})
494
+ return score(rep), row, rep
495
+
496
+ base = copy.deepcopy(full)
497
+ base["nodes"][lat].pop("orient", None)
498
+ ref = homogenized_check(base, study, node=name, base_dir=base_dir, cells=cells, fields=True)
499
+ fl = ref["fields"]
500
+ if fl["latticeStress"] is None:
501
+ raise ValueError("the anisotropic reading is off (LOADPATH_LATTICE_ANISO=0): orientation needs it")
502
+ unrot = {"mass": ref["mass"], "safetyFactor": ref["safetyFactor"],
503
+ "compliance": float(sum(c["compliance"] for c in ref["cases"]))}
504
+ lat_cells = fl["fc"] > 0.05
505
+ w = (fl["fc"] * fl["fill"])
506
+ labels, at = stress_zones(fl["centers"][lat_cells], [s[lat_cells] for s in fl["latticeStress"]],
507
+ w[lat_cells], zones, spatial=spatial)
508
+ group = _cell_group(kind, aspect)
509
+ rho_mid = float(np.median(fl["rho"][lat_cells]))
510
+ C = tensors_at(kind, [rho_mid], aspect)[0][0]
511
+ # start from the best single direction (so zones can only gain on it)
512
+ if start is None:
513
+ uni = optimize_orientation(doc, study, node=node, base_dir=base_dir, cells=cells, objective=objective,
514
+ full_checks=2, refine=uniform_refine, progress=progress, confirm="none")
515
+ start = uni["orient"]
516
+ uniform = uni["best"]
517
+ else:
518
+ uniform = None
519
+ r0 = _rotation_from_deg(start)
520
+ rots = [r0 for _ in range(zones)]
521
+ sc0, row0, rep0 = check(at, rots, fields=True)
522
+ best = (sc0, row0, list(rots))
523
+ fl = rep0["fields"]
524
+ for rnd in range(rounds):
525
+ # the zones' weights at the cells: a cell screens with the zone it mostly belongs to
526
+ wz = zone_weights(at, blend, fl["centers"])
527
+ own = np.argmax(wz, axis=1)
528
+ ranked = []
529
+ for i in range(zones):
530
+ m = own == i
531
+ dirs, _ = dominant_directions([s[m & lat_cells] for s in fl["latticeStress"]], w[m & lat_cells])
532
+ R = np.concatenate([[rots[i]], candidate_rotations(C, dirs, n_random=600, seed=rnd * 31 + i)])
533
+ peak, energy = screen_rotations(R, fl, aspect, mask=m, absolute=True)
534
+ order = np.argsort(peak if objective == "safetyFactor" else energy)
535
+ picks = []
536
+ for k in order:
537
+ if picks and min(_angle_mod(R[k], p, group) for p in picks) < np.radians(8.0):
538
+ continue
539
+ picks.append(_smallest(R[k], group))
540
+ if len(picks) > alternatives:
541
+ break
542
+ ranked.append(picks)
543
+ trial = [p[0] for p in ranked]
544
+ sc, row, rep = check(at, trial)
545
+ if sc < best[0]:
546
+ best = (sc, row, list(trial))
547
+ for i in range(zones):
548
+ for alt in ranked[i][1:]:
549
+ cand = list(best[2])
550
+ cand[i] = alt
551
+ sc2, row2, _ = check(at, cand)
552
+ if sc2 < best[0]:
553
+ best = (sc2, row2, cand)
554
+ rots = best[2]
555
+ # the next round's stresses: the design as it now stands
556
+ fl = homogenized_check(doc_for(at, rots), study, node=name, base_dir=base_dir, cells=cells,
557
+ fields=True)["fields"] if rnd + 1 < rounds else fl
558
+ # then each zone's rotation vector on its own, the others held
559
+ if zone_refine > 0:
560
+ from scipy.optimize import minimize
561
+
562
+ for i in range(zones):
563
+ x0 = _rotvec_deg(best[2][i])
564
+
565
+ def f(x, i=i):
566
+ nonlocal best
567
+ cand = list(best[2])
568
+ cand[i] = _rotation_from_deg(x)
569
+ sc2, row2, _ = check(at, cand)
570
+ if sc2 < best[0]:
571
+ best = (sc2, row2, cand)
572
+ return sc2
573
+
574
+ minimize(f, x0, method="Nelder-Mead", options={
575
+ "initial_simplex": [x0] + [x0 + 10.0 * e for e in np.eye(3)], "maxfev": zone_refine})
576
+ out_doc = doc_for(at, best[2])
577
+ zones_out = out_doc["nodes"][lat]["orient"]["zones"]
578
+ turned = max(_turn_deg(z["rotate"]) for z in zones_out) > TURN_MIN_DEG
579
+ guard = _guard(confirm, float(ref.get("latticeExposed") or 0.0), turned, base, out_doc, study, objective,
580
+ name, base_dir, resolved_pitch)
581
+ candidate = None
582
+ if guard is not None and not guard["confirmed"]:
583
+ candidate, out_doc, zones_out = best[1], base, None
584
+ best = (None, {**unrot, "zones": None}, None)
585
+ say({"stage": "lattice-zones-guard", "reason": guard["reason"]})
586
+ return {"doc": out_doc, "lattice": lat, "objective": objective, "zones": zones_out,
587
+ "guard": guard, "candidate": candidate,
588
+ "blend": blend, "unrotated": unrot, "uniform": uniform, "uniformOrient": list(map(float, start)),
589
+ "best": best[1], "evaluations": len(history), "history": history, "seconds": time.time() - t0}
590
+
591
+
592
+ def _rotation_from_deg(rv):
593
+ from scipy.spatial.transform import Rotation
594
+
595
+ return Rotation.from_rotvec(np.radians(np.asarray(rv, float))).as_matrix()