ROS2

Mastering ROS2 Navigation: From Theory to Practice

June 10, 2024 15 min read Meraj Hossain Promit
ROS2 Navigation

> Introduction

Autonomous navigation is one of the most fundamental capabilities any mobile robot must possess. The ROS2 Navigation Stack — commonly known as Nav2 — provides a production-grade, highly configurable framework for achieving exactly this. Whether you are building a warehouse AMR, a delivery robot, or a research platform, Nav2 gives you the building blocks to get from point A to point B reliably.

In this guide we walk through the entire Nav2 stack from the ground up. We will cover the costmap architecture, global and local planners, AMCL-based localization, lifecycle node management, and behavior trees that orchestrate the high-level navigation logic. By the end you will have a working understanding of every moving part and how to tune them for your specific robot.

> Costmaps: Global and Local

A costmap is a 2D grid where each cell holds a cost value representing how dangerous it is to place the robot's footprint there. Nav2 maintains two separate costmaps: a global costmap used by the planner and a local costmap used by the controller.

Costmap Layers

  • StaticLayer: Inflates obstacles from a static map
  • ObstacleLayer: Marks/clears dynamic obstacles from sensor data
  • InflationLayer: Spreads cost outward from lethal obstacles
  • VoxelLayer: 3D-aware obstacle marking using depth cameras
  • SpeedLimitLayer: Annotates regions with maximum speed constraints

global_costmap params (nav2_params.yaml)

global_costmap:
  global_costmap:
    ros__parameters:
      update_frequency: 1.0
      publish_frequency: 1.0
      global_frame: map
      robot_base_frame: base_link
      use_sim_time: True
      robot_radius: 0.22
      resolution: 0.05
      track_unknown_space: true
      plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
      static_layer:
        plugin: "nav2_costmap_2d::StaticLayer"
        map_subscribe_transient_local: True
      obstacle_layer:
        plugin: "nav2_costmap_2d::ObstacleLayer"
        observation_sources: scan
        scan:
          topic: /scan
          max_obstacle_height: 2.0
          clearing: True
          marking: True
          data_type: "LaserScan"
      inflation_layer:
        plugin: "nav2_costmap_2d::InflationLayer"
        cost_scaling_factor: 3.0
        inflation_radius: 0.55

The local costmap is typically smaller (3–5 m rolling window) and updates much faster (5–10 Hz) so the controller can react to dynamic obstacles in real time. Increase inflation_radius for wider robots and reduce cost_scaling_factor when corridors are narrow.

> Path Planners and Controllers

Nav2 separates path planning from path following. The planner computes a feasible global route; the controller drives the robot along that route while avoiding local obstacles.

Global Planners

  • NavFn (Dijkstra/A*): Classic grid-based planner
  • Smac Hybrid-A*: Kinematically feasible paths
  • Smac Lattice: For Ackermann-steered robots
  • Theta*: Any-angle path planning

Local Controllers

  • DWB (Dynamic Window): Samples velocity space
  • TEB (Timed Elastic Band): Trajectory optimization
  • MPPI: Model Predictive Path Integral
  • RPP (Regulated Pure Pursuit): Simple and robust

Configuring Smac Hybrid-A* Planner

planner_server:
  ros__parameters:
    expected_planner_frequency: 20.0
    planner_plugins: ["GridBased"]
    GridBased:
      plugin: "nav2_smac_planner/SmacPlannerHybrid"
      downsample_costmap: false
      downsampling_factor: 1
      tolerance: 0.25
      allow_unknown: true
      max_iterations: 1000000
      max_on_approach_iterations: 1000
      max_planning_time: 5.0
      motion_model_for_search: "REEDS_SHEPP"
      angle_quantization_bins: 72
      analytic_expansion_ratio: 3.5
      minimum_turning_radius: 0.40
      reverse_penalty: 2.0
      change_penalty: 0.05
      non_straight_penalty: 1.20
      cost_penalty: 2.0
      lookup_table_size: 20.0
      cache_obstacle_heuristic: false
      smooth_path: True

> AMCL Localization

Adaptive Monte Carlo Localization (AMCL) estimates the robot's pose within a known map by maintaining a particle filter. Each particle is a hypothesis about where the robot might be; particles that match sensor observations survive while inconsistent ones are pruned.

Key AMCL Parameters

amcl:
  ros__parameters:
    use_sim_time: True
    alpha1: 0.2   # rotation noise from rotation
    alpha2: 0.2   # rotation noise from translation
    alpha3: 0.2   # translation noise from translation
    alpha4: 0.2   # translation noise from rotation
    alpha5: 0.2   # strafe noise (holonomic only)
    base_frame_id: "base_footprint"
    beam_skip_distance: 0.5
    beam_skip_error_threshold: 0.9
    beam_skip_threshold: 0.3
    do_beamskip: false
    global_frame_id: "map"
    lambda_short: 0.1
    laser_likelihood_max_dist: 2.0
    laser_max_range: 100.0
    laser_min_range: -1.0
    laser_model_type: "likelihood_field"
    max_beams: 60
    max_particles: 2000
    min_particles: 500
    odom_frame_id: "odom"
    pf_err: 0.05
    pf_z: 0.99
    recovery_alpha_fast: 0.0
    recovery_alpha_slow: 0.0
    resample_interval: 1
    robot_model_type: "nav2_amcl::DifferentialMotionModel"
    save_pose_rate: 0.5
    sigma_hit: 0.2
    tf_broadcast: true
    transform_tolerance: 1.0
    update_min_a: 0.2
    update_min_d: 0.25
    z_hit: 0.5
    z_max: 0.05
    z_rand: 0.5
    z_short: 0.05
    scan_topic: scan

Initial Pose Estimation

AMCL requires a rough initial pose. Publish it programmatically or via RViz2:

import rclpy
from geometry_msgs.msg import PoseWithCovarianceStamped

rclpy.init()
node = rclpy.create_node('pose_publisher')
pub = node.create_publisher(
    PoseWithCovarianceStamped, '/initialpose', 10)

msg = PoseWithCovarianceStamped()
msg.header.frame_id = 'map'
msg.pose.pose.position.x = 0.0
msg.pose.pose.position.y = 0.0
msg.pose.pose.orientation.w = 1.0
# Covariance: [xx, xy, xθ, yx, yy, yθ, θx, θy, θθ ...]
msg.pose.covariance[0]  = 0.25   # x variance
msg.pose.covariance[7]  = 0.25   # y variance
msg.pose.covariance[35] = 0.0685 # yaw variance

pub.publish(msg)
node.destroy_node()
rclpy.shutdown()

> Lifecycle Nodes and the Nav2 Bringup

ROS2 lifecycle nodes introduce a managed node state machine with well-defined transitions: Unconfigured → Inactive → Active → Finalized. Nav2 uses lifecycle nodes throughout so the system can be brought up and torn down deterministically, which is critical for safety-critical applications.

Lifecycle State Machine

  • configure(): Allocate resources, load parameters, set up publishers/subscribers
  • activate(): Start processing data, publish transforms
  • deactivate(): Stop processing but keep resources allocated
  • cleanup(): Release all resources, return to Unconfigured
  • shutdown(): Terminate the node entirely

Nav2 Lifecycle Manager Configuration

lifecycle_manager:
  ros__parameters:
    use_sim_time: True
    autostart: True
    bond_timeout: 4.0
    attempt_respawn_reconnection: true
    bond_respawn_max_duration: 10.0
    node_names:
      - map_server
      - amcl
      - planner_server
      - smoother_server
      - controller_server
      - behavior_server
      - bt_navigator
      - waypoint_follower
      - velocity_smoother

> Behavior Trees for Navigation

Nav2's bt_navigator executes behavior trees (BTs) defined as XML files. BTs give you a modular, readable way to encode navigation logic including retries, recovery behaviors, and goal checking — all without tangled state-machine code.

Default Navigate-to-Pose BT (simplified)

<root main_tree_to_execute="MainTree">
  <BehaviorTree ID="MainTree">
    <RecoveryNode number_of_retries="6" name="NavigateRecovery">
      <PipelineSequence name="NavigateWithReplanning">
        <RateController hz="1.0">
          <RecoveryNode number_of_retries="1" name="ComputePathRecovery">
            <ComputePathToPose goal="{goal}"
                              path="{path}"
                              planner_id="GridBased"/>
            <ClearEntireCostmap service_name="global_costmap/clear_entirely_global_costmap"/>
          </RecoveryNode>
        </RateController>
        <RecoveryNode number_of_retries="1" name="FollowPathRecovery">
          <FollowPath path="{path}"
                      controller_id="FollowPath"/>
          <ClearEntireCostmap service_name="local_costmap/clear_entirely_local_costmap"/>
        </RecoveryNode>
      </PipelineSequence>
      <Sequence name="RecoverySequence">
        <ClearEntireCostmap service_name="local_costmap/clear_entirely_local_costmap"/>
        <ClearEntireCostmap service_name="global_costmap/clear_entirely_global_costmap"/>
        <Spin spin_dist="1.57"/>
        <Wait wait_duration="5"/>
        <BackUp backup_dist="0.30" backup_speed="0.05"/>
      </Sequence>
    </RecoveryNode>
  </BehaviorTree>
</root>

The tree above first tries to compute a path and follow it. If following fails it clears costmaps. If planning fails it spins in place or backs up. This entire behavior is hot-swappable — just point bt_navigator at a different XML file.

> Real-World Examples and Tuning

Configuration is half the battle. Here are concrete tuning tips gathered from deploying Nav2 on actual hardware.

Sending Goals Programmatically

from nav2_simple_commander.robot_navigator import BasicNavigator
from geometry_msgs.msg import PoseStamped
import rclpy

rclpy.init()
navigator = BasicNavigator()
navigator.waitUntilNav2Active()

goal = PoseStamped()
goal.header.frame_id = 'map'
goal.header.stamp = navigator.get_clock().now().to_msg()
goal.pose.position.x = 3.0
goal.pose.position.y = 1.5
goal.pose.orientation.w = 1.0

navigator.goToPose(goal)

while not navigator.isTaskComplete():
    feedback = navigator.getFeedback()
    print(f'Distance remaining: {feedback.distance_remaining:.2f} m')

result = navigator.getResult()
print(f'Navigation result: {result}')

Common Tuning Issues

  • Robot oscillates: Reduce DWB max_vel_theta or increase goal_dist_tol
  • Planner times out: Increase max_planning_time or reduce inflation radius
  • Ghost obstacles: Tune obstacle_layer.track_unknown_space and sensor clearing params
  • AMCL drifts: Increase max_particles and verify odometry covariance
  • Costmap lag: Synchronize sensor timestamps with transform_tolerance

Waypoint Following with Intermediate Tasks

from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult
from geometry_msgs.msg import PoseStamped

waypoints = []
for (x, y, yaw) in [(1.0, 0.0, 0.0), (2.0, 1.0, 1.57), (0.0, 2.0, 3.14)]:
    p = PoseStamped()
    p.header.frame_id = 'map'
    p.header.stamp = navigator.get_clock().now().to_msg()
    p.pose.position.x = x
    p.pose.position.y = y
    import math
    p.pose.orientation.z = math.sin(yaw / 2)
    p.pose.orientation.w = math.cos(yaw / 2)
    waypoints.append(p)

navigator.followWaypoints(waypoints)
while not navigator.isTaskComplete():
    pass

if navigator.getResult() == TaskResult.SUCCEEDED:
    print('All waypoints reached!')

> Conclusion

Nav2 is a mature, extensible stack that handles the hard problems of autonomous navigation — costmapping, path planning, trajectory control, localization, and recovery — so you can focus on your application. The key to success is understanding which component to tune when things go wrong.

Start with the default parameters, profile your robot in simulation, and iterate. The behavior tree abstraction makes it easy to add custom logic without touching the core navigation code.

Ready to Navigate?

Clone the nav2_bringup repository, launch the Turtlebot3 simulation, and start sending goals via the simple commander API. The community Discord and GitHub Discussions are great resources when you get stuck.

ROS2 Nav2 SLAM Robotics Autonomous Navigation Behavior Trees

// Related Articles

Deep RL ROS2

Deep RL in ROS2

A complete guide to integrating Deep Reinforcement Learning with ROS2.

Read More
SLAM RL

SLAM with Reinforcement Learning

Combining SLAM and RL for curiosity-driven autonomous exploration.

Read More
YOLO Object Detection

Real-time Object Detection

Deploying YOLOv8 on a robot with ROS2 for real-time perception.

Read More