I recently implemented a PID routine for controlling temperature settings. The goal was to maintain a fixed temperature rate for a specified ramp up period, hold the temperature for a specified soak time, and then cool down at a specified rate. This could be useful for solder reflow cycles, food baking, beer brewing, or anything that needs a controlled timing scheme for temperature control.

PID stands for proportional-integral-derivative and is a very popular algorithm in control systems. In very simple terms, the temperature controller takes readings of the current state of the system and uses that data to determine whether to keep the heating element on. I won’t get into the details of the equation or the coefficients in this post; I am merely showing an Arduino implementation. My PID controller library on Github is here. I based my Arduino code off of this library on Github. This library is very good with handling the PID equation, but I felt like it could use some more description and clarification in applying the equation to ordinary Arduino peripherals, which is why I’m writing this post.

My system includes:

Arduino Uno

MAX31855 Thermocouple Reader

Power Switch for switching heating element, activated by digital pin. Solid State Relays can work too.

The MAX31855 is controlled through a SPI interface. I wrote a library for communicating with this chip on Github. The MISO and SCLK pin positions are dictated by the type of Arduino board you are using (see this). Any digital pin can be used for CSB. I don’t normally use the built-in SPI CSB pin (pin 10 on Uno) just because I normally am using multiple SPI devices at the same time that can’t all use pin 10.

I also just randomly picked a digital pin to control the SSR.

I have posted the full code at the bottom of the page, but I will break down some of the main functions now.

First, the function to run the PID equation. As parameters, this function takes in the 3 PID coefficient values (Kp, Ki, Kd); WindowSize, which is the maximum value the response could potentially be; and time_interval, the amount of time before the PID equation is re-evaluated. After setting the properties of the myPID object (part of the borrowed PID class), the output response is calculated based on the current temperature reading input and the desired temperature set point. I had issues getting the routine to start if the set point was too low, so the init_read section kick-starts the routine for the first time_interval. I calculated a ratio of the output response divided by the maximum response to determine how long the heating element should be on and how long it should be off in a given time_interval.

void run_PID(double Kp, double Ki, double Kd, uint16_t WindowSize, uint32_t time_interval)
{
double ratio;
uint32_t windowStartTime;
uint8_t buttons;
//Specify the links and initial tuning parameters
myPID.SetOutputLimits(0, WindowSize);
myPID.SetTunings(Kp, Ki, Kd);
myPID.SetMode(AUTOMATIC);
//This prevents the system from initially hanging up
if(init_read){init_read = 0;Setpoint = Setpoint + 1;}
else{read_temps();}
windowStartTime = millis();
Input = CtoF(thermo.thermocouple_temp);
myPID.Compute();
ratio = Output / WindowSize;
digitalWrite(SSR_PIN, 1);
while(millis() - windowStartTime < time_interval * ratio);
digitalWrite(SSR_PIN, 0);
while(millis() - windowStartTime < time_interval);
}

Next is the section that loops through the multiple time intervals. Depending on the system state (ramp up, soak, cool down), the function first determines how long that state will run based on the beginning and ending temperatures, calculates the number of time intervals are in that state time, and then loops through those iterations. In ramp up, the function determines the initial temperature reading and subtracts from the soak temperature to get the temperature delta, which along with the ramp rate can be used to find the total ramp time. In the soak state, the length of time is already specified. In cool down, the reverse of the ramp up is used.

void run_cycle_time(void)
{
uint32_t initial_time = millis();
uint32_t elapsed_time;
double diff_time_min;
double cycle_time;
//This set of statements calculates time of cycle phase
if(state == 0) //rising ramp, increasing temperature
{
//Calculate time remaining in rise phase based on temperature and rate
read_temps();
init_temp = CtoF(thermo.thermocouple_temp);
cycle_time = (max_temp - init_temp)/ramp_rate;
}
else if(state == 1) //soak time
{
//Soak time is already determined
cycle_time = soak_time;
}
else if(state == 2) //falling ramp, decreasing temperature
{
//Calculate time remaining in fall phase based on temperature and rate each cycle
read_temps();
cycle_time = (CtoF(thermo.thermocouple_temp) - init_temp)/cool_down;
}
//Determine time left in current phase
elapsed_time = millis();
diff_time_min = float(elapsed_time - initial_time) / 60000;
while(diff_time_min < cycle_time)
{
if(state == 0) //rising ramp, increasing temperature
{
//While increasing, Setpoint increases based on elapsed time
Setpoint = (diff_time_min * ramp_rate) + init_temp;
}
else if(state == 1) //soak time
{
Setpoint = max_temp;
}
else if(state == 2) //falling ramp, decreasing temperature
{
//While decreasing, Setpoint increases based on elapsed time
Setpoint = max_temp - (diff_time_min * cool_down);
}
//Determine current temp
read_temps();
//Determine PID response based on current temp
run_PID(2, 5, 1, 500, PID_interval);
//Determine time left in current phase
elapsed_time = millis();
diff_time_min = float(elapsed_time - initial_time) / 60000;
}
}

The main loop simply steps through the various states in sequence.

void loop()
{
switch (state) {
case 0: //Ramp Up
run_cycle_time();
state = state + 1;
break;
case 1: //Soak Time
run_cycle_time();
state = state + 1;
break;
case 2: //Cool Down
run_cycle_time();
state = state + 1;
break;
case 3: //Finished
while(1);
}
}

UPDATE: I’ve started a new post on Controlling Multiple PID Loops with One Arduino. Please check it out!

The full source code for PID controller

#include <PID_v1.h>
#include "MAX31855.h"
#define SPI_transfer 1
#if defined(SPI_transfer)
#include "SPI.h"
#endif
//Arduino Pins
uint8_t DHTPIN = 2;
uint8_t MAX31855_DATA = 12;
uint8_t MAX31855_CLK = 13;
uint8_t MAX31855_LAT0 = 7;
uint8_t SSR_PIN = 4;
//Temperature Cycle Settings
uint8_t state = 0;
uint16_t max_temp = 95; //in degrees F
uint8_t soak_time = 10; //in minutes
uint8_t ramp_rate = 10; //in degrees F/min
uint8_t cool_down = 10; //in degrees F/min
//PID parameters
double init_temp;
double Input, Output, Setpoint;
uint8_t init_read = 1; //flag to prevent 2 temp reads on first PID pass
uint32_t PID_interval = 5000; //time in ms to run PID interval
//Object Instantiation
MAX31855 thermo;
PID myPID(&Input, &Output, &Setpoint, 2, 5, 1, DIRECT);
void setup()
{
pinMode(SSR_PIN, OUTPUT);
thermo.setup(MAX31855_LAT0);
read_temps();
}
void loop()
{
switch (state) {
case 0: //Ramp Up
run_cycle_time();
state = state + 1;
break;
case 1: //Soak Time
run_cycle_time();
state = state + 1;
break;
case 2: //Cool Down
run_cycle_time();
state = state + 1;
break;
case 3: //Finished
while(1);
}
}
void run_PID(double Kp, double Ki, double Kd, uint16_t WindowSize, uint32_t time_interval)
{
double ratio;
uint32_t windowStartTime;
uint8_t buttons;
//Specify the links and initial tuning parameters
myPID.SetOutputLimits(0, WindowSize);
myPID.SetTunings(Kp, Ki, Kd);
myPID.SetMode(AUTOMATIC);
//This prevents the system from initially hanging up
if(init_read){init_read = 0;Setpoint = Setpoint + 1;}
else{read_temps();}
windowStartTime = millis();
Input = CtoF(thermo.thermocouple_temp);
myPID.Compute();
ratio = Output / WindowSize;
digitalWrite(SSR_PIN, 1);
while(millis() - windowStartTime < time_interval * ratio);
digitalWrite(SSR_PIN, 0);
while(millis() - windowStartTime < time_interval);
}
void run_cycle_time(void)
{
uint32_t initial_time = millis();
uint32_t elapsed_time;
double diff_time_min;
double cycle_time;
//This set of statements calculates time of cycle phase
if(state == 0) //rising ramp, increasing temperature
{
//Calculate time remaining in rise phase based on temperature and rate
read_temps();
init_temp = CtoF(thermo.thermocouple_temp);
cycle_time = (max_temp - init_temp)/ramp_rate;
}
else if(state == 1) //soak time
{
//Soak time is already determined
cycle_time = soak_time;
}
else if(state == 2) //falling ramp, decreasing temperature
{
//Calculate time remaining in fall phase based on temperature and rate each cycle
read_temps();
cycle_time = (CtoF(thermo.thermocouple_temp) - init_temp)/cool_down;
}
//Determine time left in current phase
elapsed_time = millis();
diff_time_min = float(elapsed_time - initial_time) / 60000;
while(diff_time_min < cycle_time)
{
if(state == 0) //rising ramp, increasing temperature
{
//While increasing, Setpoint increases based on elapsed time
Setpoint = (diff_time_min * ramp_rate) + init_temp;
}
else if(state == 1) //soak time
{
Setpoint = max_temp;
}
else if(state == 2) //falling ramp, decreasing temperature
{
//While decreasing, Setpoint increases based on elapsed time
Setpoint = max_temp - (diff_time_min * cool_down);
}
//Determine current temp
read_temps();
//Determine PID response based on current temp
run_PID(2, 5, 1, 500, PID_interval);
//Determine time left in current phase
elapsed_time = millis();
diff_time_min = float(elapsed_time - initial_time) / 60000;
}
}
double CtoF(double temp_C)
{
double temp_F = (temp_C * 1.8) + 32;
return temp_F;
}
void read_temps(void)
{
thermo.read_temp();
}