Skip to content

Commit f2809b7

Browse files
fmauchVinDp
andauthored
Add tests moving the robot (#67)
* Add tests moving the robot * Add missing dependencies to package.xml * Check for active state while waiting for controller * Apply suggestions from code review Co-authored-by: Vincenzo Di Pentima <[email protected]>
1 parent 8f28abc commit f2809b7

File tree

5 files changed

+344
-0
lines changed

5 files changed

+344
-0
lines changed

ur_simulation_gazebo/CMakeLists.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,13 @@ install(DIRECTORY config launch
77
DESTINATION share/${PROJECT_NAME}
88
)
99

10+
if(BUILD_TESTING)
11+
find_package(launch_testing_ament_cmake)
12+
find_package(ament_cmake_pytest REQUIRED)
13+
add_launch_test(test/test_gazebo.py
14+
TIMEOUT
15+
180
16+
)
17+
endif()
18+
1019
ament_package()

ur_simulation_gazebo/launch/ur_sim_control.launch.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def launch_setup(context, *args, **kwargs):
5959
start_joint_controller = LaunchConfiguration("start_joint_controller")
6060
initial_joint_controller = LaunchConfiguration("initial_joint_controller")
6161
launch_rviz = LaunchConfiguration("launch_rviz")
62+
gazebo_gui = LaunchConfiguration("gazebo_gui")
6263

6364
initial_joint_controllers = PathJoinSubstitution(
6465
[FindPackageShare(runtime_config_package), "config", controllers_file]
@@ -151,6 +152,9 @@ def launch_setup(context, *args, **kwargs):
151152
PythonLaunchDescriptionSource(
152153
[FindPackageShare("gazebo_ros"), "/launch", "/gazebo.launch.py"]
153154
),
155+
launch_arguments={
156+
"gui": gazebo_gui,
157+
}.items(),
154158
)
155159

156160
# Spawn robot
@@ -264,5 +268,10 @@ def generate_launch_description():
264268
declared_arguments.append(
265269
DeclareLaunchArgument("launch_rviz", default_value="true", description="Launch RViz?")
266270
)
271+
declared_arguments.append(
272+
DeclareLaunchArgument(
273+
"gazebo_gui", default_value="true", description="Start gazebo with GUI?"
274+
)
275+
)
267276

268277
return LaunchDescription(declared_arguments + [OpaqueFunction(function=launch_setup)])

ur_simulation_gazebo/package.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
<exec_depend>urdf</exec_depend>
3131
<exec_depend>xacro</exec_depend>
3232

33+
<test_depend>ament_cmake_pytest</test_depend>
34+
<test_depend>launch_testing_ament_cmake</test_depend>
35+
<test_depend>launch_testing_ros</test_depend>
36+
3337
<export>
3438
<build_type>ament_cmake</build_type>
3539
</export>
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
# Copyright 2024, FZI Forschungszentrum Informatik
2+
#
3+
# Redistribution and use in source and binary forms, with or without
4+
# modification, are permitted provided that the following conditions are met:
5+
#
6+
# * Redistributions of source code must retain the above copyright
7+
# notice, this list of conditions and the following disclaimer.
8+
#
9+
# * Redistributions in binary form must reproduce the above copyright
10+
# notice, this list of conditions and the following disclaimer in the
11+
# documentation and/or other materials provided with the distribution.
12+
#
13+
# * Neither the name of the {copyright_holder} nor the names of its
14+
# contributors may be used to endorse or promote products derived from
15+
# this software without specific prior written permission.
16+
#
17+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
18+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
19+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
20+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
21+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
22+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
23+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
24+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
25+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
26+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
27+
# POSSIBILITY OF SUCH DAMAGE.
28+
import logging
29+
import time
30+
31+
import rclpy
32+
from rclpy.action import ActionClient
33+
34+
from controller_manager_msgs.srv import ListControllers
35+
36+
TIMEOUT_WAIT_SERVICE = 10
37+
TIMEOUT_WAIT_SERVICE_INITIAL = 120
38+
TIMEOUT_WAIT_ACTION = 20
39+
40+
41+
def _wait_for_service(node, srv_name, srv_type, timeout):
42+
client = node.create_client(srv_type, srv_name)
43+
44+
logging.info("Waiting for service '%s' with timeout %fs...", srv_name, timeout)
45+
if client.wait_for_service(timeout) is False:
46+
raise Exception(f"Could not reach service '{srv_name}' within timeout of {timeout}")
47+
logging.info(" Successfully connected to service '%s'", srv_name)
48+
49+
return client
50+
51+
52+
def _wait_for_action(node, action_name, action_type, timeout):
53+
client = ActionClient(node, action_type, action_name)
54+
55+
logging.info("Waiting for action server '%s' with timeout %fs...", action_name, timeout)
56+
if client.wait_for_server(timeout) is False:
57+
raise Exception(
58+
f"Could not reach action server '{action_name}' within timeout of {timeout}"
59+
)
60+
61+
logging.info(" Successfully connected to action server '%s'", action_name)
62+
return client
63+
64+
65+
def wait_for_controller(
66+
node, controller_name, timeout, cm_list_service="/controller_manager/list_controllers"
67+
):
68+
logging.info("Waiting for controller '%s' with timeout %fs...", controller_name, timeout)
69+
client = _wait_for_service(node, cm_list_service, ListControllers, TIMEOUT_WAIT_SERVICE)
70+
controller_active = False
71+
start_time = node.get_clock().now()
72+
while not controller_active and (
73+
node.get_clock().now() - start_time < rclpy.time.Duration(seconds=timeout)
74+
):
75+
result = _call_service(node, client, ListControllers.Request())
76+
for controller in result.controller:
77+
if controller.name == controller_name:
78+
controller_active = controller.state == "active"
79+
if controller_active:
80+
logging.info("Controller '%s' is active.", controller_name)
81+
return True
82+
time.sleep(1)
83+
raise Exception(
84+
f"Could not find active controller '{controller_name}' within timeout of {timeout}"
85+
)
86+
87+
88+
def _call_service(node, client, request):
89+
logging.info("Calling service client '%s' with request '%s'", client.srv_name, request)
90+
future = client.call_async(request)
91+
92+
rclpy.spin_until_future_complete(node, future)
93+
94+
if future.result() is not None:
95+
logging.info(" Received result: %s", future.result())
96+
return future.result()
97+
98+
raise Exception(f"Error while calling service '{client.srv_name}': {future.exception()}")
99+
100+
101+
class _ServiceInterface:
102+
def __init__(
103+
self, node, initial_timeout=TIMEOUT_WAIT_SERVICE_INITIAL, timeout=TIMEOUT_WAIT_SERVICE
104+
):
105+
self.__node = node
106+
107+
self.__service_clients = {
108+
srv_name: (
109+
_wait_for_service(self.__node, srv_name, srv_type, initial_timeout),
110+
srv_type,
111+
)
112+
for srv_name, srv_type in self.__initial_services.items()
113+
}
114+
self.__service_clients.update(
115+
{
116+
srv_name: (_wait_for_service(self.__node, srv_name, srv_type, timeout), srv_type)
117+
for srv_name, srv_type in self.__services.items()
118+
}
119+
)
120+
121+
def __init_subclass__(mcs, namespace="", initial_services={}, services={}, **kwargs):
122+
super().__init_subclass__(**kwargs)
123+
124+
mcs.__initial_services = {
125+
namespace + "/" + srv_name: srv_type for srv_name, srv_type in initial_services.items()
126+
}
127+
mcs.__services = {
128+
namespace + "/" + srv_name: srv_type for srv_name, srv_type in services.items()
129+
}
130+
131+
for srv_name, srv_type in list(initial_services.items()) + list(services.items()):
132+
full_srv_name = namespace + "/" + srv_name
133+
134+
setattr(
135+
mcs,
136+
srv_name,
137+
lambda s, full_srv_name=full_srv_name, *args, **kwargs: _call_service(
138+
s.__node,
139+
s.__service_clients[full_srv_name][0],
140+
s.__service_clients[full_srv_name][1].Request(*args, **kwargs),
141+
),
142+
)
143+
144+
145+
class ActionInterface:
146+
def __init__(self, node, action_name, action_type, timeout=TIMEOUT_WAIT_ACTION):
147+
self.__node = node
148+
149+
self.__action_name = action_name
150+
self.__action_type = action_type
151+
self.__action_client = _wait_for_action(node, action_name, action_type, timeout)
152+
153+
def send_goal(self, *args, **kwargs):
154+
goal = self.__action_type.Goal(*args, **kwargs)
155+
156+
logging.info("Sending goal to action server '%s': %s", self.__action_name, goal)
157+
future = self.__action_client.send_goal_async(goal)
158+
159+
rclpy.spin_until_future_complete(self.__node, future)
160+
161+
if future.result() is not None:
162+
logging.info(" Received result: %s", future.result())
163+
return future.result()
164+
pass
165+
166+
def get_result(self, goal_handle, timeout):
167+
future_res = goal_handle.get_result_async()
168+
169+
logging.info(
170+
"Waiting for action result from '%s' with timeout %fs", self.__action_name, timeout
171+
)
172+
rclpy.spin_until_future_complete(self.__node, future_res, timeout_sec=timeout)
173+
174+
if future_res.result() is not None:
175+
logging.info(" Received result: %s", future_res.result().result)
176+
return future_res.result().result
177+
else:
178+
raise Exception(
179+
f"Exception while calling action '{self.__action_name}': {future_res.exception()}"
180+
)
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
#!/usr/bin/env python
2+
# Copyright 2024, FZI Forschungszentrum Informatik
3+
#
4+
# Redistribution and use in source and binary forms, with or without
5+
# modification, are permitted provided that the following conditions are met:
6+
#
7+
# * Redistributions of source code must retain the above copyright
8+
# notice, this list of conditions and the following disclaimer.
9+
#
10+
# * Redistributions in binary form must reproduce the above copyright
11+
# notice, this list of conditions and the following disclaimer in the
12+
# documentation and/or other materials provided with the distribution.
13+
#
14+
# * Neither the name of the {copyright_holder} nor the names of its
15+
# contributors may be used to endorse or promote products derived from
16+
# this software without specific prior written permission.
17+
#
18+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
21+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
22+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
23+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
24+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
25+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
26+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
27+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
28+
# POSSIBILITY OF SUCH DAMAGE.
29+
30+
import logging
31+
import os
32+
import pytest
33+
import sys
34+
import time
35+
import unittest
36+
37+
38+
import rclpy
39+
from rclpy.node import Node
40+
from launch import LaunchDescription
41+
from launch.actions import (
42+
IncludeLaunchDescription,
43+
)
44+
from launch.substitutions import PathJoinSubstitution
45+
from launch.launch_description_sources import PythonLaunchDescriptionSource
46+
from launch_ros.substitutions import FindPackageShare
47+
from launch_testing.actions import ReadyToTest
48+
import launch_testing
49+
50+
from builtin_interfaces.msg import Duration
51+
from control_msgs.action import FollowJointTrajectory
52+
from trajectory_msgs.msg import JointTrajectory, JointTrajectoryPoint
53+
54+
sys.path.append(os.path.dirname(__file__))
55+
from test_common import ActionInterface, wait_for_controller # noqa: E402
56+
57+
58+
TIMEOUT_EXECUTE_TRAJECTORY = 30
59+
60+
ROBOT_JOINTS = [
61+
"elbow_joint",
62+
"shoulder_lift_joint",
63+
"shoulder_pan_joint",
64+
"wrist_1_joint",
65+
"wrist_2_joint",
66+
"wrist_3_joint",
67+
]
68+
69+
70+
# TODO: Add tf_prefix parametrization
71+
@pytest.mark.launch_test
72+
@launch_testing.parametrize(
73+
"ur_type",
74+
["ur3", "ur3e", "ur5", "ur5e", "ur10", "ur10e", "ur16e", "ur20", "ur30"],
75+
)
76+
def generate_test_description(ur_type):
77+
gazebo = IncludeLaunchDescription(
78+
PythonLaunchDescriptionSource(
79+
PathJoinSubstitution(
80+
[FindPackageShare("ur_simulation_gazebo"), "launch", "ur_sim_control.launch.py"]
81+
)
82+
),
83+
launch_arguments={
84+
"ur_type": ur_type,
85+
"launch_rviz": "false",
86+
"start_joint_controller": "true",
87+
"launch_rviz": "false",
88+
"gazebo_gui": "false",
89+
}.items(),
90+
)
91+
return LaunchDescription([ReadyToTest(), gazebo])
92+
93+
94+
class GazeboTest(unittest.TestCase):
95+
@classmethod
96+
def setUpClass(cls):
97+
# Initialize the ROS context
98+
rclpy.init()
99+
cls.node = Node("ur_gazebo_test")
100+
time.sleep(1)
101+
cls.init_robot(cls)
102+
103+
@classmethod
104+
def tearDownClass(cls):
105+
# Shutdown the ROS context
106+
cls.node.destroy_node()
107+
rclpy.shutdown()
108+
109+
def init_robot(self):
110+
wait_for_controller(self.node, "joint_trajectory_controller", 30)
111+
self._follow_joint_trajectory = ActionInterface(
112+
self.node,
113+
"/joint_trajectory_controller/follow_joint_trajectory",
114+
FollowJointTrajectory,
115+
)
116+
117+
def test_trajectory(self, ur_type):
118+
"""Test robot movement."""
119+
# Construct test trajectory
120+
test_trajectory = [
121+
(Duration(sec=6, nanosec=0), [-0.1 for j in ROBOT_JOINTS]),
122+
(Duration(sec=9, nanosec=0), [-0.5 for j in ROBOT_JOINTS]),
123+
(Duration(sec=12, nanosec=0), [-1.0 for j in ROBOT_JOINTS]),
124+
]
125+
126+
trajectory = JointTrajectory(
127+
# joint_names=[tf_prefix + joint for joint in ROBOT_JOINTS],
128+
joint_names=ROBOT_JOINTS,
129+
points=[
130+
JointTrajectoryPoint(positions=test_pos, time_from_start=test_time)
131+
for (test_time, test_pos) in test_trajectory
132+
],
133+
)
134+
135+
# Sending trajectory goal
136+
logging.info("Sending simple goal")
137+
goal_handle = self._follow_joint_trajectory.send_goal(trajectory=trajectory)
138+
self.assertTrue(goal_handle.accepted)
139+
140+
# Verify execution
141+
result = self._follow_joint_trajectory.get_result(goal_handle, TIMEOUT_EXECUTE_TRAJECTORY)
142+
self.assertEqual(result.error_code, FollowJointTrajectory.Result.SUCCESSFUL)

0 commit comments

Comments
 (0)