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.

Table 1: 10k NTC Thermistor Characteristics and Steinhart-Hart Coefficients
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
Bench Note on ESP32 ADC Non-Linearity: The ESP32-WROOM-32E's internal ADC is notoriously non-linear, particularly near the 0V and 3.3V rails. According to Espressif's official ADC documentation, you should design your voltage divider so the thermistor's operating voltage sits between 0.5V and 2.8V. For this build, a 10k pull-up resistor to 3.3V keeps the mid-range (25°C - 60°C) well within the ADC's linear sweet spot.

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:

  1. 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.
  2. 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.
  3. 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 34 in the code.

2. The "Short Circuit" Saturation Error

Exact Error String: [ERROR] ADC Saturation: Thermistor reads 0 (Short Circuit)

Ranked Causes:

  1. Breadboard Short: The thermistor and pull-up resistor junction is accidentally bridged to the ground rail via a stray wire or metallic debris.
  2. 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:

  1. 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.
  2. Integral Windup: If you started the system with a cold block and a massive Ki value, 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 PubSubClient library to publish the currentTemp and output variables to a local Mosquitto broker. You can then build a Node-RED dashboard to plot the thermal response curve in real-time and adjust the Kp, Ki, and Kd variables 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.