ROS 2 Simulation Integration Exercise
Objective
Integrate ROS 2 nodes with a Gazebo simulation environment to control a virtual robot. This exercise demonstrates the sim-to-real transfer methodology by implementing robot control in simulation before real-world deployment.
Prerequisites
- ROS 2 Humble Hawksbill installed
- Gazebo installed with ROS 2 plugins
- Basic understanding of robot URDF models
- Completed ROS 2 basic publisher-subscriber exercise
Learning Goals
By completing this exercise, you will:
- Launch a robot simulation in Gazebo
- Control a simulated robot using ROS 2 topics
- Implement robot navigation in a simulated environment
- Understand the relationship between simulation and real-world robotics
- Use ROS 2 tools to monitor and debug simulated robot behavior
Exercise Steps
Step 1: Verify Gazebo Installation
First, ensure Gazebo is properly installed with ROS 2 support:
# Check Gazebo version
gazebo --version
# Check for ROS 2 Gazebo packages
apt list --installed | grep ros-humble-gazebo
Step 2: Create a Simple Robot Model
Create a basic differential drive robot URDF for simulation:
- Create the robot description package:
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python robot_simulation
cd robot_simulation
- Create the URDF directory and file
robot_simulation/urdf/simple_robot.urdf:
<?xml version="1.0"?>
<robot name="simple_robot" xmlns:xacro="http://www.ros.org/wiki/xacro">
<!-- Base Link -->
<link name="base_link">
<visual>
<geometry>
<box size="0.5 0.3 0.15"/>
</geometry>
<material name="blue">
<color rgba="0 0 1 0.8"/>
</material>
</visual>
<collision>
<geometry>
<box size="0.5 0.3 0.15"/>
</geometry>
</collision>
<inertial>
<mass value="1.0"/>
<inertia ixx="0.1" ixy="0.0" ixz="0.0" iyy="0.1" iyz="0.0" izz="0.1"/>
</inertial>
</link>
<!-- Left Wheel -->
<joint name="left_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="left_wheel"/>
<origin xyz="0.0 0.2 -0.05" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
<link name="left_wheel">
<visual>
<geometry>
<cylinder radius="0.1" length="0.05"/>
</geometry>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
</visual>
<collision>
<geometry>
<cylinder radius="0.1" length="0.05"/>
</geometry>
</collision>
<inertial>
<mass value="0.2"/>
<inertia ixx="0.001" ixy="0.0" ixz="0.0" iyy="0.001" iyz="0.0" izz="0.001"/>
</inertial>
</link>
<!-- Right Wheel -->
<joint name="right_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="right_wheel"/>
<origin xyz="0.0 -0.2 -0.05" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
</joint>
<link name="right_wheel">
<visual>
<geometry>
<cylinder radius="0.1" length="0.05"/>
</geometry>
<material name="black">
<color rgba="0 0 0 1"/>
</material>
</visual>
<collision>
<geometry>
<cylinder radius="0.1" length="0.05"/>
</geometry>
</collision>
<inertial>
<mass value="0.2"/>
<inertia ixx="0.001" ixy="0.0" ixz="0.0" iyy="0.001" iyz="0.0" izz="0.001"/>
</inertial>
</link>
<!-- Robot Footprint -->
<link name="base_footprint">
<visual>
<origin xyz="0 0 0.075"/>
<geometry>
<box size="0.001 0.001 0.001"/>
</geometry>
</visual>
</link>
<joint name="base_joint" type="fixed">
<parent link="base_footprint"/>
<child link="base_link"/>
<origin xyz="0 0 0.075" rpy="0 0 0"/>
</joint>
<!-- Gazebo Plugins -->
<gazebo>
<plugin name="diff_drive" filename="libgazebo_ros_diff_drive.so">
<left_joint>left_wheel_joint</left_joint>
<right_joint>right_wheel_joint</right_joint>
<wheel_separation>0.4</wheel_separation>
<wheel_diameter>0.2</wheel_diameter>
<max_wheel_torque>20</max_wheel_torque>
<max_wheel_acceleration>1.0</max_wheel_acceleration>
<publish_odom>true</publish_odom>
<publish_odom_tf>true</publish_odom_tf>
<publish_wheel_tf>true</publish_wheel_tf>
<odometry_frame>odom</odometry_frame>
<robot_base_frame>base_footprint</robot_base_frame>
</plugin>
</gazebo>
</robot>
Step 3: Create Robot Spawn Launch File
Create a launch file to spawn the robot in Gazebo:
- Create the launch directory:
mkdir -p robot_simulation/launch
- Create the launch file
robot_simulation/launch/spawn_robot.launch.py:
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
# Launch Gazebo
gazebo = IncludeLaunchDescription(
PythonLaunchDescriptionSource([
PathJoinSubstitution([
FindPackageShare('gazebo_ros'),
'launch',
'empty_world.launch.py'
])
]),
launch_arguments={
'world': PathJoinSubstitution([
FindPackageShare('gazebo_ros'),
'worlds',
'empty_world.world'
])
}.items()
)
# Spawn robot in Gazebo
spawn_entity = Node(
package='gazebo_ros',
executable='spawn_entity.py',
arguments=[
'-topic', 'robot_description',
'-entity', 'simple_robot',
'-x', '0', '-y', '0', '-z', '0.1'
],
output='screen'
)
# Robot State Publisher
robot_state_publisher = Node(
package='robot_state_publisher',
executable='robot_state_publisher',
parameters=[{
'robot_description': PathJoinSubstitution([
FindPackageShare('robot_simulation'),
'urdf',
'simple_robot.urdf'
])
}]
)
return LaunchDescription([
gazebo,
robot_state_publisher,
spawn_entity
])
Step 4: Create Robot Controller Node
Create a node to control the simulated robot:
- Create the controller directory:
mkdir -p robot_simulation/robot_controller
- Create the controller script
robot_simulation/robot_controller/robot_controller.py:
#!/usr/bin/env python3
"""
Robot controller node for simulation
"""
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from nav_msgs.msg import Odometry
from std_msgs.msg import String
import math
class RobotController(Node):
def __init__(self):
super().__init__('robot_controller')
# Create publisher for velocity commands
self.cmd_vel_publisher = self.create_publisher(Twist, '/cmd_vel', 10)
# Create subscriber for odometry
self.odom_subscriber = self.create_subscription(
Odometry,
'/odom',
self.odom_callback,
10)
# Create publisher for robot status
self.status_publisher = self.create_publisher(String, '/robot_status', 10)
# Timer for sending commands
self.timer = self.create_timer(0.1, self.timer_callback)
# Robot state
self.current_x = 0.0
self.current_y = 0.0
self.current_theta = 0.0
self.is_moving = False
# Navigation goals
self.goals = [
(2.0, 0.0), # Move 2m forward
(2.0, 2.0), # Move 2m right
(0.0, 2.0), # Move 2m back
(0.0, 0.0), # Return to origin
]
self.current_goal_index = 0
self.reached_goal = False
self.get_logger().info('Robot controller initialized')
def odom_callback(self, msg):
"""Update robot position from odometry"""
self.current_x = msg.pose.pose.position.x
self.current_y = msg.pose.pose.position.y
# Extract orientation (simplified - assuming only z-rotation)
quat = msg.pose.pose.orientation
self.current_theta = math.atan2(2.0 * (quat.w * quat.z + quat.x * quat.y),
1.0 - 2.0 * (quat.y * quat.y + quat.z * quat.z))
def timer_callback(self):
"""Send movement commands based on navigation goals"""
if self.current_goal_index >= len(self.goals):
# All goals reached, stop the robot
self.stop_robot()
return
current_goal = self.goals[self.current_goal_index]
goal_x, goal_y = current_goal
# Calculate distance to goal
dist_to_goal = math.sqrt((goal_x - self.current_x)**2 + (goal_y - self.current_y)**2)
# Check if we've reached the current goal
if dist_to_goal < 0.1: # 10cm tolerance
if not self.reached_goal:
self.get_logger().info(f'Reached goal {self.current_goal_index}: ({goal_x}, {goal_y})')
self.reached_goal = True
self.stop_robot()
# Schedule next goal after a short pause
self.get_timer = self.create_timer(2.0, self.next_goal)
else:
# Move toward the goal
self.reached_goal = False
self.move_to_goal(goal_x, goal_y)
def move_to_goal(self, goal_x, goal_y):
"""Move the robot toward the specified goal"""
# Calculate direction to goal
dx = goal_x - self.current_x
dy = goal_y - self.current_y
goal_angle = math.atan2(dy, dx)
# Calculate angle difference
angle_diff = goal_angle - self.current_theta
# Normalize angle to [-pi, pi]
while angle_diff > math.pi:
angle_diff -= 2 * math.pi
while angle_diff < -math.pi:
angle_diff += 2 * math.pi
# Create twist message
twist = Twist()
# Set angular velocity to turn toward goal
if abs(angle_diff) > 0.1: # 0.1 rad tolerance
twist.angular.z = max(min(angle_diff * 1.0, 1.0), -1.0) # Limit angular velocity
else:
# Move forward when facing the right direction
twist.linear.x = min(dist_to_goal * 0.5, 0.5) # Limit linear velocity
self.cmd_vel_publisher.publish(twist)
# Publish status
status_msg = String()
status_msg.data = f'Navigating to ({goal_x:.2f}, {goal_y:.2f}), dist: {dist_to_goal:.2f}m'
self.status_publisher.publish(status_msg)
def next_goal(self):
"""Move to the next navigation goal"""
self.current_goal_index += 1
self.destroy_timer() # Destroy the temporary timer
def stop_robot(self):
"""Stop the robot by sending zero velocities"""
twist = Twist()
self.cmd_vel_publisher.publish(twist)
status_msg = String()
status_msg.data = 'Robot stopped'
self.status_publisher.publish(status_msg)
def get_timer(self):
"""Placeholder for temporary timer"""
pass
def main(args=None):
rclpy.init(args=args)
robot_controller = RobotController()
try:
rclpy.spin(robot_controller)
except KeyboardInterrupt:
robot_controller.get_logger().info('Shutting down robot controller...')
finally:
robot_controller.stop_robot()
robot_controller.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Step 5: Create Visualization Node
Create a node to visualize robot path and goals:
- Create the visualization directory:
mkdir -p robot_simulation/path_visualizer
- Create the visualization script
robot_simulation/path_visualizer/path_visualizer.py:
#!/usr/bin/env python3
"""
Path visualizer for robot navigation
"""
import rclpy
from rclpy.node import Node
from visualization_msgs.msg import Marker, MarkerArray
from geometry_msgs.msg import Point
from nav_msgs.msg import Odometry
import math
class PathVisualizer(Node):
def __init__(self):
super().__init__('path_visualizer')
# Create publisher for visualization markers
self.marker_publisher = self.create_publisher(MarkerArray, '/visualization_marker_array', 10)
# Create subscriber for odometry
self.odom_subscriber = self.create_subscription(
Odometry,
'/odom',
self.odom_callback,
10)
# Timer for publishing markers
self.timer = self.create_timer(0.5, self.publish_markers)
# Store robot path
self.path_points = []
self.max_path_length = 100 # Limit path history
# Navigation goals
self.goals = [
(2.0, 0.0),
(2.0, 2.0),
(0.0, 2.0),
(0.0, 0.0),
]
self.get_logger().info('Path visualizer initialized')
def odom_callback(self, msg):
"""Add current position to path"""
point = Point()
point.x = msg.pose.pose.position.x
point.y = msg.pose.pose.position.y
point.z = 0.05 # Slightly above ground for visibility
self.path_points.append(point)
# Limit path length
if len(self.path_points) > self.max_path_length:
self.path_points.pop(0)
def publish_markers(self):
"""Publish visualization markers"""
marker_array = MarkerArray()
# Create path marker
path_marker = Marker()
path_marker.header.frame_id = "odom"
path_marker.header.stamp = self.get_clock().now().to_msg()
path_marker.ns = "path"
path_marker.id = 0
path_marker.type = Marker.LINE_STRIP
path_marker.action = Marker.ADD
# Set path marker properties
path_marker.pose.orientation.w = 1.0
path_marker.scale.x = 0.02 # Line width
path_marker.color.r = 1.0
path_marker.color.g = 0.0
path_marker.color.b = 0.0
path_marker.color.a = 1.0
path_marker.points = self.path_points
marker_array.markers.append(path_marker)
# Create goal markers
for i, (goal_x, goal_y) in enumerate(self.goals):
goal_marker = Marker()
goal_marker.header.frame_id = "odom"
goal_marker.header.stamp = self.get_clock().now().to_msg()
goal_marker.ns = "goals"
goal_marker.id = i + 1
goal_marker.type = Marker.CYLINDER
goal_marker.action = Marker.ADD
# Set goal marker properties
goal_marker.pose.position.x = goal_x
goal_marker.pose.position.y = goal_y
goal_marker.pose.position.z = 0.0
goal_marker.pose.orientation.w = 1.0
goal_marker.scale.x = 0.3 # Diameter
goal_marker.scale.y = 0.3 # Diameter
goal_marker.scale.z = 0.1 # Height
goal_marker.color.r = 0.0
goal_marker.color.g = 1.0
goal_marker.color.b = 0.0
goal_marker.color.a = 0.7
marker_array.markers.append(goal_marker)
self.marker_publisher.publish(marker_array)
def main(args=None):
rclpy.init(args=args)
path_visualizer = PathVisualizer()
try:
rclpy.spin(path_visualizer)
except KeyboardInterrupt:
pass
finally:
path_visualizer.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
Step 6: Update Package Configuration
Update the setup.py file to make your nodes executable:
from setuptools import find_packages, setup
package_name = 'robot_simulation'
setup(
name=package_name,
version='0.0.0',
packages=find_packages(exclude=['test']),
data_files=[
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
('share/' + package_name, ['package.xml']),
('share/' + package_name + '/urdf', ['urdf/simple_robot.urdf']),
('share/' + package_name + '/launch', ['launch/spawn_robot.launch.py']),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='your_name',
maintainer_email='your_email@example.com',
description='Robot simulation package',
license='TODO: License declaration',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'robot_controller = robot_simulation.robot_controller.robot_controller:main',
'path_visualizer = robot_simulation.path_visualizer.path_visualizer:main',
],
},
)
Step 7: Build and Run the Simulation
- Build your package:
cd ~/ros2_ws
colcon build --packages-select robot_simulation
source install/setup.bash
- Launch the robot in Gazebo:
ros2 launch robot_simulation spawn_robot.launch.py
- In a new terminal, run the robot controller:
source ~/ros2_ws/install/setup.bash
ros2 run robot_simulation robot_controller
- In another terminal, run the path visualizer (optional):
source ~/ros2_ws/install/setup.bash
ros2 run robot_simulation path_visualizer
Validation Criteria
Your implementation is successful if:
- The robot spawns correctly in Gazebo
- The robot navigates to all specified goals in sequence
- The robot stops when it reaches each goal before proceeding to the next
- The path visualization shows the robot's trajectory
- The robot returns to the origin at the end of the navigation sequence
- All nodes run without errors
Advanced Challenge
Extend the exercise by:
- Adding obstacle avoidance using simulated laser scan data
- Implementing a more sophisticated path planning algorithm (A*, Dijkstra)
- Adding a camera sensor to the robot and processing visual data
- Creating a custom Gazebo world with obstacles and landmarks
Monitoring and Debugging
Use these ROS 2 tools to monitor your simulation:
ros2 topic list- List all active topicsros2 topic echo /odom- Monitor robot odometryros2 topic echo /cmd_vel- Monitor velocity commandsrqt_graph- Visualize the node graphrviz2- Visualize robot state and sensor data
Troubleshooting
- If the robot doesn't move, check that
/cmd_velcommands are being published - If Gazebo doesn't start, verify that the Gazebo ROS packages are installed
- If the robot falls through the ground, check the URDF inertial properties
- If the simulation runs too fast/slow, adjust the Gazebo real-time factor
- Use
gz topic -lto list Gazebo topics and verify communication