2. 시뮬레이션
2.12Nav2 설정 읽기
2.10장과 같은 방식으로 진행합니다. VM에는 지도도 /scan도 오도메트리도 없으므로 Nav2를 실행하지 않습니다. 실제 설정 파일을 읽고, 값이 서로 어떻게 맞물리는지 이해하는 장입니다. 실기기 실행은 4.7장입니다.
Nav2 구조
Nav2는 노드 하나가 아니라 서버 여러 개의 모음입니다. tiki_navigation이 띄우는 서버는 nav2_bringup의 launch 파일에서 확인했습니다.
| 구분 | 노드 | 하는 일 |
|---|---|---|
| 위치 추정 | map_server | 저장된 지도(.yaml + 이미지)를 /map으로 발행 |
| 위치 추정 | amcl | /scan과 오도메트리로 map → odom TF를 계산 |
| 경로 계획 | planner_server | 현재 위치에서 목표까지 전역 경로 계산 (TIKI는 NavfnPlanner) |
| 경로 추종 | controller_server | 지역 계획기(TIKI는 DWB)로 경로를 따라가는 속도 명령 계산 |
| 경로 추종 | smoother_server | 경로를 부드럽게 다듬는 서버 |
| 장애물 정보 | 코스트맵 | 전역과 지역 두 장이 각각 planner와 controller 안에서 동작 |
| 복구 | behavior_server | 제자리 회전(spin), 후진(backup) 같은 복구 동작 |
| 조율 | bt_navigator | 행동 트리(Behavior Tree)로 계획, 추종, 복구의 순서를 결정 |
| 후처리 | velocity_smoother | 속도 명령의 최대치와 가속도를 제한 |
| 부가 | waypoint_follower | 여러 지점을 차례로 방문 |
| 관리 | lifecycle_manager_* | 위 노드들의 lifecycle 전환을 순서대로 수행 |
사용자가 목표 지점을 주는 통로는 1.6장에서 배운 액션입니다. bt_navigator가 NavigateToPose 액션 서버(/navigate_to_pose)를 제공합니다. 목표를 보내면 피드백으로 남은 거리 등이 오고, 도착하면 결과가 오며, 중간에 취소할 수 있습니다. RViz2의 Nav2 Goal 버튼도 이 액션 클라이언트입니다.
목표 하나가 처리되는 흐름은 이렇습니다.
bt_navigator가 행동 트리를 돌립니다. 기본 트리는 경로를 계산하고 따라가며, 막히면 복구 동작을 하고 다시 계산하는 구조입니다(navigate_to_pose_w_replanning_and_recovery.xml).planner_server가 전역 코스트맵 위에서 경로를 계산합니다.controller_server가 지역 코스트맵을 보며 경로를 따라갈 속도를 정해cmd_vel_nav로 보냅니다.velocity_smoother가cmd_vel_nav를 받아 한계 안으로 다듬어cmd_vel로 내보냅니다.diff_controller가cmd_vel을 받아 바퀴를 돌립니다. 2.6장에서 본 그/cmd_vel입니다.
4번과 5번의 이름 연결은 두 곳의 리매핑에서 나옵니다. nav2_bringup의 navigation_launch.py가 controller_server의 cmd_vel을 cmd_vel_nav로, velocity_smoother의 입력을 cmd_vel_nav, 출력 cmd_vel_smoothed를 cmd_vel로 바꿉니다. tiki_control 런치는 diff_controller의 입력을 /cmd_vel로 바꿉니다. 그래서 TIKI에서는 속도 명령이 반드시 속도 평활기를 지나서 바퀴에 닿습니다.
실행 파일 읽기: bringup.launch.py
tiki_navigation/launch/bringup.launch.py는 nav2_bringup 예제 launch를 바탕으로 한 구조입니다. 인자부터 봅니다.
| 인자 | 기본값 | 의미 |
|---|---|---|
namespace | '' | 전체를 이 이름공간 아래에 둠 |
use_namespace | false | true일 때만 위 namespace를 적용 |
slam | False | True면 SLAM 모드, False면 저장된 지도 + AMCL 모드 |
map | 없음(필수) | 지도 .yaml 파일의 전체 경로 |
use_sim_time | false | 시뮬레이션 시계 사용 여부 (2.9장) |
params_file | tiki_navigation/params/nav2_params.yaml | 모든 노드가 읽는 파라미터 파일 |
autostart | true | 시작 후 lifecycle을 자동으로 활성화 |
use_composition | True | 노드를 한 컨테이너(nav2_container)에 합쳐서 실행 |
use_respawn | False | 노드가 죽으면 다시 띄움 (composition을 끈 경우에 적용) |
log_level | info | 로그 수준 |
map에는 기본값이 없어서 slam:=True로 SLAM을 켜더라도 인자를 주어야 launch가 시작됩니다.
GroupAction 안의 세 IncludeLaunchDescription을 보면 구조가 드러납니다.
IncludeLaunchDescription(..., 'slam_launch.py',
condition=IfCondition(slam), ...),
IncludeLaunchDescription(..., 'localization_launch.py',
condition=IfCondition(PythonExpression(['not ', slam])), ...),
IncludeLaunchDescription(..., 'navigation_launch.py', ...),
slam이 참이면 nav2_bringup의slam_launch.py가, 거짓이면localization_launch.py(map_server+amcl)가 실행됩니다. 둘 중 하나만 돕니다.navigation_launch.py는 항상 실행되어 위 표의 경로 계획, 추종, 복구, 속도 평활 서버를 띄웁니다.use_composition이 참이면component_container_isolated실행 파일의nav2_container가 먼저 뜨고, 서버들은 그 컨테이너에 로드됩니다. 프로세스 수가 줄어듭니다.
위에서 nav2_params.yaml 안의 use_sim_time: False가 여기저기 보였을 것입니다. 파일에 값이 적혀 있어도 launch는 RewrittenYaml로 파라미터 파일을 읽어 use_sim_time을 launch 인자 값으로, map_server의 yaml_filename을 map 인자의 경로로 바꿉니다. 이렇게 고친 내용으로 임시 파일을 만들어 노드에 넘깁니다. 그래서 yaml_filename: ""처럼 비어 있는 값이 있어도 정상입니다. 값이 의도와 다르게 들어갔다면 파일이 아니라 launch 인자를 먼저 봅니다.
설정 파일 읽기: nav2_params.yaml
347줄 중에서 동작에 영향이 큰 부분만 읽습니다.
AMCL: 저장된 지도 위에서의 위치 추정
AMCL은 입자 필터로 위치를 추정합니다. 로봇이 있을 법한 자세 후보(입자)를 많이 뿌리고, 스캔이 지도와 잘 맞는 후보에 점수를 주며 추려 갑니다.
| 파라미터 | 값 | 의미 |
|---|---|---|
min_particles / max_particles | 500 / 3000 | 입자 수 범위. 위치가 불확실하면 늘고 확실하면 줄어듦 |
laser_model_type | likelihood_field | 스캔 점이 지도의 장애물에 얼마나 가까운지로 점수를 매기는 모델 |
max_beams | 60 | 한 번의 갱신에 쓰는 스캔 빔 수 |
robot_model_type | DifferentialMotionModel | 차동구동 이동 모델 |
alpha1 ~ alpha5 | 모두 0.2 | 오도메트리 잡음 정도. 클수록 오도메트리를 덜 믿음 |
update_min_d / update_min_a | 0.25 m / 0.2 rad | 이만큼 움직여야 필터를 갱신 |
base_frame_id / odom_frame_id / global_frame_id | base_footprint / odom / map | 사용하는 프레임 |
tf_broadcast | true | AMCL이 map → odom TF를 발행 |
scan_topic | scan | 구독할 토픽 (이름공간을 따라가는 상대 이름) |
tf_broadcast: true가 중요합니다. 2.10장에서 slam_toolbox도 map → odom을 발행했습니다. 프레임의 부모는 하나뿐이라 둘을 동시에 띄우면 TF가 충돌합니다. 그래서 bringup.launch.py가 slam에 따라 둘 중 하나만 실행합니다. 지도를 만들 때는 SLAM, 지도를 쓸 때는 AMCL입니다. tiki_slam을 따로 띄운 채 slam:=False로 Nav2를 켜는 일은 피하세요.
AMCL을 쓸 때는 시작 직후 로봇이 지도의 어디에 있는지 알려 줘야 합니다. RViz2의 2D Pose Estimate로 초기 위치를 한 번 찍어 줍니다(/initialpose).
DWB 지역 계획기
controller_server 아래 FollowPath가 DWB입니다. DWB는 짧은 시간 동안의 후보 궤적을 여러 개 만들고, 궤적마다 비용 함수(critic) 점수를 합산해 합이 가장 좋은 궤적의 속도를 명령으로 내보냅니다.
controller_frequency: 10.0
FollowPath:
max_vel_x: 0.26
max_vel_theta: 1.0
acc_lim_x: 2.5
acc_lim_theta: 3.2
vx_samples: 20
vtheta_samples: 20
sim_time: 1.7
controller_frequency: 10.0: 1초에 10번, 즉 0.1초마다 새 속도를 계산합니다.max_vel_x: 0.26(m/s),max_vel_theta: 1.0(rad/s): DWB가 후보로 고려하는 속도의 상한입니다.vx_samples: 20,vtheta_samples: 20: 직진 속도 20단계와 회전 속도 20단계를 조합하면 한 번에 400개 안팎의 후보가 나옵니다.vy_samples: 5는max_vel_y가 0이라 의미가 없습니다.sim_time: 1.7(초): 각 후보를 1.7초 앞까지 시뮬레이션합니다. 0.26 m/s로 달리면 약 0.44 m, 0.15 m/s이면 약 0.26 m 앞을 내다봅니다.
critic은 일곱 개이고 각자 가중치(scale)가 있습니다.
| critic | scale | 점수 기준 (DWB 문서 기준) |
|---|---|---|
PathAlign | 32.0 | 궤적이 전역 경로와 방향이 얼마나 맞나 (forward_point_distance 0.1 m 앞 지점 기준) |
PathDist | 32.0 | 궤적 끝이 전역 경로에서 얼마나 먼가 |
GoalAlign | 24.0 | 궤적이 목표 자세와 얼마나 맞나 |
GoalDist | 24.0 | 궤적 끝이 목표에서 얼마나 먼가 |
RotateToGoal | 32.0 | 목표 근처에서만 목표 방향으로 제자리 회전을 허용 |
BaseObstacle | 0.02 | 궤적이 지나는 코스트맵 비용 (inflation과 함께 쓰는 것이 좋다고 문서에 설명됨) |
Oscillation | 없음 | 앞뒤로 왔다 갔다 하는 움직임을 방지 |
BaseObstacle의 가중치가 경로 추종 critic들보다 훨씬 작은 것(0.02 대 32)은 눈에 띕니다. 장애물 피하기는 비용 크기와 코스트맵 설정이 영향을 주는 방식이고, 실기기에서 장애물 곁을 지나가는 모습을 보며 판단합니다.
도착 판정과 진행 확인
progress_checker:
required_movement_radius: 0.5
movement_time_allowance: 10.0
general_goal_checker:
xy_goal_tolerance: 0.25
yaw_goal_tolerance: 0.25
- 도착 판정은 목표에서 0.25 m 이내이고 방향 차이가 0.25 rad(약 14.3도) 이내면 성공입니다. 로봇 폭의 두 배가 넘는 여유라서, 정밀하게 목표 자리에 서야 하는 작업에는 맞지 않을 수 있습니다.
- 진행 확인은 10초 안에 0.5 m 이상 움직이지 못하면 실패로 봅니다. 평균 0.05 m/s 이상 나아가야 한다는 뜻입니다. 속도 평활기가 막는 최대 속도가 0.15 m/s이니 평소에는 여유가 있지만, 장애물 앞에서 천천히 돌아 나올 때는 걸릴 수 있습니다.
코스트맵
코스트맵은 격자 한 칸마다 0~254의 비용을 담은 지도입니다. 지도 위의 장애물이 있는 칸은 높게, 비어 있는 칸은 0으로 둡니다. 전역과 지역 두 장을 씁니다.
| 항목 | 지역(local_costmap) | 전역(global_costmap) |
|---|---|---|
| 기준 프레임 | odom | map |
| 크기 | 3 m x 3 m 창이 로봇을 따라 이동 (rolling_window) | 지도 전체 |
| 해상도 | 0.05 m | 0.05 m |
| 칸 수 | 60 x 60 = 3,600 | 지도 크기에 따라 다름 |
| 갱신 / 발행 | 5 Hz / 2 Hz | 1 Hz / 1 Hz |
| 층(plugins) | voxel_layer, inflation_layer | static_layer, obstacle_layer, inflation_layer |
robot_radius | 0.12 m | 0.22 m |
inflation_radius / cost_scaling_factor | 0.2 m / 2.0 | 0.2 m / 2.0 |
| 기타 | track_unknown_space: true |
지역 코스트맵에는 static_layer: 설정 블록이 있지만 plugins 목록에 없어서 사용되지 않습니다. 파일에 있는 설정 블록도 plugins 목록에 올라야 동작합니다.
LiDAR 입력은 두 코스트맵이 같은 값을 씁니다.
scan:
topic: /scan
max_obstacle_height: 2.0
clearing: True
marking: True
raytrace_max_range: 3.0
obstacle_max_range: 2.5
marking: True: 스캔 점을 장애물로 표시합니다.obstacle_max_range: 2.5m보다 먼 점은 장애물로 표시하지 않습니다.clearing: True: 로봇에서 스캔 점까지 빔이 지나간 칸을 비웁니다.raytrace_max_range: 3.0m까지 비웁니다.- 전역 코스트맵의
track_unknown_space: true는 지도에 없는 칸을 "알 수 없음"(255)으로 구분해 추적하라는 설정입니다.
inflation: 장애물 주변에 비용을 번지게
장애물 칸만 높은 비용으로 두면 경로가 벽에 바짝 붙습니다. inflation 층은 장애물에서 멀어질수록 줄어드는 비용을 주변에 번지게 해서 경로가 벽에서 떨어지도록 유도합니다. 아래 규칙은 navigation2 humble 브랜치의 InflationLayer와 cost_values.hpp에서 읽었습니다.
| 장애물에서 거리 d | 비용 | 이름 |
|---|---|---|
| 0 (장애물 칸) | 254 | lethal |
0 초과 ~ robot_radius 이하 | 253 | inscribed (로봇이 걸치면 충돌) |
robot_radius 초과 ~ inflation_radius 이하 | 252 x exp(-cost_scaling_factor x (d - robot_radius)) 를 정수로 내림 | inflated |
inflation_radius 초과 | 0 | 영향 없음 |
감쇠 계수가 클수록 비용이 빠르게 줄어듭니다. 지수 감쇠이므로 거리가 두 배 멀어진다고 비용이 반이 되지는 않습니다.
아래 모형에서 슬라이더를 움직여 보세요. 기본값 버튼으로 TIKI의 두 코스트맵 값을 불러올 수 있습니다. 모형은 장애물이 한 칸뿐이라고 가정합니다. 실제 층은 여러 장애물 중 높은 비용을 씁니다.
주황 점선: 로봇 반경, 파랑 점선: 번짐 반경. 세로축은 비용(0~254)입니다.
- 장애물 칸, 비용 254 (lethal)
- 로봇 반경 안, 비용 253 (inscribed)
- 번짐 구간, 253 아래로 줄어드는 비용
navigation2 humble 브랜치의 InflationLayer::computeCost와 cost_values.hpp(254, 253, 0)를 읽고 옮긴 식입니다. 비용 = 252 x exp(-감쇠 계수 x (거리 - 로봇 반경)) 를 정수로 내립니다. 장애물이 하나뿐이라고 가정한 모형이며 실제 레이어는 여러 장애물 중 큰 값을 씁니다.
직접 해 보기
- TIKI 로컬 값(반경 0.12, 번짐 0.2, 계수 2.0)에서 d = 0.17 m와 0.20 m의 비용을 읽어 보세요. 공식으로 계산하면 각각 228과 214입니다. 0.2 m를 넘는 순간 비용이 어떻게 되나요?
- 계수를 2.0에서 10.0으로 올리면 d = 0.17 m의 비용이 어떻게 변하나요?
플래너, 행동 서버, 속도 평활기
planner_server: NavfnPlanner에 use_astar: true로 A* 탐색을 씁니다. tolerance: 0.5(m)는 목표 칸에 닿지 못해도 이 거리 안이면 경로를 인정한다는 뜻이고, allow_unknown: true는 알 수 없는 칸을 지나는 경로를 허용합니다. 전역 코스트맵의 track_unknown_space: true와 짝을 이룹니다.
behavior_server: spin, backup, drive_on_heading, assisted_teleop, wait 다섯 가지 동작을 등록했습니다. 회전 한계 max_rotational_vel: 1.0, min_rotational_vel: 0.4(rad/s), rotational_acc_lim: 3.2가 spin에 적용됩니다. 행동 트리가 막힘을 만났을 때 이 중 몇 가지를 순서대로 시도합니다.
velocity_smoother는 마지막 관문입니다.
smoothing_frequency: 10.0
feedback: "OPEN_LOOP"
max_velocity: [0.15, 0.0, 1.0]
max_accel: [2.5, 0.0, 3.2]
- 배열은 [x 직진, y 옆걸음, θ 회전] 순서입니다. 직진 최대 0.15 m/s, 회전 최대 1.0 rad/s입니다.
OPEN_LOOP는 오도메트리 대신 직전에 내보낸 명령을 현재 속도로 가정하고 가속도를 제한한다는 뜻입니다.velocity_timeout: 1.0(초) 동안 새 명령이 없으면 평활기가 멈춤 명령을 냅니다.
설정 일관성 점검
설정 파일은 여러 사람이 여러 시점에 값을 넣은 결과물이라, 한 줄씩은 맞아도 줄끼리 어긋나 있을 수 있습니다. 읽을 때는 이 값이 다른 곳의 어떤 값과 맞물리는지 같이 확인합니다. 아래는 이 패키지에서 찾은 세 가지입니다. 어느 쪽이 틀렸는지 판정하지 않고 확인할 거리로 다룹니다.
1. 속도 상한이 네 군데에 있다
| 위치 | 직진 (m/s) | 회전 (rad/s) |
|---|---|---|
DWB max_vel_x, max_vel_theta | 0.26 | 1.0 |
tiki_controllers.yaml의 diff_controller | 0.2 | 0.85 |
velocity_smoother의 max_velocity | 0.15 | 1.0 |
behavior_server의 max_rotational_vel | 1.0 |
속도 명령은 DWB, 평활기, diff_controller 순으로 지납니다. 가장 작은 값이 실제 한계가 되므로 계산상 직진은 0.15 m/s, 회전은 0.85 rad/s입니다. DWB는 0.26 m/s까지 가는 궤적을 평가하고 고르는데, 실제로는 그보다 낮게 깎여서 나갑니다. 평가한 속도와 실행되는 속도가 달라서, 경로 추종이 설계보다 느슨해질 수 있습니다.
실기기에서 확인할 것:
ros2 topic echo /cmd_vel_nav(평활기 입력)와/cmd_vel(평활기 출력)의linear.x최댓값을 비교합니다.ros2 topic echo /odom의 속도가/cmd_vel을 따라가는지 봅니다.- 의도한 최대 속도를 정하고 세 곳을 같은 값으로 맞출지 판단합니다.
2. base_link와 base_footprint가 섞여 있다
| 프레임 | 사용하는 곳 |
|---|---|
base_link | bt_navigator, 두 코스트맵, behavior_server |
base_footprint | amcl, slam_toolbox, diff_controller의 odom |
URDF의 base_joint는 base_footprint → base_link를 z 방향 0.0365 m, 회전 없이 이었습니다. 위치 차이가 높이뿐이라서 평면 주행에는 영향이 거의 없고, 두 프레임 모두 TF에 존재하므로 조회도 성공할 것입니다. 점검은 이렇게 합니다.
ros2 run tf2_ros tf2_echo base_footprint base_link
ros2 run tf2_tools view_frames
값이 (0, 0, 0.0365)이고 회전이 0인지 확인합니다. 프레임을 하나로 통일할지는 이후 수정할 때의 선택입니다.
실기기에서 실행하기 (4.7장 미리보기)
지금은 실행하지 마세요. 4.7장 순서를 미리 보는 것입니다. 2.10장에서 저장한 지도 파일을 쓴다고 가정합니다.
# 터미널 1: 로봇 기본 구동
ros2 launch tiki_bringup tiki.launch.py
# 터미널 2: Nav2 시작 (AMCL 모드)
ros2 launch tiki_navigation bringup.launch.py map:=$HOME/maps/room1.yaml
# 터미널 3: RViz2에서 2D Pose Estimate로 초기 위치를 찍고 Nav2 Goal로 목표를 줌
rviz2
# 또는 액션 클라이언트로 직접 목표 전달
ros2 action send_goal /navigate_to_pose nav2_msgs/action/NavigateToPose \
"{pose: {header: {frame_id: map}, pose: {position: {x: 1.0, y: 0.0}, orientation: {w: 1.0}}}}" --feedback
처음에는 가까운 목표(1 m 안팎)를 주고 속도 평활기 아래의 실제 속도와 코스트맵을 RViz2에서 같이 관찰합니다. ros2 action send_goal은 1.6장에서 쓴 명령입니다.
한눈에 정리
- Nav2는 지도와 위치 추정(
map_server,amcl), 계획(planner_server), 추종(controller_server), 복구(behavior_server), 조율(bt_navigator), 속도 제한(velocity_smoother) 서버의 모음이다. - 목표는
bt_navigator의NavigateToPose액션으로 보낸다. bringup.launch.py는slam이 참이면slam_launch.py, 아니면localization_launch.py를 실행하고navigation_launch.py는 항상 실행한다.map은 필수 인자다.- launch는
RewrittenYaml로use_sim_time과yaml_filename을 인자 값으로 덮어쓴다. - DWB는 0.1초마다 400개 안팎의 후보 궤적을 1.7초 앞까지 평가해 critic 점수가 가장 좋은 속도를 고른다.
- inflation 비용은 장애물 254, 로봇 반경 안 253, 그 밖은 252 x exp(-계수 x (거리 - 로봇 반경))이고 번짐 반경 밖은 0이다.
- 속도 상한은 DWB 0.26,
diff_controller0.2, 평활기 0.15 m/s로 셋이며 실제 한계는 가장 작은 값이다. - 설정은 줄끼리 값이 맞는지 점검하며 읽는다.
확인 문제
Q1. 로봇 반경 0.12 m, 번짐 반경 0.2 m, 감쇠 계수 2.0인 코스트맵에서 장애물 칸에서 0.17 m 떨어진 칸의 비용은 얼마인가요?
0.17은 로봇 반경 0.12를 넘고 번짐 반경 0.2 이내이므로 공식을 씁니다. 252 x exp(-2.0 x (0.17 - 0.12)) = 252 x exp(-0.1) = 약 228.0이라 정수로 내리면 228입니다.
Q2. DWB max_vel_x 0.26, diff_controller 직진 한계 0.2, velocity_smoother 0.15일 때 계산상 로봇의 실제 최대 직진 속도와 그 이유는 무엇인가요?
0.15 m/s입니다. 속도 명령이 이 세 곳을 모두 지나므로 가장 작은 값에 걸립니다. DWB가 0.26 m/s 궤적을 골라도 평활기가 0.15로 깎습니다.
Q3. bringup.launch.py를 slam:=True로 실행하면서 tiki_slam의 slam.launch.py도 따로 실행했습니다. 어떤 문제가 생기나요?
slam_toolbox가 둘 떠서 map → odom TF와 /map을 둘 다 발행합니다. TF 프레임의 부모는 하나뿐이어서 충돌합니다. 하나만 실행해야 합니다.
다음 장
다음 장에서는 설정으로만 읽은 이 구성 요소들을 에뮬레이터 안에서 직접 짜 봅니다. 지도에서 코스트맵을 만들고, A*로 경로를 계획하고, 그 경로를 따라가는 로봇을 실행합니다. 그 뒤에 2부의 미니 프로젝트가 이어집니다.