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.
- loadpath/README.md +294 -0
- loadpath/SKILL.md +584 -0
- loadpath/__init__.py +17 -0
- loadpath/__main__.py +1202 -0
- loadpath/am.py +316 -0
- loadpath/amgfast.py +326 -0
- loadpath/assembly.py +985 -0
- loadpath/benchmarks/__init__.py +0 -0
- loadpath/benchmarks/beams.py +266 -0
- loadpath/benchmarks/cases/ge-bracket.json +380 -0
- loadpath/benchmarks/evaluate.py +172 -0
- loadpath/benchmarks/mechanisms.py +416 -0
- loadpath/benchmarks/problems.py +140 -0
- loadpath/benchmarks/reference/bridge-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/cantilever-3d.top3d.npz +0 -0
- loadpath/benchmarks/reference/lbracket-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft1.npz +0 -0
- loadpath/benchmarks/reference/mbb-2d.top88-ft2.npz +0 -0
- loadpath/benchmarks/reference.py +138 -0
- loadpath/benchmarks/run.py +301 -0
- loadpath/brep.py +1470 -0
- loadpath/bucklingopt.py +300 -0
- loadpath/buildsim.py +1490 -0
- loadpath/cadimport.py +904 -0
- loadpath/calibration.py +885 -0
- loadpath/cavities.py +211 -0
- loadpath/checks/__init__.py +0 -0
- loadpath/checks/dfm.py +354 -0
- loadpath/checks/exchanger.py +7 -0
- loadpath/checks/fea.py +212 -0
- loadpath/checks/fit.py +475 -0
- loadpath/checks/networks.py +260 -0
- loadpath/checks/opening.py +67 -0
- loadpath/checks/sdf_kit.py +7 -0
- loadpath/conjugate.py +267 -0
- loadpath/constrained.py +329 -0
- loadpath/contact.py +691 -0
- loadpath/coopt.py +342 -0
- loadpath/cores.py +163 -0
- loadpath/crease.py +1138 -0
- loadpath/critic.py +212 -0
- loadpath/crosscheck.py +1107 -0
- loadpath/cutcell.py +510 -0
- loadpath/data/lattice_buckling.json +439 -0
- loadpath/data/lattice_tensors.json +27449 -0
- loadpath/data/published_experiments.json +71 -0
- loadpath/decimate.py +177 -0
- loadpath/delivery.py +190 -0
- loadpath/directions.py +262 -0
- loadpath/domain.py +630 -0
- loadpath/drains.py +615 -0
- loadpath/durability.py +413 -0
- loadpath/editor.html +3135 -0
- loadpath/editor.py +610 -0
- loadpath/escape.py +354 -0
- loadpath/evals/__init__.py +0 -0
- loadpath/evals/baselines/coarse.json +1543 -0
- loadpath/evals/baselines/full.json +1497 -0
- loadpath/evals/brief_kinds/__init__.py +1 -0
- loadpath/evals/brief_kinds/heatx.py +387 -0
- loadpath/evals/brief_kinds/lattice.py +571 -0
- loadpath/evals/brief_kinds/multimat.py +593 -0
- loadpath/evals/briefs/assets/bracket.step +1201 -0
- loadpath/evals/briefs/assets/desk_arm.urdf +22 -0
- loadpath/evals/briefs/assets/elbow-gearbox.stl +0 -0
- loadpath/evals/briefs/assets/motor-body.stl +0 -0
- loadpath/evals/briefs/assets/sketch.png +0 -0
- loadpath/evals/briefs/base-adapter-fdm.json +492 -0
- loadpath/evals/briefs/bell-crank-lpbf.json +180 -0
- loadpath/evals/briefs/belt-tensioner-fdm.json +151 -0
- loadpath/evals/briefs/camera-boom-mjf.json +339 -0
- loadpath/evals/briefs/camera-mount-pla.json +203 -0
- loadpath/evals/briefs/coolant-isolator-petg.json +100 -0
- loadpath/evals/briefs/elbow-bearing-bracket-sls.json +300 -0
- loadpath/evals/briefs/elbow-gearbox-forearm-mjf.json +558 -0
- loadpath/evals/briefs/elbow-link-fatigue-fdm.json +251 -0
- loadpath/evals/briefs/elbow-stop-pad-tpu.json +39 -0
- loadpath/evals/briefs/forearm-fdm.json +146 -0
- loadpath/evals/briefs/forearm-servo-link-fdm.json +327 -0
- loadpath/evals/briefs/grip-pad-finger-mm.json +354 -0
- loadpath/evals/briefs/gripper-bracket-creep-fdm.json +340 -0
- loadpath/evals/briefs/gripper-finger-fdm.json +155 -0
- loadpath/evals/briefs/gripper-jaw-fatigue-mjf.json +328 -0
- loadpath/evals/briefs/gripper-palm-fdm.json +715 -0
- loadpath/evals/briefs/hot-stepper-mount-asa-fdm.json +471 -0
- loadpath/evals/briefs/hpu-oil-cooler-lpbf.json +121 -0
- loadpath/evals/briefs/isolated-camera-mount-mm.json +675 -0
- loadpath/evals/briefs/kart-charge-cooler-lpbf.json +93 -0
- loadpath/evals/briefs/lighten-step-bracket.json +220 -0
- loadpath/evals/briefs/nema17-mount-stl.json +429 -0
- loadpath/evals/briefs/pillow-block-sls.json +220 -0
- loadpath/evals/briefs/polyjet-fingertip-mm.json +305 -0
- loadpath/evals/briefs/rod-hook-pla.json +154 -0
- loadpath/evals/briefs/sensor-arm-resin.json +150 -0
- loadpath/evals/briefs/shelf-bracket-pla.json +158 -0
- loadpath/evals/briefs/shoulder-riser-mjf.json +487 -0
- loadpath/evals/briefs/sketch-clevis.json +229 -0
- loadpath/evals/briefs/spring-bracket-pacf.json +294 -0
- loadpath/evals/briefs/tool-flange-ti64.json +280 -0
- loadpath/evals/briefs/tray-arm-lattice-fdm.json +260 -0
- loadpath/evals/briefs/turret-fdm.json +281 -0
- loadpath/evals/briefs/upper-arm-sls.json +179 -0
- loadpath/evals/briefs/urdf-forearm.json +233 -0
- loadpath/evals/briefs/wrist-link-mjf.json +278 -0
- loadpath/evals/briefs/wrist-yoke-mjf.json +245 -0
- loadpath/evals/briefs.py +393 -0
- loadpath/evals/cases/arm-link-cast.json +157 -0
- loadpath/evals/cases/arm-link-driven.json +171 -0
- loadpath/evals/cases/arm-link-sandwich.json +315 -0
- loadpath/evals/cases/arm-link.json +111 -0
- loadpath/evals/cases/bell-crank.json +116 -0
- loadpath/evals/cases/grip-turret.json +479 -0
- loadpath/evals/cases/grip-upper-arm.json +253 -0
- loadpath/evals/cases/grip-wrist-link-mjf.json +929 -0
- loadpath/evals/cases/grip-wrist-yoke.json +831 -0
- loadpath/evals/cases/lpbf-intercooler-core.json +59 -0
- loadpath/evals/cases/mbb-beam.json +61 -0
- loadpath/evals/cases/motor-body.stl +0 -0
- loadpath/evals/cases/motor-mount.json +265 -0
- loadpath/evals/cases/printed-hook.json +77 -0
- loadpath/evals/cases/wall-bracket-fasteners.json +125 -0
- loadpath/evals/cases/wall-bracket.json +126 -0
- loadpath/evals/corpus.py +179 -0
- loadpath/evals/pareto.py +90 -0
- loadpath/evals/run.py +860 -0
- loadpath/examples/arm-link.json +25 -0
- loadpath/examples/bumpered-bracket-compression.json +225 -0
- loadpath/examples/bumpered-bracket-lattice.json +204 -0
- loadpath/examples/bumpered-bracket.json +34 -0
- loadpath/examples/gripper-finger-mm.json +185 -0
- loadpath/examples/heat-exchanger-counterflow.json +21 -0
- loadpath/examples/heat-exchanger-crossflow-tuned.json +48 -0
- loadpath/examples/heat-exchanger-crossflow.json +20 -0
- loadpath/examples/pedal-lattice.json +23 -0
- loadpath/examples/pressure-plenum.json +12 -0
- loadpath/examples/shelf-bracket.json +27 -0
- loadpath/exchanger.py +831 -0
- loadpath/fasteners.py +199 -0
- loadpath/fem.py +668 -0
- loadpath/femops.py +70 -0
- loadpath/fieldjit.py +981 -0
- loadpath/fieldops.py +119 -0
- loadpath/fields.py +3697 -0
- loadpath/fieldtorch.py +464 -0
- loadpath/flow.py +984 -0
- loadpath/flowtopo.py +300 -0
- loadpath/gpu.py +521 -0
- loadpath/heatx.py +1067 -0
- loadpath/heatx_thermal.py +432 -0
- loadpath/homogenized.py +886 -0
- loadpath/ibrep.py +552 -0
- loadpath/impact.py +693 -0
- loadpath/infill.py +376 -0
- loadpath/interface_library.py +340 -0
- loadpath/interlock.py +186 -0
- loadpath/isolator.py +149 -0
- loadpath/knn.py +204 -0
- loadpath/lattice.py +1065 -0
- loadpath/lattice3mf.py +760 -0
- loadpath/lattice_buckling.py +245 -0
- loadpath/lattice_grade.py +325 -0
- loadpath/lattice_orient.py +595 -0
- loadpath/lattice_print.py +159 -0
- loadpath/lattice_tensors.py +558 -0
- loadpath/latticestep.py +1210 -0
- loadpath/manufacture.py +446 -0
- loadpath/material_library.py +489 -0
- loadpath/materials.py +144 -0
- loadpath/mcp_server.py +786 -0
- loadpath/mechanism.py +218 -0
- loadpath/mechanism_nl.py +811 -0
- loadpath/meshops.py +452 -0
- loadpath/mg.py +853 -0
- loadpath/mma.py +214 -0
- loadpath/modal.py +210 -0
- loadpath/multimat.py +1788 -0
- loadpath/narrowband.py +418 -0
- loadpath/nlkernel.py +241 -0
- loadpath/nonlinear.py +1095 -0
- loadpath/orient.py +334 -0
- loadpath/porous.py +994 -0
- loadpath/pressure.py +171 -0
- loadpath/preview.py +335 -0
- loadpath/printcheck.py +263 -0
- loadpath/printsetup.py +207 -0
- loadpath/processcheck.py +376 -0
- loadpath/quick.py +113 -0
- loadpath/recall.py +406 -0
- loadpath/reconstruct.py +797 -0
- loadpath/render.py +436 -0
- loadpath/resolved.py +189 -0
- loadpath/schema.py +1701 -0
- loadpath/sdf_kit.py +843 -0
- loadpath/selfx.py +292 -0
- loadpath/shapes.py +584 -0
- loadpath/sharp.py +717 -0
- loadpath/simp.py +1170 -0
- loadpath/sizing.py +312 -0
- loadpath/slicer.py +321 -0
- loadpath/spec.py +1072 -0
- loadpath/step.py +806 -0
- loadpath/struts.py +639 -0
- loadpath/study.py +1937 -0
- loadpath/tasks.py +822 -0
- loadpath/thermal.py +1691 -0
- loadpath/threemf.py +487 -0
- loadpath/tune.py +437 -0
- loadpath/validation.py +145 -0
- loadpath/verify.py +1215 -0
- loadpath/viewer.html +694 -0
- loadpath/viewer.py +446 -0
- loadpath/viscoelastic.py +106 -0
- loadpath/voxelprint.py +582 -0
- loadpath/voxels.py +402 -0
- loadpath-0.1.1.dist-info/METADATA +319 -0
- loadpath-0.1.1.dist-info/RECORD +220 -0
- loadpath-0.1.1.dist-info/WHEEL +5 -0
- loadpath-0.1.1.dist-info/entry_points.txt +2 -0
- loadpath-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|