Creating an Arduino Temperature Controller for Home Brewing

My dad and I have been brewing some beer lately and it's difficult to keep the beer at the correct temperature for fermentation. Luckily we had an old used small refrigerator that just fit the 5 gallon bucket we were using for the first stage of fermentation after my dad cut out some of the shelves in the door.

We were able to keep the temperature close to what we wanted by manually adjusting one of those plugs that lets you set when it goes on in 15 minute intervals, but I thought it would be fun to try and create something to actively manage the temperature using an Arduino and PID loop.

To start with I bought the following components from Adafruit :

Testing the Arduino

First, it's a good idea to double check that you can actually communicate with the Arduino. I'm using Fedora and so to get started I downloaded the Arduino IDE by running:

$ sudo yum install arduino

For some reason it wouldn't work by default and so I had to additionally run:

$ sudo yum install java-1.8.0-openjdk

Then, I had to add myself to the groups:

$ sudo usermod -aG dialout [username]
$ sudo usermod -aG lock [username]

You may have to log out and log back in again for those changes to take place. Then, I was able to run the IDE by running:

$ arduino

When you plug your Arduino in to the computer via USB, you should see a message pop up in dmesg:

$ dmesg -wH

If not try a different cable (I had to try three before finding one that worked). Once it does pop up in dmesg, you can run:

$ lsusb /dev/ttyUSB*

and it should show up. Then in the Arduino IDE go to Tools -> Port and then select the device.

Importing Libraries

For some reason when I would try to import Libraries via Sketch -> Include Library -> Manage Libraries, it would pop up an empty gray window. So, instead I installed them manually.

To install the Adafruit LCD Shield library you can go to the github webpage here . Then click on the latest release in the bar on the right and download the zip file.

Next, go to Sketch -> Include Library -> Add .ZIP Library and select the zip file you downloaded. It should now show up in ~/Arduino/libraries, and you can import it in your code.

For the rest of the project I needed the following libraries:

Hello World

Once I soldered the LCD Shield kit and got it hooked up I ran and compiled the "Hello world" program here .

Programming the Arduino

After soldering everything together, I wrote a program to control the outlet power relay module which is based on the relay output example here .

The input to the PID loop is the current temperature, and the set point is the desired temperature. The tricky part is the output; since we're controlling a fridge, the only real output we have is on or off. Therefore, we use the output of the PID loop as a duty cycle, i.e. the output of the PID loop gets mapped to a fraction between 0 and 1 (in the code it's actually mapped from 0 to the window size), and then we turn the fridge on that fraction of the window. It's like a very slow version of pulse width modulation.

In our case, to avoid power cycling the fridge over and over, I have the window set to 15 minutes.

#include <Adafruit_RGBLCDShield.h>
#include <Adafruit_MAX31855.h>
#include <PID_v1.h>

// These #defines make it easy to set the backlight color
#define RED 0x1
#define YELLOW 0x3
#define GREEN 0x2
#define TEAL 0x6
#define BLUE 0x4
#define VIOLET 0x5
#define WHITE 0x7

// Example creating a thermocouple instance with software SPI on any three
// digital IO pins.
#define MAXDO   4
#define MAXCS   3
#define MAXCLK  2

// I/O pin connected to the relay
#define RELAY  5

// The shield uses the I2C SCL and SDA pins. On classic Arduinos
// this is Analog 4 and 5 so you can't use those for analogRead() anymore
// However, you can connect other I2C sensors to the I2C bus and share
// the I2C bus.
Adafruit_RGBLCDShield lcd = Adafruit_RGBLCDShield();

// Initialize the Thermocouple
Adafruit_MAX31855 thermocouple(MAXCLK, MAXCS, MAXDO);

/* Current temperature (Fahrenheit) */
double cur_temp = 70;
/* PID output */
double output = 0;
/* Set temperature (Fahrenheit) */
double set_temp = 70;

// Specify the links and initial tuning parameters
double Kp = 100.0, Ki = 1.0, Kd = 0;

PID myPID(&cur_temp, &output, &set_temp, Kp, Ki, Kd, DIRECT);

// Window size (seconds)
int window_size = 15*60;

void setup() {
    // put your setup code here, to run once:
    // Debugging output
    Serial.begin(9600);
    // set up the LCD's number of columns and rows: 
    lcd.begin(16, 2);

    pinMode(RELAY,OUTPUT);

    // Print a message to the LCD. We track how long it takes since
    // this library has been optimized a bit and we're proud of it :)
    int time = millis();
    lcd.print("Hello, world!");
    time = millis() - time;
    Serial.print("Took "); Serial.print(time); Serial.println(" ms");
    lcd.setBacklight(WHITE);
    lcd.clear();

    //tell the PID to range between 0 and the full window size
    myPID.SetOutputLimits(0, window_size);
    myPID.SetMode(AUTOMATIC);
}

void print_temp(double f)
{
    char tmp[10];
 
    dtostrf(f,4,1,tmp);
    lcd.print(tmp);
}

void print_percentage(double p)
{
    char tmp[10];

    dtostrf(p,3,0,tmp);
    lcd.print(tmp);
}

void loop() {
    /* Total number of seconds elapsed. */
    unsigned int seconds;
    /* New, current, and old button state. */
    uint8_t newButtons, buttons, oldButtons = 0;
    /* Global loop counter. */
    static uint8_t count = 0;
    /* Variable to determine if we redraw the LCD screen. */
    static uint8_t redraw = 1;
    /* Disable PID loop and turn off relay output. */
    static uint8_t always_off = 0;
    /* Previous number of seconds into window. */
    static unsigned int last_delta = 10000;
    /* Number of seconds into window. */
    unsigned int delta;
    /* PID output at start of window. */
    static double locked_output = 0;

    cur_temp = thermocouple.readFahrenheit();

    myPID.Compute();

    if (count % 100 == 0) {
        if (!isnan(cur_temp)) {
            Serial.print("Thermocouple Temp = *");
            Serial.println(cur_temp);
        }

        Serial.print("output = ");
        Serial.println(output);
    }

    if (redraw) {
        lcd.setCursor(0, 0);
        if (isnan(cur_temp)) {
            lcd.print("T/C Problem");
        } else {
            lcd.print("Cur: ");
            print_temp(cur_temp);
        }

        lcd.setCursor(0,1);
        if (always_off) {
            lcd.print("OFF             ");
        } else {
            lcd.print("Set: ");
            print_temp(set_temp);

            lcd.print(" ");
            print_percentage((1-output/window_size)*100);
            lcd.print("%");
        }

        redraw = 0;
    }

    if (always_off) {
        digitalWrite(RELAY, LOW);
        myPID.SetMode(MANUAL);
    } else {
        /************************************************
         * turn the output pin on/off based on pid output
         ************************************************/
        myPID.SetMode(AUTOMATIC);
        seconds = millis()/1000;
        delta = seconds % window_size;
        if (delta < last_delta)
            locked_output = output;
        last_delta = delta;
        if (locked_output < delta) digitalWrite(RELAY, HIGH);
        else digitalWrite(RELAY, LOW);
    }

    newButtons = lcd.readButtons();
    buttons = newButtons & ~oldButtons;
    oldButtons = newButtons;

    if (buttons) {
        if (buttons & BUTTON_UP) {
            set_temp += 1;
            redraw = 1;
        }

        if (buttons & BUTTON_DOWN) {
            set_temp -= 1;
            redraw = 1;
        }

        if (buttons & BUTTON_SELECT) {
            if (always_off) always_off = 0;
            else always_off = 1;
            redraw = 1;
        }
    }

    count += 1;
}

Here is what the current version of the controller looks like:

A picture of the Arduino temperature controller.

You can just barely make out the MAX31855 breakout board peeking out on the top left. The thermocouple wire is the long brown wire coming out of the breakout board. It currently displays the current temperature on the top line, and the set point along with the current duty cycle on the bottom line.

I'm now in the testing phase to try and figure out the three tuning parameters, which could take a while since the fridge changes temperatures on the time scale of hours.

Update (2021-1-17)

I just finished my thesis, and so now I have some time to come back to this. The previous version of the software did not work very well. After leaving it on for a few days some quantity in the PID controller went to nan, and it stopped working. One thing I realised was that tuning the PID parameters by hand and testing it out for hours was completely unfeasible, so this time I decided I would simulate the system so that I could tune the PID parameters.

The model I chose for the system is very simple, and probably not very accurate, but I hoped it would be good enough to get the PID parameters to within an order of magnitude of their correct values. I modelled the fridge by taking into account two processes:

  1. Heat gain due to the temperature difference between the fridge and the outside temperature:

    Temperature change = k*(T_outside - T_fridge)*time/c

    where c is the heat capacity of the fridge.

  2. Heat loss due to the fridge running:

    Temperature change = -W*time/c

    where W is the number of useful watts the fridge is using.

To estimate W, I looked online and found that a typical minifridge uses 80 Watts, and that a typical coefficient of performance is 25% (see this link ). Therefore, I chose W = 20 Watts.

Next, I needed to measure the heat capacity of the fridge, c, and the heat gain coefficient, k.

To do so, I decided to take two measurements. First, I would start when the fridge was at room temperature. That way, the heat gain from the temperature difference would be zero, and the net temperature change would be only due to the fridge running, i.e.

Temperature change = -W*time/c

Then, I would unplug the fridge. At that point, the only process occuring would be the heat loss, so the temperature change would be:

Temperature change = k*(T_outside - T_fridge)*time/c

and I could measure k.

Here is the data I took:

Time (min) Temp (F) Comments
0 71.6
1 71.6
2 71.6
3 71.2
4 70.7
5 70.3
6 69.3
7 68.4
8 68.0
9 66.7
10 65.8 after this, I unplugged the fridge
15 63.0 note, it's still cooling
20 63.0
25 62.6
50 63.0

One thing I wasn't expecting is that the fridge would continue to cool after it was unplugged. I think this is because the coolant was still cool even after unplugging. From these numbers, I was able to calculate:

c = 1300 J/degree F

k = 0.04 J/s

and I was able to write a small simulation using the exact same PID code running on the arduino. From this simulation I was able to test different values of Kp and Ki (I don't use the derivative term because I think it will be too noisy), to see the results.

The initial results of the simulation from the parameters I was previously using were pretty awful:

Plot of the simulated temperature and PID output using the initial set of PID parameters.

I found that to have a stable PID output, it was necessary that the integral term be much much smaller than the proportional term. Eventually, I settled on Kp = 10.0, and Ki = 0.001, which produce the following output:

Plot of the simulated temperature and PID output after tuning.

Another thing I did was buy an Arduino shield kit , a case , and rewire the thing so that it is much more robust.

Here is the current version of the code:

#include <Adafruit_RGBLCDShield.h>
#include <Adafruit_MAX31855.h>
#include <PID_v1.h>

// Example creating a thermocouple instance with software SPI on any three
// digital IO pins.
#define MAXDO   4
#define MAXCS   3
#define MAXCLK  2

// I/O pin connected to the relay
#define RELAY  5

// The shield uses the I2C SCL and SDA pins. On classic Arduinos
// this is Analog 4 and 5 so you can't use those for analogRead() anymore
// However, you can connect other I2C sensors to the I2C bus and share
// the I2C bus.
Adafruit_RGBLCDShield lcd = Adafruit_RGBLCDShield();

// Initialize the Thermocouple
Adafruit_MAX31855 thermocouple(MAXCLK, MAXCS, MAXDO);

/* Current temperature (Fahrenheit) */
double cur_temp = 70;
/* PID output */
double output = 0;
/* Set temperature (Fahrenheit) */
double set_temp = 70;

// Specify the links and initial tuning parameters
double Kp = 10.0, Ki = 0.001, Kd = 0;

PID myPID(&cur_temp, &output, &set_temp, Kp, Ki, Kd, REVERSE);

// Window size (seconds)
int window_size = 15*60;

/* Calculate once every minute. */
int SampleTime = 1000.0;

/* millis() at start. */
unsigned long t0 = 0;

void setup() {
    // put your setup code here, to run once:
    // Debugging output
    Serial.begin(9600);
    // set up the LCD's number of columns and rows: 
    lcd.begin(16, 2);

    pinMode(RELAY,OUTPUT);

    // Print a message to the LCD. We track how long it takes since
    // this library has been optimized a bit and we're proud of it :)
    int time = millis();
    lcd.print("Hello, world!");
    time = millis() - time;
    Serial.print("Took "); Serial.print(time); Serial.println(" ms");
    lcd.setBacklight(WHITE);
    lcd.clear();

    //tell the PID to range between 0 and the full window size
    myPID.SetOutputLimits(0, window_size);
    myPID.SetMode(AUTOMATIC);
    myPID.SetSampleTime(SampleTime);

    t0 = millis();
}

byte print_time(double f)
{
    char tmp[10];
 
    dtostrf(f/1000.0/60.0,4,1,tmp);
    return lcd.print(tmp);
}

byte print_temp(double f)
{
    char tmp[10];
 
    dtostrf(f,4,1,tmp);
    return lcd.print(tmp);
}

byte print_percentage(double p)
{
    char tmp[10];

    dtostrf(p,3,0,tmp);
    return lcd.print(tmp);
}

/* Display modes.
 *
 * In DISPLAY_TEMP mode, we display the current and set temperature, along with
 * the current and locked PID output. In DISPLAY_TIME mode we display the
 * elapsed time and the minimum temperature. */
#define DISPLAY_TEMP 0
#define DISPLAY_TIME 1

void loop() {
    /* Total number of seconds elapsed. */
    unsigned int seconds;
    /* New, current, and old button state. */
    uint8_t newButtons, buttons, oldButtons = 0;
    /* Global loop counter. */
    static uint8_t count = 0;
    /* Variable to determine if we redraw the LCD screen. */
    static uint8_t redraw = 1;
    /* Disable PID loop and turn off relay output. */
    static uint8_t always_off = 0;
    /* Previous number of seconds into window. */
    static unsigned int last_delta = 10000;
    /* Number of seconds into window. */
    unsigned int delta;
    /* PID output at start of window. */
    static double locked_output = 0;
    /* Minimum temperature. */
    static double min_temp = 1e9;
    /* Display mode (temperature or time). */
    static unsigned int display_mode = DISPLAY_TEMP;
    byte length;
    int i;

    cur_temp = thermocouple.readFahrenheit();

    if (cur_temp < min_temp)
        min_temp = cur_temp;

    myPID.Compute();

    if (count % 100 == 0) {
        if (!isnan(cur_temp)) {
            Serial.print("Thermocouple Temp = *");
            Serial.println(cur_temp);
        }

        Serial.print("output = ");
        Serial.println(output);
    }

    if (redraw || (count % 1000) == 0) {
        lcd.setCursor(0, 0);
        if (display_mode == DISPLAY_TEMP) {
            if (isnan(cur_temp)) {
                length = lcd.print("T/C Problem");
            } else {
                length = lcd.print("Cur: ");
                length += print_temp(cur_temp);
                length += lcd.print(" ");
                length += print_percentage((output/window_size)*100);
                length += lcd.print("%");
            }
            for (i = length; i < 16; i++) lcd.print(" ");

            lcd.setCursor(0,1);
            if (always_off) {
                length = lcd.print("OFF             ");
            } else {
                length = lcd.print("Set: ");
                length += print_temp(set_temp);

                length += lcd.print(" ");
                length += print_percentage((locked_output/window_size)*100);
                length += lcd.print("%");
            }
            for (i = length; i < 16; i++) lcd.print(" ");
        } else if (display_mode == DISPLAY_TIME) {
            length = lcd.print("Time: ");
            length += print_time(millis() - t0);
            length += lcd.print(" min");
            for (i = length; i < 16; i++) lcd.print(" ");
            lcd.setCursor(0,1);
            length = lcd.print("Min Temp:  ");
            length += print_temp(min_temp);
            for (i = length; i < 16; i++) lcd.print(" ");
        }

        redraw = 0;
    }

    if (always_off) {
        digitalWrite(RELAY, LOW);
        myPID.SetMode(MANUAL);
    } else {
        /************************************************
         * turn the output pin on/off based on pid output
         ************************************************/
        myPID.SetMode(AUTOMATIC);
        seconds = millis()/1000;
        delta = seconds % window_size;
        if (delta < last_delta)
            locked_output = output;
        last_delta = delta;
        if (delta < locked_output) digitalWrite(RELAY, HIGH);
        else digitalWrite(RELAY, LOW);
    }

    newButtons = lcd.readButtons();
    buttons = newButtons & ~oldButtons;
    oldButtons = newButtons;

    if (buttons) {
        if (buttons & BUTTON_RIGHT) {
            display_mode = (display_mode + 1) % 2;
            redraw = 1;
        }

        if (buttons & BUTTON_LEFT) {
            display_mode = (display_mode + 1) % 2;
            redraw = 1;
        }

        if (display_mode == DISPLAY_TEMP) {
            if (buttons & BUTTON_UP) {
                set_temp += 1;
                redraw = 1;
            }

            if (buttons & BUTTON_DOWN) {
                set_temp -= 1;
                redraw = 1;
            }

            if (buttons & BUTTON_SELECT) {
                if (always_off) always_off = 0;
                else always_off = 1;
                redraw = 1;
            }
        }
    }

    count += 1;
}

Update (2021-1-20)

I updated the code with a new algorithm that doesn't require locking in the duty cycle every 15 minutes. This was a problem because it meant that any time the temperature changed significantly, the system had to wait until the end of the 15 minute window to change.

I also made some more changes:

#include <Adafruit_RGBLCDShield.h>
#include <Adafruit_MAX31855.h>
#include <PID_v1.h>

// These #defines make it easy to set the backlight color
#define RED 0x1
#define YELLOW 0x3
#define GREEN 0x2
#define TEAL 0x6
#define BLUE 0x4
#define VIOLET 0x5
#define WHITE 0x7

// Example creating a thermocouple instance with software SPI on any three
// digital IO pins.
#define MAXDO   4
#define MAXCS   3
#define MAXCLK  2

// I/O pin connected to the relay
#define RELAY  5

// The shield uses the I2C SCL and SDA pins. On classic Arduinos
// this is Analog 4 and 5 so you can't use those for analogRead() anymore
// However, you can connect other I2C sensors to the I2C bus and share
// the I2C bus.
Adafruit_RGBLCDShield lcd = Adafruit_RGBLCDShield();

// Initialize the Thermocouple
Adafruit_MAX31855 thermocouple(MAXCLK, MAXCS, MAXDO);

/* Current temperature (Fahrenheit) */
double cur_temp = 70;
/* PID output */
double output = 0;
/* Set temperature (Fahrenheit) */
double set_temp = 70;

// Specify the links and initial tuning parameters
float Kp = 0.02, Ki = 0.000002, Kd = 0.0;

PID myPID(&cur_temp, &output, &set_temp, Kp, Ki, Kd, REVERSE);

// Window size (seconds)
int window_size = 15*60;

/* Calculate once every minute. */
int SampleTime = 1000.0;

/* millis() at start. */
unsigned long t0 = 0;

void setup() {
    // put your setup code here, to run once:
    // Debugging output
    Serial.begin(9600);
    // set up the LCD's number of columns and rows: 
    lcd.begin(16, 2);

    pinMode(RELAY,OUTPUT);

    // Print a message to the LCD. We track how long it takes since
    // this library has been optimized a bit and we're proud of it :)
    int time = millis();
    lcd.print("Hello, world!");
    time = millis() - time;
    Serial.print("Took "); Serial.print(time); Serial.println(" ms");
    lcd.setBacklight(WHITE);
    lcd.clear();

    //tell the PID to range between 0 and the full duty cycle
    myPID.SetOutputLimits(0, 1);
    myPID.SetMode(AUTOMATIC);
    myPID.SetSampleTime(SampleTime);

    t0 = millis();
}

byte print_time(double f)
{
    char tmp[10];
 
    dtostrf(f/1000.0/60.0,4,1,tmp);
    return lcd.print(tmp);
}

byte print_temp(double f)
{
    char tmp[10];
 
    dtostrf(f,4,1,tmp);
    return lcd.print(tmp);
}

byte print_percentage(double p)
{
    char tmp[10];

    dtostrf(p,3,0,tmp);
    return lcd.print(tmp);
}

/* Display modes.
 *
 * In DISPLAY_TEMP mode, we display the current and set temperature, along with
 * the current PID output. In DISPLAY_TIME mode we display the elapsed time and
 * the minimum temperature. */
#define DISPLAY_TEMP 0
#define DISPLAY_TIME 1

#define STATE_AUTO 0
#define STATE_ON   1
#define STATE_OFF  2

void loop() {
    int i;
    /* Keep track of the state of the relay. */
    static int relay = 1;
    /* New, current, and old button state. */
    uint8_t newButtons, buttons, oldButtons = 0;
    /* Global loop counter. */
    static uint8_t count = 0;
    /* Variable to determine if we redraw the LCD screen. */
    static uint8_t redraw = 1;
    /* Disable PID loop and turn relay output on/off. */
    static uint8_t state = 0;
    /* Minimum temperature. */
    static double min_temp = 1e9;
    /* Display mode (temperature or time). */
    static unsigned int display_mode = DISPLAY_TEMP;
    /* Keep track of the number of characters printed so we can pad the rest of
     * the characters with spaces. */
    byte length;
    /* Value of millis() on last loop. */
    static unsigned long last_millis;
    /* Number of ms in the current relay state. */
    static unsigned long millis_in_state = 0;

    if (count == 0)
        last_millis = millis();

    cur_temp = thermocouple.readFahrenheit();

    if (cur_temp < min_temp)
        min_temp = cur_temp;

    if (!isnan(cur_temp))
        myPID.Compute();

    millis_in_state += millis() - last_millis;
    last_millis = millis();

    if (relay == 0) {
        if (millis_in_state > window_size*(1 - output)*1000) {
            relay = 1;
            millis_in_state = 0;
        }
    } else {
        if (millis_in_state > window_size*output*1000) {
            relay = 0;
            millis_in_state = 0;
        }
    }

    if (count % 100 == 0) {
        if (!isnan(cur_temp)) {
            Serial.print("Thermocouple Temp = *");
            Serial.println(cur_temp);
        }

        Serial.print("output = ");
        Serial.println(output);
    }

    if (redraw || (count % 1000) == 0) {
        lcd.setCursor(0, 0);
        if (display_mode == DISPLAY_TEMP) {
            if (isnan(cur_temp)) {
                length = lcd.print("T/C Problem");
            } else {
                length = lcd.print("Cur: ");
                length += print_temp(cur_temp);
                length += lcd.print(" ");
                length += print_percentage(output*100);
                length += lcd.print("%");
            }
            for (i = length; i < 16; i++) lcd.print(" ");

            lcd.setCursor(0,1);
            if (state == STATE_OFF) {
                length = lcd.print("OFF");
            } else if (state == STATE_ON) {
                length = lcd.print("ON");
            } else {
                length = lcd.print("Set: ");
                length += print_temp(set_temp);
            }
            for (i = length; i < 16; i++) lcd.print(" ");
        } else if (display_mode == DISPLAY_TIME) {
            length = lcd.print("Time: ");
            length += print_time(millis() - t0);
            length += lcd.print(" min");
            for (i = length; i < 16; i++) lcd.print(" ");
            lcd.setCursor(0,1);
            length = lcd.print("Min Temp:  ");
            length += print_temp(min_temp);
            for (i = length; i < 16; i++) lcd.print(" ");
        }

        redraw = 0;
    }

    if (state == STATE_OFF) {
        digitalWrite(RELAY, LOW);
        myPID.SetMode(MANUAL);
    } else if (state == STATE_ON) {
        digitalWrite(RELAY, HIGH);
        myPID.SetMode(MANUAL);
    } else {
        /************************************************
         * turn the output pin on/off based on pid output
         ************************************************/
        myPID.SetMode(AUTOMATIC);
        if (relay) digitalWrite(RELAY, HIGH);
        else digitalWrite(RELAY, LOW);
    }

    newButtons = lcd.readButtons();
    buttons = newButtons & ~oldButtons;
    oldButtons = newButtons;

    if (buttons) {
        if (buttons & BUTTON_RIGHT) {
            display_mode = (display_mode + 1) % 2;
            redraw = 1;
        }

        if (buttons & BUTTON_LEFT) {
            display_mode = (display_mode + 1) % 2;
            redraw = 1;
        }

        if (display_mode == DISPLAY_TEMP) {
            if (buttons & BUTTON_UP) {
                set_temp += 1;
                redraw = 1;
            }

            if (buttons & BUTTON_DOWN) {
                set_temp -= 1;
                redraw = 1;
            }

            if (buttons & BUTTON_SELECT) {
                state = (state + 1) % 3;
                redraw = 1;
            }
        }
    }

    count += 1;
}