urkit 0.3.21__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.21 → urkit-0.3.22}/PKG-INFO +1 -1
  2. {urkit-0.3.21 → urkit-0.3.22}/pyproject.toml +1 -1
  3. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/__init__.py +1 -1
  4. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/geometry.py +60 -63
  5. {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/PKG-INFO +1 -1
  6. urkit-0.3.22/tests/test_geometry.py +286 -0
  7. urkit-0.3.21/tests/test_geometry.py +0 -187
  8. {urkit-0.3.21 → urkit-0.3.22}/README.md +0 -0
  9. {urkit-0.3.21 → urkit-0.3.22}/setup.cfg +0 -0
  10. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/__main__.py +0 -0
  11. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/__init__.py +0 -0
  12. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/colors.py +0 -0
  13. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/connection_monitor.py +0 -0
  14. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/points.py +0 -0
  15. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/cli/teach.py +0 -0
  16. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/config.py +0 -0
  17. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/connection.py +0 -0
  18. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/exceptions.py +0 -0
  19. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/__init__.py +0 -0
  20. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/base.py +0 -0
  21. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/digital.py +0 -0
  22. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/presets.py +0 -0
  23. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/robotiq.py +0 -0
  24. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/gripper/robotiq_preamble.py +0 -0
  25. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/io.py +0 -0
  26. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/motion.py +0 -0
  27. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/points.py +0 -0
  28. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/robot.py +0 -0
  29. {urkit-0.3.21 → urkit-0.3.22}/src/urkit/telemetry.py +0 -0
  30. {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/SOURCES.txt +0 -0
  31. {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/dependency_links.txt +0 -0
  32. {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/entry_points.txt +0 -0
  33. {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/requires.txt +0 -0
  34. {urkit-0.3.21 → urkit-0.3.22}/src/urkit.egg-info/top_level.txt +0 -0
  35. {urkit-0.3.21 → urkit-0.3.22}/tests/test_exceptions.py +0 -0
  36. {urkit-0.3.21 → urkit-0.3.22}/tests/test_gripper.py +0 -0
  37. {urkit-0.3.21 → urkit-0.3.22}/tests/test_gripper_factory.py +0 -0
  38. {urkit-0.3.21 → urkit-0.3.22}/tests/test_gripper_presets.py +0 -0
  39. {urkit-0.3.21 → urkit-0.3.22}/tests/test_move_sequence.py +0 -0
  40. {urkit-0.3.21 → urkit-0.3.22}/tests/test_points.py +0 -0
  41. {urkit-0.3.21 → 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.21
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.21"
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.21"
27
+ __version__ = "0.3.22"
28
28
 
29
29
  from urkit.config import load_config, resolve_config
30
30
  from urkit.exceptions import (
@@ -90,13 +90,13 @@ def rpy_to_quat(
90
90
  def orient_tcp(
91
91
  pose: list[float], direction: list[float]
92
92
  ) -> list[float]:
93
- """Orient TCP along a target direction while preserving tool heading.
93
+ """Orient TCP along a target direction via minimal relative rotation.
94
94
 
95
- Points the tool Z-axis along *direction* (base frame) while
96
- preserving the tool's heading (X-axis direction projected onto
97
- the plane perpendicular to the target). This avoids the ambiguity
98
- of RPY yaw at extreme angles and produces a smooth, predictable
99
- orientation change.
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.
100
100
 
101
101
  Args:
102
102
  pose: [x, y, z, rx, ry, rz] rotation vector pose.
@@ -113,69 +113,66 @@ def orient_tcp(
113
113
  norm_d = math.sqrt(direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2)
114
114
  if norm_d < 1e-10:
115
115
  raise ValueError(f"Direction vector is zero: {direction}")
116
- z_new = [d / norm_d for d in direction]
117
-
118
- # Current rotation matrix (columns are X, Y, Z axes)
119
- R = _rotvec_to_matrix(rv)
120
-
121
- # Current X and Y axes
122
- x_curr = [R[0][0], R[1][0], R[2][0]]
123
- y_curr = [R[0][1], R[1][1], R[2][1]]
124
-
125
- # Preserve heading: project current X axis onto plane perpendicular to z_new
126
- dot_xz = x_curr[0] * z_new[0] + x_curr[1] * z_new[1] + x_curr[2] * z_new[2]
127
- x_new = [x_curr[i] - dot_xz * z_new[i] for i in range(3)]
128
- norm_x = math.sqrt(x_new[0] ** 2 + x_new[1] ** 2 + x_new[2] ** 2)
129
-
130
- if norm_x < 1e-6:
131
- # X is nearly parallel to target — use Y projection instead
132
- dot_yz = y_curr[0] * z_new[0] + y_curr[1] * z_new[1] + y_curr[2] * z_new[2]
133
- y_new = [y_curr[i] - dot_yz * z_new[i] for i in range(3)]
134
- norm_y = math.sqrt(y_new[0] ** 2 + y_new[1] ** 2 + y_new[2] ** 2)
135
-
136
- if norm_y < 1e-6:
137
- # Both parallel — fallback: pick arbitrary orthogonal axis
138
- # Use cross product with smallest component of z_new
139
- if abs(z_new[0]) <= abs(z_new[1]) and abs(z_new[0]) <= abs(z_new[2]):
140
- ref = [1.0, 0.0, 0.0]
141
- elif abs(z_new[1]) <= abs(z_new[2]):
142
- ref = [0.0, 1.0, 0.0]
143
- else:
144
- ref = [0.0, 0.0, 1.0]
145
- y_new = [
146
- z_new[1] * ref[2] - z_new[2] * ref[1],
147
- z_new[2] * ref[0] - z_new[0] * ref[2],
148
- z_new[0] * ref[1] - z_new[1] * ref[0],
149
- ]
150
- norm_y = math.sqrt(y_new[0] ** 2 + y_new[1] ** 2 + y_new[2] ** 2)
151
-
152
- y_new[0] /= norm_y
153
- y_new[1] /= norm_y
154
- y_new[2] /= norm_y
155
- # x = y cross z
156
- x_new = [
157
- y_new[1] * z_new[2] - y_new[2] * z_new[1],
158
- y_new[2] * z_new[0] - y_new[0] * z_new[2],
159
- y_new[0] * z_new[1] - y_new[1] * z_new[0],
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],
160
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
161
152
  else:
162
- x_new[0] /= norm_x
163
- x_new[1] /= norm_x
164
- x_new[2] /= norm_x
165
- # y = z cross x
166
- y_new = [
167
- z_new[1] * x_new[2] - z_new[2] * x_new[1],
168
- z_new[2] * x_new[0] - z_new[0] * x_new[2],
169
- 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],
170
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)))
171
162
 
172
- # Build rotation matrix from columns [x_new, y_new, z_new]
173
- R_new = [
174
- [x_new[0], y_new[0], z_new[0]],
175
- [x_new[1], y_new[1], z_new[1]],
176
- [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],
177
171
  ]
178
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
+
179
176
  rv_target = _matrix_to_rotvec(R_new)
180
177
 
181
178
  return [pos[0], pos[1], pos[2], rv_target[0], rv_target[1], rv_target[2]]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.3.21
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
File without changes
File without changes