4. 실기기 연동
4.6시뮬과 실기기의 차이
4.5장에서 2.7장의 정사각형 코드를 로봇에 옮겼습니다. 에뮬레이터에서는 출발점에 정확히 돌아왔지만 실제 로봇은 몇 cm나 몇 도쯤 어긋납니다. 이 장은 그 어긋남이 어디서 오는지 하나씩 나눕니다. 파일에서 읽을 수 있는 것은 숫자로 쓰고, 읽을 수 없는 것은 측정 항목으로 남깁니다.
차이가 생기는 곳
| 요인 | 에뮬레이터 | 실기기 | 근거 또는 측정 방법 |
|---|---|---|---|
| 명령 지연 | 없음 | 노드 주기, 네트워크, 시리얼 루프, 모터 응답 | 아래 지연 절. 일부는 측정 필요 |
| 속도 한계와 정지 | 0.2 / 0.85로 자르고 타임아웃에 정지 | 같은 값. 정지 후 감속은 모터 몫 | tiki_controllers.yaml, 실험 1 |
| 바퀴와 바닥 | 미끄러짐 없음 | 미끄러짐, 바퀴 지름 편차 | 실험 1, 2 |
| 오도메트리 | 참값에 가깝게 만든 값 | 엔코더 적산값에 오차가 쌓임 | 아래 오도메트리 절, 실험 3 |
| 라이다 | 이상적인 측정 | 노이즈, 측정 실패, 최소·최대 거리 | rplidar_node.cpp |
| 시간 | 가상 시간 | 실제 시간, 두 컴퓨터의 시계 | ros2 topic delay |
| 전원 | 일정 | 배터리 전압이 변할 수 있음. OLED에서 전압 확인 | 아래 배터리 절 |
명령이 바퀴에 닿기까지
노드가 Twist를 내보낸 순간부터 바퀴가 그 속도로 돌 때까지 다음 단계를 거칩니다.
- 노드의 타이머: 10 Hz면 명령은 최대 100 ms 간격으로 나갑니다. 장애물을 본 시점과 명령이 바뀌는 시점이 이만큼 어긋납니다.
- 네트워크: VM에서 노드를 돌리면 무선망을 한 번 건넙니다. 크기는 측정해야 압니다.
diff_controller:controller_manager의 주기 루프에서 읽습니다. 주기는update_rate: 50이므로 20 ms입니다.diffdrive_tiki의write(): 바퀴 각속도를 정수로 환산해 시리얼로AT+M=<좌>,<우>를 보냅니다.- 모터 제어기:
on_init이AT+MODE=2(코드 주석은 PID 모드)와AT+PID=1.4,7.4,0,1.4,7.4,0을 보냅니다.
Nav2를 쓰면 속도 평활기와 DWB 주기(10 Hz)가 앞에 더 붙습니다. 이 장에서는 4.7장 몫으로 남깁니다.
시리얼 루프를 읽는다
controller_manager의 ros2_control_node는 제어 루프를 별도 스레드로 돌리며, 한 주기마다 read(), 컨트롤러 update(), write()를 차례로 부릅니다(ros2_control humble 브랜치 소스). 이 스레드에 실시간(SCHED_FIFO) 우선순위를 요청하고, 못 얻으면 경고 로그를 남깁니다. 로봇의 기본 구동 로그에 Could not enable FIFO RT scheduling policy가 있는지 보세요. 이 경고가 있어도 동작은 하지만 주기가 더 흔들릴 수 있습니다.
diffdrive_tiki.cpp가 한 주기에 하는 일은 다음과 같습니다.
| 호출 | 보내는 글자 | ReadLine 횟수 |
|---|---|---|
read() → readEncoder(false) | AT+EL? | 2 (첫 줄은 버리고 둘째 줄을 값으로 읽음) |
read() → readEncoder(true) | AT+ER? | 2 |
write() → writeMotorSpeed | AT+M=<좌>,<우> | 1 |
한 주기에 요청 세 번, 줄 읽기 다섯 번입니다. 요청마다 응답을 기다리는 구조라서 세 번의 왕복이 모두 끝나야 write()가 반환합니다.
시간을 어림해 봅니다. 921,600 baud, 8N1이면 한 글자에 10비트(시작 1, 데이터 8, 정지 1)가 들어가서 초당 92,160글자, 한 글자에 약 10.9 µs입니다. 요청 세 개는 AT+EL?\r\n 8글자 두 번과 AT+M=1146,1146\r\n 16글자 한 번으로 모두 32글자, 약 0.35 ms입니다. 응답 길이는 가정으로 어림합니다. 다섯 줄이 각각 16글자 이하라고 가정하면 80글자, 약 0.87 ms입니다. 합쳐서 1.2 ms 안팎이고 20 ms 주기의 6% 정도입니다. 이 값은 전송 시간뿐이며 응답 길이에 대한 가정이 들어 있습니다.
지연은 이렇게 정리됩니다. 노드 주기와 제어 주기 20 ms는 파일에서 읽힙니다. 시리얼 전송 자체는 위에서 어림한 대로 약 1 ms 규모입니다. 네트워크와 모터 응답은 측정합니다. 4.5장의 ros2 topic delay /scan(헤더 시간과 수신 시각의 차이, 두 컴퓨터의 시계가 맞아야 의미가 있음)과 ros2 topic hz /odom으로 일부를 잴 수 있습니다.
속도 한계와 정지
한계는 4.5장에서 다뤘으니 정지에 대해서만 덧붙입니다. 명령이 끊겨 0.5초가 지나면 컨트롤러가 속도 0을 명령합니다. 이 순간에도 로봇은 즉시 서지 않습니다. 모터 제어기가 PID로 속도를 줄이는 시간이 더 필요합니다. tiki_controllers.yaml에는 가속도 제한이 꺼져 있어서(has_acceleration_limits: false) 컨트롤러는 0.2에서 0으로 한 주기 만에 바꾸고, 감속은 전부 모터 제어기의 PID가 맡습니다. 얼마나 미끄러지며 서는지는 실험 1에서 잽니다.
바퀴와 바닥
컨트롤러는 바퀴가 구르는 만큼 로봇이 간다고 가정합니다. 바닥이 미끄러우면 바퀴는 돌았는데 로봇은 덜 갑니다. 엔코더는 바퀴 회전만 세므로 이 손실은 오도메트리에 나타나지 않습니다. 실제 이동은 줄었는데 기록은 그대로 남습니다.
두 바퀴가 서로 다르게 구르는 경우도 있습니다. 같은 wheel_radius: 0.0325를 두 바퀴에 쓰지만 실제 바퀴는 지름 편차, 타이어 마모, 하중 차이가 있습니다. 직진 명령인데 한쪽 바퀴의 실제 지름이 조금 크면 그쪽으로 휩니다. 다음 절에서 계산합니다.
오도메트리와 드리프트
diff_drive_controller는 엔코더 위치로 오도메트리를 적산합니다. 파라미터 정의 파일에서 open_loop의 기본값은 false이고 position_feedback은 true입니다. tiki_controllers.yaml은 둘 다 따로 정하지 않으므로 하드웨어가 올리는 바퀴 위치를 적산한다고 읽힙니다.
틱 한 개의 크기
diffdrive_tiki.cpp는 rad_per_tick = 2π/1170을 씁니다. 바퀴 반지름 0.0325 m를 곱해 계산합니다.
바퀴 둘레 = 2π × 0.0325 ≈ 0.2042 m
틱 한 개 = 2π / 1170 ≈ 0.005370 rad
틱 한 개의 거리 = 0.0325 × 0.005370 ≈ 0.0001745 m (0.1745 mm)
직진 1 m = 1 / 0.0001745 ≈ 5730틱 (바퀴 약 4.9회전)
틱 한 개의 회전 (한 바퀴만 한 틱 움직일 때) = 0.0001745 / 0.1924 ≈ 0.000907 rad (약 0.052도)
제자리 360도 = 각 바퀴 π × 0.1924 = 0.6044 m ≈ 3463틱
틱 하나가 0.17 mm이니 해상도 때문에 생기는 오차는 작습니다. 50 Hz에서 한 주기(20 ms)의 속도 분해능은 한 틱당 0.1745 mm / 0.02 s ≈ 8.7 mm/s입니다. 0.2 m/s의 4.4%에 해당합니다. 속도 값(left_wheel_vel)은 이 단위로 계단 모양이 되지만 위치에는 영향이 없습니다.
상수 오차가 쌓이는 방식
실제 오차는 해상도가 아니라 모델과 현실의 차이에서 옵니다. 엔코더는 바퀴 회전을 정확히 세지만 컨트롤러가 곱하는 반지름과 간격이 현실과 다르면 오차가 거리에 비례해 쌓입니다.
바퀴 반지름이 1% 크다 (양쪽 모두)
기록 1.000 m 이동 → 실제 1.010 m 이동 (+1 cm / m)
왼쪽 바퀴만 반지름이 1% 크다
두 바퀴 이동 거리 차이 = 0.01 × s (s는 이동한 거리)
방향 오차 ≈ 0.01 × s / L = 0.01 / 0.1924 ≈ 0.052 rad ≈ 3.0도 / m
1 m 직진 후 옆으로 벗어남 ≈ s × 방향오차 / 2 ≈ 2.6 cm
(오도메트리는 이 차이를 모르고 직진했다고 기록)
바퀴 간격 L이 실제로 1% 크다
제자리 360도 명령 → 실제 회전은 약 356.4도 (3.6도 모자람)
세 번째는 고무 바퀴의 접지 면 때문에 실제 유효 간격이 URDF의 0.1924 m와 다를 수 있다는 의미입니다. 몇 %인지는 실험 2에서 잽니다.
컨트롤러의 오도메트리 공분산(pose_covariance_diagonal)도 기본값이 모두 0입니다. tiki_controllers.yaml이 따로 정하지 않아서, 오도메트리는 "나는 틀리지 않는다"는 불확실도 0으로 발행됩니다. Nav2와 SLAM은 이 값을 믿지 않고 라이다로 보정합니다. 그래서 오도메트리가 조금 틀려도 지도 작성과 길 찾기가 돌아갑니다. 오도메트리만 믿는 2.7장의 정사각형 주행은 이런 보정이 없어서 오차가 그대로 보입니다.
직접 보기
아래 모델은 한 변 1 m 사각형을 이상적인 로봇과 오차가 있는 로봇에 똑같이 시킵니다. 회색은 이상적인 경로, 강조색 실선은 오차가 있는 로봇의 실제 경로, 점선은 그 로봇의 오도메트리가 믿는 경로입니다.
교육용 모델입니다. TIKI-MINI를 측정한 값이 아니며, 슬라이더 값은 오차가 어떤 모양으로 쌓이는지 보기 위한 임의의 값입니다. 로봇은 한 변 1 m, 속도 0.1 m/s의 사각형을 반시계 방향으로 돕니다.
- 이상적인 경로
- 실제 경로
- 오도메트리가 믿는 경로 (점선)
- 끝점과 출발점 거리 (실제)
- 6.5 cm
- 끝 방향 오차 (실제)
- -6.0°
- 오도메트리와 실제의 거리
- 23.4 cm
- 이상적인 경우의 끝점 거리
- 1.3 cm
소요 시간 57초. 시간으로 판단하는 방식에서는 지연이 경로를 바꾸지 않고 시작을 늦출 뿐입니다. 오도메트리로 판단하는 방식에서는 정지 명령이 늦게 닿아 변과 각도가 넘칩니다.
직접 해 보기
- 미끄러짐만 6%로 올리면 실제 경로와 오도메트리가 어떻게 달라지나요? 위 절의 "기록은 그대로, 실제는 덜 감"과 맞춰 보세요.
- 왼쪽 바퀴 오차를 1%로 두고 나머지를 0으로 해서, 사각형 끝점 거리를 읽어 보세요. 위에서 계산한 방향 오차와 비교합니다.
- "시간으로 판단"에서 지연을 500 ms로 올려도 경로는 같습니다. 오도메트리로 판단으로 바꾸면 변 길이와 각도가 어떻게 되나요? 정지 명령이 늦게 닿으면
속도 × 지연만큼 더 갑니다. - 모델의 슬라이더 값은 TIKI-MINI를 잰 값이 아닙니다. 실제 값은 아래 실험으로 구합니다.
라이다의 현실
tiki_bringup이 rplidar_ros의 rplidar_node를 angle_compensate: true, scan_mode: Standard, 프레임 laser로 실행합니다. 소스(rplidar_node.cpp, ros2 브랜치)에서 확인한 것은 다음과 같습니다.
| 항목 | 소스에서 읽은 내용 |
|---|---|
range_min | 0.15 m로 고정 |
range_max | 스캔 모드가 정한 max_distance. 멤버 기본값은 8.0 m이고 모드 설정 때 덮어씀 |
| 측정 실패 | 거리가 0이면 inf로 채움 |
| 필터 | range_min, range_max 밖의 값을 걸러내지 않음 |
scan_frequency | 파라미터 기본값 10 (UDP가 아닐 때) |
| 노드 수 | angle_compensate일 때 360 × 배수. 한 바퀴를 각도 칸으로 나눠 채움 |
TIKI-MINI의 LiDAR는 RPLIDAR C1이고 스캔 주파수는 10 Hz, 최대 측정 거리는 12 m입니다. rplidar_node는 시작할 때 current scan mode: ..., sample rate: ... Khz, max_distance: ... m을 로그로 남깁니다(소스의 로그 문구). 로봇에서 ros2 launch tiki_bringup tiki.launch.py를 실행한 터미널에서 이 줄을 찾아 값을 적어 두세요.
노이즈, 유리와 검은 표면, 햇빛의 영향은 광학식 거리 센서에 흔히 있는 약점입니다. 실제로 어떤 표면에서 inf가 나오는지 다음처럼 직접 봅니다.
ros2 topic echo /scan --once --field ranges --full-length | grep -c inf
벽 앞, 유리문 앞, 검은 천 앞에서 각각 실행해 inf가 든 줄이 몇 개인지 비교해 보세요. --full-length를 빼면 배열이 중간에서 잘립니다. 출력 형식에 따라 한 줄에 값이 하나가 아닐 수 있으니 숫자보다 표면별 차이를 보세요. 4.5장의 scan_guard.py가 정면 값이 하나도 없을 때 멈추도록 만든 것도 이 때문입니다.
QoS와 TF 시간
/scan은 4.5장에서 확인한 대로 Reliable로 발행됩니다. 무선망에서 구독자가 느리면 재전송 때문에 늦게 도착할 수 있고, 그 효과는 ros2 topic delay /scan으로 봅니다.
TF에서는 시간이 문제입니다. odom → base_footprint는 로봇에서 50 Hz로 발행되고 map → odom은 SLAM 또는 AMCL이 발행합니다. 이 변환을 쓰는 쪽은 "그 시각의 변환"을 찾으므로, 두 컴퓨터의 시계가 어긋나면 변환을 못 찾거나 미래 시각을 요구하는 경고가 납니다. 설정 파일의 허용 시간은 slam.yaml의 transform_timeout: 0.2, nav2_params.yaml의 AMCL transform_tolerance: 1.0, DWB transform_tolerance: 0.2, 행동 서버 0.1처럼 서로 다릅니다. VM과 로봇의 시각을 date로 비교해 크게 다르면 먼저 시각을 맞추세요.
배터리
배터리 전압은 사용하면서 변합니다. 전압은 로봇의 OLED에 표시되고, 저전압이 되면 충전 알림이 뜹니다. 펌웨어는 저전압 경고 상태에서 모터를 멈춥니다. 충전 중에는 주행할 수 없습니다. 배터리 전압과 전류는 ROS2 토픽으로 나오지 않으므로 OLED에서 확인합니다.
측정해서 확인하기
세 가지 실험으로 이 로봇의 실제 오차를 잽니다. 줄자, 마스킹 테이프, 각도 표시(바닥에 그은 선과 분도기 또는 테이프로 만든 십자)가 필요합니다. 로봇 주변 1 m 이상을 비우고 4.1장의 안전수칙을 지킵니다. 각 실험은 3회씩 반복하고 평균과 최댓값을 적습니다.
기본 구동은 켜져 있어야 합니다.
ros2 launch tiki_bringup tiki.launch.py
/odom의 값은 이렇게 읽습니다.
ros2 topic echo /odom --once --field pose.pose
실험 1: 직진 1 m
- 바닥에 출발선을 테이프로 표시하고 로봇의 바퀴 축을 맞춥니다.
/odom의position.x, y를 적습니다. - 아래 명령을 실행합니다. 20 Hz로 0.1 m/s를 10초 발행하므로 명령 상으로 1 m입니다.
- 로봇이 완전히 선 뒤 줄자로 이동 거리와 옆으로 벗어난 거리를 잽니다. 다시
/odom을 읽어 이동 거리를 계산합니다.
timeout 10 ros2 topic pub -r 20 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.1}}"
기록할 것:
| 항목 | 회차 1 | 회차 2 | 회차 3 |
|---|---|---|---|
| 줄자로 잰 이동 거리 (m) | |||
| 오도메트리 이동 거리 (m) | |||
| 비율 (줄자 / 오도메트리) | |||
| 옆으로 벗어난 거리 (cm, 왼쪽이 +) | |||
| 바닥 종류, 배터리 상태 |
timeout이 종료된 뒤에도 로봇은 곧바로 서지 않고 조금 더 갑니다. 이 거리도 줄자 값에 들어갑니다. 이 꼬리가 크면 명령이 끊긴 후 감속이 길다고 보면 됩니다.
해석: 비율이 1에서 얼마나 떨어졌는가가 바퀴 반지름·미끄러짐의 합산 효과입니다. 한쪽으로 일정하게 벗어나면 위에서 계산한 좌우 바퀴 차이를 의심합니다. 제안 기준: 비율은 0.97~1.03, 옆 이탈은 5 cm 이내. 이 값은 제안이며 로봇과 바닥에 따라 조정하세요.
실험 2: 제자리 360도
- 바닥에 로봇 앞쪽 방향의 기준선을 긋습니다.
- 0.4 rad/s로 한 바퀴를 도는 시간은
2π / 0.4 ≈ 15.7초입니다. - 실행하고 멈춘 뒤 기준선과 로봇 앞쪽이 이루는 각도를 잽니다.
timeout 15.7 ros2 topic pub -r 20 /cmd_vel geometry_msgs/msg/Twist "{angular: {z: 0.4}}"
기록할 것: 실제로 돈 각도(도), 오도메트리가 말하는 각도(/odom의 방향을 yaw로 환산), 로봇 중심이 밀린 거리(cm), 회전 방향(왼쪽이 +).
해석: 360도에서 모자라거나 넘친 비율이 바퀴 간격의 유효값과 미끄러짐의 합산입니다. 위 계산대로라면 간격 1% 오차가 3.6도입니다. 제안 기준: 360도 ± 5도 이내. 3회 값이 서로 크게 다르면 바닥 마찰이 일정하지 않은 것입니다.
실험 3: 반복 정사각형
4.5장의 square_driver.py를 노드 재시작만 하면서 3번 이어서 돌립니다. 로봇을 제자리로 옮기지 말고 끝난 자리에서 다시 시작합니다. 각 회차마다 출발선 대비 위치 이탈과 방향 오차를 적습니다.
| 항목 | 회차 1 | 회차 2 | 회차 3 |
|---|---|---|---|
| 출발선 대비 위치 이탈 (cm) | |||
| 방향 오차 (도) | |||
| 오도메트리가 믿는 이탈 (cm) |
해석: 이탈이 회차마다 커지면 오차가 계통적이라는 뜻이라 한 번에 한 요인(1번 직진 또는 2번 회전)으로 되돌려 찾을 수 있습니다. 회차마다 제멋대로면 미끄러짐 같은 우연 요인이 큽니다. 오도메트리가 믿는 이탈이 실제보다 훨씬 작으면 이 로봇의 오도메트리는 자기 오차를 못 본다는 뜻입니다. 제안 기준: 한 번 돌 때 끝점 10 cm, 방향 10도 이내.
세 실험의 결과로 GapDemo 슬라이더를 맞춰 보세요. 실제 로봇과 비슷한 곡선이 나오면 그 값이 이 로봇의 대략적 오차입니다.
한눈에 정리
- 같은 코드가 다르게 도는 이유는 지연, 속도 한계와 정지, 바닥과 바퀴, 오도메트리 적산, 라이다, 시간 어긋남이다.
- 제어 주기는 50 Hz(20 ms)이고 한 주기에 시리얼 요청 3번,
ReadLine5번이다. 전송 시간은 1 ms 안팎으로 어림된다. - 엔코더 한 틱은 0.1745 mm, 직진 1 m는 약 5730틱, 제자리 360도는 바퀴당 약 3463틱이다.
- 바퀴 반지름 1% 오차는 1 m당 1 cm, 좌우 바퀴 1% 차이는 1 m당 약 3도의 방향 오차다. 엔코더는 이 오차를 보지 못한다.
rplidar_node는range_min을 0.15 m로 고정하고 측정 실패를inf로 채우며 범위 밖 값을 걸러내지 않는다.- 시계가 어긋나면 TF 조회가 실패한다. 속도 한계와 정지, 배터리 영향은 실험으로 확인한다.
- 직진 1 m, 제자리 360도, 반복 정사각형을 3회씩 재서 로봇의 실제 오차를 기록한다.
확인 문제
Q1. 같은 직진 명령인데 로봇이 왼쪽으로 조금씩 휩니다. 오도메트리는 직진했다고 기록합니다. 가장 가능성 높은 원인과 확인 방법은?
좌우 바퀴의 실제 반지름이나 마찰 차이입니다. 엔코더는 바퀴 회전만 세므로 오도메트리는 이 차이를 보지 못합니다. 실험 1에서 옆 이탈을 잴 때 좌우 바퀴의 접지 상태와 지름을 줄자로 비교해 봅니다. 바퀴 하나의 지름 1% 차이는 1 m에 약 3도의 방향 오차를 만듭니다.
Q3. 엔코더 한 틱이 0.17 mm인데도 오도메트리가 1 m에서 몇 cm씩 어긋나는 이유는?
원인은 모델 오차입니다. 컨트롤러는 설정의 wheel_radius와 wheel_separation으로 틱을 거리와 각도로 바꿉니다. 실제 값이 1%만 달라도 거리에 비례해 쌓이고, 바닥에서 미끄러진 거리는 엔코더에 잡히지도 않습니다.
다음 장
4.7장에서는 SLAM으로 방의 지도를 만들고, Nav2로 목표 지점까지 자율 주행합니다. 이 장에서 잰 오차가 지도 품질과 도착 오차에 어떻게 나타나는지 비교하면서 진행합니다.