How an Optical Arduino Velocity Sensor Actually Works
When you need to measure the speed of a wheel, conveyor, or motor shaft, a simple Hall effect switch often lacks the resolution for low-speed precision, while a GPS module is useless indoors or on a stationary rig. The industry-standard solution for bench and robotics applications is an optical rotary encoder. By pairing a 600 P/R (Pulses per Revolution) optical encoder with an Arduino, you create a high-fidelity velocity sensor capable of tracking both rapid acceleration and ultra-slow crawling.
An optical encoder uses a slotted disk spinning between an infrared LED and a phototransistor. As the slots pass, the sensor outputs a square wave. By measuring the time interval between these pulses on a hardware interrupt pin, the Arduino calculates instantaneous angular velocity (RPM). If you know the diameter of the wheel attached to the shaft, you can mathematically convert that angular velocity into true linear velocity (meters per second).
This guide targets the Arduino Uno Rev3 (ATmega328P) and its Nano v3 equivalents. We will use an NPN open-collector encoder module, which requires external pull-up resistors—a detail frequently missed in beginner tutorials, leading to erratic readings.
Resolution Math: PPR vs. Linear Velocity
Before wiring the sensor, you must understand how your wheel size dictates the physical resolution of your velocity sensor. A 600 P/R encoder outputs 600 pulses per full rotation. If you use quadrature decoding (reading both Channel A and Channel B on rising and falling edges), you can multiply this by 4 for 2400 steps, but for raw velocity tracking, reading a single channel on a single edge (600 pulses) is cleaner and avoids phase-shift errors at high RPM.
The table below maps common robotics wheel diameters to their physical resolution when paired with a 600 P/R encoder. This data assumes a single-channel, single-edge interrupt read.
| Wheel Diameter (mm) | Circumference (m) | Distance per Pulse (mm) | Linear Velocity at 1 kHz Pulse Rate (m/s) | Min Detectable Velocity (10 Hz Sample) |
|---|---|---|---|---|
| 65 (Standard RC Car) | 0.2042 | 0.340 | 0.340 | 0.0034 m/s |
| 100 (Small Rover) | 0.3142 | 0.524 | 0.524 | 0.0052 m/s |
| 152 (6" Robot Wheel) | 0.4775 | 0.796 | 0.796 | 0.0079 m/s |
| 254 (10" Scooter Wheel) | 0.7980 | 1.330 | 1.330 | 0.0133 m/s |
Note: The 'Distance per Pulse' is your physical resolution limit. If your 65mm wheel moves less than 0.340mm between your sampling intervals, the sensor will register zero velocity. For sub-millimeter tracking at crawling speeds, you must increase the sampling window or use a gearbox to multiply shaft RPM relative to wheel RPM.
Hardware BOM and Pin Mapping
To build this reliably, avoid the cheap 20 P/R KY-040 rotary encoder modules sold for volume knobs; they are mechanically noisy and lack the resolution for velocity tracking. Instead, source an industrial-style optical encoder.
Parts List
- Microcontroller: Arduino Uno Rev3 (or Nano v3 with ATmega328P).
- Sensor: 5V-24V 600 P/R Optical Encoder (e.g., OMRON E6B2-CWZ6C equivalent or generic '600PR NPN Open Collector' module).
- Resistors: 2x 10kΩ through-hole or breadboard resistors (critical for open-collector pull-ups).
- Power Supply: 5V DC (Arduino 5V pin is sufficient for the encoder's internal optocouplers if drawing <30mA).
- Wiring: 22 AWG stranded hookup wire.
Pin Mapping Table
| Encoder Wire / Pin | Arduino Uno Rev3 Pin | Notes & Hardware Requirements |
|---|---|---|
| VCC (Brown/Red) | 5V | Verify module accepts 5V. Industrial units often need 12V-24V. |
| GND (Blue/Black) | GND | Ensure common ground with motor driver if measuring driven wheels. |
| Phase A (White) | Pin 2 (INT0) | Must have 10kΩ pull-up to 5V. Hardware interrupt required. |
| Phase B (Green) | Pin 3 (INT1) | Pull-up to 5V. Used for direction (not used in base velocity code). |
| Index Z (Orange) | Pin 4 | Pull-up to 5V. Fires once per revolution for homing. |
Most 600 P/R encoders use NPN open-collector outputs. This means the internal transistor pulls the signal line to GND when a pulse occurs, but does not drive it HIGH when idle. Without the 10kΩ pull-up resistor connecting the signal wire to the Arduino's 5V rail, the pin will float, causing the interrupt to fire thousands of times a second from EMI noise. If your Serial monitor is flooded with random RPM spikes while the shaft is still, you forgot the pull-ups.
Compilable C++ Code for Interrupt-Driven Velocity
This code uses hardware interrupts to capture the exact microsecond timestamp of each pulse. By calculating the delta time (dt) between pulses, we derive the instantaneous RPM and linear velocity. It includes a timeout function to report 0 m/s when the wheel stops, preventing the 'divide by zero' crash or the 'last known speed' ghost reading common in naive polling loops.
/*
* Arduino Velocity Sensor - 600 P/R Optical Encoder
* Target Board: Arduino Uno Rev3 / Nano v3 (ATmega328P)
* Author: Electricalflux.com
*/
// --- PIN DEFINITIONS ---
#define ENCODER_PIN_A 2 // Must be hardware interrupt pin (2 or 3 on Uno)
#define ENCODER_PIN_B 3 // Reserved for future quadrature direction decoding
// --- PHYSICAL CONSTANTS ---
#define PPR 600.0 // Pulses per revolution (single edge, single channel)
#define WHEEL_DIAMETER_MM 152.0 // 6-inch standard robot wheel
#define PI 3.14159265359
// Derived constants
const float WHEEL_CIRCUMFERENCE_M = (WHEEL_DIAMETER_MM * PI) / 1000.0;
const float DISTANCE_PER_PULSE_M = WHEEL_CIRCUMFERENCE_M / PPR;
// --- VOLATILE INTERRUPT VARIABLES ---
volatile unsigned long lastPulseMicros = 0;
volatile unsigned long pulseIntervalMicros = 0;
volatile bool newPulseReceived = false;
volatile bool timingError = false;
// Timeout threshold (100,000 microseconds = 100ms)
// If no pulse for 100ms, we assume velocity is 0.
#define STOP_TIMEOUT_MICROS 100000
void setup() {
Serial.begin(115200);
// Configure pins with internal pull-ups AS WELL AS external 10k resistors
// for maximum noise immunity on long wire runs.
pinMode(ENCODER_PIN_A, INPUT_PULLUP);
pinMode(ENCODER_PIN_B, INPUT_PULLUP);
// Attach interrupt on RISING edge
attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), isrPulse, RISING);
lastPulseMicros = micros();
Serial.println("Arduino Velocity Sensor Initialized.");
Serial.println("Time(ms)\tRPM\tLinear_Vel(m/s)");
}
void loop() {
unsigned long currentTime = micros();
float rpm = 0.0;
float velocity_m_s = 0.0;
// Check for timeout (wheel has stopped)
if ((currentTime - lastPulseMicros) > STOP_TIMEOUT_MICROS) {
pulseIntervalMicros = 0;
rpm = 0.0;
velocity_m_s = 0.0;
}
else if (newPulseReceived) {
newPulseReceived = false; // Reset flag
if (timingError) {
Serial.println("ERR: dt <= 0. Check INT pin or contact bounce.");
timingError = false;
} else if (pulseIntervalMicros > 0) {
// Calculate RPM: (60 seconds / interval in seconds) / PPR
float intervalSeconds = pulseIntervalMicros / 1000000.0;
rpm = 60.0 / (intervalSeconds * PPR);
// Calculate Linear Velocity: Pulses per second * distance per pulse
float pulsesPerSecond = 1.0 / intervalSeconds;
velocity_m_s = pulsesPerSecond * DISTANCE_PER_PULSE_M;
}
} else {
// No new pulse, maintain last known velocity or 0 if timed out
// (Omitted for brevity, relies on previous loop state)
}
// Output data at a readable rate (approx 10Hz)
static unsigned long lastPrintTime = 0;
if (currentTime - lastPrintTime >= 100000) { // 100ms
lastPrintTime = currentTime;
Serial.print(currentTime / 1000);
Serial.print("\t");
Serial.print(rpm, 2);
Serial.print("\t");
Serial.println(velocity_m_s, 4);
}
}
// --- INTERRUPT SERVICE ROUTINE (ISR) ---
void isrPulse() {
unsigned long now = micros();
unsigned long dt = now - lastPulseMicros;
// Error handling for microsecond overflow or contact bounce (dt == 0)
if (dt > 0 && dt < 4000000000UL) {
pulseIntervalMicros = dt;
newPulseReceived = true;
} else {
timingError = true;
}
lastPulseMicros = now;
}
Debugging: When Your Velocity Reads Zero or Spikes
Embedded sensor builds rarely work perfectly on the first power-up. If your Serial monitor outputs erratic data, follow this decision path.
The Exact Error String
If you see the following string in your Serial output:
ERR: dt <= 0. Check INT pin or contact bounce.
This means the microcontroller registered two interrupts in the exact same microsecond, or the micros() timer rolled over (which happens every 70 minutes). The code's error-handling block catches this to prevent a divide-by-zero crash when calculating RPM.
The First Three Things to Check
- Floating Interrupt Pins (Missing Pull-ups): This is the #1 cause of erratic spikes. If you are using an NPN open-collector encoder and forgot the external 10kΩ pull-up resistors to 5V, the pin is floating. EMI from nearby motors or even your hand waving near the breadboard will trigger the interrupt. Fix: Install 10kΩ resistors between the signal wire and the 5V rail.
- Wrong Interrupt Pin Mapping: The code defines
ENCODER_PIN_Aas Pin 2. On the Arduino Uno Rev3 (ATmega328P), only Pins 2 and 3 support hardware interrupts. If you wired the encoder to Pin 4 and changed the macro to4,attachInterrupt()will silently fail, and your velocity will read exactly 0.00. Fix: Move the Phase A wire to Pin 2. - Mechanical Shaft Slip or Coupler Backlash: If the RPM reads correctly on the bench but drops to zero intermittently under load, your physical coupling is failing. Set screws on 3D-printed shaft couplers frequently strip or loosen under vibration. Fix: Use a clamp-style aluminum coupler or apply blue Loctite to the set screws.
Extending and Simplifying the Build
Depending on your project constraints, you may need to alter this baseline design.
How to Simplify (Low-Speed Applications)
If you are measuring the velocity of a slow-moving conveyor belt (<10 RPM) and want to free up hardware interrupts for other tasks, you can drop the interrupt entirely. Switch the pin to INPUT_PULLUP and use a polling loop with digitalRead() and delay(). While polling is generally frowned upon in embedded systems due to missed pulses, at very low speeds with a 600 P/R encoder, the pulse width is long enough (milliseconds) that a 1ms polling loop will easily catch the transitions without dropping data.
How to Extend (High-Speed and Direction Tracking)
For mobile robots that need to know if the wheel is rolling backward (e.g., slipping on a hill), you must implement quadrature decoding. This requires reading both Phase A and Phase B. By checking the state of Phase B at the exact moment Phase A triggers an interrupt, you can determine direction. If B is HIGH when A rises, the wheel is moving forward; if B is LOW, it is reversing.
Furthermore, if you are measuring a high-RPM spindle (e.g., 10,000 RPM), the 600 P/R encoder will generate 100,000 pulses per second. The ATmega328P will spend all its CPU cycles in the ISR, starving the main loop. For these extreme cases, upgrade to an Arduino Due (SAM3X8E) or an ESP32, which feature faster clock speeds and dedicated hardware timer counters that can tally encoder pulses in silicon without firing a software interrupt for every single edge.
By understanding the physical resolution limits and the electrical requirements of open-collector outputs, you can build an Arduino velocity sensor that rivals industrial tachometers in accuracy, provided you respect the math and the wiring.






