feat(experement): add guarded motion adapter with pose verification
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user