urkit 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.
urkit/motion.py ADDED
@@ -0,0 +1,504 @@
1
+ """Motion commands for Universal Robots e-Series.
2
+
3
+ Provides joint moves (moveJ), linear moves (moveL), and relative
4
+ Cartesian moves via the RTDE control interface. All methods support per-call
5
+ velocity and acceleration override.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import sys
13
+ from contextlib import contextmanager
14
+ from enum import IntEnum
15
+ from typing import TYPE_CHECKING
16
+
17
+ from urkit.exceptions import MotionError
18
+ from urkit.geometry import MoveFrame, transform_pose_delta
19
+
20
+
21
+ @contextmanager
22
+ def _suppress_rtde_stderr():
23
+ """Temporarily redirect stderr to /dev/null.
24
+
25
+ The ur_rtde C++ library prints "RTDE control script is not running!"
26
+ to raw stderr (fd 2) on every call when the script is stopped.
27
+ This context manager suppresses those messages by redirecting fd 2
28
+ around the call.
29
+ """
30
+ devnull = os.open(os.devnull, os.O_WRONLY)
31
+ old_stderr = os.dup(2)
32
+ try:
33
+ os.dup2(devnull, 2)
34
+ sys.stderr.flush()
35
+ yield
36
+ finally:
37
+ os.dup2(old_stderr, 2)
38
+ os.close(old_stderr)
39
+ os.close(devnull)
40
+
41
+ if TYPE_CHECKING:
42
+ from rtde_control import RTDEControlInterface
43
+ from rtde_io import RTDEIOInterface
44
+ from rtde_receive import RTDEReceiveInterface
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # URScript axis order for delta move commands:
49
+ # 0=X, 1=Y, 2=Z, 3=Roll, 4=Pitch, 5=Yaw
50
+ _AXIS_LABELS = ["X", "Y", "Z", "Roll", "Pitch", "Yaw"]
51
+
52
+
53
+ class FreedriveMode(IntEnum):
54
+ """Freedrive axis selection mode.
55
+
56
+ Controls which axes the robot allows manual movement on.
57
+ """
58
+
59
+ ALL = 0 # All 6 axes free
60
+ XYZ = 1 # Only linear axes (X, Y, Z)
61
+ ROTATION = 2 # Only rotational axes (Roll, Pitch, Yaw)
62
+
63
+
64
+ class Motion:
65
+ """Motion commands via RTDE control interface.
66
+
67
+ Wraps the RTDEControlInterface with typed, documented motion
68
+ primitives. All methods raise MotionError on failure.
69
+
70
+ Args:
71
+ rtde_control: RTDEControlInterface instance.
72
+ rtde_receive: RTDEReceiveInterface instance (for current pose reads).
73
+ rtde_io: RTDEIOInterface instance (for speed slider control).
74
+ default_vel: Default linear velocity (m/s) for move commands.
75
+ default_acc: Default linear acceleration (m/s²) for move commands.
76
+
77
+ Example:
78
+ >>> motion = Motion(rtde_c, rtde_r, rtde_io, default_vel=0.5, default_acc=0.3)
79
+ >>> motion.movej([0, -1.57, 0, -1.57, 0, 0])
80
+ >>> motion.movel([0.5, 0, 0.3, 0, 0, 0])
81
+ >>> motion.move_by([0.01, 0, 0, 0, 0, 0])
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ rtde_control: "RTDEControlInterface",
87
+ rtde_receive: "RTDEReceiveInterface",
88
+ rtde_io: "RTDEIOInterface",
89
+ default_vel: float = 0.5,
90
+ default_acc: float = 0.3,
91
+ ) -> None:
92
+ if default_vel <= 0:
93
+ raise MotionError(
94
+ f"default_vel must be > 0, got {default_vel}."
95
+ )
96
+ if default_acc <= 0:
97
+ raise MotionError(
98
+ f"default_acc must be > 0, got {default_acc}."
99
+ )
100
+ self._rtde_c = rtde_control
101
+ self._rtde_r = rtde_receive
102
+ self._rtde_io = rtde_io
103
+ self._default_vel = default_vel
104
+ self._default_acc = default_acc
105
+ self._freedrive_active = False
106
+
107
+ @property
108
+ def is_freedrive_active(self) -> bool:
109
+ """Return whether freedrive is currently active."""
110
+ return self._freedrive_active
111
+
112
+ def movej(
113
+ self,
114
+ joints: list[float],
115
+ vel: float | None = None,
116
+ acc: float | None = None,
117
+ ) -> None:
118
+ """Move to target joint configuration.
119
+
120
+ Uses URScript movej() for joint-space interpolation.
121
+
122
+ Args:
123
+ joints: 6 joint angles in radians [j0, j1, j2, j3, j4, j5].
124
+ vel: Linear velocity (m/s). Falls back to default_vel.
125
+ acc: Linear acceleration (m/s²). Falls back to default_acc.
126
+
127
+ Raises:
128
+ MotionError: If the move fails.
129
+ """
130
+ if len(joints) != 6:
131
+ raise MotionError(
132
+ f"Joint move requires 6 values, got {len(joints)}."
133
+ )
134
+ vel = vel if vel is not None else self._default_vel
135
+ acc = acc if acc is not None else self._default_acc
136
+
137
+ try:
138
+ if not self._rtde_c.isConnected():
139
+ raise MotionError(
140
+ "RTDE connection lost. The robot may have faulted. "
141
+ "Reconnect or reinitialize the robot."
142
+ )
143
+ logger.debug(
144
+ "movej: joints=%s, vel=%.3f, acc=%.3f", joints, vel, acc
145
+ )
146
+ with _suppress_rtde_stderr():
147
+ self._rtde_c.moveJ(joints, vel, acc)
148
+ except MotionError:
149
+ raise
150
+ except Exception as e:
151
+ raise MotionError(
152
+ f"Joint move failed: {e}"
153
+ )
154
+
155
+ def movel(
156
+ self,
157
+ pose: list[float],
158
+ vel: float | None = None,
159
+ acc: float | None = None,
160
+ ) -> None:
161
+ """Move in a straight line to target TCP pose.
162
+
163
+ Uses URScript movel() for Cartesian linear interpolation.
164
+
165
+ Args:
166
+ pose: 6 floats [x, y, z, rx, ry, rz] in meters/radians.
167
+ vel: Linear velocity (m/s). Falls back to default_vel.
168
+ acc: Linear acceleration (m/s²). Falls back to default_acc.
169
+
170
+ Raises:
171
+ MotionError: If the move fails.
172
+ """
173
+ if len(pose) != 6:
174
+ raise MotionError(
175
+ f"Linear move requires 6 values [x,y,z,rx,ry,rz], got {len(pose)}."
176
+ )
177
+ vel = vel if vel is not None else self._default_vel
178
+ acc = acc if acc is not None else self._default_acc
179
+
180
+ try:
181
+ if not self._rtde_c.isConnected():
182
+ raise MotionError(
183
+ "RTDE connection lost. The robot may have faulted. "
184
+ "Reconnect or reinitialize the robot."
185
+ )
186
+ logger.debug(
187
+ "movel: pose=%s, vel=%.3f, acc=%.3f", pose, vel, acc
188
+ )
189
+ with _suppress_rtde_stderr():
190
+ self._rtde_c.moveL(pose, vel, acc)
191
+ except MotionError:
192
+ raise
193
+ except Exception as e:
194
+ raise MotionError(
195
+ f"Linear move failed: {e}"
196
+ )
197
+
198
+ def move_by(
199
+ self,
200
+ delta: list[float],
201
+ vel: float | None = None,
202
+ acc: float | None = None,
203
+ frame: MoveFrame = MoveFrame.BASE,
204
+ ) -> None:
205
+ """Execute a relative Cartesian linear move.
206
+
207
+ Reads the current TCP pose, transforms the delta according to
208
+ the given frame of reference, and moves linearly to the
209
+ resulting pose.
210
+
211
+ Args:
212
+ delta: 6 floats [dx, dy, dz, droll, dpitch, dyaw].
213
+ vel: Linear velocity (m/s). Falls back to default_vel.
214
+ acc: Linear acceleration (m/s²). Falls back to default_acc.
215
+ frame: Coordinate frame for interpreting the delta
216
+ (``MoveFrame.BASE`` or ``MoveFrame.TOOL``).
217
+
218
+ Raises:
219
+ MotionError: If the move fails.
220
+ """
221
+ if len(delta) != 6:
222
+ raise MotionError(
223
+ f"Relative move requires 6 values [dx,dy,dz,droll,dpitch,dyaw], "
224
+ f"got {len(delta)}."
225
+ )
226
+ vel = vel if vel is not None else self._default_vel
227
+ acc = acc if acc is not None else self._default_acc
228
+
229
+ try:
230
+ if not self._rtde_c.isConnected():
231
+ raise MotionError(
232
+ "RTDE connection lost. The robot may have faulted. "
233
+ "Reconnect or reinitialize the robot."
234
+ )
235
+ current = list(self._rtde_r.getActualTCPPose())
236
+ target = transform_pose_delta(current, delta, frame)
237
+ logger.debug(
238
+ "move_by: current=%s, target=%s, frame=%s, vel=%.3f, acc=%.3f",
239
+ current, target, frame.name, vel, acc,
240
+ )
241
+ with _suppress_rtde_stderr():
242
+ self._rtde_c.moveL(target, vel, acc)
243
+ except MotionError:
244
+ raise
245
+ except Exception as e:
246
+ raise MotionError(
247
+ f"Relative move failed: {e}"
248
+ )
249
+
250
+ def move_until_contact(
251
+ self,
252
+ speed_vector: list[float],
253
+ *,
254
+ threshold: float = 5.0,
255
+ acceleration: float = 0.1,
256
+ ) -> None:
257
+ """Move until contact is detected via TCP force sensing.
258
+
259
+ Runs a 500 Hz control loop that sends ``speedL()`` commands and
260
+ checks the tool force/torque on every iteration. Unlike the
261
+ native ``moveUntilContact`` URScript command, this wrapper is
262
+ interruptible with ``KeyboardInterrupt`` (Ctrl+C) because the
263
+ contact check happens in Python each cycle.
264
+
265
+ Accepts a full 6-element speed vector so the caller controls
266
+ every axis — linear and rotational.
267
+
268
+ Args:
269
+ speed_vector: 6-element speed vector
270
+ ``[vx, vy, vz, vRoll, vPitch, dYaw]`` in m/s and rad/s.
271
+ threshold: Force/torque delta (N or Nm) that triggers contact.
272
+ Contact fires when any of the 6 wrench components changes
273
+ by more than this value from the baseline reading.
274
+ acceleration: Acceleration limit passed to ``speedL()`` in m/s².
275
+
276
+ Raises:
277
+ MotionError: If the command fails or the vector is invalid.
278
+
279
+ Example:
280
+ >>> # Move straight down until contact
281
+ >>> motion.move_until_contact([0, 0, -0.02, 0, 0, 0])
282
+ >>> # Move down while rotating, higher threshold
283
+ >>> motion.move_until_contact([0, 0, -0.02, 0, 0.1, 0], threshold=10.0)
284
+ """
285
+ if len(speed_vector) != 6:
286
+ raise MotionError(
287
+ f"Speed vector must have 6 values, got {len(speed_vector)}."
288
+ )
289
+ if threshold <= 0:
290
+ raise MotionError(f"Threshold must be > 0, got {threshold}.")
291
+
292
+ try:
293
+ logger.debug(
294
+ "move_until_contact: speed_vector=%s, threshold=%.2f",
295
+ speed_vector, threshold,
296
+ )
297
+
298
+ # Baseline force reading before the loop
299
+ baseline = list(self._rtde_r.getActualTCPForce())
300
+
301
+ while True:
302
+ if not self._rtde_c.isConnected():
303
+ raise MotionError(
304
+ "RTDE connection lost during move_until_contact. "
305
+ "The robot may have faulted or the script stopped."
306
+ )
307
+ # Check robot safety state (cached data, non-blocking)
308
+ try:
309
+ if self._rtde_r.isProtectiveStopped():
310
+ raise MotionError(
311
+ "Robot is in protective stop. "
312
+ "Clear the stop and reinitialize."
313
+ )
314
+ if self._rtde_r.isEmergencyStopped():
315
+ raise MotionError(
316
+ "Robot is in emergency stop. "
317
+ "Clear the stop and reinitialize."
318
+ )
319
+ except MotionError:
320
+ raise
321
+ except Exception:
322
+ pass # Telemetry read failed, continue
323
+
324
+ with _suppress_rtde_stderr():
325
+ t_start = self._rtde_c.initPeriod()
326
+ self._rtde_c.speedL(speed_vector, acceleration, 0.002)
327
+
328
+ # Check force/torque for contact
329
+ current = list(self._rtde_r.getActualTCPForce())
330
+ if any(
331
+ abs(current[i] - baseline[i]) > threshold for i in range(6)
332
+ ):
333
+ break
334
+
335
+ with _suppress_rtde_stderr():
336
+ self._rtde_c.waitPeriod(t_start)
337
+ except MotionError:
338
+ raise
339
+ except Exception as e:
340
+ raise MotionError(f"move_until_contact failed: {e}")
341
+ finally:
342
+ try:
343
+ with _suppress_rtde_stderr():
344
+ self._rtde_c.speedStop()
345
+ except Exception:
346
+ pass
347
+
348
+ def move_velocity(
349
+ self,
350
+ speed_vector: list[float],
351
+ duration: float,
352
+ acceleration: float = 0.1,
353
+ dt: float = 0.002,
354
+ ) -> None:
355
+ """Move at a constant Cartesian velocity for a given duration.
356
+
357
+ Runs a 500 Hz control loop that sends ``speedL()`` commands.
358
+ Blocks until *duration* seconds have elapsed.
359
+
360
+ Args:
361
+ speed_vector: 6-element Cartesian velocity
362
+ ``[vx, vy, vz, vRoll, vPitch, dYaw]`` in m/s and rad/s.
363
+ duration: How long to move in seconds.
364
+ acceleration: Acceleration limit in m/s\ :sup:2.
365
+ dt: Control loop period in seconds (default 0.002 = 500 Hz).
366
+
367
+ Raises:
368
+ MotionError: If the command fails.
369
+
370
+ Example:
371
+ >>> # Move down at 20 mm/s for 1 second
372
+ >>> motion.move_velocity([0, 0, -0.02, 0, 0, 0], duration=1.0)
373
+ """
374
+ if len(speed_vector) != 6:
375
+ raise MotionError(
376
+ f"Speed vector must have 6 values, got {len(speed_vector)}."
377
+ )
378
+ if duration <= 0:
379
+ raise MotionError(f"Duration must be > 0, got {duration}.")
380
+
381
+ try:
382
+ import time as _time
383
+
384
+ start = _time.monotonic()
385
+ while True:
386
+ elapsed = _time.monotonic() - start
387
+ if elapsed >= duration:
388
+ break
389
+ if not self._rtde_c.isConnected():
390
+ raise MotionError(
391
+ "RTDE connection lost during move_velocity. "
392
+ "The robot may have faulted or the script stopped."
393
+ )
394
+ # Check robot safety state (cached data, non-blocking)
395
+ try:
396
+ if self._rtde_r.isProtectiveStopped():
397
+ raise MotionError(
398
+ "Robot is in protective stop. "
399
+ "Clear the stop and reinitialize."
400
+ )
401
+ if self._rtde_r.isEmergencyStopped():
402
+ raise MotionError(
403
+ "Robot is in emergency stop. "
404
+ "Clear the stop and reinitialize."
405
+ )
406
+ except MotionError:
407
+ raise
408
+ except Exception:
409
+ pass # Telemetry read failed, continue
410
+
411
+ with _suppress_rtde_stderr():
412
+ t_start = self._rtde_c.initPeriod()
413
+ self._rtde_c.speedL(speed_vector, acceleration, dt)
414
+ self._rtde_c.waitPeriod(t_start)
415
+ except MotionError:
416
+ raise
417
+ except Exception as e:
418
+ raise MotionError(f"move_velocity failed: {e}")
419
+ finally:
420
+ try:
421
+ with _suppress_rtde_stderr():
422
+ self._rtde_c.speedStop()
423
+ except Exception:
424
+ pass
425
+
426
+ def speed_stop(self) -> None:
427
+ """Stop any ongoing speed motion immediately.
428
+
429
+ Used to halt speed-based operations.
430
+ """
431
+ try:
432
+ with _suppress_rtde_stderr():
433
+ self._rtde_c.speedStop()
434
+ except Exception as e:
435
+ logger.warning("speedStop failed: %s", e)
436
+
437
+ def stop_script(self) -> None:
438
+ """Stop the running URScript program."""
439
+ try:
440
+ with _suppress_rtde_stderr():
441
+ self._rtde_c.stopScript()
442
+ except Exception as e:
443
+ logger.warning("stopScript failed: %s", e)
444
+
445
+ def set_speed_slider(self, factor: float) -> None:
446
+ """Set the speed slider factor for subsequent motions.
447
+
448
+ Args:
449
+ factor: Speed factor 0.0–1.0.
450
+
451
+ Raises:
452
+ MotionError: If the setting fails.
453
+ """
454
+ if not 0.0 <= factor <= 1.0:
455
+ raise MotionError(
456
+ f"Speed slider factor must be 0.0–1.0, got {factor}."
457
+ )
458
+ try:
459
+ self._rtde_io.setSpeedSlider(factor)
460
+ except Exception as e:
461
+ raise MotionError(
462
+ f"Failed to set speed slider to {factor}: {e}"
463
+ )
464
+
465
+ def enable_freedrive(self, mode: FreedriveMode = FreedriveMode.ALL) -> None:
466
+ """Enable freedrive mode for manual robot manipulation.
467
+
468
+ Args:
469
+ mode: Which axes to allow manual movement on.
470
+
471
+ Raises:
472
+ MotionError: If freedrive cannot be enabled.
473
+ """
474
+ try:
475
+ if mode == FreedriveMode.ALL:
476
+ free_axes = [1, 1, 1, 1, 1, 1]
477
+ elif mode == FreedriveMode.XYZ:
478
+ free_axes = [1, 1, 1, 0, 0, 0]
479
+ elif mode == FreedriveMode.ROTATION:
480
+ free_axes = [0, 0, 0, 1, 1, 1]
481
+ else:
482
+ raise MotionError(f"Unknown freedrive mode: {mode}")
483
+
484
+ feature = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0] # base coordinate frame
485
+ success = self._rtde_c.freedriveMode(free_axes, feature)
486
+ if not success:
487
+ raise MotionError(
488
+ f"freedriveMode returned false (mode={mode.name})"
489
+ )
490
+ self._freedrive_active = True
491
+ logger.info("Freedrive mode enabled (%s)", mode.name)
492
+ except Exception as e:
493
+ raise MotionError(
494
+ f"Failed to enable freedrive mode: {e}"
495
+ )
496
+
497
+ def disable_freedrive(self) -> None:
498
+ """Disable freedrive mode."""
499
+ try:
500
+ self._rtde_c.endFreedriveMode()
501
+ self._freedrive_active = False
502
+ logger.info("Freedrive mode disabled")
503
+ except Exception as e:
504
+ logger.warning("Failed to disable freedrive: %s", e)