Why PID Control is the Ultimate Project Idea for Electrical and Electronics Engineering
When searching for practical project ideas for electrical and electronics engineering students and hobbyists, the gap between abstract textbook theory and physical hardware is the hardest bridge to cross. Control theory—specifically Proportional-Integral-Derivative (PID) loops—is often taught using Laplace transforms and block diagrams, leaving students wondering how to actually implement it in silicon. Building a closed-loop PID temperature controller using an ESP32 and a solid-state relay (SSR) forces you to confront real-world non-linearities: thermal mass, sensor noise, and ADC quantization.
This build targets the ESP32-WROOM-32E (DevKit V1). We will use a 10k NTC thermistor in a voltage divider to measure temperature, apply the Steinhart-Hart equation to linearize the resistance-to-temperature curve, and output a PWM signal to drive a 5V Solid State Relay switching a 12V cartridge heater. By the end of this guide, you will have a working, tunable thermal control system and a deep understanding of the underlying component physics.
Sensor Theory: NTC Thermistors and the Steinhart-Hart Equation
Before writing firmware, we must address the sensor. Negative Temperature Coefficient (NTC) thermistors are highly non-linear; their resistance drops exponentially as temperature rises. If you simply map the ADC voltage linearly to temperature, your PID loop will oscillate wildly at higher temperatures due to the compressed resistance curve.
To solve this, we use the Steinhart-Hart equation, a third-order polynomial that models the thermistor's behavior with high accuracy. The equation is:
1/T = A + B*ln(R) + C*(ln(R))^3
Where T is temperature in Kelvin, R is resistance in Ohms, and A, B, C are coefficients provided by the manufacturer. Below is a data-dense comparison of two common 10k NTC thermistors to help you select the right component for your target temperature range.
| Thermistor Type | Beta (B25/85) | Coefficient A | Coefficient B | Coefficient C | Max Error (0-100°C) | Best Application |
|---|---|---|---|---|---|---|
| Standard 10k NTC | 3950 K | 1.129148 × 10⁻³ | 2.34125 × 10⁻⁴ | 8.76741 × 10⁻⁸ | ± 1.2 °C | General purpose, 3D printers |
| Precision 10k NTC | 3435 K | 1.140000 × 10⁻³ | 2.32000 × 10⁻⁴ | 9.01000 × 10⁻⁸ | ± 0.4 °C | Medical, incubators, lab gear |
| High-Temp 10k NTC | 4250 K | 0.980000 × 10⁻³ | 2.50000 × 10⁻⁴ | 7.50000 × 10⁻⁸ | ± 2.5 °C (at 25°C) | Reflow ovens, extruders |
| PT100 RTD (Reference) | N/A (Linear) | N/A (Callendar-Van) | N/A | N/A | ± 0.1 °C | Industrial process control |
Hardware Build: Parts List and Pin Mapping
To replicate this exact build, source the following components. Do not substitute the mechanical relay for the Solid State Relay (SSR); a mechanical relay will fail within a few thousand cycles when subjected to the 1Hz+ PWM switching required for stable PID control.
Bill of Materials (BOM)
- Microcontroller: ESP32-WROOM-32E (DevKit V1 footprint, 38-pin)
- Sensor: 10k NTC Thermistor (Beta 3950, glass encapsulated for moisture resistance)
- Divider Resistor: 10kΩ 1% Metal Film Resistor (1/4W)
- Switching Element: Omron G3MB-202P 5V DC-DC Solid State Relay (2A max load)
- Load: 12V 40W Cartridge Heater (6mm x 30mm)
- Power Supply: 12V 5A Switching PSU (Mean Well LRS-60-12 or equivalent)
- Flyback Protection: 1N4007 Diode (across heater terminals if using inductive loads, though cartridge heaters are purely resistive)
Pin Mapping Table
| ESP32 Pin | Function | Connected To | Notes |
|---|---|---|---|
| 3V3 | Power | 10k Pull-up Resistor | Use the regulated 3.3V pin, not VBUS |
| GND | Ground | Common Ground Rail | Must share ground with 12V PSU |
| GPIO 34 | ADC1_CH6 (Input) | Thermistor / Resistor Junction | Input only pin, no internal pull-up |
| GPIO 25 | PWM Output | SSR Control (+) | DAC1 capable, excellent for PWM |
| GPIO 2 | Status LED | Onboard LED | Indicates heater active state |
Firmware: ESP32 PID Implementation with Error Handling
Below is the complete, compilable C++ code for the Arduino IDE. Select "ESP32 Dev Module" as your board variant. This implementation avoids external PID libraries to expose the underlying math, allowing you to see exactly how the Integral windup and Derivative kick are handled. It also includes strict ADC bounds checking to prevent the heater from locking on if a wire breaks.
#include <Arduino.h>
// --- Hardware Pin Definitions ---
#define THERMISTOR_PIN 34 // ADC1_CH6
#define SSR_PIN 25 // PWM Output
#define LED_PIN 2 // Status LED
// --- PWM Configuration ---
#define PWM_FREQ 1000 // 1kHz switching frequency
#define PWM_RESOLUTION 10 // 10-bit (0-1023)
// --- Steinhart-Hart Coefficients (Beta 3950) ---
#define SH_A 1.129148e-3
#define SH_B 2.34125e-4
#define SH_C 8.76741e-8
#define SERIES_RESISTOR 10000.0
// --- PID Tuning Parameters (Ziegler-Nichols starting point) ---
float Kp = 15.0; // Proportional gain
float Ki = 0.5; // Integral gain
float Kd = 2.0; // Derivative gain
float setpoint = 60.0; // Target temperature in Celsius
float integral = 0.0;
float previousError = 0.0;
unsigned long lastTime = 0;
float readTemperature() {
int raw_adc = analogRead(THERMISTOR_PIN);
// Error Handling: Check for open/short circuits
if (raw_adc >= 4090) {
Serial.println("[ERROR] ADC Saturation: Thermistor reads 4095 (Open Circuit)");
return -999.0;
}
if (raw_adc <= 5) {
Serial.println("[ERROR] ADC Saturation: Thermistor reads 0 (Short Circuit)");
return -999.0;
}
// Convert ADC to Resistance
float resistance = SERIES_RESISTOR * ((4095.0 / (float)raw_adc) - 1.0);
// Steinhart-Hart Equation
float logR = log(resistance);
float tempK = 1.0 / (SH_A + SH_B * logR + SH_C * pow(logR, 3));
return tempK - 273.15; // Convert Kelvin to Celsius
}
void setup() {
Serial.begin(115200);
pinMode(LED_PIN, OUTPUT);
// Configure PWM using ESP32 LEDC API
ledcSetup(0, PWM_FREQ, PWM_RESOLUTION);
ledcAttachPin(SSR_PIN, 0);
ledcWrite(0, 0); // Ensure heater is OFF at boot
Serial.println("ESP32 PID Temperature Controller Initialized.");
lastTime = millis();
}
void loop() {
unsigned long now = millis();
float dt = (now - lastTime) / 1000.0; // Time step in seconds
if (dt >= 0.1) { // Run PID loop every 100ms
lastTime = now;
float currentTemp = readTemperature();
// Safety Failsafe: If sensor fails, shut down heater immediately
if (currentTemp == -999.0) {
ledcWrite(0, 0);
digitalWrite(LED_PIN, LOW);
return;
}
float error = setpoint - currentTemp;
// Integral calculation with anti-windup clamping
integral += error * dt;
if (integral > 200.0) integral = 200.0;
if (integral < -200.0) integral = -200.0;
// Derivative calculation (based on error change)
float derivative = (error - previousError) / dt;
// PID Output calculation
float output = (Kp * error) + (Ki * integral) + (Kd * derivative);
// Clamp output to PWM resolution (0 to 1023)
if (output > 1023) output = 1023;
if (output < 0) output = 0;
ledcWrite(0, (int)output);
digitalWrite(LED_PIN, output > 0 ? HIGH : LOW);
previousError = error;
// Telemetry
Serial.printf("Set: %.1fC | Act: %.1fC | Out: %d\n", setpoint, currentTemp, (int)output);
}
}
Debugging: First Three Checks When the Heater Won't Fire
When moving from simulation to physical hardware, control loops frequently fail to stabilize or refuse to engage. If your serial monitor is throwing errors or the heater remains cold, follow this ranked decision path.
1. The "Open Circuit" Saturation Error
Exact Error String: [ERROR] ADC Saturation: Thermistor reads 4095 (Open Circuit)
Ranked Causes:
- Broken Voltage Divider: The ground connection to the thermistor is loose. The ESP32 GPIO 34 is floating high, pulled up by the 10k resistor to 3.3V. Check your breadboard ground rail continuity with a multimeter.
- Thermistor Lead Fracture: Glass-encapsulated NTCs are brittle. If you bent the leads sharply during insertion, the internal weld may have snapped. Measure the thermistor directly; it should read ~10kΩ at room temperature.
- Wrong ADC Channel: You wired the junction to GPIO 35, 36, or 39 (ADC1 channels that lack internal pull-ups and have different noise floors) but defined
THERMISTOR_PIN 34in the code.
2. The "Short Circuit" Saturation Error
Exact Error String: [ERROR] ADC Saturation: Thermistor reads 0 (Short Circuit)
Ranked Causes:
- Breadboard Short: The thermistor and pull-up resistor junction is accidentally bridged to the ground rail via a stray wire or metallic debris.
- SSR Backfeed: If you are using a cheap, unbranded SSR module instead of the Omron G3MB-202P, the internal optocoupler LED might be wired incorrectly, pulling the ESP32 GPIO low.
3. Temperature Reads Correctly, but Heater Stays Cold
If the serial monitor shows valid temperatures but the PWM output remains at 0:
- Insufficient SSR Drive Current: The ESP32 GPIO pins can source up to 40mA, but some high-power SSRs require 15-20mA at 3.3V to trigger. The Omron G3MB-202P triggers reliably at 3.3V, but if you swapped to a 5V-only SSR (like the Fotek SSR-25DA), the 3.3V logic high will not cross the optocoupler's forward voltage threshold. Use a logic-level MOSFET (e.g., 2N7000) to level-shift the 3.3V signal to 5V.
- Integral Windup: If you started the system with a cold block and a massive
Kivalue, the integral term may have saturated negatively. The anti-windup clamp in the code prevents this, but if you modified the limits, reset the ESP32 to clear the RAM.
Scaling the Build: How to Extend or Simplify
Depending on your final application or academic requirements, you may need to adjust the complexity of this project.
How to Simplify: Bang-Bang Control with Hysteresis
If PID tuning (Ziegler-Nichols method) is proving too difficult, or if your load has massive thermal inertia (like a large water tank), replace the PID math block with a simple Bang-Bang controller with hysteresis. This mimics a mechanical thermostat. Set the heater to 100% PWM if currentTemp < (setpoint - 1.0), and 0% PWM if currentTemp > (setpoint + 1.0). This eliminates the need for derivative calculations and prevents rapid relay chatter, though it sacrifices the ±0.5°C stability that PID provides.
How to Extend: MQTT Telemetry and Auto-Tuning
To elevate this from a bench experiment to an IoT engineering project:
- Add MQTT: Integrate the
PubSubClientlibrary to publish thecurrentTempandoutputvariables to a local Mosquitto broker. You can then build a Node-RED dashboard to plot the thermal response curve in real-time and adjust theKp,Ki, andKdvariables over the air without recompiling. - Implement Auto-Tune: Incorporate the Arduino PID AutoTune library. By forcing the heater into a relay-feedback oscillation (bang-bang), the ESP32 can measure the ultimate gain and oscillation period of your specific thermal mass, automatically calculating the optimal PID constants for your exact hardware setup.
- Upgrade to PT100: If your application requires accuracy beyond the ESP32's native ADC capabilities, replace the thermistor with a PT100 RTD and a MAX31865 SPI breakout board. This bypasses the ESP32's internal ADC non-linearity entirely, shifting the analog-to-digital conversion to a dedicated 15-bit delta-sigma IC.






