Skip to content

Advanced 13: Encoder Fundamentals

Prerequisites: Lab 4 (Motor Control), Lab: OLED Display Time: ~90 min


Overview

Before you can control motion, you need to measure it. This module introduces quadrature encoders — the sensors inside your motors that count wheel rotation. You'll start by turning the wheel by hand and watching ticks appear on screen, then graduate from polling to interrupts, and finally calibrate your encoder so ticks become real-world distances.

Along the way, you'll learn a methodology that applies far beyond encoders: measure → calibrate → control. Every sensor in embedded systems follows this pipeline.

You'll learn:

  • What a quadrature encoder measures and how the A/B channels work
  • How to read encoder ticks by polling and by interrupt (ISR)
  • Why ISR design discipline matters (and what breaks when you violate it)
  • The measure → calibrate → control methodology as a transferable skill
  • How to calibrate: ticks per revolution, ticks to distance
  • How to display live data on both the serial console and the OLED

Part 1: What's Inside the Motor (~10 min)

Hall-Effect Encoders

Your motors include hall-effect sensors — small chips that detect magnetic fields. A magnet ring attached to the motor shaft passes by two sensors (Channel A and Channel B) placed at slightly different positions.

As the motor turns, each sensor outputs a digital pulse train. The two channels are offset by 90 degrees (a quarter cycle) — this is why it's called "quadrature."

Why Two Channels?

A single channel can count rotations, but can't tell direction. With two channels offset by 90°, the relationship between them reveals which way the shaft is spinning:

Forward rotation:
  Ch A: ──┐  ┌──┐  ┌──┐  ┌──
          │  │  │  │  │  │
          └──┘  └──┘  └──┘
  Ch B:    ──┐  ┌──┐  ┌──┐  ┌──
             │  │  │  │  │  │
             └──┘  └──┘  └──┘
         ↑ A rises while B is LOW → forward

Reverse rotation:
  Ch A: ──┐  ┌──┐  ┌──┐  ┌──
          │  │  │  │  │  │
          └──┘  └──┘  └──┘
  Ch B: ┐  ┌──┐  ┌──┐  ┌──┐
        │  │  │  │  │  │  │
        └──┘  └──┘  └──┘  └──
         ↑ A rises while B is HIGH → reverse

The rule: On a rising edge of Channel A, read Channel B.

  • B is LOW → forward
  • B is HIGH → reverse
Pin Configuration

The encoder pins are defined in picobot/config.py (see the robot schematic (PDF) for the full circuit):

  • Left encoder: GP20 (Ch A), GP21 (Ch B)
  • Right encoder: GP18 (Ch A), GP19 (Ch B)

If your wiring is different, update config.PINS.ENCODER_LEFT_A etc.


Part 2: Feel the Ticks (~20 min)

Before writing any motor code, let's see the encoder work with your own hands. No motor, no PWM — just you turning the wheel.

Task 1 — Hand-Rotate and Print

Pick up the robot so the wheels are free. Slowly rotate the left wheel by hand and watch the tick count change:

from machine import Pin
import time

# Set up encoder pins directly (no library yet)
pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)

# Simple polling: read pin states in a loop
ticks = 0
last_a = pin_a.value()

print("Rotate the LEFT wheel by hand. Ctrl+C to stop.")
print()

try:
    while True:
        a = pin_a.value()

        # Detect rising edge on channel A
        if a == 1 and last_a == 0:
            if pin_b.value():
                ticks -= 1   # Reverse
            else:
                ticks += 1   # Forward

        last_a = a
        print(f"Ticks: {ticks}    ", end="\r")

except KeyboardInterrupt:
    print(f"\nFinal ticks: {ticks}")

Try this:

  1. Rotate the wheel forward slowly — ticks should increase
  2. Rotate backward — ticks should decrease
  3. Rotate a full revolution — note the tick count
  4. Change the inputs to try the other motor encoder too (GP18,GP19)
  5. Try rotating fast — do you lose ticks?
Wrong Direction?

If forward rotation gives negative ticks, swap the A and B pin numbers. This is a wiring convention, not a bug.

Can't Turn the Wheel?

Some motor gear ratios are too high to turn by hand comfortably. If this is the case, use the lowest PWM that moves the wheel (see Task 3). But try hand-rotation first — feeling the encoder respond to your fingers builds intuition that no amount of code can replace.

Task 2 — Show Ticks on OLED

Print is fine for debugging, but your robot has a screen. Display the tick count on the OLED so you can rotate the wheel and watch the display without a laptop connected:

from machine import Pin, I2C
from oled import SSD1306
import time

# Encoder
pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)

# OLED
i2c = I2C(1, scl=Pin(15), sda=Pin(14), freq=100_000)
oled = SSD1306(i2c)

ticks = 0
last_a = pin_a.value()
last_display = time.ticks_ms()

print("Rotate the wheel — watch the OLED!")

try:
    while True:
        a = pin_a.value()

        if a == 1 and last_a == 0:
            if pin_b.value():
                ticks -= 1
            else:
                ticks += 1
        last_a = a

        # Update display every 100 ms (not every tick!)
        now = time.ticks_ms()
        if time.ticks_diff(now, last_display) > 100:
            oled.fill(0)
            oled.text("Encoder Ticks", 0, 0)
            oled.text(f"Ticks: {ticks}", 0, 16)
            oled.show()
            last_display = now

except KeyboardInterrupt:
    oled.fill(0)
    oled.text(f"Final: {ticks}", 0, 8)
    oled.show()
Note

Notice we don't update the OLED on every tick — that would be far too slow. The display refreshes every 100 ms while the polling loop runs as fast as it can. This is your first encounter with rate separation: the sensor loop and the display loop run at different speeds.

Task 3 — Visualize with a Bar

Make the ticks visual. Add a progress bar that grows with the tick count:

# Inside the display update block, replace the oled lines:
oled.fill(0)
oled.text(f"Ticks: {ticks}", 0, 0)

# Draw a bar proportional to ticks (wrap at screen width)
bar_width = abs(ticks) % 128
oled.fill_rect(0, 20, bar_width, 10, 1)

# Direction arrow
direction = ">>>" if ticks >= 0 else "<<<"
oled.text(direction, 100, 0)
oled.show()

Try this: rotate the wheel forward and backward — watch the bar grow and the arrow flip.

Checkpoint — Encoder Responding

You should see tick counts changing as you rotate the wheel, on both the serial console and the OLED. Forward and backward produce opposite signs. If you see zero, check your wiring and pin numbers.


Part 3: Polling vs Interrupts (~20 min)

The Problem with Polling

Your Task 1 code works, but it has a fatal flaw: it only checks the encoder when the main loop gets around to it. Add a time.sleep(0.01) inside the loop and watch what happens — you'll miss ticks at any reasonable speed.

Task 4 — Measure What Polling Misses

Let's quantify the problem. Run the motor at a fixed speed and compare tick counts with different polling delays:

from machine import Pin
import time

pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)

def count_ticks_polling(duration_sec, poll_delay_us):
    """Count ticks by polling for a fixed duration."""
    ticks = 0
    last_a = pin_a.value()
    end = time.ticks_add(time.ticks_ms(), duration_sec * 1000)

    while time.ticks_diff(end, time.ticks_ms()) > 0:
        a = pin_a.value()
        if a == 1 and last_a == 0:
            if pin_b.value():
                ticks -= 1
            else:
                ticks += 1
        last_a = a

        if poll_delay_us > 0:
            time.sleep_us(poll_delay_us)

    return ticks

# You'll need the motor running for this test
from picobot import Robot
robot = Robot()

print("Polling delay test at PWM 80")
print("=" * 40)

try:
    robot.set_motors(80, 0)
    time.sleep(0.5)  # Let motor stabilize

    for delay in [0, 100, 500, 1000, 5000, 10000]:
        ticks = count_ticks_polling(2, delay)
        label = f"{delay} µs" if delay > 0 else "none (tight loop)"
        print(f"Delay {label:>20s}: {ticks:5d} ticks")

finally:
    robot.stop()

Record your results:

Polling delay Ticks in 2 sec % of best
None (tight loop) 100%
100 µs
500 µs
1 ms
5 ms
10 ms

Observation: Even small delays cause missed pulses. In real code, your main loop does many things (read sensors, update display, compute control) — each takes time. Polling is fundamentally unreliable for fast signals.

Task 5 — The ISR Solution

Hardware interrupts (IRQ) solve this: the processor automatically pauses whatever you're doing, runs a tiny handler function, and resumes. No pulses missed, even if your main loop is busy.

from machine import Pin, disable_irq, enable_irq
import time

pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)

ticks = 0

def encoder_isr(pin):
    """ISR: runs on every rising edge of channel A."""
    global ticks
    if pin_b.value():
        ticks -= 1
    else:
        ticks += 1

# Attach ISR to rising edge of channel A
pin_a.irq(trigger=Pin.IRQ_RISING, handler=encoder_isr)

print("ISR-based encoder. Rotate wheel or run motor.")
print("Ctrl+C to stop.")

try:
    while True:
        # Main loop is free to do anything — ISR handles ticks
        print(f"Ticks: {ticks}    ", end="\r")
        time.sleep(0.1)  # Sleep doesn't miss ticks!
except KeyboardInterrupt:
    pin_a.irq(handler=None)  # Detach ISR
    print(f"\nFinal: {ticks}")

Key insight: The time.sleep(0.1) in the main loop doesn't cause missed ticks anymore. The ISR fires automatically on every rising edge, regardless of what the main loop is doing.

Task 6 — Compare: Polling vs ISR Under Load

Run both approaches with the motor at the same speed, but with the main loop doing "work" (a simulated delay). Compare:

from machine import Pin, disable_irq, enable_irq
from picobot import Robot
import time

pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)
robot = Robot()

# --- ISR setup ---
isr_ticks = 0

def encoder_isr(pin):
    global isr_ticks
    if pin_b.value():
        isr_ticks -= 1
    else:
        isr_ticks += 1

# --- Test both methods ---
PWM = 80
DURATION = 3

print(f"Motor at PWM {PWM} for {DURATION}s — comparing poll vs ISR")
print()

try:
    # Test 1: Polling with 5ms "work" delay
    poll_ticks = 0
    last_a = pin_a.value()
    robot.set_motors(PWM, 0)
    time.sleep(0.3)
    end = time.ticks_add(time.ticks_ms(), DURATION * 1000)

    while time.ticks_diff(end, time.ticks_ms()) > 0:
        a = pin_a.value()
        if a == 1 and last_a == 0:
            if pin_b.value():
                poll_ticks -= 1
            else:
                poll_ticks += 1
        last_a = a
        time.sleep_ms(5)  # Simulated work

    robot.stop()
    time.sleep(0.5)

    # Test 2: ISR with same "work" delay
    isr_ticks = 0
    pin_a.irq(trigger=Pin.IRQ_RISING, handler=encoder_isr)

    robot.set_motors(PWM, 0)
    time.sleep(0.3)
    end = time.ticks_add(time.ticks_ms(), DURATION * 1000)

    while time.ticks_diff(end, time.ticks_ms()) > 0:
        time.sleep_ms(5)  # Same "work" — but ticks are captured by ISR

    robot.stop()
    pin_a.irq(handler=None)

    print(f"Polling (5ms delay): {poll_ticks:5d} ticks")
    print(f"ISR     (5ms delay): {isr_ticks:5d} ticks")
    print(f"Polling captured {poll_ticks * 100 // isr_ticks}% of actual ticks")

finally:
    robot.stop()
    pin_a.irq(handler=None)
Checkpoint — ISR Wins

The ISR count should be significantly higher (closer to the true pulse count). Polling with even 5 ms delay misses a substantial fraction of pulses. This is why all real encoder code uses interrupts.

ISR Safety Rules

The ISR must be minimal and allocation-free:

ISR Discipline

Code inside an interrupt handler runs with normal execution suspended. It must be fast and safe:

  • No print() — string formatting allocates memory
  • No list/dict creation — heap allocation can crash in ISR context
  • No floating-point math — slow, can corrupt FPU state
  • Just read pins and update an integer counter

Heavy work (speed calculations, display updates, logging) happens in your main loop.

This is the same discipline you'll see in any real-time system — whether it's a robot, a car engine controller, or a medical device. ISRs are fast and minimal; processing happens elsewhere.


Part 4: The Measure → Calibrate → Control Pipeline (~25 min)

This is the most important section of this module — not because of encoders specifically, but because of the methodology. Every sensor you'll ever use follows the same pipeline:

┌─────────┐     ┌───────────┐     ┌─────────┐
│ MEASURE  │────►│ CALIBRATE │────►│ CONTROL │
│ raw data │     │ real units│     │ feedback│
└─────────┘     └───────────┘     └─────────┘
  1. Measure — Get raw sensor output (ticks, ADC counts, voltage)
  2. Calibrate — Convert raw values to meaningful units (mm, degrees, °C)
  3. Control — Use calibrated measurements as feedback to drive actuators

You've already done Step 1 (reading ticks). Now let's calibrate.

Why Calibrate?

Raw tick counts are just numbers. To use them for anything useful, you need to know:

  1. How many ticks = one full wheel revolution (PPR — Pulses Per Revolution)
  2. How far the wheel travels per revolution (wheel circumference)

These two numbers convert ticks into millimeters — a unit you can reason about.

Task 7 — Measure Ticks per Revolution (PPR)

Mark the wheel with a small piece of tape. Use the ISR-based code (it's accurate), and drive the motor at very low speed:

from machine import Pin
from picobot import Robot
import time

pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)
robot = Robot()

ticks = 0

def encoder_isr(pin):
    global ticks
    if pin_b.value():
        ticks -= 1
    else:
        ticks += 1

pin_a.irq(trigger=Pin.IRQ_RISING, handler=encoder_isr)

ticks = 0
input("Mark the wheel with tape. Align to a reference. Press Enter...")

print("Driving slowly — press Ctrl+C after EXACTLY one revolution.")
try:
    robot.set_motors(30, 0)  # Very slow
    while True:
        print(f"Ticks: {ticks}    ", end="\r")
        time.sleep(0.05)
except KeyboardInterrupt:
    pass
finally:
    robot.stop()
    pin_a.irq(handler=None)

ppr = ticks
print(f"\nTicks per revolution (PPR): {ppr}")
Tip

Use the lowest PWM that still moves the wheel. Watch the tape mark carefully and hit Ctrl+C the moment it completes one full turn.

Run this 3 times and record:

Trial Ticks Notes
1
2
3

Your PPR: ______ (should be consistent across trials)

Consistency Check

PPR is a fixed property of your motor + encoder combination — it shouldn't change between trials. Expect a value around ~350 ticks per revolution for the PicoBot motors. If your values vary by more than 2-3 ticks, you're probably not aligning the mark precisely enough. This is your first calibration lesson: measurement precision limits control precision.

Task 8 — Measure Wheel Circumference

Measure the wheel circumference physically:

  1. Mark the floor at the contact point
  2. Roll the wheel exactly one revolution along the floor
  3. Measure the distance traveled: ______ mm

Or calculate it: \(C = \pi \times d\) where \(d\) is the wheel diameter in mm.

Your wheel circumference: ______ mm

Which Method is Better?

Measuring by rolling is more accurate because it accounts for tire compression under the robot's weight. A caliper measurement of the wheel diameter gives the unloaded circumference — slightly larger than reality.

This is another calibration lesson: measure the quantity you care about, in the conditions you'll use it.

Task 9 — Verify Your Calibration

Now connect the numbers. Drive the robot a known distance and check if the encoder agrees:

from machine import Pin
from picobot import Robot
import time

# === YOUR CALIBRATION VALUES ===
PPR = ___                    # From Task 7
WHEEL_CIRCUMFERENCE_MM = ___ # From Task 8
MM_PER_TICK = WHEEL_CIRCUMFERENCE_MM / PPR
# ===============================

pin_a = Pin(20, Pin.IN, Pin.PULL_UP)
pin_b = Pin(21, Pin.IN, Pin.PULL_UP)
robot = Robot()

ticks = 0

def encoder_isr(pin):
    global ticks
    if pin_b.value():
        ticks -= 1
    else:
        ticks += 1

pin_a.irq(trigger=Pin.IRQ_RISING, handler=encoder_isr)

# Drive forward for 2 seconds
ticks = 0
print("Place robot at a marked starting line.")
input("Press Enter to drive...")

robot.set_motors(60, 60)
time.sleep(2)
robot.stop()
pin_a.irq(handler=None)

distance_mm = ticks * MM_PER_TICK
print(f"Ticks: {ticks}")
print(f"Calculated distance: {distance_mm:.1f} mm")
print(f"Measure the actual distance with a ruler: ______ mm")

Record:

Run Encoder says (mm) Ruler says (mm) Error (%)
1
2
3
Acceptable Error

Within 5% is good for a first calibration. If you're off by more, double-check your PPR count and circumference measurement. If the error is consistent (always 8% too high, for example), adjust your circumference value — the wheel may compress more than you measured.

Store Your Calibration

Save your values so every future program can use them:

# Add to your config.py or create encoder_config.py
class ENCODER:
    PPR = ___                      # Your measured PPR
    WHEEL_CIRCUMFERENCE_MM = ___   # Your measured circumference
    MM_PER_TICK = WHEEL_CIRCUMFERENCE_MM / PPR
Checkpoint — Calibration Complete

You have three numbers: PPR, wheel circumference, and mm/tick. These are properties of YOUR specific robot — a different robot with different wheels will have different values. This is the essence of calibration: turning a general sensor into an accurate instrument for your specific system.


Part 5: Using the picobot Library (~10 min)

Now that you understand what happens under the hood, use the picobot library's encoder support. It does everything you just built — ISR-based counting, atomic tick reads, speed computation — in a tested, reusable package.

Task 10 — Library Encoder

from picobot import Robot
import time

robot = Robot(encoders=True)
enc = robot.encoders.left

enc.reset()
print("Using picobot Encoder class.")
print("Rotate the wheel or drive the motor.")
print()

try:
    robot.set_motors(60, 0)
    for _ in range(50):
        enc.update()
        print(f"Ticks: {enc.ticks:6d}  "
              f"Speed: {enc.speed_tps:7.1f} ticks/sec", end="\r")
        time.sleep(0.1)
finally:
    robot.stop()
    print()

Compare this to your hand-built ISR code. The library:

  • Uses the same ISR approach (channel A rising edge → read channel B)
  • Provides ticks property with atomic read (disables IRQ briefly to prevent torn reads)
  • Computes speed_tps in update() — from tick deltas, not inside the ISR
  • Pairs left and right encoders via the Encoders class
Why Build It First, Then Use the Library?

You could have started with Robot(encoders=True) on line 1. But then the encoder would be a black box. By building polling and ISR from scratch, you understand:

  • Why interrupts exist (polling misses ticks)
  • Why the ISR is minimal (safety and speed)
  • What the ticks and speed_tps values actually mean
  • Why calibration numbers differ between robots

This is the difference between using a library and understanding it.


What You Discovered

Concept What You Learned
Quadrature encoding Two channels, 90° apart, detect rotation AND direction
Polling vs ISR Polling misses ticks under load; ISR never misses
ISR discipline Minimal handler — count only, compute in main loop
Measure → Calibrate → Control Universal pipeline: raw data → real units → feedback
Calibration PPR + circumference → mm/tick, specific to YOUR robot
Verification Always check calibration against a physical measurement

The Measure → Calibrate → Control Pipeline

This methodology applies to every sensor you'll use:

Sensor Measure (raw) Calibrate (units) Control (feedback)
Encoder Ticks mm, mm/s Speed, distance
Line sensor ADC counts Position (weighted) Steering
Ultrasonic Pulse µs Distance mm Obstacle avoidance
IMU gyro Raw °/s Heading ° Turn control
Temperature ADC voltage °C Thermal protection

The skills you practiced here — repeated measurement, consistency checking, physical verification — are the same skills used in industrial sensor calibration. The tool changes; the method doesn't.


What's Next?

Now that you can measure wheel rotation accurately, the next modules in this track use encoder feedback for increasingly capable control:

  1. Drive Straight — Match left/right wheel speeds to eliminate drift
  2. Drive to Distance — Encoder odometry for precise distance commands
  3. Speed Control Lab — Log, plot, and tune a speed controller with CSV data
  4. Precise Turns with Encoders — Differential kinematics for accurate turns
  5. Closed-Loop Line Following — Cascaded control: speed + steering

← Back to Advanced Topics