Simulate a DC Motor PID Controller
Build a Python simulation of a PID-controlled DC motor with gain tuning.
Introduction
30 Second Summary
Every robot arm, drone, and self-driving car needs to move with precision. The secret behind that precision is a control algorithm that constantly measures, corrects, and adjusts in real time.
In this project, you will simulate a DC motor controlled by a PID controller in Python. You will watch the motor respond to a target speed, tune its behavior, and compare different control strategies on a graph.
What You'll Build
A complete DC motor speed controller simulation in Python that models real motor physics, runs a closed-loop feedback loop, and visualizes how different PID tunings affect performance.
By the end of this project, you'll have:
- A Python script that models DC motor physics, converting voltage input into RPM output.
- A PID control loop using the simple-pid library wired to the motor model.
- Matplotlib comparison plots showing how P-only, PI, and full PID tunings perform side by side.
- Secret Mission: add a sudden load disturbance mid-simulation and watch how the PID recovers.
Are there any prerequisites?
None. This guide walks you through installing Python, VS Code, and every library you need from scratch.
Install Python, VS Code, and Project Libraries
Before you can simulate a DC motor, you need a few tools on your machine. Python runs the simulation code.
VS Code gives you a code editor with a built-in terminal. You'll also install three libraries that handle PID control, math operations, and plotting.
In this step, get ready to:
- Install Python and verify it works.
- Set up VS Code with the Python extension.
- Create a project folder and install the required libraries.
Install Python
Python is the programming language your simulation runs in. Let's check if it's already on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Check your Python version by running this command:
python --version
✔️ I see a version number
You're all set. Any version of Python 3.8 or higher works for this project. Skip ahead to setting up VS Code.
ⓧ It's not installed yet
No worries. Let's install Python now.
macOS
- Open https://www.python.org/downloads/ in your browser.
- Click the Download Python button for the latest version.
- Open the downloaded .pkg file and follow the installer prompts.
- After installation, close and reopen your terminal.
- Verify the install by running this command:
python3 --version
You should see a version number like Python 3.13.x. On macOS, use python3 and pip3 wherever this project says python and pip.
Windows
- Press the Windows key and type Microsoft Store.
- Press Enter to open it.
- Search for Python 3.13 in the Store search bar.
- Click Get to install it.
- After installation, close and reopen Command Prompt.
- Verify the install by running this command:
python --version
You should see a version number like Python 3.13.x.
Still seeing 'command not found'?
On macOS, the command is often python3 instead of python. Try running python3 --version instead.
On Windows, if the Microsoft Store opens instead of showing a version number, close it and install Python from the Store as described above.
Help me troubleshoot my Python installation
Set up VS Code with the Python extension
VS Code is the code editor you'll write your simulation in. It also has a built-in terminal so you can run your scripts without switching windows.
✔️ I already have VS Code
Great. Skip ahead to installing the Python extension below.
ⓧ I need to install it
- Open https://code.visualstudio.com/download in your browser.
- Click the download button for your operating system.
- Open the downloaded installer and follow the prompts. Accept all the default settings.
Which installer on Windows?
Choose the User Installer for Windows x64. It doesn't require admin permissions and updates automatically.
Now let's add the Python extension so VS Code understands your Python code.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click the Extensions icon in the left sidebar. You can also press Ctrl+Shift+X (Windows) or Cmd+Shift+X (macOS).
- Type Python in the search bar at the top of the Extensions panel.
- Find the one published by Microsoft and click Install.
What does the Python extension do?
The Python extension adds syntax highlighting, error detection, and code completion for Python files. It makes VS Code aware of your Python installation so you can run scripts directly from the editor.
Don't see the Extensions sidebar?
Make sure you're clicking the icon that looks like four small squares in the left sidebar. If the sidebar is hidden entirely, click View in the top menu bar, then select Extensions.
Help me find the Extensions panel in VS Code
Create your project folder and install libraries
Your simulation needs three libraries. simple-pid handles the PID controller logic. NumPy handles array math. Matplotlib draws the plots.
- In VS Code, open the integrated terminal by pressing Ctrl+` (the backtick key, usually above Tab).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir pid-motor-sim
cd pid-motor-sim
- Confirm you're in the right folder by running:
pwd
You should see a path ending in pid-motor-sim.
Seeing 'no such file or directory'?
Make sure you ran the cd ~/Desktop command first. If your Desktop is in a different location, replace ~/Desktop with the correct path.
Help me navigate to my project folder
- Install the three project libraries by running this command:
pip install simple-pid==2.0.1 numpy matplotlib
What are these libraries?
- simple-pid (version 2.0.1) is a lightweight PID controller library. It handles the math of proportional, integral, and derivative control so you don't have to implement it from scratch.
- numpy provides fast array operations. You'll use it to store and process simulation data.
- matplotlib is Python's standard plotting library. You'll use it to graph the motor's speed response over time.
The install may take a minute. You'll see progress bars as each library downloads.
- Verify everything installed correctly by running this command:
python -c "from simple_pid import PID; print('simple-pid OK')"
You should see simple-pid OK printed in your terminal. This confirms that Python can find and import the simple-pid library.
Seeing 'ModuleNotFoundError'?
This means pip installed the library for a different Python version than the one you're running. Try running the install with python -m pip install simple-pid==2.0.1 numpy matplotlib instead. This ensures pip targets the exact Python you're calling.
On macOS, replace python with python3 in both the install and verify commands.
Help me fix my pip installation
Your tools are ready and your project folder is set up. Next up, you'll write the DC motor physics model that turns voltage into RPM.
Model the DC Motor Physics
Your development environment is set up and ready to go. Now it's time to build the heart of your simulation: a virtual DC motor.
A real DC motor converts electrical voltage into rotational speed. Your Python model will do the same thing mathematically, using three physical properties: how heavy the rotor is, how much friction slows it down, and how strongly voltage drives it.
In this step, get ready to:
- Create the motor_sim.py file in VS Code.
- Build a DCMotor class that models voltage-to-speed physics.
- Test the motor by running a quick simulation.
Create the DCMotor class
The DCMotor class takes a voltage input (0 to 12V) and outputs a speed in RPM. Each time step, it calculates the balance between driving torque and friction drag, then updates the speed accordingly.
- In the VS Code Explorer sidebar, hover over pid-motor-sim and click the New File icon.
- Name the file motor_sim.py and press Enter.
- Copy the following code into motor_sim.py:
class DCMotor:
"""Simplified DC motor model."""
def __init__(self, Km=50.0, b=0.5, J=0.02):
self.Km = Km # Motor constant (RPM per volt)
self.b = b # Friction coefficient
self.J = J # Moment of inertia
self.speed = 0.0 # Current speed in RPM
def update(self, voltage, dt):
"""Advance the motor simulation by one time step."""
acceleration = (voltage * self.Km - self.b * self.speed) / self.J
self.speed += acceleration * dt
return self.speed
What does this code do?
- Km is the motor constant. It sets how many RPM the motor produces per volt at full speed.
- b is the friction coefficient. Higher friction means the motor slows down faster on its own.
- J is the moment of inertia. A heavier rotor takes longer to speed up or slow down.
- The update method calculates acceleration from the net torque (voltage driving force minus friction drag), then nudges the speed forward by one small time step.
Extra for Experts: Why not model the electrical circuit too?
A full DC motor model includes armature resistance and inductance from the electrical circuit. This first-order approximation skips those details and captures the essential behavior: inertia resists speed changes, friction decelerates the motor, and voltage drives it forward.
This is the standard model used in introductory control courses. It's accurate enough to demonstrate PID tuning without requiring an electrical engineering background.
- Save motor_sim.py.
You won't see any output yet. The next section adds a quick test to confirm the motor works.
Test the motor model
Before building the full PID loop, you need to confirm the motor model produces realistic speeds. A simple test applies a constant voltage and checks whether the motor reaches a steady speed.
- At the bottom of motor_sim.py, below the DCMotor class, add this test code:
# Quick test
motor = DCMotor()
for _ in range(100):
motor.update(6.0, 0.01)
print(motor.speed)
What does this test do?
- It creates a DCMotor with the default settings you defined in __init__.
- It applies 6V for 100 time steps at dt = 0.01, which simulates 1 second of motor operation.
- print(motor.speed) outputs the final speed so you can verify the motor reached a realistic value.
- Save motor_sim.py.
- Run the script in your terminal by entering this command:
python motor_sim.py
You should see a number very close to 600.0 in the terminal. This is the motor's steady-state speed at 6V.
Why 600?
The steady-state formula is voltage * Km / b. With 6V, a motor constant of 50, and friction of 0.5, that works out to 6 * 50 / 0.5 = 600 RPM. At this speed, the friction drag exactly balances the voltage driving force, so acceleration drops to zero.
Seeing a different number or an error?
- Make sure the test code is outside the class body. It should be at the very bottom of the file with no indentation.
- Check that you saved the file before running the command. Unsaved changes won't be picked up by Python.
- Verify the update method uses self.speed (not just speed) in the acceleration formula.
- Why isn't my motor_sim.py printing the expected output?
✔️ Awesome, I've got everything!
Great. Double check you've saved motor_sim.py before moving on.
ⓧ I'd like to double check the full code
Here is the complete motor_sim.py file so far. Compare it with yours line by line.
class DCMotor:
"""Simplified DC motor model."""
def __init__(self, Km=50.0, b=0.5, J=0.02):
self.Km = Km # Motor constant (RPM per volt)
self.b = b # Friction coefficient
self.J = J # Moment of inertia
self.speed = 0.0 # Current speed in RPM
def update(self, voltage, dt):
"""Advance the motor simulation by one time step."""
acceleration = (voltage * self.Km - self.b * self.speed) / self.J
self.speed += acceleration * dt
return self.speed
# Quick test
motor = DCMotor()
for _ in range(100):
motor.update(6.0, 0.01)
print(motor.speed)
Your motor model is alive and spinning at the right speed. Next up, you'll wire a PID controller to it and let closed-loop feedback take the wheel.
Wire Up the PID Controller
Your DCMotor class can model how a real motor responds to voltage. Right now though, it runs at whatever voltage you hard-code, with no way to automatically hit a target speed.
In this step, you will connect a PID controller from the simple-pid library to your motor model. The controller reads the motor's current speed, compares it to a target of 500 RPM, and adjusts the voltage every time step to close the gap.
In this step, get ready to:
- Connect the simple-pid library to your motor model.
- Run a 5-second closed-loop simulation targeting 500 RPM.
- Verify the controller reaches the target speed.
Add imports and simulation parameters
Before building the control loop, you need two things: the libraries that power the controller and the target values it will aim for.
- In motor_sim.py, find the quick test block at the bottom of the file:
# Quick test
motor = DCMotor()
for _ in range(100):
motor.update(6.0, 0.01)
print(motor.speed)
- Select everything from # Quick test down to print(motor.speed) and delete it.
Why delete the test block?
The quick test from the previous step was a temporary check to confirm the motor model works. You are about to replace it with a proper simulation loop that does much more.
- At the very top of motor_sim.py (above the class DCMotor: line), add these imports:
import numpy as np
from simple_pid import PID
- Below the DCMotor class (after the update method), add these simulation parameters:
# Simulation parameters
setpoint = 500
dt = 0.01
sim_time = 5.0
What do these parameters mean?
- setpoint = 500 is your target speed in RPM. This is the value the PID controller will try to reach and maintain.
- dt = 0.01 is the time step in seconds. Each loop iteration simulates 0.01 seconds of real time.
- sim_time = 5.0 means the entire simulation runs for 5 seconds total.
You won't see a change yet. The next substep adds the simulation loop that uses these values.
Create the PID controller and simulation loop
The PID controller is the brain of your system. It takes the motor's current speed, compares it to the setpoint, and outputs a voltage to correct the error.
- Below the simulation parameters you just added, add this code:
# PID controller
pid = PID(0.02, 0.01, 0.001, setpoint=setpoint)
pid.output_limits = (0, 12)
# Motor instance
motor = DCMotor()
time_data = []
speed_data = []
steps = int(sim_time / dt)
for i in range(steps):
t = i * dt
voltage = pid(motor.speed)
motor.update(voltage, dt)
time_data.append(t)
speed_data.append(motor.speed)
print(f'Final speed: {motor.speed:.2f} RPM')
What does this code do?
- PID(0.02, 0.01, 0.001, setpoint=setpoint) creates the controller with proportional, integral, and derivative gains.
- pid.output_limits = (0, 12) clamps the voltage between 0V and 12V. This prevents integral windup, where the integral term accumulates a huge error and causes massive overshoot.
- The loop runs 500 times (5 seconds divided by 0.01 per step). Each iteration reads the motor speed, asks the PID for a voltage, and updates the motor.
- time_data and speed_data store every value so you can plot them later.
Run the simulation
- Save motor_sim.py.
- Run the script by entering this command in your terminal:
python motor_sim.py
You should see output like Final speed: 500.00 RPM (or very close to 500). This confirms your PID controller is driving the motor to the target speed.
Final speed far from 500 RPM?
- Make sure you set pid.output_limits = (0, 12) right after creating the PID instance. Without this, the controller can output extreme voltages that destabilize the motor.
- Check that your update method returns self.speed. The PID reads motor.speed directly, so the attribute must be updated each step.
- Verify that the three PID gains are 0.02, 0.01, 0.001 in that exact order (Kp, Ki, Kd). Swapping them changes the behavior dramatically.
- Why is my PID simulation not reaching 500 RPM?
✔️ Awesome, I've got everything!
Double check you have saved motor_sim.py before moving on.
ⓧ I'd like to double check the full code
Here is what your complete motor_sim.py file should look like at this point.
import numpy as np
from simple_pid import PID
class DCMotor:
"""Simplified DC motor model."""
def __init__(self, Km=50.0, b=0.5, J=0.02):
self.Km = Km
self.b = b
self.J = J
self.speed = 0.0
def update(self, voltage, dt):
"""Advance the motor simulation by one time step."""
acceleration = (voltage * self.Km - self.b * self.speed) / self.J
self.speed += acceleration * dt
return self.speed
# Simulation parameters
setpoint = 500
dt = 0.01
sim_time = 5.0
# PID controller
pid = PID(0.02, 0.01, 0.001, setpoint=setpoint)
pid.output_limits = (0, 12)
# Motor instance
motor = DCMotor()
time_data = []
speed_data = []
steps = int(sim_time / dt)
for i in range(steps):
t = i * dt
voltage = pid(motor.speed)
motor.update(voltage, dt)
time_data.append(t)
speed_data.append(motor.speed)
print(f'Final speed: {motor.speed:.2f} RPM')
Your PID controller is driving the motor to exactly 500 RPM. Next up, you will visualize the motor's speed response over time so you can see the rise, overshoot, and settling behavior on a plot.
Visualize the Motor Response
In the last step, you wired a PID controller to your motor model and confirmed it reaches the 500 RPM setpoint.
A single final speed number tells you the motor got there, but not how it got there. Did it overshoot and oscillate? Did it creep up slowly? In this step, you will plot the motor's speed over time so you can actually see the PID response.
In this step, get ready to:
- Plot the motor's speed response using matplotlib.
- Observe the classic PID step response: rise time, overshoot, and settling time.
- Refactor the simulation into a reusable function for easy gain experiments.
Plot the motor speed response
Right now, the script prints a final speed number and exits. You will add a matplotlib chart that plots the motor's speed at every time step, along with a setpoint line so you can see how close the controller gets.
- In motor_sim.py, find the import section at the very top of your file.
- Add this new import line between import numpy as np and from simple_pid import PID:
import matplotlib.pyplot as plt
- Now scroll to the very bottom of the file. Find the print(f'Final speed: {motor.speed:.2f} RPM') line.
- Add the following plotting code below that print line:
# Create a figure with a larger size for readability
plt.figure(figsize=(10, 6))
# Plot the motor speed over time
plt.plot(time_data, speed_data, label='Motor Speed')
# Add a horizontal dashed line at the target speed
plt.axhline(y=setpoint, color='r', linestyle='--', label='Setpoint')
# Label the axes, add a title, legend, and grid
plt.xlabel('Time (s)')
plt.ylabel('Speed (RPM)')
plt.title('DC Motor PID Control')
plt.legend()
plt.grid(True)
plt.show()
What does this code do?
plt.figure(figsize=(10, 6)) creates a 10-by-6 inch chart window so the curve is easy to read.
plt.plot() draws the motor speed over time. plt.axhline() draws a red dashed horizontal line at 500 RPM so you can visually compare the actual speed to the target.
plt.show() opens the chart in an interactive window. The script pauses until you close the window.
- Save motor_sim.py.
- Run the script by entering this command in your terminal:
python motor_sim.py
A chart window pops up showing a curve that starts at 0 RPM, rises toward 500 RPM, may overshoot slightly, and then settles at the setpoint.
What am I looking at?
This is the classic PID step response. Three things to look for:
- Rise time is how quickly the speed climbs from 0 toward 500 RPM.
- Overshoot is how far past the target the speed goes before coming back down.
- Settling time is how long until the speed stays within a small band of the target.
Every real PID system (robot arms, thermostats, cruise control) produces a response shaped by these same three properties.
No chart window appearing?
- Make sure you saved motor_sim.py before running it.
- Check that import matplotlib.pyplot as plt is at the top of the file, not inside a function or class.
- If the terminal hangs without showing a window, matplotlib may need a different backend. Close the script with Ctrl+C and try again.
Why isn't my matplotlib chart showing up when I run motor_sim.py?
Refactor into a reusable simulation function
The simulation code works, but the PID gains are hardcoded inline. In the next step, you will compare multiple tunings side by side. To make that possible, you need a function you can call with different gain values.
- In motor_sim.py, select and delete everything below the DCMotor class. That means deleting from the # Simulation parameters comment all the way down to plt.show().
- In place of that deleted code, add the following run_simulation function right after the DCMotor class:
def run_simulation(Kp, Ki, Kd, setpoint=500, sim_time=5.0, dt=0.01):
"""Run a PID-controlled motor simulation and return time and speed data."""
pid = PID(Kp, Ki, Kd, setpoint=setpoint)
pid.output_limits = (0, 12)
pid.sample_time = None # Update every call in simulation
motor = DCMotor()
time_data = []
speed_data = []
steps = int(sim_time / dt)
for i in range(steps):
t = i * dt
voltage = pid(motor.speed)
motor.update(voltage, dt)
time_data.append(t)
speed_data.append(motor.speed)
return np.array(time_data), np.array(speed_data)
What does this function do?
run_simulation takes PID gains (Kp, Ki, Kd) as inputs and returns the time and speed arrays. Each call creates a fresh PID controller and motor, runs the full simulation, and hands back the results.
pid.sample_time = None tells the PID to compute a new output every time you call it, rather than waiting for a real-time interval to pass. This is important because you are running a simulation, not controlling real hardware.
The function returns np.array() versions of the lists. NumPy arrays make math operations (like calculating overshoot) much simpler in later steps.
- Below the run_simulation function, add the code that calls it and plots the result:
# --- Single simulation plot ---
setpoint = 500
time_data, speed_data = run_simulation(Kp=0.02, Ki=0.01, Kd=0.001, setpoint=setpoint)
plt.figure(figsize=(10, 6))
plt.plot(time_data, speed_data, label='Motor Speed')
plt.axhline(y=setpoint, color='r', linestyle='--', label='Setpoint')
plt.xlabel('Time (s)')
plt.ylabel('Speed (RPM)')
plt.title('DC Motor PID Control')
plt.legend()
plt.grid(True)
plt.show()
What changed here?
The simulation logic that was previously written inline is now inside run_simulation. The calling code passes the same gains (Kp=0.02, Ki=0.01, Kd=0.001) you used before, so the plot looks identical.
The payoff comes next step. You will call run_simulation three times with different gains and overlay the results on one chart.
- Save motor_sim.py and run it again:
python motor_sim.py
You should see the same chart as before. The curve, setpoint line, and labels are identical because you passed the same gain values.
Seeing a NameError or unexpected output?
- If you see NameError: name 'run_simulation' is not defined, make sure the function definition appears before the calling code at the bottom of the file.
- If the old print statement still runs, you may not have deleted all of the inline simulation code. Check that the lines from # Simulation parameters through the old plt.show() are gone.
- Make sure the run_simulation function is defined at the module level (no extra indentation), not inside the DCMotor class.
Help me debug my refactored motor_sim.py
✔️ Awesome, I've got everything!
Great. Double check you have saved motor_sim.py and the chart looks the same as before the refactor.
ⓧ I'd like to double check the full code
Here is the complete motor_sim.py at the end of this step. Compare your file line by line.
import numpy as np
import matplotlib.pyplot as plt
from simple_pid import PID
class DCMotor:
"""Simplified DC motor model."""
def __init__(self, Km=50.0, b=0.5, J=0.02):
self.Km = Km
self.b = b
self.J = J
self.speed = 0.0
def update(self, voltage, dt):
"""Advance the motor simulation by one time step."""
acceleration = (voltage * self.Km - self.b * self.speed) / self.J
self.speed += acceleration * dt
return self.speed
def run_simulation(Kp, Ki, Kd, setpoint=500, sim_time=5.0, dt=0.01):
"""Run a PID-controlled motor simulation and return time and speed data."""
pid = PID(Kp, Ki, Kd, setpoint=setpoint)
pid.output_limits = (0, 12)
pid.sample_time = None # Update every call in simulation
motor = DCMotor()
time_data = []
speed_data = []
steps = int(sim_time / dt)
for i in range(steps):
t = i * dt
voltage = pid(motor.speed)
motor.update(voltage, dt)
time_data.append(t)
speed_data.append(motor.speed)
return np.array(time_data), np.array(speed_data)
# --- Single simulation plot ---
setpoint = 500
time_data, speed_data = run_simulation(Kp=0.02, Ki=0.01, Kd=0.001, setpoint=setpoint)
plt.figure(figsize=(10, 6))
plt.plot(time_data, speed_data, label='Motor Speed')
plt.axhline(y=setpoint, color='r', linestyle='--', label='Setpoint')
plt.xlabel('Time (s)')
plt.ylabel('Speed (RPM)')
plt.title('DC Motor PID Control')
plt.legend()
plt.grid(True)
plt.show()
Your motor model now has a visual output, and the simulation is packaged in a function you can call with any gains you want. Next up, you will run three different PID tunings and compare them side by side on one chart.
Compare Different PID Tunings
Your single simulation plot proves the PID controller works. But in real mechatronics projects, choosing the right gains is the hard part.
In this step, you will run three different PID tunings side by side and measure how each one performs. This is the core skill that separates a working controller from a great one.
In this step, get ready to:
- Write helper functions to measure overshoot and settling time.
- Define three different tuning profiles (P-only, PI, and full PID) and plot them on one graph.
- Analyze how each gain combination shapes the motor's speed response.
Add helper functions for performance metrics
Before comparing tunings, you need a way to measure how good each one is. Two key metrics in control systems are overshoot (how far past the target the motor goes) and settling time (how long until the motor stays at the target).
- In motor_sim.py, find the end of the run_simulation function (the return np.array(time_data), np.array(speed_data) line).
- Below that function, add the calculate_overshoot function by copying the following code:
def calculate_overshoot(speed_data, setpoint):
"""Calculate overshoot as a percentage of the setpoint."""
max_speed = np.max(speed_data)
if max_speed > setpoint:
return (max_speed - setpoint) / setpoint * 100
return 0.0
What does this code do?
This function finds the highest speed the motor reached during the simulation using np.max().
If that peak is above the setpoint, it calculates how far it overshot as a percentage. A 10% overshoot on a 500 RPM target means the motor hit 550 RPM before settling back down.
You won't see a change yet. This function is used by the comparison plot you will build next.
- Directly below calculate_overshoot, add the calculate_settling_time function by copying this code:
def calculate_settling_time(time_data, speed_data, setpoint, tolerance=0.02):
"""Calculate the time to settle within a tolerance band of the setpoint."""
band = setpoint * tolerance
for i in range(len(speed_data) - 1, -1, -1):
if abs(speed_data[i] - setpoint) > band:
if i + 1 < len(time_data):
return time_data[i + 1]
return time_data[-1]
return 0.0
What does this code do?
Settling time is the moment the motor reaches the target and stays there. The function defines a tolerance band of 2% around the setpoint (so for 500 RPM, the band is 490 to 510 RPM).
It walks backward through the speed data from the end. The first point it finds outside the band marks the last time the motor was unsettled. The next time step after that is the settling time.
- Save motor_sim.py.
You won't see a visible change yet. The next substep uses both of these functions in the comparison plot.
Build the tuning comparison plot
Now you will define three controller configurations and plot them on the same graph. Each configuration uses a different combination of P, I, and D gains so you can see how each term affects the motor's behavior.
- In motor_sim.py, scroll to the very bottom of the file and find the existing plt.show() line (the last line of your single simulation plot).
- Below that line, add the tuning comparison block by copying this code:
# --- Tuning comparison plot ---
tunings = [
("P-only", 0.05, 0, 0),
("PI", 0.02, 0.05, 0),
("PID", 0.02, 0.01, 0.001),
]
plt.figure(figsize=(10, 6))
for label, Kp, Ki, Kd in tunings:
t, s = run_simulation(Kp, Ki, Kd, setpoint=setpoint)
overshoot = calculate_overshoot(s, setpoint)
settling = calculate_settling_time(t, s, setpoint)
plt.plot(t, s, label=f"{label} (OS={overshoot:.1f}%, Ts={settling:.2f}s)")
plt.axhline(y=setpoint, color="r", linestyle="--", label="Setpoint")
plt.xlabel("Time (s)")
plt.ylabel("Speed (RPM)")
plt.title("PID Tuning Comparison")
plt.legend()
plt.grid(True)
plt.show()
What does this code do?
- tunings is a list of three tuning profiles. Each entry is a tuple of (label, Kp, Ki, Kd). The P-only controller has no integral or derivative terms. The PI controller adds an integral term. The full PID uses all three.
- The for loop runs a simulation for each tuning, computes its overshoot and settling time, and plots the speed curve. The label includes the metrics so you can read them directly in the legend.
- The setpoint variable is reused from the single simulation block above. All three tunings target the same 500 RPM.
- Save motor_sim.py.
- Run the script by entering this command in your terminal:
python motor_sim.py
- Two plot windows will appear one after the other. The first is your original single simulation plot from the previous step. Close it to see the second plot.
- The second plot should show three colored curves, a red dashed setpoint line, and a legend with overshoot (OS%) and settling time (Ts) for each tuning.
Only seeing one plot?
Make sure you added the comparison code below the existing plt.show() line, not inside the single simulation block. The two plot sections should be separate.
Also check that you spelled calculate_overshoot and calculate_settling_time exactly as you defined them. Python function names are case-sensitive.
Still stuck? Why is my second plot not appearing when I run motor_sim.py?
Analyze the tuning differences
Your comparison plot tells a clear story about what each gain does. Look at the three curves and the metrics in the legend.
What should I see in each curve?
- P-only (Kp=0.05, Ki=0, Kd=0): This curve rises quickly but never quite reaches 500 RPM. It levels off below the setpoint. This gap is called steady-state error. Without an integral term, the controller has no way to close the remaining gap.
- PI (Kp=0.02, Ki=0.05, Kd=0): The integral term accumulates error over time and pushes the motor all the way to 500 RPM. The tradeoff is more overshoot. You should see this curve shoot past the setpoint before coming back down.
- PID (Kp=0.02, Ki=0.01, Kd=0.001): The derivative term acts like a brake. It detects how fast the error is changing and dampens the response. This tuning reaches 500 RPM with less overshoot than PI alone.
Why does P-only have steady-state error?
A proportional controller outputs voltage proportional to the current error. As the motor gets closer to the setpoint, the error shrinks, so the voltage shrinks too.
At some point, the voltage is too low to overcome friction, and the motor stalls just below the target. Only the integral term can fix this by accumulating past errors into a larger correction.
This is the fundamental tradeoff in PID tuning. More integral gain eliminates error but increases overshoot. The derivative term reduces overshoot but can make the system sluggish if set too high.
✔️ Awesome, I've got everything!
Double check you have saved motor_sim.py and that both plots appear when you run the script.
ⓧ I'd like to double check the full code
Here is the complete motor_sim.py file as it should look at the end of this step.
import numpy as np
import matplotlib.pyplot as plt
from simple_pid import PID
class DCMotor:
"""Simplified DC motor model."""
def __init__(self, Km=50.0, b=0.5, J=0.02):
self.Km = Km # Motor constant (RPM per volt)
self.b = b # Friction coefficient
self.J = J # Moment of inertia
self.speed = 0.0 # Current speed in RPM
def update(self, voltage, dt):
"""Advance the motor simulation by one time step."""
acceleration = (voltage * self.Km - self.b * self.speed) / self.J
self.speed += acceleration * dt
return self.speed
def run_simulation(Kp, Ki, Kd, setpoint=500, sim_time=5.0, dt=0.01):
"""Run a PID-controlled motor simulation and return time and speed data."""
pid = PID(Kp, Ki, Kd, setpoint=setpoint)
pid.output_limits = (0, 12)
pid.sample_time = None # Update every call in simulation
motor = DCMotor()
time_data = []
speed_data = []
steps = int(sim_time / dt)
for i in range(steps):
t = i * dt
voltage = pid(motor.speed)
motor.update(voltage, dt)
time_data.append(t)
speed_data.append(motor.speed)
return np.array(time_data), np.array(speed_data)
def calculate_overshoot(speed_data, setpoint):
"""Calculate overshoot as a percentage of the setpoint."""
max_speed = np.max(speed_data)
if max_speed > setpoint:
return (max_speed - setpoint) / setpoint * 100
return 0.0
def calculate_settling_time(time_data, speed_data, setpoint, tolerance=0.02):
"""Calculate the time to settle within a tolerance band of the setpoint."""
band = setpoint * tolerance
for i in range(len(speed_data) - 1, -1, -1):
if abs(speed_data[i] - setpoint) > band:
if i + 1 < len(time_data):
return time_data[i + 1]
return time_data[-1]
return 0.0
# --- Single simulation plot ---
setpoint = 500
time_data, speed_data = run_simulation(Kp=0.02, Ki=0.01, Kd=0.001, setpoint=setpoint)
plt.figure(figsize=(10, 6))
plt.plot(time_data, speed_data, label="Motor Speed")
plt.axhline(y=setpoint, color="r", linestyle="--", label="Setpoint")
plt.xlabel("Time (s)")
plt.ylabel("Speed (RPM)")
plt.title("DC Motor PID Control")
plt.legend()
plt.grid(True)
plt.show()
# --- Tuning comparison plot ---
tunings = [
("P-only", 0.05, 0, 0),
("PI", 0.02, 0.05, 0),
("PID", 0.02, 0.01, 0.001),
]
plt.figure(figsize=(10, 6))
for label, Kp, Ki, Kd in tunings:
t, s = run_simulation(Kp, Ki, Kd, setpoint=setpoint)
overshoot = calculate_overshoot(s, setpoint)
settling = calculate_settling_time(t, s, setpoint)
plt.plot(t, s, label=f"{label} (OS={overshoot:.1f}%, Ts={settling:.2f}s)")
plt.axhline(y=setpoint, color="r", linestyle="--", label="Setpoint")
plt.xlabel("Time (s)")
plt.ylabel("Speed (RPM)")
plt.title("PID Tuning Comparison")
plt.legend()
plt.grid(True)
plt.show()
You now have a complete PID tuning workbench. You can see exactly how each gain shapes the motor's response and read the performance metrics right from the legend. Next up, the Secret Mission challenges you to throw a wrench into the system and see how your controller handles it.
Secret mission
Add a Disturbance and Watch the PID Recover
Your DC motor simulation is running smoothly at 500 RPM. But real motors face sudden loads all the time. Can your PID controller handle a surprise?
Before you go
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing costs. Decide whether to keep your project files, pause by freeing up space, or delete everything entirely.
Resources you used:
- Python installation.
- VS Code with the Python extension.
- pip packages: simple-pid, numpy, and matplotlib.
- Project folder: pid-motor-sim with motor_sim.py.
Keep everything running
No action needed. Choose this if you want to keep experimenting with different PID gains or extending the simulation.
- Your pid-motor-sim folder and all installed packages stay on your machine.
- You can re-run the simulation any time with python motor_sim.py.
Pause - I'll come back to this later
Uninstall the pip packages to free up disk space while keeping your project files intact.
- Uninstall the project packages by running:
pip uninstall simple-pid numpy matplotlib -y
- Your pid-motor-sim folder and motor_sim.py file stay on your Desktop.
- When you come back, reinstall the packages by running pip install simple-pid==2.0.1 numpy matplotlib from inside the project folder.
Delete - I don't want to use this again
Remove all project files and uninstall the packages to start fresh.
- Uninstall the project packages by running:
pip uninstall simple-pid numpy matplotlib -y
- Delete the project folder by running:
cd ~/Desktop
rmdir /s /q pid-motor-sim
- Keep Python and VS Code installed if you plan to use them for other projects.
- If you want to remove Python entirely, open Settings, select Apps, search for Python, and click Uninstall.
Mission accomplished
Nice Work!
You just built a complete PID motor controller simulation from scratch in Python. You modeled real motor physics, tuned a feedback controller, and proved your understanding with data visualizations.
Here's what you learned:
- Built a DC motor physics model that converts voltage input to RPM output using inertia and friction dynamics.
- Wired the simple-pid library into a closed-loop feedback controller and visualized the classic step response with matplotlib.
- Compared P-only, PI, and full PID tunings side by side to understand how each gain shapes overshoot, settling time, and steady-state error.
- Secret Mission: Added a disturbance rejection test to see how the PID controller recovers from a sudden load applied mid-simulation.
Ready to quiz yourself?