Nav2 Navigation Stack - lyrical  lyrical
ROS 2 Navigation Stack
tester_node.py
1 #! /usr/bin/env python3
2 # Copyright 2025 Open Navigation LLC
3 #
4 # Licensed under the Apache License, Version 2.0 (the "License");
5 # you may not use this file except in compliance with the License.
6 # You may obtain a copy of the License at
7 #
8 # http://www.apache.org/licenses/LICENSE-2.0
9 #
10 # Unless required by applicable law or agreed to in writing, software
11 # distributed under the License is distributed on an "AS IS" BASIS,
12 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 # See the License for the specific language governing permissions and
14 # limitations under the License.
15 
16 import argparse
17 import math
18 import sys
19 import time
20 
21 from action_msgs.msg import GoalStatus
22 from geometry_msgs.msg import Pose, PoseStamped, PoseWithCovarianceStamped
23 from lifecycle_msgs.srv import GetState
24 from nav2_msgs.action import ComputeAndTrackRoute, ComputeRoute
25 from nav2_msgs.srv import ManageLifecycleNodes
26 from nav2_simple_commander.robot_navigator import BasicNavigator
27 import rclpy
28 from rclpy.action import ActionClient
29 from rclpy.client import Client
30 from rclpy.node import Node
31 from rclpy.qos import QoSDurabilityPolicy, QoSHistoryPolicy, QoSProfile, QoSReliabilityPolicy
32 from std_srvs.srv import Trigger
33 
34 
35 class RouteTester(Node):
36 
37  def __init__(self, initial_pose: Pose, goal_pose: Pose, namespace: str = ''):
38  super().__init__(node_name='nav2_tester', namespace=namespace)
39  self.initial_pose_pubinitial_pose_pub = self.create_publisher(
40  PoseWithCovarianceStamped, 'initialpose', 10
41  )
42 
43  pose_qos = QoSProfile(
44  durability=QoSDurabilityPolicy.TRANSIENT_LOCAL,
45  reliability=QoSReliabilityPolicy.RELIABLE,
46  history=QoSHistoryPolicy.KEEP_LAST,
47  depth=1,
48  )
49 
50  self.model_pose_submodel_pose_sub = self.create_subscription(
51  PoseWithCovarianceStamped, 'amcl_pose', self.poseCallbackposeCallback, pose_qos
52  )
53  self.initial_pose_receivedinitial_pose_received = False
54  self.initial_poseinitial_pose = initial_pose
55  self.goal_posegoal_pose = goal_pose
56  self.compute_action_client: ActionClient[
57  ComputeRoute.Goal,
58  ComputeRoute.Result,
59  ComputeRoute.Feedback
60  ] = ActionClient(self, ComputeRoute, 'compute_route')
61  self.compute_track_action_client: ActionClient[
62  ComputeAndTrackRoute.Goal,
63  ComputeAndTrackRoute.Result,
64  ComputeAndTrackRoute.Feedback
65  ] = ActionClient(
66  self, ComputeAndTrackRoute, 'compute_and_track_route')
67  self.feedback_msgsfeedback_msgs: list[ComputeAndTrackRoute.Feedback] = []
68 
69  self.navigatornavigator = BasicNavigator()
70 
71  def runComputeRouteTest(self, use_poses: bool = True) -> bool:
72  # Test 1: See if we can compute a route that is valid and correctly sized
73  self.info_msginfo_msg("Waiting for 'ComputeRoute' action server")
74  while not self.compute_action_client.wait_for_server(timeout_sec=1.0):
75  self.info_msginfo_msg("'ComputeRoute' action server not available, waiting...")
76 
77  route_msg = ComputeRoute.Goal()
78  if use_poses:
79  route_msg.start = self.getStampedPoseMsggetStampedPoseMsg(self.initial_poseinitial_pose)
80  route_msg.goal = self.getStampedPoseMsggetStampedPoseMsg(self.goal_posegoal_pose)
81  route_msg.use_start = True
82  route_msg.use_poses = True
83  else:
84  # Same request, just now the node IDs to test
85  route_msg.start_id = 7
86  route_msg.goal_id = 13
87  route_msg.use_start = False
88  route_msg.use_poses = False
89 
90  self.info_msginfo_msg('Sending ComputeRoute goal request...')
91  send_goal_future = self.compute_action_client.send_goal_async(route_msg)
92 
93  rclpy.spin_until_future_complete(self, send_goal_future)
94  goal_handle = send_goal_future.result()
95 
96  if not goal_handle or not goal_handle.accepted:
97  self.error_msgerror_msg('Goal rejected')
98  return False
99 
100  self.info_msginfo_msg('Goal accepted')
101  get_result_future = goal_handle.get_result_async()
102 
103  self.info_msginfo_msg("Waiting for 'ComputeRoute' action to complete")
104  rclpy.spin_until_future_complete(self, get_result_future)
105  status = get_result_future.result().status # type: ignore[union-attr]
106  result = get_result_future.result().result # type: ignore[union-attr]
107  if status != GoalStatus.STATUS_SUCCEEDED:
108  self.info_msginfo_msg(f'Goal failed with status code: {status}')
109  return False
110 
111  self.info_msginfo_msg('Action completed! Checking validity of results...')
112 
113  # Check result for validity
114  self.info_msginfo_msg(f'Route path len(result.path.poses): {len(result.path.poses)}')
115  assert (len(result.path.poses) == 79)
116  assert (result.route.route_cost > 6)
117  assert (result.route.route_cost < 7)
118  assert (len(result.route.nodes) == 5)
119  assert (len(result.route.edges) == 4)
120  assert (result.error_code == 0)
121  assert (result.error_msg == '')
122 
123  self.info_msginfo_msg('Goal succeeded!')
124  return True
125 
126  def runComputeRouteSamePoseTest(self) -> bool:
127  # Test 2: try with the same start and goal point edge case
128  self.info_msginfo_msg("Waiting for 'ComputeRoute' action server")
129  while not self.compute_action_client.wait_for_server(timeout_sec=1.0):
130  self.info_msginfo_msg("'ComputeRoute' action server not available, waiting...")
131 
132  route_msg = ComputeRoute.Goal()
133  route_msg.start_id = 7
134  route_msg.goal_id = 7
135  route_msg.use_start = False
136  route_msg.use_poses = False
137 
138  self.info_msginfo_msg('Sending ComputeRoute goal request...')
139  send_goal_future = self.compute_action_client.send_goal_async(route_msg)
140 
141  rclpy.spin_until_future_complete(self, send_goal_future)
142  goal_handle = send_goal_future.result()
143 
144  if not goal_handle or not goal_handle.accepted:
145  self.error_msgerror_msg('Goal rejected')
146  return False
147 
148  self.info_msginfo_msg('Goal accepted')
149  get_result_future = goal_handle.get_result_async()
150 
151  self.info_msginfo_msg("Waiting for 'ComputeRoute' action to complete")
152  rclpy.spin_until_future_complete(self, get_result_future)
153  status = get_result_future.result().status # type: ignore[union-attr]
154  result = get_result_future.result().result # type: ignore[union-attr]
155  if status != GoalStatus.STATUS_SUCCEEDED:
156  self.info_msginfo_msg(f'Goal failed with status code: {status}')
157  return False
158 
159  self.info_msginfo_msg('Action completed! Checking validity of results...')
160 
161  # Check result for validity, should be a 1-node path as its the same
162  assert (len(result.path.poses) == 1)
163  assert (len(result.route.nodes) == 1)
164  assert (len(result.route.edges) == 0)
165  assert (result.error_code == 0)
166  assert (result.error_msg == '')
167 
168  self.info_msginfo_msg('Goal succeeded!')
169  return True
170 
171  def runTrackRouteTest(self) -> bool:
172  # Test 3: See if we can compute and track a route with proper state
173  self.info_msginfo_msg("Waiting for 'ComputeAndTrackRoute' action server")
174  while not self.compute_track_action_client.wait_for_server(timeout_sec=1.0):
175  self.info_msginfo_msg("'ComputeAndTrackRoute' action server not available, waiting...")
176 
177  route_msg = ComputeAndTrackRoute.Goal()
178  route_msg.goal = self.getStampedPoseMsggetStampedPoseMsg(self.goal_posegoal_pose)
179  route_msg.use_start = False # Use TF pose instead
180  route_msg.use_poses = True
181 
182  self.info_msginfo_msg('Sending ComputeAndTrackRoute goal request...')
183  send_goal_future = self.compute_track_action_client.send_goal_async(
184  route_msg, feedback_callback=self.feedback_callbackfeedback_callback)
185 
186  rclpy.spin_until_future_complete(self, send_goal_future)
187  goal_handle = send_goal_future.result()
188 
189  if not goal_handle or not goal_handle.accepted:
190  self.error_msgerror_msg('Goal rejected')
191  return False
192 
193  self.info_msginfo_msg('Goal accepted')
194  get_result_future = goal_handle.get_result_async()
195 
196  # Trigger a reroute
197  time.sleep(1)
198  self.info_msginfo_msg('Triggering a reroute')
199  srv_client: Client[Trigger.Request, Trigger.Response] = \
200  self.create_client(Trigger, 'route_server/ReroutingService/reroute')
201  while not srv_client.wait_for_service(timeout_sec=1.0):
202  self.info_msginfo_msg('Reroute service not available, waiting...')
203  req = Trigger.Request()
204  future = srv_client.call_async(req)
205  rclpy.spin_until_future_complete(self, future)
206  if future.result() is not None:
207  self.info_msginfo_msg('Reroute triggered')
208  else:
209  self.error_msgerror_msg('Reroute failed')
210  return False
211  # Wait a bit for it to compute the route and start tracking (but no movement)
212 
213  # Cancel it after a bit
214  time.sleep(2)
215  cancel_future = goal_handle.cancel_goal_async()
216  rclpy.spin_until_future_complete(self, cancel_future)
217  status = cancel_future.result()
218  if status is not None and len(status.goals_canceling) > 0:
219  self.info_msginfo_msg('Action cancel completed!')
220  else:
221  self.info_msginfo_msg('Goal cancel failed')
222  return False
223 
224  # Send it again
225  self.info_msginfo_msg('Sending ComputeAndTrackRoute goal request...')
226  send_goal_future = self.compute_track_action_client.send_goal_async(
227  route_msg, feedback_callback=self.feedback_callbackfeedback_callback)
228 
229  rclpy.spin_until_future_complete(self, send_goal_future)
230  goal_handle = send_goal_future.result()
231 
232  if not goal_handle or not goal_handle.accepted:
233  self.error_msgerror_msg('Goal rejected')
234  return False
235 
236  self.info_msginfo_msg('Goal accepted')
237  get_result_future = goal_handle.get_result_async()
238 
239  # Wait a bit for it to compute the route and start tracking (but no movement)
240  time.sleep(2)
241 
242  # Preempt with a new request type on the graph
243  route_msg.use_poses = False
244  route_msg.start_id = 7
245  route_msg.goal_id = 13
246  send_goal_future = self.compute_track_action_client.send_goal_async(
247  route_msg, feedback_callback=self.feedback_callbackfeedback_callback)
248 
249  rclpy.spin_until_future_complete(self, send_goal_future)
250  goal_handle = send_goal_future.result()
251 
252  if not goal_handle or not goal_handle.accepted:
253  self.error_msgerror_msg('Goal rejected')
254  return False
255 
256  self.info_msginfo_msg('Goal accepted')
257  get_result_future = goal_handle.get_result_async()
258  self.feedback_msgsfeedback_msgs = []
259 
260  self.info_msginfo_msg("Waiting for 'ComputeAndTrackRoute' action to complete")
261  progressing = True
262  last_feedback_msg = None
263  follow_path_task = None
264  while progressing:
265  rclpy.spin_until_future_complete(self, get_result_future, timeout_sec=0.10)
266  if get_result_future.result() is not None:
267  status = get_result_future.result().status # type: ignore[union-attr]
268  if status == GoalStatus.STATUS_SUCCEEDED:
269  progressing = False
270  elif status == GoalStatus.STATUS_CANCELED or status == GoalStatus.STATUS_ABORTED:
271  self.info_msginfo_msg(f'Goal failed with status code: {status}')
272  return False
273 
274  # Else, processing. Check feedback
275  while len(self.feedback_msgsfeedback_msgs) > 0:
276  feedback_msg = self.feedback_msgsfeedback_msgs.pop(0)
277 
278  # Start following the path
279  if (last_feedback_msg and feedback_msg.path != last_feedback_msg.path):
280  follow_path_task = self.navigatornavigator.followPath(feedback_msg.path)
281 
282  # Check if the feedback is valid, if changed (not for route operations)
283  if last_feedback_msg and \
284  last_feedback_msg.current_edge_id != feedback_msg.current_edge_id and \
285  int(feedback_msg.current_edge_id) != 0:
286  if last_feedback_msg.next_node_id != feedback_msg.last_node_id:
287  self.error_msgerror_msg('Feedback state is not tracking in order!')
288  return False
289 
290  last_feedback_msg = feedback_msg
291 
292  # Validate the state of the final feedback message
293  if last_feedback_msg is None:
294  self.error_msgerror_msg('No feedback message received!')
295  return False
296 
297  if int(last_feedback_msg.next_node_id) != 0:
298  self.error_msgerror_msg('Terminal feedback state of nodes is not correct!')
299  return False
300  if int(last_feedback_msg.current_edge_id) != 0:
301  self.error_msgerror_msg('Terminal feedback state of edges is not correct!')
302  return False
303  if int(last_feedback_msg.route.nodes[-1].nodeid) != 13:
304  self.error_msgerror_msg('Final route node is not correct!')
305  return False
306 
307  while not self.navigatornavigator.isTaskComplete(task=follow_path_task):
308  time.sleep(0.1)
309 
310  self.info_msginfo_msg('Action completed! Checking validity of terminal condition...')
311 
312  # Check result for validity
313  if not self.distanceFromGoaldistanceFromGoal() < 1.0:
314  self.error_msgerror_msg('Did not make it to the goal pose!')
315  return False
316 
317  self.info_msginfo_msg('Goal succeeded!')
318  return True
319 
320  def feedback_callback(
321  self, feedback_msg: ComputeAndTrackRoute.Impl.FeedbackMessage) -> None:
322  self.feedback_msgsfeedback_msgs.append(feedback_msg.feedback)
323 
324  def distanceFromGoal(self) -> float:
325  d_x = self.current_posecurrent_pose.position.x - self.goal_posegoal_pose.position.x
326  d_y = self.current_posecurrent_pose.position.y - self.goal_posegoal_pose.position.y
327  distance = math.sqrt(d_x * d_x + d_y * d_y)
328  self.info_msginfo_msg(f'Distance from goal is: {distance}')
329  return distance
330 
331  def info_msg(self, msg: str) -> None:
332  self.get_logger().info('\033[1;37;44m' + msg + '\033[0m')
333 
334  def error_msg(self, msg: str) -> None:
335  self.get_logger().error('\033[1;37;41m' + msg + '\033[0m')
336 
337  def setInitialPose(self) -> None:
338  msg = PoseWithCovarianceStamped()
339  msg.pose.pose = self.initial_poseinitial_pose
340  msg.header.frame_id = 'map'
341  self.info_msginfo_msg('Publishing Initial Pose')
342  self.initial_pose_pubinitial_pose_pub.publish(msg)
343  self.currentPosecurrentPose = self.initial_poseinitial_pose
344 
345  def getStampedPoseMsg(self, pose: Pose) -> PoseStamped:
346  msg = PoseStamped()
347  msg.header.frame_id = 'map'
348  msg.pose = pose
349  return msg
350 
351  def poseCallback(self, msg: PoseWithCovarianceStamped) -> None:
352  self.info_msginfo_msg('Received amcl_pose')
353  self.current_posecurrent_pose = msg.pose.pose
354  self.initial_pose_receivedinitial_pose_received = True
355 
356  def wait_for_node_active(self, node_name: str) -> None:
357  # Waits for the node within the tester namespace to become active
358  self.info_msginfo_msg(f'Waiting for {node_name} to become active')
359  node_service = f'{node_name}/get_state'
360  state_client: Client[GetState.Request, GetState.Response] = \
361  self.create_client(GetState, node_service)
362  while not state_client.wait_for_service(timeout_sec=1.0):
363  self.info_msginfo_msg(f'{node_service} service not available, waiting...')
364  req = GetState.Request() # empty request
365  state = 'UNKNOWN'
366  while state != 'active':
367  self.info_msginfo_msg(f'Getting {node_name} state...')
368  future = state_client.call_async(req)
369  rclpy.spin_until_future_complete(self, future)
370  if future.result() is not None:
371  state = future.result().current_state.label # type: ignore[union-attr]
372  self.info_msginfo_msg(f'Result of get_state: {state}')
373  else:
374  self.error_msgerror_msg(
375  f'Exception while calling service: {future.exception()!r}'
376  )
377  time.sleep(5)
378 
379  def shutdown(self) -> None:
380  self.info_msginfo_msg('Shutting down')
381  self.compute_action_client.destroy()
382  self.compute_track_action_client.destroy()
383 
384  transition_service = 'lifecycle_manager_navigation/manage_nodes'
385  mgr_client: Client[ManageLifecycleNodes.Request, ManageLifecycleNodes.Response] = \
386  self.create_client(ManageLifecycleNodes, transition_service)
387  while not mgr_client.wait_for_service(timeout_sec=1.0):
388  self.info_msginfo_msg(f'{transition_service} service not available, waiting...')
389 
390  req = ManageLifecycleNodes.Request()
391  req.command = ManageLifecycleNodes.Request.SHUTDOWN
392  future = mgr_client.call_async(req)
393  try:
394  self.info_msginfo_msg('Shutting down navigation lifecycle manager...')
395  rclpy.spin_until_future_complete(self, future)
396  future.result()
397  self.info_msginfo_msg('Shutting down navigation lifecycle manager complete.')
398  except Exception as e: # noqa: B902
399  self.error_msgerror_msg(f'Service call failed {e!r}')
400  transition_service = 'lifecycle_manager_localization/manage_nodes'
401  mgr_client = self.create_client(ManageLifecycleNodes, transition_service)
402  while not mgr_client.wait_for_service(timeout_sec=1.0):
403  self.info_msginfo_msg(f'{transition_service} service not available, waiting...')
404 
405  req = ManageLifecycleNodes.Request()
406  req.command = ManageLifecycleNodes.Request.SHUTDOWN
407  future = mgr_client.call_async(req)
408  try:
409  self.info_msginfo_msg('Shutting down localization lifecycle manager...')
410  rclpy.spin_until_future_complete(self, future)
411  future.result()
412  self.info_msginfo_msg('Shutting down localization lifecycle manager complete')
413  except Exception as e: # noqa: B902
414  self.error_msgerror_msg(f'Service call failed {e!r}')
415 
416  def wait_for_initial_pose(self) -> None:
417  self.initial_pose_receivedinitial_pose_received = False
418  while not self.initial_pose_receivedinitial_pose_received:
419  self.info_msginfo_msg('Setting initial pose')
420  self.setInitialPosesetInitialPose()
421  self.info_msginfo_msg('Waiting for amcl_pose to be received')
422  rclpy.spin_once(self, timeout_sec=1)
423 
424 
425 def run_all_tests(robot_tester: RouteTester) -> bool:
426  # set transforms to use_sim_time
427  robot_tester.wait_for_node_active('amcl')
428  robot_tester.wait_for_initial_pose()
429  robot_tester.wait_for_node_active('bt_navigator')
430  result_poses = robot_tester.runComputeRouteTest(use_poses=True)
431  result_node_ids = robot_tester.runComputeRouteTest(use_poses=False)
432  result_same = robot_tester.runComputeRouteSamePoseTest()
433  result = result_poses and result_node_ids and result_same and robot_tester.runTrackRouteTest()
434 
435  if result:
436  robot_tester.info_msg('Test PASSED')
437  else:
438  robot_tester.error_msg('Test FAILED')
439  return result
440 
441 
442 def fwd_pose(x: float = 0.0, y: float = 0.0, z: float = 0.01) -> Pose:
443  initial_pose = Pose()
444  initial_pose.position.x = x
445  initial_pose.position.y = y
446  initial_pose.position.z = z
447  initial_pose.orientation.x = 0.0
448  initial_pose.orientation.y = 0.0
449  initial_pose.orientation.z = 0.0
450  initial_pose.orientation.w = 1.0
451  return initial_pose
452 
453 
454 def main(argv: list[str] = sys.argv[1:]): # type: ignore[no-untyped-def]
455  # The robot(s) positions from the input arguments
456  parser = argparse.ArgumentParser(description='Route server tester node')
457  group = parser.add_mutually_exclusive_group(required=True)
458  group.add_argument(
459  '-r',
460  '--robot',
461  action='append',
462  nargs=4,
463  metavar=('init_x', 'init_y', 'final_x', 'final_y'),
464  help='The robot starting and final positions.',
465  )
466  args, unknown = parser.parse_known_args()
467 
468  rclpy.init()
469 
470  # Create test object
471  init_x, init_y, final_x, final_y = args.robot[0]
472  tester = RouteTester(
473  initial_pose=fwd_pose(float(init_x), float(init_y)),
474  goal_pose=fwd_pose(float(final_x), float(final_y)),
475  )
476  tester.info_msg(
477  'Starting tester, robot going from '
478  + init_x
479  + ', '
480  + init_y
481  + ' to '
482  + final_x
483  + ', '
484  + final_y
485  + ' via route server.'
486  )
487 
488  # wait a few seconds to make sure entire stacks are up
489  time.sleep(10)
490 
491  # run tests
492  passed = run_all_tests(tester)
493 
494  tester.shutdown()
495  tester.info_msg('Done Shutting Down.')
496 
497  if not passed:
498  tester.info_msg('Exiting failed')
499  exit(1)
500  else:
501  tester.info_msg('Exiting passed')
502  exit(0)
503 
504 
505 if __name__ == '__main__':
506  main()
None info_msg(self, str msg)
Definition: tester_node.py:331
None poseCallback(self, PoseWithCovarianceStamped msg)
Definition: tester_node.py:351
None error_msg(self, str msg)
Definition: tester_node.py:334
None feedback_callback(self, ComputeAndTrackRoute.Impl.FeedbackMessage feedback_msg)
Definition: tester_node.py:321
PoseStamped getStampedPoseMsg(self, Pose pose)
Definition: tester_node.py:345
float distanceFromGoal(self)
Definition: tester_node.py:324