Real Robot Project Part 3

Hello Construct Team,

I hope everyone is doing well.

I’ve completed all three parts of the “ROS2 Basics in 5 Days (Python)” real robot project. However, I’ve encountered an issue in Part 3. We are instructed to create an action server that records odometry. Subsequently, we have to create a client to call this server from the wall_following.py script. My challenge is that I can’t ascertain if the server and client are functioning correctly since there aren’t any examples or videos that clarify this process.

I understand this might seem like a trivial question, but considering I’m only 14 years old, I hope you can offer some guidance.

this is my record_odom.py code :

import rclpy

from rclpy.action import ActionServer
from rclpy.node import Node
from rclpy.qos import ReliabilityPolicy, QoSProfile
from rclpy.executors import MultiThreadedExecutor, SingleThreadedExecutor
from rclpy.callback_groups import ReentrantCallbackGroup, MutuallyExclusiveCallbackGroup

from geometry_msgs.msg import Point
from nav_msgs.msg import Odometry
from custom_interfaces.action import OdomRecord

import time
import numpy as np

class OdomRecordServer(Node):

    def __init__(self):
        super().__init__('record_odom_server')
        
        self.group1 = ReentrantCallbackGroup()
        # self.group2 = MutuallyExclusiveCallbackGroup()
        
        self._action_server = ActionServer(self, 
            OdomRecord, 
            'record_odom',
            self.action_callback,
            callback_group=self.group1)

        self.odom_subscriber_ = self.create_subscription(
            Odometry,
            '/odom',
            self.odom_callback,
            QoSProfile(depth=10, reliability=ReliabilityPolicy.RMW_QOS_POLICY_RELIABILITY_RELIABLE),
            callback_group=self.group1)
        
        self.current_point = Point()
        self.start_point = Point()
        self.start_point_identified = False
        self.total_distance = 0.0
        self.odom_record = []
        self.lap_finished = False
        self.check_finished_lap_flag = False


    def action_callback(self, goal_handle):
        
        self.get_logger().info('Executing goal...')

        feedback_msg = OdomRecord.Feedback()
        

        while not self.start_point_identified:
            time.sleep(1)

        self.odom_record.append(self.start_point)    
        N = 1

        while not self.lap_finished:
            time.sleep(1)

            self.get_logger().info('Distance to start point: "%f"' % self.distance_to_start)
            self.odom_record.append(self.current_point)
            N += 1
            delta_x = self.odom_record[N-1].x - self.odom_record[N-2].x
            delta_y = self.odom_record[N-1].y - self.odom_record[N-2].y

            dist_to_last_point = np.sqrt(np.power(delta_x, 2) + np.power(delta_y, 2))   
            self.get_logger().info('Distance to last point: "%f"' % dist_to_last_point)

            self.total_distance += dist_to_last_point         
            self.get_logger().info('Current total distance feedback: "%f"' % self.total_distance)
            feedback_msg.current_total = self.total_distance
            goal_handle.publish_feedback(feedback_msg)
            
            if N > 5:
                self.check_finished_lap_flag = True
            
        goal_handle.succeed()
        result = OdomRecord.Result()
        result.list_of_odoms = self.odom_record
        self.get_logger().info('The Action Server has finished, it has recorded: "%s" points' % str(len(self.odom_record)))
        return result
    
    def odom_callback(self, msg):
        x = msg.pose.pose.position.x
        y = msg.pose.pose.position.y
        if not self.start_point_identified:
            self.start_point.x = x
            self.start_point.y = y
            self.get_logger().info('Start point: ("%f", "%f")' %(self.start_point.x, self.start_point.y))
            self.start_point_identified = True
            
        else:
            self.current_point = Point()
            self.current_point.x = x
            self.current_point.y = y
            self.check_finished_lap()
            

    def check_finished_lap(self):
        self.distance_to_start = np.sqrt(np.power(self.current_point.x - self.start_point.x, 2) + np.power(self.current_point.y - self.start_point.y, 2))
        if self.check_finished_lap_flag and self.distance_to_start <= 0.1:
            self.lap_finished = True
            
def main(args=None):
    rclpy.init(args=args)
    odom_recorder_node = OdomRecordServer()
    # Create a MultiThreadedExecutor with 3 threads
    executor = MultiThreadedExecutor(num_threads=3)
    # Add the node to the executor
    executor.add_node(odom_recorder_node)
    try:
        # Spin the executor
        executor.spin()
    finally:
        # Shutdown the executor
        executor.shutdown()
        odom_recorder_node.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

and this is my wall_following.py :

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
from geometry_msgs.msg import Twist
from custom_interfaces.srv import FindWall
from custom_interfaces.action import OdomRecord
from rclpy.action import ActionClient

class WallFollower(Node):
    def __init__(self):
        super().__init__('wall_follower')

        self.publisher_ = self.create_publisher(Twist, '/cmd_vel', 10)
        self.subscription_ = self.create_subscription(
            LaserScan,
            '/scan',
            self.laser_callback,
            10
        )
        self.wall_distance_ = 0.3
        self.front_wall_distance_ = 0.5
        self.laser_forward = 0
        self.wall_aligned = False
        self.ranges = []

        # Create the find_wall service client
        self.find_wall_client = self.create_client(FindWall, 'find_wall')
        self.request_sent = False
        if not self.find_wall_client.wait_for_service(timeout_sec=1.0):
            self.get_logger().error('Service not available')
        else:
            self.get_logger().info('Service is available')

        # Create an action client for the '/record_odom' action server
        self.odom_record_client = ActionClient(self, OdomRecord, '/record_odom')

        # Flag to check if the odom recording has been started
        self.odom_recording_started = False

        self.timer = self.create_timer(0.1, self.timer_callback)

    def timer_callback(self):
        # If not aligned and not requested find_wall service, 
        # AND if odom recording hasn't started, start it.
        if not self.wall_aligned and not self.odom_recording_started:
            self.start_odom_recording()
            
        # Existing find wall logic
        if not self.wall_aligned and not self.request_sent:
            request = FindWall.Request()
            future = self.find_wall_client.call_async(request)
            future.add_done_callback(self.find_wall_response_callback)
            self.request_sent = True
            
        if self.wall_aligned:
            self.wall_following_behavior()

    def start_odom_recording(self):
        # Check if the action server is available
        if not self.odom_record_client.wait_for_server(timeout_sec=1.0):
            self.get_logger().error('Odom Record Action Server not available!')
            return
        
        # Send goal to start recording
        goal_msg = OdomRecord.Goal()
        self.get_logger().info('Sending goal to start odom recording...')
        send_goal_future = self.odom_record_client.send_goal_async(goal_msg)
        send_goal_future.add_done_callback(self.odom_recording_goal_sent_callback)
        self.odom_recording_started = True

    def odom_recording_goal_sent_callback(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().error('Odom Record goal rejected :(')
            return

        self.get_logger().info('Odom Record goal accepted :)')
        get_result_future = goal_handle.get_result_async()
        get_result_future.add_done_callback(self.odom_recording_result_callback)

    def odom_recording_result_callback(self, future):
        status = future.result().status
        if status == GoalStatus.STATUS_SUCCEEDED:
            self.get_logger().info('Odom recording completed successfully.')
        else:
            self.get_logger().info('Odom recording failed with status code: {0}'.format(status))

    def find_wall_response_callback(self, future):
        try:
            response = future.result()
            if response and response.wallfound:
                self.get_logger().info('Wall found! Robot is aligned with the wall.')
                self.wall_aligned = True
            else:
                self.get_logger().info('Wall not found yet.')
        except Exception as e:
            self.get_logger().error('Service call failed: %s' % str(e))

    def wall_following_behavior(self):
        ranges = self.ranges
        if not ranges:
            return

        right_ray_index = int(len(ranges) * 3 / 4)  
        self.laser_forward = ranges[0]

        if self.laser_forward < self.front_wall_distance_:
            self.update_velocity(0.0, 0.35) 
            return

        right_distance = ranges[right_ray_index]

        if right_distance > self.wall_distance_ + 0.05:
            self.update_velocity(0.15, -0.35)  
        elif right_distance < self.wall_distance_ - 0.05:
            self.update_velocity(0.15, 0.35)  
        else:
            self.update_velocity(0.15, 0.0)  

    def laser_callback(self, msg):
        self.ranges = msg.ranges

    def update_velocity(self, linear, angular):
        msg = Twist()
        msg.linear.x = linear
        msg.angular.z = angular
        self.publisher_.publish(msg)

def main(args=None):
    rclpy.init(args=args)
    wall_follower = WallFollower()

    executor = rclpy.executors.MultiThreadedExecutor(num_threads=3)
    executor.add_node(wall_follower)

    try:
        executor.spin()
    finally:
        executor.shutdown()
        wall_follower.destroy_node()
        rclpy.shutdown()

if __name__ == '__main__':
    main()

I hope you can help me :blush:

Thank you.

@girishkumar.kannan please anyone can help me :blush:

Hi @ghassan11 ,

You cannot find videos on how to do this process because you, as a robot designer, must know how to make a scenario to test out your program.
To test out your action server and client, as in your case, you must first create a standalone action client program and an action server program. Then start the action server and use the action client to call the server to test the action.
Only after you confirm that the action server-client pair is working as desired, you should go ahead with integrating action client with the main program, which in your case, is the wall follower.

My views on your Action Server code:

  1. Please stop importing the whole of numpy library just to use a couple of functions. sqrt and power functions can be replaced with python’s built in ** method. I want you to try this out in a python terminal (I do not mind if you use your own laptop or the rosject, but just try this).
    To perform square root, you just exponentiate the number by 0.5.
    np.sqrt(2) is same as doing 2 ** 0.5 in python. The answer will be the same 1.4142......
    Similarly, for squaring a number, np.power(2, 2) is same as doing 2 ** 2, which gives 4.0 (float).
    numpy is a huge library. Do not import numpy library to make your program look fancy.
    [Yes, I know when you compile, only the required functions are borrowed from the library, but you are not using functions like linear algebra or regression curve-fitting, so I recommend that you stop using numpy for simple functions].
  2. You could have used a distance calculation function to calculate the (Euclidean) distance. You have repeated the formula twice in your code.
  3. From an action server point of view, your code seems to be correct, although I have no idea how (exactly) it will execute - this is how you have designed your logic.
  4. Looking at your check_finished_lap function, I am sure you will run in one major issue: your robot will detect the starting position as final position. The reason being you do not have any other conditions to let the robot know that it needs to stop after completing a lap. So come up with some idea to tell the robot to check if the lap is complete once it has left the starting point. The simplest way is to wait for a few seconds (like 10-15 seconds) after it has started moving, to check if it has reached the starting point for finishing a lap.

My views on your Main Code:

  1. Move the wait_for_server code set for checking if action server is available - into the __init__ function. The action server needs to be up and running so when you call the action, it will start. You should not check for action server at the time of making the goal call. You must be sure that the action server is online long before you call the goal.
  2. For the odom result callback function, make sure you return the action server’s result to the main code so you can make use of the result (for printing or any calculations). At the moment, your result callback just prints if the result is received or not - which is quite useless.
  3. I do not see any other major problems with your Main program (at the moment).

Once you get all the parts working, I would recommend you to add more intelligence to your code to make the robot execute things properly. One problem that I am sure that you will encounter is that: once your robot stops at the starting point after finishing the lap, there will be a time-advance or a time-delay in getting the result from the action server. So you might have to work on that. This is just one example though. You will have to analyze your robot’s behavior to make it work good.

That’s all from me!

Regards,
Girish

1 Like

Hi @girishkumar.kannan so the challenge here is that we will stop the robot using the action server when it will take one lap and reaches the position that it start from it :thinking:

@girishkumar.kannan

Dear Girish,

I’ve managed to fix my code, and now I’m at the stage where I want to call the server using the following command:

ros2 action send_goal /record_odom custom_interfaces/action/OdomRecord “list_of_odoms: []”

After sending this goal to the action server, I plan to move the robot using keyboard teleop. As it moves, I expect the action to be calculating based on the movement. Is this the correct approach?

Could you please confirm if I’m on the right track?

Thank you so much! :blush:

Best regards,

Ghassan

Hi @ghassan11 ,

If I remember correctly, the action message has an empty message for goal.
So the command you are using is wrong.
It should be:
ros2 action send_goal /record_odom custom_interfaces/action/OdomRecord “{}”
Which will return the list_of_odoms as result (at the end, after action completes) and current_distance as feedback (every second while action is running).

Yes, after sending the (correct) goal call command, you should move the robot with teleop. Every time the robot continues to move, you should get a feedback of how much distance it has moved.

Yes, but you should come up with your own program-based idea to let the robot know that it should not stop right after starting, since start and stop point are almost the same.

Regards,
Girish

@girishkumar.kannan

Dear Girish,

I hope this message finds you well. I have made further progress with my action server, but I have a couple of questions and I hope you can help me clarify them.

Firstly, I would like to confirm the behavior I should expect when the action server completes a full lap. Should it automatically stop the wall_following behavior when it finishes the full lap?

Secondly, I have adjusted and fixed my code. It now provides feedback when I run it, but I am unsure how to interpret this feedback to confirm if the action server is functioning correctly. Could you please take a look at my updated code and provide some guidance?

Here is my updated code:

import rclpy
from rclpy.action import ActionServer
from rclpy.node import Node
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
from geometry_msgs.msg import Point
from nav_msgs.msg import Odometry
from custom_interfaces.action import OdomRecord
from rclpy.executors import MultiThreadedExecutor
import time

class OdomRecordServer(Node):

    def __init__(self):
        super().__init__('record_odom_server')
        
        self.callback_group = MutuallyExclusiveCallbackGroup()

        self._action_server = ActionServer(self, 
            OdomRecord, 
            'record_odom',
            self.action_callback,
            callback_group=self.callback_group)

        self.odom_subscriber_ = self.create_subscription(
            Odometry,
            '/odom',
            self.odom_callback,
            10,
            callback_group=self.callback_group)
        
        self.current_point = Point()
        self.start_point = Point()
        self.total_distance = 0.0
        self.odom_record = []
        self.check_finished_lap_flag = False
        self.start_point_identified = False
        self.lap_finished = False

        # Store the start time of a lap
        self.lap_start_time = None
        # Define a constant for lap duration (e.g., 10 seconds)
        self.lap_duration = 10.0

    def action_callback(self, goal_handle):
        
        self.get_logger().info('Executing goal...')
        self.start_point_identified = False
        self.lap_finished = False

        feedback_msg = OdomRecord.Feedback()

        while not self.start_point_identified:
            time.sleep(1)

        self.odom_record.append(self.start_point)
        N = 1

        while not self.lap_finished:
            time.sleep(1)

            self.get_logger().info('Distance to start point: "%f"' % self.distance_to_start)
            self.odom_record.append(self.current_point)
            N += 1
            # Previous point
            x1 = self.odom_record[N - 2].x
            y1 = self.odom_record[N - 2].y
            # Current point
            x2 = self.odom_record[N - 1].x
            y2 = self.odom_record[N - 1].y

            # Now, we are passing the coordinates of the two points to calculate_distance
            dist_to_last_point = self.calculate_distance(x1, y1, x2, y2)            self.get_logger().info('Distance to last point: "%f"' % dist_to_last_point)

            self.total_distance += dist_to_last_point         
            self.get_logger().info('Current total distance feedback: "%f"' % self.total_distance)
            feedback_msg.current_total = float(self.total_distance)
            goal_handle.publish_feedback(feedback_msg)
            
            if N > 5:
                self.check_finished_lap_flag = True
            
        goal_handle.succeed()
        result = OdomRecord.Result()
        result.list_of_odoms = self.odom_record
        self.get_logger().info('The Action Server has finished, it has recorded: "%s" points' % str(len(self.odom_record)))
        return result
    
    def odom_callback(self, msg):
        x = msg.pose.pose.position.x
        y = msg.pose.pose.position.y
        if not self.start_point_identified:
            self.start_point.x = x
            self.start_point.y = y
            self.get_logger().info('Start point: ("%f", "%f")' %(self.start_point.x, self.start_point.y))
            self.start_point_identified = True
            # Record the time when a new lap starts
            self.lap_start_time = time.time()
        else:
            self.current_point = Point()
            self.current_point.x = x
            self.current_point.y = y
            if (time.time() - self.lap_start_time) > self.lap_duration:
                self.check_finished_lap()
            
    def calculate_distance(self, x1, y1, x2=0, y2=0):
        """Calculates the Euclidean distance between two points (x1, y1) and (x2, y2)."""
        return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5

    def check_finished_lap(self):
        self.distance_to_start = self.calculate_distance(self.current_point.x, self.start_point.x, self.current_point.y, self.start_point.y)
        if self.check_finished_lap_flag and self.distance_to_start <= 0.1:
            self.lap_finished = True
            
def main(args=None):
    rclpy.init(args=args)
    odom_recorder_node = OdomRecordServer()
    # Create a MultiThreadedExecutor with 3 threads
    executor = MultiThreadedExecutor(num_threads=3)
    # Add the node to the executor
    executor.add_node(odom_recorder_node)
    try:
        # Spin the executor
        executor.spin()
    finally:
        # Shutdown the executor
        executor.shutdown()
        odom_recorder_node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

I sincerely appreciate your time and assistance. Thank you in advance!

Best regards,

Ghassan

@girishkumar.kannan Hello Ros2 Master :grin:
I got this results :

I don’t know but I think the action did not Work can you check my code and with this outputs can you tell me if the Action server worked Yes or No

Hi @ghassan11 ,

I understand your enthusiasm, but please do not do this (again)! I am not a “master” is ROS[1/2] and there are people much much experienced in ROS than me. I do not welcome such greetings (or unintended flattery). I just help people who have slight interest in robotics become more interested in the field. I do not mean to be impolite.

Now to clarify your doubts…

Technically, the robot should stop momentarily when it reaches the “starting point” after it completes one lap. And during this “small halt” the action server should finish the task and return the result to the main program’s action client while sending back feedback while doing the lap.

After looking at the two images that you posted (the terminal outputs), I understand that your total distance and your list of odoms result has some issues.

current distance cannot increase in 0.000001. You must be moving extremely slow to get that result - which is same as being stopped. You need to debug your distance calculation by printing the distance in the loop of the action server.

The list of odoms values being the same is due to python making shallow copies instead of deep copies. The best way to fix this is to add another local variable inside the action callback just before appending the self.current_point into the odom_record list.
The problem here is, when you overwrite the values in the self.current_point at every odom callback procedure, the list only makes a shallow copy of that variable and adds it into the list of odoms.
So what you do is, create a new local Point object and assign the x, y, z values to the self.current_point values as shown below:

current_point = Point() # this is a local variable, thus no 'self'
current_point.x = self.current_point.x
current_point.y = self.current_point.y
current_point.z = calculated 'yaw' value from 'orientation' values
self.odom_record.append(current_point) # append the local variable

These steps should fix your problem.

Regards,
Girish

This topic was automatically closed 5 days after the last reply. New replies are no longer allowed.