Differential Drive (Python)

Converts CmdVel velocity commands into left/right wheel speeds with RPM clamping, dead-reckoning odometry, and safe shutdown.

Problem

You need to drive a 2-wheel robot from CmdVel commands, with safety limits and position tracking.

When To Use

  • Any 2-wheel differential drive robot (TurtleBot, AGV, hobby bots)
  • Converting CmdVel from a planner or teleoperation to motor outputs
  • When you need velocity clamping and safe shutdown

Prerequisites

Kinematics

v_left  = v - ω * L / 2
v_right = v + ω * L / 2

Where v is linear velocity (m/s), ω is angular velocity (rad/s), and L is the wheel base (m).

horus.toml

[package]
name = "diff-drive-py"
version = "0.1.0"
language = "python"

Complete Code

import horus
from horus import Node, CmdVel, run, us
import math

# ── Robot parameters ──────────────────────────────────
WHEEL_BASE = 0.3       # meters between wheels
WHEEL_RADIUS = 0.05    # meters
MAX_RPM = 200.0         # motor safety limit

# ── State ─────────────────────────────────────────────
x, y, theta = [0.0], [0.0], [0.0]

# ── Hardware stub ─────────────────────────────────────
def write_motors(left_rpm, right_rpm):
    """Send to motor controller — replace with serial/CAN driver."""
    pass

# ── Node callbacks ────────────────────────────────────
def drive_tick(node):
    cmd = node.recv("cmd_vel")
    if cmd is None:
        return

    v = cmd.linear      # m/s forward
    w = cmd.angular      # rad/s counter-clockwise

    # IMPORTANT: differential drive kinematics
    v_left = v - w * WHEEL_BASE / 2.0
    v_right = v + w * WHEEL_BASE / 2.0

    # Convert m/s to RPM
    left_rpm = v_left / WHEEL_RADIUS * 60.0 / (2.0 * math.pi)
    right_rpm = v_right / WHEEL_RADIUS * 60.0 / (2.0 * math.pi)

    # SAFETY: clamp to motor limits
    left_rpm = max(-MAX_RPM, min(MAX_RPM, left_rpm))
    right_rpm = max(-MAX_RPM, min(MAX_RPM, right_rpm))

    write_motors(left_rpm, right_rpm)

    # Dead-reckoning odometry
    dt = 1.0 / 50.0  # 50 Hz
    theta[0] += w * dt
    x[0] += v * dt * math.cos(theta[0])
    y[0] += v * dt * math.sin(theta[0])

    node.send("odom", {
        "x": x[0], "y": y[0], "theta": theta[0],
        "v_linear": v, "v_angular": w,
        "left_rpm": left_rpm, "right_rpm": right_rpm,
    })

def drive_shutdown(node):
    # SAFETY: stop both motors before exit
    write_motors(0.0, 0.0)
    node.log_info(f"Shutdown at ({x[0]:.2f}, {y[0]:.2f})")

# ── Main ──────────────────────────────────────────────
drive = Node(
    name="DiffDrive",
    tick=drive_tick,
    shutdown=drive_shutdown,
    rate=50,
    order=10,
    budget=500 * us,
    on_miss="safe_mode",
    subs=[CmdVel],
    pubs=["odom"],
)

run(drive, tick_rate=100, rt=True)

Expected Output

[HORUS] Scheduler running — tick_rate: 100 Hz
[HORUS] Node "DiffDrive" started (Rt, 50 Hz, budget: 500μs)
^C
[HORUS] Shutting down...
[HORUS] DiffDrive: Shutdown at (1.23, 0.45)
[HORUS] Node "DiffDrive" shutdown complete

Key Points

  • RPM clamping prevents motor damage from bad upstream commands
  • shutdown() zeros both motors — critical for robots under gravity or in motion
  • on_miss="safe_mode" stops motors if the tick budget is exceeded
  • Dead-reckoning drifts — in production, fuse with IMU or wheel encoders
  • write_motors() is a stub — replace with pyserial, python-can, or GPIO. See Real Hardware for complete examples.

Common Errors

SymptomCauseFix
Robot spins in circlesWHEEL_BASE wrong (too small)Measure wheel center-to-center distance
Motors saturate at low speedWHEEL_RADIUS too small → RPM too highMeasure actual wheel radius
Odometry drifts badlyPure dead-reckoning, no correctionFuse with IMU (Multi-Sensor Fusion)
Robot does not stop on Ctrl+CMissing shutdown callbackAlways implement shutdown for actuator nodes

See Also