Docs State estimation Workspace repo
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

Topics of vesc_to_odom_node vesc_to_odom_node subscribes to /sensors/core, /sensors/servo_position_command and publishes /odom, /tf (odom -> base_link). SUBSCRIBES PUBLISHES /sensors/core VescStateStamped /sensors/servo_position_command Float64 /odom Odometry /tf (odom -> base_link) TFMessage vesc_to_odom_node
How to read it: the navy box is the node. The boxes on the left are the topics it subscribes to and the boxes on the right are the topics it publishes, each with its message type underneath. An arrow shows which way the data flows.
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.

calibration/vesc.yaml, speed
Car 1Car 2
Motor command gain (/** block)36504614
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:

calibration/vesc.yaml, steering
Car 1Car 2
steering_angle_to_servo_gain −0.83−0.78
steering_angle_to_servo_offset 0.3660.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.x

The 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/fused is the workspace’s answer to that.

Checked against car 1 commit 0265263 and car 2 commit b81b291, before the two workspaces merged.