bag은 토픽으로 흐른 메시지를 받은 시각과 함께 파일로 남기는 로그예요. 블랙박스와 같은 자리인데 차이가 하나 있어요. 기록을 다시 토픽으로 흘려보낼 수 있다는 것이죠. 명령은 기록·확인·재생 셋이 전부예요.
기록에서 첫 습관은 토픽을 고르는 거예요. 전부 기록하는 옵션은 편해 보이지만 카메라나 포인트클라우드가 섞이는 순간 용량이 분 단위로 기가바이트씩 불어나요. 목적이 분명한 기록은 필요한 토픽만 이름으로 지정하는 게 기본이에요.
두 번째 습관은 기록이 끝나자마자 확인하는 거예요.
Duration: 20.4s
Messages: 2447
Topic information: /arm_cmd | JointTrajectory | Count: 408
/joint_states | JointState | Count: 2039볼 것은 토픽별 개수와 기간이에요. 개수가 0인 토픽이 있으면 이름 오타이거나 QoS가 안 맞아 구독 자체가 실패한 것이고, 기간이 예상보다 짧으면 기록을 너무 일찍 끊은 거예요. 이걸 분석 단계에서 발견하면 실험을 통째로 다시 해야 하니, 기록 직후 10초의 확인이 실험 한 회차를 살려요.
구조 감각도 하나 잡고 가요. bag 내부는 토픽별 파일이 따로 있는 게 아니라 여러 토픽의 메시지가 수신 시각순으로 한 줄로 병합된 스트림이에요. 명령과 응답을 같은 bag에 담으면 두 토픽이 시간 순서대로 섞여 들어가고, 분석할 때 토픽별로 갈라 쓰게 돼요.
시각이 두 종류 붙어요
메시지 하나에는 시각이 둘 붙을 수 있어요. 구분하지 않으면 지연 분석이 처음부터 어긋나요.
| 축 | 누가 찍나 | 무엇의 시각 | 어디에 |
|---|---|---|---|
| 헤더 스탬프 | 발행하는 노드 | 데이터를 만든 순간 | 헤더 있는 메시지만 |
| 수신 시각 | 기록기 | 기록기에 도착한 순간 | 모든 메시지 |
센서가 측정한 순간과 그 데이터가 기록기에 도착한 순간은 다르니, 두 축 사이에는 전송 지연과 큐 대기만큼 항상 틈이 있어요.
분석의 원칙은 데이터 생성 시각 기준이에요. 재고 싶은 건 로봇의 응답 지연이지 미들웨어 전송 지연이 아니거든요. 수신 시각으로 재면 큐와 스케줄링의 흔들림이 지표에 섞여요. 실전 증상은 이래요. 로봇도 코드도 그대로인데 실험을 반복할 때마다 지연 숫자가 몇 밀리초씩 이유 없이 출렁이고, 원인을 로봇에서 찾다가 시간을 잃어요.
다만 헤더가 없는 메시지는 수신 시각밖에 없어요. 맨 데이터 타입으로 명령을 쏘면 생성 시각이 기록에 안 남아요. 그래서 측정용 명령은 헤더 있는 메시지로 보내는 게 좋아요.
시계 축이 갈라지면 숫자는 나오는데 전부 틀려요
이 주제에서 가장 값어치 있는 부분이에요.
노드의 현재 시각은 기본적으로 벽시계를 읽어요. 1970년 기준 17억 초대의 큰 수죠. 그런데 시뮬레이션의 시간은 물리 스텝이 흘러야 흐르는 별개의 시간이라, 시뮬레이터가 시뮬 시계를 따로 발행해요. 노드 파라미터를 켜면 그 노드의 현재 시각이 벽시계 대신 시뮬 시계를 따라가요. 시뮬 시계는 0초부터 세니 두 시계는 자릿수부터 완전히 달라요.
함정은 이게 시스템 전체 스위치가 아니라 노드별 파라미터라는 데 있어요. 런치 파일에서 일부 노드에만 넘기면 한 시스템 안에 두 시계 축이 공존해요.
/arm_cmd stamp: 1753776000.10 1753776000.15 1753776000.20 ...
/joint_states stamp: 12.31 12.32 12.33 ...기록은 멀쩡히 됐고 에러도 없었는데, 명령은 17억 초대에 있고 응답은 12초대에 있어요. 뒤에 나올 짝짓기를 돌리면 모든 명령이 수십 년 떨어진 엉뚱한 응답과 짝지어지고, 거기서 나온 지표는 전부 무의미해요.
크래시가 나는 사고는 원인이 그 자리에서 드러나지만, 이 사고는 숫자가 나오긴 나와서 지표를 한참 들여다본 뒤에야 이상을 눈치채요. 원인 추적이 가장 오래 걸리는 종류예요.
실물과 시뮬을 대조할 때는 한 발 더 가요. 실물 기록은 벽시계 축이고 시뮬 기록은 시뮬 시계 축이라 애초에 같은 축에 있지 않아요. 방법은 절대 시각을 버리고 상대 시간으로 바꾸는 거예요.
실물 명령: 1753776000.10 1753776000.15 1753776000.20 (벽시계 축)
시뮬 명령: 12.31 12.36 12.41 (시뮬 시계 축)
→ 각자 첫 명령을 0으로: 0.00 0.05 0.10 (이제 비교 가능)오프셋 하나를 전체 스탬프에 더하는 단순한 변환인데, 이게 없으면 대조 자체가 성립하지 않아요.
점검 습관 둘로 사고를 막아요. 기록을 시작하기 전에 관련 노드의 파라미터가 같은 값인지 확인하고, 기록 직후 첫 메시지의 스탬프가 어느 자릿수인지 눈으로 봐요. 지표 스크립트를 돌리기 전에 시계부터 보는 것이 이 절의 결론이에요.
명령 하나에 응답이 하나가 아니에요
명령과 응답은 발행 주기가 달라요. 명령이 20Hz면 관절 상태는 100Hz쯤이라 대응하는 응답이 정확히 하나 있는 게 아니에요. 그래서 명령 시각에 대해 가장 가까운 응답을 찾는 최근접 매칭을 써요.
응답 스탬프는 이미 시각순 정렬이니 전부 훑을 필요가 없어요.
// 정렬된 응답 스탬프에서 명령 시각 t의 최근접을 찾는다 — 명령당 O(log n)
auto it = std::lower_bound(resp.begin(), resp.end(), t);
// 후보는 직후와 직전 둘뿐 — 차이가 작은 쪽이 짝
// 경계: it가 처음이면 직후만, 끝이면 직전만 후보지연을 안 걷어내면 결론이 뒤바뀌어요
짝을 지었으면 지연과 궤적 오차를 분리해야 해요. 응답이 늦게 오는 건 시간 축의 결함이고 값이 다른 건 값 축의 결함이라 원인이 서로 달라요. 지연은 통신·제어 주기·모터 응답성에서 오고, 궤적 오차는 게인·마찰·관성 모델의 오차에서 와요.
분리하지 않고 짝지어진 값을 바로 빼면 시간 지연이 위치 오차로 둔갑해요.
명령 t=1.000s 목표 0.500 rad
응답 t=1.042s 실측 0.462 rad ← 최근접 짝, 시각 차 42ms
이 구간에서 관절이 0.9 rad/s로 움직이는 중이었다면
42ms 지연만으로 생기는 값 차이 = 0.9 × 0.042 ≈ 0.038 rad
→ 보정 없이 빼면 이 0.038이 전부 '궤적 오차'로 찍힌다팔이 궤적을 완벽하게 그리더라도 42ms 늦게 그리면 오차가 크게 찍히고, 빠르게 움직이는 구간일수록 부풀려져요. 지표는 “모델이 나쁘다”라고 말하는데 실제로는 “지연이 있다”인 상황이에요.
분리 절차는 셋이에요. 짝지어진 쌍들의 시각 차이를 평균 내 지연을 추정하고, 응답의 시간 축을 그만큼 앞으로 당겨 다시 짝짓고, 남은 값 차이로 오차를 계산해요. 보고에는 “평균 지연 42ms, 지연 보정 후 관절각 오차 0.011rad”처럼 두 숫자를 나란히 적어요.
정리는 여기까지, 통계는 다른 도구로
분석 전에 데이터를 둘로 정리해요.
첫째는 구간 자르기예요. 기록의 앞뒤에는 노드가 뜨는 웜업과 팔이 멈춰 있는 정지 구간이 섞여 있어서 실제로 움직인 구간만 남겨요. 웜업을 안 자르면 정지 구간의 오차 0이 평균을 희석해 지표가 실제보다 좋아 보여요. 좋은 쪽으로 틀리는 왜곡이라 더 위험해요.
둘째는 다운샘플이에요. 시간을 일정 폭의 버킷으로 나누고 버킷마다 마지막 샘플 하나만 남겨요. 평균이 아니라 마지막 값을 쓰는 이유가 있어요. 관절 상태 같은 값은 구간의 평균이 아니라 그 순간의 스냅샷이 의미라서, 그 버킷이 끝나는 시점의 최신 상태를 남기는 것이 의미를 보존해요.
경계도 분명히 해요. 여기까지가 데이터 설계이고 그 뒤의 통계와 플롯은 다른 도구로 넘겨요. 읽기 API로 데이터프레임으로 바꾸면 그다음은 집계와 시각화의 세계예요. 통계 파이프라인을 저수준 언어로 다시 짤 이유는 없어요.
명령은 bag으로 고정해요
대조 실험은 명령이 같아야 성립해요. 그런데 명령 생성 스크립트를 실험마다 새로 실행하면 발행 타이밍이 미세하게 흔들리고, 난수나 현재 시각이 섞였다면 값 자체가 달라져요.
그래서 규칙은 이거예요. 명령 시퀀스를 한 번 bag으로 기록해 두고, 이후 모든 실험에서 그 bag을 재생해요. 재생은 기록된 간격 그대로 다시 발행하니 문자 그대로 같은 명령을 받아요.
재생 옵션 둘만 조심해요. 배속은 기본값을 바꾸지 않아요. 바꾸면 명령 간격이 달라져 다른 실험이 돼요. 시계를 직접 발행하는 옵션은 시뮬레이터 없이 bag만으로 돌릴 때 쓰는 것이라, 시뮬레이터가 이미 시계를 내고 있으면 주인이 둘이 되니 켜지 않아요.
정리하면 한 회차의 구성은 이래요. 명령 bag은 하나로 고정하고(통제 변수), 응답은 회차마다 새 bag으로 기록해요(측정 대상). 시작 자세를 통일하고 시계 점검을 거친 뒤 재생을 시작해요. 이 규칙이 지켜지면 차이에는 오직 대상 자체의 차이만 남아요.
명령을 보내는 쪽 코드는 C++ rclcpp — 콜백 수명과 스레딩이 만드는 사고에, 받는 쪽은 ros2_control — update()의 주기 계약에 있어요.