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

  1. It publishes. ros2 topic hz /drive shows the node’s rate.
  2. 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.
  3. It survives a lap. On the dashboard, wall contact shows as the car stopping with LiDAR points crowded on one side.
  4. 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_wide before debugging the node.

Pass or fail

tools/racerbot_sim/run_auto_map_validation.py --scenario all

It 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/status

The 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.json

It 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=rb1 or rb2 on 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.