diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100755 index 0000000000000000000000000000000000000000..8bafa33466036ee9c0c47de39f63a96677b5c0de --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 2.8.3) +project(mecanum_drive) + +find_package(catkin REQUIRED COMPONENTS + rospy + std_msgs + nav_msgs + geometry_msgs + tf + tf2 + actionlib + actionlib_msgs +) + +add_action_files( + DIRECTORY action + FILES GoToPose.action +) + +if (CATKIN_ENABLE_TESTING) + catkin_add_nosetests(tests) +endif() + +if (CATKIN_ENABLE_TESTING) + find_package(rostest REQUIRED) +# add_rostest(tests/your_first_rostest.test) +# add_rostest(tests/your_second_rostest.test) +endif() + +catkin_install_python( + PROGRAMS + nodes/mecanum_drive_odometry + nodes/mecanum_drive_controller + DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}) + +catkin_python_setup() + +generate_messages ( + DEPENDENCIES actionlib_msgs std_msgs geometry_msgs +) + +catkin_package( + CATKIN_DEPENDS actionlib_msgs +) diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000000000000000000000000000000000000..862674582582c9e4963d6537f3e573321111056a --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2016, Mark Rose +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of diff_drive nor the names of its contributors may + be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.asciidoc b/README.asciidoc new file mode 100755 index 0000000000000000000000000000000000000000..54127dc8c084b6f60918efe3f4c5434b1605fd38 --- /dev/null +++ b/README.asciidoc @@ -0,0 +1,234 @@ += diff_drive -- Differential-Drive Controller +:imagesdir: ./images + +This package implements ROS nodes to control and monitor a differential-drive robot. + +The package +is intended as a lighter-weight solution than the ROS controller framework, albeit with lower +performance since it is written in Python. If you need tight, real-time control, you may want +to look at link:http://wiki.ros.org/ros_controllers[ros_controllers], +a C++ package which includes a differential-drive controller that is integrated with +link:http://wiki.ros.org/ros_control[ros_control] and +link:http://wiki.ros.org/controller_manager[controller_manager]. Those controllers are designed +to integrate with hardware in the same process, rather than using topics. Instead, this package +expects to publish the desired motor speeds using standard ROS messages. + +== Supplied Nodes + +* `diff_drive_controller` -- Converts from twist to wheel velocities for motors. +* `diff_drive_odometry` -- Publishes odometry from wheel encoder data. +* `diff_drive_go_to_goal` -- Moves the robot to a goal position. +* `diff_drive_mock_robot` -- Implements a mock differential drive robot, for testing. + +The nodes in this package are designed with these considerations: + +* The node and hardware implementing differential drive should deal only in encoder ticks. +* Conversions to and from physical coordinates should happen within the nodes in this package. +* This package should integrate cleanly with the navigation stack, perhaps with remappings. +* Nodes should use standard topic and parameter names used by the navigation stack, but should allow remapping. + +== Demo + +To see all the nodes in this package in action, you can launch a demo from ROS. There are no +dependencies other than the standard ROS packages. + + roslaunch diff_drive demo.launch + +This launches _rviz_ as part of the demo, and shows the robot position as a small coordinate system on +a 0.25m grid. In rviz you can move the robot by clicking the _2D Nav Goal_ button in the tools panel at the top. +Then click and drag within the grid to set the robot goal position and heading. The mock robot will move to +that new pose, which you can see by the movement of the robot axes. + +In the demo, both forward and backward movement is allowed, so if the goal position is behind the robot, +it will move backward. You can force the robot to move foward only by setting the parameter `~forwardMovementOnly` +to `true`. + +== ROS API + +=== 1. diff_drive_controller + +Listens for desired linear and angular velocity, and publishes corresponding wheel velocities, in encoder ticks per second, required to achieve those velocities. + +==== Published Topics + +`~lwheel_desired_rate` (std_msgs/Int32):: +Desired left wheel rotation rate, in encoder ticks per second. + +`~rwheel_desired_rate` (std_msgs/Int32):: +Desired right wheel rotation rate, in encoder ticks per second. + +==== Subscribed Topics + +`~cmd_vel` (geometry_msgs/Twist):: +Desired linear and angular velocity. + +==== Parameters + +`~ticks_per_meter` (double):: +Number of encoder ticks per meter of travel. + +`~wheel_separation` (double):: +Distance between the two wheels (meters). + +`~rate` (int, default: 50):: +The rate that the output velocity target messages will be published (Hz). + +`~timeout_ticks` (int, default: 2):: +The number of velocity target messages that will be published after the last twist message is received. + +=== 2. diff_drive_odometry + +Listens for wheel movement and rates and publishes the transform between the odom frame and the robot frame. + +==== Published Topics + +`~odom` -- (nav_msgs/Odometry):: +The robot odometry -- the current robot pose. + +`~tf`:: +The transform between the odometry frame and the robot frame. + +==== Subscribed Topics + +`~lwheel_ticks` (std_msgs/Int32):: +Cumulative encoder ticks of the left wheel. + +`~rwheel_ticks` (std_msgs/Int32):: +Cumulative encoder ticks of the right wheel. + +`~lwheel_rate` (std_msgs/Float32):: +Left wheel rotation rate, in encoder ticks per second. + +`~rwheel_rate` (std_msgs/Float32):: +Right wheel rotation rate, in encoder ticks per second. + +==== Parameters + +`~ticks_per_meter` (double):: +Number of encoder ticks per meter of travel. + +`~wheel_separation` (double):: +Distance between the two wheels (m). + +`~rate` (double, default 10.0):: +The rate at which the `tf` and `odom` topics are published (Hz). + +`~timeout` (double, default 0.2):: +The amount of time to continue publishing desired wheel rates after receiving a twist message (seconds). +If set to zero, wheel velocities will be sent only when a new twist message is received. + +`~base_frame_id` (string, default: "base_link"):: +The name of the base frame of the robot. + +`~odom_frame_id` (string, default: "odom"):: +The name of the odometry reference frame. + +`~encoder_min` (int, default: -32768):: + +`~encoder_max` (int, default: 32768):: +The min and max value the encoder should output. Used to calculate odometry when the values wrap around. + +`~wheel_low_wrap` (int, default: 0.3 * (encoder_max - encoder_min + 1) + encoder_min):: + +`~wheel_high_wrap` (int, default: 0.7 * (encoder_max - encoder_min + 1) + encoder_min):: +If a reading is greater than wheel_high_wrap and the next reading is less than wheel_low_wrap, then the reading has wrapped around in the positive direction, and the odometry will be calculated appropriately. The same concept applies for the negative direction. + +=== 3. diff_drive_go_to_goal + +Listens for new goal poses and computes velocities needed to achieve the goal. + +==== Published Topics + +`~distance_to_goal` (std_msgs/Float32):: +Distance to the goal position (meters). + +`~cmd_vel` (geometry_msgs/Twist):: +Desired linear and angular velocity to move toward the goal pose. + +==== Subscribed Topics + +`~goal` (geometry_msgs/Pose):: +Desired goal pose. + +==== Parameters + +`~rate` (float, default: 10):: +Rate at which to publish desired velocities (Hz). + +`~goal_linear_tolerance` (float, default: 0.1):: +The distance from the goal at which the robot is assumed to have accomplished the goal position (meters). + +`~goal_angular_tolerance` (float, default: 0.087):: +The difference between robot angle and goal pose angle at which the robot is assumed to have +accomplished the goal attitude (radians). Default value is approximately 5 degrees. + +`~max_linear_velocity` (float, default: 0.2):: +The maximum linear velocity toward the goal (meters/second). + +`~max_angular_velocity` (float, default: 1.5):: +The maximum angular velocity (radians/second). + +`~max_linear_acceleration` (float, default: 4.0):: +The maximum linear acceleration (meters/second^2). + +`~forwardMovementOnly` (boolean, default: true):: +If true, only forward movement is allowed to achieve the goal position. +If false, the robot will move backward to the goal if that is the most +direct path. + +`~Kp` (float, default: 3.0):: +Linear distance proportionality constant. Higher values make the robot accelerate more quickly toward the goal and decelerate less quickly. + +`~Ka` (float: default: 8.0):: +Proportionality constant for angle to goal position. Higher values make the robot turn more quickly toward the goal. + +`~Kb` (float: default: -1.5):: +Proportionality constant for angle to goal pose direction. Higher values make the robot turn more quickly toward the goal pose direction. This value should be negative, per _Autonomous Mobile Robots_. + +The control law for determining the linear and angular velocity to move toward the goal works as follows. Let _d_ be the distance to the goal. Let _a_ be the angle between the robot heading and the goal position, where left is positive. Let _b_ be the angle between the goal direction and the final pose angle, where left is positive. Then the robot linear and angular velocities are calculated like this: + + v = Kp * d + w = Ka*a + Kb*b + +See _Autonomous Mobile Robots, Second Edition_ by Siegwart et. al., section 3.6.2.4. In this code, when the robot +is near enough to the goal, _v_ is set to zero, and _w_ is simply _Kb*b_. + +To ensure convergence toward the goal, _K~p~_ and _K~a~_ must be positive, _K~b~_ must be negative, and _K~a~_ +must be greater than _K~p~_. To ensure robust convergence, so that the robot never changes direction, +_K~a~_ - 5/3*_K~b~_ - 2/pi*_K~p~_ must be greater than zero. + +=== 4. diff_drive_mock_robot + +Implements a simulation of perfect differential drive robot hardware. It immediately follows any speed +commands received with infinite acceleration, and publishes the wheel encoder values and encoder +rates. + +==== Published Topics + +`~lwheel_ticks` (std_msgs/Int32):: +Cumulative encoder ticks of the left wheel. + +`~rwheel_ticks` (std_msgs/Int32):: +Cumulative encoder ticks of the right wheel. + +`~lwheel_rate` (std_msgs/Float32):: +Left wheel rotation rate, in encoder ticks per second. + +`~rwheel_rate` (std_msgs/Float32):: +Right wheel rotation rate, in encoder ticks per second. + +==== Subscribed Topics + +`~lwheel_desired_rate` (std_msgs/Int32):: +Desired left wheel rotation rate, in encoder ticks per second. + +`~rwheel_desired_rate` (std_msgs/Int32):: +Desired right wheel rotation rate, in encoder ticks per second. + +==== Parameters + +`~cmd_timeout` (float, default: 0.2):: +The amount of time after the last wheel rate message when the robot should stop automatically (seconds). + +`~rate` (float, default 10.0):: +The rate at which the simulated wheel encoder values and rates should be published (Hz). diff --git a/action/GoToPose.action b/action/GoToPose.action new file mode 100755 index 0000000000000000000000000000000000000000..1cf9b79dcffbc9cd9585562fd9d78351998b542b --- /dev/null +++ b/action/GoToPose.action @@ -0,0 +1,9 @@ +# goal definition +geometry_msgs/PoseStamped pose +--- +# result definition +bool success +--- +# feedback +bool processing + diff --git a/launch/demo.launch b/launch/demo.launch new file mode 100755 index 0000000000000000000000000000000000000000..60711d53224ea1a1b42680d332a897122523aef8 --- /dev/null +++ b/launch/demo.launch @@ -0,0 +1,68 @@ +<launch> + + <arg name="ticks_per_meter" value="10000" /> + <arg name="wheel_separation" value="0.2" /> + + <arg name="urdf_file" default="$(find diff_drive)/urdf/diff_drive_description.xacro" /> + <param name="robot_description" command="$(find xacro)/xacro --inorder $(arg urdf_file)" /> + + <!-- Publish the robot state --> + <node name="robot_state_publisher" pkg="robot_state_publisher" + type="robot_state_publisher"> + <param name="publish_frequency" value="10.0"/> + </node> + + <!-- Provide simulated control of the robot joint angles --> + <node name="joint_state_publisher" pkg="joint_state_publisher" + type="joint_state_publisher"> + <param name="use_gui" value="False" /> + <param name="rate" value="10.0"/> + </node> + + <node name="controller" pkg="diff_drive" type="diff_drive_controller" + output="screen"> + <rosparam subst_value="true"> + ticks_per_meter: $(arg ticks_per_meter) + wheel_separation: $(arg wheel_separation) + max_motor_speed: 3000 + timeout: 1.0 + </rosparam> + <remap from="cmd_vel" to="/robot/cmd_vel" /> + </node> + + <node name="odom_publisher" pkg="diff_drive" type="diff_drive_odometry" + output="screen"> + <rosparam subst_value="true"> + ticks_per_meter: $(arg ticks_per_meter) + wheel_separation: $(arg wheel_separation) + </rosparam> + </node> + + <node name="robot" pkg="diff_drive" type="diff_drive_mock_robot" + output="screen"> + <remap from="~lwheel_desired_rate" to="lwheel_desired_rate" /> + <remap from="~rwheel_desired_rate" to="rwheel_desired_rate" /> + <remap from="~lwheel_ticks" to="lwheel_ticks" /> + <remap from="~rwheel_ticks" to="rwheel_ticks" /> + </node> + + <node name="diff_drive_go_to_goal" pkg="diff_drive" + type="diff_drive_go_to_goal" output="screen"> + <param name="~rate" value="20" /> + <param name="~kP" value="0.5" /> + <param name="~kA" value="1.0" /> + <param name="~kB" value="-0.8" /> + <param name="~max_linear_speed" value="0.2" /> + <param name="~min_linear_speed" value="0.05" /> + <param name="~max_angular_speed" value="0.7" /> + <param name="~min_angular_speed" value="0.1" /> + <param name="~linear_tolerance" value="0.01" /> + <param name="~angular_tolerance" value="0.04" /> + <param name="~forwardMovementOnly" value="false" /> + <remap from="cmd_vel" to="/robot/cmd_vel" /> + </node> + + <node name="rviz" pkg="rviz" type="rviz" + args="-d $(find diff_drive)/config/view.rviz" /> + +</launch> diff --git a/launch/revvy-config.launch b/launch/revvy-config.launch new file mode 100755 index 0000000000000000000000000000000000000000..39ab4befdad8ccfdd5b6228b3817567be368e9eb --- /dev/null +++ b/launch/revvy-config.launch @@ -0,0 +1,42 @@ +<?xml version="1.0"?> +<launch> + + <arg name="ticks_per_meter" value="1800" /> + <arg name="wheel_separation" value="0.1" /> + + <arg name="urdf_file" default="$(find diff_drive)/urdf/diff_drive_description.xacro" /> + <param name="robot_description" command="$(find xacro)/xacro --inorder $(arg urdf_file)" /> + + <!-- Publish the robot state --> + <node name="robot_state_publisher" pkg="robot_state_publisher" + type="robot_state_publisher"> + <param name="publish_frequency" value="10.0"/> + </node> + + <!-- Provide simulated control of the robot joint angles --> + <node name="joint_state_publisher" pkg="joint_state_publisher" + type="joint_state_publisher"> + <param name="use_gui" value="False" /> + <param name="rate" value="10.0"/> + </node> + + <node name="controller" pkg="diff_drive" type="diff_drive_controller" + output="screen"> + <rosparam subst_value="true"> + ticks_per_meter: $(arg ticks_per_meter) + wheel_separation: $(arg wheel_separation) + max_motor_speed: 3000 + timeout: 1.0 + </rosparam> + </node> + + <node name="odom_publisher" pkg="diff_drive" type="diff_drive_odometry" + output="screen"> + <rosparam subst_value="true"> + ticks_per_meter: $(arg ticks_per_meter) + wheel_separation: $(arg wheel_separation) + </rosparam> + </node> + + +</launch> diff --git a/nodes/mecanum_drive_controller b/nodes/mecanum_drive_controller new file mode 100755 index 0000000000000000000000000000000000000000..d788a3a99bbab788a3072276e58201e9822ee826 --- /dev/null +++ b/nodes/mecanum_drive_controller @@ -0,0 +1,68 @@ +#! /usr/bin/env python +from __future__ import division + +import rospy +from geometry_msgs.msg import Twist +from std_msgs.msg import Int16, Int16MultiArray + +from mecanum_drive import controller + +class ControllerNode: + + def __init__(self): + self.controller = controller.Controller() + self.linearXVelocity = 0.0 + self.linearYVelocity = 0.0 + self.angularVelocity = 0.0 + self.wheels_to_send = Int16MultiArray() + + def main(self): + self.wheelPub = rospy.Publisher('wheels_desired_rate', + Int16MultiArray, queue_size=1) + + rospy.init_node('mecanum_drive_controller') + self.nodeName = rospy.get_name() + rospy.loginfo("{0} started".format(self.nodeName)) + + rospy.Subscriber("cmd_vel", Twist, self.twistCallback) + + self.ticksPerMeter = float(rospy.get_param('~ticks_per_meter')) + self.wheelSeparation = float(rospy.get_param('~wheel_separation')) + self.maxMotorSpeed = int(rospy.get_param('~max_motor_speed')) + self.rate = float(rospy.get_param('~rate', 10.0)) + self.timeout = float(rospy.get_param('~timeout', 0.2)) + + self.controller.setWheelSeparation(self.wheelSeparation) + self.controller.setTicksPerMeter(self.ticksPerMeter) + self.controller.setMaxMotorSpeed(self.maxMotorSpeed) + + rate = rospy.Rate(self.rate) + self.lastTwistTime = rospy.get_time() + while not rospy.is_shutdown(): + self.publish() + rate.sleep() + + def publish(self): + if rospy.get_time() - self.lastTwistTime < self.timeout: + speeds = self.controller.getSpeeds(self.linearXVelocity,self.linearYVelocity, + self.angularVelocity) + + self.wheels_to_send.data = [int(speeds.frontLeft), int(speeds.frontRight), int(speeds.rearLeft), int(speeds.rearRight)] + self.wheelPub.publish(self.wheels_to_send) + else: + self.wheels_to_send.data = [0, 0, 0, 0] + self.wheelPub.publish(self.wheels_to_send) + + def twistCallback(self, twist): + self.linearXVelocity = twist.linear.x + self.linearYVelocity = twist.linear.y + self.angularVelocity = twist.angular.z + # print("%d,%d" % (self.linearVelocity, self.angularVelocity)) + self.lastTwistTime = rospy.get_time() + +if __name__ == '__main__': + try: + node = ControllerNode() + node.main() + except rospy.ROSInterruptException: + pass diff --git a/nodes/mecanum_drive_odometry b/nodes/mecanum_drive_odometry new file mode 100755 index 0000000000000000000000000000000000000000..74c05acdf94e21a525144426e5daf03c296ab5b0 --- /dev/null +++ b/nodes/mecanum_drive_odometry @@ -0,0 +1,123 @@ +#! /usr/bin/env python +from __future__ import division + +import rospy +from geometry_msgs.msg import Quaternion +from geometry_msgs.msg import Twist +from nav_msgs.msg import Odometry +from std_msgs.msg import Int32 +from geometry_msgs.msg import PoseWithCovarianceStamped +from tf.broadcaster import TransformBroadcaster +from tf.transformations import quaternion_from_euler, euler_from_quaternion +from math import sin, cos +from mecanum_drive.pose import Pose +from mecanum_drive import odometry + +ODOM_POSE_COVARIANCE = [1e-3, 0, 0, 0, 0, 0, + 0, 1e-3, 0, 0, 0, 0, + 0, 0, 1e6, 0, 0, 0, + 0, 0, 0, 1e6, 0, 0, + 0, 0, 0, 0, 1e6, 0, + 0, 0, 0, 0, 0, 1e3] + +ODOM_TWIST_COVARIANCE = [1e-3, 0, 0, 0, 0, 0, + 0, 1e-3, 0, 0, 0, 0, + 0, 0, 1e6, 0, 0, 0, + 0, 0, 0, 1e6, 0, 0, + 0, 0, 0, 0, 1e6, 0, + 0, 0, 0, 0, 0, 1e3] + +class OdometryNode: + + def __init__(self): + self.odometry = odometry.Odometry() + + def main(self): + self.odomPub = rospy.Publisher('odom', Odometry, queue_size=10) + self.tfPub = TransformBroadcaster() + + rospy.init_node('mecanum_drive_odometry') + self.nodeName = rospy.get_name() + rospy.loginfo("{0} started".format(self.nodeName)) + + rospy.Subscriber("lwheel_ticks", Int32, self.leftCallback) + rospy.Subscriber("rwheel_ticks", Int32, self.rightCallback) + rospy.Subscriber("initialpose", PoseWithCovarianceStamped, + self.on_initial_pose) + + self.ticksPerMeter = int(rospy.get_param('~ticks_per_meter')) + self.wheelSeparation = float(rospy.get_param('~wheel_separation')) + self.rate = float(rospy.get_param('~rate', 20.0)) + self.baseFrameID = rospy.get_param('~base_frame_id', 'base_link') + self.odomFrameID = rospy.get_param('~odom_frame_id', 'odom') + self.encoderMin = int(rospy.get_param('~encoder_min', -32768)) + self.encoderMax = int(rospy.get_param('~encoder_max', 32767)) + + self.odometry.setWheelSeparation(self.wheelSeparation) + self.odometry.setTicksPerMeter(self.ticksPerMeter) + self.odometry.setEncoderRange(self.encoderMin, self.encoderMax) + self.odometry.setTime(rospy.get_time()) + + rate = rospy.Rate(self.rate) + while not rospy.is_shutdown(): + self.publish() + rate.sleep() + + def publish(self): + self.odometry.updatePose(rospy.get_time()) + now = rospy.get_rostime() + pose = self.odometry.getPose() + + q = quaternion_from_euler(0, 0, pose.theta) + self.tfPub.sendTransform( + (pose.x, pose.y, 0), + (q[0], q[1], q[2], q[3]), + now, + self.baseFrameID, + self.odomFrameID + ) + + odom = Odometry() + odom.header.stamp = now + odom.header.frame_id = self.odomFrameID + odom.child_frame_id = self.baseFrameID + odom.pose.pose.position.x = pose.x + odom.pose.pose.position.y = pose.y + odom.pose.pose.orientation.x = q[0] + odom.pose.pose.orientation.y = q[1] + odom.pose.pose.orientation.z = q[2] + odom.pose.pose.orientation.w = q[3] + odom.twist.twist.linear.x = pose.xVel + odom.twist.twist.angular.z = pose.thetaVel + odom.pose.covariance = ODOM_POSE_COVARIANCE + odom.twist.covariance = ODOM_TWIST_COVARIANCE + self.odomPub.publish(odom) + + def on_initial_pose(self, msg): + q = [msg.pose.pose.orientation.x, + msg.pose.pose.orientation.x, + msg.pose.pose.orientation.x, + msg.pose.pose.orientation.w] + roll, pitch, yaw = euler_from_quaternion(q) + + pose = Pose() + pose.x = msg.pose.pose.position.x + pose.y = msg.pose.pose.position.y + pose.theta = yaw + + rospy.loginfo('Setting initial pose to %s', pose) + self.odometry.setPose(pose) + + def leftCallback(self, msg): + self.odometry.updateLeftWheel(msg.data) + + def rightCallback(self, msg): + self.odometry.updateRightWheel(msg.data) + + +if __name__ == '__main__': + try: + node = OdometryNode() + node.main() + except rospy.ROSInterruptException: + pass diff --git a/package.xml b/package.xml new file mode 100755 index 0000000000000000000000000000000000000000..eb225cf173f90849fb51c3197aeb674268445ae2 --- /dev/null +++ b/package.xml @@ -0,0 +1,22 @@ +<?xml version="1.0" encoding="utf-8"?> +<package format="2"> + <name>mecanum_drive</name> + <version>1.0.0</version> + <description> + This package implements a control mechanism for a mecanum drive robot. + </description> + <author>David Dudas</author> + <maintainer email="david.dudas@outlook.com">David Dudas</maintainer> + <url type="website">https://github.com/dudasdavid/mecanum_drive</url> + <url type="repository">https://github.com/dudasdavid/mecanum_drive.git</url> + <url type="bugtracker">https://github.com/dudasdavid/mecanum_drive/issues</url> + <license>BSD</license> + <buildtool_depend>catkin</buildtool_depend> + + <depend>std_msgs</depend> + <depend>geometry_msgs</depend> + <depend>actionlib_msgs</depend> + <build_depend>rostest</build_depend> + <depend>message_generation</depend> + +</package> diff --git a/setup.py b/setup.py new file mode 100755 index 0000000000000000000000000000000000000000..dec9110728116c31ae11b3a5825cea46fbb1c708 --- /dev/null +++ b/setup.py @@ -0,0 +1,12 @@ +## ! DO NOT MANUALLY INVOKE THIS setup.py, USE CATKIN INSTEAD + +from distutils.core import setup +from catkin_pkg.python_setup import generate_distutils_setup + +# fetch values from package.xml +setup_args = generate_distutils_setup( + packages=['mecanum_drive'], + package_dir={'': 'src'}) + +setup(**setup_args) + diff --git a/src/mecanum_drive/__init__.py b/src/mecanum_drive/__init__.py new file mode 100755 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/mecanum_drive/controller.py b/src/mecanum_drive/controller.py new file mode 100755 index 0000000000000000000000000000000000000000..c7f8be49b3d6b5a9a468b679e6373aee1fa8ef46 --- /dev/null +++ b/src/mecanum_drive/controller.py @@ -0,0 +1,62 @@ +from __future__ import division + +class MotorCommand: + """Holds motor control commands for a differential-drive robot. + """ + + def __init__(self): + self.frontLeft = 0 + self.frontRight = 0 + self.rearLeft = 0 + self.rearRight = 0 + + +class Controller: + """Determines motor speeds to accomplish a desired motion. + """ + + def __init__(self): + # Set the max motor speed to a very large value so that it + # is, essentially, unbound. + self.maxMotorSpeed = 10000 # ticks/s + + def getSpeeds(self, linearXSpeed, linearYSpeed, angularSpeed): + + + # print(linearXSpeed,linearYSpeed,angularSpeed) + + WHEEL_SEPARATION_WIDTH = 0.1 + WHEEL_SEPARATION_LENGTH = 0.13 + + speeds = MotorCommand() + + speeds.frontLeft = self.ticksPerMeter * (linearXSpeed - linearYSpeed - (WHEEL_SEPARATION_WIDTH + WHEEL_SEPARATION_LENGTH)*angularSpeed) + speeds.frontRight = self.ticksPerMeter * (linearXSpeed + linearYSpeed + (WHEEL_SEPARATION_WIDTH + WHEEL_SEPARATION_LENGTH)*angularSpeed) + speeds.rearLeft = self.ticksPerMeter * (linearXSpeed + linearYSpeed - (WHEEL_SEPARATION_WIDTH + WHEEL_SEPARATION_LENGTH)*angularSpeed) + speeds.rearRight = self.ticksPerMeter * (linearXSpeed - linearYSpeed + (WHEEL_SEPARATION_WIDTH + WHEEL_SEPARATION_LENGTH)*angularSpeed) + + # print(speeds.frontLeft, speeds.frontRight, speeds.rearLeft, speeds.rearRight) + + + # Adjust speeds if they exceed the maximum. + if max(speeds.frontLeft, speeds.frontRight, speeds.rearLeft, speeds.rearRight) > self.maxMotorSpeed: + factor = self.maxMotorSpeed / max(speeds.frontLeft, speeds.frontRight, speeds.rearLeft, speeds.rearRight) + speeds.frontLeft *= factor + speeds.frontRight *= factor + speeds.rearLeft *= factor + speeds.rearRight *= factor + + speeds.frontLeft = int(speeds.frontLeft) + speeds.frontRight = int(speeds.frontRight) + speeds.rearLeft = int(speeds.rearLeft) + speeds.rearRight = int(speeds.rearRight) + return speeds + + def setWheelSeparation(self, separation): + self.wheelSeparation = separation + + def setMaxMotorSpeed(self, limit): + self.maxMotorSpeed = limit + + def setTicksPerMeter(self, ticks): + self.ticksPerMeter = ticks diff --git a/src/mecanum_drive/controller.pyc b/src/mecanum_drive/controller.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf6c72d36464af76c9709cda0465ab1b6a10f0cd Binary files /dev/null and b/src/mecanum_drive/controller.pyc differ diff --git a/src/mecanum_drive/encoder.py b/src/mecanum_drive/encoder.py new file mode 100755 index 0000000000000000000000000000000000000000..d99e59728b26f88e7844fedfcbf7f0a6aad0e65f --- /dev/null +++ b/src/mecanum_drive/encoder.py @@ -0,0 +1,52 @@ +from __future__ import division + + +class Encoder: + """Monitors a single wheel encoder and accumulates delta ticks + since the last time they were requested. + """ + + def __init__(self): + self.setRange(-32768, 32767) + self.initCount(0) + self.isReversed = False + + def setRange(self, low, high): + self.range = high - low + 1 + self.lowThresh = low + self.range*30//100 + self.highThresh = low + self.range*70//100 + + def initCount(self, startCount): + self.delta = 0 + self.last = startCount + + def update(self, newCount): + if self.last > self.highThresh and newCount < self.lowThresh: + # Wrapped around the upper limit + increment = newCount + self.range - self.last + elif self.last < self.lowThresh and newCount > self.highThresh: + # Wrapped around the lower limit + increment = newCount - self.range - self.last + else: + increment = newCount - self.last + + self.delta += increment + self.last = newCount + + def setReversed(self, isReversed): + self.isReversed = isReversed + + def getDelta(self): + delta = self.delta + self.delta = 0 + if self.isReversed: + return -delta + else: + return delta + + def getLimits(self): + return { + 'range': self.range, + 'lowThresh': self.lowThresh, + 'highThresh': self.highThresh + } diff --git a/src/mecanum_drive/encoder.pyc b/src/mecanum_drive/encoder.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b0ef9b08957448661a87ccd075d53fc9a4f8427 Binary files /dev/null and b/src/mecanum_drive/encoder.pyc differ diff --git a/src/mecanum_drive/odometry.py b/src/mecanum_drive/odometry.py new file mode 100755 index 0000000000000000000000000000000000000000..5e36026423cfc54d2e014924fb066392098b218b --- /dev/null +++ b/src/mecanum_drive/odometry.py @@ -0,0 +1,79 @@ +from __future__ import division +from math import pi, sin, cos +from mecanum_drive.encoder import Encoder +from mecanum_drive.pose import Pose + +class Odometry: + """Keeps track of the current position and velocity of a + robot using differential drive. + """ + + def __init__(self): + self.leftEncoder = Encoder() + self.rightEncoder = Encoder() + self.pose = Pose() + self.lastTime = 0 + + def setWheelSeparation(self, separation): + self.wheelSeparation = separation + + def setTicksPerMeter(self, ticks): + self.ticksPerMeter = ticks + + def setEncoderRange(self, low, high): + self.leftEncoder.setRange(low, high) + self.rightEncoder.setRange(low, high) + + def setTime(self, newTime): + self.lastTime = newTime + + def updateLeftWheel(self, newCount): + self.leftEncoder.update(newCount) + + def updateRightWheel(self, newCount): + self.rightEncoder.update(newCount) + + def updatePose(self, newTime): + """Updates the pose based on the accumulated encoder ticks + of the two wheels. See https://chess.eecs.berkeley.edu/eecs149/documentation/differentialDrive.pdf + for details. + """ + leftTravel = self.leftEncoder.getDelta() / self.ticksPerMeter + rightTravel = self.rightEncoder.getDelta() / self.ticksPerMeter + deltaTime = newTime - self.lastTime + + deltaTravel = (rightTravel + leftTravel) / 2 + deltaTheta = (rightTravel - leftTravel) / self.wheelSeparation + + if rightTravel == leftTravel: + deltaX = leftTravel*cos(self.pose.theta) + deltaY = leftTravel*sin(self.pose.theta) + else: + radius = deltaTravel / deltaTheta + + # Find the instantaneous center of curvature (ICC). + iccX = self.pose.x - radius*sin(self.pose.theta) + iccY = self.pose.y + radius*cos(self.pose.theta) + + deltaX = cos(deltaTheta)*(self.pose.x - iccX) \ + - sin(deltaTheta)*(self.pose.y - iccY) \ + + iccX - self.pose.x + + deltaY = sin(deltaTheta)*(self.pose.x - iccX) \ + + cos(deltaTheta)*(self.pose.y - iccY) \ + + iccY - self.pose.y + + self.pose.x += deltaX + self.pose.y += deltaY + self.pose.theta = (self.pose.theta + deltaTheta) % (2*pi) + self.pose.xVel = deltaTravel / deltaTime if deltaTime > 0 else 0. + self.pose.yVel = 0 + self.pose.thetaVel = deltaTheta / deltaTime if deltaTime > 0 else 0. + + self.lastTime = newTime + + def getPose(self): + return self.pose; + + def setPose(self, newPose): + self.pose = newPose diff --git a/src/mecanum_drive/odometry.pyc b/src/mecanum_drive/odometry.pyc new file mode 100644 index 0000000000000000000000000000000000000000..905fe8fea72ed0ae9e457a22a2305313fa948a06 Binary files /dev/null and b/src/mecanum_drive/odometry.pyc differ diff --git a/src/mecanum_drive/pose.py b/src/mecanum_drive/pose.py new file mode 100755 index 0000000000000000000000000000000000000000..a9b7f3697dee2122132df2c6f4ce0d209103a63a --- /dev/null +++ b/src/mecanum_drive/pose.py @@ -0,0 +1,17 @@ +from __future__ import division + +class Pose: + + def __init__(self): + self.x = 0 + self.y = 0 + self.theta = 0 + self.xVel = 0 + self.yVel = 0 + self.thetaVel = 0 + + + def __str__(self): + return str({'x': self.x, 'y': self.y, 'theta': self.theta, + 'xVel': self.xVel, 'yVel': self.yVel, + 'thetaVel': self.thetaVel}) diff --git a/src/mecanum_drive/pose.pyc b/src/mecanum_drive/pose.pyc new file mode 100644 index 0000000000000000000000000000000000000000..01e65ddc0f3632bb9b99143f2296020fce3c8e1f Binary files /dev/null and b/src/mecanum_drive/pose.pyc differ diff --git a/tests/test_controller.py b/tests/test_controller.py new file mode 100755 index 0000000000000000000000000000000000000000..29c67f7c72169b0e3f44334101f5e35f5911be3d --- /dev/null +++ b/tests/test_controller.py @@ -0,0 +1,66 @@ +from __future__ import division +PKG='test_controller' + +import unittest +from diff_drive.controller import Controller + + +class TestController(unittest.TestCase): + + def setUp(self): + self.ticksPerMeter = 10000 + self.wheelSeparation = 0.1 + self.controller = Controller() + self.controller.setTicksPerMeter(self.ticksPerMeter) + self.controller.setWheelSeparation(self.wheelSeparation) + + def testStraightForward(self): + speeds = self.controller.getSpeeds(0.1, 0) + self.assertAlmostEqual(speeds.left, self.ticksPerMeter*0.1) + self.assertAlmostEqual(speeds.right, self.ticksPerMeter*0.1) + + def testStraightBackward(self): + speeds = self.controller.getSpeeds(-0.1, 0) + self.assertAlmostEqual(speeds.left, -self.ticksPerMeter*0.1) + self.assertAlmostEqual(speeds.right, -self.ticksPerMeter*0.1) + + def testRotateLeft(self): + speeds = self.controller.getSpeeds(0, 1) + diffTicks = self.wheelSeparation * self.ticksPerMeter + self.assertAlmostEqual(speeds.left, -diffTicks) + self.assertAlmostEqual(speeds.right, diffTicks) + + def testRotateRight(self): + speeds = self.controller.getSpeeds(0, -1) + diffTicks = self.wheelSeparation * self.ticksPerMeter + self.assertAlmostEqual(speeds.left, diffTicks) + self.assertAlmostEqual(speeds.right, -diffTicks) + + def testCurveLeft(self): + speeds = self.controller.getSpeeds(0.1, 1) + aheadTicks = 0.1 * self.ticksPerMeter + diffTicks = self.wheelSeparation * self.ticksPerMeter + self.assertAlmostEqual(speeds.left, aheadTicks-diffTicks) + self.assertAlmostEqual(speeds.right, aheadTicks+diffTicks) + + def testMotorLimitsStraight(self): + maxTickSpeed = self.ticksPerMeter // 4 + self.controller.setMaxMotorSpeed(maxTickSpeed) + speeds = self.controller.getSpeeds(1, 0) + self.assertEqual(speeds.left, maxTickSpeed) + self.assertEqual(speeds.right, maxTickSpeed) + + def testMotorLimitsCurved(self): + maxTickSpeed = self.ticksPerMeter // 4 + self.controller.setMaxMotorSpeed(maxTickSpeed) + speeds = self.controller.getSpeeds(1, 1) + aheadTicks = self.ticksPerMeter + diffTicks = self.wheelSeparation * self.ticksPerMeter + factor = maxTickSpeed / (aheadTicks + diffTicks) + self.assertEqual(speeds.left, + int((aheadTicks-diffTicks) * factor)) + self.assertEqual(speeds.right, maxTickSpeed) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_encoder.py b/tests/test_encoder.py new file mode 100755 index 0000000000000000000000000000000000000000..5923d171aeb3c6134490c7bf61064eaca5f7abc7 --- /dev/null +++ b/tests/test_encoder.py @@ -0,0 +1,62 @@ +#! /usr/bin/env python +from __future__ import division +PKG='test_encoder' + +import unittest +from diff_drive.encoder import Encoder + + +class TestEncoder(unittest.TestCase): + + def setUp(self): + self.encoder = Encoder() + + def testInitialization(self): + self.assertEquals(self.encoder.getDelta(), 0) + + def testClearedDelta(self): + self.encoder.update(100) + self.assertEquals(self.encoder.getDelta(), 100) + self.assertEquals(self.encoder.getDelta(), 0) + + def testIncrement(self): + self.encoder.update(100) + self.assertEquals(self.encoder.getDelta(), 100) + + self.encoder.update(50) + self.assertEquals(self.encoder.getDelta(), -50) + + def testWraparound(self): + defaultRange = 32767 - (-32768) + 1 + self.encoder.update(20000) + self.assertEquals(self.encoder.getDelta(), 20000) + + # Wrap around the high end. + self.encoder.update(-20000) + self.assertEquals(self.encoder.getDelta(), + (-20000) + defaultRange - 20000) + + # Wrap around the low end. + self.encoder.update(20000) + self.assertEquals(self.encoder.getDelta(), + 20000 - defaultRange - (-20000)) + + def testCustomRange(self): + self.encoder.setRange(0, 999) + self.encoder.update(500) + self.assertEquals(self.encoder.getDelta(), 500) + + self.encoder.update(900) + self.assertEquals(self.encoder.getDelta(), 400) + + # Wrap around the high end. + self.encoder.update(100) + self.assertEquals(self.encoder.getDelta(), 200) + + # Wrap around the low end. + self.encoder.update(900) + self.assertEquals(self.encoder.getDelta(), -200) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_odometry.py b/tests/test_odometry.py new file mode 100755 index 0000000000000000000000000000000000000000..7f33b9975fa511a32aff21d71bc7708ace753000 --- /dev/null +++ b/tests/test_odometry.py @@ -0,0 +1,107 @@ +#! /usr/bin/env python +from __future__ import division +PKG='test_odometry' + +from math import pi, sin, cos +import unittest +from diff_drive.odometry import Odometry + + +class TestOdometry(unittest.TestCase): + + def setUp(self): + self.wheelSeparation = 0.10 + self.ticksPerMeter = 10000 + self.odom = Odometry() + self.odom.setWheelSeparation(self.wheelSeparation) + self.odom.setTicksPerMeter(self.ticksPerMeter) + + def testInitialization(self): + pose = self.odom.getPose() + self.assertEquals(pose.x, 0) + self.assertEquals(pose.y, 0) + self.assertEquals(pose.theta, 0) + self.assertEquals(pose.xVel, 0) + self.assertEquals(pose.yVel, 0) + self.assertEquals(pose.thetaVel, 0) + + def testTravelForward(self): + self.checkUpdate(10000, 10000, 2, {'x': 1, 'xVel': 1/2}) + + def testSpinLeft(self): + angle = (200/self.ticksPerMeter / self.wheelSeparation) % (2*pi) + self.checkUpdate(-100, 100, 2, + {'theta': angle, 'thetaVel': angle/2}) + + def testSpinRight(self): + angle = (200/self.ticksPerMeter / self.wheelSeparation) % (2*pi) + self.checkUpdate(100, -100, 2, + {'theta': (-angle) % (2*pi), + 'thetaVel': -angle/2}) + + def testCurveLeft(self): + radius = self.wheelSeparation / 2 + angle = pi + s = angle * self.wheelSeparation + ticks = int(s * self.ticksPerMeter) + self.checkUpdate(0, ticks, 2, + {'x': 0, + 'y': 2*radius, + 'theta': angle, + 'xVel': s/4, + 'yVel': 0, + 'thetaVel': angle/2}) + + def testCurveRight(self): + radius = self.wheelSeparation / 2 + angle = pi + s = angle * self.wheelSeparation + ticks = int(s * self.ticksPerMeter) + self.checkUpdate(ticks, 0, 2, + {'x': 0, + 'y': -2*radius, + 'theta': -angle, + 'xVel': s/4, + 'yVel': 0, + 'thetaVel': -angle/2}) + + def testCircle(self): + self.checkCircleRight(8, 9) + self.checkCircleRight(0, 100) + self.checkCircleRight(100, 0) + + def checkCircle(self, vr, vl): + radius = abs(self.wheelSeparation/2 * (vr+vl)/(vr-vl)) + circumference = 2*pi*radius + deltaTravel = (vr+vl)/2 * self.ticksPerMeter; + for i in range(int(circumference/deltaTravel)): + self.odom.updateLeftWheel(vl) + self.odom.updateRightWheel(vr) + self.odom.updatePose(i+1) + self.checkPose(self.odom.getPose(), {'x': 0, 'y': 0}) + + def checkUpdate(self, leftTicks, rightTicks, deltaTime, attrs): + self.odom.updateLeftWheel(leftTicks) + self.odom.updateRightWheel(rightTicks) + self.odom.updatePose(deltaTime) + self.checkPose(self.odom.getPose(), attrs) + + def checkPose(self, pose, attrs): + for key in ['x', 'y', 'theta', 'xVel', 'yVel', 'thetaVel']: + if key in attrs: + self.assertClose( + getattr(pose, key), attrs[key], + msg="{0}: {1}!={2}".format(key, + getattr(pose, key), + attrs[key])) + else: + self.assertEquals(getattr(pose, key), 0, key) + + def assertClose(self, x, y, msg): + if y == 0: + self.assertLess(abs(x), 0.0001, msg) + else: + self.assertLess(abs(x-y)/y, 0.001, msg) + +if __name__ == '__main__': + unittest.main()