Docs menu
Wheel odometry
How vesc_to_odom_node turns the VESC's motor speed and the servo command into speed, yaw rate and a dead-reckoned pose on /odom, and why its heading drifts.
vesc_to_odom_node estimates how far the car has moved by counting motor revolutions and reading the last steering command. It publishes /odom and the odom to base_link transform each time the VESC reports its state, at 50 Hz. This is dead reckoning, covered in Lecture 7 (notes): adding up small measured movements from a known start. It is fast and smooth, and its error grows without limit, most of all in heading, which is why SLAM, the particle filter and the IMU all correct it.
The node is part of bringup. Its code is vesc_to_odom.cppGitHub, kept identical to upstream; its shared settings, such as the wheelbase, are in vesc.yamlGitHub, and everything specific to each car is in the car’s calibration/vesc.yaml (car 1Car 1, car 2Car 2), which bringup loads on top. The excerpts below are from the C++ file.
Topics
| Topic and type | Direction | Used for |
|---|---|---|
/sensors/core, vesc_msgs/VescStateStamped |
Subscribed | The VESC’s state, including the motor’s electrical RPM, from vesc_driver_node |
/sensors/servo_position_command, std_msgs/Float64 |
Subscribed | The last servo command, from ackermann_to_vesc_node, used as the steering angle |
/odom, nav_msgs/Odometry |
Published | Speed, yaw rate and the dead-reckoned pose. Read by gap_follow, pure_pursuit, imu_bridge, the particle filter and the dashboard. |
odom → base_link on /tf |
Published | The same pose as a transform (publish_tf: true). SLAM reads this instead of the topic. |
Until the first servo command arrives, the node publishes nothing: without a steering angle it cannot compute a yaw rate. Bringup alone sends no servo command, so /odom stays silent until a control layer starts.
Speed from the motor
The VESC drives a brushless motor and counts its electrical revolutions per minute (ERPM). That is proportional to the car’s speed, through a chain of fixed ratios: the motor’s pole pairs (electrical revolutions per mechanical one), the gear ratio between the motor and the wheels, and the wheel’s diameter . One wheel revolution moves the car , so at speed in m/s:
Every factor in is a constant, so ERPM is times the speed. Rather than measure , and the tyre diameter under load separately, the gain is measured directly: drive a tape-measured distance, divide the ERPM by the speed. Car 1’s odometry gain was set on October 21, 2026 from five short tape runs, whose implied gains were 3959, 3936, 4044, 4088 and 4179; the median, 4044, was rounded to 4040.
The node inverts that relation, with an offset for a motor that reads non-zero at rest:
double current_speed = (-state->state.speed - speed_to_erpm_offset_) / speed_to_erpm_gain_;
if (std::fabs(current_speed) < 0.05) {
current_speed = 0.0;
}state.speed holds the ERPM. Speeds under 0.05 m/s are set to zero, so sensor noise on a parked car does not slowly move the pose.
The sign. The upstream code negates the ERPM it reads, and these cars’ VESCs already report ERPM with the commanded sign, so a positive gain would make /odom read negative speed while the car drives forward. The vesc_to_odom_node block of each car’s calibration/vesc.yaml therefore sets a negative gain, which cancels the negation. The shared /** block keeps a positive gain for ackermann_to_vesc_node, which converts speed commands into motor commands. Never fix the sign in both places: the two fixes cancel and /odom runs backwards.
| Car 1 | Car 2 | |
|---|---|---|
Motor command gain (/** block) | 3650 | 4614 |
Odometry gain (vesc_to_odom_node block) | −4040 | −4447.98 |
The command gain and the odometry gain are calibrated separately and need not match. The odometry gain says how far the car went; the command gain says what to ask the motor for to get a given speed.
Steering from the servo command
There is no sensor on the steering. The node takes the last servo command , a number between 0 and 1, and inverts the linear servo map that ackermann_to_vesc_node used to produce it:
| Car 1 | Car 2 | |
|---|---|---|
steering_angle_to_servo_gain | −0.83 | −0.78 |
steering_angle_to_servo_offset | 0.366 | 0.5904 |
The gain is negative because a larger servo value turns the wheels right, while a positive steering angle means left. The offset is the servo value that points the wheels straight, which depends on how the servo horn sits on its splines, so each car has its own.
This is the steering the car was asked for, not what the wheels did. Servo lag, play in the linkage and tyre slip all go unmeasured.
Yaw rate
The kinematic bicycle model from Lecture 6 (notes) collapses each axle to one wheel on the centre line. The rear wheel cannot slide sideways, so the car turns about a point on the line through the rear axle. The front wheel, a wheelbase ahead, must point at right angles to the line from that point, which makes a right-angled triangle with the turning radius :
A car moving at around a circle of radius turns at the yaw rate :
current_steering_angle =
(last_servo_cmd_->data - steering_to_servo_offset_) / steering_to_servo_gain_;
current_angular_velocity = current_speed * tan(current_steering_angle) / wheelbase_;Integrating the pose
Each new VESC message arrives after the last, about 20 ms. Over that interval the node assumes the speed, heading and yaw rate were constant, and steps the pose forward. This is Euler integration: the rate of change of a quantity times the time step gives its change.
and split the velocity along the map’s and axes. The position step uses the heading from the start of the interval, then the heading is updated.
auto dt = rclcpp::Time(state->header.stamp) - rclcpp::Time(last_state_->header.stamp);
double x_dot = current_speed * cos(yaw_);
double y_dot = current_speed * sin(yaw_);
x_ += x_dot * dt.seconds();
y_ += y_dot * dt.seconds();
yaw_ += current_angular_velocity * dt.seconds();comes from the timestamps on the VESC messages, not from the time the node received them, so a delayed message does not stretch the step.
Where the error comes from
Every step adds a little error, and nothing ever removes it. Heading error matters most, because it turns into position error that grows with distance. A heading 1° off makes the estimated path diverge from the real one by : 17 cm after 10 m, 1.7 m after a 100 m session.
| Source | Effect |
|---|---|
| Wheelspin or a locked wheel | The motor count keeps going, so speed and distance are wrong |
| The steering is the command, not the wheels | Servo lag, play in the steering and understeer go unmeasured |
| A wrong wheelbase | Every steering angle gives the wrong yaw rate. A 10% short wheelbase gives 10% too much turn. |
| A wrong servo centre or gain | The heading drifts steadily to one side |
| A wrong speed gain | Every distance is off by the same factor |
This is why SLAM corrects odom against the map, why the particle filter localizes against a saved map, and why car 1 adds a gyro heading through imu_bridge. See IMU fusion.
Check it
With bringup and teleop running, push the car forward by hand:
ros2 topic echo /odom --field twist.twist.linear.xThe speed is positive while the car moves forward and 0.0 at rest. A negative speed going forward means the sign fix is missing or doubled. If /odom is silent, no servo command has been sent yet: start teleop. To calibrate the gains with a tape measure, use the odometry calibration wizard.
Limitations
- The steering angle is the commanded one, so every source of steering error goes unmeasured.
- The geometry, including the 0.36 m wheelbase, was measured on car 2. Car 1 uses the same values.
- Wheelspin makes the speed wrong with no warning.
imu_bridge’s fused speed on/odom/fusedis the workspace’s answer to that.
Checked against
car 1 commit 0265263
and
car 2 commit b81b291, before the two workspaces merged.