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 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"]
@@ -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