Phidget Smoker Project #3: Bradley Smoker VINT Converison
This project is a continuation of the Phidget Smoker Project
by James
Introduction
This project was a continuation of the Phidget Smoker Project. We changed the controller to an SBC3003_0 Phidget SBC4, and switched all components to VINT devices. We also changed the algorithm controlling the temperature of the smoker to be more stable, as the previous version suffered wide fluctuations in temperature and an offset from the target temperature.
The control code was also ported to C from JavaScript, as there was no need to run a separate web server to control the devices connected locally. The program receives commands from, and posts data to a PhidgetDictionary and website, hosted by the PhidgetSBC4.
Hardware
The smoker was monitored with a thermocouple installed near the middle of the cooking area and attached to a Phidget TMP1101_0 4x Thermocouple Phidget.
The TMP1101 was placed inside the project box we made in a previous phase of the project.
Next, we cut the power cord between the Bradley and the heating element to insert our relay. For this project we used a Phidget REL2001 to control the power to the element. The actual power to the element is provided by the Bradley so that we can use their safety features in the case of a relay or software failure.
We then stripped the cable, and applied ferrules to the end of the black (hot) wire, to allow better connection to the terminals of the REL2001_0 relay. We then put the wires in the project box. Here we also cut, stripped and inserted a power cable which will be used to power our SBC.
We then applied wire nuts to connect the white (neutral) wires and the green (ground) wires respectively.
Then we moved on to the power supply for the SBC. Here, we attached a 12V DC power adapter directly to the 110V cord from the wall. To do so, we crimped a connector to one end of a wire, plugged it onto one of the socket terminals, and wrapped it in heat-shrink.
Next we attached the wires to the power cable with more wire nuts, and put the adapter in the box, as pictured to the right.
We attached REL2001 to the ferrules of the smoker power cables in a normally open configuration.
And added Phidget cables.
Then we added a divider to keep the SBC separate from the power circuitry.
We equipped the SBC3003_0, with a wifi adapter so it can host the web interface.
Lastly, we put a lid on the enclosure and plugged it all in.
Software
Smoker Control
While updating the software to run in VINT, we also decided to implement a PID loop to control the smoker temperature to take the major fluctuations out of the temperature profile. As well, we wanted to eliminate the arbitrary offset that was caused by the previous algorithm.
The previous algorithm called for turning on the element as soon as the temperature dropped below the target temperature. The problem with this, is that there was a significant delay between power being applied to the element, the element heating up, and the air around the thermocouple heating up in response. This meant that by the time the element got the temperature to stop falling and rise back up to the target, enough energy was added to the system to push the final temperature much higher than the target. This caused two problems.
- Temperature swing of 8°C on average.
- Temperature offset of 5-6°C
To fix these problems, we implemented a form of PID control loop to regulate the temperature more precisely.
#define dutyCycleSeconds 10
int dutyCycleCount = 0;
while (1) {
...
//don't run the calculation if the temperature is unknown.
if (smokerTemp == PUNK_DBL)
continue;
#define Kp 5
#define Ki 0.1
#define Kd 100
double error = (targetTemperature - smokerTemp);
//We'll only increment Ki if Kp isn't already near the target to prevent overshoot.
//This loop runs 10x for each time smokerTemp is adjusted, so we've
//compensated Ki accordingly.
if (Kp * error < dutyCycleSeconds)
errorSum += error * 0.1;
if (Ki * errorSum < -1 * dutyCycleSeconds)
errorSum = -1 * dutyCycleSeconds / Ki;
if (Ki * errorSum > dutyCycleSeconds)
errorSum = dutyCycleSeconds / Ki;
double targetDutyCycle = Kp * error + Ki * errorSum + Kd * (error - prevError);
//The duty cycle can be neither larger than the maximum time, or less than 0.
if (targetDutyCycle >= dutyCycleSeconds)
targetDutyCycle = dutyCycleSeconds;
if (targetDutyCycle < 0)
targetDutyCycle = 0;
printf("Target: %lf | Error Sum: %lf\n", targetDutyCycle, errorSum);
if (dutyCycleCount < targetDutyCycle)
PhidgetDigitalOutput_setState(element, 1);
else
PhidgetDigitalOutput_setState(element, 0);
//Since we're using a relay, our duty cycle is regulated in terms of seconds.
dutyCycleCount++;
dutyCycleCount %= dutyCycleSeconds;
if (dutyCycleCount == 0) //Record the current error for use with Kd
prevError = error;
ssleep(1); // wait for one second
}
With this PID loop, we were able to regulate the temperature to stay within ±1°C of the target.
In this system there is a correlation between desired accuracy and the frequency of switching the element, as the system is either heating up with the element on, or cooling down with the element off. This in turn leads to a trade-off in the longevity of the mechanical relay, as its lifespan is measured in how many times it can switch a load.
Since we're using a mechanical relay with a lifespan of approximately 100,000 cycles, if we continuously PWM the output, we get a worst-case lifespan of: 100,000 cycles * 10 seconds = 1,000,000 seconds, or 277.8 hours. That's approximately 17 16-hour smokes.
In practice, the relay doesn't switch nearly that often, as more often than not it's fully on or fully off for multiple 10-second intervals in a row. On average the relay cycles once every 90 seconds to maintain a consistent temperature, which will be good for 156 16-hour smokes, which is a more reasonable number. For comparison, the previous algorithm switched the relay once every 300 seconds on average, which yields 520 16-hour smokes.
An alternate solution to avoid the problem of longevity altogether would be to use a 3959_0 solid state relay in place of the mechanical REL2001_0.
The controller also tracks and stores the temperature from three sources. These temperatures are averaged every 10 seconds, and stored in a file labeled by their date to keep file sizes down for when the data is downloaded by the web application for graphing.
char* baseFileName = "SmokerData/smokerData";
double temperatureSum[3] = { 0 };
int temperatureCount[3] = { 0 };
static void CCONV
onTemperatureChangeHandler(PhidgetTemperatureSensorHandle ch, void *ctx, double temperature) {
int channel;
Phidget_getChannel((PhidgetHandle)ch, &channel);
temperatureCount[channel]++;
temperatureSum[channel] += temperature;
}
#define TMP_CHANNEL 0
#define FOOD1_CHANNEL 1
#define FOOD2_CHANNEL 2
...
int main() {
...
while(1) {
//take the temperature at the start of a new dutyCycle itteration
if (dutyCycleCount == 0) {
for (int channel = 0; channel < 3; channel++) {
char fileName[50] = "";
time_t currentTime;
struct tm *timeStruct;
char temperatureString[20] = "";
char channelName[10] = "";
if (temperatureCount[channel] == 0)
continue;
double avgTemp = temperatureSum[channel] / temperatureCount[channel];
temperatureCount[channel] = 0;
temperatureSum[channel] = 0;
sprintf(temperatureString, "%.2lf", avgTemp);
switch (channel) {
case TMP_CHANNEL:
strcpy(channelName, "TMP");
break;
case FOOD1_CHANNEL:
strcpy(channelName, "FOOD1");
break;
case FOOD2_CHANNEL:
strcpy(channelName, "FOOD2");
break;
}
printf("%s Temperature Changed: %.3g\n", channelName, avgTemp);
PhidgetDictionary_set(dict, channelName, temperatureString);
//control the temperature of the smoker, if this is the smoker temperature measurement
if (channel == TMP_CHANNEL)
smokerTemp = avgTemp;
currentTime = time(NULL);
timeStruct = localtime(¤tTime);
sprintf(fileName, "%s_%d_%d_%d%s", baseFileName, timeStruct->tm_mon + 1,
timeStruct->tm_mday, timeStruct->tm_year + 1900, ".csv");
FILE *f = fopen(fileName, "a");
if (f == NULL) {
printf("error opening file!\n");
continue;
}
fprintf(f, "%d,%ld,%f\n", channel, currentTime, avgTemp);
fclose(f);
}
}
}
...
}
To allow setting new target temperatures from the web application, we used the following dictionary event handlers to handle requests to change the target temperature.
void CCONV
onDictionaryUpdate(PhidgetDictionaryHandle ch, void *ctx, const char *key, const char* value) {
if (strcmp(key, "targetTemperature") == 0) {
targetTemperature = atof(value);
printf("NewTarget: %lf\n", targetTemperature);
}
}
void CCONV
onDictionaryAdd(PhidgetDictionaryHandle ch, void *ctx, const char *key, const char* value) {
onDictionaryUpdate(ch, ctx, key, value);
}
Website
We also created a website to track and control the smoker temperature. It reports the current temperatures being tracked, allows you to set a new target temperature remotely, and plots the smoker temperature information over the previous two days.
Firstly, it reads the current temperatures from the dictionary, as follows.
var dict;
function runCode() {
document.getElementById("password").style.display = "none";
document.getElementById("smokerManager").style.display = "block";
dict = new jPhidgets.Dictionary();
dict.setDeviceLabel('Smoker');
dict.onAttach = attach;
dict.onUpdate = update;
dict.open().then(function() {
loadDataGraph(new Date(Date.now()));
$("#datepicker").val((new Date(Date.now()))); //\xB0 is the degree symbol
dict.get("TMP", "Unknown").then(function(val){$('#currentTemp').text(val + '\xB0C');});
dict.get("FOOD1", "Unknown").then(function(val){$('#food1').text(val + '\xB0C');});
dict.get("FOOD2", "Unknown").then(function(val){$('#food2').text(val + '\xB0C');});
dict.get("targetTemperature", "Unknown")
.then(function(val){$('#targetTemperatureInput').val(val);});
}).catch(function (err) {
});
}
function update(key, value) {
if(key == "TMP") {
$("#currentTemp").text(value + '\xB0C');
}
if(key == "FOOD1") {
$("#food1").text(value + '\xB0C');
}
if(key == "FOOD2") {
$("#food2").text(value + '\xB0C');
}
}
To set a new target temperature, the corresponding value is changed in the dictionary to be interpreted by the server code.
function setTargetTemperature(){
var val = $('#targetTemperatureInput').val();
if(isNaN(val))
$('#targetErrorText').text("Must be a number!");
else
$('#targetErrorText').text("");
dict.set("targetTemperature", val);
}
To graph the last two days of data, the program collects data from the file corresponding to the current (or selected) date, and the day before it with a jquery get request. It then uses vis.js to graph the data.
var graph;
function loadDataGraph(targetDate){
//in this part of the function, we try to get data from the previous day to append
//to the selected day's data
//this is useful, as smoking meats can be done overnight
//from here, we will call the function that gets the selected day's data and the actual
//graphing after the request returns
var prevDayDate = new Date(targetDate - 8.64e+7);
var prevDayurl = '/SmokerData/smokerData_' + (prevDayDate.getMonth() + 1) + '_'
+ prevDayDate.getDate() + '_' + prevDayDate.getFullYear() + '.csv';
var prevDay = $.get(prevDayurl, { "_": $.now() });
prevDay.fail(function(prevDay,textStatus,errorThrown){
console.log('get prevDay data failed');
prevDayData = "";
displayGraph(targetDate, "")
});
prevDay.done(function(rawdata){
prevDayData = rawdata;
displayGraph(targetDate, rawdata);
});
}
function displayGraph(targetDate, prevDayData) {
var url = '/SmokerData/smokerData_' + (targetDate.getMonth() + 1) + '_'
+ targetDate.getDate() + '_' + targetDate.getFullYear() + '.csv';
var options = {
legend: true,
width: '75%',
height: '400px',
drawPoints: false,
interpolation: false
};
var jq = $.get(url, { "_": $.now() });
jq.fail(function(jq,textStatus,errorThrown){
console.log('loadDataGraph() failed');
$("#graphErrorText").text(jq.status + ": " + errorThrown).css('color', 'red');
if(jq.status === 404)
$("#graphErrorText").html($("#graphErrorText").html() + "
No Data Exists For That Day");
});
jq.done(function(rawdata){
rawdata = prevDayData + rawdata;
$("#graphErrorText").text("");
console.log('success getting raw data');
var groups = new vis.DataSet();
groups.add({
id:0,
content: "Smoker"
});
groups.add({
id:1,
content: "Food1"
});
groups.add({
id:2,
content: "Food2"
});
var container = document.getElementById('visualization');
var data = rawdata.split('\n');
var wdata = [];
for (var d in data) {
var l = data[d].split(',');
if (l.length != 3)
continue;
var channel = parseInt(l[0]);
var dateConv = new Date(parseInt(l[1])*1000);
var doorState = parseFloat(l[2]);
wdata.push({ x: dateConv, y: doorState, group: channel});
}
var dataset = new vis.DataSet();
dataset.add(wdata);
graph = new vis.Graph2d(container, dataset, groups, options);
});
}
The date may be selected using a jquery datepicker.
$( function() {
$( "#datepicker" ).datepicker();
} );
The website is supported by the following html.
<!DOCTYPE html>
<html>
<head>
<title>Smoker Control Centre</title>
<script src="jquery-3.2.0.min.js"></script>
<script src="sha256.js"></script>
<script src="jphidgets22.1.0.0.min.js"></script>
<script src="vis.min.js"></script>
<link href="vis.min.css" rel="stylesheet" type="text/css">
<link rel="stylesheet" href="pcStyles.css">
<link rel="stylesheet" href="jquery-ui.css">
<script src="jquery-ui.min.js"></script>
<script>
//Javascript Here
</script>
</head>
<body>
<div id="smokerManager" style="display: none;">
<center><h1>SMOKER CONTROL CENTRE</h1></center>
<center><h2>Smoker Temperature Graph</h2></center>
<center><div id="visualization"/></center>
<center><h2 id="graphErrorText"></h2></center>
<br>
<center><h3>Date: <input type="text" id="datepicker">
<input type="button" class="btn btn-primary" value="Set Date" onclick="setDate()" >
</h3></center>
<center>
<input type="button" class="btn btn-primary" value="Refresh Graph" onclick="setDate()" >
</center>
<center><h2>Current Temperature: <label id="currentTemp"></label>
</h2></center>
<center><input type="text" id="targetTemperatureInput"/>
<span style="margin-left:-90px; color:lightgrey">Temperature</span>
<span style="margin-left:10px;"></span>
<input type="button" class="btn btn-primary" value="Set Target Temp"
onclick="setTargetTemperature()">
</center>
<center><label id="targetErrorText" style="display: none;">
</label></center><br>
<center><h2>Food1 Temperature: <label id="food1">
</label></h2></center>
<center><h2>Food2 Temperature: <label id="food2">
</label></h2></center>
</div>
</body>
</html>
Pork Rib Smoke
As a test, we smoked a set of pork ribs.
To collect data, we stuck a pair of thermocouples in the centre of two separate racks of ribs, and recorded the temperature of the smoker itself. The smoker temperature was set at 115°C (240°F), and once it reached that temperature, the smoker held it much better than previous iterations.
The ribs were done by 6:30, six and a half hours after they started. They turned out nicely cooked and full of flavour.
Below is a graph of the recorded data. The smoker temperature is labeled "Smoker", and the two food temperature probes are labeled "Food1" and "Food2".
The temperature drops off at 4:25pm when we moved the smoker inside to finish the cooking there, as it was deemed unwise to leave cooked meats in an unlocked smoker outside. The next temperature drops were due to premature sampling of the ribs, since the smell was so good we had to try a few before they were fully done to perfection.
Waiting for graph to load...
Conclusion
The smoker worked well with the PhidgetSBC4 and the new algorithm. The results were delicious, and we look forward to further "testing" in the future.