Enhancing XRP Functionality with a Mecanum Wheel Design
2024-07-24 | By Travis Foss
In working with the XRP recently, I was trying to come up with new ways to try and expand the functionality of this robotics platform. Recently, two things were mentioned on the XRP discourse forum that caught my attention. The first was that all the individual pieces of the frame had been added to Tinkercad. The second was a post where someone had created a mecanum XRP frame that was split in the center and glued together. After seeing the mecanum chassis, I started to wonder if I could put together a frame that could be printed in one single piece on my LulzBot TAZ6 printer. I thought this would be an excellent opportunity to dive into Tinkercad and see what I could come up with. I'm working on designing a robot, and I remembered a frame style for a different type of robot base called the X-Drive style base for Omni wheels. I decided to create two different frames for the XRP: one that uses mecanum wheels and one that is designed to be used with Omni wheels.
The mecanum wheel version was designed to use two sets of these mecanum wheels, 1528-4990-ND. The wheels press-fit directly on the motors that come with the XRP. In order to assemble the mecanum robot, I also needed a second set of motors for the XRP. The only additional piece needed was the frame.
As I mentioned before, the individual pieces of the frame were in Tinkercad, so I was able to assemble those pieces into a frame that is roughly 240mm x 250mm. I was able to fit this frame on the print bed of the LulzBot Taz 6 and was able to print the full frame in 1 piece.
To make the mecanum wheel version drive, it took a bit of work on the software side. To make it easier for you to jump into, I will be including a copy of the newly created files at the end of this write-up. To begin with the software setup, I went into the lib folder on the XRP, saved a copy of differential_drive.py, and called it mecanum_drive.py. In the below picture, I have the new mecanum_drive.py file on the left along with the original differential_drive.py file on the right.
There were a number of changes that needed to be made in the mecanum file. To start, I changed the class name to MecanumDrive on line 9. From there I changed line 11 DIFFERENTIAL to MECANUM as well as the differential to mecanum on line 14. On lines 20 and 21, I changed DIFFERENTIAL to MECANUM. To add the other two drive motors, I had to add them to lines 24 and 25. To do this, I copied line 23 and pasted it twice, then changed the index numbers to 3 and 4. On line 29 I again changed DIFFERENTIAL to MECANUM. On line 31 for the init function, I added motor_three: Encoded Motor, and motor_four: Encoded Motor to the parameters for this function. I then named motor_three and motor_four on lines 49 and 50.
In the set_effort function, I created two new parameters in the input parameters called three_effort and four_effort, which are both defined as float-type variables. From there, I had to set the input applied to three_effort and four_effort to apply to motor_three and motor_four, as shown on lines 68 and 69.
Under the Set speed function, I added a parameter for three_speed and four_speed, which were both defined as floats. From there, I added lines 83 and 85 to set motor_three and motor_four speeds to the set speeds in the variables given.
To the stop function, I added motor_three and motor_four set speeds on lines 93 and 94 so that it turns off all motors when the stop command is given.
For the arcade function, I added lines 111 and 113 for three_speed and four_speed as well as adding these two variables into line 114.
For the reset_encoder_position, I added lines 123 and 124 so that the encoders on motor three and motor four would reset when the function was called.
I then defined functions to get encoder positions from motor 3 (lines 140-145) and motor 4 (lines 147-152).The straight function had a bit more that needed to be added than the previous functions. To start, I added lines 181 and 182 in order to get the starting encoder position. Next, for the left_delta (line 212), I added an average to this math equation by adding the same formula for the motor three encoder and then dividing the equation by two. I then did this same thing to the right side with motor four on line 213. These should give an average of the two motors on each side.
The last section is the turn function. I started by adding lines 270 and 271, which get the starting encoder position. Next, I added the same equation that I had added in the straight function for both the left and right delta on lines 297 and 298. The last change is I had to add to line 315. To this line, I had to add two more parameters to the function to control motors three and four. In this case, I was able to copy the two variables shown and paste them again.
In encoded_motor.py I had to flip motors three and four. Originally motor 4 had the flip_dir variable set to True, and motor 3 didn’t have the variable set. As you can see on line 40, I defined the motor to be directionally flipped. I also removed the flip_dir variable from line 47.
Now that we created the mecanum_drive.py has been created, it needs to be loaded into defaults.py. After taking cues from what was loaded from differential_drive I added line 3 and line 24, which call out the library as well as create a mechanumdrivetrain variable. I also added lines 20 and 21 to add the motors for the 3rd and 4th motor.
The last thing that I did was create a copy of the drive_examples file from the XRP examples folder. I saved a copy of this file and renamed it mecanum_drive_examples.py. Now that there are 4 motors on this drivetrain, I needed to add 4 parameters to each function to give an effort to each motor. I started by adding more values of 0.8 to the drive_straight function on line 11. Looking closer at the drive straight function, I noticed that I was only able to give it a drive time parameter that is passed into that function. Because of that, I was unable to change the effort of each of these motors, so I decided to add a new function called reverse. This function was exactly the same as the drive straight function, however the efforts were set to a -0.8 effort for each wheel. The next thing I decided to add were strafe functions. Strafing is where the robot moves in a straight line 90 degrees to the left or right of the direction it goes during drive straight. To accomplish this, the wheels on the same side will move in opposite directions. For example, to strafe left, the Left motor turns forward where motor 3 turns in reverse, and on the opposite side of the frame the right motor turns in reverse, and motor four turns forward. To Strafe right, that motion is reversed on all 4 motors. For the arc turn, point turn, and swing turn, it was as simple as copying the same effort for the motors on their respective side. The last thing that I added was a reverse point turn, which is the same as the point turn, however, the efforts are reversed so that it turns in a counterclockwise motion.
Now that the mecanum_drive_example is done it was time to test. Initial tests showed that it was having trouble strafing, and it was drifting hard, instead of driving straight. After a bit of digging into the problem, I realized that I had the wheels in the incorrect orientation. The easiest way to describe how they should be set up is that the top tread/roller on the wheel should form a line pointing toward the center of the robot. Once I flipped the wheels around, the control greatly improved.
If you would like to build a version of this frame and robot, here are the files that you would need.
If you choose to copy the code blocks below, save the mecanum_drive.py file into the lib/XRPLib folder. The mecanum_drive_examples.py file can be saved to the root directory. You will still need to make changes to the enoded_motor file as well as the defaults file, however, you can follow the directions above to complete this.
At this point, I have not put together the X-Drive style chassis for the Omni Wheels, however, I’m hoping to get that together soon. The Tinkercad file for both of the frames are located here, https://www.tinkercad.com/things/iPSpwwwZmJ6-xrp-mecanum-and-omni-x-drive?sharecode=t3ELwGq-QeZcXKd346ll2UYqYlW9bNcEh_LxGZe_Ez0. Once I have the X-Drive chassis together, I will put together another write-up on getting that robot up and running. So be on the lookout for that soon.
You can download the python files and XRP mecanum frame STL file by clicking here.
Mecanum_drive.py
from .encoded_motor import EncodedMotor from .imu import IMU from .controller import Controller from .pid import PID from .timeout import Timeout import time import math class MecanumDrive: _DEFAULT_MECANUM_DRIVE_INSTANCE =None @classmethod def get_default_mecanum_drive(cls): """ Get the default XRP v2 differential drive instance. This is a singleton, so only one instance of the drivetrain will ever exist. """ if cls._DEFAULT_MECANUM_DRIVE_INSTANCE is None: cls._DEFAULT_MECANUM_DRIVE_INSTANCE = cls( EncodedMotor.get_default_encoded_motor(index=1), EncodedMotor.get_default_encoded_motor(index=2), EncodedMotor.get_default_encoded_motor(index=3), EncodedMotor.get_default_encoded_motor(index=4), IMU.get_default_imu() ) return cls._DEFAULT_MECANUM_DRIVE_INSTANCE def __init__(self, left_motor: EncodedMotor, right_motor: EncodedMotor,motor_three: EncodedMotor,motor_four: EncodedMotor, imu: IMU = None, wheel_diam:float = 6.0, wheel_track:float = 15.5): """ A Differential Drive class designed for the XRP two-wheeled drive robot. :param leftMotor: The left motor of the drivetrain :type leftMotor: EncodedMotor :param rightMotor: The right motor of the drivetrain :type rightMotor: EncodedMotor :param imu: The IMU of the robot. If None, the robot will not use the IMU for turning or maintaining heading. :type imu: IMU :param wheelDiam: The diameter of the wheels in inches. Defaults to 6 cm. :type wheelDiam: float :param wheelTrack: The distance between the wheels in inches. Defaults to 15.5 cm. :type wheelTrack: float """ self.left_motor = left_motor self.right_motor = right_motor self.motor_three = motor_three self.motor_four = motor_four self.imu = imu self.wheel_diam = wheel_diam self.track_width = wheel_track def set_effort(self, left_effort: float, right_effort: float,three_effort: float,four_effort: float) -> None: """ Set the raw effort of both motors individually :param leftEffort: The power (Bounded from -1 to 1) to set the left motor to. :type leftEffort: float :param rightEffort: The power (Bounded from -1 to 1) to set the right motor to. :type rightEffort: float """ self.left_motor.set_effort(left_effort) self.right_motor.set_effort(right_effort) self.motor_three.set_effort(three_effort) self.motor_four.set_effort(four_effort) def set_speed(self, left_speed: float, right_speed: float, three_speed: float, four_speed: float) -> None: """ Set the speed of both motors individually :param leftSpeed: The speed (In Centimeters per Second) to set the left motor to. :type leftSpeed: float :param rightSpeed: The speed (In Centimeters per Second) to set the right motor to. :type rightSpeed: float """ # Convert from cm/s to RPM cmpsToRPM = 60 / (math.pi * self.wheel_diam) self.left_motor.set_speed(left_speed*cmpsToRPM) self.motor_three.set_speed(three_speed*cmpsToRPM) self.right_motor.set_speed(right_speed*cmpsToRPM) self.motor_four.set_speed(four_speed*cmpsToRPM) def stop(self) -> None: """ Stops both drivetrain motors """ self.left_motor.set_speed() self.right_motor.set_speed() self.motor_three.set_speed() self.motor_four.set_speed() self.set_effort(0,0,0,0) def arcade(self, straight:float, turn:float): """ Sets the raw effort of both motors based on the arcade drive scheme :param straight: The base effort (Bounded from -1 to 1) used to drive forwards or backwards. :type straight: float :param turn: The modifier effort (Bounded from -1 to 1) used to skew robot left (positive) or right (negative). :type turn: float """ if straight == 0 and turn == 0: self.set_effort(0, 0) else: scale = max(abs(straight), abs(turn))/(abs(straight) + abs(turn)) left_speed = (straight - turn)*scale three_speed = (straight - turn)*scale right_speed = (straight + turn)*scale four_speed = (straight + turn)*scale self.set_effort(left_speed, right_speed, three_speed, four_speed) def reset_encoder_position(self) -> None: """ Resets the position of both motors' encoders to 0 """ self.left_motor.reset_encoder_position() self.right_motor.reset_encoder_position() self.motor_three.reset_encoder_position() self.motor_four.reset_encoder_position() def get_left_encoder_position(self) -> float: """ :return: the current position of the left motor's encoder in cm. :rtype: float """ return self.left_motor.get_position()*math.pi*self.wheel_diam def get_right_encoder_position(self) -> float: """ :return: the current position of the right motor's encoder in cm. :rtype: float """ return self.right_motor.get_position()*math.pi*self.wheel_diam def get_three_encoder_position(self) -> float: """ :return: the current position of the right motor's encoder in cm. :rtype: float """ return self.motor_three.get_position()*math.pi*self.wheel_diam def get_four_encoder_position(self) -> float: """ :return: the current position of the right motor's encoder in cm. :rtype: float """ return self.motor_four.get_position()*math.pi*self.wheel_diam def straight(self, distance: float, max_effort: float = 0.5, timeout: float = None, main_controller: Controller = None, secondary_controller: Controller = None) -> bool: """ Go forward the specified distance in centimeters, and exit function when distance has been reached. Max_effort is bounded from -1 (reverse at full speed) to 1 (forward at full speed) :param distance: The distance for the robot to travel (In Centimeters) :type distance: float :param max_effort: The max effort for which the robot to travel (Bounded from -1 to 1). Default is half effort forward :type max_effort: float :param timeout: The amount of time before the robot stops trying to move forward and continues to the next step (In Seconds) :type timeout: float :param main_controller: The main controller, for handling the distance driven forwards :type main_controller: Controller :param secondary_controller: The secondary controller, for correcting heading error that may result during the drive. :type secondary_controller: Controller :return: if the distance was reached before the timeout :rtype: bool """ # ensure effort is always positive while distance could be either positive or negative if max_effort < 0: max_effort *= -1 distance *= -1 time_out = Timeout(timeout) starting_left = self.get_left_encoder_position() starting_right = self.get_right_encoder_position() starting_three = self.get_three_encoder_position() starting_four = self.get_four_encoder_position() if main_controller is None: main_controller = PID( kp = 0.1, ki = 0.04, kd = 0.04, min_output = 0.3, max_output = max_effort, max_integral = 10, tolerance = 0.25, tolerance_count = 3, ) # Secondary controller to keep encoder values in sync if secondary_controller is None: secondary_controller = PID( kp = 0.075, kd=0.001, ) if self.imu is not None: # record current heading to maintain it initial_heading = self.imu.get_yaw() else: initial_heading = 0 while True: # calculate the distance traveled left_delta = (self.get_left_encoder_position() - starting_left) + (self.get_three_encoder_position() - starting_three) / 2 right_delta = (self.get_right_encoder_position() - starting_right) + (self.get_four_encoder_position() - starting_four) / 2 dist_traveled = (left_delta + right_delta) / 2 # PID for distance distance_error = distance - dist_traveled effort = main_controller.update(distance_error) if main_controller.is_done() or time_out.is_done(): break # calculate heading correction if self.imu is not None: # record current heading to maintain it current_heading = self.imu.get_yaw() else: current_heading = ((right_delta-left_delta)/2)*360/(self.track_width*math.pi) headingCorrection = secondary_controller.update(initial_heading - current_heading) self.set_effort(effort - headingCorrection, effort + headingCorrection, effort - headingCorrection, effort + headingCorrection) time.sleep(0.01) self.stop() return not time_out.is_done()
Mecanum_drive_examples
from XRPLib.defaults import * import time """ By the end of this file students will learn how to control the mecanumdrivetrain, both by setting effort values directly to the motors and by using go_straight and go_turn """ # drive straight for a set time period (defualt 1 second) def drive_straight(drive_time: float = 1): mecanumdrivetrain.set_effort(0.8, 0.8, 0.8, 0.8) time.sleep(drive_time) mecanumdrivetrain.stop() time.sleep(.5) def reverse(drive_time: float = 1): mecanumdrivetrain.set_effort(-0.8, -0.8, -0.8, -0.8) time.sleep(drive_time) mecanumdrivetrain.stop() time.sleep(.5) def strafe_left(drive_time: float = 1): mecanumdrivetrain.set_effort(1, -1, -1, 1) time.sleep(drive_time) mecanumdrivetrain.stop() time.sleep(.5) def strafe_right(drive_time: float = 1): mecanumdrivetrain.set_effort(-1, 1, 1, -1) time.sleep(drive_time) mecanumdrivetrain.stop() time.sleep(.5) # drive at a slight counter clockwise arc for a set time period (default 1 second) def arc_turn(turn_time: float = 1): mecanumdrivetrain.set_effort(0.5, 0.8, 0.5, 0.8) time.sleep(turn_time) mecanumdrivetrain.stop() # turn CCW at a point for a set time period (default 1 second) def point_turn(turn_time: float = 1): mecanumdrivetrain.set_effort(1, -1, 1, -1) time.sleep(turn_time) mecanumdrivetrain.stop() # turn CCW at a point for a set time period (default 1 second) def reverse_point_turn(turn_time: float = 1): mecanumdrivetrain.set_effort(-1, 1, -1, 1) time.sleep(turn_time) mecanumdrivetrain.stop() # pivot turn around the left wheel for a set time period (default 1 second) def swing_turn(turn_time: float = 1): mecanumdrivetrain.set_effort(-0.5, 0.8, 0.5, 0.8) time.sleep(turn_time) mecanumdrivetrain.stop() # Driving in a circle by setting a difference in motor efforts def circle(): while True: mecanumdrivetrain.set_effort(0.8, 1, 0.8, 1) # Follow the perimeter of a square with variable sidelength def square(sidelength): for sides in range(4): mecanumdrivetrain.straight(sidelength, 0.8) mecanumdrivetrain.turn(90) # Alternatively: # polygon(sidelength, 4) # Follow the perimeter of an arbitrary polygon with variable side length and number of sides # Side length in centimeters def polygon(side_length, number_of_sides): for s in range(number_of_sides): mecanumdrivetrain.straight(side_length) mecanumdrivetrain.turn(360/number_of_sides) # A slightly longer example program showing how a robot may follow a simple path def test_drive(): servo_one.set_angle(0) time.sleep(.5) print("Driving forward 25 cm") mecanumdrivetrain.straight(25, 0.8,5) time.sleep(.5) print("turn 90 degrees right") #mecanumdrivetrain.turn(90,0.8) point_turn() time.sleep(.5) print("Driving backward 15cm") mecanumdrivetrain.straight(-15, 0.8, 5) time.sleep(.5) servo_one.set_angle(55) time.sleep(.5) print("Driving forward 15cm") mecanumdrivetrain.straight(15, 0.8, 5) time.sleep(.5) print("turn 90 degrees left by setting speed negative") #mecanumdrivetrain.turn(90, -0.8) reverse_point_turn() time.sleep(.5) print("drive backwards 25 cm by setting distance negative") # There is no difference between setting speed or distance negative, both work mecanumdrivetrain.straight(-25,0.8, 5) servo_one.set_angle(0) time.sleep(.5) #table top demo #drive_straight(.25) #point_turn(3) #strafe_left(.5) #strafe_right(.5) #reverse_point_turn(3) #reverse(.25) drive_straight(2) point_turn(3) strafe_left(4) strafe_right(4) reverse_point_turn(3) reverse(2)
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.
Visit TechForum