May 12, 2013

Sous Vide Part 3: Advanced Development

In part 1 I introduced the concept of sous vide and why you should be interested in it. Part 2 covered a basic and low cost way to get your feet wet with sous vide. Today, part 3 will detail the more advanced stand alone controller I designed.



There were a few drawbacks of my arduino controller. First, a lack of a user interface which required me to upload new code whenever I wanted to change the temperature, and the only way I could monitor the temperature was over the serial line which required a computer and more cables. Second, wires were always coming loose from the breadboard, or I would use the arduino for a different project, so I was always having to remember what wires went where. Third, it took up far too much counter space what with all the wires hanging out everywhere.

After playing around with sous vide for a few months and deciding it was definitely something I wanted to stick with I decided to put some effort and money into a stand alone controller. I had done a little bit of circuit board design over the past several months, but this would easily be the most complex to date. I started out by making a list of important features:

  • It should be based off of an atmega microcontroller for simplicity. An atmega8 proved to have too little memory, so now I use an atmega328p.
  • It would need a user input device and display. A 8x2 character LCD display with an encoder knob and button fit the bill nicely.
  • I wanted the ability to switch as large of a heater as I might ever want to connect to the thing, so I stuck with the BTA20 triac, capable of switching 20 amps AC, with an appropriately sized heat sink. Since most home outlets are connected to a 15A circuit breaker, this would be plenty. 
  • Since I would have the capability to switch such high current, I figured a zero cross detection circuit to switch the power on only when the AC waveform was crossing 0 volts (in essence easing into full current draw) would be a nice touch. I used this design.
  • It should have a audible sound for alerting the user. I used a DC buzzer.
  • It should use the same DS18B20 temperature sensor that I had been using, but should be able to interface with different sensors if I ever wanted it to. I broke out the I2C lines for this.
  • It should be able to interface with a computer for logging data to a file. I included an FTDI header for that.
  • It should be self powered from the one power cord plugged into the outlet. I.e. I didn't want to have a separate 5V power supply for the digital circuitry.
I spent what time I could between classes and homework jams and came up with a working design. I refined it a bit over the following years and version 3 looks like so:







Design files are linked to at the end of this post.

A warning: 
This circuit is connected directly to the mains power lines that carry potentially lethal voltages. Exercise extreme caution while using, and install the circuit board in an appropriate enclosure to reduce the risk of electric shock. By downloading and using these design files you agree to release me of any and all responsibility regarding its use. I am working on a 3d printable case for this to greatly reduce the risk involved.

For the pot, I decided to go with something that was a little bigger. Ok, so I went with something that was a lot bigger. I purchased a stainless steel 16 qt stock pot and a 1500W 10" water heater heating element to go inside. That's a 400% increase in volume and 790% increase in power over the crock pot I had been using.




The increase in volume has obvious benefits. The increase in power means that even with 4 gallons of water it can go from cold tap water to set temperature in 20 minutes (as opposed to 1-2 hours with the small crock pot) and recover from dropping in several chunks of cold meat in less than 5 minutes (20-30 minutes with the crock pot).

I went with a heating element inside the pot for one main reason: I figured it was the cheapest route. Shortly after I purchased the parts my brother asked me why I didn't just use a hot plate. Because I didn't realize how cheap hot plates actually were. Turns out the heating element and matching nut were about the same price as a hot plate, but I could have used any pot in the kitchen rather than buying a dedicated pot and drilling a hole in the side. However, each setup has it's pros and cons. Here's the ones I can think of.

Heating element inside pot
  • Pros
    • 100% of heating power is transferred directly to the water
    • System is easier to model since power input is known
    • No thermal inertia to worry about (i.e. water temperature doesn't keep rising after power is shut off due to a large thermal mass on the outside of the pot)
    • Set the gains once and forget about it
  • Cons
    • Requires a fair amount of labor to get the heating element through the side wall of the pot
    • It is a large, dedicated item that requires it's own storage space
    • Screw terminals where wires attach are exposed and hazardous
    • The heating element gets a bit rusty with extensive use (non-hazerdous to the food, though)
Hot plate with pot on top
  • Pros
    • Hot plate is small and can be stored easily
    • Any pot could easily be used
    • No assembly required
    • No exposed wires to worry about (aside from the circuit board, of course)
  • Cons
    • PID gains might be different enough between pot sizes to require individual tuning
    • There is an unknown portion of heating power that doesn't make it to the water, making theoretical modeling difficult
    • Thermal inertia could make tuning the controller difficult (but once tuned the controller would be perfectly capable of handling it)

In the end I'm glad I ended up with the setup I have, but I don't see any strong reason not to go with a hot plate.

As for selecting the PID gains and tuning the controller, I'll leave that for another post. For this system I ended up with gains of 23, 0.01, and 0 for the P, I, and D gains, respectively. 

Several people have asked me if I plan on insulating the pot. The answer is a firm no. While insulation may seem intuitive on a first pass (and it seemed that way to me, too), it can have some ill side effects. In a typical closed loop controller you have the ability to command the system in either direction (think position control on a robot). However, with a heater setup there is no [practical] way of driving the system in the cold direction. Therefore, if you overshoot your temperature (which will happen to some degree on any controller with an integral component, guaranteed) your only recourse is to wait for it to cool down naturally. Insulation would only increase the amount of time it takes to cool. In theory the insulation would help to make the temperature more stable using less power, but I haven't found either factor to be a problem without insulation. The controller is perfectly capable of controlling the temperature to within the resolution of the temperature sensor, and during steady state operation the system is only consuming on average 70W of power. With my electricity rates that amounts to about $0.15 for 24 hours of run time.


So that's the hardware side. Now for the software.

Again, I started with a list of what I wanted it to do. The list grew over time as I thought of more features to add. This is how it is now:
  • The user should be able to intuitively navigate a menu system with the encoder knob and button.
  • Within the menu system, the user should be able to:
    • set the desired temperature
    • set the temperature units (Fahrenheit or Celsius)
    • set the PID gains
    • set the timer direction (count up/down), and if down set duration
    • choose whether the buzzer is active
    • choose whether to log to an external computer
  • The current state of everything should be saved to EEPROM to be remembered through a power cycle
  • The user should be able to save and load up to 8 profiles (temperature, gains, timer) in EEPROM
  • The user should be able to adjust any of the settings "on the fly" while the controller is active
  • The controller should operate off the zero cross detection interrupt by default, and off a timer interrupt in the absence of zero cross detection
  • In the event of a power failure, the controller should resume as it was before the failure
Implementing all of this led to the largest arduino program I've written to date. It's over 1000 lines of code, most of it going to the menu system. At first I was able to just build off of what I had already written as I added features. Each time I added a feature the code would grow more convoluted and harder to follow. Finally, when I decided to implement changing settings on the fly, I realized a complete rewrite of the code was necessary. 

I recognized that my code lent itself to being a state machine, and it was a state machine in some sense, so I decided to go all out with the state machine concept upon the rewrite. I also decided to do something I've heard a lot about, but never though I needed until now--diagram the code. 


Diagramming the state machine was a bit tedious, but oh so helpful. First, it forced me to think through the layers of the menu system. Second, it allowed me to see improvements that I probably would have missed otherwise. Third, I gained back much more time in the coding phase by having a clear direction than I spent on the diagram. If you have a decent sized program that you have to write, I highly encourage you to diagram what the code does before you start coding.

There's no way I'm going to talk about all of the code here. I'll let you pour through it at your own pace, I think I've done a decent job commenting it. But feel free to ask any questions you may have about it in the comments. But there are a few things I'd like to address here.

Different from the controller in part 2, this new version relies on the zero cross detection signal for the timing of the control loop. In every period of the AC waveform there are two places where the voltage crosses zero. Keeping with the same 1 second control strategy, there are 120 zero cross events per control loop. Between each zero cross event is 1/120 = 8.33ms. It is important that whatever code is run in the ISR be done in under this amount of time. This wasn't enough time for the microcontroller to run both the getTemp() function and controller during the same zero cross event. So I opted to run the getTemp() function on the 119th zero cross event and the controller on the 120th (or 0th) zero cross event. Going a little further, I combed through the OneWire.h library, took out the parts that weren't relevant to this application, and changed the bus requests to broadcast mode which eliminated any handshaking between the DS18B20 and microcontroller at the expense of being able to only have one sensor on the bus. With these changes I was able to get the DS18B20 temperature requests to run reliably in less than 6ms, which is good enough for me.

void zcd() { // zero cross detection ISR

    Cross++;
    
    if (Cross > Num_On) { // turn off AC when # of ZC exceeds num_on
        digitalWrite(GATE, LOW);
    }

    if (Cross == 119) { // get temperature in this 8ms time slot
        Current_Temp = getTemp();
    }

    if (Cross == 120) { // compute num_on in this 8ms time slot
        if (Gate_Allow) {
            Num_On = control(); // controller computes num_on
        } else {
            Num_On = 0;
        }
        Cross = 0;
        if (Num_On != 0) {
            digitalWrite(GATE, HIGH); // every 120 ZC's except when num_on is 0
        }
    } 
}



uint8_t control() {

    static float _set_temp_celsius = Set_Temp;
    if ((Units&1) == 1) {
        _set_temp_celsius = Set_Temp;
    } else if ((Units&1) == 0) {
        _set_temp_celsius = round(temp_convert(Set_Temp, 1) * 2) / 2.0;
    }
    
    // calculate PID output
    long _val = Kp*Err + Ki*Err_Integral + Kd*(Err-Err_Prev); 

    // compute errors
    Err_Prev = Err;
    Err = _set_temp_celsius - Current_Temp; // always work in degrees C
    if (_val < 120 && _val > 0) { // integrator anti-windup
        Err_Integral += Err;
    }

    // constrain the output
    _val = constrain(_val, 0, 120);
    
    return _val;
}

The output of the controller (run once every second) is a number between 0 and 120 (< 7 bits). This number is used as how many zero cross events must pass before power is shut off. This is different than the timer interrupt used in part 2 which had an output between 0 and 1023 (10 bits), and the gains need to be scaled to match. It seems like a big drop going from 10 bit PWM resolution to less than 7. But with triac switching it is impossible to switch faster than 2x the AC frequency, so nothing is lost in practice.
For the encoder input knob I use the same technique that I described in this post:

For the input button, I found this handy little library that lets me easily distinguish between short and long button presses (and double clicks, but I don't use that here).

OneButton encoder_button(BUTTON, false);

// more code here

void setup() {

// more code here

encoder_button.attachClick(encoder_click);
encoder_button.attachPress(encoder_press);
encoder_button.setClickTicks(100); // single click timeout 100ms
encoder_button.setPressTicks(1000); // long press timeout 1000ms

// more code here

}

The GNU toolchain for AVR microcontrollers doesn't have any support for floating point formatted printing. However, I found this lovely trick for redirecting STDOUT to allow fprintf of floats, making my life a whole lot easier.

LiquidCrystal lcd(LCD_RS,LCD_EN,LCD_DB4,LCD_DB5,LCD_DB6,LCD_DB7);
static FILE lcdout = {0};

// more code here

void setup() {

lcd.begin(8,2);
fdev_setup_stream (&lcdout, lcd_putchar, NULL, _FDEV_SETUP_WRITE);

// more code here

}

Which allows me to fprintf to the lcd like so:

lcd.setCursor(0,1);
fprintf(&lcdout, "%2d:00:00", Timer);


You can download the full code and design files here:

For sale in the Makeatronics Store.

4 comments:

  1. Why do you need a zero crossing detect circuit when there's one in the MOC3063? Since its triac is in phase with the mains, shouldn't that suffice?

    ReplyDelete
    Replies
    1. Whoops - I was coming to this from the thermostat post, so I assumed by the symbol that you were using the 63, not the 23. Were you to replace the 23 with the 63, you wouldn't need the daughter card... right?

      Delete
  2. The MOC3063 will wait for a zero cross before turning the triac on, but it doesn't give any feedback to the microcontroller about when that happens. In zero cross mode I'm using the AC crossings for timing the feedback loop--power output is defined as (number of zero crossings in on state) / (120 zero crossings per second)--so the microcontroller needs to be able to count how many crossings have passed.

    In absence of the daughter board the micro automatically goes into timer mode where everything is time based instead of zero crossing based. It works well enough but doesn't ensure the triac gets turned on at the correct time to minimize current spikes. Going to a *63 in timer mode may work, but I haven't thought through the details...

    ReplyDelete
  3. You could have the main loop do the control and temp reading tasks and have the interrupt routines simply mark flags to tell the main loop it's time for those to run.

    ReplyDelete