Chapter 2: Motor Control — PWM, H-Bridges, and Calibration

A wheeled robot’s relationship with the physical world begins at the motor. Every high-level navigation algorithm ultimately resolves to voltages applied to motor windings. Getting this layer right — understanding why motors behave the way they do and how to command them reliably — makes every layer above it easier.

2.1 DC Motor Physics

A brushed DC motor converts electrical power to mechanical rotation. Three quantities govern its behaviour:

Torque $\tau$ is proportional to armature current $I$: $\tau = K_t \cdot I$

where $K_t$ is the torque constant (units: N·m/A).

Back-EMF is a voltage generated by the spinning motor that opposes the supply voltage. It is proportional to rotational speed $\omega$: $V_{back} = K_e \cdot \omega$

where $K_e$ is the back-EMF constant. For a well-designed motor, $K_t \approx K_e$ in SI units.

Speed-torque curve. At a fixed supply voltage $V$, the armature current is: $I = (V - K_e \omega) / R_a$

where $R_a$ is armature resistance. Substituting into the torque equation gives the linear speed-torque relationship: maximum speed (no-load) at zero torque, and maximum torque (stall) at zero speed. A practical operating point sits between these extremes.

The implication for control: the motor speed we command via duty cycle is not the motor speed we get. Load torque, battery voltage droop, and winding resistance all shift the actual speed. This is why Chapter 4 introduces PID feedback.

2.2 H-Bridge Operation

An H-bridge is a circuit of four switches arranged in an H shape that allows current to flow through the load (motor) in either direction. The L298N and TB6612FNG chips both implement H-bridges.

IN1 IN2 Motor state
H L Forward
L H Reverse
L L Coast (free-spin)
H H Brake (short winding)

The enable (EN) pin controls the overall gate. Driving EN with a PWM signal at a fixed frequency while holding IN1/IN2 for direction gives proportional speed control.

PWM frequency choice. At very low frequencies (< 100 Hz), you can hear the motor switching (audible whine). At very high frequencies (> 20 kHz), switching losses in the driver increase and the driver may overheat. A good compromise for small gear-motors is 1–5 kHz using hardware PWM, or 50–200 Hz using software PWM (which is acceptable for low-speed manoeuvring but not for smooth audio-quiet operation).

2.3 Generating PWM on a Raspberry Pi

The Pi has two hardware PWM channels on GPIO12 and GPIO13, available via the pigpio or gpiozero libraries. Hardware PWM is generated by dedicated hardware with microsecond precision. Software PWM uses a thread to toggle a pin, which introduces jitter proportional to OS scheduling latency — acceptable at low duty cycles and low frequencies.

The Motor class wraps all of this:

See code/02_motor_driver.py

class Motor:
    def set_speed(self, speed: float) -> None:
        """Set motor speed. speed in [-1.0, 1.0]."""
        speed = max(-1.0, min(1.0, speed))
        if abs(speed) < self.deadband:
            self.brake()
            return
        if speed >= 0:
            GPIO.output(self.in1, GPIO.HIGH)
            GPIO.output(self.in2, GPIO.LOW)
        else:
            GPIO.output(self.in1, GPIO.LOW)
            GPIO.output(self.in2, GPIO.HIGH)
        self.pwm.ChangeDutyCycle(abs(speed) * 100.0)

The DifferentialDrive class maps a desired linear velocity $v$ (m/s) and angular velocity $\omega$ (rad/s) to left and right motor speeds:

See code/02_differential_drive.py

2.4 The Dead-Band

Every motor has a dead-band: a range of duty cycles near zero where the applied torque is insufficient to overcome static friction. Below the dead-band threshold, the motor does not move at all. Above it, motion begins abruptly. For a typical N20 gear-motor on a wooden floor, the dead-band duty cycle is 15–30%.

This non-linearity causes problems for low-speed precision manoeuvring and for PID controllers whose output sits near zero. Practical solutions:

  1. Hard dead-band compensation: add an offset to any non-zero command, mapping the software 0–100% range to the hardware [deadband, 100%] range.
  2. PID integrator: if the robot uses closed-loop speed control, the integrator term will naturally compensate for the dead-band by building up until the motor moves.

2.5 Non-Linear Speed Response

Even above the dead-band, the speed-vs-duty-cycle relationship is not linear. A 50% duty cycle does not produce half the no-load speed. The non-linearity arises from the motor’s speed-torque curve and from load variation.

A simple piecewise correction maps commanded normalised speed $u \in [0,1]$ to duty cycle $d$: $d = d_{min} + (1 - d_{min}) \cdot u^\gamma$

where $d_{min}$ is the dead-band duty cycle and $\gamma \approx 0.7$–$1.3$ is fitted to measured data. The calibration script (described next) measures this curve and saves the parameters.

2.6 Calibration

Calibration has two goals:

  1. Find the dead-band for left and right motors separately (they differ).
  2. Match left-right speeds so the robot drives straight at equal commands.

The interactive calibration script code/02_calibrate_motors.py steps through duty cycles from 0% to 100% in 5% increments. At each step it holds the speed for one second and prompts you to enter the measured wheel speed (from a tachometer, timing marks, or an encoder if fitted). It then fits a correction curve and saves the parameters to a JSON file that DifferentialDrive reads at startup.

See code/02_calibrate_motors.py

A simple correction factor approach — if the left wheel is 8% faster than the right at matched commands — applies a scale factor of $0.92$ to the left motor permanently:

# In RobotConfig
left_speed_scale:  float = 0.92
right_speed_scale: float = 1.00

2.7 Safety Patterns

Two safety patterns belong in every motor driver:

Always-stop on exit. Register a cleanup function with Python’s atexit module so that if the program crashes or the user hits Ctrl-C, the motors stop immediately:

import atexit

def _emergency_stop():
    motor_left.brake()
    motor_right.brake()
    GPIO.cleanup()

atexit.register(_emergency_stop)

Current-limit via fault pin. The TB6612FNG has a STBY (standby) pin and can be configured with current-sense resistors. The L298N has a current-sense output. If you detect a stall condition (duty cycle > 80%, speed feedback ≈ 0 for more than 500 ms), cut power and raise an exception. This prevents winding burnout.

2.8 Putting It Together

With the Motor, DifferentialDrive, and calibration in place, higher-level code can write:

drive = DifferentialDrive(config)
drive.move(linear_mps=0.2, angular_radps=0.0)  # straight ahead at 0.2 m/s
time.sleep(2.0)
drive.stop()

The move method converts the desired body velocity into left/right speeds using the kinematic equations derived in Chapter 3, applies the calibration correction factors, and writes the resulting duty cycles to the motor PWM channels. Every subsequent chapter will call drive.move() as the primary actuation interface; the hardware details are now safely encapsulated.


Navigation: