Wheel Encoders and Odometry: Tracking Robot Position with Arduino

Without feedback, a robot told to drive one metre simply runs its motors for a guessed time and ends up somewhere near the target. Wheel encoders fix this by counting how far each wheel actually turned. From those counts the robot can calculate its distance, speed and turn angle, and by adding up many small movements it can estimate its position on the floor. This estimate is called odometry or dead reckoning.

In this tutorial you will learn how quadrature encoders work, read two of them reliably with Arduino interrupts, convert ticks into distance, and implement the standard odometry update for a two-wheel differential drive robot. You will also see why odometry drifts over time, and what that means for real navigation.

How Quadrature Encoders Work

Gear motors such as the N20 and many 12 V metal gear motors are sold with a magnetic Hall-effect encoder on the rear motor shaft. The encoder has two outputs, A and B, that produce square waves 90 degrees out of phase. The number of pulses tells you how far the shaft turned, and which channel leads tells you the direction.

Forward:   A  _|--|__|--|__|--      Reverse:  A  _|--|__|--|__|--
           B  __|--|__|--|__|-                B  |--|__|--|__|--|_

On a rising edge of A:  B LOW  -> one direction
                        B HIGH -> the other direction

Because the encoder sits on the motor shaft, before the gearbox, its resolution at the wheel is multiplied by the gear ratio. Sellers list the pulses per motor revolution and the gear ratio, but these figures differ between products, so always measure: mark the wheel, turn it exactly ten revolutions by hand and divide the counted ticks by ten.

Ticks per wheel revolution  N = encoder pulses per motor rev x gear ratio x edges counted
Distance per tick           d = (pi x wheel diameter) / N

Example: measured N = 700 ticks/rev, wheel diameter 43 mm
d = (3.1416 x 43) / 700 = 0.193 mm per tick

Wiring to an Arduino Uno

The Uno has two external interrupt pins, 2 and 3. Each encoder's channel A goes to one of them so that no pulse is missed, and channel B goes to an ordinary input to read direction.

Encoder wireLeft motorRight motor
Channel AD2 (INT0)D3 (INT1)
Channel BD11D12
Encoder VCC5 V (check your encoder rating)5 V
Encoder GNDGNDGND

The Odometry Update

Every short time step, read how many ticks each wheel moved since the last step, convert them to distances dL and dR, and update the pose (x, y, theta). L is the wheelbase, the distance between the two wheel contact points.

d      = (dR + dL) / 2           distance moved by robot centre
dtheta = (dR - dL) / L           change in heading (radians)

x     = x + d * cos(theta + dtheta/2)
y     = y + d * sin(theta + dtheta/2)
theta = theta + dtheta

Using the heading at the middle of the step, theta plus half of dtheta, is a simple improvement over using the old heading and noticeably reduces error on curves.

Encoder and Odometry Code

Interrupt-based encoder counting and 2WD odometry at 50 Hz
// Quadrature encoder reading + differential-drive odometry (Arduino Uno)
const int L_A = 2, L_B = 11;   // left encoder
const int R_A = 3, R_B = 12;   // right encoder

// Robot geometry: MEASURE these on your robot
const float TICKS_PER_REV = 700.0;   // ticks per wheel revolution (measured)
const float WHEEL_DIAM_M  = 0.043;   // wheel diameter in metres
const float WHEELBASE_M   = 0.130;   // distance between wheel centres
const float M_PER_TICK = PI * WHEEL_DIAM_M / TICKS_PER_REV;

volatile long leftTicks = 0, rightTicks = 0;
long prevLeft = 0, prevRight = 0;
float x = 0, y = 0, theta = 0;       // pose in metres and radians
unsigned long lastUpdate = 0;

// ISRs: count on rising edge of A, use B for direction.
// If a wheel counts backwards when driving forward, swap the ++ and --.
void leftISR()  { if (digitalRead(L_B)) leftTicks--;  else leftTicks++;  }
void rightISR() { if (digitalRead(R_B)) rightTicks++; else rightTicks--; }

void setup() {
  pinMode(L_A, INPUT_PULLUP); pinMode(L_B, INPUT_PULLUP);
  pinMode(R_A, INPUT_PULLUP); pinMode(R_B, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(L_A), leftISR, RISING);
  attachInterrupt(digitalPinToInterrupt(R_A), rightISR, RISING);
  Serial.begin(115200);
}

void loop() {
  if (millis() - lastUpdate < 20) return;   // 50 Hz update
  float dt = (millis() - lastUpdate) / 1000.0;
  lastUpdate = millis();

  // Copy counters with interrupts off so a 4-byte read is not torn
  noInterrupts();
  long l = leftTicks, r = rightTicks;
  interrupts();

  float dL = (l - prevLeft)  * M_PER_TICK;
  float dR = (r - prevRight) * M_PER_TICK;
  prevLeft = l; prevRight = r;

  float d = (dR + dL) / 2.0;
  float dTheta = (dR - dL) / WHEELBASE_M;
  x += d * cos(theta + dTheta / 2.0);
  y += d * sin(theta + dTheta / 2.0);
  theta += dTheta;
  theta = atan2(sin(theta), cos(theta));    // wrap to -pi..pi

  float vL = dL / dt, vR = dR / dt;         // wheel speeds in m/s

  Serial.print("x=");    Serial.print(x, 3);
  Serial.print(" y=");   Serial.print(y, 3);
  Serial.print(" th=");  Serial.print(theta * 180.0 / PI, 1);
  Serial.print(" vL=");  Serial.print(vL, 2);
  Serial.print(" vR=");  Serial.println(vR, 2);
}

Calibrating and Understanding Drift

  1. Push the robot by hand in a straight line for exactly one metre along a measuring tape. If x reads 0.95 m, scale the wheel diameter by 1 / 0.95.
  2. Rotate the robot on the spot through exactly ten full turns using a floor mark. Compare the summed heading with 10 x 360 degrees and correct the wheelbase in the same way.
  3. Repeat each test three times and use the average.

Even after calibration, odometry error grows the further the robot travels, because small errors in wheel diameter, wheel slip on turns and uneven floors are added up at every step. Heading errors are the worst, since a one degree heading error turns into a growing sideways error. Real robots correct this by fusing odometry with a gyroscope, landmarks, or a map using lidar.

Troubleshooting

  • Counts jump randomly when motors run: motor noise on the encoder lines. Twist encoder wires, keep them away from motor leads, and add a small capacitor if the encoder datasheet allows.
  • Count only increases: channel B is not connected or reads constantly the same level.
  • x goes negative when driving forward: one or both ISRs have the wrong sign.
  • Serial output slows the loop: printing at 115200 baud for every update is fine; at 9600 it may not keep up with 50 Hz.

Summary

Quadrature encoders give both distance and direction, and interrupts on pins 2 and 3 make sure no tick is missed. Measured ticks per revolution, wheel diameter and wheelbase turn tick counts into the robot's x, y and heading through a simple odometry update. Calibrate with straight and rotation tests, and expect drift that must eventually be corrected by other sensors.