urkit 0.3.18__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.18 → urkit-0.3.20}/PKG-INFO +39 -1
  2. {urkit-0.3.18 → urkit-0.3.20}/README.md +38 -0
  3. {urkit-0.3.18 → urkit-0.3.20}/pyproject.toml +1 -1
  4. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/__init__.py +1 -1
  5. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/cli/teach.py +22 -0
  6. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/config.py +1 -0
  7. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/robot.py +363 -38
  8. {urkit-0.3.18 → urkit-0.3.20}/src/urkit.egg-info/PKG-INFO +39 -1
  9. {urkit-0.3.18 → 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.18 → urkit-0.3.20}/setup.cfg +0 -0
  12. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/__main__.py +0 -0
  13. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/cli/__init__.py +0 -0
  14. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/cli/colors.py +0 -0
  15. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/cli/connection_monitor.py +0 -0
  16. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/cli/points.py +0 -0
  17. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/connection.py +0 -0
  18. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/exceptions.py +0 -0
  19. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/geometry.py +0 -0
  20. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/gripper/__init__.py +0 -0
  21. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/gripper/base.py +0 -0
  22. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/gripper/digital.py +0 -0
  23. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/gripper/presets.py +0 -0
  24. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/gripper/robotiq.py +0 -0
  25. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/gripper/robotiq_preamble.py +0 -0
  26. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/io.py +0 -0
  27. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/motion.py +0 -0
  28. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/points.py +0 -0
  29. {urkit-0.3.18 → urkit-0.3.20}/src/urkit/telemetry.py +0 -0
  30. {urkit-0.3.18 → urkit-0.3.20}/src/urkit.egg-info/dependency_links.txt +0 -0
  31. {urkit-0.3.18 → urkit-0.3.20}/src/urkit.egg-info/entry_points.txt +0 -0
  32. {urkit-0.3.18 → urkit-0.3.20}/src/urkit.egg-info/requires.txt +0 -0
  33. {urkit-0.3.18 → urkit-0.3.20}/src/urkit.egg-info/top_level.txt +0 -0
  34. {urkit-0.3.18 → urkit-0.3.20}/tests/test_exceptions.py +0 -0
  35. {urkit-0.3.18 → urkit-0.3.20}/tests/test_geometry.py +0 -0
  36. {urkit-0.3.18 → urkit-0.3.20}/tests/test_gripper.py +0 -0
  37. {urkit-0.3.18 → urkit-0.3.20}/tests/test_gripper_factory.py +0 -0
  38. {urkit-0.3.18 → urkit-0.3.20}/tests/test_gripper_presets.py +0 -0
  39. {urkit-0.3.18 → urkit-0.3.20}/tests/test_points.py +0 -0
  40. {urkit-0.3.18 → 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.18
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.18"
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.18"
27
+ __version__ = "0.3.20"
28
28
 
29
29
  from urkit.config import load_config, resolve_config
30
30
  from urkit.exceptions import (
@@ -738,9 +738,17 @@ def _submenu_goto_point(
738
738
  time.sleep(0.05)
739
739
 
740
740
  # Show dedicated moving screen with monotonic progress
741
+ # Timeout after 60s in case is_moving() stalls (heavy robot settling,
742
+ # robot already at target, etc.)
741
743
  cancelled = False
744
+ timed_out = False
742
745
  max_progress = 0.0
746
+ move_start = time.monotonic()
743
747
  while robot.is_moving():
748
+ if time.monotonic() - move_start > 60.0:
749
+ timed_out = True
750
+ break
751
+
744
752
  pct, bar = _draw_moving_screen(
745
753
  robot, name, mode_label, start_pose, target_pose, is_cartesian, max_progress
746
754
  )
@@ -758,6 +766,11 @@ def _submenu_goto_point(
758
766
  if cancelled:
759
767
  robot.stop()
760
768
  messages.append(f"Move to '{name}' cancelled")
769
+ elif timed_out:
770
+ messages.append(
771
+ f"Moved to '{name}' ({mode_label}) — "
772
+ "timeout, robot may already have been at target"
773
+ )
761
774
  else:
762
775
  messages.append(f"Moved to '{name}' ({mode_label})")
763
776
  except URKitConnectionError:
@@ -1373,6 +1386,12 @@ def teach_command(args) -> None:
1373
1386
  if gripper_name and isinstance(gripper_name, str):
1374
1387
  gripper_name = gripper_name.lower().replace("_", "-")
1375
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
1376
1395
 
1377
1396
  # Resolve gripper constructor params from config.yaml gripper_config section
1378
1397
  # and CLI --gripper-* flags (CLI overrides config)
@@ -1441,6 +1460,8 @@ def teach_command(args) -> None:
1441
1460
  if gripper_name:
1442
1461
  print(f" Gripper: {gripper_name}")
1443
1462
  print(f" Points: {points_path}")
1463
+ if ik_reference:
1464
+ print(f" IK ref: {ik_reference}")
1444
1465
 
1445
1466
  # URRobot handles everything: safety recovery, remote mode check,
1446
1467
  # power on, brake release, program stop, and RTDE connection.
@@ -1449,6 +1470,7 @@ def teach_command(args) -> None:
1449
1470
  ip=ip, # type: ignore
1450
1471
  points=points_path, # type: ignore
1451
1472
  gripper=gripper_config,
1473
+ ik_reference=ik_reference,
1452
1474
  **gripper_kwargs,
1453
1475
  )
1454
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,11 @@ 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
144
+
145
+ # Target tracking for pose-based arrival detection in is_moving()
146
+ self._move_target_pose: list[float] | None = None
147
+ self._move_target_joints: list[float] | None = None
120
148
 
121
149
  # Points database (internal) — optional, lazy-initialized
122
150
  self._points: Points | None = Points(points) if points is not None else None
@@ -348,6 +376,7 @@ class URRobot:
348
376
  gripper: GripperPreset | DigitalGripperConfig | str | None = None,
349
377
  default_vel: float | None = None,
350
378
  default_acc: float | None = None,
379
+ ik_reference: str | list[float] | None = None,
351
380
  **gripper_kwargs,
352
381
  ) -> "URRobot":
353
382
  """Create a URRobot from a YAML config file or dict.
@@ -365,6 +394,8 @@ class URRobot:
365
394
  the ``gripper`` key from config.
366
395
  default_vel: Default linear velocity (m/s).
367
396
  default_acc: Default linear acceleration (m/s²).
397
+ ik_reference: IK reference posture name. Overrides the
398
+ ``ik_reference`` key from config.
368
399
  gripper_kwargs: Overrides for gripper preset values
369
400
  (e.g. ``max_mm``, ``force``, ``speed``, ``pin``,
370
401
  ``mass``, ``center_of_gravity``, ``tcp_offset``).
@@ -375,6 +406,7 @@ class URRobot:
375
406
  robot_ip: 192.168.1.50
376
407
  points_path: points.db
377
408
  gripper: hand-e
409
+ ik_reference: home # prevent elbow flipping
378
410
  gripper_config:
379
411
  mass: 1.5
380
412
  force: 50
@@ -483,12 +515,24 @@ class URRobot:
483
515
  if value is not None:
484
516
  gripper_kwargs[key] = value
485
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
+
486
529
  return cls(
487
530
  ip=resolved_ip,
488
531
  points=resolved_points,
489
532
  gripper=resolved_gripper,
490
533
  default_vel=default_vel if default_vel is not None else cfg.get("default_vel", 0.5), # type: ignore
491
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,
492
536
  **gripper_kwargs,
493
537
  )
494
538
 
@@ -1009,6 +1053,7 @@ class URRobot:
1009
1053
  offset_rx: float = 0.0,
1010
1054
  offset_ry: float = 0.0,
1011
1055
  offset_rz: float = 0.0,
1056
+ ik_reference: str | list[float] | None | Literal["__global__"] = "__global__",
1012
1057
  ) -> None:
1013
1058
  """Move to a saved point or raw pose.
1014
1059
 
@@ -1016,7 +1061,9 @@ class URRobot:
1016
1061
  target: A saved point name (str) or a raw TCP pose
1017
1062
  [x, y, z, rx, ry, rz].
1018
1063
  linear: If True (default), use Cartesian linear move (moveL).
1019
- 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.
1020
1067
  offset: Optional offset [dx, dy, dz, drx, dry, drz]
1021
1068
  applied to the target pose before moving. Mutually
1022
1069
  exclusive with individual offset_* parameters.
@@ -1032,6 +1079,15 @@ class URRobot:
1032
1079
  offset_rx: Roll offset in radians (default 0.0).
1033
1080
  offset_ry: Pitch offset in radians (default 0.0).
1034
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.
1035
1091
 
1036
1092
  Raises:
1037
1093
  MotionError: If the move fails or IK has no solution.
@@ -1044,6 +1100,8 @@ class URRobot:
1044
1100
  >>> robot.move_to("pick", offset_x=0.01, offset_z=-0.02)
1045
1101
  >>> robot.move_to("pick", offset=[0, 0, 0.05, 0, 0.1, 0]) # full
1046
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
1047
1105
  """
1048
1106
  self._check_connection()
1049
1107
  self._disable_freedrive_guard()
@@ -1078,13 +1136,32 @@ class URRobot:
1078
1136
  vel = vel if vel is not None else self._default_vel
1079
1137
  acc = acc if acc is not None else self._default_acc
1080
1138
 
1139
+ # Resolve effective ik_reference: per-call > global
1140
+ effective_ik = (
1141
+ self._ik_reference if ik_reference == "__global__" else ik_reference
1142
+ )
1143
+
1081
1144
  try:
1082
- 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
1083
1155
  if not self._rtde_c.getInverseKinematicsHasSolution(pose):
1084
1156
  raise MotionError(f"Pose unreachable: {pose}")
1157
+ self._move_target_pose = list(pose)
1158
+ self._move_target_joints = None
1085
1159
  self._motion.movel(pose, vel=vel, acc=acc, asynchronous=asynchronous)
1086
1160
  else:
1161
+ # No IK reference, joint mode: resolve IK without qnear
1087
1162
  joints = self.inverse_kinematics(pose)
1163
+ self._move_target_joints = list(joints)
1164
+ self._move_target_pose = list(pose)
1088
1165
  self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1089
1166
  except MotionError:
1090
1167
  raise
@@ -1094,13 +1171,29 @@ class URRobot:
1094
1171
  )
1095
1172
  raise MotionError(f"Move to {target_label} failed: {e}")
1096
1173
 
1097
- def is_moving(self) -> bool:
1098
- """Check if the robot is currently moving.
1174
+ def is_moving(
1175
+ self,
1176
+ *,
1177
+ position_tolerance: float = 0.002,
1178
+ orientation_tolerance: float = 0.035,
1179
+ joint_tolerance: float = 0.01,
1180
+ ) -> bool:
1181
+ """Check if the robot has arrived at the last move target.
1182
+
1183
+ Compares current TCP pose (or joint angles for joint moves)
1184
+ to the target stored by ``move_to()``. Returns False when
1185
+ within tolerance.
1099
1186
 
1100
- Returns True if any joint or TCP velocity is non-zero.
1187
+ Args:
1188
+ position_tolerance: Max distance in meters to consider
1189
+ "arrived" (default 2 mm).
1190
+ orientation_tolerance: Max rotation error in radians to
1191
+ consider "arrived" (default ~2 degrees).
1192
+ joint_tolerance: Max per-joint error in radians for joint
1193
+ moves (default ~0.6 degrees).
1101
1194
 
1102
1195
  Returns:
1103
- True if moving, False if stopped.
1196
+ True if still moving toward target, False if arrived.
1104
1197
 
1105
1198
  Example:
1106
1199
  >>> robot.move_to("home", asynchronous=True)
@@ -1109,15 +1202,38 @@ class URRobot:
1109
1202
  >>> print("Done!")
1110
1203
  """
1111
1204
  try:
1112
- joint_vel = self._rtde_r.getActualQd()
1113
- tcp_speed = self._rtde_r.getActualTCPSpeed()
1205
+ # Joint-based: compare current joints to target
1206
+ if self._move_target_joints is not None:
1207
+ current = self._rtde_r.getActualQ()
1208
+ target = self._move_target_joints
1209
+ for c, t in zip(current, target):
1210
+ if abs(c - t) > joint_tolerance:
1211
+ return True
1212
+ self._move_target_joints = None
1213
+ self._move_target_pose = None
1214
+ return False
1114
1215
 
1115
- joint_moving = any(abs(v) > 0.0001 for v in joint_vel)
1116
- tcp_moving = any(abs(v) > 0.0001 for v in tcp_speed[:3]) or any(
1117
- abs(v) > 0.0001 for v in tcp_speed[3:]
1118
- )
1216
+ # Pose-based: compare current TCP pose to target
1217
+ if self._move_target_pose is not None:
1218
+ current = self._rtde_r.getActualTCPPose()
1219
+ target = self._move_target_pose
1220
+ dx = current[0] - target[0]
1221
+ dy = current[1] - target[1]
1222
+ dz = current[2] - target[2]
1223
+ dist = (dx * dx + dy * dy + dz * dz) ** 0.5
1224
+
1225
+ d_rx = abs(current[3] - target[3])
1226
+ d_ry = abs(current[4] - target[4])
1227
+ d_rz = abs(current[5] - target[5])
1228
+ orient_err = max(d_rx, d_ry, d_rz)
1229
+
1230
+ if dist <= position_tolerance and orient_err <= orientation_tolerance:
1231
+ self._move_target_pose = None
1232
+ return False
1233
+ return True
1119
1234
 
1120
- return joint_moving or tcp_moving
1235
+ # No target set — assume not moving
1236
+ return False
1121
1237
  except Exception:
1122
1238
  return False
1123
1239
 
@@ -1136,6 +1252,10 @@ class URRobot:
1136
1252
  self._rtde_c.stopL(5.0, True)
1137
1253
  except Exception:
1138
1254
  pass
1255
+
1256
+ # Clear move targets so is_moving() falls back to velocity check
1257
+ self._move_target_pose = None
1258
+ self._move_target_joints = None
1139
1259
  try:
1140
1260
  self._rtde_c.stopJ(5.0, True)
1141
1261
  except Exception:
@@ -1156,6 +1276,7 @@ class URRobot:
1156
1276
  delta_rx: float = 0.0,
1157
1277
  delta_ry: float = 0.0,
1158
1278
  delta_rz: float = 0.0,
1279
+ ik_reference: str | list[float] | None | Literal["__global__"] = "__global__",
1159
1280
  ) -> None:
1160
1281
  """Relative Cartesian move from the current position.
1161
1282
 
@@ -1166,7 +1287,9 @@ class URRobot:
1166
1287
  delta: [dx, dy, dz, drx, dry, drz] in meters/radians.
1167
1288
  Mutually exclusive with individual delta_* parameters.
1168
1289
  linear: If True (default), use Cartesian linear move.
1169
- 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.
1170
1293
  frame: Coordinate frame for the delta. Falls back to the
1171
1294
  current ``move_frame`` property (BASE or TOOL).
1172
1295
  vel: Velocity override. Falls back to default_vel.
@@ -1179,6 +1302,15 @@ class URRobot:
1179
1302
  delta_rx: Roll delta in radians (default 0.0).
1180
1303
  delta_ry: Pitch delta in radians (default 0.0).
1181
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.
1182
1314
 
1183
1315
  Raises:
1184
1316
  MotionError: If the move fails.
@@ -1221,20 +1353,91 @@ class URRobot:
1221
1353
  acc = acc if acc is not None else self._default_acc
1222
1354
  effective_frame = frame or self._move_frame
1223
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
+
1224
1361
  try:
1225
1362
  current = list(self._rtde_r.getActualTCPPose())
1226
1363
  target = transform_pose_delta(current, final_delta, effective_frame)
1227
1364
 
1228
- 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)
1370
+ self._move_target_pose = list(target)
1371
+ self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1372
+ elif linear:
1373
+ # No IK reference, linear mode: standard moveL
1374
+ self._move_target_pose = list(target)
1375
+ self._move_target_joints = None
1229
1376
  self._motion.movel(target, vel=vel, acc=acc, asynchronous=asynchronous)
1230
1377
  else:
1378
+ # No IK reference, joint mode: resolve IK without qnear
1231
1379
  joints = self.inverse_kinematics(target)
1380
+ self._move_target_joints = list(joints)
1381
+ self._move_target_pose = list(target)
1232
1382
  self._motion.movej(joints, vel=vel, acc=acc, asynchronous=asynchronous)
1233
1383
  except MotionError:
1234
1384
  raise
1235
1385
  except Exception as e:
1236
1386
  raise MotionError(f"Relative move failed: {e}")
1237
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
+
1238
1441
  def move_sequence(
1239
1442
  self,
1240
1443
  targets: list[str | list[float]],
@@ -1244,29 +1447,79 @@ class URRobot:
1244
1447
  vel: float | None = None,
1245
1448
  acc: float | None = None,
1246
1449
  asynchronous: bool = False,
1450
+ ik_reference: str | list[float] | None | Literal["__global__"] = "__global__",
1247
1451
  ) -> None:
1248
1452
  """Move through a sequence of points.
1249
1453
 
1250
- Executes each target in order using individual moveL/moveJ calls.
1251
- 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).
1252
1463
 
1253
1464
  Args:
1254
1465
  targets: List of saved point names or raw poses
1255
1466
  [x, y, z, rx, ry, rz].
1256
- linear: If True (default), use Cartesian linear moves (moveL).
1257
- If False, use joint-space moves (moveJ).
1258
- 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.
1259
1477
  vel: Velocity override. Falls back to default_vel.
1260
1478
  acc: Acceleration override. Falls back to default_acc.
1261
- 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.
1262
1505
 
1263
1506
  Raises:
1264
- MotionError: If the sequence fails or fewer than 2 targets.
1265
- 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.
1266
1510
 
1267
1511
  Example:
1268
- >>> # Move through waypoints, stop at each
1512
+ >>> # Legacy: individual moveL calls, no IK control
1269
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")
1270
1523
  """
1271
1524
  self._check_connection()
1272
1525
  self._disable_freedrive_guard()
@@ -1279,23 +1532,67 @@ class URRobot:
1279
1532
  v = vel if vel is not None else self._default_vel
1280
1533
  a = acc if acc is not None else self._default_acc
1281
1534
 
1282
- for i, target in enumerate(targets):
1283
- point = self._lookup_point(target)
1284
- label = (
1285
- f"'{target}'" if isinstance(target, str) else str(target[:3])
1286
- )
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
+
1287
1568
  logger.info(
1288
- "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
1289
1571
  )
1290
1572
  try:
1291
- if linear:
1292
- self._rtde_c.moveL(list(point.pose), v, a)
1293
- else:
1294
- self._rtde_c.moveJ_IK(
1295
- list(point.pose), self._rtde_r.getActualQ(), v, a
1296
- )
1573
+ self._rtde_c.moveJ(path, asynchronous=asynchronous)
1297
1574
  except Exception as e:
1298
- 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
+ )
1299
1596
 
1300
1597
  def zero_ft_sensor(self) -> None:
1301
1598
  """Zero the robot's force/torque sensor.
@@ -1430,6 +1727,34 @@ class URRobot:
1430
1727
  """Current default acceleration (m/s²) for motion commands."""
1431
1728
  return self._default_acc
1432
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
+
1433
1758
  def set_speed(self, vel: float) -> None:
1434
1759
  """Change the default velocity for subsequent motions.
1435
1760
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.3.18
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