PID in Python: Speed Control of a Real DC Motor

Written by

in

We designed a speed controller for a real motor, the ABB Baldor-Reliance CDP3306 (¼ HP, 180 V, 1750 rpm), using only Python and its datasheet. The result settles a 1000 rpm step in 75 ms with no overshoot, a 76° phase margin, and a peak demand of 117 V from a 180 V drive.

Everything here uses open-source tools: python-control, NumPy, Matplotlib and pytest. The full script is built up section by section, and every number in the post comes from running it. This is the long-form companion to our Instagram carousel on the same build.

From datasheet to model parameters

Every model parameter traces back to a line in ABB’s customer information packet for the CDP3306. These are typical values, not guaranteed ones.

Datasheet fieldDatasheet valueModel parameter
Armature voltage180 VDrive limit: 180 V
Base speed1750 rpm183 rad/s
Rated armature current1.25 A—
Armature resistance8.54 Ω at 25 °CR = 8.54 Ω
Armature inductance60.59 mHL = 60.6 mH
Voltage constant98.01 V/krpmK = 0.936 V·s/rad (= N·m/A)
Armature inertia4.68 lb·in²J = 1.37×10⁻³ kg·m²
No-load point0.10 A at 1830 rpmb = 4.88×10⁻⁴ N·m·s/rad

Two values need a short derivation:

  • K is both the back-EMF constant and the torque constant in SI units: 98.01 V/krpm ÷ 104.7 = 0.936.
  • b isn’t on the datasheet, but the no-load point gives it: b = K · I₀ / ω₀.

One datasheet catch: the online catalog listing gives the inertia as 4.68 lb·ft². The performance data sheet labels the same number LI² (lb·in²). The lb·ft² reading would mean 0.197 kg·m², which is physically impossible for an 11 kg motor, so we use lb·in². Always sanity-check your data before you design.

The motor model

Armature voltage in, shaft speed out: the DC motor is a second-order transfer function.

ω(s)V(s)=K(Js+b)(Ls+R)+K2\frac{\omega(s)}{V(s)} = \frac{K}{(Js + b)(Ls + R) + K^2}

In python-control, with the datasheet constants:

import control as ct
import matplotlib.pyplot as plt
import numpy as np

# 1. Define physical motor constants (ABB Baldor-Reliance CDP3306 datasheet)
J = 1.37e-3   # Rotor moment of inertia (kg.m^2), 4.68 lb.in^2
b = 4.88e-4   # Motor viscous friction constant (N.m.s), from the no-load point
K = 0.936     # Motor torque & back-emf constant (N.m/A or V/rad/s), 98.01 V/krpm
R = 8.54      # Armature resistance (Ohm), at 25 C
L = 60.6e-3   # Armature inductance (H)

# Open-Loop Motor Transfer Function: armature voltage (V) -> speed (rad/s)
num_motor = [K]
den_motor = [J * L, (J * R) + (b * L), (b * R) + (K**2)]
motor_sys = ct.TransferFunction(num_motor, den_motor)

Before designing anything, check the model against the data:

  • No-load speed: at 180 V the model predicts 1828 rpm. The datasheet measured 1830 rpm.
  • Under load: across all seven load points on the datasheet, from 0.10 A to 1.68 A, the model’s speed stays within 1.3%. It droops slightly less than the real motor, because R rises as the winding warms.
  • Poles: a lightly damped pair at −70.6 ± 74.9j rad/s. The electrical and mechanical time constants (7.1 ms and 13.4 ms) are close enough to interact.

Validate against data before you tune a single gain.

Requirements first

Write the requirements down before tuning anything, each one measurable so it can become a test later.

IDRequirementTarget
REQ-01Overshoot≤ 5%
REQ-02Settling time (2% band)≤ 100 ms
REQ-03Steady-state error0
REQ-04Phase margin≥ 60°
REQ-05Peak armature voltage for a 1000 rpm step≤ 180 V

REQ-05 is the one most designs forget: the drive can’t supply more than 180 V, whatever the controller asks for.

The controller: PI on the error, D on the speed

The P and I terms act on the error, as usual. The D term acts only on the measured speed, so a step in the setpoint never passes through the derivative.

In code, PI sits in the forward path and the filtered D sits in an inner feedback loop around the motor:

# 2. Define PID Gains
Kp = 1.0     # Proportional Gain
Ki = 60.0    # Integral Gain
Kd = 0.002   # Derivative Gain
Tf = 0.002   # Derivative filter time constant (s)

# 3. Forward path controller (PI action on the error)
# C_pi(s) = Kp + Ki/s = (Kp*s + Ki) / s
C_pi = ct.TransferFunction([Kp, Ki], [1, 0])

# 4. Feedback path controller (filtered D action on the measured speed only)
# H_d(s) = Kd*s / (Tf*s + 1)
H_d = ct.TransferFunction([Kd, 0], [Tf, 1])

# 5. Connect the loop
# Inner loop: D feedback around the motor        -> P / (1 + P*H_d)
# Outer loop: PI on the error, unity feedback   -> C_pi*P / (1 + P*H_d + C_pi*P)
inner_loop = ct.feedback(motor_sys, H_d)
closed_loop_sys = ct.feedback(C_pi * inner_loop, 1)

# Armature voltage the controller asks for: u = C_pi*(r - y) - H_d*y
voltage_sys = ct.minreal(C_pi / (1 + motor_sys * H_d + C_pi * motor_sys), verbose=False)

The filter time constant Tf = 2 ms limits how much the derivative amplifies sensor noise. A pure derivative would multiply every measurement change by Kd divided by the sample time; at 1 ms that is a gain of 2 V per rad/s here, and far more with a larger Kd.

Closed-loop results

Simulate a 0 to 1000 rpm step, then read the metrics straight off step_info. Always plot the armature voltage too, not just the speed.

# 6. Simulate the response to a 1000 rpm step command
RPM = 60 / (2 * np.pi)
r = 1000 / RPM                                   # 104.7 rad/s
time = np.linspace(0, 0.2, 2001)
time, speed = ct.step_response(closed_loop_sys * r, T=time)
_, volts = ct.step_response(voltage_sys * r, T=time)

info = ct.step_info(closed_loop_sys, SettlingTimeThreshold=0.02)
gm, pm, wcg, wcp = ct.margin((C_pi + H_d) * motor_sys)

The speed rises in 19 ms, settles within 2% in 75 ms and never overshoots, so REQ-01, REQ-02 and REQ-03 pass. The voltage panel shows why the derivative sits on the speed. With the same gains applied to the error, the setpoint step asks for 209 V at t = 0, more than the drive can deliver. With D on the measured speed, the peak is 117 V and REQ-05 passes with margin.

Robustness and sample time

A good step response isn’t enough on its own, so check the loop’s margins too. The phase margin is 76° at a crossover of 89 rad/s, which passes REQ-04. The gain margin is infinite, because the phase never crosses −180°. Without the filtered D, the same PI gains give about 65°.

The controller will run on a microcontroller, so test it at the rate it will actually run. Discretize the controller with Tustin and the plant with a zero-order hold, which is how the processor sees it:

Ts = 0.001
motor_d = ct.c2d(motor_sys, Ts, 'zoh')     # plant as the MCU sees it
C_pi_d  = ct.c2d(C_pi, Ts, 'tustin')
H_d_d   = ct.c2d(H_d, Ts, 'tustin')
closed_d = ct.feedback(C_pi_d * ct.feedback(motor_d, H_d_d), 1)

Sampling and the zero-order hold add roughly ωc·Ts/2 of phase lag. At 1 ms that is about 2.5° and the discrete loop matches the design. At 5 ms it is about 13°, enough to push overshoot past the 5% limit. The sample time is a design parameter, not an afterthought.

Requirements as tests

Each requirement becomes a pytest that imports the results from the script (saved as motor_pid.py):

# test_requirements.py
from motor_pid import info, pm, volts

def test_REQ_01_overshoot():
    assert info["Overshoot"] <= 5.0

def test_REQ_02_settling_time():
    assert info["SettlingTime"] <= 0.100

def test_REQ_03_steady_state_error():
    assert abs(info["SteadyStateValue"] - 1.0) < 1e-3

def test_REQ_04_phase_margin():
    assert pm >= 60.0

def test_REQ_05_voltage_limit():
    assert volts.max() <= 180.0
$ pytest -q
.....                                                    [100%]
5 passed

Run this on every commit. If you change a gain or the sample time, the failing test tells you which requirement broke. That turns model-in-the-loop simulation into a regression suite for free.

Summary

StepResult
Datasheet to modelK, R, L, J and b, each traced to a datasheet line
Model validation1828 rpm predicted vs 1830 rpm measured
ControllerPI on the error (Kp 1.0, Ki 60), filtered D on the speed (Kd 0.002, Tf 2 ms)
Step response0% overshoot, 75 ms settling, 19 ms rise
Robustness76° phase margin, infinite gain margin
Actuator demand117 V peak, under the 180 V limit
Sample time1 ms, Tustin
Verification5 requirement tests, all passing

These results use typical datasheet values. Next in the series, we read a Bode plot properly, then loop-shape this same motor. Later, we’ll identify the real motor from measured data and see how far the datasheet was off.

Have you ever caught a datasheet error that would have broken your design? Tell us in the comments, and follow Quadyne for the rest of the series.