urkit 0.3.19__tar.gz → 0.3.20__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 (40) hide show
  1. {urkit-0.3.19 → urkit-0.3.20}/PKG-INFO +39 -1
  2. {urkit-0.3.19 → urkit-0.3.20}/README.md +38 -0
  3. {urkit-0.3.19 → urkit-0.3.20}/pyproject.toml +1 -1
  4. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/__init__.py +1 -1
  5. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/cli/teach.py +9 -0
  6. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/config.py +1 -0
  7. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/robot.py +299 -39
  8. {urkit-0.3.19 → urkit-0.3.20}/src/urkit.egg-info/PKG-INFO +39 -1
  9. {urkit-0.3.19 → urkit-0.3.20}/src/urkit.egg-info/SOURCES.txt +1 -0
  10. urkit-0.3.20/tests/test_move_sequence.py +296 -0
  11. {urkit-0.3.19 → urkit-0.3.20}/setup.cfg +0 -0
  12. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/__main__.py +0 -0
  13. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/cli/__init__.py +0 -0
  14. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/cli/colors.py +0 -0
  15. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/cli/connection_monitor.py +0 -0
  16. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/cli/points.py +0 -0
  17. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/connection.py +0 -0
  18. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/exceptions.py +0 -0
  19. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/geometry.py +0 -0
  20. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/gripper/__init__.py +0 -0
  21. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/gripper/base.py +0 -0
  22. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/gripper/digital.py +0 -0
  23. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/gripper/presets.py +0 -0
  24. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/gripper/robotiq.py +0 -0
  25. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/gripper/robotiq_preamble.py +0 -0
  26. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/io.py +0 -0
  27. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/motion.py +0 -0
  28. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/points.py +0 -0
  29. {urkit-0.3.19 → urkit-0.3.20}/src/urkit/telemetry.py +0 -0
  30. {urkit-0.3.19 → urkit-0.3.20}/src/urkit.egg-info/dependency_links.txt +0 -0
  31. {urkit-0.3.19 → urkit-0.3.20}/src/urkit.egg-info/entry_points.txt +0 -0
  32. {urkit-0.3.19 → urkit-0.3.20}/src/urkit.egg-info/requires.txt +0 -0
  33. {urkit-0.3.19 → urkit-0.3.20}/src/urkit.egg-info/top_level.txt +0 -0
  34. {urkit-0.3.19 → urkit-0.3.20}/tests/test_exceptions.py +0 -0
  35. {urkit-0.3.19 → urkit-0.3.20}/tests/test_geometry.py +0 -0
  36. {urkit-0.3.19 → urkit-0.3.20}/tests/test_gripper.py +0 -0
  37. {urkit-0.3.19 → urkit-0.3.20}/tests/test_gripper_factory.py +0 -0
  38. {urkit-0.3.19 → urkit-0.3.20}/tests/test_gripper_presets.py +0 -0
  39. {urkit-0.3.19 → urkit-0.3.20}/tests/test_points.py +0 -0
  40. {urkit-0.3.19 → urkit-0.3.20}/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.19
3
+ Version: 0.3.20
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
@@ -424,6 +424,43 @@ robot.move_relative(delta_z=0.05) # 5cm along tool Z
424
424
  - **BASE** (default): delta relative to robot base
425
425
  - **TOOL**: delta relative to TCP orientation
426
426
 
427
+ #### IK Reference (recommended)
428
+
429
+ **Problem:** When the robot has multiple valid joint configurations to reach the same TCP pose (e.g., elbow up vs. elbow down), it can unexpectedly flip its posture between moves. This is called an **IK ambiguity** and it causes "weird" movements where the robot takes a strange path or flips its wrist.
430
+
431
+ **Solution:** Set an **IK reference posture** — a saved point that defines your preferred arm configuration. The robot then stays close to that posture for all moves.
432
+
433
+ ```python
434
+ # 1. Put the robot in your preferred posture (e.g., "home")
435
+ # 2. Save it: robot.save_point("home")
436
+ # 3. Set as IK reference:
437
+ robot.ik_reference = "home"
438
+
439
+ # Now all moves stay close to that posture — no elbow flipping
440
+ robot.move_to("pick")
441
+ robot.move_to("place")
442
+ robot.move_relative(delta_z=-0.05)
443
+ ```
444
+
445
+ **In config.yaml** (recommended for permanent setup):
446
+
447
+ ```yaml
448
+ robot_ip: 192.168.1.50
449
+ ik_reference: home # prevents weird elbow/wrist flips
450
+ ```
451
+
452
+ **How it works:** The robot's inverse kinematics solver uses the reference posture as a bias (`qnear`). The TCP still reaches the exact same pose, but the arm configuration (elbow up/down, wrist orientation) stays consistent with your reference.
453
+
454
+ **Per-move override:**
455
+
456
+ ```python
457
+ robot.ik_reference = "home" # global default
458
+ robot.move_to("weird_pose", ik_reference=None) # one move without it
459
+ robot.move_to("back", ik_reference="current") # use current joints
460
+ ```
461
+
462
+ **When to use it:** Almost always. If you've ever seen the robot move in a way that looked "wrong" or flipped its elbow unexpectedly, this is what fixes it. Set it once in config.yaml and forget about it.
463
+
427
464
  #### Points are tool-agnostic
428
465
 
429
466
  Points are stored in the active TCP frame, so they work with any tool. If you swap grippers and set the correct TCP offset, your saved points remain valid.
@@ -589,6 +626,7 @@ URKit searches for `config.yaml` in this order:
589
626
  | `robot_ip` | Robot IP address | `192.168.1.50` |
590
627
  | `points_path` | Path to SQLite points database | `points.db` |
591
628
  | `gripper` | Gripper preset name | `hand-e`, `2f-85`, `2f-140`, `digital` |
629
+ | `ik_reference` | IK reference posture (prevents elbow flipping) | `home` |
592
630
  | `default_vel` | Default linear velocity (m/s) | `0.5` |
593
631
  | `default_acc` | Default linear acceleration (m/s²) | `0.3` |
594
632
  | `expert_mode` | Disable safety speed clamping | `false` |
@@ -398,6 +398,43 @@ robot.move_relative(delta_z=0.05) # 5cm along tool Z
398
398
  - **BASE** (default): delta relative to robot base
399
399
  - **TOOL**: delta relative to TCP orientation
400
400
 
401
+ #### IK Reference (recommended)
402
+
403
+ **Problem:** When the robot has multiple valid joint configurations to reach the same TCP pose (e.g., elbow up vs. elbow down), it can unexpectedly flip its posture between moves. This is called an **IK ambiguity** and it causes "weird" movements where the robot takes a strange path or flips its wrist.
404
+
405
+ **Solution:** Set an **IK reference posture** — a saved point that defines your preferred arm configuration. The robot then stays close to that posture for all moves.
406
+
407
+ ```python
408
+ # 1. Put the robot in your preferred posture (e.g., "home")
409
+ # 2. Save it: robot.save_point("home")
410
+ # 3. Set as IK reference:
411
+ robot.ik_reference = "home"
412
+
413
+ # Now all moves stay close to that posture — no elbow flipping
414
+ robot.move_to("pick")
415
+ robot.move_to("place")
416
+ robot.move_relative(delta_z=-0.05)
417
+ ```
418
+
419
+ **In config.yaml** (recommended for permanent setup):
420
+
421
+ ```yaml
422
+ robot_ip: 192.168.1.50
423
+ ik_reference: home # prevents weird elbow/wrist flips
424
+ ```
425
+
426
+ **How it works:** The robot's inverse kinematics solver uses the reference posture as a bias (`qnear`). The TCP still reaches the exact same pose, but the arm configuration (elbow up/down, wrist orientation) stays consistent with your reference.
427
+
428
+ **Per-move override:**
429
+
430
+ ```python
431
+ robot.ik_reference = "home" # global default
432
+ robot.move_to("weird_pose", ik_reference=None) # one move without it
433
+ robot.move_to("back", ik_reference="current") # use current joints
434
+ ```
435
+
436
+ **When to use it:** Almost always. If you've ever seen the robot move in a way that looked "wrong" or flipped its elbow unexpectedly, this is what fixes it. Set it once in config.yaml and forget about it.
437
+
401
438
  #### Points are tool-agnostic
402
439
 
403
440
  Points are stored in the active TCP frame, so they work with any tool. If you swap grippers and set the correct TCP offset, your saved points remain valid.
@@ -563,6 +600,7 @@ URKit searches for `config.yaml` in this order:
563
600
  | `robot_ip` | Robot IP address | `192.168.1.50` |
564
601
  | `points_path` | Path to SQLite points database | `points.db` |
565
602
  | `gripper` | Gripper preset name | `hand-e`, `2f-85`, `2f-140`, `digital` |
603
+ | `ik_reference` | IK reference posture (prevents elbow flipping) | `home` |
566
604
  | `default_vel` | Default linear velocity (m/s) | `0.5` |
567
605
  | `default_acc` | Default linear acceleration (m/s²) | `0.3` |
568
606
  | `expert_mode` | Disable safety speed clamping | `false` |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "urkit"
7
- version = "0.3.19"
7
+ version = "0.3.20"
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.19"
27
+ __version__ = "0.3.20"
28
28
 
29
29
  from urkit.config import load_config, resolve_config
30
30
  from urkit.exceptions import (
@@ -1386,6 +1386,12 @@ def teach_command(args) -> None:
1386
1386
  if gripper_name and isinstance(gripper_name, str):
1387
1387
  gripper_name = gripper_name.lower().replace("_", "-")
1388
1388
  points_path = args.points or config.get("points_path") or "points.db"
1389
+ raw_ik = config.get("ik_reference")
1390
+ ik_reference: str | list[float] | None = None
1391
+ if isinstance(raw_ik, str):
1392
+ ik_reference = raw_ik
1393
+ elif isinstance(raw_ik, list) and len(raw_ik) == 6:
1394
+ ik_reference = raw_ik
1389
1395
 
1390
1396
  # Resolve gripper constructor params from config.yaml gripper_config section
1391
1397
  # and CLI --gripper-* flags (CLI overrides config)
@@ -1454,6 +1460,8 @@ def teach_command(args) -> None:
1454
1460
  if gripper_name:
1455
1461
  print(f" Gripper: {gripper_name}")
1456
1462
  print(f" Points: {points_path}")
1463
+ if ik_reference:
1464
+ print(f" IK ref: {ik_reference}")
1457
1465
 
1458
1466
  # URRobot handles everything: safety recovery, remote mode check,
1459
1467
  # power on, brake release, program stop, and RTDE connection.
@@ -1462,6 +1470,7 @@ def teach_command(args) -> None:
1462
1470
  ip=ip, # type: ignore
1463
1471
  points=points_path, # type: ignore
1464
1472
  gripper=gripper_config,
1473
+ ik_reference=ik_reference,
1465
1474
  **gripper_kwargs,
1466
1475
  )
1467
1476
  print(" Connected.", flush=True)
@@ -60,6 +60,7 @@ Full config reference::
60
60
  max_mm: 50 # finger travel
61
61
  default_vel: 0.5 # m/s
62
62
  default_acc: 0.3 # m/s²
63
+ ik_reference: home # point name for IK reference posture
63
64
  expert_mode: false # show advanced CLI commands
64
65
  """
65
66
 
@@ -10,7 +10,7 @@ from dataclasses import replace
10
10
  import logging
11
11
  import sys
12
12
  import time
13
- from typing import TYPE_CHECKING, Optional, Union
13
+ from typing import TYPE_CHECKING, Literal, Optional, Union
14
14
  from pathlib import Path
15
15
 
16
16
  if TYPE_CHECKING:
@@ -89,6 +89,26 @@ class URRobot:
89
89
  Presets provide mass, CoG, TCP offset, and backend type.
90
90
  default_vel: Default linear velocity (m/s).
91
91
  default_acc: Default linear acceleration (m/s²).
92
+ ik_reference: Global reference posture for inverse kinematics.
93
+ One of:
94
+
95
+ - ``None`` (default): No IK reference — the controller picks
96
+ the IK solution closest to the current joint positions.
97
+ - ``"current"``: Use the robot's current joint positions
98
+ as the reference.
99
+ - A point name (str): Look up the saved point, resolve its
100
+ pose to joints, and use as the reference. If the point
101
+ doesn't exist, logs a warning and falls back to current
102
+ joint positions.
103
+ - A 6-element list: Use directly as joint angles.
104
+
105
+ When set, all motion commands (``move_to``, ``move_relative``,
106
+ ``move_sequence``) resolve target poses to joint angles using
107
+ this reference, preventing the robot from unexpectedly flipping
108
+ its elbow or wrist. Can be overridden per-call by passing
109
+ ``ik_reference`` to individual motion methods.
110
+
111
+ Can be changed at runtime via the ``ik_reference`` property.
92
112
  gripper_kwargs: Additional kwargs passed to the gripper backend
93
113
  to override preset values (e.g. ``max_mm=80`` for custom
94
114
  fingers, ``force=50``, ``speed=80`` for Robotiq).
@@ -99,6 +119,8 @@ class URRobot:
99
119
  >>> robot.move_relative([0.01, 0, 0, 0, 0, 0]) # works without points
100
120
  >>> robot.points_db = "points.db" # set lazily
101
121
  >>> robot.save_point("home")
122
+ >>> robot.ik_reference = "home" # prevent elbow flipping
123
+ >>> robot.move_to("pick") # uses home as IK reference
102
124
  """
103
125
 
104
126
  def __init__(
@@ -109,6 +131,7 @@ class URRobot:
109
131
  gripper: GripperPreset | DigitalGripperConfig | None = None,
110
132
  default_vel: float = 0.5,
111
133
  default_acc: float = 0.3,
134
+ ik_reference: str | list[float] | None = None,
112
135
  **gripper_kwargs: object,
113
136
  ) -> None:
114
137
  self._ip = ip
@@ -117,6 +140,7 @@ class URRobot:
117
140
  self._rtde_frequency = 500.0
118
141
  self._connection_lost = False
119
142
  self._move_frame: MoveFrame = MoveFrame.BASE
143
+ self._ik_reference: str | list[float] | None = ik_reference
120
144
 
121
145
  # Target tracking for pose-based arrival detection in is_moving()
122
146
  self._move_target_pose: list[float] | None = None
@@ -352,6 +376,7 @@ class URRobot:
352
376
  gripper: GripperPreset | DigitalGripperConfig | str | None = None,
353
377
  default_vel: float | None = None,
354
378
  default_acc: float | None = None,
379
+ ik_reference: str | list[float] | None = None,
355
380
  **gripper_kwargs,
356
381
  ) -> "URRobot":
357
382
  """Create a URRobot from a YAML config file or dict.
@@ -369,6 +394,8 @@ class URRobot:
369
394
  the ``gripper`` key from config.
370
395
  default_vel: Default linear velocity (m/s).
371
396
  default_acc: Default linear acceleration (m/s²).
397
+ ik_reference: IK reference posture name. Overrides the
398
+ ``ik_reference`` key from config.
372
399
  gripper_kwargs: Overrides for gripper preset values
373
400
  (e.g. ``max_mm``, ``force``, ``speed``, ``pin``,
374
401
  ``mass``, ``center_of_gravity``, ``tcp_offset``).
@@ -379,6 +406,7 @@ class URRobot:
379
406
  robot_ip: 192.168.1.50
380
407
  points_path: points.db
381
408
  gripper: hand-e
409
+ ik_reference: home # prevent elbow flipping
382
410
  gripper_config:
383
411
  mass: 1.5
384
412
  force: 50
@@ -487,12 +515,24 @@ class URRobot:
487
515
  if value is not None:
488
516
  gripper_kwargs[key] = value
489
517
 
518
+ # Resolve ik_reference: explicit kwarg > config > None
519
+ resolved_ik: str | list[float] | None = None
520
+ if ik_reference is not None:
521
+ resolved_ik = ik_reference
522
+ else:
523
+ cfg_ik = cfg.get("ik_reference")
524
+ if isinstance(cfg_ik, str):
525
+ resolved_ik = cfg_ik
526
+ elif isinstance(cfg_ik, list) and len(cfg_ik) == 6:
527
+ resolved_ik = cfg_ik
528
+
490
529
  return cls(
491
530
  ip=resolved_ip,
492
531
  points=resolved_points,
493
532
  gripper=resolved_gripper,
494
533
  default_vel=default_vel if default_vel is not None else cfg.get("default_vel", 0.5), # type: ignore
495
534
  default_acc=default_acc if default_acc is not None else cfg.get("default_acc", 0.3), # type: ignore
535
+ ik_reference=resolved_ik,
496
536
  **gripper_kwargs,
497
537
  )
498
538
 
@@ -1013,6 +1053,7 @@ class URRobot:
1013
1053
  offset_rx: float = 0.0,
1014
1054
  offset_ry: float = 0.0,
1015
1055
  offset_rz: float = 0.0,
1056
+ ik_reference: str | list[float] | None | Literal["__global__"] = "__global__",
1016
1057
  ) -> None:
1017
1058
  """Move to a saved point or raw pose.
1018
1059
 
@@ -1020,7 +1061,9 @@ class URRobot:
1020
1061
  target: A saved point name (str) or a raw TCP pose
1021
1062
  [x, y, z, rx, ry, rz].
1022
1063
  linear: If True (default), use Cartesian linear move (moveL).
1023
- If False, use joint-space move (moveJ).
1064
+ If False, use joint-space move (moveJ). When an IK
1065
+ reference is active, the pose is resolved to joints
1066
+ and a joint-space move is used regardless of this flag.
1024
1067
  offset: Optional offset [dx, dy, dz, drx, dry, drz]
1025
1068
  applied to the target pose before moving. Mutually
1026
1069
  exclusive with individual offset_* parameters.
@@ -1036,6 +1079,15 @@ class URRobot:
1036
1079
  offset_rx: Roll offset in radians (default 0.0).
1037
1080
  offset_ry: Pitch offset in radians (default 0.0).
1038
1081
  offset_rz: Yaw offset in radians (default 0.0).
1082
+ ik_reference: Per-call IK reference override. One of:
1083
+
1084
+ - ``"__global__"`` (default): Use the robot's global
1085
+ ``ik_reference`` setting.
1086
+ - ``None``: Explicitly disable IK reference for this
1087
+ move (use controller's default behavior).
1088
+ - ``"current"``: Use current joint positions.
1089
+ - A point name (str): Look up and resolve to joints.
1090
+ - A 6-element list: Use directly as joint angles.
1039
1091
 
1040
1092
  Raises:
1041
1093
  MotionError: If the move fails or IK has no solution.
@@ -1048,6 +1100,8 @@ class URRobot:
1048
1100
  >>> robot.move_to("pick", offset_x=0.01, offset_z=-0.02)
1049
1101
  >>> robot.move_to("pick", offset=[0, 0, 0.05, 0, 0.1, 0]) # full
1050
1102
  >>> robot.move_to([0.5, 0, 0.3, 0, 0, 0]) # raw pose
1103
+ >>> robot.move_to("pick", ik_reference="home") # per-call override
1104
+ >>> robot.move_to("pick", ik_reference=None) # disable for one move
1051
1105
  """
1052
1106
  self._check_connection()
1053
1107
  self._disable_freedrive_guard()
@@ -1082,22 +1136,32 @@ class URRobot:
1082
1136
  vel = vel if vel is not None else self._default_vel
1083
1137
  acc = acc if acc is not None else self._default_acc
1084
1138
 
1085
- # Store target for pose-based arrival detection
1086
- if linear:
1087
- self._move_target_pose = list(pose)
1088
- self._move_target_joints = None
1089
- else:
1090
- self._move_target_joints = None # set below after IK
1091
- self._move_target_pose = list(pose)
1139
+ # Resolve effective ik_reference: per-call > global
1140
+ effective_ik = (
1141
+ self._ik_reference if ik_reference == "__global__" else ik_reference
1142
+ )
1092
1143
 
1093
1144
  try:
1094
- if linear:
1145
+ if effective_ik is not None:
1146
+ # IK reference active: resolve pose to joints with qnear,
1147
+ # then moveJ (prevents elbow/wrist flipping)
1148
+ qnear = self._resolve_ik_reference(effective_ik)
1149
+ joints = self.inverse_kinematics(pose, seed=qnear)
1150
+ self._move_target_joints = list(joints)
1151
+ self._move_target_pose = list(pose)
1152
+ self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1153
+ elif linear:
1154
+ # No IK reference, linear mode: standard moveL
1095
1155
  if not self._rtde_c.getInverseKinematicsHasSolution(pose):
1096
1156
  raise MotionError(f"Pose unreachable: {pose}")
1157
+ self._move_target_pose = list(pose)
1158
+ self._move_target_joints = None
1097
1159
  self._motion.movel(pose, vel=vel, acc=acc, asynchronous=asynchronous)
1098
1160
  else:
1161
+ # No IK reference, joint mode: resolve IK without qnear
1099
1162
  joints = self.inverse_kinematics(pose)
1100
1163
  self._move_target_joints = list(joints)
1164
+ self._move_target_pose = list(pose)
1101
1165
  self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1102
1166
  except MotionError:
1103
1167
  raise
@@ -1212,6 +1276,7 @@ class URRobot:
1212
1276
  delta_rx: float = 0.0,
1213
1277
  delta_ry: float = 0.0,
1214
1278
  delta_rz: float = 0.0,
1279
+ ik_reference: str | list[float] | None | Literal["__global__"] = "__global__",
1215
1280
  ) -> None:
1216
1281
  """Relative Cartesian move from the current position.
1217
1282
 
@@ -1222,7 +1287,9 @@ class URRobot:
1222
1287
  delta: [dx, dy, dz, drx, dry, drz] in meters/radians.
1223
1288
  Mutually exclusive with individual delta_* parameters.
1224
1289
  linear: If True (default), use Cartesian linear move.
1225
- If False, solve IK and use joint-space move.
1290
+ If False, solve IK and use joint-space move. When an IK
1291
+ reference is active, the pose is resolved to joints
1292
+ and a joint-space move is used regardless of this flag.
1226
1293
  frame: Coordinate frame for the delta. Falls back to the
1227
1294
  current ``move_frame`` property (BASE or TOOL).
1228
1295
  vel: Velocity override. Falls back to default_vel.
@@ -1235,6 +1302,15 @@ class URRobot:
1235
1302
  delta_rx: Roll delta in radians (default 0.0).
1236
1303
  delta_ry: Pitch delta in radians (default 0.0).
1237
1304
  delta_rz: Yaw delta in radians (default 0.0).
1305
+ ik_reference: Per-call IK reference override. One of:
1306
+
1307
+ - ``"__global__"`` (default): Use the robot's global
1308
+ ``ik_reference`` setting.
1309
+ - ``None``: Explicitly disable IK reference for this
1310
+ move (use controller's default behavior).
1311
+ - ``"current"``: Use current joint positions.
1312
+ - A point name (str): Look up and resolve to joints.
1313
+ - A 6-element list: Use directly as joint angles.
1238
1314
 
1239
1315
  Raises:
1240
1316
  MotionError: If the move fails.
@@ -1277,29 +1353,91 @@ class URRobot:
1277
1353
  acc = acc if acc is not None else self._default_acc
1278
1354
  effective_frame = frame or self._move_frame
1279
1355
 
1356
+ # Resolve effective ik_reference: per-call > global
1357
+ effective_ik = (
1358
+ self._ik_reference if ik_reference == "__global__" else ik_reference
1359
+ )
1360
+
1280
1361
  try:
1281
1362
  current = list(self._rtde_r.getActualTCPPose())
1282
1363
  target = transform_pose_delta(current, final_delta, effective_frame)
1283
1364
 
1284
- # Store target for arrival detection
1285
- if linear:
1365
+ if effective_ik is not None:
1366
+ # IK reference active: resolve pose to joints with qnear
1367
+ qnear = self._resolve_ik_reference(effective_ik)
1368
+ joints = self.inverse_kinematics(target, seed=qnear)
1369
+ self._move_target_joints = list(joints)
1286
1370
  self._move_target_pose = list(target)
1287
- self._move_target_joints = None
1288
- else:
1371
+ self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1372
+ elif linear:
1373
+ # No IK reference, linear mode: standard moveL
1289
1374
  self._move_target_pose = list(target)
1290
1375
  self._move_target_joints = None
1291
-
1292
- if linear:
1293
1376
  self._motion.movel(target, vel=vel, acc=acc, asynchronous=asynchronous)
1294
1377
  else:
1378
+ # No IK reference, joint mode: resolve IK without qnear
1295
1379
  joints = self.inverse_kinematics(target)
1296
1380
  self._move_target_joints = list(joints)
1381
+ self._move_target_pose = list(target)
1297
1382
  self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1298
1383
  except MotionError:
1299
1384
  raise
1300
1385
  except Exception as e:
1301
1386
  raise MotionError(f"Relative move failed: {e}")
1302
1387
 
1388
+ def _resolve_ik_reference(
1389
+ self,
1390
+ ik_reference: str | list[float],
1391
+ ) -> list[float]:
1392
+ """Resolve an ik_reference specifier to joint angles.
1393
+
1394
+ Args:
1395
+ ik_reference: One of:
1396
+ - ``"current"`` — use the robot's current joint positions.
1397
+ - A point name (str) — look up the point, resolve its pose
1398
+ to joints via IK. If the point doesn't exist, fall back
1399
+ to current joint positions with a warning.
1400
+ - A 6-element list — use directly as joint angles.
1401
+
1402
+ Returns:
1403
+ 6 joint angles in radians.
1404
+ """
1405
+ if ik_reference == "current":
1406
+ return list(self._rtde_r.getActualQ())
1407
+
1408
+ if isinstance(ik_reference, list) and len(ik_reference) == 6:
1409
+ return list(ik_reference)
1410
+
1411
+ if isinstance(ik_reference, str):
1412
+ # Try to look up as a named point
1413
+ if self._points is None:
1414
+ logger.warning(
1415
+ "ik_reference='%s': points DB not configured, "
1416
+ "falling back to current joint positions.",
1417
+ ik_reference,
1418
+ )
1419
+ return list(self._rtde_r.getActualQ())
1420
+
1421
+ point = self._points.get(ik_reference)
1422
+ if point is None:
1423
+ logger.warning(
1424
+ "ik_reference='%s': point not found, "
1425
+ "falling back to current joint positions. "
1426
+ "Available: %s",
1427
+ ik_reference,
1428
+ self._points.list(),
1429
+ )
1430
+ return list(self._rtde_r.getActualQ())
1431
+
1432
+ # Resolve the point's pose to joints (no qnear — just need
1433
+ # a representative joint config for this posture)
1434
+ return self.inverse_kinematics(point.pose)
1435
+
1436
+ raise MotionError(
1437
+ f"ik_reference must be a point name, 'current', or 6 joint "
1438
+ f"angles, got {type(ik_reference).__name__}."
1439
+ )
1440
+
1303
1441
  def move_sequence(
1304
1442
  self,
1305
1443
  targets: list[str | list[float]],
@@ -1309,29 +1447,79 @@ class URRobot:
1309
1447
  vel: float | None = None,
1310
1448
  acc: float | None = None,
1311
1449
  asynchronous: bool = False,
1450
+ ik_reference: str | list[float] | None | Literal["__global__"] = "__global__",
1312
1451
  ) -> None:
1313
1452
  """Move through a sequence of points.
1314
1453
 
1315
- Executes each target in order using individual moveL/moveJ calls.
1316
- Convenience method to condense multiple moves into one call.
1454
+ Executes each target in order. When ``ik_reference`` is provided,
1455
+ all poses are resolved to joint targets using chained inverse
1456
+ kinematics (each pose resolves relative to the previous one),
1457
+ then executed as a single blended joint-space path via
1458
+ ``moveJ(path)``. This prevents the robot from unexpectedly
1459
+ flipping its elbow or wrist between waypoints.
1460
+
1461
+ Without ``ik_reference``, falls back to individual ``moveL``/
1462
+ ``moveJ_IK`` calls (legacy behavior, no blending).
1317
1463
 
1318
1464
  Args:
1319
1465
  targets: List of saved point names or raw poses
1320
1466
  [x, y, z, rx, ry, rz].
1321
- linear: If True (default), use Cartesian linear moves (moveL).
1322
- If False, use joint-space moves (moveJ).
1323
- blend_radius: Currently ignored. Kept for API compatibility.
1467
+ linear: Used only when ``ik_reference`` is ``None``.
1468
+ If True (default), use Cartesian linear moves (moveL).
1469
+ If False, use joint-space moves (moveJ_IK).
1470
+ Ignored when ``ik_reference`` is set (always uses
1471
+ joint-space path for IK stability).
1472
+ blend_radius: Blend radius in meters. Used only when
1473
+ ``ik_reference`` is set — applied between consecutive
1474
+ waypoints in the joint path. Default 0.0 (stop at each
1475
+ waypoint). When set, the robot rounds corners instead
1476
+ of stopping at intermediate waypoints.
1324
1477
  vel: Velocity override. Falls back to default_vel.
1325
1478
  acc: Acceleration override. Falls back to default_acc.
1326
- asynchronous: Currently ignored.
1479
+ asynchronous: If True, move runs in background. Used only
1480
+ when ``ik_reference`` is set (path-based moves support
1481
+ async). Ignored in legacy mode.
1482
+ ik_reference: Reference posture for inverse kinematics
1483
+ resolution. One of:
1484
+
1485
+ - ``"__global__"`` (default): Use the robot's global
1486
+ ``ik_reference`` setting. If the global setting is
1487
+ ``None``, falls back to legacy behavior.
1488
+ - ``None``: Explicitly disable IK reference for this
1489
+ sequence (use controller's default behavior).
1490
+ - ``"current"``: Use the robot's current joint
1491
+ positions as the starting reference.
1492
+ - A point name (str): Look up the saved point,
1493
+ resolve its pose to joints, and use as the
1494
+ starting reference. If the point doesn't exist,
1495
+ falls back to current joint positions with a
1496
+ warning.
1497
+ - A 6-element list: Use directly as joint angles.
1498
+
1499
+ The reference is **chained** through the sequence:
1500
+ the first pose resolves relative to the reference,
1501
+ the second relative to the first's resolved joints,
1502
+ and so on. This keeps the arm configuration
1503
+ (elbow up/down, wrist orientation) consistent
1504
+ throughout the entire sequence.
1327
1505
 
1328
1506
  Raises:
1329
- MotionError: If the sequence fails or fewer than 2 targets.
1330
- PointError: If a named point is not found.
1507
+ MotionError: If the sequence fails, fewer than 2 targets,
1508
+ or IK resolution fails for a pose.
1509
+ PointError: If a named target point is not found.
1331
1510
 
1332
1511
  Example:
1333
- >>> # Move through waypoints, stop at each
1512
+ >>> # Legacy: individual moveL calls, no IK control
1334
1513
  >>> robot.move_sequence(["a", "b", "c"])
1514
+
1515
+ >>> # IK-stable: resolve to joints, blend between waypoints
1516
+ >>> robot.move_sequence(["a", "b", "c"],
1517
+ ... ik_reference="home",
1518
+ ... blend_radius=0.02)
1519
+
1520
+ >>> # Start from current posture
1521
+ >>> robot.move_sequence(["a", "b", "c"],
1522
+ ... ik_reference="current")
1335
1523
  """
1336
1524
  self._check_connection()
1337
1525
  self._disable_freedrive_guard()
@@ -1344,23 +1532,67 @@ class URRobot:
1344
1532
  v = vel if vel is not None else self._default_vel
1345
1533
  a = acc if acc is not None else self._default_acc
1346
1534
 
1347
- for i, target in enumerate(targets):
1348
- point = self._lookup_point(target)
1349
- label = (
1350
- f"'{target}'" if isinstance(target, str) else str(target[:3])
1351
- )
1535
+ # Resolve effective ik_reference: per-call > global
1536
+ effective_ik = (
1537
+ self._ik_reference if ik_reference == "__global__" else ik_reference
1538
+ )
1539
+
1540
+ if effective_ik is not None:
1541
+ # --- IK-stable path mode: resolve all poses to joints,
1542
+ # build blended joint path, single moveJ(path) call. ---
1543
+ qnear = self._resolve_ik_reference(effective_ik)
1544
+ logger.info("move_sequence: ik_reference resolved to %s", qnear)
1545
+
1546
+ path: list[list[float]] = []
1547
+ for i, target in enumerate(targets):
1548
+ point = self._lookup_point(target)
1549
+ label = (
1550
+ f"'{target}'" if isinstance(target, str)
1551
+ else str(target[:3])
1552
+ )
1553
+
1554
+ # Chain qnear: each pose resolves relative to previous
1555
+ joints = self.inverse_kinematics(point.pose, seed=qnear)
1556
+ logger.info(
1557
+ "move_sequence: %s (%d/%d) → joints %s",
1558
+ label, i + 1, len(targets),
1559
+ [f"{j:.3f}" for j in joints],
1560
+ )
1561
+
1562
+ # 9-element path: [j0..j5, velocity, acceleration, blend]
1563
+ # Last waypoint gets blend=0 (come to rest)
1564
+ r = blend_radius if i < len(targets) - 1 else 0.0
1565
+ path.append([*joints, v, a, r])
1566
+ qnear = joints # chain to next
1567
+
1352
1568
  logger.info(
1353
- "move_sequence: %s (%d/%d)", label, i + 1, len(targets)
1569
+ "move_sequence: executing blended joint path (%d waypoints, "
1570
+ "blend=%.3f m)", len(path), blend_radius
1354
1571
  )
1355
1572
  try:
1356
- if linear:
1357
- self._rtde_c.moveL(list(point.pose), v, a)
1358
- else:
1359
- self._rtde_c.moveJ_IK(
1360
- list(point.pose), self._rtde_r.getActualQ(), v, a
1361
- )
1573
+ self._rtde_c.moveJ(path, asynchronous=asynchronous)
1362
1574
  except Exception as e:
1363
- raise MotionError(f"move_sequence failed at target {i}: {e}")
1575
+ raise MotionError(f"move_sequence path execution failed: {e}")
1576
+ else:
1577
+ # --- Legacy mode: individual moveL/moveJ_IK calls. ---
1578
+ for i, target in enumerate(targets):
1579
+ point = self._lookup_point(target)
1580
+ label = (
1581
+ f"'{target}'" if isinstance(target, str)
1582
+ else str(target[:3])
1583
+ )
1584
+ logger.info(
1585
+ "move_sequence: %s (%d/%d)", label, i + 1, len(targets)
1586
+ )
1587
+ try:
1588
+ if linear:
1589
+ self._rtde_c.moveL(list(point.pose), v, a)
1590
+ else:
1591
+ self._rtde_c.moveJ_IK(list(point.pose), v, a)
1592
+ except Exception as e:
1593
+ raise MotionError(
1594
+ f"move_sequence failed at target {i}: {e}"
1595
+ )
1364
1596
 
1365
1597
  def zero_ft_sensor(self) -> None:
1366
1598
  """Zero the robot's force/torque sensor.
@@ -1495,6 +1727,34 @@ class URRobot:
1495
1727
  """Current default acceleration (m/s²) for motion commands."""
1496
1728
  return self._default_acc
1497
1729
 
1730
+ @property
1731
+ def ik_reference(self) -> str | list[float] | None:
1732
+ """Current global IK reference posture.
1733
+
1734
+ Returns the reference used for inverse kinematics resolution
1735
+ in motion commands. One of:
1736
+
1737
+ - ``None``: No reference (controller picks IK solution).
1738
+ - ``"current"``: Use current joint positions.
1739
+ - A point name (str): Look up and resolve to joints.
1740
+ - A 6-element list: Use directly as joint angles.
1741
+
1742
+ Example:
1743
+ >>> robot.ik_reference = "home"
1744
+ >>> robot.move_to("pick") # uses home as IK reference
1745
+ >>> robot.ik_reference = None # disable
1746
+ """
1747
+ return self._ik_reference
1748
+
1749
+ @ik_reference.setter
1750
+ def ik_reference(self, value: str | list[float] | None) -> None:
1751
+ """Set the global IK reference posture.
1752
+
1753
+ Args:
1754
+ value: Reference posture (see getter docstring).
1755
+ """
1756
+ self._ik_reference = value
1757
+
1498
1758
  def set_speed(self, vel: float) -> None:
1499
1759
  """Change the default velocity for subsequent motions.
1500
1760
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.3.19
3
+ Version: 0.3.20
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
@@ -424,6 +424,43 @@ robot.move_relative(delta_z=0.05) # 5cm along tool Z
424
424
  - **BASE** (default): delta relative to robot base
425
425
  - **TOOL**: delta relative to TCP orientation
426
426
 
427
+ #### IK Reference (recommended)
428
+
429
+ **Problem:** When the robot has multiple valid joint configurations to reach the same TCP pose (e.g., elbow up vs. elbow down), it can unexpectedly flip its posture between moves. This is called an **IK ambiguity** and it causes "weird" movements where the robot takes a strange path or flips its wrist.
430
+
431
+ **Solution:** Set an **IK reference posture** — a saved point that defines your preferred arm configuration. The robot then stays close to that posture for all moves.
432
+
433
+ ```python
434
+ # 1. Put the robot in your preferred posture (e.g., "home")
435
+ # 2. Save it: robot.save_point("home")
436
+ # 3. Set as IK reference:
437
+ robot.ik_reference = "home"
438
+
439
+ # Now all moves stay close to that posture — no elbow flipping
440
+ robot.move_to("pick")
441
+ robot.move_to("place")
442
+ robot.move_relative(delta_z=-0.05)
443
+ ```
444
+
445
+ **In config.yaml** (recommended for permanent setup):
446
+
447
+ ```yaml
448
+ robot_ip: 192.168.1.50
449
+ ik_reference: home # prevents weird elbow/wrist flips
450
+ ```
451
+
452
+ **How it works:** The robot's inverse kinematics solver uses the reference posture as a bias (`qnear`). The TCP still reaches the exact same pose, but the arm configuration (elbow up/down, wrist orientation) stays consistent with your reference.
453
+
454
+ **Per-move override:**
455
+
456
+ ```python
457
+ robot.ik_reference = "home" # global default
458
+ robot.move_to("weird_pose", ik_reference=None) # one move without it
459
+ robot.move_to("back", ik_reference="current") # use current joints
460
+ ```
461
+
462
+ **When to use it:** Almost always. If you've ever seen the robot move in a way that looked "wrong" or flipped its elbow unexpectedly, this is what fixes it. Set it once in config.yaml and forget about it.
463
+
427
464
  #### Points are tool-agnostic
428
465
 
429
466
  Points are stored in the active TCP frame, so they work with any tool. If you swap grippers and set the correct TCP offset, your saved points remain valid.
@@ -589,6 +626,7 @@ URKit searches for `config.yaml` in this order:
589
626
  | `robot_ip` | Robot IP address | `192.168.1.50` |
590
627
  | `points_path` | Path to SQLite points database | `points.db` |
591
628
  | `gripper` | Gripper preset name | `hand-e`, `2f-85`, `2f-140`, `digital` |
629
+ | `ik_reference` | IK reference posture (prevents elbow flipping) | `home` |
592
630
  | `default_vel` | Default linear velocity (m/s) | `0.5` |
593
631
  | `default_acc` | Default linear acceleration (m/s²) | `0.3` |
594
632
  | `expert_mode` | Disable safety speed clamping | `false` |
@@ -33,5 +33,6 @@ tests/test_geometry.py
33
33
  tests/test_gripper.py
34
34
  tests/test_gripper_factory.py
35
35
  tests/test_gripper_presets.py
36
+ tests/test_move_sequence.py
36
37
  tests/test_points.py
37
38
  tests/test_robot_integration.py
@@ -0,0 +1,296 @@
1
+ """Unit tests for move_sequence with ik_reference and blending.
2
+
3
+ Tests the IK resolution, path building, and blending logic
4
+ without requiring a real robot.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from unittest.mock import MagicMock, patch
11
+
12
+ import pytest
13
+
14
+ from urkit.exceptions import MotionError, PointError
15
+ from urkit.points import Point, Points
16
+
17
+
18
+ # ------------------------------------------------------------------
19
+ # Fixtures
20
+ # ------------------------------------------------------------------
21
+
22
+
23
+ @pytest.fixture
24
+ def mock_rtde_c():
25
+ """Mock RTDEControlInterface."""
26
+ mock = MagicMock()
27
+ mock.isConnected.return_value = True
28
+ mock.getInverseKinematicsHasSolution.return_value = True
29
+ mock.getInverseKinematics.return_value = [0.0, -0.5, 0.5, -1.5, 1.5, 0.0]
30
+ return mock
31
+
32
+
33
+ @pytest.fixture
34
+ def mock_rtde_r():
35
+ """Mock RTDEReceiveInterface."""
36
+ mock = MagicMock()
37
+ mock.getActualQ.return_value = [0.0, -1.0, 1.0, -1.57, 1.57, 0.0]
38
+ mock.getActualTCPPose.return_value = [0.5, 0.0, 0.3, 0.0, 0.0, 0.0]
39
+ return mock
40
+
41
+
42
+ @pytest.fixture
43
+ def mock_points(tmp_path):
44
+ """Create a temporary points database with test data."""
45
+ db_path = tmp_path / "test_points.db"
46
+ pts = Points(db_path)
47
+ pts.save(Point(name="home", pose=[0.5, 0.0, 0.3, 0.0, 0.0, 0.0]))
48
+ pts.save(Point(name="pick", pose=[0.3, 0.2, 0.1, 0.0, 0.0, 0.0]))
49
+ pts.save(Point(name="place", pose=[0.3, -0.2, 0.1, 0.0, 0.0, 0.0]))
50
+ return pts
51
+
52
+
53
+ @pytest.fixture
54
+ def robot(mock_rtde_c, mock_rtde_r, mock_points, tmp_path):
55
+ """Create a URRobot with mocked RTDE interfaces."""
56
+ with patch("urkit.robot._validate_connection"), \
57
+ patch("urkit.robot._check_remote_mode"), \
58
+ patch("urkit.robot._connect_rtde") as mock_connect, \
59
+ patch("urkit.robot._connect_dashboard"), \
60
+ patch("urkit.robot._try_recover_safety"):
61
+
62
+ # Set up the connect mock to populate rtde interfaces
63
+ mock_connect.return_value = (mock_rtde_c, mock_rtde_r)
64
+
65
+ from urkit.robot import URRobot
66
+ robot = object.__new__(URRobot) # bypass __init__
67
+ robot._ip = "127.0.0.1"
68
+ robot._rtde_c = mock_rtde_c
69
+ robot._rtde_r = mock_rtde_r
70
+ robot._rtde_frequency = 500.0
71
+ robot._connection_lost = False
72
+ robot._default_vel = 0.5
73
+ robot._default_acc = 0.3
74
+ robot._points = mock_points
75
+ robot._move_frame = None
76
+ robot._move_target_joints = None
77
+ robot._ik_reference = None
78
+
79
+ # Mock the motion object
80
+ robot._motion = MagicMock()
81
+
82
+ # Mock _check_connection and _disable_freedrive_guard
83
+ robot._check_connection = MagicMock()
84
+ robot._disable_freedrive_guard = MagicMock()
85
+
86
+ # Mock inverse_kinematics to delegate to rtde_c
87
+ original_ik = robot.inverse_kinematics.__func__ if hasattr(robot.inverse_kinematics, '__func__') else None
88
+
89
+ def mock_ik(pose, seed=None):
90
+ qnear = seed if seed is not None else []
91
+ if not mock_rtde_c.getInverseKinematicsHasSolution(pose, qnear):
92
+ raise MotionError(f"No IK solution for pose {pose}")
93
+ return mock_rtde_c.getInverseKinematics(pose, qnear)
94
+
95
+ # Bind mock_ik to the robot instance
96
+ import types
97
+ robot.inverse_kinematics = types.MethodType(
98
+ lambda self, pose, seed=None: mock_ik(pose, seed), robot
99
+ )
100
+
101
+ return robot
102
+
103
+
104
+ # ------------------------------------------------------------------
105
+ # Tests: _resolve_ik_reference
106
+ # ------------------------------------------------------------------
107
+
108
+
109
+ class TestResolveIkReference:
110
+ """Test the _resolve_ik_reference helper method."""
111
+
112
+ def test_current_returns_actual_joints(self, robot, mock_rtde_r):
113
+ result = robot._resolve_ik_reference("current")
114
+ assert result == list(mock_rtde_r.getActualQ())
115
+
116
+ def test_point_name_resolves_to_joints(self, robot, mock_rtde_c):
117
+ """Named point should be looked up and resolved via IK."""
118
+ result = robot._resolve_ik_reference("home")
119
+ # Should call inverse_kinematics with the point's pose
120
+ assert len(result) == 6
121
+ # The mock returns the default IK result
122
+ assert result == [0.0, -0.5, 0.5, -1.5, 1.5, 0.0]
123
+
124
+ def test_missing_point_falls_back_to_current(self, robot, mock_rtde_r, caplog):
125
+ """Non-existent point name should fall back to current joints."""
126
+ with caplog.at_level(logging.WARNING):
127
+ result = robot._resolve_ik_reference("nonexistent")
128
+ assert result == list(mock_rtde_r.getActualQ())
129
+ assert "point not found" in caplog.text or "falling back" in caplog.text
130
+
131
+ def test_joints_list_used_directly(self, robot):
132
+ """Raw joint list should be returned as-is."""
133
+ joints = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]
134
+ result = robot._resolve_ik_reference(joints)
135
+ assert result == joints
136
+
137
+ def test_no_points_db_falls_back(self, robot, mock_rtde_r, caplog):
138
+ """When points DB is None, should fall back to current joints."""
139
+ robot._points = None
140
+ result = robot._resolve_ik_reference("home")
141
+ assert result == list(mock_rtde_r.getActualQ())
142
+
143
+
144
+ # ------------------------------------------------------------------
145
+ # Tests: move_sequence validation
146
+ # ------------------------------------------------------------------
147
+
148
+
149
+ class TestMoveSequenceValidation:
150
+ """Test move_sequence input validation."""
151
+
152
+ def test_requires_at_least_two_targets(self, robot):
153
+ with pytest.raises(MotionError, match="at least 2"):
154
+ robot.move_sequence(["single_point"])
155
+
156
+ def test_empty_targets_raises(self, robot):
157
+ with pytest.raises(MotionError, match="at least 2"):
158
+ robot.move_sequence([])
159
+
160
+
161
+ # ------------------------------------------------------------------
162
+ # Tests: move_sequence legacy mode (no ik_reference)
163
+ # ------------------------------------------------------------------
164
+
165
+
166
+ class TestMoveSequenceLegacy:
167
+ """Test move_sequence without ik_reference (legacy behavior)."""
168
+
169
+ def test_linear_mode_calls_movel(self, robot, mock_rtde_c):
170
+ """Without ik_reference and linear=True, should call moveL."""
171
+ robot.move_sequence(["home", "pick"])
172
+ # Should have called moveL twice (once per target)
173
+ assert mock_rtde_c.moveL.call_count == 2
174
+
175
+ def test_joints_mode_calls_movej_ik(self, robot, mock_rtde_c):
176
+ """Without ik_reference and linear=False, should call moveJ_IK."""
177
+ robot.move_sequence(["home", "pick"], linear=False)
178
+ assert mock_rtde_c.moveJ_IK.call_count == 2
179
+
180
+ def test_raw_poses_work(self, robot, mock_rtde_c):
181
+ """Raw pose lists should work as targets."""
182
+ poses = [
183
+ [0.5, 0.0, 0.3, 0.0, 0.0, 0.0],
184
+ [0.3, 0.2, 0.1, 0.0, 0.0, 0.0],
185
+ ]
186
+ robot.move_sequence(poses)
187
+ assert mock_rtde_c.moveL.call_count == 2
188
+
189
+
190
+ # ------------------------------------------------------------------
191
+ # Tests: move_sequence with ik_reference
192
+ # ------------------------------------------------------------------
193
+
194
+
195
+ class TestMoveSequenceWithIkReference:
196
+ """Test move_sequence with ik_reference for IK stability."""
197
+
198
+ def test_ik_reference_resolves_path(self, robot, mock_rtde_c):
199
+ """With ik_reference, should call moveJ(path) once."""
200
+ robot.move_sequence(["home", "pick", "place"], ik_reference="current")
201
+ # Should call moveJ once with a path (not individual moveL)
202
+ assert mock_rtde_c.moveJ.call_count == 1
203
+ # moveL should NOT be called
204
+ assert mock_rtde_c.moveL.call_count == 0
205
+
206
+ def test_path_format_is_nine_elements(self, robot, mock_rtde_c):
207
+ """Each waypoint in the path should have 9 elements."""
208
+ robot.move_sequence(["home", "pick"], ik_reference="current")
209
+ call_args = mock_rtde_c.moveJ.call_args
210
+ path = call_args[0][0] # first positional arg
211
+ assert len(path) == 2 # two waypoints
212
+ for waypoint in path:
213
+ assert len(waypoint) == 9 # [j0..j5, vel, acc, blend]
214
+
215
+ def test_last_waypoint_has_zero_blend(self, robot, mock_rtde_c):
216
+ """Last waypoint should have blend_radius=0 (come to rest)."""
217
+ robot.move_sequence(
218
+ ["home", "pick", "place"],
219
+ ik_reference="current",
220
+ blend_radius=0.02,
221
+ )
222
+ path = mock_rtde_c.moveJ.call_args[0][0]
223
+ # Last waypoint blend should be 0
224
+ assert path[-1][8] == 0.0
225
+ # Intermediate waypoints should have the blend radius
226
+ assert path[0][8] == 0.02
227
+
228
+ def test_chained_ik_calls(self, robot, mock_rtde_c):
229
+ """IK should be called once per target (chained)."""
230
+ # Reset the call count
231
+ mock_rtde_c.getInverseKinematics.reset_mock()
232
+
233
+ robot.move_sequence(["home", "pick", "place"], ik_reference="current")
234
+
235
+ # 3 targets = 3 IK calls (each chained from previous)
236
+ assert mock_rtde_c.getInverseKinematics.call_count == 3
237
+
238
+ def test_ik_reference_point_name(self, robot, mock_rtde_c):
239
+ """ik_reference as a point name should resolve via IK."""
240
+ robot.move_sequence(["pick", "place"], ik_reference="home")
241
+ assert mock_rtde_c.moveJ.call_count == 1
242
+
243
+ def test_ik_reference_joints_list(self, robot, mock_rtde_c):
244
+ """ik_reference as a raw joints list should work."""
245
+ ref_joints = [0.0, -1.0, 1.0, -1.57, 1.57, 0.0]
246
+ robot.move_sequence(["pick", "place"], ik_reference=ref_joints)
247
+ assert mock_rtde_c.moveJ.call_count == 1
248
+
249
+ def test_async_passed_through(self, robot, mock_rtde_c):
250
+ """asynchronous parameter should be passed to moveJ(path)."""
251
+ robot.move_sequence(
252
+ ["home", "pick"],
253
+ ik_reference="current",
254
+ asynchronous=True,
255
+ )
256
+ call_kwargs = mock_rtde_c.moveJ.call_args[1]
257
+ assert call_kwargs.get("asynchronous") is True
258
+
259
+ def test_vel_acc_in_path(self, robot, mock_rtde_c):
260
+ """Velocity and acceleration should be in each path waypoint."""
261
+ robot.move_sequence(
262
+ ["home", "pick"],
263
+ ik_reference="current",
264
+ vel=0.8,
265
+ acc=0.5,
266
+ )
267
+ path = mock_rtde_c.moveJ.call_args[0][0]
268
+ for waypoint in path:
269
+ assert waypoint[6] == 0.8 # velocity
270
+ assert waypoint[7] == 0.5 # acceleration
271
+
272
+ def test_defaults_used_when_no_vel_acc(self, robot, mock_rtde_c):
273
+ """Default vel/acc should be used when not specified."""
274
+ robot._default_vel = 0.5
275
+ robot._default_acc = 0.3
276
+ robot.move_sequence(["home", "pick"], ik_reference="current")
277
+ path = mock_rtde_c.moveJ.call_args[0][0]
278
+ for waypoint in path:
279
+ assert waypoint[6] == 0.5
280
+ assert waypoint[7] == 0.3
281
+
282
+ def test_global_ik_reference_used_when_not_specified(self, robot, mock_rtde_c):
283
+ """When ik_reference is not specified, should use global setting."""
284
+ robot._ik_reference = "home"
285
+ mock_rtde_c.getInverseKinematics.reset_mock()
286
+ robot.move_sequence(["pick", "place"]) # no ik_reference arg
287
+ # Should use global ik_reference and call moveJ(path)
288
+ assert mock_rtde_c.moveJ.call_count == 1
289
+ assert mock_rtde_c.getInverseKinematics.call_count >= 2
290
+
291
+ def test_per_call_overrides_global(self, robot, mock_rtde_c):
292
+ """Per-call ik_reference=None should override global setting."""
293
+ robot._ik_reference = "home"
294
+ robot.move_sequence(["pick", "place"], ik_reference=None)
295
+ # Should use legacy mode (individual moveL)
296
+ assert mock_rtde_c.moveL.call_count == 2
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