Status: Draft (2026-10-18), Measured. Measurements were taken on 2026-10-17 in a single temporary pod on a home lab Kubernetes cluster. The node is a mini PC (Intel Core Ultra series with 22 threads, 31 GiB memory), and the pod limits were set to 3 CPU cores and 4 GiB memory, without GPU or
/dev/dri(no privileged mode, no extra capabilities). The image isros:jazzy(ros-base, Ubuntu 24.04.5) withros-jazzy-ros-gz·ros-jazzy-turtlebot3-gazeboadded via apt. The versions are Gazebo Sim 8.15.0 (Harmonic), ros_gz 1.0.24, and turtlebot3_gazebo 2.3.7. The tests were repeated 2 times for B, 3 times for C, and 1 time for the rest; the values in the table are single measurements per run (not medians). Raw data (rosbag2 mcap, summary JSON, 1-second cgroup samples) are stored in the ROS measurement folder in the working directory, and the article only shows values with node names and addresses masked out.
Table of Contents
- 1. Intuition: A Robot Counting Steps with Eyes Closed
- 2. Setup: Headless Simulation Inside a Single Pod
- 3. Four Worlds
- 4. Navigation Node: Following Odom, Not Time
- 5. Results: The Cost of Maintaining Real-Time and Corner Errors
- 6. When a Single Line of Floor Friction is Omitted
- 7. LiDAR Running Without a GPU
- 8. Maximum Speed Mode Usable for CI
- 9. Troubleshooting
- 10. Limitations
- 11. Next Up
- References
1. Intuition: A Robot Counting Steps with Eyes Closed
It is easy to believe that if you close your eyes and walk "ten steps forward, 90 degrees left" four times, you will return to your starting point, because you counted the steps accurately. However, if the floor is slippery, you take a shorter step, and if your feet slip while turning, you rotate 80 degrees instead of 90. Someone who only counts steps will not know the difference.
Wheel robot odometry (odom) is precisely this step-counting process. It integrates wheel revolutions to declare, "I am here now." The simulator provides one more thing on top of this: the true position (ground truth pose) known to the physics engine. This post demonstrates through actual measurements what happens when both values are recorded side by side.
2. Setup: Headless Simulation Inside a Single Pod
gz sim -s -rWe launched only the server. Since there is no screen (GUI), an X server or VNC is not required.ros_gz_bridgeconnects Gazebo topics and ROS 2 topics. By adding one line to the default bridge configuration used by TurtleBot3, we exposed Gazebo's/world/default/dynamic_pose/infoas/gt_poses. This is the ground truth pose.ros2 bag record -s mcappackages/odom,/cmd_vel,/gt_poses,/imu(worlds with LiDAR include/scan) into a single file.- We bound the DDS discovery to
ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOSTto prevent it from leaking into the pod network.
Image costs were also measured. ros:jazzy is 283 MiB compressed and took 18.3 s to download. Adding the simulation packages via apt took 272 s, downloading 768 packages and 554 MB, which increased the installed size by 2,637 MB to a total of 2,637 MB. For reference, judged solely by registry manifests, osrf/ros:jazzy-desktop is 1,203.9 MiB compressed, and jazzy-simulation is 698.3 MiB (not downloaded). If you find the installation time wasteful on every run, baking this step into an image is your next task.
3. Four Worlds
| Name | What's Different |
|---|---|
| A | Original TurtleBot3 empty world. Because it includes the Sensors system (ogre2) and requires rendering gpu_lidar , it was launched with --headless-rendering . |
| A2 | Two static boxes were added to A. In an empty world, all LiDAR values are infinite, making it impossible to verify if rendering works properly. |
| B | The Sensors system was removed. The floor is an inline plane, and friction matches the original floor with mu 100 and mu2 50. Neither rendering nor network is used. |
| C | In B, real_time_factor is changed to 0 to run at maximum speed without real-time constraints. |
4. Navigation Node: Following Odom, Not Time
The navigation node is not an open-loop controller like "go straight for 5 seconds." It is a closed-loop controller that visits the corners (1,0), (1,1), (0,1), and (0,0) in sequence by checking /odom , operating at a maximum of 0.2 m/s, 0.6 rad/s, and 50 Hz based on simulation time. This must match real robot waypoint navigation so that the "error against target" can be interpreted as the combined effect of the controller, odometry, and physics. The core lies in a few lines of the straight-line phase.
if self.phase == 'drive':
tx, ty = CORNERS[self.leg]
dx, dy = tx - x, ty - y
# 진행 방향 위로 남은 거리. 지나치면 음수가 된다.
seg_heading = HEADINGS[self.leg - 1] if self.leg else 0.0
along = dx * math.cos(seg_heading) + dy * math.sin(seg_heading)
if along <= POS_TOL: # 5 mm 안이면 멈추고 돌기 시작
self.send(0.0, 0.0)
self.mark('corner')
self.phase = 'turn'
return
bearing = math.atan2(dy, dx) if math.hypot(dx, dy) > 0.05 else seg_heading
w = max(-W_MAX, min(W_MAX, 2.0 * wrap(bearing - th)))
v = max(0.02, min(V_MAX, 0.8 * along))
self.send(v, w)
(Excerpted from TT Lab experimental code square_driver.py , with comments shortened or expanded to fit the article. Rotation targets were given as absolute orientations of 0, π/2, π, and −π/2 at each corner to prevent accumulated rotational errors.)
5. Results: The Cost of Maintaining Real-Time and Corner Errors
| Run | RTF | Wall Clock per Lap | Actual Corner Error mm (1→4) | Actual Final Position Error | Max odom↔Actual | Avg CPU | Max anon | gz RSS |
|---|---|---|---|---|---|---|---|---|
| A Render LiDAR | 1.000 | 47.5 s | 11, 33, 40, 25 | 16 mm | 41 mm | 0.91 core | 467 MiB | 515 MiB |
| A2 A + Boxes | 1.000 | 47.5 s | 11, 47, 66, 43 | 50 mm | 74 mm | 0.92 core | 466 MiB | 509 MiB |
| B Physics Only | 1.000 | 47.6 s | 17, 23, 14, 5 | 16 mm | 26 mm | 0.73 cores | 196 MiB | 156 MiB |
| B iteration | 1.000 | 47.5 s | 15, 33, 28, 13 | 16 mm | 32 mm | 0.74 cores | 196 MiB | Not measured |
| C Max speed | 11.11 | 4.3 s | 19, 27, 20, 7 | 16 mm | 39 mm | 2.88 cores | 196 MiB | 156 MiB |
| C iteration 2 | 11.76 | 4.1 s | 20, 28, 20, 8 | 17 mm | 39 mm | 2.54 cores | 196 MiB | Not measured |
| C iteration 3 | 10.88 | 4.4 s | 19, 28, 20, 7 | 16 mm | 39 mm | 2.86 cores | 196 MiB | Not measured |
RTF (Real-Time Factor) is /odom the increase in simulation time in the header divided by the increase in wall-clock time received by the recorder.
CPU averaged the driving section by reading the pod cgroup's cpu.stat every second, and memory is the anon max of memory.stat .
memory.current shows up as 3.0–3.6 GiB, but since most of it is page cache created during apt installation, the actually held memory is considered to be anon.
- Based on odom, all runs were nearly perfect. Corner errors were 4.6–5.0 mm (stop tolerance 5 mm), and rotation was 89.74–89.78° (tolerance 0.005 rad). Almost all the actual errors in the table are the gap between odom and reality.
- Every time it turned 90° in place, the actual position shifted by 12–13 mm (run B, odom under 0.3 mm). This is suspected to be due to caster drag, but it was not verified (estimated).
- Startup took
gz sim3.6–3.7 s from the run to the first/odom, and only the first run of A took 5.4 s to download the original floor model.
6. When a floor friction line is omitted
When initially creating the world without rendering (B), the floor model of the original was replaced with an inline plane, and the friction setting was omitted. As a result, the physics engine's (ODE) default value mu 1 is used. The floor after the fix has the following single line.
FRIC = '<surface><friction><ode><mu>100</mu><mu2>50</mu2></ode></friction></surface>' # 원본 바닥과 같게
The figure below shows records of the same driving node running in two worlds. Clicking the figure replays the actual trajectory left in the rosbag at 4x speed.
| Run | Actual angle per rotation | Corner actual error mm (1→4) | End position actual error / orientation | End position error seen by odom |
|---|---|---|---|---|
| B (with friction) | 89.2, 89.7, 89.7, 89.7° | 17, 23, 14, 5 | 16 mm / −0.98° | 5 mm |
| B missing friction | 80.3, 79.7, 80.3, 80.9° | 10, 138, 344, 487 | 503 mm / −37.99° | 5 mm |
| C missing friction | 78.0, 79.2, 80.5, 80.9° | 19, 164, 399, 533 | 549 mm / −40.24° | 4 mm |
During in-place rotation, the wheels slipped and turned only about 80° instead of 90°, and that discrepancy accumulated at every corner. However, since odom integrates wheel joint angles, it cannot detect the slip. Therefore, odom reported until the very end that it "returned within 5 mm". If the results were judged solely by odom, this world would have passed the test.
We also separately verified whether the ground-truth pose was correct. After the run ended, the position queried via gz model -m burger -p was compared with the last value of /gt_poses .
In the C missing friction run, gz model returned (−0.2505, 0.4883) m and an orientation of −40.0°, while /gt_poses was (−0.2505, 0.4884) m and −40.2°.
In B with friction, the two values were identical at (−0.0130, 0.0099) m.
Lesson. When evaluating robot performance in simulation, record both the robot's self-estimated position and the simulator's ground-truth position together , and make the judgment based on the ground truth. Looking only at the estimated position makes physics configuration mistakes look like robot success. Conversely, looking only at the ground truth makes it impossible to know how wrong the estimator is. The difference between the two (the red line in the figure) is odometry drift, which is why actual robots maintain a separate position estimator.
7. LiDAR running without a GPU
A and A2 use gpu_lidar(360 beams, 5 Hz), which measures distances via rendering. The pod had neither a GPU nor /dev/dri , but it launched with --headless-rendering(EGL) and maintained an RTF of 1.000.
/scanThe cycle was 5.00 Hz, exactly matching update_rate 5 specified in the model.- In A2, out of the last five scans totaling 1,800 beams, 185 beams had finite distances. This means it actually saw the box. The empty world A was normal at 0/1,800 (all infinite).
- Rendering cost (the difference between A and B) was CPU +0.18 cores, anon memory +270 MiB, and gz process RSS from 156 to 515 MiB.
- The log only contains a line indicating that the ogre2 rendering plugin was loaded, and the renderer name does not appear. Since there was no GPU, it is assumed to have rendered using Mesa's software renderer, but this was not verified (unconfirmed).
- Camera sensors (which have much higher resolution and frequency) were not measured (unmeasured). Working with one LiDAR does not mean cameras will work.
8. Max speed mode usable for CI
C is B with real_time_factor set to 0. One lap of a 1 m square (simulation 47.7–51.7 s) finished in 4.1–4.4 s of wall-clock time, and the RTF was 10.88–11.76.
- This speed is measured with a CPU limit of 3 cores (average 2.54–2.88 cores). A single physical thread, the bridge, the logger, and the driving node shared the upper limit. We did not measure how RTF scales when changing the number of cores (unmeasured).
- Reproducibility has actually improved. Corner errors for C run three times were identical within 1 mm (19–20, 27–28, 20, 7–8 mm). When real-time B was run twice, the final corners differed at 5 mm and 13 mm. We assume that in real-time mode, the wall-clock synchronization process intervenes slightly differently each time (estimated).
- There is one thing to note. In C,
dynamic_poseis output based on the wall clock, making the ground truth pose sparse at about 12 Hz in simulation time. Interpolation is required when matching the ground truth and odom by time.
This means that per PR, you can run a 4-second test where "the robot drives a 1 m square within 2 cm." However, that judgment must be made using the ground truth pose as in section 6.
9. Troubleshooting
set -uIn a shell script with/opt/ros/jazzy/setup.bashenabled, reading- causes it to stop due to an undefined variable.
&In a script without job control,ros2 bag recordlaunched with ros2 runignores SIGINT. We changed it to SIGTERM, and rosbag2 closed the files normally upon SIGTERM as well.parameter_bridgeEven after ending the wrapper, thePose_Vchild process remained, so we executed the binary directly.tf2_msgs/TFMessageWhen bridginggz model -pto- , frame_id, child_frame_id, and timestamps arrive empty. We confirmed that the first entry is the robot model by comparing it with
odom_publisher_frequency, and set the timestamp to the time the logger received it.
The odom frequency was measured at 50.0 Hz based on simulation time. The
- in the model SDF is 30, but we did not look into the cause of the discrepancy (unverified).
- 10. Limitations
- One node, once or twice per run. There are too few repetitions to discuss the error distribution.
- We only looked at a single wheel robot type (burger) and empty flat ground. Slopes, bumps, and different floor materials were not covered.
The caster drag hypothesis (12–13 mm movement per revolution) can be tested by varying caster friction, but we have not done so yet.
Comparisons on GPU nodes and camera sensors are unmeasured.
11. Next Up The next part moves on to table tennis robots. Instead of wheels, a single ball is placed in MuJoCo to measure how much bounce and spin are affected by a single line of simulator settings. The basics of the robot arm itself (coordinate systems, kinematics) are covered in the "Table Tennis Robot Arm Basics" series (table of contents) currently being published together.
A word from TT.
If you only count steps, I always return to the same spot. But if the floor is slippery, I am actually standing half a meter to the side. That's why in the simulator, I always record my "real position" as well!
- References https://docs.ros.org/en/jazzy/
- All links were verified to be working when accessed on 2026-10-18. The editions are those used for the measurements. https://gazebosim.org/docs/harmonic/
- ROS 2 Jazzy Documentation: https://github.com/gazebosim/ros_gz
- Gazebo Harmonic Documentation: https://github.com/ROBOTIS-GIT/turtlebot3_simulations
- ros_gz (bridge, 1.0.24): https://github.com/ros2/rosbag2TurtleBot3 Simulation (turtlebot3_gazebo 2.3.7): https://mcap.dev/spec
- rosbag2:
ros:jazzy: https://hub.docker.com/_/ros - , MCAP format: