Docs menu
Neural racer
An experimental learned racing policy: a small network trained in simulation, run in shadow mode beside gap_follow, inside a deterministic safety wrapper that can only make its commands safer.
neural_racer_node drives with a small neural network trained in simulation. It reads the LiDAR scan, the car’s speed and the IMU, and outputs a steering angle and a speed once per scan, about 40 times a second. It is experimental: it runs in shadow mode by default, and no model has driven the car yet. The aim is a policy that races well on the first lap of a course it has never seen, uses that lap’s map from the second lap on, and handles other cars.
The node is in src/neural_racer/GitHub and the training code in tools/neural_racer/GitHub. Both cars run it. It needs a healthy IMU, and drives only while /imu/status reads ok and the IMU data is fresh. Car 2’s BNO085 mount yaw is not measured yet, so the network has been run only on car 1. Code excerpts below are from observation.py, policy_numpy.py and safety_wrapper.py, with docstrings and input checks removed.
Topics
/neural_racer/shadow_drive; with shadow:=false it goes to /drive.Subscribes to
| Topic and type | Used for |
|---|---|
/scan, sensor_msgs/LaserScan |
The network’s main input. Each scan runs one tick. |
/odom, nav_msgs/Odometry |
The measured speed |
/imu/data, sensor_msgs/Imu |
Yaw rate and forward and sideways acceleration, from imu_bridge |
/imu/status, std_msgs/String (JSON, latched) |
imu_bridge’s health verdict. Anything but ok means a zero command, with require_imu (on). |
/joy, sensor_msgs/Joy |
The LB deadman |
/slam_pose, geometry_msgs/PoseStamped |
Map mode only: where the car is |
/auto_map_race/race_path, nav_msgs/Path (latched) |
Map mode only: the reference path. Nothing in the workspace publishes it yet. |
/auto_map_race/frozen_map, nav_msgs/OccupancyGrid (latched) |
Map mode only: the map for the path’s braking cap |
Publishes
| Topic and type | Used for |
|---|---|
/neural_racer/shadow_drive or /drive, ackermann_msgs/AckermannDriveStamped |
The command. Shadow mode publishes to the first, which nothing forwards to the motor. |
/drive_intent, std_msgs/String (JSON) |
The network’s forecast of the car’s path, and which limit set the speed |
One tick
- Check the driver and the inputs. LB must be held, with
/joynewer than 0.5 s. The scan must be newer than 75 ms, odometry newer than 0.1 s, the IMU newer than 0.1 s with its statusok, and in map mode the pose newer than 0.2 s. Any failure gives a zero command. - Build the observation: turn the scans, speeds, IMU samples and past commands into fixed-size arrays of numbers.
- Run the network to get a steering angle, a speed, and a forecast of the car’s path.
- Wrap it: a fixed sequence of checks that can only lower the command.
- Publish the command, then the intent.
The observation
A network takes a fixed number of numbers, each on roughly the same scale. The observation turns the raw inputs into that form. Every scale is a constant written in observation.py, never a running average, so the car’s copy and the training code’s copy cannot drift apart.
The scan. The LiDAR gives 1081 ranges, 270° at 0.25°. The node groups them into 270 bins of four beams and keeps the minimum valid range in each bin, so a thin chair leg or the corner of another car cannot vanish into an average. Each range is then put on a log scale between 0.05 m and 10 m:
which maps 0.05 m to 0 and 10 m to 1. A log scale spends the network’s resolution where it matters: the difference between 0.3 m and 0.6 m is as large as the difference between 4 m and 8 m, because both are a doubling. A bin with no valid beam reads 10 m with a separate validity flag of 0, so “no echo” and “far away” stay distinguishable.
def pool_scan(ranges, range_min, range_max):
r = np.asarray(ranges, dtype=np.float64)
valid = beam_validity(r, range_min, range_max)
usable = np.where(valid, r, np.inf)[:N_BINS * POOL].reshape(N_BINS, POOL)
nearest = usable.min(axis=1)
bin_valid = np.isfinite(nearest)
nearest = np.where(bin_valid, nearest, R_MAX)
return encode_range(nearest).astype(np.float32), bin_valid.astype(np.float32)reshape(N_BINS, POOL) lays the 1080 beams out as 270 rows of 4, so min(axis=1) takes the minimum of each row at once. Beam 1081 is dropped.
The scan block holds the last three scans plus the newest scan’s validity flags, four rows of 270. Three scans in a row let the network see motion: how fast things are approaching, not only where they are.
| Block | Shape | Contents |
|---|---|---|
scan |
4 × 270 | Log ranges of the last three scans, and the newest scan’s validity |
ego |
23 | The last three speeds, steering and speed commands, yaw rates and accelerations; IMU present; mode; speed cap; camera flags |
preview |
4 × 32 | Map mode: the next 16 m of the reference path, every 0.5 m |
race |
6 | Map mode: lap fraction, place and gaps |
camera |
64 | Zeros until a camera is added |
Speeds are divided by 5 m/s, yaw rates by 3 rad/s and accelerations by 9.81 m/s², so each lands near the range −1 to 1.
The network
The network runs in NumPy on the Jetson’s CPU, with no PyTorch or GPU. It has four parts.
Convolutions over the scan. A 1D convolution slides a small set of weights along the 270 bins and computes a weighted sum at each position. The same weights are used everywhere, so a pattern such as a gap edge is detected the same way wherever it appears. Four convolutions with stride 2 each halve the length, building up from edges to larger shapes. The code computes each one as a single matrix multiplication: it lays out every window of the input as one row of a matrix, then multiplies by the weights.
def __call__(self, x):
xp = np.pad(x, ((0, 0), (self.padding, self.padding)))
win = sliding_window_view(xp, self.k, axis=1)[:, ::self.stride, :]
cols = np.ascontiguousarray(win.transpose(1, 0, 2)).reshape(win.shape[1], -1)
return (cols @ self.w2 + self.b).TFusion. The scan features, the ego features (two dense layers) and, in map mode, the preview and race features are joined into one vector. Layer normalization rescales it to mean 0 and variance 1, so no block dominates by size. In reactive mode the map blocks are multiplied by 0.
Memory. A gated recurrent unit (GRU) with 128 numbers of state carries information from one tick to the next. Each tick, two gates decide how much of the old state to keep and how much to replace with the new input:
is the update gate, between 0 and 1 for each of the 128 entries, is the candidate new state, and multiplies entry by entry. Where is near 1 the old state is kept, so the network can remember, for example, that a wall it saw a second ago is now beside it.
Outputs. A small head turns the state into two numbers, and , and squashes each into its allowed range:
lies between −1 and 1, so the steering can never exceed 0.33 rad, and between 0 and 1, so the speed lies between 0 and the cap. The limits hold by construction, whatever the weights. A second head forecasts the car’s own path for the next 1.5 s, six points 0.25 s apart, and the positions of up to four opponents. Neither forecast drives the car. The path forecast goes to /drive_intent, so the dashboard shows where the network expects to go, and in map mode the wrapper uses it for the map path cap.
r = _sigmoid(gi[:H] + gh[:H])
zg = _sigmoid(gi[H:2 * H] + gh[H:2 * H])
n = np.tanh(gi[2 * H:] + r * gh[2 * H:])
self.h = ((1.0 - zg) * n + zg * self.h).astype(np.float32)
t = _silu(self._lin('head.fc0', self.h))
a = self._lin('head.act', t)
...
steering=float(obs.STEER_LIMIT * np.tanh(a[0])),
speed=float(v_cap * _sigmoid(a[1])),One tick, observation, network and wrapper together, took 3.90 ms median and 4.03 ms at the 99th percentile on car 1’s Jetson, 16% of the 25 ms between scans.
The safety wrapper
A deterministic wrapper runs around the network every tick. Each step can only make the command safer, never faster. The deadman and freshness checks are in step 1 above. The rest, in order:
- Scan sanity. Stop if the beam count is wrong or more than 30% of beams are invalid (
max_invalid_fraction). - Output sanity. Stop if the network produced a non-finite number.
- Clip the steering to
max_steering_angleand the speed tomax_speed(1.0 m/s). - Steering rate. Limit the change to 2.0 rad/s.
- Braking cap.
gap_follow’s own braking-distance formula, imported fromgap_logic, not copied, applied to the smallest body clearance in any direction. See Follow the gap for its derivation. - Cornering cap. with and = 2.0 m/s².
- Map path cap. In map mode, the braking cap again, to the first wall along the network’s forecast path.
The final speed is the lowest of the network’s speed and every cap. The wrapper records which one won, and whether anything other than the network shaped the command.
caps = {'network': speed}
max_step = c.max_steering_rate * min(dt, c.command_slew_max_dt)
steering = min(max(steering, prev_steering - max_step), prev_steering + max_step)
boundary = self.boundary(angles)
clearance = gap_logic.minimum_footprint_clearance(ranges, valid, boundary)
caps['braking'] = gap_logic.braking_speed_limit(
clearance, c.reserve_distance, c.max_braking_decel, c.max_speed,
c.braking_latency_sec)
kappa = abs(math.tan(steering)) / c.wheelbase
if kappa > 1e-9:
caps['curvature'] = math.sqrt(c.max_lateral_accel / kappa)
binding = min(caps, key=caps.get)
final_speed = caps[binding]| Car 1 | Car 2 | |
|---|---|---|
max_steering_angle | 0.33 rad | 0.349 rad. The network still never asks for more than 0.33 rad, the limit it was trained with. |
Shadow mode
By default the node publishes only on /neural_racer/shadow_drive, never on /drive. It refuses a shadow topic that reaches the motor, and then publishes nothing at all. gap_follow drives the car while the network’s commands are recorded beside it for comparison.
Terminal 1:
ros2 launch f1tenth_stack bringup_launch.pyTerminal 2, the IMU from imu_bridge. Keep the car still for about 3 s after it starts, for the gyro bias and the gravity check.
ros2 launch imu_bridge vehicle_state_launch.pyIt is working when ros2 topic echo /imu/status --once shows "state": "ok". A fault names its reason.
Terminal 3:
ros2 launch gap_follow gap_follow_launch.pyTerminal 4, the network:
ros2 launch neural_racer neural_racer_launch.py model_hash:=<hash>It is working when the log reads SHADOW -> /neural_racer/shadow_drive and ros2 topic hz /neural_racer/shadow_drive reads about 40 Hz while LB is held. A REFUSING TO DRIVE: line names the reason when it is not: an unapproved model, a camera that is turned on, a max_speed outside 0 to 6.37 m/s, or an unknown mode.
Approve a model
The node loads only models listed in ~/.ros/neural_racer/models/manifest.yaml. approve_model adds one after checking its hash, its observation format, and that the NumPy forward pass matches PyTorch to within 1e-5.
ros2 run neural_racer approve_model ~/.ros/neural_racer/incoming/<hash> --shadow-onlyIt prints approved <hash> for shadow mode only; golden check passed, or REFUSED: with the reason. A model may drive /drive only once its selection and referee reports both pass.
Training
Training runs on the FIR cluster or a CUDA workstation, never on the car. A batched simulator in PyTorch runs about 16,000 races at once and matches the calibrated CPU simulator to within 2 cm and 0.5° over 10 s of driving. A teacher is trained first, distilled into the student network the car runs, then improved in a league against archived copies of itself and scripted opponents. Releases are chosen by racing the exported policy against gap_follow and pure_pursuit on held-out tracks that training never sees. The stages and the cluster scripts are in tools/neural_racer/README.mdGitHub.
Test order
- Static check of its topics, with no driver stack running.
- Shadow mode beside
gap_follow. - Wheels off the ground, with
shadow:=false. - The floor at
max_speed:=1.0, raised 0.5 m/s at a time after each clean run.
Tests
The package’s tests run without ROS, apart from the node test:
python3 -m pytest src/neural_racer/test/ -v --ignore=src/neural_racer/test/test_neural_racer_node.pyThey check, among other things, that the NumPy network matches a plain reference implementation step by step, that its outputs stay inside their limits for any input, that the wrapper never raises the network’s speed, and that its braking cap matches gap_logic. The match against PyTorch is checked per model, by approve_model. See Run the tests.
Limitations
- No model has driven the car yet.
- Car 2 cannot run it until it has a BNO085 and its IMU calibration, because
imu_bridgewill not start without them. - Map mode has no source for its reference path yet: nothing publishes
/auto_map_race/race_path. - The camera input slot exists but is disabled.
camera_enabled: trueis refused.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.