Docs menu
ROS simulator
racerbot_sim replaces only the car's hardware with F1TENTH Gym, so the real launch files, SLAM, driving nodes and dashboard run unchanged above it.
racerbot_sim puts F1TENTH Gym’s physics behind the car’s own topics. It replaces only the hardware layer, so everything above it is the code that runs on the car: ackermann_mux with its real configuration, slam_toolbox, gap_follow_node, pure_pursuit_node, auto_map_race_node and the dashboard. sim_auto_map_race_launch.py includes the real auto_map_race_launch.py rather than a copy, so the launch file people run is the one validated. The package is src/racerbot_sim/GitHub.
On the car (bringup_launch.py) |
In the simulator (sim_bringup_launch.py) |
|---|---|
joy_node, reading the F710 |
sim_joy_node, a synthetic LB |
urg_node, the LiDAR |
gym_bridge_node |
vesc_driver_node, ackermann_to_vesc_node, vesc_to_odom_node |
gym_bridge_node |
ackermann_mux |
The same node and configuration |
The base_link to laser transform |
The same transform |
It runs in real time on the wall clock, with no /clock, so the nodes see the same timing they see on the car. A full validation takes about four minutes per scenario, and runs are not bit-identical because the ROS graph’s scheduling varies.
The hardware guard
sim_joy_node forges the LB deadman, and gym_bridge_node publishes an imaginary /scan, /odom and IMU. Beside the real drivers, the driving nodes would steer on a mix of a real room and an invented one, with a forged LB. Neither node publishes anything while vesc_driver_node, ackermann_to_vesc_node, vesc_to_odom_node, urg_node or joy is on the ROS graph. The list also includes imu_bridge_node, in any namespace, because a mix of real and simulated gyro readings would mislead the controllers. The check repeats, so starting the car’s bringup after the simulator silences the simulator too. It is a live check of the graph, in racerbot_sim/hardware_guard.py, because a flag can be forgotten.
Run it
It needs F1TENTH Gym from tools/f1tenth_sim/setup.sh, run once.
# The whole map-then-race flow, with the dashboard
ros2 launch racerbot_sim sim_auto_map_race_launch.py track:=indoor_wide dashboard:=true
# A tighter course with two slower cars
ros2 launch racerbot_sim sim_auto_map_race_launch.py track:=indoor_tight opponents:="6,0.7,0.0;14,0.6,0.0"To test a driving node of your own, start only the simulated hardware and put the node on top:
| Terminal | Command |
|---|---|
| 1 | ros2 launch racerbot_sim sim_bringup_launch.py |
| 2 | ros2 launch <your_package> <your_launch_file> |
| 3 | ros2 launch racerbot_launch dashboard_launch.py |
A node that subscribes to /scan and publishes on /drive works here unchanged.
| Argument | Default | Effect |
|---|---|---|
track |
indoor_oval |
indoor_oval (30 m lap, 1.8 m corridor), indoor_tight (27 m, 1.4 m), indoor_wide (42 m, 2.6 m), or asb_10000 (about 50 m, 1.5 m, modelled on the hallway loop the cars are test-driven on) |
opponents |
None | Other cars as offset_m,speed_mps,lateral_m, separated by ;. Speed 0 parks a car on the line. |
hold_deadman |
true |
false checks that the car stays stopped with LB released |
release_after_sec |
−1 | Releases the synthetic LB this far into the run |
odom_speed_scale |
1.0 | An odometry scale error, as a miscalibrated speed gain would give |
odom_yaw_rate_scale |
1.0 | A turn-rate scale error in /odom, as a miscalibrated steering model would give. The simulated gyro still measures the true turn rate. |
publish_imu |
true |
false publishes no simulated IMU. |
imu_fault, car2_imu_fault |
none |
A fault in the first or second simulated car’s IMU, as below |
seed |
12345 | The LiDAR and odometry noise |
dashboard |
false |
Also starts the dashboard |
indoor_wide is the layout the car can race: on the narrower ones the racing line can leave the car too little room on corners.
Check a driving node
- It publishes.
ros2 topic hz /driveshows the node’s rate. - It respects the deadman. Start terminal 1 with
hold_deadman:=false. The node must command zero speed and stay there. A node that drives anyway is unsafe and must not go on the car. - It survives a lap. On the dashboard, wall contact shows as the car stopping with LiDAR points crowded on one side.
- It recovers from its own safety stop. A stop the car has no way out of looks the same whether the cause is the code or a course that is too narrow, so reproduce it on
indoor_widebefore debugging the node.
Pass or fail
tools/racerbot_sim/run_auto_map_validation.py --scenario allIt runs three scenarios end to end on indoor_wide and exits 0 only if all pass:
| Scenario | On the track | Checks |
|---|---|---|
solo |
Nothing | Mapping, cleanup, saving, the handover and a clean racing stint |
obstacle |
One parked car on the racing line | The racing controller gets past a static obstacle instead of stopping in front of it |
traffic |
Two slower cars | Mapping with moving traffic, and racing among it without contact |
Each scenario is judged on reaching every phase, no node crashing, no wall or car contact, and the distance covered after the handover. That last check is separate because the mapping laps make the total distance look healthy even when racing has stopped.
Simulated IMU
gym_bridge_node publishes an IMU for every car it controls, so the IMU stack and the IMU features run unchanged above it. The first car’s topics are /imu/bno/data and /imu/vesc/data, with /imu/data repeating the BNO085 reading, and a latched /imu/status describing the BNO085’s health. A second car has the same topics under /car2/imu/. The status is withdrawn while the hardware guard blocks the simulator and offered again when it reopens.
The gyro reads the simulator’s true yaw rate. The accelerometer is computed from the change in true speed and yaw rate between ticks, plus the effect of each sensor sitting ahead of the rear axle, 0.36 m for the BNO085 and 0.18 m for the VESC’s IMU, as in vehicle_state.yaml. At rest the vertical axis reads gravity, 9.80665 m/s². Gaussian noise is added to every axis, sized from 17.8 s of car 1’s run 20261026-194514 with the car parked:
| Sensor | Gyro noise | Acceleration noise |
|---|---|---|
| BNO085 | 0.000711561 rad/s | 0.021534955 m/s² |
| VESC IMU | 0.001347428 rad/s | 0.014463125 m/s² |
With the simulator running, in another sourced terminal:
ros2 topic list -t | grep imu
ros2 topic echo --once /imu/statusThe first simulated car’s four topics are listed, and the status reads {"state": "ok", "reason": null, "imu": "bno"}.
gap_follow_launch.py and map_course detect the running simulator through imu_hardware:=auto and start only vehicle_state_node, not the real sensor readers. The detection runs once, when the launch starts, so a launch started within a few seconds of sim_bringup_launch.py can miss the simulator and pick the real IMU, which the simulator then refuses to run beside. Pass imu_hardware:=false in that case.
Inject an IMU fault
imu_fault sets a fault on the first car’s simulated IMU, and car2_imu_fault on the second’s. Both can be set at launch or changed while the simulator runs.
| Fault | Readings | /imu/status |
|---|---|---|
none |
Normal | ok |
bias |
0.1 rad/s added to both gyros’ yaw axis | ok |
scale |
Both gyros’ yaw axis multiplied by 1.1 | ok |
zero |
Every axis exactly zero, without noise | fault |
status_fault |
Normal | fault |
stale |
None: the readings stop | Unchanged |
bias and scale leave the status at ok because, as on the real car, a health check cannot detect a biased or mis-scaled gyro. stale keeps the last status, so a consumer has to check the age of the data. To stop the first simulated car’s IMU stream while the simulator runs:
ros2 param set /gym_bridge_node imu_fault stale/imu/data stops, and gap_follow’s /drive_intent names imu stale for each IMU feature that is on. ros2 param set /gym_bridge_node imu_fault none resumes the readings.
Check the IMU fallbacks in closed loop
run_gap_follow_validation.py drives gap_follow on the simulator, injects an IMU fault partway through, and reports whether every IMU feature fell back. From the workspace root, sourced, on its own ROS domain:
ROS_DOMAIN_ID=64 python3 tools/racerbot_sim/run_gap_follow_validation.py --duration 120 --fault-at 40 \
--imu-fault stale --gap-follow-arg imu_all:=true --output /tmp/stale-report.jsonIt prints its usual contact-and-distance verdict and writes a JSON report. The IMU result is the imu_validation part of the report, which the printed verdict does not judge. For each of steering damping, fused speed, the understeer check, traction control and impact stop, it gives the fraction of reason-bearing /drive_intent messages, from 0.5 s after the fault was acknowledged, that name that feature’s fallback. The harness passes imu_hardware:=false itself. --imu-fault also takes status_fault, zero and bias.
On October 29, 2026, on indoor_wide, the stale, status_fault and zero runs had at least 96% of messages naming every fallback, no contacts and no stops, and each kept its mean speed after the fault within 10% of the feature-off baseline’s 3.539 m/s. The bias run, whose status stays ok, also had no contacts.
What it models
/odom is dead-reckoned from the wheel speed and the commanded servo angle, as vesc_to_odom_node does, so it drifts the way the car’s does. Ground truth is published separately on /sim/ground_truth_pose, in its own sim_world frame, for scoring only; nothing that drives the car may read it. Contact is checked by sampling the padded body against the occupancy grid, and leaving the map counts as contact.
Limitations
- Not modelled: tire grip on a real floor, the VESC and servo dynamics, LiDAR reflectivity and multiple echoes, Wi-Fi, and CPU contention on the Jetson.
- The tyres never slide, so the simulated IMU cannot show real wheelspin or understeer. Use it to check IMU wiring and fallbacks, not to tune grip, understeer or traction thresholds.
- The simulated car uses the selected car’s servo map and full-lock limits, from its profile. Set
RACERBOT_CAR=rb1orrb2on a development computer to choose which car it models. Car 1’s right full lock is computed from its servo map, not measured. - A simulator pass is not physical sign-off. The step-by-step workflow is in
docs/sim-validation.mdGitHub.
Checked against
car 1 commit 8a88dcc
and
car 2 commit 80da6f5, before the two workspaces merged.