Gazebo 시뮬레이션 시계를 0이 아닌 시각에서 시작하고 싶을 때: rclpy에서 now() − Duration이 ‘음수 시각’ ValueError를 내는 구간을 시뮬레이션 초로 계산하기
use_sim_time을 켠 rclpy 노드가 시작하자마자 “지금부터 1초 전” 시각을 self.get_clock().now() - Duration(seconds=1.0)로 구하면 ValueError: Subtraction leads to negative time.이 난다. Robotics Stack Exchange의 질문 “How can you start gazebo with a non-0 simulated clock time?”을 올린 사람도 시작 직후 시각을 빼는 계산이 에러를 낸다는 이유로 시작 시각을 바꾸려 했다. Gazebo 시뮬레이션 시계를 0이 아닌 시각에서 시작하고 싶을 때 기대하는 효과는 분명하다. 시계가 100초부터 흐르면 100 − 1 = 99초이니 음수가 나올 일이 없어 보인다. 이 기대는 절반만 맞는다. 첫 /clock 메시지를 받은 뒤의 예외는 사라지지만, 노드가 그 메시지를 받기 전까지 ROS 시각은 0이고 그사이의 0 − 1초는 시작 시각과 상관없이 예외가 된다.
그래서 제목의 “시뮬레이션 초로 계산하기”는 범위를 좁혀서 읽어야 한다. 시뮬레이션 초로 셀 수 있는 것은 첫 /clock 이후 구간이다. 그 전 구간에서는 시뮬레이션 시각이 0에 멈춰 있으므로 벽시계로만 잴 수 있고, 길이는 시뮬레이터가 첫 메시지를 언제 보내느냐에 달렸다. 아래에서는 rclpy·rcl Jazzy 소스(2026-10-25 확인)로 예외가 나는 줄을 짚고, Gazebo 없이 /clock만 흉내 낸 Jazzy 컨테이너 실험 5회로 계산을 대조한다. 초 단위 뺄셈과 ROS 2의 토픽·타이머 콜백만 알면 따라올 수 있다. 다 읽고 나면 자기 노드의 되돌아보기 길이와 타이머 주기로 실패할 콜백 수를 미리 세고, 시작 오프셋으로 가릴 수 있는 부분과 가드가 반드시 필요한 부분을 나눌 수 있다.
노드의 시계는 첫 /clock을 받기 전까지 0을 가리킨다
시뮬레이션 시간(simulation time)은 시뮬레이터가 계산하는 가상 세계의 시각이고, 벽시계 시간(wall time)은 컴퓨터 시스템 시계가 가리키는 실제 시각이다. 둘은 서로 다른 속도로 흐를 수 있다. 시뮬레이터는 현재 시뮬레이션 시각을 /clock 토픽으로 내보낸다. 토픽은 ROS 2 노드끼리 메시지를 주고받는 이름 붙은 통로이고, /clock에는 rosgraph_msgs/msg/Clock 메시지가 실린다. Gazebo Sim에서는 ros_gz_bridge가 Gazebo 쪽 gz.msgs.Clock을 이 토픽으로 이어 준다(ros_gz_bridge README).
노드 파라미터 use_sim_time을 true로 두면 노드의 ROS 시계(ROS time)는 시스템 시계 대신 /clock으로 받은 최신 값을 돌려준다(ROS 2 설계 문서). 막히는 곳은 그 최신 값이 아직 없을 때다. rclpy의 TimeSource는 마지막으로 받은 시각을 담는 _last_time_set을 Time(clock_type=ROS_TIME), 즉 0 ns로 초기화해 둔다(time_source.py L41-L42). 노드 시계를 TimeSource에 붙일 때(attach_clock) 이 값이 시계의 덮어쓰기 값으로 들어가고, 그 뒤로는 /clock을 받을 때마다 새 값으로 바뀐다(time_source.py L115-L128). use_sim_time이 켜져 있으면 시계는 이 덮어쓰기 값을 돌려주므로, 첫 /clock이 오기 전의 now()는 0이다. 설계 문서는 이 0을 이렇게 정의한다. “A time value of zero should be considered an error meaning that time is uninitialized.”(130_ros_time.md L112-L117) 여기서 0은 “시뮬레이션이 막 시작한 순간”이 아니라 “아직 시각을 모른다”는 표시다.
“시계를 미리 100초로 돌려 놓는다”는 비유는 시뮬레이터 쪽 시계에만 맞는다. 노드가 보는 것은 시뮬레이터의 시계 자체가 아니라 마지막으로 받은 방송이다. 방송을 한 번도 받지 못한 노드의 시계는 S를 얼마로 잡든 0이다. 이 0을 변수에 저장했다가 첫 dt(두 시각의 차이)가 크게 튀는 반대 방향의 문제는 ROS 2 use_sim_time 노드가 /clock을 받기 전 시각 0을 저장하면 첫 dt는 몇 초가 될까에서 따로 다뤘다.
예외는 Time에서 Duration을 빼 결과가 음수일 때만 난다
rclpy는 한 시점을 나타내는 Time(시각)과 두 시점 사이의 길이를 나타내는 Duration(기간)을 구분한다. Time − Duration은 Time이 되고 Time − Time은 Duration이 된다. 예외는 앞의 경우에서만 난다.
Jazzy의 Time.__sub__는 상대가 Duration이면 Time(nanoseconds=self.nanoseconds - other.nanoseconds, clock_type=self.clock_type)으로 새 시각을 만든다(time.py L99-L115). Time.__init__은 나노초가 음수면 ValueError('Nanoseconds value must not be negative')를 낸다(time.py L43-L60). __sub__는 이 예외를 받아 'Subtraction leads to negative time.'으로 바꿔 다시 던지고, 원래 메시지는 원인 예외로 남는다.
바로 옆의 세 경우는 동작이 다르다.
from rclpy.clock import ClockType
from rclpy.duration import Duration
from rclpy.time import Time
zero = Time(nanoseconds=0, clock_type=ClockType.ROS_TIME)
one_s = Duration(seconds=1.0)
one_s_time = Time(nanoseconds=1_000_000_000, clock_type=ClockType.ROS_TIME)
zero - one_s # ValueError: Subtraction leads to negative time.
one_s_time - one_s # 예외 없이 0 ns인 Time
zero - one_s_time # 예외 없이 -1초인 Duration
zero < one_s # TypeError: Time과 Duration은 비교할 수 없다
주석의 결과는 아래 측정 절의 정적 확인에서 실험 스크립트가 같은 계산을 실행해 얻은 것이다. 결과가 정확히 0 ns이면 음수 검사(0 < 0)를 통과하므로 조용히 정상 Time이 된다. Duration 생성자는 int64 범위만 검사하므로 음수 기간은 허용된다(duration.py L31-L44). 그리고 now < Duration(...)처럼 시각과 기간을 바로 비교하면 TypeError다. 가드를 쓸 때 정수 .nanoseconds끼리 비교하는 이유가 여기 있다. 시계 종류가 다른 Time끼리 비교할 때 생기는 TypeError는 rclpy.time.Time()과 Node.get_clock().now()는 뭐가 다를까에서 따로 설명했다.
시뮬레이션 시계 타이머는 0에 멈춘 동안 울리지 않는다
시작 직후 예외라고 하면 보통 타이머 콜백을 먼저 떠올린다. rcl 소스를 읽어 보면 시뮬레이션 시계를 쓰는 타이머는 이 0 구간에서 아예 호출되지 않는다.
rclpy의 create_timer는 따로 지정하지 않으면 노드 시계, 즉 ROS 시계를 쓴다(node.py L1777-L1781). rcl 타이머는 만들어질 때 다음 호출 시각을 next_call_time = now + period로 잡는다(timer.c L198-L199). 시각이 0에 멈춰 있으면 그 시각에 영영 도달하지 않으니 콜백도 없다. 첫 /clock이 100초로 앞으로 뛰면 next_call_time <= now가 성립해 타이머가 곧바로 한 번 깨어나고(timer.c L113-L118), 그 콜백에서 읽는 now()는 이미 100초다.
0 구간에서 0 − W를 계산하게 되는 곳은 따로 있다.
- 생성자 안에서 바로 실행하는
self.get_clock().now() - W - 벽시계 기준 타이머. rclpy에서는
create_timer(..., clock=Clock(clock_type=ClockType.STEADY_TIME))처럼 다른 시계를 지정한 타이머다. STEADY 시계(steady clock, monotonic clock)는 뒤로 가지 않는 단조 시계로, 시뮬레이션과 상관없이 흐른다. /clock보다 먼저 도착한 센서 토픽 구독 콜백이나 서비스 요청 콜백
시작 오프셋을 아무리 크게 잡아도 이 세 경로는 첫 /clock 전에 0을 읽는다.
시계가 0에서 출발하면 실패 틱은 ⌈W·f⌉ − 1개다
첫 /clock 이후는 시뮬레이션 초로 계산할 수 있다. 변수는 네 개다.
- W: 되돌아보기 창(lookback window). “지금부터 W초 전”에서 빼는 길이이며 단위는 시뮬레이션 초다.
- f: 시뮬레이션 시계 타이머의 주파수(Hz). 틱 간격은 1/f초다.
- S: 시작 시각(initial sim time). 시뮬레이터가 시계를 시작하는 값이다.
- r: 실시간 배율(real time factor). 벽시계 1초 동안 진행한 시뮬레이션 초다. r = 0.25이면 시뮬레이션이 실제보다 4배 느리다.
먼저 S = 0인 경우다. 타이머가 시각 0에 만들어졌고 /clock이 충분히 촘촘해 콜백 안에서 읽은 now()가 틱 시각과 같다고 가정한다. 틱 시각은 t_k = k/f(k = 1, 2, …)이고, 예외 조건은 k/f < W, 곧 k < W·f다. 이 조건을 만족하는 양의 정수 k의 개수가 ⌈W·f⌉ − 1이다(⌈x⌉는 x보다 작지 않은 가장 작은 정수). W·f가 정수이면 k = W·f인 틱 하나가 예외 없이 정확히 시각 0을 만든다.
W = 1초, f = 10 Hz를 대입하면 0.1~0.9초의 9틱이 예외, 1.0초 틱이 결과 0, 1.1초 틱이 첫 양수다. 후보 단계에서 만든 산술 예제 example.py와 검산 기록 verification.json은 W × f − 1이라는 식으로 이 9와 W = 5초일 때의 49를 검산했다. 로봇이나 시뮬레이터를 실행한 기록이 아니라 산술 검산이다. 두 사례는 W·f가 정수라 맞지만, 식을 일반화하면 깨진다. W = 0.25초, f = 10 Hz이면 W × f − 1 = 1.5로 개수가 될 수 없다. 올림을 쓴 식으로는 ⌈2.5⌉ − 1 = 2개(0.1초 틱 → −0.15, 0.2초 틱 → −0.05)이고, 0.3초 틱이 +0.05로 첫 양수이며 0을 만드는 틱은 없다.
이 실패 구간의 길이는 시뮬레이션으로 W초다. r이 일정하면 벽시계로는 W ÷ r초가 된다. verification.json의 사례대로 r = 0.25, W = 1초이면 벽시계 4초 동안 실패가 이어진다. 시뮬레이션이 느릴수록 사람이 체감하는 실패 시간이 길어지지만, 실패하는 틱 수는 그대로다.
시작 시각 S를 주면 계산이 바뀐다. 첫 /clock 이후 첫 콜백의 결과는 S − W다. S > W이면 양수, S = W이면 0, S < W이면 음수다. 오프셋이 첫 /clock 이후의 문제를 없애려면 S가 W보다 커야 하고, 경계 S = W는 피해야 한다.

Gazebo 없이 /clock만 흉내 내 네 가지 시작 시각을 쟀다
위 계산과 소스 해석이 실제 rclpy에서도 그대로인지, Gazebo를 빼고 시간 처리만 따로 확인했다. 한 프로세스에 노드 두 개를 띄운다. 가짜 시뮬레이터 노드(use_sim_time 꺼짐)는 S부터 메시지마다 시뮬레이션 10 ms씩 올린 /clock을 벽시계 5 ms마다 보낸다(명목 r = 2). 대상 노드(use_sim_time 켜짐)의 /clock 구독이 연결된 뒤 벽시계 0.5초 동안은 일부러 /clock을 보내지 않아 “첫 메시지 전” 구간을 만든다. 대상 노드는 생성자, 20 Hz STEADY 타이머, 10 Hz와 20 Hz 시뮬레이션 시계 타이머에서 now()를 기록한다. 시뮬레이션 시계 타이머의 기록은 같은 실행 안에서 W = 0.25, 1, 2초 각각으로 실제 rclpy 뺄셈을 다시 수행해 예외·0·양수 결과를 센다. 콜백은 SingleThreadedExecutor(등록된 콜백을 스레드 하나에서 차례로 실행하는 실행기) 하나가 돌린다. S = 0, 0.5, 1, 100 네 시나리오를 차례로 실행했다.
실험 스크립트와 실행별 원본 출력은 공개 실험 폴더에 있다. 파일을 바로 열려면 experiment.py와 results.json을 보면 된다. Docker가 있는 빈 디렉터리에서 아래 두 줄로 같은 조건을 재현할 수 있다. 이미지는 움직이는 태그 대신 실험에 쓴 digest(이미지 내용 해시)로 고정했고, 첫 실행 때 이미지를 내려받는다.
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/1062d4a269e61217efbe154a831fef37a65920f6/experiments/gazebo-nonzero-sim-clock-start-rclpy-negative-time-window/experiment.py
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
정상이라면 JSON 줄이 "kind": "env", "static", "tf2_time_zero", 시나리오 네 줄, "summary" 순서로 나오고 종료 코드는 0이다.
측정 환경: ros:jazzy-ros-base 이미지(digest ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513, RMW는 기본값 rmw_fastrtps_cpp, 네트워크 없음, 1 CPU·1 GiB. RMW는 ROS 2가 메시지를 실제로 주고받는 통신 계층 구현이다. 하네스가 5회 실행했고 5회 모두 종료 코드 0, 실행당 8.6~8.61초였다. 아래 개수는 5회 모두 같았다.
첫 /clock 전 구간 (S = 0, 0.5, 1, 100 네 시나리오 공통)
| 확인 지점 | 예측 | 실측 |
|---|---|---|
생성자의 now() |
0 | now_ns 0, 네 시나리오 모두 |
생성자의 now() − 1 s |
ValueError | ValueError, 4/4 |
| 20 Hz STEADY 타이머 중 now = 0인 호출 | 매번 예외 | 시나리오마다 10회 호출, 10회 예외 |
| 시뮬레이션 시계 타이머의 첫 /clock 전 호출 | 0회 | 10 Hz·20 Hz 모두 0회 |
| 노드 생성부터 첫 /clock 수신까지 벽시계 | 계획값 약 0.5초 | 0.516~0.589초 |
S = 0, 시뮬레이션 시계 타이머 (예외 틱 / 결과 0 틱)
| W(초) | f(Hz) | 예측 ⌈W·f⌉ − 1 / 0 틱 | 실측 | 첫 양수 틱의 now(초) |
|---|---|---|---|---|
| 0.25 | 10 | 2 / 0 | 2 / 0 | 0.3 |
| 0.25 | 20 | 4 / 1 | 4 / 1 | 0.3 |
| 1 | 10 | 9 / 1 | 9 / 1 | 1.1 |
| 1 | 20 | 19 / 1 | 19 / 1 | 1.05 |
| 2 | 10 | 19 / 1 | 19 / 1 | 2.1 |
| 2 | 20 | 39 / 1 | 39 / 1 | 2.05 |
S ≠ 0, 시뮬레이션 시계 타이머 (예외 틱 / 결과 0 틱)
| S(초) | W(초) | 예측 | 실측 10 Hz | 실측 20 Hz |
|---|---|---|---|---|
| 0.5 | 0.25 | 예외 없음(S > W) | 0 / 0 | 0 / 0 |
| 0.5 | 1 | 10 Hz에서 5 / 1 | 5 / 1 | 10 / 1 |
| 0.5 | 2 | 첫 콜백부터 음수 | 15 / 1 | 30 / 1 |
| 1 | 0.25 | 예외 없음(S > W) | 0 / 0 | 0 / 0 |
| 1 | 1 | 첫 콜백 결과 0 | 0 / 1 | 0 / 1 |
| 1 | 2 | 첫 콜백부터 음수 | 10 / 1 | 20 / 1 |
| 100 | 0.25, 1, 2 | 첫 /clock 이후 예외 0 | 0 / 0 | 0 / 0 |
S = 100에서는 두 타이머 모두 첫 호출의 now가 100.0초였다. 첫 /clock을 받는 순간 바로 한 번 깨어났다는 뜻이다. 측정한 실시간 배율은 2.0(한 시나리오는 2.001)이었고, 콜백의 now가 틱 격자에서 벗어난 호출은 5회 모두 0회였다.
같은 기록에 두 가드를 적용한 결과도 함께 셌다. now.nanoseconds <= W.nanoseconds이면 건너뛰는 가드는 모든 시나리오에서 예외 0, 결과 0도 0이었다. “now가 0이거나 W 미만이면 건너뛰는” 가드는 예외는 0이지만, 위 표에서 0 틱이 1인 모든 경우에 결과 0이 1개씩 남았다.
tf2 확인: 98·99·100초 변환을 버퍼에 넣고 ROS_TIME 0으로 조회하면 100초 변환이, 99초로 조회하면 99초 변환이 나왔다.
1회차 원본 출력 일부를 그대로 옮긴다. 시나리오 줄은 길어서 S = 100 줄의 "timers" 앞부분까지만 싣는다.
{"kind": "static", "zero_minus_1s": {"type": "ValueError", "msg": "Subtraction leads to negative time.", "cause": "Nanoseconds value must not be negative"}, "one_s_minus_1s_ns": 0, "time_minus_later_time": {"type": "Duration", "ns": -1000000000}, "time_lt_duration": {"type": "TypeError", "msg": "'<' not supported between instances of 'Time' and 'Duration'"}}
{"kind": "tf2_time_zero", "stored_stamps_s": [98, 99, 100], "query_zero_ros_time": {"stamp_s": 100.0, "x": 100.0}, "query_99s": {"stamp_s": 99.0, "x": 99.0}}
{"kind": "scenario", "S_s": 100.0, "domain_id": 43, "ctor": {"now_ns": 0, "minus_1s": {"neg": "Subtraction leads to negative time."}}, "wall_timer_20hz": {"calls_now0": 10, "exc_now0": 10, "calls_after_clock": 25, "exc_after_clock": 0}, "pre_clock_wall_s": 0.516, "first_clock_received_s": 100.0, "sim_reached_s": 102.5, "measured_r": 2.0,
{"kind": "summary", "scenarios": 4, "all_reached_end": true, "ctor_exceptions": 4, "elapsed_wall_s": 7.4}
결과는 실험 계획에 미리 적어 둔 예측과 모두 같았다. 그중 판단에 쓸 만한 해석은 세 가지다.
첫째, S = 100 줄이 이 글의 질문에 대한 답이다. 시작 시각을 100초로 줬어도 생성자는 0을 읽고 예외를 냈고, 벽시계 타이머는 첫 /clock 전 10번 모두 예외를 냈다. 첫 /clock 뒤로는 25번 호출에 예외가 하나도 없었다. 오프셋은 뒤쪽 절반만 고친다.
둘째, S가 W 이하이면 오프셋은 실패 구간을 줄일 뿐 없애지 못한다. 이번 측정에서 S = 0.5와 1로 시작하고 W가 S 이상인 조합의 예외 틱 수는 모두 (W − S)·f와 같았다. 다만 이것은 S가 타이머 주기의 정수배이고 /clock이 촘촘했던 이번 조건에서 측정값을 읽은 관찰이며, 다른 조건에서 따로 검증한 식은 아니다. S = W(1초 시작, W = 1초)에서는 예외가 사라진 대신 첫 콜백이 결과 0을 냈다.
셋째, 표의 개수는 콜백의 now가 틱 시각과 같다는 가정 위에서만 맞는다. 정식 실행 전 시험 실행에서는 일부 20 Hz 타이머 콜백이 다음 /clock이 이미 도착한 뒤에 실행돼 틱 격자에서 벗어난 호출이 나왔다(실험 계획의 시행착오 기록). 정식 5회에서는 그런 호출이 없었다. 실제 Gazebo처럼 /clock이 타이머 주기보다 거칠게 오면 경계 0 틱이 작은 양수로 바뀌거나 개수가 달라질 수 있다.
이 실험은 rclpy와 rcl의 시간 처리만 확인한다. Gazebo와 ros_gz_bridge는 띄우지 않았고, 한 프로세스의 실행기 하나라서 /clock 수신과 타이머 콜백의 순서가 여러 프로세스로 나뉜 실제 구성보다 규칙적이다. rclcpp, Humble 같은 다른 배포판, 실물 로봇은 범위 밖이다. 실물 하드웨어를 움직이는 실험이 아니므로 로봇 안전 조건이나 실제 동작은 이 결과로 판단할 수 없다.
경계 0까지 막으려면 가드는 now ≤ W에서 건너뛴다
결과 0은 예외가 나지 않으니 정상처럼 보인다. tf2(로봇의 좌표 프레임 사이 변환을 시각별로 저장해 두고 조회하는 라이브러리)에서는 그렇지 않다. ROS 2 Jazzy tf2 튜토리얼은 조회 시각 0을 “버퍼에 있는 가장 최신 변환”이라는 뜻으로 설명한다(C++ 튜토리얼 소스 L57, Python 리스너 튜토리얼 소스 L207). 측정에서도 시각 0 조회는 100초 변환을 돌려줬다. now = W인 틱에서 “1초 전 자세”를 물었는데 최신 자세가 조용히 돌아오는 셈이다. 예외보다 찾기 어려운 쪽은 이 경우다.
그래서 가드는 경계를 포함해야 한다. 아래는 설명용 조각이다. 조건식은 실험의 사후 집계에서 쓴 가드와 같지만, 이 모양 그대로 콜백 안에서 실행한 것은 아니다.
from rclpy.duration import Duration
WINDOW = Duration(seconds=1.0)
def on_timer(self):
now = self.get_clock().now()
if now.nanoseconds <= WINDOW.nanoseconds:
return # /clock 전(0)과 now == W(결과 0)를 함께 건너뛴다
since = now - WINDOW
# since를 tf2 조회나 데이터 구간 선택에 쓴다
<= 하나로 두 경우가 함께 걸러진다. W가 0보다 크면 /clock 전의 0 ns도 조건에 걸리고, now가 정확히 W인 경계 틱도 건너뛴다. 후보 단계 문구였던 “0이거나 W 미만”은 측정에서 본 것처럼 경계 틱의 결과 0을 남긴다. 이 조건은 타이머 종류를 가리지 않으므로 벽시계 타이머나 센서 콜백에도 그대로 넣을 수 있다. 생성자는 시뮬레이션 시간을 쓰는 동안 항상 0을 읽었으므로(측정 4/4), “W초 전” 계산은 생성자에서 하지 말고 가드가 있는 첫 콜백으로 미루는 편이 낫다.
빼기 대신 더하기로 비교하는 방법도 있다. 원 질문의 답변에서도 t1 − d >= t2 형태의 비교를 t1 >= t2 + d로 바꾸라는 제안이 나왔다. Time.__add__도 결과를 새 Time으로 만들기 때문에 음수 검사를 피해 가지는 않는다. 오버플로만 따로 잡아 메시지를 바꿀 뿐이고(time.py L85-L94), 음수 Duration을 더해 결과가 0 아래로 내려가면 생성자의 Nanoseconds value must not be negative가 그대로 올라온다(time.py L52-L53, 소스 기준). 덧셈 비교가 안전한 이유는 d가 음수가 아닌 기간이면 t2 + d가 t2보다 작아질 수 없다는 데 있다. 그래서 t2가 시각 0이어도 예외가 나지 않는다. 다만 tf2 조회처럼 “W초 전 시각” 값 자체가 필요한 코드는 결국 빼기를 해야 하므로 위 가드가 필요하다.
가드의 비용은 시작 후 시뮬레이션 W초 동안 계산을 건너뛰는 것이다. 매 틱 경고 로그를 남기면 그 구간 내내 같은 줄이 쌓이므로, 건너뛴 사실은 한 번만 남기는 편이 읽기 쉽다. 이 부분은 소스 근거가 아니라 운영상 판단이다.
직접 세어 보는 확인 문제
- S = 0, W = 0.5초, f = 10 Hz이면 예외 틱과 결과 0 틱은 몇 개이고, 첫 양수 틱은 몇 초인가?
답: ⌈5⌉ − 1 = 4개(0.1~0.4초 틱)가 예외다. W·f = 5가 정수이므로 0.5초 틱 하나가 결과 0이고, 첫 양수는 0.6초 틱이다. 측정 조합에 없는 값이라 손 계산이다. - W = 0.25초, f = 10 Hz에 W × f − 1을 쓰면 왜 안 되는가?
답: 1.5가 나와 개수가 될 수 없다. 올림을 쓰면 2개이고 0 틱은 없다. 측정에서도 예외 2, 결과 0은 0이었다. - Gazebo 시계를 100초에서 시작했는데, 센서 토픽 콜백이 첫
/clock보다 먼저 도착해now() − 1 s를 계산하면?
답: 그 시점의 now는 0이므로 예외다. 측정의 S = 100 시나리오에서도 생성자와 벽시계 타이머가 첫/clock전에 모두 예외를 냈다. 시작 오프셋으로는 이 경로를 막을 수 없고 가드가 막는다. - r = 0.25로 느리게 도는 시뮬레이션에서 S = 0, W = 1초이면 실패 구간은 사람에게 얼마 동안 보이는가?
답: 시뮬레이션으로는 1초, 벽시계로는 1 ÷ 0.25 = 4초다. 실패하는 틱 수(f = 10 Hz이면 9개)는 r과 상관없다.
쓰는 Gazebo에 따라 무엇을 선택할까
시작 시각을 바꾸는 옵션은 Gazebo 계열마다 이름과 도입 버전이 다르다. 아래 표는 실행 결과가 아니라 소스와 변경 이력 기준이다.
| Gazebo | 시작 시각 옵션 | 근거 |
|---|---|---|
| Gazebo Sim 7.5.0(Garden) 이상, 8.x(Harmonic) 포함 | --initial-sim-time (하이픈), 값의 단위는 초 |
변경 이력 7.5.0(2023-05-14) 절의 CLI 초기 시각 항목, gz-sim8 cmdsim.rb.in L284-L287 |
| Gazebo Classic 11.13.0 이상 | --initial_sim_time (밑줄) |
변경 이력 11.13.0(2023-05-17) 절의 “Set initial sim time from the command-line”, reset 뒤에도 적용되게 고친 11.14.0 항목, 옵션 정의 Server.cc |
| Fortress(ign-gazebo6) | 없음 | ign-gazebo6 cmdgazebo.rb.in에 해당 인자가 없다 |
gz-sim에서 이 값은 시뮬레이션 실행기의 기준 시각(simTimeEpoch)이 되고(SimulationRunner.cc L215-L219), 되감기(rewind)를 하면 0이 아니라 이 값으로 돌아간다(SimulationRunner.cc L371-L377). 표의 어느 행에 해당하는지는 대개 쓰는 ROS 배포판이 정한다. Gazebo 공식 안내가 권하는 조합은 ROS 2 Jazzy와 Harmonic, Humble과 Fortress다(Installing Gazebo with ROS). SDF 월드 파일의 <state><sim_time>으로 시작 시각을 주자는 Q&A 제안도 있지만, Fortress 사용자에게서 반영되지 않았다는 보고가 있고 공식 확인을 찾지 못해 권하지 않는다.
선택은 이렇게 갈린다. 어떤 조합이든 now.nanoseconds <= W.nanoseconds 가드는 넣는다. /clock 전 구간은 오프셋으로 없앨 수 없고, 측정에서 본 것처럼 그 구간을 맞는 것은 생성자·벽시계 타이머·먼저 온 콜백이기 때문이다. Humble과 Fortress 조합에는 시작 시각 옵션이 없으니 가드가 유일한 수단이다. Jazzy와 Harmonic, 또는 Classic 11.13 이상이라면 --initial-sim-time이나 --initial_sim_time을 W보다 충분히 큰 값으로 줘서 첫 /clock 이후의 음수와 0을 없앨 수 있다. 이때도 S를 W와 같게 두면 첫 콜백이 시각 0을 만들고, W보다 작게 두면 첫 콜백부터 예외가 난다.
아직 확인하지 않은 것도 남아 있다. ros_gz_sim launch의 gz_args로 이 옵션을 넘겼을 때의 동작, 일시정지 상태로 시작했을 때 /clock이 나가는지, 실제 ros_gz_bridge의 /clock 간격이 경계 틱 개수를 얼마나 흔드는지는 Gazebo를 직접 띄워 재야 답할 수 있다. 위의 가드와 개수 계산은 그 측정 전에도 쓸 수 있지만, 개수를 테스트의 기대값으로 고정하려면 자기 환경의 /clock 간격부터 확인해야 한다.
참고 링크
- rclpy Jazzy time.py: 음수 나노초 검사와
Subtraction leads to negative time.(2026-10-25 확인) - rclpy Jazzy time_source.py: use_sim_time의 초기 0 ns와
/clock갱신 (2026-10-25 확인) - rcl Jazzy timer.c: 타이머의 첫 호출 시각과 시간 점프 처리 (2026-10-25 확인)
- ROS 2 Design — Clock and Time: ROS 시간, 시뮬레이션 시간, 시각 0의 의미 (2026-10-25 확인)
- 실험 스크립트 experiment.py와 5회 원본 출력 results.json
제휴·협찬은 없다.