feat: 发布同周期ACT控制样本

This commit is contained in:
2026-08-10 18:01:05 +08:00
parent 043d3d0533
commit e60d620dfe
3 changed files with 398 additions and 7 deletions
@@ -0,0 +1,194 @@
from types import SimpleNamespace
import numpy as np
import pytest
from builtin_interfaces.msg import Time as TimeMsg
from xr_rm_interfaces.msg import XrController
from xr_rm_teleop.single_arm_velocity_teleop import (
SingleArmVelocityTeleop,
_ActCycleContext,
)
class FakePublisher:
def __init__(self, error=None) -> None:
self.error = error
self.messages = []
def publish(self, message) -> None:
if self.error is not None:
raise self.error
self.messages.append(message)
class FakeLogger:
def __init__(self) -> None:
self.warnings = []
def warn(self, message, **kwargs) -> None:
del kwargs
self.warnings.append(message)
def _controller(*, grip=True) -> XrController:
message = XrController()
message.hand = "right"
message.grip = grip
message.trigger = 0.25
message.primary = False
message.secondary = False
message.axis = [0.1, -0.2]
message.pose.orientation.w = 1.0
return message
def _teleop(*, last_target=None, tool_state=True):
teleop = object.__new__(SingleArmVelocityTeleop)
teleop._arm_name = "right_rm75"
teleop._last_msg = _controller()
teleop._active = True
teleop._control_fault_latched = False
teleop._last_current_pose = np.eye(4)
teleop._robot_start_transform = None
teleop._last_sent_target = None
teleop._last_sent_orientation = None
teleop._last_successful_action_target = last_target
teleop._ik_solver = SimpleNamespace(
joint_position_limits=np.asarray([[-1.0, 1.0]] * 7)
)
teleop._tool_state_snapshot = lambda: (
True,
tool_state,
False,
False,
)
teleop.get_clock = lambda: SimpleNamespace(
now=lambda: SimpleNamespace(to_msg=lambda: TimeMsg())
)
teleop.get_logger = lambda: FakeLogger()
return teleop
def _cycle(**overrides) -> _ActCycleContext:
values = {
"control_seq": 100,
"control_monotonic_ns": 1_000_000_000,
"feedback_monotonic_ns": 990_000_000,
"action_monotonic_ns": 1_005_000_000,
"feedback_age_ms": 10.0,
"q_actual": [0.1] * 7,
"q_qp_raw": [0.3] * 7,
"q_target": [0.2] * 7,
"current_pose": np.eye(4),
"raw_target_pose": np.eye(4),
"target_pose": np.eye(4),
"command_velocity": [0.0] * 6,
"feedback_valid": True,
"command_sent": True,
"qp_attempted": True,
"qp_success": True,
}
values.update(overrides)
return _ActCycleContext(**values)
def test_act_sample_uses_feedback_and_limited_target_from_one_cycle() -> None:
teleop = _teleop(last_target=[0.2] * 7)
message = teleop._build_act_control_sample(_cycle())
assert message.control_seq == 100
assert message.q_actual == pytest.approx([0.1] * 7)
assert message.q_qp_raw == pytest.approx([0.3] * 7)
assert message.q_target == pytest.approx([0.2] * 7)
assert message.joint_lower_limits == pytest.approx([-1.0] * 7)
assert message.joint_upper_limits == pytest.approx([1.0] * 7)
assert message.command_sent
assert message.action_valid
assert message.qp_attempted
assert message.qp_success
def test_act_sample_marks_qp_fallback_as_valid_held_action() -> None:
teleop = _teleop(last_target=[0.2] * 7)
cycle = _cycle(
q_qp_raw=[0.2] * 7,
q_target=[0.2] * 7,
qp_success=False,
)
message = teleop._build_act_control_sample(cycle)
assert message.q_target == pytest.approx([0.2] * 7)
assert message.action_valid
assert message.qp_attempted
assert not message.qp_success
def test_act_sample_holds_last_action_while_grip_is_released() -> None:
teleop = _teleop(last_target=[0.4] * 7)
teleop._last_msg = _controller(grip=False)
teleop._active = False
cycle = _cycle(
q_qp_raw=None,
q_target=None,
command_sent=False,
qp_attempted=False,
qp_success=False,
action_monotonic_ns=-1,
)
message = teleop._build_act_control_sample(cycle)
assert message.q_target == pytest.approx([0.4] * 7)
assert message.action_valid
assert not message.command_sent
assert not message.teleop_active
def test_act_sample_marks_send_failure_invalid() -> None:
teleop = _teleop(last_target=[0.4] * 7)
message = teleop._build_act_control_sample(
_cycle(send_failed=True, command_sent=False)
)
assert not message.action_valid
def test_act_sample_marks_unknown_gripper_state() -> None:
teleop = _teleop(last_target=[0.2] * 7, tool_state=None)
message = teleop._build_act_control_sample(_cycle())
assert not message.gripper_state_known
def test_act_sample_publish_failure_does_not_escape_control_path() -> None:
teleop = _teleop(last_target=[0.2] * 7)
logger = FakeLogger()
teleop._act_sample_pub = FakePublisher(RuntimeError("dds failed"))
teleop.get_logger = lambda: logger
teleop._publish_act_control_sample(_cycle())
assert logger.warnings == [
"right_rm75 ACT原子样本发布失败:dds failed"
]
def test_control_tick_wraps_one_impl_call_in_one_atomic_sample() -> None:
teleop = object.__new__(SingleArmVelocityTeleop)
cycles = []
published = []
teleop._act_control_seq = 7
teleop._control_tick_impl = lambda cycle: cycles.append(cycle)
teleop._publish_act_control_sample = lambda cycle: published.append(cycle)
teleop._control_tick()
assert len(cycles) == 1
assert published == cycles
assert cycles[0].control_seq == 7
assert teleop._act_control_seq == 8
+4 -2
View File
@@ -686,9 +686,10 @@ def test_qp_failure_returns_last_known_good_target() -> None:
teleop._arm_name = "right_rm75" teleop._arm_name = "right_rm75"
teleop.get_logger = lambda: FakeLogger() teleop.get_logger = lambda: FakeLogger()
target = teleop._solve_joint_target(np.eye(4)) target, qp_success = teleop._solve_joint_target(np.eye(4))
assert target == pytest.approx([0.1] * 7) assert target == pytest.approx([0.1] * 7)
assert not qp_success
assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7) assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7)
@@ -704,9 +705,10 @@ def test_qp_success_updates_last_known_good_target() -> None:
teleop._arm_name = "left_rm75" teleop._arm_name = "left_rm75"
teleop.get_logger = lambda: FakeLogger() teleop.get_logger = lambda: FakeLogger()
target = teleop._solve_joint_target(np.eye(4)) target, qp_success = teleop._solve_joint_target(np.eye(4))
assert target == pytest.approx([0.2] * 7) assert target == pytest.approx([0.2] * 7)
assert qp_success
assert teleop._last_valid_joint_target == pytest.approx([0.2] * 7) assert teleop._last_valid_joint_target == pytest.approx([0.2] * 7)
@@ -10,17 +10,19 @@ import math
import queue import queue
import threading import threading
import time import time
from dataclasses import dataclass
from typing import Iterable from typing import Iterable
import numpy as np import numpy as np
import rclpy import rclpy
from geometry_msgs.msg import PoseStamped, TwistStamped from geometry_msgs.msg import PoseStamped, TwistStamped
from rclpy.node import Node from rclpy.node import Node
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from rclpy.time import Time from rclpy.time import Time
from sensor_msgs.msg import JointState from sensor_msgs.msg import JointState
from std_msgs.msg import Bool from std_msgs.msg import Bool
from xr_rm_interfaces.msg import XrController from xr_rm_interfaces.msg import ActControlSample, XrController
from .fun_peripheral import load_peripheral_config from .fun_peripheral import load_peripheral_config
from .placo_ik_solver import PlacoIkSolver from .placo_ik_solver import PlacoIkSolver
@@ -31,6 +33,30 @@ from .realman_adapter import (
) )
@dataclass
class _ActCycleContext:
control_seq: int
control_monotonic_ns: int
feedback_monotonic_ns: int = -1
action_monotonic_ns: int = -1
feedback_age_ms: float = math.inf
qp_duration_ms: float = 0.0
q_actual: list[float] | None = None
q_qp_raw: list[float] | None = None
q_target: list[float] | None = None
current_pose: np.ndarray | None = None
raw_target_pose: np.ndarray | None = None
target_pose: np.ndarray | None = None
command_velocity: list[float] | None = None
feedback_valid: bool = False
command_sent: bool = False
send_failed: bool = False
qp_attempted: bool = False
qp_success: bool = False
target_clamped: bool = False
control_fault: bool = False
def _norm(values: Iterable[float]) -> float: def _norm(values: Iterable[float]) -> float:
return math.sqrt(sum(value * value for value in values)) return math.sqrt(sum(value * value for value in values))
@@ -291,6 +317,8 @@ class SingleArmVelocityTeleop(Node):
self._latest_joint_positions: list[float] | None = None self._latest_joint_positions: list[float] | None = None
self._last_joint_command_target: list[float] | None = None self._last_joint_command_target: list[float] | None = None
self._last_joint_command_velocity: list[float] | None = None self._last_joint_command_velocity: list[float] | None = None
self._last_successful_action_target: list[float] | None = None
self._act_control_seq = 0
self._joint_feedback_ready = False self._joint_feedback_ready = False
self._grip_rearm_required = False self._grip_rearm_required = False
self._feedback_resync_attempted = False self._feedback_resync_attempted = False
@@ -345,6 +373,15 @@ class SingleArmVelocityTeleop(Node):
f"{debug_ns}/joint_target", f"{debug_ns}/joint_target",
10, 10,
) )
self._act_sample_pub = self.create_publisher(
ActControlSample,
f"{debug_ns}/act_control_sample",
QoSProfile(
history=HistoryPolicy.KEEP_LAST,
depth=10,
reliability=ReliabilityPolicy.BEST_EFFORT,
),
)
self._adapter = self._make_adapter() self._adapter = self._make_adapter()
self._adapter.connect() self._adapter.connect()
self._initialize_joint_state() self._initialize_joint_state()
@@ -619,6 +656,18 @@ class SingleArmVelocityTeleop(Node):
self._enqueue_tool_command(self._trigger_tool_open, "trigger") self._enqueue_tool_command(self._trigger_tool_open, "trigger")
def _control_tick(self) -> None: def _control_tick(self) -> None:
control_seq = getattr(self, "_act_control_seq", 0)
self._act_control_seq = control_seq + 1
cycle = _ActCycleContext(
control_seq=control_seq,
control_monotonic_ns=time.monotonic_ns(),
)
try:
self._control_tick_impl(cycle)
finally:
self._publish_act_control_sample(cycle)
def _control_tick_impl(self, cycle: _ActCycleContext) -> None:
tick_started_ns = time.perf_counter_ns() tick_started_ns = time.perf_counter_ns()
last_tick_started_ns = getattr( last_tick_started_ns = getattr(
self, self,
@@ -633,10 +682,12 @@ class SingleArmVelocityTeleop(Node):
) )
now = self.get_clock().now() now = self.get_clock().now()
if self._control_fault_latched: if self._control_fault_latched:
cycle.control_fault = True
return return
snapshot = self._adapter.get_latest_joint_state() snapshot = self._adapter.get_latest_joint_state()
if not self._joint_snapshot_is_motion_ready(snapshot): if not self._joint_snapshot_is_motion_ready(snapshot):
cycle.control_fault = True
self._grip_rearm_required = True self._grip_rearm_required = True
if self._joint_feedback_ready: if self._joint_feedback_ready:
self.get_logger().warn( self.get_logger().warn(
@@ -647,13 +698,18 @@ class SingleArmVelocityTeleop(Node):
self._safe_stop(reset_active=True) self._safe_stop(reset_active=True)
return return
assert snapshot is not None assert snapshot is not None
cycle.q_actual = list(snapshot.positions)
cycle.feedback_monotonic_ns = int(snapshot.received_at * 1e9)
feedback_age = time.monotonic() - snapshot.received_at feedback_age = time.monotonic() - snapshot.received_at
cycle.feedback_age_ms = feedback_age * 1000.0
if feedback_age < 0.0: if feedback_age < 0.0:
cycle.control_fault = True
self._grip_rearm_required = True self._grip_rearm_required = True
self._joint_feedback_ready = False self._joint_feedback_ready = False
self._safe_stop(reset_active=True) self._safe_stop(reset_active=True)
return return
if feedback_age > self._command_timeout_sec: if feedback_age > self._command_timeout_sec:
cycle.control_fault = True
self._handle_stale_joint_feedback(feedback_age) self._handle_stale_joint_feedback(feedback_age)
return return
@@ -661,6 +717,7 @@ class SingleArmVelocityTeleop(Node):
try: try:
current_pose = self._sync_joint_feedback(snapshot) current_pose = self._sync_joint_feedback(snapshot)
except Exception as exc: except Exception as exc:
cycle.control_fault = True
self.get_logger().error( self.get_logger().error(
f"{self._arm_name} 关节反馈同步到 Placo 失败:{exc}", f"{self._arm_name} 关节反馈同步到 Placo 失败:{exc}",
throttle_duration_sec=1.0, throttle_duration_sec=1.0,
@@ -669,6 +726,8 @@ class SingleArmVelocityTeleop(Node):
self._grip_rearm_required = True self._grip_rearm_required = True
self._safe_stop(reset_active=True) self._safe_stop(reset_active=True)
return return
cycle.current_pose = current_pose
cycle.feedback_valid = True
if not self._joint_feedback_ready: if not self._joint_feedback_ready:
if self._grip_rearm_required: if self._grip_rearm_required:
message = ( message = (
@@ -717,6 +776,7 @@ class SingleArmVelocityTeleop(Node):
try: try:
controller_quat = self._controller_quaternion(self._last_msg) controller_quat = self._controller_quaternion(self._last_msg)
except ValueError as exc: except ValueError as exc:
cycle.control_fault = True
self.get_logger().warn( self.get_logger().warn(
f"{self._arm_name} XR 手柄姿态无效,停止输出:{exc}", f"{self._arm_name} XR 手柄姿态无效,停止输出:{exc}",
throttle_duration_sec=1.0, throttle_duration_sec=1.0,
@@ -761,19 +821,35 @@ class SingleArmVelocityTeleop(Node):
sent_target, sent_target,
sent_orientation, sent_orientation,
) )
cycle.raw_target_pose = raw_target_pose
cycle.target_pose = target_pose
cycle.command_velocity = list(velocity)
cycle.target_clamped = target_clamped
self._publish_debug(raw_target_pose, target_pose, velocity, target_clamped) self._publish_debug(raw_target_pose, target_pose, velocity, target_clamped)
cycle.qp_attempted = True
qp_started_ns = time.perf_counter_ns() qp_started_ns = time.perf_counter_ns()
joint_target = self._solve_joint_target(target_pose) joint_target, qp_success = self._solve_joint_target(target_pose)
qp_ms = (time.perf_counter_ns() - qp_started_ns) * 1e-6 qp_ms = (time.perf_counter_ns() - qp_started_ns) * 1e-6
cycle.qp_duration_ms = qp_ms
cycle.q_qp_raw = list(joint_target)
cycle.qp_success = qp_success
send_started_ns = time.perf_counter_ns() send_started_ns = time.perf_counter_ns()
sent = self._send_joint_target(joint_target) sent = self._send_joint_target(joint_target)
send_ms = (time.perf_counter_ns() - send_started_ns) * 1e-6 send_ms = (time.perf_counter_ns() - send_started_ns) * 1e-6
if sent: if sent:
assert self._last_joint_command_target is not None
final_target = list(self._last_joint_command_target)
self._last_successful_action_target = final_target
cycle.q_target = final_target
cycle.action_monotonic_ns = time.monotonic_ns()
cycle.command_sent = True
self._last_sent_target = sent_target self._last_sent_target = sent_target
self._last_sent_orientation = sent_orientation.copy() self._last_sent_orientation = sent_orientation.copy()
self._last_command_time = now self._last_command_time = now
self._stop_sent = False self._stop_sent = False
else:
cycle.send_failed = True
total_ms = (time.perf_counter_ns() - tick_started_ns) * 1e-6 total_ms = (time.perf_counter_ns() - tick_started_ns) * 1e-6
try: try:
self._record_timing_sample( self._record_timing_sample(
@@ -1233,7 +1309,10 @@ class SingleArmVelocityTeleop(Node):
self._last_valid_joint_target = list(snapshot.positions) self._last_valid_joint_target = list(snapshot.positions)
return current_pose return current_pose
def _solve_joint_target(self, target_pose: np.ndarray) -> list[float]: def _solve_joint_target(
self,
target_pose: np.ndarray,
) -> tuple[list[float], bool]:
if self._last_valid_joint_target is None: if self._last_valid_joint_target is None:
raise RuntimeError("valid joint feedback has not been initialized") raise RuntimeError("valid joint feedback has not been initialized")
try: try:
@@ -1243,9 +1322,9 @@ class SingleArmVelocityTeleop(Node):
f"{self._arm_name} QP 求解失败,保持上一组关节目标:{exc}", f"{self._arm_name} QP 求解失败,保持上一组关节目标:{exc}",
throttle_duration_sec=1.0, throttle_duration_sec=1.0,
) )
return list(self._last_valid_joint_target) return list(self._last_valid_joint_target), False
self._last_valid_joint_target = list(result) self._last_valid_joint_target = list(result)
return list(result) return list(result), True
def _safe_stop(self, reset_active: bool) -> None: def _safe_stop(self, reset_active: bool) -> None:
if not self._stop_sent: if not self._stop_sent:
@@ -1417,6 +1496,122 @@ class SingleArmVelocityTeleop(Node):
self._cmd_vel_pub.publish(velocity_msg) self._cmd_vel_pub.publish(velocity_msg)
self._target_clamped_pub.publish(clamped_msg) self._target_clamped_pub.publish(clamped_msg)
def _build_act_control_sample(
self,
cycle: _ActCycleContext,
) -> ActControlSample:
message = ActControlSample()
message.header.stamp = self.get_clock().now().to_msg()
message.header.frame_id = "rm_base"
message.control_seq = cycle.control_seq
message.control_monotonic_ns = cycle.control_monotonic_ns
message.feedback_monotonic_ns = cycle.feedback_monotonic_ns
message.action_monotonic_ns = cycle.action_monotonic_ns
message.feedback_age_ms = float(cycle.feedback_age_ms)
message.qp_duration_ms = float(cycle.qp_duration_ms)
q_actual = cycle.q_actual or [0.0] * 7
held_target = (
cycle.q_target
or self._last_successful_action_target
or q_actual
)
qp_target = cycle.q_qp_raw or held_target
limits = np.asarray(
self._ik_solver.joint_position_limits,
dtype=float,
)
if limits.shape != (7, 2) or not np.isfinite(limits).all():
raise ValueError("joint limits must have finite shape (7, 2)")
message.q_actual = [float(value) for value in q_actual]
message.q_qp_raw = [float(value) for value in qp_target]
message.q_target = [float(value) for value in held_target]
message.joint_lower_limits = limits[:, 0].tolist()
message.joint_upper_limits = limits[:, 1].tolist()
current_pose = cycle.current_pose
if current_pose is None:
current_pose = self._debug_pose_fallback()
if current_pose is None:
current_pose = np.eye(4)
raw_target_pose = cycle.raw_target_pose
if raw_target_pose is None:
raw_target_pose = current_pose
target_pose = cycle.target_pose
if target_pose is None:
target_pose = current_pose
message.tcp_current = self._pose_msg(
message.header.stamp,
current_pose,
).pose
message.tcp_raw_target = self._pose_msg(
message.header.stamp,
raw_target_pose,
).pose
message.tcp_target = self._pose_msg(
message.header.stamp,
target_pose,
).pose
velocity = cycle.command_velocity or [0.0] * 6
if len(velocity) != 6:
raise ValueError("ACT command velocity must contain 6 values")
message.tcp_command_velocity.linear.x = float(velocity[0])
message.tcp_command_velocity.linear.y = float(velocity[1])
message.tcp_command_velocity.linear.z = float(velocity[2])
message.tcp_command_velocity.angular.x = float(velocity[3])
message.tcp_command_velocity.angular.y = float(velocity[4])
message.tcp_command_velocity.angular.z = float(velocity[5])
controller = self._last_msg
if controller is not None:
message.pico_pose = controller.pose
message.pico_grip = bool(controller.grip)
message.pico_trigger = float(controller.trigger)
message.pico_primary = bool(controller.primary)
message.pico_secondary = bool(controller.secondary)
message.pico_axis = [float(value) for value in controller.axis]
tool_target, tool_state, tool_pending, tool_failed = (
self._tool_state_snapshot()
)
message.gripper_target_open = tool_target
message.gripper_state_known = tool_state is not None
message.gripper_state_open = bool(tool_state)
message.gripper_command_pending = tool_pending
message.gripper_command_failed = tool_failed
message.teleop_active = bool(self._active)
message.feedback_valid = cycle.feedback_valid
message.action_valid = bool(
self._last_successful_action_target is not None
and cycle.feedback_valid
and not cycle.send_failed
and not cycle.control_fault
)
message.command_sent = cycle.command_sent
message.qp_attempted = cycle.qp_attempted
message.qp_success = cycle.qp_success
message.target_clamped = cycle.target_clamped
message.control_fault = bool(
cycle.control_fault or self._control_fault_latched
)
return message
def _publish_act_control_sample(
self,
cycle: _ActCycleContext,
) -> None:
publisher = getattr(self, "_act_sample_pub", None)
if publisher is None:
return
try:
publisher.publish(self._build_act_control_sample(cycle))
except Exception as exc:
self.get_logger().warn(
f"{self._arm_name} ACT原子样本发布失败:{exc}",
throttle_duration_sec=1.0,
)
@staticmethod @staticmethod
def _pose_msg(stamp, pose: np.ndarray) -> PoseStamped: def _pose_msg(stamp, pose: np.ndarray) -> PoseStamped:
transform = _make_transform(pose[:3, 3], pose[:3, :3]) transform = _make_transform(pose[:3, 3], pose[:3, :3])