urkit 0.3.20__tar.gz → 0.3.22__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {urkit-0.3.20 → urkit-0.3.22}/PKG-INFO +1 -1
  2. {urkit-0.3.20 → urkit-0.3.22}/pyproject.toml +1 -1
  3. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/__init__.py +3 -1
  4. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/__main__.py +1 -1
  5. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/teach.py +128 -27
  6. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/geometry.py +86 -48
  7. {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/PKG-INFO +1 -1
  8. urkit-0.3.22/tests/test_geometry.py +286 -0
  9. urkit-0.3.20/tests/test_geometry.py +0 -187
  10. {urkit-0.3.20 → urkit-0.3.22}/README.md +0 -0
  11. {urkit-0.3.20 → urkit-0.3.22}/setup.cfg +0 -0
  12. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/__init__.py +0 -0
  13. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/colors.py +0 -0
  14. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/connection_monitor.py +0 -0
  15. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/cli/points.py +0 -0
  16. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/config.py +0 -0
  17. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/connection.py +0 -0
  18. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/exceptions.py +0 -0
  19. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/__init__.py +0 -0
  20. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/base.py +0 -0
  21. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/digital.py +0 -0
  22. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/presets.py +0 -0
  23. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/robotiq.py +0 -0
  24. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/gripper/robotiq_preamble.py +0 -0
  25. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/io.py +0 -0
  26. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/motion.py +0 -0
  27. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/points.py +0 -0
  28. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/robot.py +0 -0
  29. {urkit-0.3.20 → urkit-0.3.22}/src/urkit/telemetry.py +0 -0
  30. {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/SOURCES.txt +0 -0
  31. {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/dependency_links.txt +0 -0
  32. {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/entry_points.txt +0 -0
  33. {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/requires.txt +0 -0
  34. {urkit-0.3.20 → urkit-0.3.22}/src/urkit.egg-info/top_level.txt +0 -0
  35. {urkit-0.3.20 → urkit-0.3.22}/tests/test_exceptions.py +0 -0
  36. {urkit-0.3.20 → urkit-0.3.22}/tests/test_gripper.py +0 -0
  37. {urkit-0.3.20 → urkit-0.3.22}/tests/test_gripper_factory.py +0 -0
  38. {urkit-0.3.20 → urkit-0.3.22}/tests/test_gripper_presets.py +0 -0
  39. {urkit-0.3.20 → urkit-0.3.22}/tests/test_move_sequence.py +0 -0
  40. {urkit-0.3.20 → urkit-0.3.22}/tests/test_points.py +0 -0
  41. {urkit-0.3.20 → urkit-0.3.22}/tests/test_robot_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.3.20
3
+ Version: 0.3.22
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "urkit"
7
- version = "0.3.20"
7
+ version = "0.3.22"
8
8
  description = "Universal Robots e-Series control toolkit built on ur_rtde"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -24,7 +24,7 @@ Quick start::
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
- __version__ = "0.3.20"
27
+ __version__ = "0.3.22"
28
28
 
29
29
  from urkit.config import load_config, resolve_config
30
30
  from urkit.exceptions import (
@@ -41,6 +41,7 @@ from urkit.exceptions import (
41
41
  )
42
42
  from urkit.geometry import (
43
43
  MoveFrame,
44
+ orient_tcp,
44
45
  orient_tcp_down,
45
46
  quat_to_rotvec,
46
47
  quat_to_rpy,
@@ -80,6 +81,7 @@ __all__ = [
80
81
  # Move frame
81
82
  "MoveFrame",
82
83
  # Geometry
84
+ "orient_tcp",
83
85
  "orient_tcp_down",
84
86
  "quat_to_rotvec",
85
87
  "quat_to_rpy",
@@ -85,7 +85,7 @@ def main() -> None:
85
85
  "-e", "--expert",
86
86
  action="store_true",
87
87
  default=False,
88
- help="Disable safety speed clamping (full speed for goto/tcp-down)",
88
+ help="Disable safety speed clamping (full speed for goto/tcp-orient)",
89
89
  )
90
90
  teach_parser.add_argument(
91
91
  "-v", "--verbose",
@@ -40,7 +40,7 @@ from urkit.exceptions import (
40
40
  URKitConnectionError,
41
41
  URKitConnectionError as ConnectionError,
42
42
  )
43
- from urkit.geometry import MoveFrame, orient_tcp_down, transform_pose_delta
43
+ from urkit.geometry import MoveFrame, orient_tcp, transform_pose_delta
44
44
  from urkit.gripper.presets import DigitalGripperConfig, PRESETS
45
45
  from urkit.motion import FreedriveMode
46
46
  from urkit.robot import URRobot
@@ -357,6 +357,13 @@ def _draw_screen(
357
357
  except Exception:
358
358
  pass
359
359
 
360
+ # IK reference
361
+ ik_ref = state.get("ik_reference")
362
+ if ik_ref is not None:
363
+ lines.append(f" {blue('IK Ref:'.ljust(lw))} {green(ik_ref if isinstance(ik_ref, str) else 'custom')}")
364
+ else:
365
+ lines.append(f" {blue('IK Ref:'.ljust(lw))} {dim('None')}")
366
+
360
367
  if state["freedrive"]:
361
368
  mode_label = state["freedrive_mode"].name
362
369
  if mode_label == "XYZ":
@@ -397,7 +404,7 @@ def _draw_screen(
397
404
  lines.append(f" {yellow('STEP:')} {yellow('1')}: Linear (mm) {yellow('2')}: Angular (°) {yellow('.')}: Reset")
398
405
  lines.append(f" {yellow('GRIPPER:')} {yellow('X')}: Open {yellow('C')}: Close {yellow('V')}: Position {yellow('6')}: Speed {yellow('7')}: Force")
399
406
  lines.append(f" {yellow('POINTS:')} {yellow('B')}: Save {yellow('G')}: Go To {yellow('H')}: Delete {yellow('R')}: Rename {yellow('P')}: Explorer")
400
- lines.append(f" {yellow('OTHER:')} {yellow('F')}: Freedrive {yellow('M')}: Frame {yellow('N')}: GoTo Mode {yellow('T')}: TCP Down")
407
+ lines.append(f" {yellow('OTHER:')} {yellow('F')}: Freedrive {yellow('M')}: Frame {yellow('N')}: GoTo Mode {yellow('T')}: TCP Orient")
401
408
  lines.append(f" {yellow(' ')} {yellow('0')}: Speed {yellow('Y')}: Save Config")
402
409
  lines.append(f" {yellow('EXIT:')} {yellow('ESC')}")
403
410
  lines.append(dim("=" * width))
@@ -445,7 +452,7 @@ def _draw_help() -> None:
445
452
  lines.append(" OTHER:")
446
453
  lines.append(" F → Cycle freedrive: OFF → ALL → XYZ+Rz → OFF")
447
454
  lines.append(" M → Toggle move frame: BASE / TOOL")
448
- lines.append(" T → Orient TCP downward (roll=180°)")
455
+ lines.append(" T → Open TCP orient submenu (select axis direction)")
449
456
  lines.append(" 0 → Set speed slider (0-100%)")
450
457
  lines.append(" Y → Save config (IP, gripper, points path)")
451
458
  lines.append("")
@@ -878,6 +885,110 @@ def _submenu_explore_points(robot: URRobot, messages: list[str]) -> None:
878
885
  messages.append(f"Error: {e}")
879
886
 
880
887
 
888
+ # TCP orientation directions for the submenu
889
+ _TCP_ORIENT_OPTIONS = [
890
+ ("−Z (down)", [0.0, 0.0, -1.0]),
891
+ ("+Z (up)", [0.0, 0.0, 1.0]),
892
+ ("−Y (back)", [0.0, -1.0, 0.0]),
893
+ ("+Y (fwd)", [0.0, 1.0, 0.0]),
894
+ ("−X (left)", [-1.0, 0.0, 0.0]),
895
+ ("+X (right)", [ 1.0, 0.0, 0.0]),
896
+ ]
897
+
898
+
899
+ def _submenu_orient_tcp(
900
+ robot: URRobot, state: dict, messages: list[str], expert_mode: bool = False
901
+ ) -> None:
902
+ """Interactive submenu to orient TCP along a base-axis direction."""
903
+ cursor = 0
904
+ old_settings = termios.tcgetattr(sys.stdin)
905
+ new_settings = termios.tcgetattr(sys.stdin)
906
+ new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
907
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
908
+ fd = sys.stdin.fileno()
909
+
910
+ try:
911
+ while True:
912
+ sys.stdout.write("\033[2J\033[1;1H")
913
+ sys.stdout.write(cyan(" === ORIENT TCP ===") + "\n")
914
+ sys.stdout.write(dim(" Arrows navigate · Enter select · ESC cancel") + "\n\n")
915
+ sys.stdout.write(blue(" Point tool Z toward:") + "\n")
916
+ sys.stdout.write(" " + dim("─" * 60) + "\n")
917
+
918
+ for i, (label, _direction) in enumerate(_TCP_ORIENT_OPTIONS):
919
+ marker = green("►") if i == cursor else " "
920
+ sys.stdout.write(f" {marker} {label}\n")
921
+
922
+ sys.stdout.write(f"\n {dim('Enter')} to orient {green(_TCP_ORIENT_OPTIONS[cursor][0])}\n")
923
+ sys.stdout.flush()
924
+
925
+ ready, _, _ = select.select([fd], [], [], 0.1)
926
+ if not ready:
927
+ if _cli_monitor and _cli_monitor.fault_detected:
928
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
929
+ raise URKitConnectionError(
930
+ f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
931
+ "RTDE connection lost."
932
+ )
933
+ continue
934
+
935
+ raw = os.read(fd, 64)
936
+ if not raw:
937
+ continue
938
+ text = raw.decode("ascii", errors="replace")
939
+ i = 0
940
+ selected = None
941
+ while i < len(text):
942
+ ch = text[i]
943
+ if ch == "\x1b":
944
+ if i + 2 < len(text) and text[i + 1] == "[":
945
+ key = text[i + 2]
946
+ if key == "A":
947
+ cursor = max(0, cursor - 1)
948
+ elif key == "B":
949
+ cursor = min(len(_TCP_ORIENT_OPTIONS) - 1, cursor + 1)
950
+ i += 3
951
+ continue
952
+ else:
953
+ return None # ESC → cancel
954
+ if ch == "\n" or ch == "\r":
955
+ selected = cursor
956
+ elif ch == "\x03":
957
+ return None
958
+ i += 1
959
+
960
+ if selected is not None:
961
+ break
962
+ finally:
963
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
964
+
965
+ if selected is None:
966
+ messages.append("Cancelled")
967
+ return
968
+
969
+ label, direction = _TCP_ORIENT_OPTIONS[selected]
970
+ try:
971
+ if state["freedrive"]:
972
+ robot.disable_freedrive()
973
+ state["freedrive"] = False
974
+
975
+ pose = robot.get_tcp_pose()
976
+ target = orient_tcp(pose, direction)
977
+ try:
978
+ robot.inverse_kinematics(target)
979
+ except MotionError:
980
+ messages.append(f"Unreachable: TCP {label} — no IK solution")
981
+ return
982
+
983
+ if expert_mode:
984
+ robot.move_to(target, vel=0.5, acc=0.3)
985
+ else:
986
+ robot.move_to(target, vel=0.125, acc=0.1)
987
+ messages.append(f"TCP oriented {label}")
988
+ except MotionError as e:
989
+ messages.append(f"Error: {e}")
990
+
991
+
881
992
  # ------------------------------------------------------------------
882
993
  # Key input helpers
883
994
  # ------------------------------------------------------------------
@@ -944,7 +1055,7 @@ def _teach_pendant(
944
1055
 
945
1056
  Args:
946
1057
  robot: Initialized URRobot instance.
947
- expert_mode: When True, skip safety speed clamping on goto/tcp-down.
1058
+ expert_mode: When True, skip safety speed clamping on goto/tcp-orient.
948
1059
  """
949
1060
  state: dict = {
950
1061
  "linear_step": _DEFAULT_LINEAR_STEP,
@@ -954,6 +1065,7 @@ def _teach_pendant(
954
1065
  "move_frame": robot.move_frame,
955
1066
  "goto_mode": "cartesian", # "cartesian" or "joint" for Go To
956
1067
  "speed_slider": 1.0,
1068
+ "ik_reference": robot._ik_reference,
957
1069
  }
958
1070
 
959
1071
  # Read the robot's current speed slider so the display matches reality.
@@ -1193,31 +1305,13 @@ def _teach_pendant(
1193
1305
  messages.append(f"Go To mode: {state['goto_mode'].capitalize()}")
1194
1306
  command_handled = True
1195
1307
 
1196
- # --- TCP orient down ---
1308
+ # --- TCP orient submenu ---
1197
1309
  elif key == "t":
1198
1310
  if state["move_frame"] == MoveFrame.TOOL:
1199
- messages.append("TCP Down unavailable in TOOL frame")
1311
+ messages.append("TCP Orient unavailable in TOOL frame")
1200
1312
  command_handled = True
1201
1313
  else:
1202
- try:
1203
- if state["freedrive"]:
1204
- robot.disable_freedrive()
1205
- state["freedrive"] = False
1206
- pose = robot.get_tcp_pose()
1207
- target = orient_tcp_down(pose)
1208
- try:
1209
- robot.inverse_kinematics(target)
1210
- except MotionError:
1211
- messages.append("Unreachable: TCP Down — no IK solution")
1212
- command_handled = True
1213
- else:
1214
- if expert_mode:
1215
- robot.move_to(target, vel=0.5, acc=0.3)
1216
- else:
1217
- robot.move_to(target, vel=0.125, acc=0.1)
1218
- messages.append("TCP oriented downward")
1219
- except MotionError as e:
1220
- messages.append(f"Error: {e}")
1314
+ _submenu_orient_tcp(robot, state, messages, expert_mode)
1221
1315
  command_handled = True
1222
1316
 
1223
1317
  # --- Gripper ---
@@ -1460,8 +1554,15 @@ def teach_command(args) -> None:
1460
1554
  if gripper_name:
1461
1555
  print(f" Gripper: {gripper_name}")
1462
1556
  print(f" Points: {points_path}")
1463
- if ik_reference:
1464
- print(f" IK ref: {ik_reference}")
1557
+ if gripper_config is not None:
1558
+ tcp = gripper_config.tcp_offset
1559
+ non_zero = [v for v in tcp if abs(v) > 1e-6]
1560
+ if len(non_zero) == 1 and abs(tcp[0]) < 1e-6 and abs(tcp[1]) < 1e-6:
1561
+ print(f" TCP: Z={tcp[2]*1000:.1f}mm")
1562
+ elif len(non_zero) <= 3 and all(abs(tcp[i]) < 1e-6 for i in range(3, 6)):
1563
+ print(f" TCP: X={tcp[0]*1000:.1f} Y={tcp[1]*1000:.1f} Z={tcp[2]*1000:.1f}mm")
1564
+ else:
1565
+ print(f" TCP: {list(tcp)}")
1465
1566
 
1466
1567
  # URRobot handles everything: safety recovery, remote mode check,
1467
1568
  # power on, brake release, program stop, and RTDE connection.
@@ -87,75 +87,113 @@ def rpy_to_quat(
87
87
  )
88
88
 
89
89
 
90
- def orient_tcp_down(pose: list[float]) -> list[float]:
91
- """Orient TCP downward while preserving tool heading.
92
-
93
- Points the tool Z-axis straight down (base frame -Z) while
94
- preserving the tool's heading (X-axis direction projected to
95
- the XY plane). This avoids the ambiguity of RPY yaw at roll=π
96
- and produces a smooth, predictable orientation change.
90
+ def orient_tcp(
91
+ pose: list[float], direction: list[float]
92
+ ) -> list[float]:
93
+ """Orient TCP along a target direction via minimal relative rotation.
97
94
 
98
- Matches the approach used in ur_bag_picking's orient_face_down.
95
+ Computes the smallest rotation that maps the current tool Z-axis
96
+ onto *direction* (in the base frame), then applies that rotation
97
+ to the current pose. This is a relative rotation from where you
98
+ are — no absolute frame reconstruction, no heading-preservation
99
+ heuristics that cause unexpected large swings.
99
100
 
100
101
  Args:
101
102
  pose: [x, y, z, rx, ry, rz] rotation vector pose.
103
+ direction: Target direction for tool Z-axis [dx, dy, dz]
104
+ (normalized automatically).
102
105
 
103
106
  Returns:
104
- New pose with same position but TCP pointing downward.
107
+ New pose with same position but TCP pointing along direction.
105
108
  """
106
109
  pos = pose[:3]
107
110
  rv = pose[3:]
108
111
 
109
- # Current rotation matrix (columns are X, Y, Z axes)
110
- R = _rotvec_to_matrix(rv)
111
-
112
- # Target: Z points down
113
- z_new = [0.0, 0.0, -1.0]
114
-
115
- # Preserve heading: project current X axis to XY plane
116
- x_new = [R[0][0], R[1][0], 0.0]
117
- norm_x = math.sqrt(x_new[0] ** 2 + x_new[1] ** 2)
118
-
119
- if norm_x < 1e-6:
120
- # X is nearly vertical — use Y projection instead
121
- x_new = [0.0, 0.0, 0.0]
122
- y_new = [R[0][1], R[1][1], 0.0]
123
- norm_y = math.sqrt(y_new[0] ** 2 + y_new[1] ** 2)
124
-
125
- if norm_y < 1e-6:
126
- # Both vertical — fallback to roll=π
127
- return [pos[0], pos[1], pos[2], math.pi, 0.0, 0.0]
128
-
129
- y_new[0] /= norm_y
130
- y_new[1] /= norm_y
131
- # x = y cross z (right-hand rule: y × -z)
132
- x_new = [
133
- y_new[1] * z_new[2] - y_new[2] * z_new[1],
134
- y_new[2] * z_new[0] - y_new[0] * z_new[2],
135
- y_new[0] * z_new[1] - y_new[1] * z_new[0],
112
+ # Normalize target direction
113
+ norm_d = math.sqrt(direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2)
114
+ if norm_d < 1e-10:
115
+ raise ValueError(f"Direction vector is zero: {direction}")
116
+ z_target = [d / norm_d for d in direction]
117
+
118
+ # Current rotation matrix (columns are X, Y, Z axes in base frame)
119
+ R_curr = _rotvec_to_matrix(rv)
120
+
121
+ # Current tool Z-axis in base frame (3rd column of R_curr)
122
+ z_curr = [R_curr[i][2] for i in range(3)]
123
+
124
+ # Dot product to determine alignment
125
+ dot_cos = z_curr[0] * z_target[0] + z_curr[1] * z_target[1] + z_curr[2] * z_target[2]
126
+
127
+ # Already aligned within epsilon
128
+ if dot_cos > 1.0 - 1e-10:
129
+ return [pos[0], pos[1], pos[2], rv[0], rv[1], rv[2]]
130
+
131
+ # Compute rotation axis and angle
132
+ if dot_cos < -1.0 + 1e-10:
133
+ # 180° case — z_curr is opposite z_target, cross product is zero
134
+ # Pick a reference vector perpendicular to z_curr
135
+ if abs(z_curr[0]) <= abs(z_curr[1]) and abs(z_curr[0]) <= abs(z_curr[2]):
136
+ ref = [1.0, 0.0, 0.0]
137
+ elif abs(z_curr[1]) <= abs(z_curr[2]):
138
+ ref = [0.0, 1.0, 0.0]
139
+ else:
140
+ ref = [0.0, 0.0, 1.0]
141
+ axis = [
142
+ z_curr[1] * ref[2] - z_curr[2] * ref[1],
143
+ z_curr[2] * ref[0] - z_curr[0] * ref[2],
144
+ z_curr[0] * ref[1] - z_curr[1] * ref[0],
136
145
  ]
146
+ norm_axis = math.sqrt(axis[0] ** 2 + axis[1] ** 2 + axis[2] ** 2)
147
+ if norm_axis < 1e-10:
148
+ # Should not happen, but guard against it
149
+ axis, norm_axis = [1.0, 0.0, 0.0], 1.0
150
+ ax, ay, az = axis[0] / norm_axis, axis[1] / norm_axis, axis[2] / norm_axis
151
+ angle = math.pi
137
152
  else:
138
- x_new[0] /= norm_x
139
- x_new[1] /= norm_x
140
- # y = z cross x
141
- y_new = [
142
- z_new[1] * x_new[2] - z_new[2] * x_new[1],
143
- z_new[2] * x_new[0] - z_new[0] * x_new[2],
144
- z_new[0] * x_new[1] - z_new[1] * x_new[0],
153
+ # General case: rotation axis = z_curr × z_target
154
+ cross = [
155
+ z_curr[1] * z_target[2] - z_curr[2] * z_target[1],
156
+ z_curr[2] * z_target[0] - z_curr[0] * z_target[2],
157
+ z_curr[0] * z_target[1] - z_curr[1] * z_target[0],
145
158
  ]
159
+ norm_cross = math.sqrt(cross[0] ** 2 + cross[1] ** 2 + cross[2] ** 2)
160
+ ax, ay, az = cross[0] / norm_cross, cross[1] / norm_cross, cross[2] / norm_cross
161
+ angle = math.acos(max(-1.0, min(1.0, dot_cos)))
146
162
 
147
- # Build rotation matrix from columns [x_new, y_new, z_new]
148
- R_new = [
149
- [x_new[0], y_new[0], z_new[0]],
150
- [x_new[1], y_new[1], z_new[1]],
151
- [x_new[2], y_new[2], z_new[2]],
163
+ # Build delta rotation matrix from axis-angle (Rodrigues)
164
+ s = math.sin(angle)
165
+ c = math.cos(angle)
166
+ oc = 1.0 - c
167
+ R_delta = [
168
+ [c + ax*ax*oc, ax*ay*oc - az*s, ax*az*oc + ay*s],
169
+ [ay*ax*oc + az*s, c + ay*ay*oc, ay*az*oc - ax*s],
170
+ [az*ax*oc - ay*s, az*ay*oc + ax*s, c + az*az*oc],
152
171
  ]
153
172
 
173
+ # Apply delta: R_new = R_delta @ R_curr (rotate current orientation by delta)
174
+ R_new = _mat_mul(R_delta, R_curr)
175
+
154
176
  rv_target = _matrix_to_rotvec(R_new)
155
177
 
156
178
  return [pos[0], pos[1], pos[2], rv_target[0], rv_target[1], rv_target[2]]
157
179
 
158
180
 
181
+ def orient_tcp_down(pose: list[float]) -> list[float]:
182
+ """Orient TCP downward while preserving tool heading.
183
+
184
+ Points the tool Z-axis straight down (base frame -Z) while
185
+ preserving the tool's heading. Convenience wrapper around
186
+ :func:`orient_tcp`.
187
+
188
+ Args:
189
+ pose: [x, y, z, rx, ry, rz] rotation vector pose.
190
+
191
+ Returns:
192
+ New pose with same position but TCP pointing downward.
193
+ """
194
+ return orient_tcp(pose, [0.0, 0.0, -1.0])
195
+
196
+
159
197
  # ------------------------------------------------------------------
160
198
  # Quaternion algebra helpers
161
199
  # ------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.3.20
3
+ Version: 0.3.22
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
@@ -0,0 +1,286 @@
1
+ """Tests for quaternion/rotation vector geometry helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import pytest
8
+
9
+ from urkit.geometry import (
10
+ orient_tcp,
11
+ orient_tcp_down,
12
+ quat_to_rotvec,
13
+ quat_to_rpy,
14
+ rpy_to_quat,
15
+ rotvec_to_quat,
16
+ _rotvec_to_matrix,
17
+ )
18
+
19
+
20
+ class TestRotvecToQuat:
21
+ """Rotation vector to quaternion conversion."""
22
+
23
+ def test_identity(self):
24
+ q = rotvec_to_quat([0, 0, 0])
25
+ assert abs(q[3] - 1.0) < 1e-10 # w = 1
26
+ assert abs(q[0]) < 1e-10
27
+ assert abs(q[1]) < 1e-10
28
+ assert abs(q[2]) < 1e-10
29
+
30
+ def test_180_degrees_x_axis(self):
31
+ """180° rotation around X axis."""
32
+ q = rotvec_to_quat([math.pi, 0, 0])
33
+ assert abs(q[0] - 1.0) < 1e-10 # x = 1
34
+ assert abs(q[1]) < 1e-10
35
+ assert abs(q[2]) < 1e-10
36
+ assert abs(q[3]) < 1e-10 # w = 0
37
+
38
+ def test_90_degrees_z_axis(self):
39
+ """90° rotation around Z axis."""
40
+ q = rotvec_to_quat([0, 0, math.pi / 2])
41
+ assert abs(q[0]) < 1e-10
42
+ assert abs(q[1]) < 1e-10
43
+ assert abs(q[2] - math.sin(math.pi / 4)) < 1e-10
44
+ assert abs(q[3] - math.cos(math.pi / 4)) < 1e-10
45
+
46
+ def test_roundtrip(self):
47
+ """rotvec -> quat -> rotvec should be identity."""
48
+ rv = [0.5, 0.3, 0.1]
49
+ q = rotvec_to_quat(rv)
50
+ back = quat_to_rotvec(q)
51
+ for a, b in zip(rv, back):
52
+ assert abs(a - b) < 1e-10
53
+
54
+
55
+ class TestQuatToRotvec:
56
+ """Quaternion to rotation vector conversion."""
57
+
58
+ def test_identity_quat(self):
59
+ rv = quat_to_rotvec((0, 0, 0, 1))
60
+ assert all(abs(v) < 1e-10 for v in rv)
61
+
62
+ def test_roundtrip(self):
63
+ """quat -> rotvec -> quat should be identity."""
64
+ q = (0.7071, 0, 0, 0.7071) # 90° around X
65
+ rv = quat_to_rotvec(q)
66
+ back = rotvec_to_quat(rv)
67
+ for a, b in zip(q, back):
68
+ assert abs(a - b) < 1e-5
69
+
70
+
71
+ class TestQuatToRpy:
72
+ """Quaternion to RPY extraction."""
73
+
74
+ def test_identity(self):
75
+ roll, pitch, yaw = quat_to_rpy((0, 0, 0, 1))
76
+ assert abs(roll) < 1e-10
77
+ assert abs(pitch) < 1e-10
78
+ assert abs(yaw) < 1e-10
79
+
80
+ def test_90_deg_roll(self):
81
+ """90° roll should give roll=pi/2."""
82
+ q = rotvec_to_quat([math.pi / 2, 0, 0])
83
+ roll, pitch, yaw = quat_to_rpy(q)
84
+ assert abs(roll - math.pi / 2) < 1e-10
85
+ assert abs(pitch) < 1e-10
86
+ assert abs(yaw) < 1e-10
87
+
88
+ def test_roundtrip(self):
89
+ """rpy -> quat -> rpy should be identity."""
90
+ r, p, y = 0.5, 0.3, 0.1
91
+ q = rpy_to_quat(r, p, y)
92
+ back_r, back_p, back_y = quat_to_rpy(q)
93
+ assert abs(r - back_r) < 1e-10
94
+ assert abs(p - back_p) < 1e-10
95
+ assert abs(y - back_y) < 1e-10
96
+
97
+
98
+ class TestRpyToQuat:
99
+ """RPY to quaternion conversion."""
100
+
101
+ def test_zero_rpy(self):
102
+ q = rpy_to_quat(0, 0, 0)
103
+ assert abs(q[0]) < 1e-10
104
+ assert abs(q[1]) < 1e-10
105
+ assert abs(q[2]) < 1e-10
106
+ assert abs(q[3] - 1.0) < 1e-10
107
+
108
+ def test_180_roll(self):
109
+ """180° roll -> known quaternion."""
110
+ q = rpy_to_quat(math.pi, 0, 0)
111
+ assert abs(q[0] - 1.0) < 1e-10
112
+ assert abs(q[1]) < 1e-10
113
+ assert abs(q[2]) < 1e-10
114
+ assert abs(q[3]) < 1e-10
115
+
116
+
117
+ class TestOrientTcpDown:
118
+ """TCP downward orientation."""
119
+
120
+ def test_preserves_position(self):
121
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
122
+ result = orient_tcp_down(pose)
123
+ assert result[0] == 0.5
124
+ assert result[1] == 0.3
125
+ assert result[2] == 0.2
126
+
127
+ def test_z_axis_points_down(self):
128
+ """Resulting tool Z-axis should point straight down in base frame."""
129
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
130
+ result = orient_tcp_down(pose)
131
+ rv = result[3:]
132
+ # For roll=π, Z-axis = (0, 0, -1)
133
+ angle = math.sqrt(sum(v**2 for v in rv))
134
+ if angle > 1e-10:
135
+ az = rv[2] / angle
136
+ c = math.cos(angle)
137
+ oc = 1.0 - c
138
+ # R[2][2] = c + az*az*oc — tool Z in base frame
139
+ z_z = c + az * az * oc
140
+ assert abs(z_z - (-1.0)) < 1e-10
141
+
142
+ def test_minimal_rotation_angle(self):
143
+ """Orient from yaw=0.5 to down should produce minimal rotation."""
144
+ q = rpy_to_quat(0, 0, 0.5)
145
+ rv = quat_to_rotvec(q)
146
+ pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
147
+ result = orient_tcp_down(pose)
148
+
149
+ # The minimal rotation from Z=[0,0,1] to Z=[0,0,-1] is 180°
150
+ result_angle = math.sqrt(sum(v**2 for v in result[3:]))
151
+ assert abs(result_angle - math.pi) < 1e-10
152
+
153
+ def test_handles_gimbal_lock(self):
154
+ """Pitch=90° (gimbal lock) should still produce valid down orientation."""
155
+ q = rpy_to_quat(0, math.pi / 2, 0.5)
156
+ rv = quat_to_rotvec(q)
157
+ pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
158
+ result = orient_tcp_down(pose)
159
+ # Should not crash, Z should point down
160
+ assert len(result) == 6
161
+ assert result[:3] == pose[:3] # position preserved
162
+
163
+
164
+ class TestFullRoundtrip:
165
+ """Full chain: rotvec -> quat -> rpy -> quat -> rotvec."""
166
+
167
+ def test_various_angles(self):
168
+ test_vectors = [
169
+ [0, 0, 0],
170
+ [math.pi / 6, 0, 0],
171
+ [0, math.pi / 4, 0],
172
+ [0, 0, math.pi / 3],
173
+ [0.5, 0.3, 0.2],
174
+ [1.0, 0.5, 0.3],
175
+ ]
176
+ for rv in test_vectors:
177
+ q = rotvec_to_quat(rv)
178
+ r, p, y = quat_to_rpy(q)
179
+ q2 = rpy_to_quat(r, p, y)
180
+ rv2 = quat_to_rotvec(q2)
181
+ for a, b in zip(rv, rv2):
182
+ assert abs(a - b) < 1e-10, f"Failed for {rv}"
183
+
184
+
185
+ class TestOrientTcp:
186
+ """TCP orientation in arbitrary directions (minimal relative rotation)."""
187
+
188
+ def test_preserves_position(self):
189
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
190
+ result = orient_tcp(pose, [1, 0, 0])
191
+ assert result[0] == 0.5
192
+ assert result[1] == 0.3
193
+ assert result[2] == 0.2
194
+
195
+ def test_z_points_along_target(self):
196
+ """Tool Z-axis should point along the target direction."""
197
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
198
+ result = orient_tcp(pose, [1, 0, 0])
199
+ R = _rotvec_to_matrix(result[3:])
200
+ z = [R[i][2] for i in range(3)]
201
+ assert abs(z[0] - 1.0) < 1e-10
202
+ assert abs(z[1]) < 1e-10
203
+ assert abs(z[2]) < 1e-10
204
+
205
+ def test_z_points_along_neg_y(self):
206
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
207
+ result = orient_tcp(pose, [0, -1, 0])
208
+ R = _rotvec_to_matrix(result[3:])
209
+ z = [R[i][2] for i in range(3)]
210
+ assert abs(z[0]) < 1e-10
211
+ assert abs(z[1] - (-1.0)) < 1e-10
212
+ assert abs(z[2]) < 1e-10
213
+
214
+ def test_minimal_rotation_non_180(self):
215
+ """Z up (yaw=30°) → +X: delta should be 90°, not 120°."""
216
+ q = rpy_to_quat(0, 0, math.radians(30))
217
+ rv = quat_to_rotvec(q)
218
+ pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
219
+ result = orient_tcp(pose, [1, 0, 0])
220
+ # Compute delta rotation: R_delta = R_new @ R_curr^T
221
+ R_curr = _rotvec_to_matrix(pose[3:])
222
+ R_new = _rotvec_to_matrix(result[3:])
223
+ # Transpose of R_curr (since it's orthogonal, inverse = transpose)
224
+ R_curr_T = [[R_curr[0][0], R_curr[1][0], R_curr[2][0]],
225
+ [R_curr[0][1], R_curr[1][1], R_curr[2][1]],
226
+ [R_curr[0][2], R_curr[1][2], R_curr[2][2]]]
227
+ R_delta = [
228
+ [sum(R_new[i][k] * R_curr_T[k][j] for k in range(3)) for j in range(3)]
229
+ for i in range(3)
230
+ ]
231
+ trace = R_delta[0][0] + R_delta[1][1] + R_delta[2][2]
232
+ delta_angle = math.acos(max(-1, min(1, (trace - 1) / 2)))
233
+ # Minimal rotation from [0,0,1] to [1,0,0] is 90°
234
+ assert abs(delta_angle - math.pi / 2) < 1e-10, f"delta_angle={delta_angle}"
235
+
236
+ def test_already_aligned_returns_unchanged(self):
237
+ """If Z already points along target, pose should be unchanged."""
238
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
239
+ result = orient_tcp(pose, [0, 0, 1])
240
+ assert result[3:] == pose[3:]
241
+
242
+ def test_180_degree_flip(self):
243
+ """Z up → Z down should give a valid 180° rotation."""
244
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
245
+ result = orient_tcp(pose, [0, 0, -1])
246
+ angle = math.sqrt(sum(v**2 for v in result[3:]))
247
+ assert abs(angle - math.pi) < 1e-10
248
+ R = _rotvec_to_matrix(result[3:])
249
+ z = [R[i][2] for i in range(3)]
250
+ assert abs(z[0]) < 1e-10
251
+ assert abs(z[1]) < 1e-10
252
+ assert abs(z[2] - (-1.0)) < 1e-10
253
+
254
+ def test_raises_on_zero_direction(self):
255
+ pose = [0.5, 0.3, 0.2, 0, 0, 0]
256
+ with pytest.raises(ValueError):
257
+ orient_tcp(pose, [0, 0, 0])
258
+
259
+ def test_various_starting_poses(self):
260
+ """Multiple starting orientations should all produce valid results."""
261
+ targets = [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]]
262
+ for roll in [0, math.radians(45), math.radians(90)]:
263
+ for pitch in [0, math.radians(-30), math.radians(60)]:
264
+ for yaw in [0, math.radians(30)]:
265
+ q = rpy_to_quat(roll, pitch, yaw)
266
+ rv = quat_to_rotvec(q)
267
+ pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
268
+ for target in targets:
269
+ result = orient_tcp(pose, target)
270
+ # Verify Z points along target
271
+ R = _rotvec_to_matrix(result[3:])
272
+ z = [R[i][2] for i in range(3)]
273
+ norm_t = math.sqrt(target[0]**2 + target[1]**2 + target[2]**2)
274
+ t = [target[i]/norm_t for i in range(3)]
275
+ dot = z[0]*t[0] + z[1]*t[1] + z[2]*t[2]
276
+ assert abs(dot - 1.0) < 1e-10, (
277
+ f"Z not aligned: roll={roll}, pitch={pitch}, "
278
+ f"yaw={yaw}, target={target}"
279
+ )
280
+ # Verify rotation matrix is proper (det = 1)
281
+ det = (
282
+ R[0][0]*(R[1][1]*R[2][2] - R[1][2]*R[2][1])
283
+ - R[0][1]*(R[1][0]*R[2][2] - R[1][2]*R[2][0])
284
+ + R[0][2]*(R[1][0]*R[2][1] - R[1][1]*R[2][0])
285
+ )
286
+ assert abs(det - 1.0) < 1e-10
@@ -1,187 +0,0 @@
1
- """Tests for quaternion/rotation vector geometry helpers."""
2
-
3
- from __future__ import annotations
4
-
5
- import math
6
-
7
- from urkit.geometry import (
8
- orient_tcp_down,
9
- quat_to_rotvec,
10
- quat_to_rpy,
11
- rpy_to_quat,
12
- rotvec_to_quat,
13
- )
14
-
15
-
16
- class TestRotvecToQuat:
17
- """Rotation vector to quaternion conversion."""
18
-
19
- def test_identity(self):
20
- q = rotvec_to_quat([0, 0, 0])
21
- assert abs(q[3] - 1.0) < 1e-10 # w = 1
22
- assert abs(q[0]) < 1e-10
23
- assert abs(q[1]) < 1e-10
24
- assert abs(q[2]) < 1e-10
25
-
26
- def test_180_degrees_x_axis(self):
27
- """180° rotation around X axis."""
28
- q = rotvec_to_quat([math.pi, 0, 0])
29
- assert abs(q[0] - 1.0) < 1e-10 # x = 1
30
- assert abs(q[1]) < 1e-10
31
- assert abs(q[2]) < 1e-10
32
- assert abs(q[3]) < 1e-10 # w = 0
33
-
34
- def test_90_degrees_z_axis(self):
35
- """90° rotation around Z axis."""
36
- q = rotvec_to_quat([0, 0, math.pi / 2])
37
- assert abs(q[0]) < 1e-10
38
- assert abs(q[1]) < 1e-10
39
- assert abs(q[2] - math.sin(math.pi / 4)) < 1e-10
40
- assert abs(q[3] - math.cos(math.pi / 4)) < 1e-10
41
-
42
- def test_roundtrip(self):
43
- """rotvec -> quat -> rotvec should be identity."""
44
- rv = [0.5, 0.3, 0.1]
45
- q = rotvec_to_quat(rv)
46
- back = quat_to_rotvec(q)
47
- for a, b in zip(rv, back):
48
- assert abs(a - b) < 1e-10
49
-
50
-
51
- class TestQuatToRotvec:
52
- """Quaternion to rotation vector conversion."""
53
-
54
- def test_identity_quat(self):
55
- rv = quat_to_rotvec((0, 0, 0, 1))
56
- assert all(abs(v) < 1e-10 for v in rv)
57
-
58
- def test_roundtrip(self):
59
- """quat -> rotvec -> quat should be identity."""
60
- q = (0.7071, 0, 0, 0.7071) # 90° around X
61
- rv = quat_to_rotvec(q)
62
- back = rotvec_to_quat(rv)
63
- for a, b in zip(q, back):
64
- assert abs(a - b) < 1e-5
65
-
66
-
67
- class TestQuatToRpy:
68
- """Quaternion to RPY extraction."""
69
-
70
- def test_identity(self):
71
- roll, pitch, yaw = quat_to_rpy((0, 0, 0, 1))
72
- assert abs(roll) < 1e-10
73
- assert abs(pitch) < 1e-10
74
- assert abs(yaw) < 1e-10
75
-
76
- def test_90_deg_roll(self):
77
- """90° roll should give roll=pi/2."""
78
- q = rotvec_to_quat([math.pi / 2, 0, 0])
79
- roll, pitch, yaw = quat_to_rpy(q)
80
- assert abs(roll - math.pi / 2) < 1e-10
81
- assert abs(pitch) < 1e-10
82
- assert abs(yaw) < 1e-10
83
-
84
- def test_roundtrip(self):
85
- """rpy -> quat -> rpy should be identity."""
86
- r, p, y = 0.5, 0.3, 0.1
87
- q = rpy_to_quat(r, p, y)
88
- back_r, back_p, back_y = quat_to_rpy(q)
89
- assert abs(r - back_r) < 1e-10
90
- assert abs(p - back_p) < 1e-10
91
- assert abs(y - back_y) < 1e-10
92
-
93
-
94
- class TestRpyToQuat:
95
- """RPY to quaternion conversion."""
96
-
97
- def test_zero_rpy(self):
98
- q = rpy_to_quat(0, 0, 0)
99
- assert abs(q[0]) < 1e-10
100
- assert abs(q[1]) < 1e-10
101
- assert abs(q[2]) < 1e-10
102
- assert abs(q[3] - 1.0) < 1e-10
103
-
104
- def test_180_roll(self):
105
- """180° roll -> known quaternion."""
106
- q = rpy_to_quat(math.pi, 0, 0)
107
- assert abs(q[0] - 1.0) < 1e-10
108
- assert abs(q[1]) < 1e-10
109
- assert abs(q[2]) < 1e-10
110
- assert abs(q[3]) < 1e-10
111
-
112
-
113
- class TestOrientTcpDown:
114
- """TCP downward orientation."""
115
-
116
- def test_preserves_position(self):
117
- pose = [0.5, 0.3, 0.2, 0, 0, 0]
118
- result = orient_tcp_down(pose)
119
- assert result[0] == 0.5
120
- assert result[1] == 0.3
121
- assert result[2] == 0.2
122
-
123
- def test_z_axis_points_down(self):
124
- """Resulting tool Z-axis should point straight down in base frame."""
125
- pose = [0.5, 0.3, 0.2, 0, 0, 0]
126
- result = orient_tcp_down(pose)
127
- rv = result[3:]
128
- # For roll=π, Z-axis = (0, 0, -1)
129
- angle = math.sqrt(sum(v**2 for v in rv))
130
- if angle > 1e-10:
131
- ax, ay, az = rv[0]/angle, rv[1]/angle, rv[2]/angle
132
- s, c = math.sin(angle), math.cos(angle)
133
- oc = 1 - c
134
- # R[2][2] = c + az*az*oc — tool Z in base frame
135
- z_z = c + az*az*oc
136
- assert abs(z_z - (-1.0)) < 1e-10
137
-
138
- def test_preserves_heading(self):
139
- """Tool X-axis direction in XY plane should be preserved."""
140
- # Start with yaw=0.5 (tool facing ~45° from X)
141
- q = rpy_to_quat(0, 0, 0.5)
142
- rv = quat_to_rotvec(q)
143
- pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
144
- result = orient_tcp_down(pose)
145
-
146
- # Original X axis angle in XY plane
147
- orig_q = rotvec_to_quat(pose[3:])
148
- orig_rpy = quat_to_rpy(orig_q)
149
- orig_heading = orig_rpy[2] # yaw = heading in XY plane
150
-
151
- # Result X axis angle in XY plane
152
- result_q = rotvec_to_quat(result[3:])
153
- result_rpy = quat_to_rpy(result_q)
154
- result_heading = result_rpy[2]
155
-
156
- assert abs(orig_heading - result_heading) < 1e-10
157
-
158
- def test_handles_gimbal_lock(self):
159
- """Pitch=90° (gimbal lock) should still produce valid down orientation."""
160
- q = rpy_to_quat(0, math.pi / 2, 0.5)
161
- rv = quat_to_rotvec(q)
162
- pose = [0.5, 0.3, 0.2, rv[0], rv[1], rv[2]]
163
- result = orient_tcp_down(pose)
164
- # Should not crash, Z should point down
165
- assert len(result) == 6
166
- assert result[:3] == pose[:3] # position preserved
167
-
168
-
169
- class TestFullRoundtrip:
170
- """Full chain: rotvec -> quat -> rpy -> quat -> rotvec."""
171
-
172
- def test_various_angles(self):
173
- test_vectors = [
174
- [0, 0, 0],
175
- [math.pi / 6, 0, 0],
176
- [0, math.pi / 4, 0],
177
- [0, 0, math.pi / 3],
178
- [0.5, 0.3, 0.2],
179
- [1.0, 0.5, 0.3],
180
- ]
181
- for rv in test_vectors:
182
- q = rotvec_to_quat(rv)
183
- r, p, y = quat_to_rpy(q)
184
- q2 = rpy_to_quat(r, p, y)
185
- rv2 = quat_to_rotvec(q2)
186
- for a, b in zip(rv, rv2):
187
- assert abs(a - b) < 1e-10, f"Failed for {rv}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes