Skip to content

Commit 980eb08

Browse files
authored
Merge pull request #132 from Achllle/backport_odom_foxy
Backport odometry to foxy-devel and other ROS2 related fixes
2 parents 40f88e7 + 00cf541 commit 980eb08

File tree

13 files changed

+187
-42
lines changed

13 files changed

+187
-42
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
*.pyc
22
.vscode
33
ROS/osr_bringup/config/*_mod.yaml
4+
*.sublime-project
5+
*.sublime-workspace

ROS/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
There are 4 catkin packages contained in this repo. Each of these packages performs a specific purpose in the ROS
66
structure, which are covered below
77

8-
* `osr`: core code that talks to motor drivers and listens to commands
9-
* `osr_msgs`: custom message definitions
8+
* `osr_control`: core code that talks to motor drivers and listens to commands
9+
* `osr_interfaces`: custom message definitions
1010
* `osr_bringup`: configuration and launch files for starting the rover
1111
* `led_screen`: code to communicate to the Arduino Uno to run the LED screen
1212

ROS/osr_bringup/launch/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
osr_mod.launch

ROS/osr_bringup/launch/osr_launch.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import os
22

33
from launch import LaunchDescription
4+
from launch.actions import DeclareLaunchArgument
5+
from launch.substitutions import LaunchConfiguration
46
from launch_ros.actions import Node
57
from ament_index_python.packages import get_package_share_directory
68

@@ -18,23 +20,33 @@ def generate_launch_description():
1820
'osr_params.yaml'
1921
)
2022

21-
return LaunchDescription([
23+
ld = LaunchDescription()
24+
25+
ld.add_action(
2226
Node(
2327
package='osr_control',
2428
executable='roboclaw_wrapper',
2529
name='roboclaw_wrapper',
2630
output='screen',
2731
emulate_tty=True,
2832
parameters=[roboclaw_params]
29-
),
33+
)
34+
)
35+
ld.add_action(
36+
DeclareLaunchArgument('enable_odometry', default_value='false')
37+
)
38+
ld.add_action(
3039
Node(
3140
package='osr_control',
3241
executable='rover',
3342
name='rover',
3443
output='screen',
3544
emulate_tty=True,
36-
parameters=[osr_params]
37-
),
45+
parameters=[osr_params,
46+
{'enable_odometry': LaunchConfiguration('enable_odometry')}]
47+
)
48+
)
49+
ld.add_action(
3850
Node(
3951
package='teleop_twist_joy',
4052
executable='teleop_node',
@@ -54,7 +66,9 @@ def generate_launch_description():
5466
remappings=[
5567
('/cmd_vel', '/cmd_vel_intuitive')
5668
]
57-
),
69+
)
70+
)
71+
ld.add_action(
5872
Node(
5973
package='joy',
6074
executable='joy_node',
@@ -64,5 +78,8 @@ def generate_launch_description():
6478
parameters=[
6579
{"autorepeat_rate": 5.0},
6680
{"device_id": 0}, # This might be different on your computer. Run `ls -l /dev/input/js0`. If you have js1, put 1.
67-
] )
68-
])
81+
]
82+
)
83+
)
84+
85+
return ld

ROS/osr_control/osr_control/roboclaw_wrapper.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,36 +159,56 @@ def __init__(self):
159159
self.status = Status()
160160
fast_loop_rate = 0.125 # seconds
161161
slow_loop_rate = 3 # seconds
162+
# true if we're idling and started rapping down velocity to bring the motors to full stop
163+
self.idle_ramp = False
164+
# if we're idled
165+
self.idle = False
162166
self.fast_timer = self.create_timer(fast_loop_rate, self.fast_update)
163167
self.slow_timer = self.create_timer(slow_loop_rate, self.slow_update)
164168

165169
def fast_update(self):
166170
"""Read from and write to roboclaws"""
167171
# Check to see if there are commands in the buffer to send to the motor controller
172+
now = self.get_clock().now()
168173
if self.drive_cmd_buffer:
169174
drive_fcn = self.send_drive_buffer_velocity
170175
drive_fcn(self.drive_cmd_buffer)
171176
self.drive_cmd_buffer = None
177+
self.idle_ramp = False
178+
self.idle = False
179+
self.time_last_cmd = now
172180

173181
if self.corner_cmd_buffer:
174182
self.send_corner_buffer(self.corner_cmd_buffer)
175183
self.corner_cmd_buffer = None
184+
self.idle_ramp = False
185+
self.idle = False
186+
self.time_last_cmd = now
176187

177188
# read from roboclaws and publish
178189
try:
179190
self.read_encoder_values()
180191
self.enc_pub.publish(self.current_enc_vals)
181192
except AssertionError as read_exc:
182-
self.get_logger().warn( "Failed to read encoder values")
193+
self.get_logger().warn("Failed to read encoder values")
183194

184195
# stop the motors if we haven't received a command in a while
185-
# this should be a timer instead
186-
now = self.get_clock().now()
187-
if now - self.time_last_cmd > self.velocity_timeout:
188-
# rather than a hard stop, send a ramped velocity command
189-
self.drive_cmd_buffer = CommandDrive()
190-
self.send_drive_buffer_velocity(self.drive_cmd_buffer)
191-
self.time_last_cmd = now # so this doesn't get called all the time
196+
if not self.idle and (now - self.time_last_cmd > self.velocity_timeout):
197+
# rather than a hard stop, send a ramped velocity command to 0
198+
if not self.idle_ramp:
199+
self.get_logger().debug("Idling: ramping down velocity to zero")
200+
self.idle_ramp = True
201+
drive_cmd_buffer = CommandDrive()
202+
self.send_drive_buffer_velocity(drive_cmd_buffer)
203+
# if we've already ramped down, send a full stop to minimize
204+
# idle power consumption
205+
else:
206+
self.get_logger().debug("Idling: full stopping motors")
207+
self.stop_motors()
208+
self.idle = True
209+
210+
# so that's there's a delay between ramping and full stop
211+
self.time_last_cmd = now
192212

193213
def slow_update(self):
194214
"""Slower roboclaw read/write cycle"""
@@ -263,7 +283,6 @@ def corner_cmd_cb(self, cmd):
263283
on the next iteration of the run() loop.
264284
"""
265285
self.get_logger().debug("Corner command callback received: {}".format(cmd))
266-
self.time_last_cmd = self.get_clock().now()
267286
self.corner_cmd_buffer = cmd
268287

269288
def send_corner_buffer(self, cmd):
@@ -309,7 +328,6 @@ def drive_cmd_cb(self, cmd):
309328

310329
self.get_logger().debug("Drive command callback received: {}".format(cmd))
311330
self.drive_cmd_buffer = cmd
312-
self.time_last_cmd = self.get_clock().now()
313331

314332
def send_drive_buffer_velocity(self, cmd):
315333
"""

ROS/osr_control/osr_control/rover.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33

44
import rclpy
55
from rclpy.node import Node
6+
import tf2_ros
67

78
from sensor_msgs.msg import JointState
8-
from geometry_msgs.msg import Twist
9+
from geometry_msgs.msg import Twist, TwistWithCovariance, TransformStamped
10+
from nav_msgs.msg import Odometry
11+
from std_msgs.msg import Float64
912
from osr_interfaces.msg import CommandDrive, CommandCorner
1013

1114

@@ -24,7 +27,8 @@ def __init__(self):
2427
('rover_dimensions.d3', None),
2528
('rover_dimensions.d4', None),
2629
('rover_dimensions.wheel_radius', None),
27-
('drive_no_load_rpm', None)
30+
('drive_no_load_rpm', None),
31+
('enable_odometry', None)
2832
]
2933
)
3034
self.d1 = self.get_parameter('rover_dimensions.d1').get_parameter_value().double_value
@@ -41,7 +45,16 @@ def __init__(self):
4145
drive_no_load_rpm = self.get_parameter(
4246
"drive_no_load_rpm").get_parameter_value().double_value
4347
self.max_vel = self.wheel_radius * drive_no_load_rpm / 60 * 2 * math.pi # [m/s]
44-
self.curr_twist = Twist()
48+
self.should_calculate_odom = self.get_parameter("enable_odometry").get_parameter_value().bool_value
49+
if self.should_calculate_odom:
50+
self.get_logger().info("Calculting wheel odometry and publishing to /odom topic")
51+
self.odometry = Odometry()
52+
self.odometry.header.stamp = self.get_clock().now().to_msg()
53+
self.odometry.header.frame_id = "odom"
54+
self.odometry.child_frame_id = "base_link"
55+
self.odometry.pose.pose.orientation.z = 1.
56+
self.odometry.pose.pose.orientation.w = 1.
57+
self.curr_twist = TwistWithCovariance()
4558
self.curr_turning_radius = self.max_radius
4659

4760
self.cmd_vel_sub = self.create_subscription(Twist, "/cmd_vel",
@@ -50,6 +63,11 @@ def __init__(self):
5063
partial(self.cmd_cb, intuitive=True), 1)
5164
self.encoder_sub = self.create_subscription(JointState, "/encoder", self.enc_cb, 1)
5265

66+
self.turning_radius_pub = self.create_publisher(Float64, "/turning_radius", 1)
67+
if self.should_calculate_odom:
68+
self.odometry_pub = self.create_publisher(Odometry, "/odom", 2)
69+
self.tf_pub = tf2_ros.TransformBroadcaster(self)
70+
5371
self.corner_cmd_pub = self.create_publisher(CommandCorner, "/cmd_corner", 1)
5472
self.drive_cmd_pub = self.create_publisher(CommandDrive, "/cmd_drive", 1)
5573

@@ -94,7 +112,40 @@ def cmd_cb(self, twist_msg, intuitive=False):
94112
def enc_cb(self, msg):
95113
self.curr_positions = dict(zip(msg.name, msg.position))
96114
self.curr_velocities = dict(zip(msg.name, msg.velocity))
97-
self.forward_kinematics()
115+
if self.should_calculate_odom:
116+
# measure how much time has elapsed since our last update
117+
now = self.get_clock().now()
118+
dt = float(now.nanoseconds - (self.odometry.header.stamp.sec*10**9 + self.odometry.header.stamp.nanosec)) / 10**9
119+
self.forward_kinematics()
120+
dx = self.curr_twist.twist.linear.x * dt
121+
dth = self.curr_twist.twist.angular.z * dt
122+
# angle is straightforward: in 2D it's additive
123+
# first calculate the current_angle in the fixed frame
124+
current_angle = 2 * math.atan2(self.odometry.pose.pose.orientation.z,
125+
self.odometry.pose.pose.orientation.w)
126+
new_angle = current_angle + dth
127+
self.odometry.pose.pose.orientation.z = math.sin(new_angle/2.)
128+
self.odometry.pose.pose.orientation.w = math.cos(new_angle/2.)
129+
# the new pose in x and y depends on the current heading
130+
self.odometry.pose.pose.position.x += math.cos(new_angle) * dx
131+
self.odometry.pose.pose.position.y += math.sin(new_angle) * dx
132+
self.odometry.pose.covariance = 36 * [0.0,]
133+
# explanation for values at https://www.freedomrobotics.ai/blog/tuning-odometry-for-wheeled-robots
134+
self.odometry.pose.covariance[0] = 0.0225
135+
self.odometry.pose.covariance[5] = 0.01
136+
self.odometry.pose.covariance[-5] = 0.0225
137+
self.odometry.pose.covariance[-1] = 0.04
138+
self.odometry.twist = self.curr_twist
139+
self.odometry.header.stamp = now.to_msg()
140+
self.odometry_pub.publish(self.odometry)
141+
transform_msg = TransformStamped()
142+
transform_msg.header.frame_id = "odom"
143+
transform_msg.child_frame_id = "base_link"
144+
transform_msg.header.stamp = now.to_msg()
145+
transform_msg.transform.translation.x = self.odometry.pose.pose.position.x
146+
transform_msg.transform.translation.y = self.odometry.pose.pose.position.y
147+
transform_msg.transform.rotation = self.odometry.pose.pose.orientation
148+
self.tf_pub.sendTransform(transform_msg)
98149

99150
def corner_cmd_threshold(self, corner_cmd):
100151
try:
@@ -292,6 +343,18 @@ def forward_kinematics(self):
292343
self.get_logger().debug("Current approximate turning radius: {}".format(round(approx_turning_radius, 2)))
293344
self.curr_turning_radius = approx_turning_radius
294345

346+
# we know that the linear velocity in x direction is the instantaneous velocity of the middle virtual
347+
# wheel which spins at the average speed of the two middle outer wheels.
348+
drive_angular_velocity = (self.curr_velocities['drive_left_middle'] + self.curr_velocities['drive_right_middle']) / 2.
349+
self.curr_twist.twist.linear.x = drive_angular_velocity * self.wheel_radius
350+
# now calculate angular velocity from its relation with linear velocity and turning radius
351+
try:
352+
self.curr_twist.twist.angular.z = self.curr_twist.twist.linear.x / self.curr_turning_radius
353+
except ZeroDivisionError:
354+
self.get_logger().warn("Current turning radius was calculated as zero which"
355+
"is an illegal value. Check your wheel calibration.")
356+
self.curr_twist.twist.angular.z = 0. # turning in place is currently unsupported
357+
295358

296359
def main(args=None):
297360
rclpy.init(args=args)

init_scripts/launch_osr.sh

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#!/bin/bash
2+
# exit on error, and output executed commands to stdout
3+
set -ex
4+
5+
source osr_paths.sh
6+
launch_dir=$OSR_CODE_DIR/ROS/osr_bringup/launch
7+
8+
bash -c ". /home/$USER/osr_ws/install/setup.sh"
9+
bash -c ". /home/$USER/osr_ws/devel/setup.bash"
10+
bash -c ". /opt/ros/melodic/setup.sh"
11+
bash -c ". /opt/ros/melodic/setup.bash"
12+
13+
# execute the custom mod launch file if it's available
14+
if [ -e "$launch_dir/osr_mod.launch" ]; then
15+
echo "Launching osr_mod.launch"
16+
bash -i -c "roslaunch osr_bringup osr_mod.launch"
17+
# otherwise go with the default
18+
else
19+
echo "Launching osr.launch"
20+
bash -i -c "roslaunch osr_bringup osr.launch"
21+
fi

init_scripts/osr_paths.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
## Paths used for OSR code
2+
# make sure to source (`$ source osr_paths.sh`) this script!
3+
export OSR_CODE_DIR=$HOME/osr_ws/src/osr-rover-code

init_scripts/osr_startup.service

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ After=network.target
66
User=ubuntu
77
Group=ubuntu
88
WorkingDirectory=/home/ubuntu/
9-
ExecStart=/home/ubuntu/LaunchOSR.sh
9+
ExecStart=/home/ubuntu/launch_osr.sh
1010
ExecReload=/bin/kill -HUP $MAINPID
1111
Restart=always
1212

scripts/roboclawtest.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@
1515
if __name__ == "__main__":
1616

1717
address = int(sys.argv[1])
18-
roboclaw = Roboclaw("/dev/serial1", 115200)
19-
try:
20-
assert roboclaw.Open() == 1
21-
except AssertionError as e:
22-
raise e("Could not open comport /dev/serial1, make sure it has the correct permissions and is available")
23-
24-
print(roboclaw.ReadVersion(address))
25-
print(roboclaw.ReadEncM1(address))
18+
roboclaw0 = Roboclaw("/dev/serial0", 115200)
19+
roboclaw1 = Roboclaw("/dev/serial1", 115200)
20+
connected0 = roboclaw0.Open() == 1
21+
connected1 = roboclaw1.Open() == 1
22+
if connected0:
23+
print("Connected to /dev/serial0.")
24+
print(roboclaw0.ReadVersion(address))
25+
print(roboclaw0.ReadEncM1(address))
26+
elif connected1:
27+
print("Connected to /dev/serial1.")
28+
print(roboclaw1.ReadVersion(address))
29+
print(roboclaw1.ReadEncM1(address))
30+
else:
31+
print("Could not open comport /dev/serial0 or /dev/serial1, make sure it has the correct permissions and is available")

0 commit comments

Comments
 (0)