feat(experement): add guarded motion adapter with pose verification

This commit is contained in:
Даниил Грабарь
2026-11-08 14:02:55 +10:00
parent 5a63420aa2
commit c58aa56757
2 changed files with 612 additions and 0 deletions
@@ -0,0 +1,377 @@
"""Safe wrapper around the existing ``cobot/move_to_pose`` motion server.
Every call performs the same guard sequence:
fresh joint state -> synchronized planning scene -> available server ->
accepted goal -> successful result -> measured tcp pose. Any failure stops
the robot and blocks all later motion.
"""
from __future__ import annotations
import math
import time
from dataclasses import dataclass
from typing import Any, Callable
import numpy as np
from geometry_msgs.msg import PoseStamped
from rclpy.action import ActionClient
from rclpy.duration import Duration
from rclpy.node import Node
from rclpy.time import Time
from scipy.spatial.transform import Rotation
from sensor_msgs.msg import JointState
from std_srvs.srv import Trigger
import tf2_ros
from iiwa_msgs.action import MoveToPose
from iiwa_msgs.srv import MoveToNamedPose
from .geometry import GeometrySpec, Transform
MOVE_ACTION = '/cobot/move_to_pose'
NAMED_SERVICE = '/cobot/move_to_named'
STOP_SERVICE = '/cobot/stop'
PLANNERS = ('ptp', 'lin', 'ompl')
@dataclass(frozen=True)
class MotionResult:
success: bool
message: str
label: str
sent: bool = False
goal: Transform | None = None
actual: Transform | None = None
duration_sec: float = 0.0
def transform_from_pose(message, parent: str, child: str) -> Transform:
orientation = message.orientation
matrix = Rotation.from_quat([
orientation.x, orientation.y, orientation.z, orientation.w,
]).as_matrix()
return Transform(
xyz=(
float(message.position.x),
float(message.position.y),
float(message.position.z),
),
rotation=tuple(tuple(float(value) for value in row) for row in matrix),
parent=parent,
child=child,
)
def transform_from_tf(message, parent: str, child: str) -> Transform:
translation = message.transform.translation
rotation = message.transform.rotation
matrix = Rotation.from_quat([
rotation.x, rotation.y, rotation.z, rotation.w,
]).as_matrix()
return Transform(
xyz=(
float(translation.x),
float(translation.y),
float(translation.z),
),
rotation=tuple(tuple(float(value) for value in row) for row in matrix),
parent=parent,
child=child,
)
def _pose_distance(first: Transform, second: Transform) -> tuple[float, float]:
distance = float(np.linalg.norm(first.position - second.position))
cosine = (np.trace(first.matrix.T @ second.matrix) - 1.0) / 2.0
angle = float(np.arccos(max(-1.0, min(1.0, cosine))))
return distance, angle
class MotionAdapter:
"""Guarded motion client used by every reference operation."""
def __init__(
self,
node: Node,
geometry: GeometrySpec,
*,
action_client: Any | None = None,
named_client: Any | None = None,
stop_client: Any | None = None,
tf_buffer: Any | None = None,
scene_check: Callable[[], tuple[bool, str]] | None = None,
pose_provider: Callable[[], Transform | None] | None = None,
freshness_check: Callable[[], tuple[bool, str]] | None = None,
spin_until: Callable[[Any, float], bool] | None = None,
position_tolerance: float | None = None,
orientation_tolerance: float | None = None,
command_timeout_sec: float = 90.0,
settle_time_sec: float = 0.5,
) -> None:
self.node = node
self.geometry = geometry
self.action_client = action_client or ActionClient(
node, MoveToPose, MOVE_ACTION,
)
self.named_client = named_client or node.create_client(
MoveToNamedPose, NAMED_SERVICE,
)
self.stop_client = stop_client or node.create_client(
Trigger, STOP_SERVICE,
)
self.tf_buffer = tf_buffer
self._tf_listener = None
if tf_buffer is None:
self.tf_buffer = tf2_ros.Buffer()
self._tf_listener = tf2_ros.TransformListener(self.tf_buffer, node)
self._scene_check = scene_check
self._pose_provider = pose_provider
self._freshness_check = freshness_check
self._spin_until = spin_until or self._default_spin_until
self.position_tolerance = (
geometry.position_tolerance
if position_tolerance is None else float(position_tolerance)
)
self.orientation_tolerance = (
geometry.orientation_tolerance_rad
if orientation_tolerance is None else float(orientation_tolerance)
)
self.command_timeout_sec = float(command_timeout_sec)
self.settle_time_sec = max(0.0, float(settle_time_sec))
self.stopped = False
self.stop_reason: str | None = None
self._last_joint_time = None
if freshness_check is None:
node.create_subscription(JointState, '/joint_states', self._on_joint_state, 10)
# ------------------------------------------------------------------ freshness
def _on_joint_state(self, message: JointState) -> None:
del message
self._last_joint_time = self.node.get_clock().now()
def _default_freshness(self) -> tuple[bool, str]:
if self._last_joint_time is None:
return False, 'no /joint_states received yet'
age = (self.node.get_clock().now() - self._last_joint_time).nanoseconds / 1e9
if age > 1.0:
return False, f'/joint_states is stale ({age:.2f} s old)'
if self.tf_buffer is not None:
try:
self.tf_buffer.lookup_transform('base_link', 'tcp', Time())
except Exception as error:
return False, f'tcp transform is unavailable: {error}'
return True, 'ok'
def _fresh(self) -> tuple[bool, str]:
if self._freshness_check is None:
return self._default_freshness()
return self._freshness_check()
def _scene(self) -> tuple[bool, str]:
if self._scene_check is None:
return False, 'planning scene bridge is not configured'
return self._scene_check()
# ------------------------------------------------------------------ transforms
def _default_spin_until(self, future, timeout_sec: float) -> bool:
import rclpy
rclpy.spin_until_future_complete(
self.node, future, timeout_sec=timeout_sec,
)
return future.done()
def world_to_base(self) -> Transform:
# The URDF ships a ``world_base_joint`` with an identity origin, so
# robot_state_publisher always provides a synthetic world->base_link
# transform at zero. The physical Webots placement comes from the
# launch setting and is stored in GeometrySpec.world_to_base.
return self.geometry.world_to_base
def update_world_to_base(self, transform: Transform) -> None:
self.geometry = GeometrySpec(
**{**self.geometry.__dict__, 'world_to_base': transform}
)
def actual_tcp(self) -> Transform | None:
if self._pose_provider is not None:
return self._pose_provider()
if self.tf_buffer is None:
return None
try:
message = self.tf_buffer.lookup_transform('base_link', 'tcp', Time())
return transform_from_tf(message, 'base_link', 'tcp')
except Exception:
return None
def to_base_goal(self, goal: Transform) -> Transform:
return self.world_to_base().inverse().compose_relaxed(goal)
def _abc(self, goal: Transform) -> tuple[float, float, float]:
euler = Rotation.from_matrix(goal.matrix).as_euler('ZYX')
return float(euler[0]), float(euler[1]), float(euler[2])
# ------------------------------------------------------------------ guards
def _guard(self, label: str, speed: float, planner: str) -> MotionResult | None:
if self.stopped:
return MotionResult(
False,
f'motion is blocked after stop: {self.stop_reason}',
label,
)
if not math.isfinite(float(speed)) or not 0.0 < float(speed) <= 1.0:
return MotionResult(False, f'invalid speed {speed!r}', label)
if planner not in PLANNERS:
return MotionResult(False, f'unknown planner {planner!r}', label)
fresh, message = self._fresh()
if not fresh:
self.stop(message)
return MotionResult(False, message, label)
synchronized, message = self._scene()
if not synchronized:
self.stop(message)
return MotionResult(False, message, label)
return None
# ------------------------------------------------------------------ motion
def move_pose(
self,
goal: Transform,
planner: str = 'ptp',
speed: float = 0.2,
label: str = '',
) -> MotionResult:
label = label or 'move_pose'
blocked = self._guard(label, speed, planner)
if blocked is not None:
return blocked
if not self.action_client.wait_for_server(timeout_sec=5.0):
return MotionResult(False, f'{MOVE_ACTION} is unavailable', label)
base_goal = self.to_base_goal(goal)
a, b, c = self._abc(base_goal)
request = MoveToPose.Goal()
request.x, request.y, request.z = (float(value) for value in base_goal.xyz)
request.a, request.b, request.c = a, b, c
request.speed = float(speed)
request.planner = planner
request.frame_id = 'base_link'
started = time.monotonic()
future = self.action_client.send_goal_async(request)
if not self._spin_until(future, self.command_timeout_sec):
return self._fail(label, 'goal response timed out', base_goal, started)
try:
goal_handle = future.result()
except Exception as error:
return self._fail(label, f'goal call failed: {error}', base_goal, started)
if goal_handle is None or not goal_handle.accepted:
return self._fail(label, 'motion goal was rejected', base_goal, started)
result_future = goal_handle.get_result_async()
if not self._spin_until(result_future, self.command_timeout_sec):
return self._fail(label, 'motion result timed out', base_goal, started)
try:
wrapped = result_future.result()
result = wrapped.result
except Exception as error:
return self._fail(label, f'motion result failed: {error}', base_goal, started)
if not result.success:
return self._fail(
label, result.message or 'motion server reported failure',
base_goal, started,
)
self._settle()
actual = self.actual_tcp()
if actual is None:
return self._fail(label, 'actual tcp pose is unavailable after motion', base_goal, started)
distance, angle = _pose_distance(actual, base_goal)
if distance > self.position_tolerance or angle > self.orientation_tolerance:
return self._fail(
label,
f'actual tcp is {distance:.4f} m / {angle:.4f} rad from the goal',
base_goal,
started,
actual=actual,
)
return MotionResult(
True, 'motion completed', label, True, base_goal, actual,
time.monotonic() - started,
)
def move_named(self, name: str, speed: float = 0.2, label: str = '') -> MotionResult:
label = label or f'move_named({name})'
blocked = self._guard(label, speed, 'ptp')
if blocked is not None:
return blocked
if not self.named_client.wait_for_service(timeout_sec=5.0):
return MotionResult(False, f'{NAMED_SERVICE} is unavailable', label)
request = MoveToNamedPose.Request()
request.name = str(name)
request.speed = float(speed)
request.accel_scale = float(speed)
started = time.monotonic()
future = self.named_client.call_async(request)
if not self._spin_until(future, self.command_timeout_sec):
return self._fail(label, 'named motion timed out', None, started)
try:
response = future.result()
except Exception as error:
return self._fail(label, f'named motion failed: {error}', None, started)
if not response.success:
return self._fail(
label, response.message or 'named motion failed', None, started,
)
self._settle()
return MotionResult(
True, response.message or 'motion completed', label,
True, None, self.actual_tcp(), time.monotonic() - started,
)
def _settle(self) -> None:
if self.settle_time_sec <= 0.0 or self.node is None:
return
import rclpy
end = time.monotonic() + self.settle_time_sec
while time.monotonic() < end:
rclpy.spin_once(
self.node,
timeout_sec=min(0.05, max(0.0, end - time.monotonic())),
)
def _fail(
self,
label: str,
message: str,
goal: Transform | None,
started: float,
*,
actual: Transform | None = None,
) -> MotionResult:
self.stop(message)
return MotionResult(
False, message, label, True, goal, actual,
time.monotonic() - started,
)
def stop(self, reason: str) -> bool:
self.stopped = True
if self.stop_reason is None:
self.stop_reason = reason
try:
if self.stop_client.wait_for_service(timeout_sec=1.0):
future = self.stop_client.call_async(Trigger.Request())
self._spin_until(future, 5.0)
return bool(future.done() and future.result().success)
except Exception:
pass
return False
def reset_stop(self) -> None:
"""Only for a new run; never used to resume after a failed motion."""
self.stopped = False
self.stop_reason = None
+235
View File
@@ -0,0 +1,235 @@
"""Motion guards: stale state, unsynchronized scene, failures and stop."""
from pathlib import Path
import pytest
from iiwa_experement.geometry import Transform, load_geometry
from iiwa_experement.motion import MotionAdapter
PACKAGE = Path(__file__).resolve().parents[1]
GEOMETRY = PACKAGE / 'config' / 'geometry.yaml'
class FakeFuture:
def __init__(self, value=None, done=True):
self._value = value
self._done = done
def done(self):
return self._done
def result(self):
return self._value
class FakeResult:
def __init__(self, success=True, message='ok'):
self.success = success
self.message = message
class FakeWrapped:
def __init__(self, result):
self.result = result
class FakeGoalHandle:
def __init__(self, accepted=True, result=None):
self.accepted = accepted
self._result = result or FakeResult()
def get_result_async(self):
return FakeFuture(FakeWrapped(self._result))
class FakeActionClient:
def __init__(self, *, available=True, accepted=True, result=None, timeout=False):
self.available = available
self.accepted = accepted
self.result = result
self.timeout = timeout
self.requests = []
def wait_for_server(self, timeout_sec=0.0):
return self.available
def send_goal_async(self, request):
self.requests.append(request)
if self.timeout:
return FakeFuture(None, done=False)
return FakeFuture(FakeGoalHandle(self.accepted, self.result))
class FakeService:
def __init__(self, *, available=True, success=True, message='ok', timeout=False):
self.available = available
self.success = success
self.message = message
self.timeout = timeout
self.requests = []
def wait_for_service(self, timeout_sec=0.0):
return self.available
def call_async(self, request):
self.requests.append(request)
if self.timeout:
return FakeFuture(None, done=False)
return FakeFuture(FakeResult(self.success, self.message))
class StopService(FakeService):
def __init__(self):
super().__init__(success=True, message='stopped')
self.calls = 0
def call_async(self, request):
self.calls += 1
return super().call_async(request)
def geometry():
return load_geometry(GEOMETRY)
def goal():
return Transform.from_xyz_rpy(
[0.20, 0.10, 0.90], [180.0, 0.0, 45.0], parent='world', child='tcp',
)
def make_adapter(
*,
action=None,
named=None,
stop=None,
fresh=True,
scene=True,
actual=None,
spin=None,
):
g = geometry()
action = action or FakeActionClient()
named = named or FakeService()
stop = stop or StopService()
adapter = MotionAdapter(
node=None,
geometry=g,
action_client=action,
named_client=named,
stop_client=stop,
tf_buffer=object(),
scene_check=lambda: (scene, 'scene is stale' if not scene else 'ok'),
freshness_check=lambda: (fresh, 'joint state is stale' if not fresh else 'ok'),
pose_provider=lambda: actual if actual is not None else adapter.to_base_goal(goal()),
spin_until=spin or (lambda future, timeout: future.done()),
)
return adapter, action, named, stop
def test_successful_move_populates_abc_and_verifies_actual_pose():
adapter, action, _, _ = make_adapter()
result = adapter.move_pose(goal(), planner='ptp', speed=0.2, label='test')
assert result.success, result.message
assert len(action.requests) == 1
request = action.requests[0]
expected = adapter.to_base_goal(goal())
assert (request.x, request.y, request.z) == pytest.approx(expected.xyz)
assert request.speed == pytest.approx(0.2)
assert request.planner == 'ptp'
assert request.frame_id == 'base_link'
assert result.goal.xyz == pytest.approx(expected.xyz)
def test_stale_joint_state_blocks_before_sending_and_stops():
stop = StopService()
adapter, action, _, _ = make_adapter(fresh=False, stop=stop)
result = adapter.move_pose(goal(), label='stale')
assert not result.success
assert 'stale' in result.message
assert action.requests == []
assert adapter.stopped is True
assert stop.calls == 1
def test_unsynchronized_scene_blocks_before_sending_and_stops():
adapter, action, _, _ = make_adapter(scene=False)
result = adapter.move_pose(goal(), label='scene')
assert not result.success
assert 'scene' in result.message
assert action.requests == []
assert adapter.stopped is True
def test_invalid_speed_and_planner_are_rejected_without_stopping():
adapter, action, _, _ = make_adapter()
assert not adapter.move_pose(goal(), speed=0.0).success
assert not adapter.move_pose(goal(), speed=2.0).success
assert not adapter.move_pose(goal(), planner='teleport').success
assert action.requests == []
assert adapter.stopped is False
def test_rejected_goal_stops_and_blocks_the_next_move():
action = FakeActionClient(accepted=False)
adapter, action, _, stop = make_adapter(action=action)
result = adapter.move_pose(goal(), label='rejected')
assert not result.success
assert 'rejected' in result.message
assert adapter.stopped is True
assert stop.calls == 1
count = len(action.requests)
blocked = adapter.move_pose(goal(), label='again')
assert not blocked.success
assert 'blocked' in blocked.message
assert len(action.requests) == count
def test_timeout_stops_the_robot():
adapter, _, _, stop = make_adapter(action=FakeActionClient(timeout=True))
result = adapter.move_pose(goal(), label='timeout')
assert not result.success
assert 'timed out' in result.message
assert adapter.stopped is True
assert stop.calls == 1
def test_actual_pose_out_of_tolerance_stops_the_robot():
actual = goal().compose_relaxed(Transform.from_xyz_rpy(
[0.0, 0.0, 0.05], [0, 0, 0], parent='world', child='tcp',
))
adapter, _, _, stop = make_adapter(actual=actual)
result = adapter.move_pose(goal(), label='mismatch')
assert not result.success
assert 'from the goal' in result.message
assert adapter.stopped is True
assert stop.calls == 1
def test_named_motion_uses_the_existing_service():
adapter, _, named, _ = make_adapter()
result = adapter.move_named('home', speed=0.2)
assert result.success
assert len(named.requests) == 1
assert named.requests[0].name == 'home'
assert named.requests[0].speed == pytest.approx(0.2)
def test_failed_named_motion_stops():
named = FakeService(success=False, message='unknown state')
adapter, _, _, stop = make_adapter(named=named)
result = adapter.move_named('missing')
assert not result.success
assert 'unknown state' in result.message
assert adapter.stopped is True
assert stop.calls == 1
def test_unavailable_server_is_reported_without_motion():
adapter, action, _, _ = make_adapter(action=FakeActionClient(available=False))
result = adapter.move_pose(goal(), label='no server')
assert not result.success
assert 'unavailable' in result.message
assert action.requests == []
assert adapter.stopped is False