Refine the guard control to eliminate jitter

This commit is contained in:
ChenYuhan 2026-08-16 16:49:16 +08:00
parent ede2b4bed9
commit 4f76d5cefb
3 changed files with 126 additions and 4 deletions

View File

@ -25,6 +25,7 @@ INIT_SYNC_JOINT_VELOCITY_RAD = 0.2
ROBOT_RESET_SPEED_DEG = 60 ROBOT_RESET_SPEED_DEG = 60
TCP_Z_CLAMP_TOLERANCE_MM = 1e-3 TCP_Z_CLAMP_TOLERANCE_MM = 1e-3
TCP_Z_LOG_INTERVAL_S = 1.0 TCP_Z_LOG_INTERVAL_S = 1.0
TCP_Z_MAX_IK_JOINT_STEP_RAD = math.radians(10.0)
CARTESIAN_OBS_KEYS = [ CARTESIAN_OBS_KEYS = [
"pose.x", "pose.y", "pose.z", "pose.rx", "pose.ry", "pose.rz", "pose.x", "pose.y", "pose.z", "pose.rx", "pose.ry", "pose.rz",
@ -102,6 +103,7 @@ class UFRobot(Robot, Thread):
self._max_linear_velocity = self.config.max_linear_velocity self._max_linear_velocity = self.config.max_linear_velocity
self._min_tcp_z_mm = self.config.min_tcp_z_mm self._min_tcp_z_mm = self.config.min_tcp_z_mm
self._tcp_z_guard_activation_margin_mm = self.config.tcp_z_guard_activation_margin_mm
self._last_safe_joint_target = None self._last_safe_joint_target = None
self._last_safe_cartesian_target = None self._last_safe_cartesian_target = None
self._tcp_z_is_clamped = False self._tcp_z_is_clamped = False
@ -111,7 +113,11 @@ class UFRobot(Robot, Thread):
self.report_stop_event = Event() self.report_stop_event = Event()
self._rt_report_normal = False self._rt_report_normal = False
self._update_lock = Lock() self._update_lock = Lock()
self._use_rt_report = (self._control_space == "cartesian") # Cartesian observations must utilize rt_report # The TCP z guard uses the asynchronous RT report to avoid a blocking
# FK request on every GELLO servo cycle.
self._use_rt_report = (
self._control_space == "cartesian" or self._min_tcp_z_mm is not None
)
self._cart_obs_has_vel = any('velo.' in key for key in CARTESIAN_OBS_KEYS) self._cart_obs_has_vel = any('velo.' in key for key in CARTESIAN_OBS_KEYS)
self._jnt_obs_has_vel = self.config.observe_joint_vel self._jnt_obs_has_vel = self.config.observe_joint_vel
@ -323,6 +329,15 @@ class UFRobot(Robot, Thread):
if desired.shape != (self._dof,) or not np.all(np.isfinite(desired)): if desired.shape != (self._dof,) or not np.all(np.isfinite(desired)):
raise ValueError("joint target has invalid shape or contains NaN/Inf") raise ValueError("joint target has invalid shape or contains NaN/Inf")
# Far above the floor, the current TCP height is available from
# the RT report. Bypass the synchronous controller FK call so the
# normal GELLO path keeps a stable command cadence. A large
# activation margin absorbs ordinary per-cycle motion changes.
if self._rt_actual_tcp_is_far_above_floor():
self._last_safe_joint_target = desired.copy()
self._log_tcp_z_clamp(False)
return desired
code, pose = self.real_arm.get_forward_kinematics( code, pose = self.real_arm.get_forward_kinematics(
desired.tolist(), input_is_radian=True, return_is_radian=True desired.tolist(), input_is_radian=True, return_is_radian=True
) )
@ -332,24 +347,27 @@ class UFRobot(Robot, Thread):
requested_z = float(pose[2]) requested_z = float(pose[2])
if requested_z >= self._min_tcp_z_mm: if requested_z >= self._min_tcp_z_mm:
self._validate_guard_joint_target(desired, fallback, "GELLO target")
self._last_safe_joint_target = desired.copy() self._last_safe_joint_target = desired.copy()
self._log_tcp_z_clamp(False) self._log_tcp_z_clamp(False)
return desired return desired
clamped_pose = pose[:6].copy() clamped_pose = pose[:6].copy()
clamped_pose[2] = self._min_tcp_z_mm clamped_pose[2] = self._min_tcp_z_mm
ik_reference = fallback if fallback is not None else desired
code, inverse = self.real_arm.get_inverse_kinematics( code, inverse = self.real_arm.get_inverse_kinematics(
clamped_pose.tolist(), clamped_pose.tolist(),
input_is_radian=True, input_is_radian=True,
return_is_radian=True, return_is_radian=True,
limited=True, limited=True,
ref_angles=desired.tolist(), ref_angles=np.asarray(ik_reference, dtype=np.float64).tolist(),
) )
inverse = np.asarray(inverse, dtype=np.float64) inverse = np.asarray(inverse, dtype=np.float64)
if code != 0 or inverse.shape[0] < self._dof or not np.all(np.isfinite(inverse)): if code != 0 or inverse.shape[0] < self._dof or not np.all(np.isfinite(inverse)):
raise RuntimeError(f"inverse kinematics failed, code={code}") raise RuntimeError(f"inverse kinematics failed, code={code}")
safe_target = inverse[:self._dof].copy() safe_target = inverse[:self._dof].copy()
self._validate_guard_joint_target(safe_target, fallback, "clamped IK target")
code, verified_pose = self.real_arm.get_forward_kinematics( code, verified_pose = self.real_arm.get_forward_kinematics(
safe_target.tolist(), input_is_radian=True, return_is_radian=True safe_target.tolist(), input_is_radian=True, return_is_radian=True
) )
@ -371,6 +389,48 @@ class UFRobot(Robot, Thread):
return None return None
return np.asarray(fallback, dtype=np.float64).copy() return np.asarray(fallback, dtype=np.float64).copy()
def _validate_guard_joint_target(
self,
target: np.ndarray,
previous_safe_target: np.ndarray | None,
label: str,
) -> None:
code, is_limited = self.real_arm.is_joint_limit(target.tolist(), is_radian=True)
if code != 0 or is_limited is not False:
raise RuntimeError(
f"{label} violates a joint limit, code={code}, limited={is_limited}, "
f"target={target.tolist()}"
)
if previous_safe_target is None:
return
previous = np.asarray(previous_safe_target, dtype=np.float64)
if previous.shape != target.shape or not np.all(np.isfinite(previous)):
raise RuntimeError("previous safe joint target is invalid")
delta = (target - previous + math.pi) % (2 * math.pi) - math.pi
max_delta = float(np.max(np.abs(delta)))
if max_delta > TCP_Z_MAX_IK_JOINT_STEP_RAD:
raise RuntimeError(
f"{label} jumps {math.degrees(max_delta):.1f} deg from the previous safe target"
)
def _rt_actual_tcp_is_far_above_floor(self) -> bool:
if self._min_tcp_z_mm is None or not getattr(self, "_rt_report_normal", False):
return False
update_lock = getattr(self, "_update_lock", None)
if update_lock is None:
return False
with update_lock:
pose = getattr(self, "rt_actual_tcp_pose", None)
if pose is None or len(pose) < 3:
return False
actual_z = float(pose[2])
activation_margin = getattr(self, "_tcp_z_guard_activation_margin_mm", 100.0)
return (
math.isfinite(actual_z)
and actual_z > self._min_tcp_z_mm + activation_margin
)
def _guard_cartesian_target(self, command: list[float]) -> np.ndarray | None: def _guard_cartesian_target(self, command: list[float]) -> np.ndarray | None:
"""Clamp a Cartesian target without changing its other five components.""" """Clamp a Cartesian target without changing its other five components."""
target = np.asarray(command, dtype=np.float64) target = np.asarray(command, dtype=np.float64)

View File

@ -32,6 +32,10 @@ class UFRobotConfig(RobotConfig):
# Optional TCP height floor in the xArm base coordinate system (mm). # Optional TCP height floor in the xArm base coordinate system (mm).
# The value should include any desired safety margin above the table. # The value should include any desired safety margin above the table.
min_tcp_z_mm: float | None = None min_tcp_z_mm: float | None = None
# Skip synchronous FK while the actual TCP is this far above the floor.
# The RT report keeps this fast path asynchronous and avoids jitter during
# normal teleoperation; FK/IK remains active near the configured floor.
tcp_z_guard_activation_margin_mm: float = 100.0
def __post_init__(self): def __post_init__(self):
super().__post_init__() super().__post_init__()
@ -49,3 +53,8 @@ class UFRobotConfig(RobotConfig):
raise ValueError("joint_command_mode must be 1 or 6 for joint control") raise ValueError("joint_command_mode must be 1 or 6 for joint control")
if self.min_tcp_z_mm is not None and not math.isfinite(self.min_tcp_z_mm): if self.min_tcp_z_mm is not None and not math.isfinite(self.min_tcp_z_mm):
raise ValueError("min_tcp_z_mm must be finite when provided") raise ValueError("min_tcp_z_mm must be finite when provided")
if (
not math.isfinite(self.tcp_z_guard_activation_margin_mm)
or self.tcp_z_guard_activation_margin_mm < 0
):
raise ValueError("tcp_z_guard_activation_margin_mm must be finite and non-negative")

View File

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from threading import Lock
import numpy as np import numpy as np
import pytest import pytest
@ -13,10 +14,11 @@ class FakeKinematicsArm:
def __init__(self, requested_pose=None, safe_pose=None): def __init__(self, requested_pose=None, safe_pose=None):
self.requested_pose = requested_pose or [300.0, 10.0, 90.0, 0.1, 0.2, 0.3] self.requested_pose = requested_pose or [300.0, 10.0, 90.0, 0.1, 0.2, 0.3]
self.safe_pose = safe_pose or [300.0, 10.0, 100.0, 0.1, 0.2, 0.3] self.safe_pose = safe_pose or [300.0, 10.0, 100.0, 0.1, 0.2, 0.3]
self.inverse_result = [0.9] * 7 self.inverse_result = [0.3] * 7
self.inverse_calls = [] self.inverse_calls = []
self.fail_forward = False self.fail_forward = False
self.fail_inverse = False self.fail_inverse = False
self.joint_limit = False
def get_forward_kinematics(self, angles, **kwargs): def get_forward_kinematics(self, angles, **kwargs):
if self.fail_forward: if self.fail_forward:
@ -31,6 +33,9 @@ class FakeKinematicsArm:
return 2, [] return 2, []
return 0, self.inverse_result.copy() return 0, self.inverse_result.copy()
def is_joint_limit(self, target, **kwargs):
return 0, self.joint_limit
def make_guard_robot(arm, min_tcp_z_mm=100.0, control_space="joint"): def make_guard_robot(arm, min_tcp_z_mm=100.0, control_space="joint"):
robot = UFRobot.__new__(UFRobot) robot = UFRobot.__new__(UFRobot)
@ -58,6 +63,20 @@ def test_joint_target_above_floor_is_unchanged():
assert np.allclose(robot._last_safe_joint_target, requested) assert np.allclose(robot._last_safe_joint_target, requested)
def test_joint_guard_uses_rt_report_fast_path_far_above_floor():
arm = FakeKinematicsArm()
robot = make_guard_robot(arm)
robot._rt_report_normal = True
robot._update_lock = Lock()
robot.rt_actual_tcp_pose = [0.0, 0.0, 200.0, 0.0, 0.0, 0.0]
robot._tcp_z_guard_activation_margin_mm = 50.0
result = robot._guard_joint_target([0.1] * 7)
assert np.allclose(result, [0.1] * 7)
assert arm.inverse_calls == []
def test_joint_target_below_floor_clamps_only_tcp_z_before_inverse_kinematics(): def test_joint_target_below_floor_clamps_only_tcp_z_before_inverse_kinematics():
arm = FakeKinematicsArm() arm = FakeKinematicsArm()
robot = make_guard_robot(arm) robot = make_guard_robot(arm)
@ -69,10 +88,23 @@ def test_joint_target_below_floor_clamps_only_tcp_z_before_inverse_kinematics():
inverse_pose, inverse_kwargs = arm.inverse_calls[0] inverse_pose, inverse_kwargs = arm.inverse_calls[0]
assert inverse_pose == pytest.approx([300.0, 10.0, 100.0, 0.1, 0.2, 0.3]) assert inverse_pose == pytest.approx([300.0, 10.0, 100.0, 0.1, 0.2, 0.3])
assert inverse_kwargs["limited"] is True assert inverse_kwargs["limited"] is True
assert inverse_kwargs["ref_angles"] == requested assert inverse_kwargs["ref_angles"] == pytest.approx([0.25] * 7)
assert np.allclose(robot._last_safe_joint_target, arm.inverse_result) assert np.allclose(robot._last_safe_joint_target, arm.inverse_result)
def test_successive_clamped_ik_uses_last_accepted_solution_as_reference():
arm = FakeKinematicsArm()
robot = make_guard_robot(arm)
first_result = robot._guard_joint_target([0.1] * 7)
arm.inverse_result = [0.32] * 7
second_result = robot._guard_joint_target([0.05] * 7)
assert first_result == pytest.approx([0.3] * 7)
assert second_result == pytest.approx([0.32] * 7)
assert arm.inverse_calls[1][1]["ref_angles"] == pytest.approx([0.3] * 7)
@pytest.mark.parametrize("failed_stage", ["forward", "inverse", "verification"]) @pytest.mark.parametrize("failed_stage", ["forward", "inverse", "verification"])
def test_joint_guard_holds_last_safe_target_when_kinematics_fails(failed_stage): def test_joint_guard_holds_last_safe_target_when_kinematics_fails(failed_stage):
arm = FakeKinematicsArm() arm = FakeKinematicsArm()
@ -89,6 +121,27 @@ def test_joint_guard_holds_last_safe_target_when_kinematics_fails(failed_stage):
assert np.allclose(result, [0.25] * 7) assert np.allclose(result, [0.25] * 7)
def test_joint_guard_holds_last_safe_target_when_ik_hits_joint_limit():
arm = FakeKinematicsArm()
arm.joint_limit = True
robot = make_guard_robot(arm)
result = robot._guard_joint_target([0.1] * 7)
assert np.allclose(result, [0.25] * 7)
def test_joint_guard_rejects_discontinuous_ik_solution():
arm = FakeKinematicsArm()
arm.inverse_result = [1.5] * 7
arm.safe_pose[2] = 100.0
robot = make_guard_robot(arm)
result = robot._guard_joint_target([0.1] * 7)
assert np.allclose(result, [0.25] * 7)
def test_cartesian_guard_preserves_other_axes_and_clamps_z(): def test_cartesian_guard_preserves_other_axes_and_clamps_z():
robot = make_guard_robot(object(), control_space="cartesian") robot = make_guard_robot(object(), control_space="cartesian")