screws 0.1.0__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.
- screws/__init__.py +37 -0
- screws/_version.py +1 -0
- screws/aliases.py +94 -0
- screws/coppelia/__init__.py +8 -0
- screws/coppelia/_sim.py +119 -0
- screws/coppelia/arm.py +230 -0
- screws/coppelia/log.py +105 -0
- screws/coppelia/scene.py +152 -0
- screws/kinematics.py +214 -0
- screws/robot.py +261 -0
- screws/robots/__init__.py +49 -0
- screws/robots/rrp.urdf +20 -0
- screws/robots/ur5.urdf +114 -0
- screws/se3.py +210 -0
- screws/so3.py +166 -0
- screws/testing.py +68 -0
- screws/urdf.py +240 -0
- screws-0.1.0.dist-info/METADATA +159 -0
- screws-0.1.0.dist-info/RECORD +21 -0
- screws-0.1.0.dist-info/WHEEL +4 -0
- screws-0.1.0.dist-info/licenses/LICENSE +26 -0
screws/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""screws: screw-theory robotics, after Lynch and Park, *Modern Robotics* (MR).
|
|
2
|
+
|
|
3
|
+
``import screws as sc`` gives every chapter function flat (``sc.exp6``, ``sc.fk_space``),
|
|
4
|
+
MR's CamelCase names as aliases (``sc.FKinSpace is sc.fk_space``), the ``Robot`` class,
|
|
5
|
+
the ``robots`` that ship, the ``urdf`` loader and the ``testing`` helpers. The CoppeliaSim
|
|
6
|
+
bridge is ``screws.coppelia``, imported only on request.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from . import kinematics, robots, se3, so3, testing, urdf
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .aliases import *
|
|
12
|
+
from .aliases import ALIASES
|
|
13
|
+
from .kinematics import *
|
|
14
|
+
from .kinematics import IKResult
|
|
15
|
+
from .robot import MissingInertias, Robot
|
|
16
|
+
from .se3 import *
|
|
17
|
+
from .so3 import *
|
|
18
|
+
|
|
19
|
+
__all__ = (
|
|
20
|
+
[
|
|
21
|
+
"__version__",
|
|
22
|
+
"ALIASES",
|
|
23
|
+
"IKResult",
|
|
24
|
+
"MissingInertias",
|
|
25
|
+
"Robot",
|
|
26
|
+
"kinematics",
|
|
27
|
+
"robots",
|
|
28
|
+
"se3",
|
|
29
|
+
"so3",
|
|
30
|
+
"testing",
|
|
31
|
+
"urdf",
|
|
32
|
+
]
|
|
33
|
+
+ list(so3.__all__)
|
|
34
|
+
+ list(se3.__all__)
|
|
35
|
+
+ list(kinematics.__all__)
|
|
36
|
+
+ list(ALIASES)
|
|
37
|
+
)
|
screws/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
screws/aliases.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""The Modern Robotics library's CamelCase names, bound to their screws equivalents.
|
|
2
|
+
|
|
3
|
+
Where the signatures match, the alias is the very same function object (``FKinSpace is
|
|
4
|
+
fk_space``). Where screws changed the interface (the IK functions return an IKResult), the
|
|
5
|
+
alias is a thin wrapper with MR's exact signature and return. No deprecation warnings: the
|
|
6
|
+
names exist so a student can type what the book says.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from . import kinematics, se3, so3
|
|
12
|
+
|
|
13
|
+
#: MR name -> screws primary name.
|
|
14
|
+
ALIASES: dict[str, str] = {
|
|
15
|
+
# chapter 3
|
|
16
|
+
"NearZero": "near_zero",
|
|
17
|
+
"Normalize": "normalize",
|
|
18
|
+
"RotInv": "rot_inv",
|
|
19
|
+
"VecToso3": "vec_to_so3",
|
|
20
|
+
"so3ToVec": "so3_to_vec",
|
|
21
|
+
"AxisAng3": "axis_angle3",
|
|
22
|
+
"MatrixExp3": "exp3",
|
|
23
|
+
"MatrixLog3": "log3",
|
|
24
|
+
"RpToTrans": "rp_to_transform",
|
|
25
|
+
"TransToRp": "transform_to_rp",
|
|
26
|
+
"TransInv": "transform_inv",
|
|
27
|
+
"VecTose3": "vec_to_se3",
|
|
28
|
+
"se3ToVec": "se3_to_vec",
|
|
29
|
+
"Adjoint": "adjoint",
|
|
30
|
+
"ScrewToAxis": "screw_axis",
|
|
31
|
+
"AxisAng6": "axis_angle6",
|
|
32
|
+
"MatrixExp6": "exp6",
|
|
33
|
+
"MatrixLog6": "log6",
|
|
34
|
+
"ProjectToSO3": "project_so3",
|
|
35
|
+
"ProjectToSE3": "project_se3",
|
|
36
|
+
"DistanceToSO3": "distance_so3",
|
|
37
|
+
"DistanceToSE3": "distance_se3",
|
|
38
|
+
"TestIfSO3": "is_so3",
|
|
39
|
+
"TestIfSE3": "is_se3",
|
|
40
|
+
# chapter 4
|
|
41
|
+
"FKinBody": "fk_body",
|
|
42
|
+
"FKinSpace": "fk_space",
|
|
43
|
+
# chapter 5
|
|
44
|
+
"JacobianBody": "jacobian_body",
|
|
45
|
+
"JacobianSpace": "jacobian_space",
|
|
46
|
+
# chapter 6
|
|
47
|
+
"IKinBody": "ik_body",
|
|
48
|
+
"IKinSpace": "ik_space",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Identity aliases: the same object under MR's name.
|
|
52
|
+
NearZero = so3.near_zero
|
|
53
|
+
Normalize = so3.normalize
|
|
54
|
+
RotInv = so3.rot_inv
|
|
55
|
+
VecToso3 = so3.vec_to_so3
|
|
56
|
+
so3ToVec = so3.so3_to_vec
|
|
57
|
+
AxisAng3 = so3.axis_angle3
|
|
58
|
+
MatrixExp3 = so3.exp3
|
|
59
|
+
MatrixLog3 = so3.log3
|
|
60
|
+
RpToTrans = se3.rp_to_transform
|
|
61
|
+
TransToRp = se3.transform_to_rp
|
|
62
|
+
TransInv = se3.transform_inv
|
|
63
|
+
VecTose3 = se3.vec_to_se3
|
|
64
|
+
se3ToVec = se3.se3_to_vec
|
|
65
|
+
Adjoint = se3.adjoint
|
|
66
|
+
ScrewToAxis = se3.screw_axis
|
|
67
|
+
AxisAng6 = se3.axis_angle6
|
|
68
|
+
MatrixExp6 = se3.exp6
|
|
69
|
+
MatrixLog6 = se3.log6
|
|
70
|
+
ProjectToSO3 = so3.project_so3
|
|
71
|
+
ProjectToSE3 = se3.project_se3
|
|
72
|
+
DistanceToSO3 = so3.distance_so3
|
|
73
|
+
DistanceToSE3 = se3.distance_se3
|
|
74
|
+
TestIfSO3 = so3.is_so3
|
|
75
|
+
TestIfSE3 = se3.is_se3
|
|
76
|
+
FKinBody = kinematics.fk_body
|
|
77
|
+
FKinSpace = kinematics.fk_space
|
|
78
|
+
JacobianBody = kinematics.jacobian_body
|
|
79
|
+
JacobianSpace = kinematics.jacobian_space
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def IKinBody(Blist, M, T, thetalist0, eomg, ev):
|
|
83
|
+
"""MR's IKinBody: returns (thetalist, success). Wraps screws.kinematics.ik_body."""
|
|
84
|
+
r = kinematics.ik_body(M, Blist, T, thetalist0, tol_omega=eomg, tol_v=ev)
|
|
85
|
+
return r.theta, r.converged
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def IKinSpace(Slist, M, T, thetalist0, eomg, ev):
|
|
89
|
+
"""MR's IKinSpace: returns (thetalist, success). Wraps screws.kinematics.ik_space."""
|
|
90
|
+
r = kinematics.ik_space(M, Slist, T, thetalist0, tol_omega=eomg, tol_v=ev)
|
|
91
|
+
return r.theta, r.converged
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
__all__ = list(ALIASES) + ["ALIASES"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""The CoppeliaSim bridge. Requires ``screws[coppelia]``; ``import screws`` never loads it."""
|
|
2
|
+
|
|
3
|
+
from ._sim import SimulatorNotRunning, connect
|
|
4
|
+
from .arm import Arm
|
|
5
|
+
from .log import Log
|
|
6
|
+
from .scene import Scene
|
|
7
|
+
|
|
8
|
+
__all__ = ["Arm", "Log", "Scene", "SimulatorNotRunning", "connect"]
|
screws/coppelia/_sim.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""The thin layer over CoppeliaSim's ZMQ remote API: connecting, and converting between
|
|
2
|
+
CoppeliaSim's flat matrix, pose and inertia encodings and 4x4 / 3x3 numpy arrays.
|
|
3
|
+
|
|
4
|
+
Everything here is testable without a simulator: the bridge receives a ``sim`` object and
|
|
5
|
+
never imports the client except inside connect().
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ..se3 import rp_to_transform
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"SimulatorNotRunning",
|
|
16
|
+
"connect",
|
|
17
|
+
"inertia9_to_matrix",
|
|
18
|
+
"matrix12_to_transform",
|
|
19
|
+
"pose7_to_transform",
|
|
20
|
+
"transform_to_matrix12",
|
|
21
|
+
"transform_to_pose7",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
STUDENT_SENTENCE = "Start CoppeliaSim and open a scene, then run this again."
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimulatorNotRunning(RuntimeError):
|
|
28
|
+
"""CoppeliaSim did not answer on the remote API port."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _new_client(host: str, port: int):
|
|
32
|
+
from coppeliasim_zmqremoteapi_client import RemoteAPIClient
|
|
33
|
+
|
|
34
|
+
return RemoteAPIClient(host, port)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def connect(host: str = "localhost", port: int = 23000, *, timeout_s: float = 5.0):
|
|
38
|
+
"""Connect to CoppeliaSim's ZMQ remote API and return its ``sim`` object.
|
|
39
|
+
|
|
40
|
+
Raises SimulatorNotRunning, with the sentence a student needs, if nothing answers.
|
|
41
|
+
"""
|
|
42
|
+
socket = None
|
|
43
|
+
try:
|
|
44
|
+
client = _new_client(host, port)
|
|
45
|
+
socket = getattr(client, "socket", None)
|
|
46
|
+
# The client's own `timeout` is a server-side setting sent with the first request;
|
|
47
|
+
# its recv() blocks forever. Put a real receive timeout on the socket for the probe.
|
|
48
|
+
if socket is not None:
|
|
49
|
+
socket.setsockopt(_zmq().RCVTIMEO, int(timeout_s * 1000))
|
|
50
|
+
socket.setsockopt(_zmq().LINGER, 0)
|
|
51
|
+
sim = client.require("sim")
|
|
52
|
+
sim.getSimulationTime()
|
|
53
|
+
except Exception as exc: # zmq.Again, ConnectionRefusedError, ...: all mean "not running"
|
|
54
|
+
if socket is not None:
|
|
55
|
+
socket.close()
|
|
56
|
+
raise SimulatorNotRunning(
|
|
57
|
+
f"No CoppeliaSim answered at {host}:{port} ({type(exc).__name__}). {STUDENT_SENTENCE}"
|
|
58
|
+
) from exc
|
|
59
|
+
if socket is not None:
|
|
60
|
+
socket.setsockopt(_zmq().RCVTIMEO, -1)
|
|
61
|
+
sim._screws_client = client # keep the client alive as long as sim is
|
|
62
|
+
return sim
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _zmq():
|
|
66
|
+
import zmq
|
|
67
|
+
|
|
68
|
+
return zmq
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def matrix12_to_transform(m) -> np.ndarray:
|
|
72
|
+
"""CoppeliaSim's 12 floats (rows 0-2 of T, row-major) to a 4x4 transformation."""
|
|
73
|
+
T = np.eye(4)
|
|
74
|
+
T[:3, :] = np.asarray(m, dtype=float).reshape(3, 4)
|
|
75
|
+
return T
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def transform_to_matrix12(T) -> list[float]:
|
|
79
|
+
"""A 4x4 transformation to CoppeliaSim's 12 floats (rows 0-2, row-major)."""
|
|
80
|
+
return [float(x) for x in np.asarray(T, dtype=float)[:3, :].reshape(-1)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pose7_to_transform(p) -> np.ndarray:
|
|
84
|
+
"""CoppeliaSim's pose (x, y, z, qx, qy, qz, qw) to a 4x4 transformation."""
|
|
85
|
+
p = np.asarray(p, dtype=float)
|
|
86
|
+
x, y, z, w = p[3:7] / np.linalg.norm(p[3:7])
|
|
87
|
+
R = np.array(
|
|
88
|
+
[
|
|
89
|
+
[1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w)],
|
|
90
|
+
[2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w)],
|
|
91
|
+
[2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y)],
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
return rp_to_transform(R, p[:3])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def transform_to_pose7(T) -> list[float]:
|
|
98
|
+
"""A 4x4 transformation to CoppeliaSim's pose (x, y, z, qx, qy, qz, qw)."""
|
|
99
|
+
T = np.asarray(T, dtype=float)
|
|
100
|
+
R = T[:3, :3]
|
|
101
|
+
tr = np.trace(R)
|
|
102
|
+
if tr > 0:
|
|
103
|
+
s = np.sqrt(tr + 1.0) * 2
|
|
104
|
+
w, x, y, z = 0.25 * s, (R[2, 1] - R[1, 2]) / s, (R[0, 2] - R[2, 0]) / s, (R[1, 0] - R[0, 1]) / s
|
|
105
|
+
elif R[0, 0] > R[1, 1] and R[0, 0] > R[2, 2]:
|
|
106
|
+
s = np.sqrt(1.0 + R[0, 0] - R[1, 1] - R[2, 2]) * 2
|
|
107
|
+
w, x, y, z = (R[2, 1] - R[1, 2]) / s, 0.25 * s, (R[0, 1] + R[1, 0]) / s, (R[0, 2] + R[2, 0]) / s
|
|
108
|
+
elif R[1, 1] > R[2, 2]:
|
|
109
|
+
s = np.sqrt(1.0 + R[1, 1] - R[0, 0] - R[2, 2]) * 2
|
|
110
|
+
w, x, y, z = (R[0, 2] - R[2, 0]) / s, (R[0, 1] + R[1, 0]) / s, 0.25 * s, (R[1, 2] + R[2, 1]) / s
|
|
111
|
+
else:
|
|
112
|
+
s = np.sqrt(1.0 + R[2, 2] - R[0, 0] - R[1, 1]) * 2
|
|
113
|
+
w, x, y, z = (R[1, 0] - R[0, 1]) / s, (R[0, 2] + R[2, 0]) / s, (R[1, 2] + R[2, 1]) / s, 0.25 * s
|
|
114
|
+
return [float(v) for v in (*T[:3, 3], x, y, z, w)]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def inertia9_to_matrix(i) -> np.ndarray:
|
|
118
|
+
"""CoppeliaSim's 9 inertia floats (row-major) to a 3x3 matrix."""
|
|
119
|
+
return np.asarray(i, dtype=float).reshape(3, 3)
|
screws/coppelia/arm.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Arm: the joints under one scene tree, read and commanded through ``sim``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..robot import Robot
|
|
8
|
+
from ..se3 import prismatic_axis, revolute_axis, transform_inv
|
|
9
|
+
|
|
10
|
+
__all__ = ["Arm"]
|
|
11
|
+
|
|
12
|
+
_MODES = ("position", "velocity", "torque")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Arm:
|
|
16
|
+
"""A serial chain in the scene: its joints ordered base to tip, and its tip object.
|
|
17
|
+
|
|
18
|
+
Joints are every joint under ``path``, ordered by depth, or the ones named in ``joints``
|
|
19
|
+
(aliases or paths, in order) when a gripper or other tool adds joints of its own. The tip is the first dummy
|
|
20
|
+
under ``path`` whose alias is "tip" or "ee" (or contains "tip"), else the last joint.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, scene, path: str, joints=None):
|
|
24
|
+
self.scene = scene
|
|
25
|
+
self.sim = scene.sim
|
|
26
|
+
self.path = path
|
|
27
|
+
self.base = scene._handle(path)
|
|
28
|
+
self.alias = self.sim.getObjectAlias(self.base, -1)
|
|
29
|
+
found = list(self.sim.getObjectsInTree(self.base, self.sim.sceneobject_joint, 0))
|
|
30
|
+
found.sort(key=self._depth)
|
|
31
|
+
if joints is None:
|
|
32
|
+
joints = found
|
|
33
|
+
else:
|
|
34
|
+
by_alias = {self.sim.getObjectAlias(h, -1): h for h in found}
|
|
35
|
+
picked = []
|
|
36
|
+
for j in joints:
|
|
37
|
+
if isinstance(j, str) and j in by_alias:
|
|
38
|
+
picked.append(by_alias[j])
|
|
39
|
+
elif isinstance(j, str):
|
|
40
|
+
try:
|
|
41
|
+
picked.append(scene._handle(j))
|
|
42
|
+
except LookupError as exc:
|
|
43
|
+
raise LookupError(
|
|
44
|
+
f"no joint {j!r} under {path}; found {sorted(by_alias)}"
|
|
45
|
+
) from exc
|
|
46
|
+
else:
|
|
47
|
+
picked.append(int(j))
|
|
48
|
+
joints = picked
|
|
49
|
+
self.handles: tuple[int, ...] = tuple(joints)
|
|
50
|
+
self.joint_names: tuple[str, ...] = tuple(self.sim.getObjectAlias(h, -1) for h in joints)
|
|
51
|
+
self.tip, self.tip_alias = self._find_tip()
|
|
52
|
+
self._mode: str | None = None
|
|
53
|
+
|
|
54
|
+
def _depth(self, h: int) -> int:
|
|
55
|
+
d = 0
|
|
56
|
+
while h != self.sim.handle_world and h != self.base:
|
|
57
|
+
h = self.sim.getObjectParent(h)
|
|
58
|
+
d += 1
|
|
59
|
+
return d
|
|
60
|
+
|
|
61
|
+
def _find_tip(self) -> tuple[int, str]:
|
|
62
|
+
dummies = list(self.sim.getObjectsInTree(self.base, self.sim.sceneobject_dummy, 0))
|
|
63
|
+
named = {self.sim.getObjectAlias(h, -1): h for h in dummies}
|
|
64
|
+
for want in ("tip", "ee", "connection"):
|
|
65
|
+
if want in named:
|
|
66
|
+
return named[want], want
|
|
67
|
+
for alias, h in named.items():
|
|
68
|
+
if any(key in alias.lower() for key in ("tip", "connection")):
|
|
69
|
+
return h, alias
|
|
70
|
+
if not self.handles:
|
|
71
|
+
raise LookupError(f"{self.path} has no joints and no tip dummy")
|
|
72
|
+
# Fall back to the last joint's first non-joint child (the last link), else the joint.
|
|
73
|
+
last = self.handles[-1]
|
|
74
|
+
for kind in (self.sim.sceneobject_shape, self.sim.sceneobject_dummy):
|
|
75
|
+
for h in self.sim.getObjectsInTree(last, kind, 1 + 2): # exclude base, first children only
|
|
76
|
+
return h, self.sim.getObjectAlias(h, -1)
|
|
77
|
+
return last, self.joint_names[-1]
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def n(self) -> int:
|
|
81
|
+
return len(self.handles)
|
|
82
|
+
|
|
83
|
+
# ----- reading ----------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def theta(self) -> np.ndarray:
|
|
86
|
+
"""Joint positions (rad or m)."""
|
|
87
|
+
return np.array([self.sim.getJointPosition(h) for h in self.handles], dtype=float)
|
|
88
|
+
|
|
89
|
+
def dtheta(self) -> np.ndarray:
|
|
90
|
+
"""Joint velocities."""
|
|
91
|
+
return np.array([self.sim.getJointVelocity(h) for h in self.handles], dtype=float)
|
|
92
|
+
|
|
93
|
+
def tau(self) -> np.ndarray:
|
|
94
|
+
"""Measured joint forces or torques; NaN for a joint that reports none (not dynamic)."""
|
|
95
|
+
out = []
|
|
96
|
+
for h in self.handles:
|
|
97
|
+
try:
|
|
98
|
+
out.append(float(self.sim.getJointForce(h)))
|
|
99
|
+
except Exception: # noqa: BLE001 - the remote API raises a plain Exception
|
|
100
|
+
out.append(float("nan"))
|
|
101
|
+
return np.array(out, dtype=float)
|
|
102
|
+
|
|
103
|
+
def tip_frame(self) -> np.ndarray:
|
|
104
|
+
"""The tip's 4x4 configuration in the world frame."""
|
|
105
|
+
return self.scene.frame(self.tip)
|
|
106
|
+
|
|
107
|
+
def joint_frames(self) -> list[np.ndarray]:
|
|
108
|
+
"""Each joint's 4x4 frame in the world frame; the joint axis is the local z axis."""
|
|
109
|
+
return [self.scene.frame(h) for h in self.handles]
|
|
110
|
+
|
|
111
|
+
def joint_types(self) -> tuple[str, ...]:
|
|
112
|
+
return tuple(
|
|
113
|
+
"prismatic" if self.sim.getJointType(h) == self.sim.joint_prismatic else "revolute"
|
|
114
|
+
for h in self.handles
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def joint_limits(self) -> np.ndarray | None:
|
|
118
|
+
"""nx2 limits from the joints' intervals, or None if any joint is cyclic."""
|
|
119
|
+
out = []
|
|
120
|
+
for h in self.handles:
|
|
121
|
+
cyclic, interval = self.sim.getJointInterval(h)
|
|
122
|
+
if cyclic:
|
|
123
|
+
return None
|
|
124
|
+
out.append([interval[0], interval[0] + interval[1]])
|
|
125
|
+
return np.array(out, dtype=float)
|
|
126
|
+
|
|
127
|
+
# ----- commanding -------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def mode(self, kind: str) -> None:
|
|
130
|
+
"""Set every joint's dynamic control mode: "position", "velocity" or "torque"."""
|
|
131
|
+
if kind not in _MODES:
|
|
132
|
+
raise ValueError(f"mode must be one of {_MODES}; got {kind!r}")
|
|
133
|
+
value = {
|
|
134
|
+
"position": self.sim.jointdynctrl_position,
|
|
135
|
+
"velocity": self.sim.jointdynctrl_velocity,
|
|
136
|
+
"torque": self.sim.jointdynctrl_force,
|
|
137
|
+
}[kind]
|
|
138
|
+
for h in self.handles:
|
|
139
|
+
self.sim.setObjectInt32Param(h, self.sim.jointintparam_dynctrlmode, value)
|
|
140
|
+
self._mode = kind
|
|
141
|
+
|
|
142
|
+
def _require(self, kind: str) -> None:
|
|
143
|
+
if self._mode != kind:
|
|
144
|
+
raise RuntimeError(
|
|
145
|
+
f"the arm is in {self._mode!r} mode; call arm.mode({kind!r}) before a {kind} command"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def command(self, u) -> None:
|
|
149
|
+
"""Dispatch on the current mode: positions, velocities or torques."""
|
|
150
|
+
if self._mode is None:
|
|
151
|
+
raise RuntimeError('set a mode first: arm.mode("position" | "velocity" | "torque")')
|
|
152
|
+
dispatch = {
|
|
153
|
+
"position": self.command_positions,
|
|
154
|
+
"velocity": self.command_velocities,
|
|
155
|
+
"torque": self.command_torques,
|
|
156
|
+
}
|
|
157
|
+
dispatch[self._mode](u)
|
|
158
|
+
|
|
159
|
+
def _vector(self, u) -> np.ndarray:
|
|
160
|
+
u = np.asarray(u, dtype=float).reshape(-1)
|
|
161
|
+
if u.shape[0] != self.n:
|
|
162
|
+
raise ValueError(f"got {u.shape[0]} values for {self.n} joints")
|
|
163
|
+
return u
|
|
164
|
+
|
|
165
|
+
def command_positions(self, theta) -> None:
|
|
166
|
+
self._require("position")
|
|
167
|
+
for h, v in zip(self.handles, self._vector(theta)):
|
|
168
|
+
self.sim.setJointTargetPosition(h, float(v))
|
|
169
|
+
|
|
170
|
+
def command_velocities(self, dtheta) -> None:
|
|
171
|
+
self._require("velocity")
|
|
172
|
+
for h, v in zip(self.handles, self._vector(dtheta)):
|
|
173
|
+
self.sim.setJointTargetVelocity(h, float(v))
|
|
174
|
+
|
|
175
|
+
def command_torques(self, tau) -> None:
|
|
176
|
+
"""Force mode: the signed joint force or torque is applied directly
|
|
177
|
+
(sim.setJointTargetForce with signedValue, CoppeliaSim 4.3+)."""
|
|
178
|
+
self._require("torque")
|
|
179
|
+
for h, v in zip(self.handles, self._vector(tau)):
|
|
180
|
+
self.sim.setJointTargetForce(h, float(v), True)
|
|
181
|
+
|
|
182
|
+
def teleport(self, theta) -> None:
|
|
183
|
+
"""Set joint positions directly, without physics: for animating IK iterates."""
|
|
184
|
+
for h, v in zip(self.handles, self._vector(theta)):
|
|
185
|
+
self.sim.setJointPosition(h, float(v))
|
|
186
|
+
|
|
187
|
+
# ----- the robot off the scene ------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
def robot(self, *, inertias: bool = False, relative_to: str = "world") -> Robot:
|
|
190
|
+
"""A screws.Robot read from the scene at the zero position.
|
|
191
|
+
|
|
192
|
+
M is the tip frame; joint i's screw axis has omega = its frame's z axis and
|
|
193
|
+
q = its origin (notes 4.1: v = -omega x q). relative_to="world" (default) makes {s}
|
|
194
|
+
CoppeliaSim's world frame; relative_to="base" makes {s} the frame of the object at
|
|
195
|
+
``path``, so the result is independent of where the model stands in the scene.
|
|
196
|
+
The arm is teleported to zero for the reading and put back afterwards, so call this
|
|
197
|
+
before starting the simulation or accept a jump. inertias=True arrives in 0.2.
|
|
198
|
+
"""
|
|
199
|
+
if inertias:
|
|
200
|
+
raise NotImplementedError("scene inertias arrive in screws 0.2")
|
|
201
|
+
if relative_to not in ("world", "base"):
|
|
202
|
+
raise ValueError(f'relative_to must be "world" or "base"; got {relative_to!r}')
|
|
203
|
+
here = self.theta()
|
|
204
|
+
self.teleport(np.zeros(self.n))
|
|
205
|
+
try:
|
|
206
|
+
frames = self.joint_frames()
|
|
207
|
+
M = self.tip_frame()
|
|
208
|
+
if relative_to == "base":
|
|
209
|
+
T_ws_inv = transform_inv(self.scene.frame(self.base))
|
|
210
|
+
frames = [T_ws_inv @ F for F in frames]
|
|
211
|
+
M = T_ws_inv @ M
|
|
212
|
+
finally:
|
|
213
|
+
self.teleport(here)
|
|
214
|
+
axes = []
|
|
215
|
+
for F, kind in zip(frames, self.joint_types()):
|
|
216
|
+
z, q = F[:3, 2], F[:3, 3]
|
|
217
|
+
axes.append(prismatic_axis(z) if kind == "prismatic" else revolute_axis(q, z))
|
|
218
|
+
return Robot(
|
|
219
|
+
name=self.alias,
|
|
220
|
+
M=M,
|
|
221
|
+
S=np.column_stack(axes),
|
|
222
|
+
joint_types=self.joint_types(),
|
|
223
|
+
joint_names=self.joint_names,
|
|
224
|
+
joint_limits=self.joint_limits(),
|
|
225
|
+
joint_frames_home=tuple(frames),
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
return f"Arm({self.path!r}, joints={list(self.joint_names)}, tip={self.tip_alias!r})"
|
|
230
|
+
|
screws/coppelia/log.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Log: what a Scene records at every step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
__all__ = ["Log"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Log:
|
|
14
|
+
"""Per-step records of simulated time, joint state, command and end-effector frame.
|
|
15
|
+
|
|
16
|
+
Arrays: t (N,), theta (N, n), dtheta (N, n), tau (N, n), command (N, n) and T_sb
|
|
17
|
+
(N, 4, 4). Empty arrays before the first record.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self._t: list[float] = []
|
|
22
|
+
self._theta: list[np.ndarray] = []
|
|
23
|
+
self._dtheta: list[np.ndarray] = []
|
|
24
|
+
self._tau: list[np.ndarray] = []
|
|
25
|
+
self._command: list[np.ndarray] = []
|
|
26
|
+
self._T_sb: list[np.ndarray] = []
|
|
27
|
+
|
|
28
|
+
def record(self, t, theta, dtheta, tau, command, T_sb) -> None:
|
|
29
|
+
self._t.append(float(t))
|
|
30
|
+
self._theta.append(np.asarray(theta, dtype=float))
|
|
31
|
+
self._dtheta.append(np.asarray(dtheta, dtype=float))
|
|
32
|
+
self._tau.append(np.asarray(tau, dtype=float))
|
|
33
|
+
n = len(self._theta[-1])
|
|
34
|
+
self._command.append(
|
|
35
|
+
np.full(n, np.nan) if command is None else np.asarray(command, dtype=float)
|
|
36
|
+
)
|
|
37
|
+
self._T_sb.append(np.asarray(T_sb, dtype=float))
|
|
38
|
+
|
|
39
|
+
def __len__(self) -> int:
|
|
40
|
+
return len(self._t)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def t(self) -> np.ndarray:
|
|
44
|
+
return np.array(self._t)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def theta(self) -> np.ndarray:
|
|
48
|
+
return np.array(self._theta)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def dtheta(self) -> np.ndarray:
|
|
52
|
+
return np.array(self._dtheta)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def tau(self) -> np.ndarray:
|
|
56
|
+
return np.array(self._tau)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def command(self) -> np.ndarray:
|
|
60
|
+
return np.array(self._command)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def T_sb(self) -> np.ndarray:
|
|
64
|
+
return np.array(self._T_sb)
|
|
65
|
+
|
|
66
|
+
def to_csv(self, path) -> None:
|
|
67
|
+
"""Write t, theta_i, dtheta_i, tau_i, command_i and the 12 entries of T_sb per row."""
|
|
68
|
+
n = self.theta.shape[1] if len(self) else 0
|
|
69
|
+
header = (
|
|
70
|
+
["t"]
|
|
71
|
+
+ [f"theta_{i + 1}" for i in range(n)]
|
|
72
|
+
+ [f"dtheta_{i + 1}" for i in range(n)]
|
|
73
|
+
+ [f"tau_{i + 1}" for i in range(n)]
|
|
74
|
+
+ [f"command_{i + 1}" for i in range(n)]
|
|
75
|
+
+ [f"T_{r}{c}" for r in range(3) for c in range(4)]
|
|
76
|
+
)
|
|
77
|
+
with Path(path).open("w", newline="") as f:
|
|
78
|
+
w = csv.writer(f)
|
|
79
|
+
w.writerow(header)
|
|
80
|
+
for k in range(len(self)):
|
|
81
|
+
w.writerow(
|
|
82
|
+
[self._t[k], *self._theta[k], *self._dtheta[k], *self._tau[k],
|
|
83
|
+
*self._command[k], *self._T_sb[k][:3, :].reshape(-1)]
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def to_mr_csv(self, path) -> None:
|
|
87
|
+
"""Write joint angles only, one row per step, no header: the MR wiki scenes' format."""
|
|
88
|
+
np.savetxt(path, self.theta, delimiter=",")
|
|
89
|
+
|
|
90
|
+
def plot(self):
|
|
91
|
+
"""One axis per quantity (theta, dtheta, tau, command). Needs matplotlib."""
|
|
92
|
+
import matplotlib.pyplot as plt
|
|
93
|
+
|
|
94
|
+
fig, axes = plt.subplots(4, 1, sharex=True, figsize=(8, 9))
|
|
95
|
+
for ax, (name, data) in zip(
|
|
96
|
+
axes, [("theta", self.theta), ("dtheta", self.dtheta), ("tau", self.tau),
|
|
97
|
+
("command", self.command)]
|
|
98
|
+
):
|
|
99
|
+
if len(self):
|
|
100
|
+
ax.plot(self.t, data)
|
|
101
|
+
ax.set_ylabel(name)
|
|
102
|
+
ax.grid(True)
|
|
103
|
+
axes[-1].set_xlabel("t (s)")
|
|
104
|
+
fig.tight_layout()
|
|
105
|
+
return fig
|