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/assembly.py ADDED
@@ -0,0 +1,985 @@
1
+ """
2
+ Loads and design space from the mechanism a part sits in.
3
+
4
+ A study needs loads and a design space, and for a part in a mechanism both
5
+ are already decided by the mechanism: the loads by what the part carries in
6
+ every pose it reaches, the space by what the parts round it occupy and sweep
7
+ through. Writing them by hand means picking one pose ("arm stretched out"),
8
+ a factor for everything else (1.5x for accelerations) and boxes for the
9
+ space, and the worst case for one member is rarely the worst case for
10
+ another. This module reads the mechanism from a URDF and derives both:
11
+
12
+ robot = Robot.from_urdf("grip.urdf")
13
+ sweep = sweep_loads(robot, "forearm", payload=Payload(1.0, "tool", (0, 0, 0.07)))
14
+ picked = governing_cases(sweep, study, k=6) # a few cases that set every peak
15
+ keep = swept_keepouts(robot, "forearm", box=(lo, hi), protect=study_preserve)
16
+
17
+ **Loads.** `sweep_loads` samples poses over the joint limits (the corners of
18
+ the limit box plus random poses), with joint speeds and accelerations
19
+ (bang-bang: each joint at plus or minus its limit), a payload whose mass and
20
+ position scatter, a contact push at the tool in a random direction, and each
21
+ joint upstream stalling at its rated torque against an obstacle at the tool.
22
+ For each sample it does Newton-Euler over the bodies past the part's far
23
+ joint, so the wrench the part carries there is exact for that state (gravity,
24
+ linear and angular inertia, payload, contact), and expresses it in the
25
+ study's frame (the part at the home pose, mm, N and N·mm about the load
26
+ region's centroid).
27
+
28
+ **Governing cases.** A few thousand samples are not a study. Stress is
29
+ linear in the wrench, so six unit-wrench solves on the solid design space
30
+ give every sample's stress everywhere for the price of six solves: `stress`
31
+ selection then picks, greedily, the samples that bring every cell within 5%
32
+ of its worst stress over the whole sweep, until 98% of the stress-weighted
33
+ cells are covered (usually four to eight cases). The `hull` selection (no
34
+ solve) does the same on the wrench cloud's support function in random
35
+ directions. The report says how close the chosen cases come to the
36
+ envelope, so an agent can see what was dropped.
37
+
38
+ **Design space.** `swept_keepouts` sweeps each neighbour body (the parent
39
+ and the children, from the URDF's collision or visual meshes, or a shape
40
+ given in their place) through the joint between it and the part, over the
41
+ joint's limits, in the part's frame, and returns the swept volume, plus a
42
+ clearance, as mesh keep-outs. The part's own interfaces (`protect`) are cut
43
+ out of them, so a horn the child bolts onto is not deleted by the child's own
44
+ sweep.
45
+
46
+ URDF units are metres, kg and N·m; the study's are mm, N and N·mm. `frame`
47
+ maps the URDF's world at the home pose to the study's frame (default: the
48
+ same axes, metres to mm), or is "link": the study is in the designed link's
49
+ own frame, mm (origin at its proximal joint, as a part is usually drawn).
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import os
54
+ import xml.etree.ElementTree as ET
55
+ from dataclasses import dataclass, field
56
+
57
+ import numpy as np
58
+
59
+ __all__ = ["Joint", "Body", "Robot", "Payload", "LoadSweep", "sweep_loads", "governing_cases",
60
+ "swept_keepouts", "intrusion", "region_centroid", "derive_study"]
61
+
62
+ G = 9.81
63
+
64
+
65
+ # --------------------------------------------------------------------------- URDF
66
+
67
+ def _rpy(r, p, y):
68
+ cr, sr, cp, sp_, cy, sy = np.cos(r), np.sin(r), np.cos(p), np.sin(p), np.cos(y), np.sin(y)
69
+ return np.array([[cy * cp, cy * sp_ * sr - sy * cr, cy * sp_ * cr + sy * sr],
70
+ [sy * cp, sy * sp_ * sr + cy * cr, sy * sp_ * cr - cy * sr],
71
+ [-sp_, cp * sr, cp * cr]])
72
+
73
+
74
+ def _origin(el):
75
+ t = np.eye(4)
76
+ if el is None:
77
+ return t
78
+ t[:3, 3] = [float(v) for v in el.get("xyz", "0 0 0").split()]
79
+ t[:3, :3] = _rpy(*[float(v) for v in el.get("rpy", "0 0 0").split()])
80
+ return t
81
+
82
+
83
+ def _axis_rot(a, q):
84
+ a = np.asarray(a, float)
85
+ k = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
86
+ return np.eye(3) + np.sin(q) * k + (1 - np.cos(q)) * k @ k
87
+
88
+
89
+ def _primitive(geom):
90
+ """A URDF <box>, <cylinder> or <sphere> as (kind, dimensions in metres),
91
+ or None."""
92
+ if geom is None:
93
+ return None
94
+ if (el := geom.find("box")) is not None:
95
+ return "box", tuple(float(v) for v in el.get("size").split())
96
+ if (el := geom.find("cylinder")) is not None:
97
+ return "cylinder", (float(el.get("radius")), float(el.get("length")))
98
+ if (el := geom.find("sphere")) is not None:
99
+ return "sphere", (float(el.get("radius")),)
100
+ return None
101
+
102
+
103
+ @dataclass
104
+ class Joint:
105
+ name: str
106
+ type: str # revolute, continuous, prismatic, fixed
107
+ parent: str
108
+ child: str
109
+ origin: np.ndarray # 4x4, child frame at q = 0 in the parent's frame
110
+ axis: np.ndarray # unit, in the joint (child) frame
111
+ lower: float = -np.pi
112
+ upper: float = np.pi
113
+ effort: float = 0.0 # N·m (N for prismatic)
114
+ velocity: float = 0.0 # rad/s (m/s)
115
+ mimic: tuple | None = None # (joint, multiplier, offset)
116
+
117
+ @property
118
+ def moves(self):
119
+ return self.type != "fixed" and self.mimic is None
120
+
121
+
122
+ @dataclass
123
+ class Body:
124
+ name: str
125
+ mass: float = 0.0
126
+ com: np.ndarray = field(default_factory=lambda: np.zeros(3)) # m, link frame
127
+ inertia: np.ndarray = field(default_factory=lambda: np.zeros((3, 3))) # kg m², about the COM, link axes
128
+ meshes: list = field(default_factory=list) # (path, 4x4 origin in link frame, scale xyz)
129
+
130
+
131
+ class Robot:
132
+ """A kinematic tree from a URDF: bodies, joints, forward kinematics and
133
+ the velocities and accelerations of every body for given joint rates."""
134
+
135
+ def __init__(self, bodies: dict, joints: list, base_dir: str = "."):
136
+ self.bodies = bodies
137
+ self.joints = {j.name: j for j in joints}
138
+ self.base_dir = base_dir
139
+ children = {j.child for j in joints}
140
+ roots = [b for b in bodies if b not in children]
141
+ if len(roots) != 1:
142
+ raise ValueError(f"a URDF tree needs exactly one root link, found {roots}")
143
+ self.root = roots[0]
144
+ # joints in an order where every parent comes before its child
145
+ order, seen = [], {self.root}
146
+ pending = list(joints)
147
+ while pending:
148
+ nxt = [j for j in pending if j.parent in seen]
149
+ if not nxt:
150
+ raise ValueError("URDF joints do not form a tree from " + self.root)
151
+ for j in nxt:
152
+ order.append(j)
153
+ seen.add(j.child)
154
+ pending.remove(j)
155
+ self.order = order
156
+ self.parent_joint = {j.child: j for j in joints}
157
+
158
+ @classmethod
159
+ def from_urdf(cls, path: str) -> "Robot":
160
+ root = ET.parse(path).getroot()
161
+ bodies = {}
162
+ for link in root.findall("link"):
163
+ b = Body(link.get("name"))
164
+ i = link.find("inertial")
165
+ if i is not None:
166
+ b.mass = float(i.find("mass").get("value"))
167
+ o = _origin(i.find("origin"))
168
+ b.com = o[:3, 3].copy()
169
+ ie = i.find("inertia")
170
+ if ie is not None:
171
+ v = {k: float(ie.get(k, 0)) for k in ("ixx", "ixy", "ixz", "iyy", "iyz", "izz")}
172
+ it = np.array([[v["ixx"], v["ixy"], v["ixz"]], [v["ixy"], v["iyy"], v["iyz"]],
173
+ [v["ixz"], v["iyz"], v["izz"]]])
174
+ b.inertia = o[:3, :3] @ it @ o[:3, :3].T
175
+ for tag in ("collision", "visual"):
176
+ for el in link.findall(tag):
177
+ m = el.find("geometry/mesh")
178
+ if m is not None:
179
+ scale = np.array([float(v) for v in m.get("scale", "1 1 1").split()])
180
+ b.meshes.append((m.get("filename"), _origin(el.find("origin")), scale))
181
+ continue
182
+ prim = _primitive(el.find("geometry"))
183
+ if prim is not None:
184
+ b.meshes.append((prim, _origin(el.find("origin")), np.ones(3)))
185
+ if b.meshes:
186
+ break
187
+ bodies[b.name] = b
188
+ joints = []
189
+ for j in root.findall("joint"):
190
+ ax = j.find("axis")
191
+ axis = np.array([float(v) for v in (ax.get("xyz") if ax is not None else "1 0 0").split()])
192
+ lim = j.find("limit")
193
+ jt = j.get("type")
194
+ lo, hi = (-np.pi, np.pi) if jt == "continuous" else (0.0, 0.0)
195
+ eff = vel = 0.0
196
+ if lim is not None:
197
+ if jt != "continuous":
198
+ lo, hi = float(lim.get("lower", 0)), float(lim.get("upper", 0))
199
+ eff, vel = float(lim.get("effort", 0)), float(lim.get("velocity", 0))
200
+ mim = j.find("mimic")
201
+ mimic = None if mim is None else (mim.get("joint"), float(mim.get("multiplier", 1)),
202
+ float(mim.get("offset", 0)))
203
+ joints.append(Joint(j.get("name"), jt, j.find("parent").get("link"), j.find("child").get("link"),
204
+ _origin(j.find("origin")), axis / (np.linalg.norm(axis) or 1.0),
205
+ lo, hi, eff, vel, mimic))
206
+ return cls(bodies, joints, os.path.dirname(os.path.abspath(path)))
207
+
208
+ # ----------------------------------------------------------------- kinematics
209
+
210
+ @property
211
+ def movable(self) -> list:
212
+ return [j.name for j in self.order if j.moves]
213
+
214
+ def _value(self, j: Joint, q: dict):
215
+ if j.type == "fixed":
216
+ return 0.0
217
+ if j.mimic:
218
+ src, mult, off = j.mimic
219
+ return mult * q.get(src, 0.0) + off
220
+ return q.get(j.name, 0.0)
221
+
222
+ def state(self, q: dict | None = None, qd: dict | None = None, qdd: dict | None = None):
223
+ """World transform, angular velocity, angular acceleration and linear
224
+ acceleration of the origin of every link (base fixed)."""
225
+ q, qd, qdd = q or {}, qd or {}, qdd or {}
226
+ T = {self.root: np.eye(4)}
227
+ w = {self.root: np.zeros(3)}
228
+ al = {self.root: np.zeros(3)}
229
+ a = {self.root: np.zeros(3)}
230
+ for j in self.order:
231
+ p = j.parent
232
+ tj = T[p] @ j.origin
233
+ z = tj[:3, :3] @ j.axis
234
+ qv = self._value(j, q)
235
+ if j.mimic:
236
+ src, mult, _ = j.mimic
237
+ dq, ddq = mult * qd.get(src, 0.0), mult * qdd.get(src, 0.0)
238
+ else:
239
+ dq, ddq = qd.get(j.name, 0.0), qdd.get(j.name, 0.0)
240
+ if j.type == "fixed":
241
+ dq = ddq = 0.0
242
+ m = np.eye(4)
243
+ if j.type in ("revolute", "continuous"):
244
+ m[:3, :3] = _axis_rot(j.axis, qv)
245
+ elif j.type == "prismatic":
246
+ m[:3, 3] = j.axis * qv
247
+ tc = tj @ m
248
+ r = tc[:3, 3] - T[p][:3, 3]
249
+ ac = a[p] + np.cross(al[p], r) + np.cross(w[p], np.cross(w[p], r))
250
+ if j.type in ("revolute", "continuous"):
251
+ wc = w[p] + z * dq
252
+ alc = al[p] + z * ddq + np.cross(w[p], z * dq)
253
+ else:
254
+ wc, alc = w[p].copy(), al[p].copy()
255
+ if j.type == "prismatic":
256
+ ac = ac + 2.0 * np.cross(w[p], z * dq) + z * ddq
257
+ T[j.child], w[j.child], al[j.child], a[j.child] = tc, wc, alc, ac
258
+ return T, w, al, a
259
+
260
+ def fk(self, q: dict | None = None) -> dict:
261
+ return self.state(q)[0]
262
+
263
+ def subtree(self, joint: str) -> list:
264
+ """Links carried by `joint` (its child and everything past it)."""
265
+ out = [self.joints[joint].child]
266
+ for j in self.order:
267
+ if j.parent in out and j.child not in out:
268
+ out.append(j.child)
269
+ return out
270
+
271
+ def path_joints(self, link: str) -> list:
272
+ """Joints from the root down to `link`."""
273
+ out = []
274
+ while link in self.parent_joint:
275
+ j = self.parent_joint[link]
276
+ out.append(j.name)
277
+ link = j.parent
278
+ return out[::-1]
279
+
280
+ def far_joint(self, link: str) -> str:
281
+ kids = [j for j in self.order if j.parent == link and j.type != "fixed"]
282
+ if len(kids) != 1:
283
+ raise ValueError(f"{link} has {len(kids)} moving child joints ({[j.name for j in kids]}); "
284
+ "name the one whose load to sweep with joint=")
285
+ return kids[0].name
286
+
287
+ def mesh(self, link: str):
288
+ """The link's mesh in its own frame, metres (trimesh), or None."""
289
+ import trimesh
290
+
291
+ parts = self.meshes(link)
292
+ if not parts:
293
+ return None
294
+ return trimesh.util.concatenate(parts) if len(parts) > 1 else parts[0]
295
+
296
+ def meshes(self, link: str) -> list:
297
+ """The link's collision (else visual) shapes in its own frame, metres,
298
+ one closed trimesh each (they may overlap)."""
299
+ import trimesh
300
+
301
+ parts = []
302
+ for fn, org, scale in self.bodies[link].meshes:
303
+ if not isinstance(fn, str):
304
+ # a URDF box, cylinder or sphere (metres, centred on its origin)
305
+ kind, dims = fn
306
+ m = (trimesh.creation.box(extents=dims) if kind == "box" else
307
+ trimesh.creation.cylinder(radius=dims[0], height=dims[1], sections=48) if kind == "cylinder"
308
+ else trimesh.creation.icosphere(subdivisions=3, radius=dims[0]))
309
+ m.apply_transform(org)
310
+ parts.append(m)
311
+ continue
312
+ p = fn.split("://", 1)[1] if "://" in fn else fn
313
+ cands = [os.path.join(self.base_dir, p), os.path.join(self.base_dir, os.path.basename(p))]
314
+ if "://" in fn:
315
+ cands.append(os.path.join(self.base_dir, *p.split("/")[1:]))
316
+ hit = next((c for c in cands if os.path.exists(c)), None)
317
+ if hit is None:
318
+ continue
319
+ m = trimesh.load(hit, force="mesh")
320
+ m.apply_scale(1.0)
321
+ m.vertices = np.asarray(m.vertices) * scale
322
+ m.apply_transform(org)
323
+ parts.append(m)
324
+ return parts
325
+
326
+
327
+ # --------------------------------------------------------------------------- load sweep
328
+
329
+ @dataclass
330
+ class Payload:
331
+ kg: float
332
+ link: str
333
+ at: tuple = (0.0, 0.0, 0.0) # m, in the link's frame
334
+ radius: float = 0.0 # m: the payload's centre scatters inside this sphere
335
+ min_kg: float | None = None # sampled between min_kg and kg (default: always kg)
336
+
337
+
338
+ @dataclass
339
+ class LoadSweep:
340
+ link: str
341
+ joint: str
342
+ point: np.ndarray # mm, study frame: what the moments are about
343
+ wrenches: np.ndarray # (n, 6): force N, moment N·mm, study frame
344
+ kinds: list # per sample: "pose", "dynamic", "push", "stall:<joint>"
345
+ states: list # per sample: dict(q, qd, qdd, payload)
346
+ region: str | None = None
347
+
348
+ def cases(self, idx, prefix="sweep", cycles: dict | None = None) -> list:
349
+ """Study load cases for samples idx. cycles: {kind: times applied over
350
+ the part's life} by sample kind ("pose", "dynamic", "push", "stall",
351
+ or "stall:j2" for one joint), so durability.py checks fatigue."""
352
+ out = []
353
+ for n, i in enumerate(idx):
354
+ f, m = self.wrenches[i, :3], self.wrenches[i, 3:]
355
+ case = {"name": f"{prefix}-{n}-{self.kinds[i].replace(':', '-')}",
356
+ "loads": [{"region": self.region, "force": [round(float(v), 2) for v in f],
357
+ "moment": [round(float(v), 1) for v in m]}]}
358
+ kind = self.kinds[i]
359
+ c = (cycles or {}).get(kind, (cycles or {}).get(kind.split(":")[0]))
360
+ if c:
361
+ case["cycles"] = float(c)
362
+ out.append(case)
363
+ return out
364
+
365
+
366
+ def _frame(frame, robot=None, link=None):
367
+ if frame is None or (isinstance(frame, str) and frame in ("world", "link")):
368
+ f = np.eye(4)
369
+ f[:3, :3] *= 1000.0
370
+ if frame == "link":
371
+ # world at home -> the link's own frame, then metres to mm
372
+ f = f @ np.linalg.inv(robot.fk({})[link])
373
+ return f
374
+ if isinstance(frame, str):
375
+ raise ValueError(f"unknown frame {frame!r}: world, link or a 4x4 matrix")
376
+ return np.asarray(frame, float)
377
+
378
+
379
+ class _State:
380
+ """Per-body loads of one mechanism state, so the wrench at any joint is
381
+ a sum over that joint's subtree: f = m (g - a) on each body (and the
382
+ payload, and a contact), with its moment about the world origin less the
383
+ body's rate of change of angular momentum."""
384
+
385
+ def __init__(self, robot, q, qd, qdd, gravity, payload=None):
386
+ self.robot = robot
387
+ self.T, w, al, a = robot.state(q, qd, qdd)
388
+ names, f, m0 = [], [], []
389
+ for name, b in robot.bodies.items():
390
+ if b.mass <= 0 or name not in self.T:
391
+ continue
392
+ R = self.T[name][:3, :3]
393
+ o = self.T[name][:3, 3]
394
+ rc = R @ b.com
395
+ ac = a[name] + np.cross(al[name], rc) + np.cross(w[name], np.cross(w[name], rc))
396
+ fi = b.mass * (gravity - ac)
397
+ Iw = R @ b.inertia @ R.T
398
+ names.append(name)
399
+ f.append(fi)
400
+ m0.append(np.cross(o + rc, fi) - (Iw @ al[name] + np.cross(w[name], Iw @ w[name])))
401
+ if payload is not None:
402
+ kg, at, link = payload
403
+ pos = (self.T[link] @ np.append(at, 1.0))[:3]
404
+ r = pos - self.T[link][:3, 3]
405
+ ap = a[link] + np.cross(al[link], r) + np.cross(w[link], np.cross(w[link], r))
406
+ fi = kg * (gravity - ap)
407
+ names.append(link)
408
+ f.append(fi)
409
+ m0.append(np.cross(pos, fi))
410
+ self.tool = pos
411
+ self.names = np.array(names)
412
+ self.f = np.array(f).reshape(-1, 3)
413
+ self.m0 = np.array(m0).reshape(-1, 3)
414
+
415
+ def wrench(self, joint, contact=None):
416
+ """Force and moment (world, SI, about the joint's origin) that what
417
+ `joint` carries puts on its parent; contact = (force, world point) on
418
+ a carried body."""
419
+ sub = np.isin(self.names, self.robot.subtree(joint))
420
+ F = self.f[sub].sum(axis=0)
421
+ M = self.m0[sub].sum(axis=0)
422
+ if contact is not None:
423
+ F = F + contact[0]
424
+ M = M + np.cross(contact[1], contact[0])
425
+ o = self.T[self.robot.joints[joint].child][:3, 3]
426
+ return F, M - np.cross(o, F)
427
+
428
+ static = None # the same pose at rest (for a counterbalance's share)
429
+
430
+ def gravity_torque(self, joint):
431
+ return (self.static or self).torque(joint)
432
+
433
+ def torque(self, joint, contact=None):
434
+ """What the joint's actuator has to hold (about its axis)."""
435
+ j = self.robot.joints[joint]
436
+ F, M = self.wrench(joint, contact)
437
+ return float(-(self.T[j.child][:3, :3] @ j.axis) @ M)
438
+
439
+
440
+ def _largest_scale(a, b, effort):
441
+ """Largest s in [0, 1] (or inf) with |a_j + s b_j| <= effort_j for every j."""
442
+ s = np.inf
443
+ for aj, bj, ej in zip(a, b, effort):
444
+ if ej <= 0 or abs(bj) < 1e-12:
445
+ continue
446
+ lim = (ej - aj) / bj if bj > 0 else (ej + aj) / -bj
447
+ s = min(s, max(lim, 0.0))
448
+ return s
449
+
450
+
451
+ def sweep_loads(robot: Robot, link: str, *, joint: str | None = None, payload: Payload | None = None,
452
+ samples: int = 1500, corners: bool = True, speed: float = 1.0, accel: float | dict | None = None,
453
+ ramp: float = 0.2, push: float = 20.0, stall: bool = True, contact_max: float | None = None,
454
+ balanced: tuple = (), gravity=(0.0, 0.0, -G),
455
+ masses: dict | None = None, frame=None, point=None, region: str | None = None,
456
+ seed: int = 0) -> LoadSweep:
457
+ """Sample the states the mechanism can be in and return the wrench `link`
458
+ carries at its far `joint` in each, in the study frame.
459
+
460
+ Every state is one the actuators can produce and hold: accelerations
461
+ are scaled down until no joint needs more than its rated effort, and a
462
+ contact at the payload pushes only as hard as the weakest joint between
463
+ the base and the payload can resist (a harder push back-drives it).
464
+
465
+ speed: fraction of each joint's velocity limit. accel: rad/s² per joint
466
+ (a number, or {joint: value}); default velocity / ramp, i.e. full speed
467
+ in `ramp` seconds, before the effort limit. push: N, a contact at the
468
+ payload in a random direction. stall: each actuated joint from the base
469
+ to `joint` at its rated effort against an obstacle at the payload.
470
+ contact_max: N, the hardest an obstacle pushes back on a stalled joint
471
+ (default: push). Near a joint's axis a stalled joint has almost no
472
+ lever and, held by nothing but the structure, would push without limit
473
+ (a roll joint with the wrist straight), so this is the input to set from
474
+ what the arm can meet.
475
+ balanced: joints whose static gravity torque a counterbalance carries
476
+ (a spring), so only the rest counts against their effort.
477
+ masses: {link: kg} overrides (centres of mass kept). point: mm in the
478
+ study frame to take moments about (the load region's centroid; default
479
+ the joint's origin)."""
480
+ rng = np.random.default_rng(seed)
481
+ contact_max = push if contact_max is None else contact_max
482
+ if masses:
483
+ for n, kg in masses.items():
484
+ robot.bodies[n].mass = float(kg)
485
+ joint = joint or robot.far_joint(link)
486
+ carried = robot.subtree(joint)
487
+ frame = _frame(frame, robot, link)
488
+ jt = robot.joints
489
+ names = robot.movable
490
+ lim = {n: (jt[n].lower, jt[n].upper) for n in names}
491
+ g = np.asarray(gravity, float)
492
+ if payload is not None and payload.link not in carried:
493
+ raise ValueError(f"payload link {payload.link!r} is not carried by {joint}")
494
+
495
+ def acc(n):
496
+ if isinstance(accel, dict):
497
+ return float(accel.get(n, jt[n].velocity / ramp))
498
+ return float(accel) if accel is not None else jt[n].velocity / ramp
499
+
500
+ # joints that change the load: those above the part (they turn gravity
501
+ # round it) and those past it (they move the carried mass)
502
+ above = robot.path_joints(link)
503
+ relevant = [n for n in names if n in above or jt[n].child in carried]
504
+ # joints whose rated effort bounds what the arm can do: every actuated
505
+ # revolute joint, and for a contact, those between the base and the payload
506
+ actuated = [n for n in names if jt[n].type in ("revolute", "continuous") and jt[n].effort > 0]
507
+ effort = np.array([jt[n].effort for n in actuated])
508
+ reach = robot.path_joints(payload.link) if payload is not None else []
509
+ chain = [n for n in actuated if n in reach]
510
+ chain_effort = np.array([jt[n].effort for n in chain])
511
+
512
+ poses = []
513
+ if corners:
514
+ levels = [(lo, 0.5 * (lo + hi), hi) if lo < hi else (lo,) for lo, hi in (lim[n] for n in relevant)]
515
+ if int(np.prod([len(v) for v in levels])) <= 4096:
516
+ for idx in np.ndindex(*[len(v) for v in levels]):
517
+ poses.append({n: levels[i][idx[i]] for i, n in enumerate(relevant)})
518
+ for _ in range(samples):
519
+ poses.append({n: rng.uniform(*lim[n]) for n in relevant})
520
+
521
+ T0 = robot.fk({})
522
+ home_link = T0[link]
523
+ o_study = (frame @ np.append(T0[jt[joint].child][:3, 3], 1.0))[:3]
524
+ pt_study = o_study if point is None else np.asarray(point, float)
525
+ scale = float(np.cbrt(abs(np.linalg.det(frame[:3, :3]))))
526
+ rot_frame = frame[:3, :3] / scale
527
+
528
+ W, kinds, states = [], [], []
529
+
530
+ def record(st, q, qd, qdd, kind, pay, contact=None, extra=None, moment=None):
531
+ F, M = st.wrench(joint, contact)
532
+ if moment is not None:
533
+ # (axis, torque): the joint at exactly that torque about its axis
534
+ # (the actuator holds the load and the jam together)
535
+ z, tq = moment
536
+ M = M + (tq - z @ M) * z
537
+ # world at this state -> the part's frame -> world at home -> study frame
538
+ Rl = home_link[:3, :3] @ st.T[link][:3, :3].T
539
+ Fs = rot_frame @ (Rl @ F)
540
+ Ms = rot_frame @ (Rl @ M) * scale # N·m -> N·mm
541
+ W.append(np.concatenate([Fs, Ms + np.cross(o_study - pt_study, Fs)]))
542
+ kinds.append(kind)
543
+ rec = {"q": {k: round(float(v), 4) for k, v in q.items()},
544
+ "payloadKg": None if pay is None else round(float(pay[0]), 3)}
545
+ if qd:
546
+ rec["qd"] = {k: round(float(v), 3) for k, v in qd.items()}
547
+ rec["qdd"] = {k: round(float(v), 3) for k, v in qdd.items()}
548
+ if extra:
549
+ rec.update(extra)
550
+ states.append(rec)
551
+
552
+ def draw_payload():
553
+ if payload is None:
554
+ return None
555
+ lo = payload.kg if payload.min_kg is None else payload.min_kg
556
+ kg = rng.uniform(lo, payload.kg)
557
+ off = np.zeros(3)
558
+ if payload.radius > 0:
559
+ v = rng.normal(size=3)
560
+ off = v / np.linalg.norm(v) * payload.radius * rng.uniform() ** (1 / 3)
561
+ return kg, np.asarray(payload.at, float) + off
562
+
563
+ def pay3(p):
564
+ return None if p is None else (p[0], p[1], payload.link)
565
+
566
+ def held(st, joints):
567
+ """Torques the actuators hold in state st, less a counterbalance's share."""
568
+ return np.array([st.torque(n) - (st.gravity_torque(n) if n in balanced else 0.0) for n in joints])
569
+
570
+ def pushed(st, u, cap):
571
+ """The contact u (unit, at the tool) as hard as the chain holds, up to cap N."""
572
+ a = held(st, chain)
573
+ b = np.array([st.torque(n, (u, st.tool)) - st.torque(n) for n in chain])
574
+ s = min(_largest_scale(a, b, chain_effort), cap)
575
+ return (s * u, st.tool), s
576
+
577
+ moving = [n for n in relevant if jt[n].type in ("revolute", "continuous", "prismatic")]
578
+ stall_every = max(1, len(poses) // 200)
579
+ upstream = [n for n in robot.path_joints(jt[joint].child) if n in chain]
580
+ jam = jt[joint].type in ("revolute", "continuous") and jt[joint].effort > 0
581
+ for i, q in enumerate(poses):
582
+ pay = draw_payload()
583
+ st = _State(robot, q, None, None, g, pay3(pay))
584
+ record(st, q, None, None, "pose", pay)
585
+ # the same pose moving: each joint near its speed limit and at its
586
+ # acceleration limit (bang-bang, signs at random), with the
587
+ # acceleration scaled back to what the actuators can deliver
588
+ if speed > 0 and moving:
589
+ qd = {n: rng.choice([-1.0, 1.0]) * speed * jt[n].velocity * rng.uniform(0.5, 1.0) for n in moving}
590
+ qdd = {n: rng.choice([-1.0, 1.0]) * acc(n) for n in moving}
591
+ s0 = _State(robot, q, qd, None, g, pay3(pay))
592
+ s1 = _State(robot, q, qd, qdd, g, pay3(pay))
593
+ s0.static = st
594
+ a = held(s0, actuated)
595
+ b = np.array([s1.torque(n) for n in actuated]) - np.array([s0.torque(n) for n in actuated])
596
+ s = min(_largest_scale(a, b, effort), 1.0)
597
+ qdd = {n: v * s for n, v in qdd.items()}
598
+ record(_State(robot, q, qd, qdd, g, pay3(pay)), q, qd, qdd, "dynamic", pay,
599
+ extra={"accelScale": round(float(s), 3)})
600
+ if stall and i % stall_every == 0 and jam:
601
+ # the far joint's own actuator at full effort against a jam past
602
+ # it (a hard stop, a blocked gripper, the hand caught): its whole
603
+ # rated torque goes through the part, however little lever a
604
+ # contact at the tool would have
605
+ z = st.T[jt[joint].child][:3, :3] @ jt[joint].axis
606
+ for sgn in (-1.0, 1.0):
607
+ record(st, q, None, None, f"stall:{joint}", pay, None,
608
+ {"jam": True, "effortNm": jt[joint].effort}, moment=(z, sgn * jt[joint].effort))
609
+ if payload is None:
610
+ continue
611
+ if push > 0 and i % 2 == 0:
612
+ v = rng.normal(size=3)
613
+ c, s = pushed(st, v / np.linalg.norm(v), push)
614
+ record(st, q, None, None, "push", pay, c, {"contactN": round(float(s), 2)})
615
+ if stall and i % stall_every == 0:
616
+ for n in upstream:
617
+ tj = st.T[jt[n].child]
618
+ z = tj[:3, :3] @ jt[n].axis
619
+ r = st.tool - tj[:3, 3]
620
+ r = r - z * (z @ r)
621
+ if r @ r < 1e-10:
622
+ continue
623
+ for sgn in (-1.0, 1.0):
624
+ # the obstacle's push that loads joint n hardest per newton,
625
+ # grown until n (or a weaker joint on the way) is at its effort
626
+ u = sgn * np.cross(z, r) / np.linalg.norm(np.cross(z, r))
627
+ c, s = pushed(st, u, contact_max)
628
+ if np.isfinite(s):
629
+ record(st, q, None, None, f"stall:{n}", pay, c, {"contactN": round(float(s), 2)})
630
+ return LoadSweep(link, joint, pt_study, np.array(W), kinds, states, region)
631
+
632
+
633
+ # --------------------------------------------------------------------------- governing cases
634
+
635
+ def _greedy_cover(R, k, threshold=0.95, target=0.98, weights=None, chunk=256):
636
+ """Pick rows of R (samples x responses, >= 0) so that every response
637
+ column reaches `threshold` of its maximum over all rows; greedy on the
638
+ weighted coverage sum_c w_c min(1, best_c / (threshold env_c))."""
639
+ env = R.max(axis=0)
640
+ ok = env > 0
641
+ R, env = R[:, ok], env[ok]
642
+ w = (env ** 2 if weights is None else np.asarray(weights, float)[ok])
643
+ w = w / w.sum()
644
+ cur = np.zeros(R.shape[1])
645
+ chosen, history = [], []
646
+ denom = threshold * env
647
+ for _ in range(k):
648
+ best, best_gain = -1, -1.0
649
+ for s in range(0, R.shape[0], chunk):
650
+ frac = np.minimum(1.0, R[s:s + chunk] / denom)
651
+ gain = (np.maximum(frac - cur, 0.0) * w).sum(axis=1)
652
+ j = int(np.argmax(gain))
653
+ if gain[j] > best_gain:
654
+ best, best_gain = s + j, float(gain[j])
655
+ if best_gain <= 1e-9:
656
+ break
657
+ chosen.append(best)
658
+ cur = np.maximum(cur, np.minimum(1.0, R[best] / denom))
659
+ history.append(float((cur * w).sum()))
660
+ if history[-1] >= target:
661
+ break
662
+ ratio = R[chosen].max(axis=0) / env
663
+ return chosen, history, ratio, env
664
+
665
+
666
+ def _unit_stress(study_dict, region, max_elements=20000, band=2.0, base_dir=None):
667
+ """Element stress (6 unit wrenches x elements x 6) on the solid design
668
+ space, and the stress-free mask near loads and supports."""
669
+ import copy
670
+
671
+ from scipy.spatial import cKDTree
672
+
673
+ from .domain import build_domain
674
+ from .fasteners import expand_fasteners
675
+ from .fem import Solver, Stiffness
676
+ from .spec import study_from_dict
677
+
678
+ # the fasteners' regions are needed (the load region may be one), their
679
+ # preload case is not: it is not a unit wrench
680
+ s = expand_fasteners(copy.deepcopy(study_dict))
681
+ s.pop("loads", None)
682
+ s["loadCases"] = []
683
+ for k in range(6):
684
+ e = [0.0] * 6
685
+ e[k] = 1.0
686
+ s["loadCases"].append({"name": f"unit-{k}", "loads": [{"region": region, "force": e[:3], "moment": e[3:]}]})
687
+ s["resolution"] = {"maxElements": int(max_elements)}
688
+ s.pop("symmetry", None)
689
+ st = study_from_dict(s, base_dir=base_dir)
690
+ dom = build_domain(st)
691
+ stiff = Stiffness(dom.mesh, st.material.nu if hasattr(st.material, "nu") else 0.35, dom.fixed)
692
+ kmat = stiff.assemble(np.ones(dom.n_elem))
693
+ if dom.springs is not None:
694
+ kmat = (kmat + dom.springs).tocsr()
695
+ us = Solver(dom.mesh, rtol=1e-6, maxiter=3000).solve_many(kmat, list(dom.loads))
696
+ S = np.stack([stiff.stress(u, 1.0) for u in us])
697
+ contact = dom.support_nodes | dom.load_nodes
698
+ centers = dom.mesh.elem_centers()
699
+ d = cKDTree(dom.mesh.node_xyz[contact]).query(centers, k=1)[0]
700
+ body = d > band * dom.pitch
701
+ return S[:, body], centers[body], dom
702
+
703
+
704
+ def governing_cases(sweep: LoadSweep, study: dict | None = None, *, k: int = 8, method: str | None = None,
705
+ threshold: float = 0.95, target: float = 0.98, max_elements: int = 20000,
706
+ directions: int = 600, seed: int = 0, base_dir: str | None = None,
707
+ cycles: dict | None = None) -> dict:
708
+ """Collapse a sweep to at most k cases that come within `threshold` of
709
+ the sweep's worst stress at (a `target` share of) every point.
710
+
711
+ method "stress" (default when a study is given): six unit-wrench solves
712
+ on the solid design space, then greedy cover of every cell's von Mises
713
+ envelope. "hull": no solve, greedy cover of the wrench cloud's support
714
+ function over random directions (moments scaled by the part's size).
715
+ cycles: {sample kind: life cycles}, written on the cases (LoadSweep.cases)."""
716
+ from .fem import von_mises
717
+
718
+ W = sweep.wrenches
719
+ method = method or ("stress" if study is not None else "hull")
720
+ info = {"method": method, "samples": int(len(W))}
721
+ if method == "stress":
722
+ if study is None or sweep.region is None:
723
+ raise ValueError("stress selection needs the study and the sweep's load region")
724
+ S, centers, dom = _unit_stress(study, sweep.region, max_elements=max_elements, base_dir=base_dir)
725
+ R = np.empty((len(W), S.shape[1]), np.float32)
726
+ for s in range(0, len(W), 64):
727
+ sig = np.einsum("nk,kec->nec", W[s:s + 64], S)
728
+ R[s:s + 64] = von_mises(sig.reshape(-1, 6)).reshape(len(sig), -1)
729
+ info.update(cells=int(S.shape[1]), pitch=float(dom.pitch))
730
+ elif method == "hull":
731
+ L = 1.0
732
+ if study is not None:
733
+ from .spec import study_from_dict
734
+ lo, hi = study_from_dict(study, base_dir=base_dir).bounds()
735
+ L = float(np.linalg.norm(hi - lo)) / 2
736
+ Wn = np.concatenate([W[:, :3], W[:, 3:] / L], axis=1)
737
+ d = np.random.default_rng(seed).normal(size=(directions, 6))
738
+ d /= np.linalg.norm(d, axis=1, keepdims=True)
739
+ R = np.maximum(Wn @ d.T, 0.0)
740
+ info.update(lengthMm=L, directions=directions)
741
+ else:
742
+ raise ValueError(f"unknown method {method!r}: stress or hull")
743
+ chosen, hist, ratio, env = _greedy_cover(R, k, threshold, target)
744
+ top = env >= np.quantile(env, 0.8)
745
+ info.update({
746
+ "chosen": [int(i) for i in chosen],
747
+ "coverage": [round(h, 4) for h in hist],
748
+ # how close the chosen cases come to the sweep's worst, cell by cell
749
+ "envelopeRatio": {"min": round(float(ratio.min()), 3), "p5": round(float(np.quantile(ratio, 0.05)), 3),
750
+ "p5Top20": round(float(np.quantile(ratio[top], 0.05)), 3)},
751
+ "kinds": [sweep.kinds[i] for i in chosen],
752
+ "states": [sweep.states[i] for i in chosen],
753
+ })
754
+ if method == "stress":
755
+ info["peakUnitVonMises"] = round(float(env.max()), 4)
756
+ info["cases"] = sweep.cases(chosen, cycles=cycles)
757
+ return info
758
+
759
+
760
+ # --------------------------------------------------------------------------- swept volumes
761
+
762
+ def swept_keepouts(robot: Robot, link: str, *, box, pitch: float = 1.0, clearance: float = 1.0,
763
+ neighbours: list | None = None, shapes: dict | None = None, protect: list | None = None,
764
+ frame=None, steps: int | None = None, out_dir: str | None = None, prefix: str = "sweep"):
765
+ """The space the neighbours of `link` occupy over their joints' ranges,
766
+ in the study frame, as keep-outs.
767
+
768
+ box: (lo, hi) mm, the region to resolve (the design space's bounds).
769
+ neighbours: link names; default the parent link and every link on a
770
+ moving child joint, each carrying what is fixed to it. shapes:
771
+ {link: a loadpath Shape or a closed trimesh, in that link's frame, mm}
772
+ in place of its URDF mesh (the part as designed now, or an envelope for
773
+ one not designed yet). protect: Shapes (study frame)
774
+ cut out of the result (the part's own interfaces). Returns a list of
775
+ dicts {id, link, joint, grid, origin, mesh, volume, keepOut} where
776
+ keepOut is a study entry (mesh written to out_dir/<id>.stl if given)."""
777
+ import trimesh
778
+ from scipy import ndimage
779
+ from skimage import measure
780
+
781
+ from .verify import voxelize
782
+
783
+ frame = _frame(frame, robot, link)
784
+ lo, hi = (np.asarray(v, float) for v in box)
785
+ lo, hi = lo - clearance - pitch, hi + clearance + pitch
786
+ dims = np.maximum(np.ceil((hi - lo) / pitch).astype(int), 1)
787
+ T0 = robot.fk({})
788
+ jt = robot.joints
789
+
790
+ def rigid_group(name):
791
+ out = [name]
792
+ for j in robot.order:
793
+ if j.parent in out and j.type == "fixed":
794
+ out.append(j.child)
795
+ return out
796
+
797
+ if neighbours is None:
798
+ neighbours = []
799
+ pj = robot.parent_joint.get(link)
800
+ if pj is not None:
801
+ up = pj.parent
802
+ while up in robot.parent_joint and robot.parent_joint[up].type == "fixed":
803
+ up = robot.parent_joint[up].parent
804
+ neighbours.append(up)
805
+ neighbours += [j.child for j in robot.order if j.parent == link and j.moves]
806
+
807
+ results = []
808
+ for nb in neighbours:
809
+ # the joints between the part and this neighbour
810
+ pa, pb = robot.path_joints(link), robot.path_joints(nb)
811
+ common = 0
812
+ while common < min(len(pa), len(pb)) and pa[common] == pb[common]:
813
+ common += 1
814
+ between = [n for n in pa[common:] + pb[common:] if jt[n].moves]
815
+ # the neighbour (and what is fixed to it) as occupancy on a grid in
816
+ # its own frame, mm, at half the pitch
817
+ fine = 0.5 * pitch
818
+ pts = []
819
+ for b in rigid_group(nb):
820
+ if shapes and b in shapes and not hasattr(shapes[b], "faces"):
821
+ sh = shapes[b]
822
+ slo, shi = sh.bounds()
823
+ g = np.stack(np.meshgrid(*[np.arange(slo[i] + fine / 2, shi[i], fine) for i in range(3)],
824
+ indexing="ij"), -1).reshape(-1, 3)
825
+ pb_mm = g[sh.contains(g)]
826
+ else:
827
+ # each shape on its own: ray parity over overlapping shapes
828
+ # would read their overlap as outside
829
+ ms = [shapes[b]] if shapes and b in shapes else robot.meshes(b)
830
+ if not ms:
831
+ continue
832
+ pb_mm = []
833
+ for m in ms:
834
+ if not (shapes and b in shapes):
835
+ m.apply_scale(1000.0)
836
+ solid, org = voxelize(m, fine)
837
+ pb_mm.append(org + (np.argwhere(solid) + 0.5) * fine)
838
+ pb_mm = np.concatenate(pb_mm)
839
+ rel = np.linalg.inv(T0[nb]) @ T0[b]
840
+ pts.append(pb_mm @ rel[:3, :3].T + rel[:3, 3] * 1000.0)
841
+ pts = np.concatenate(pts) if pts else np.zeros((0, 3))
842
+ if not len(pts):
843
+ continue
844
+ blo = pts.min(axis=0) - fine
845
+ bdims = np.ceil((pts.max(axis=0) + fine - blo) / fine).astype(int) + 1
846
+ body = np.zeros(bdims, bool)
847
+ body[tuple(np.floor((pts - blo) / fine).astype(int).T)] = True
848
+ corners_nb = np.array([[blo[0] + i * bdims[0] * fine, blo[1] + j * bdims[1] * fine,
849
+ blo[2] + k * bdims[2] * fine] for i in (0, 1) for j in (0, 1) for k in (0, 1)])
850
+ # poses of the joints between, over their ranges: steps small enough
851
+ # that the farthest point moves under half a pitch per step
852
+ reach = float(np.linalg.norm(corners_nb, axis=1).max())
853
+ grids = []
854
+ for n in between:
855
+ lo_q, hi_q = jt[n].lower, jt[n].upper
856
+ travel = (hi_q - lo_q) * (reach if jt[n].type in ("revolute", "continuous") else 1000.0)
857
+ grids.append(np.linspace(lo_q, hi_q, max(steps or int(np.ceil(travel / (0.5 * pitch))) + 1, 2)))
858
+ if np.prod([len(gq) for gq in grids]) > 20000:
859
+ # several joints between: cap the product (the clearance covers the gaps)
860
+ per = int(20000 ** (1 / len(grids)))
861
+ grids = [np.linspace(gq[0], gq[-1], per) for gq in grids]
862
+ occ = np.zeros(dims, bool)
863
+ for vals in np.ndindex(*[len(gq) for gq in grids]):
864
+ q = {n: grids[i][vals[i]] for i, n in enumerate(between)}
865
+ T = robot.fk(q)
866
+ # neighbour frame (mm) -> part frame -> world at home -> study frame
867
+ rel = frame @ T0[link] @ np.linalg.inv(T[link]) @ T[nb] @ np.diag([1e-3, 1e-3, 1e-3, 1.0])
868
+ c = corners_nb @ rel[:3, :3].T + rel[:3, 3]
869
+ i0 = np.clip(np.floor((c.min(axis=0) - lo) / pitch).astype(int), 0, dims)
870
+ i1 = np.clip(np.ceil((c.max(axis=0) - lo) / pitch).astype(int), 0, dims)
871
+ if np.any(i1 <= i0):
872
+ continue
873
+ ax = [lo[d] + (np.arange(i0[d], i1[d]) + 0.5) * pitch for d in range(3)]
874
+ cells = np.stack(np.meshgrid(*ax, indexing="ij"), -1).reshape(-1, 3)
875
+ inv = np.linalg.inv(rel)
876
+ pn = cells @ inv[:3, :3].T + inv[:3, 3]
877
+ bi = np.floor((pn - blo) / fine).astype(np.int64)
878
+ ok = np.all((bi >= 0) & (bi < bdims), axis=1)
879
+ hit = np.zeros(len(cells), bool)
880
+ hit[ok] = body[tuple(bi[ok].T)]
881
+ occ[i0[0]:i1[0], i0[1]:i1[1], i0[2]:i1[2]] |= hit.reshape(tuple(i1 - i0))
882
+ if clearance > 0:
883
+ occ = ndimage.distance_transform_edt(~occ) * pitch <= clearance
884
+ if protect:
885
+ c = lo + (np.argwhere(np.ones(dims, bool)) + 0.5) * pitch
886
+ inside = np.zeros(len(c), bool)
887
+ for sh in protect:
888
+ inside |= sh.contains(c)
889
+ occ &= ~inside.reshape(dims)
890
+ vol = float(occ.sum() * pitch ** 3)
891
+ rid = f"{prefix}-{nb}"
892
+ entry = {"id": rid, "link": nb, "joints": between, "grid": occ, "origin": lo, "pitch": pitch,
893
+ "volume": vol, "mesh": None, "keepOut": None}
894
+ if occ.any():
895
+ pad = np.pad(occ, 1).astype(np.float32)
896
+ v, f, _, _ = measure.marching_cubes(pad, 0.5)
897
+ mesh = trimesh.Trimesh((v - 1.0 + 0.5) * pitch + lo, f[:, ::-1], process=True)
898
+ entry["mesh"] = mesh
899
+ spec = {"type": "mesh"}
900
+ if out_dir:
901
+ os.makedirs(out_dir, exist_ok=True)
902
+ mesh.export(os.path.join(out_dir, f"{rid}.stl"))
903
+ spec["path"] = f"{rid}.stl"
904
+ else:
905
+ import base64
906
+ spec["stlBase64"] = base64.b64encode(trimesh.exchange.stl.export_stl(mesh)).decode()
907
+ entry["keepOut"] = {"id": rid, "bore": False, "shape": spec}
908
+ results.append(entry)
909
+ return results
910
+
911
+
912
+ def intrusion(entries, mesh, pitch=None) -> dict:
913
+ """mm³ of `mesh` (study frame, a part as designed) inside each swept
914
+ keep-out: where the part hits a neighbour somewhere in its range."""
915
+ out = {}
916
+ for e in entries:
917
+ p = e["pitch"] if pitch is None else pitch
918
+ from .verify import voxelize
919
+ solid, origin = voxelize(mesh, p)
920
+ c = origin + (np.argwhere(solid) + 0.5) * p
921
+ ijk = np.floor((c - e["origin"]) / e["pitch"]).astype(np.int64)
922
+ inb = np.all((ijk >= 0) & (ijk < e["grid"].shape), axis=1)
923
+ hit = np.zeros(len(c), bool)
924
+ hit[inb] = e["grid"][tuple(ijk[inb].T)]
925
+ out[e["id"]] = round(float(hit.sum() * p ** 3), 1)
926
+ return out
927
+
928
+
929
+ # --------------------------------------------------------------------------- whole study
930
+
931
+ def region_centroid(study_dict: dict, region: str, samples: int = 20000, seed: int = 0,
932
+ base_dir: str | None = None) -> np.ndarray:
933
+ from .spec import study_from_dict
934
+
935
+ st = study_from_dict(study_dict, base_dir=base_dir)
936
+ shape, _ = st.region_shape(region)
937
+ lo, hi = shape.bounds()
938
+ p = np.random.default_rng(seed).uniform(lo, hi, size=(samples, 3))
939
+ inside = p[shape.contains(p)]
940
+ return inside.mean(axis=0) if len(inside) else 0.5 * (lo + hi)
941
+
942
+
943
+ def derive_study(robot: Robot, link: str, study: dict, region: str, *, payload: Payload | None = None,
944
+ keep_cases: bool = False, sweep_space: bool = True, k: int = 8, method: str | None = None,
945
+ sweep_kw: dict | None = None, space_kw: dict | None = None, out_dir: str | None = None,
946
+ base_dir: str | None = None, cycles: dict | None = None, frame=None):
947
+ """A study with its loads (and, with sweep_space, its neighbour keep-outs)
948
+ derived from the mechanism. `study` supplies the interfaces: supports,
949
+ preserve, keep-outs for hardware, material, goal and a design space
950
+ (its bounds are where the sweep is resolved). The swept keep-outs are
951
+ written to out_dir as STLs the returned study names by file name, so
952
+ save the study there too (without out_dir they are inlined). `frame`
953
+ (see the module notes; "link" for a study drawn in the link's own
954
+ frame) goes to both the load sweep and the swept keep-outs. The study's
955
+ own load cases are replaced (it needs none) unless keep_cases. Returns
956
+ (study, report)."""
957
+ import copy
958
+
959
+ from .spec import study_from_dict
960
+
961
+ s = copy.deepcopy(study)
962
+ # parsing wants a load case; the derived ones replace it
963
+ probe = s if s.get("loadCases") or s.get("loads") else \
964
+ {**s, "loadCases": [{"name": "placeholder", "loads": [{"region": region, "force": [0, 0, -1]}]}]}
965
+ point = region_centroid(probe, region, base_dir=base_dir)
966
+ sweep = sweep_loads(robot, link, payload=payload, point=point, region=region,
967
+ **{"frame": frame, **(sweep_kw or {})})
968
+ picked = governing_cases(sweep, probe, k=k, method=method, base_dir=base_dir, cycles=cycles)
969
+ report = {"link": link, "joint": sweep.joint, "region": region,
970
+ "pointMm": [round(float(v), 2) for v in point], **{kk: v for kk, v in picked.items() if kk != "cases"}}
971
+ kinds = [kk.split(":")[0] for kk in picked["kinds"]]
972
+ if kinds and all(kk == "push" for kk in kinds):
973
+ report["note"] = (f"all {len(kinds)} governing cases are the push: its size (push, the CLI's --push) "
974
+ "sets this part's size")
975
+ s["loadCases"] = (s.get("loadCases", []) if keep_cases else []) + picked["cases"]
976
+ if sweep_space:
977
+ st = study_from_dict(s, base_dir=base_dir)
978
+ lo, hi = st.bounds()
979
+ protect = [r.shape for r in st.preserve] + [sp.shape for sp in st.supports if sp.shape is not None]
980
+ protect += [st.region_shape(sp.region)[0] for sp in st.supports if sp.region]
981
+ entries = swept_keepouts(robot, link, box=(lo, hi), protect=protect, out_dir=out_dir,
982
+ **{"frame": frame, **(space_kw or {})})
983
+ s["keepOut"] = s.get("keepOut", []) + [e["keepOut"] for e in entries if e["keepOut"]]
984
+ report["sweptKeepOuts"] = {e["id"]: {"joints": e["joints"], "mm3": round(e["volume"], 1)} for e in entries}
985
+ return s, report