# Sous Vide cooker, elasticity, and meat

• As part of something I'm doing elsewhere I have made a Sous Vide cooker, designed and made a force-displacement device for measuring elastic modulus of material, and cooked some meat for a long time.

This thread will contain lots of information about all of these things. You might be interested in one or more of them and I would be happy to discuss them in the discussion thread (https://www.electronicspoint.com/sous-vide-cooker-elasticity-and-meat-t267512.html#post1600675).

I intend to break this up into several parts:

1) Overview (what I'm doing and why)
2) Force-displacement device (design, manufacture, calibration, and use)
3) Sous Vide Cooker (design, calibration, programming, and use)
4) Steps in the cooking of the meat (from raw to meal in about 50 hours!)
5) Raw data (data logging, weight, stress, strain, time, etc.)
6) Results

1) Overview (what I'm doing and why)

I've wanted to cook using the sous vide method. However these things are pretty expensive where I live, so I decided to make one instead.

This also worked well with a course that I was doing where I needed to do a final assignment, so I worked them in together.

The first step was to determine how to make and calibrate the controller for the sous vide cooker. This is not as trivial as it seems because the slow cooker has a large thermal mass which introduces a large delay, PID control works, but it's tricky to get it to both stabilize quickly *AND* come up to temperature within a reasonable time.

After I had managed to control this enough to use the cooker at a constant temperature, I needed to determine an appropriate experiment.

I decided to look at elasticity of meat and how it changes over time in low temperature sous vide cooking.

This is interesting because there are some statements made about maximum cooking times which (when one eliminated food safety issues) seem to be based more on myth than science.

An early generalization about sous vide was that it was impossible to overcook food. That is clearly wrong when you look at things like fish which can easily be cooked to the point at which they become mushy (although I've never done this). Various web sites make the same claim for beef, and because beef is a more robust product it is easier to measure the elasticity. In addition to this the cooking time for fish can be so short that it would be hard to get enough readings taken.

A cooking time of 33 hours (which was extended to 36 hours) was chosen because it worked in well with my work schedule. In retrospect this could have been extended by another 24 or 48 hours.

2) Force-displacement device (design, manufacture, calibration, and use)

For those playing along at home, I have also attached the DXF file used to laser cut the MDF. Note that there's an error in the DXF. It cuts an additional blank and doesn't cut the platform to put the mass on. The supports and the central shaft are not included in this either.

Design

I wanted a method to make reliable and reproducible measures of stress and strain.

The method I used initially was to alter the weight placed on the test piece over a known area and measure the displacement. The problems with this were the measurement of the masses, and the inability to keep things aligned. Consequently the measurement of displacement was very inaccurate.

For this experiment I decided to use a fixed mass and alter the displacement. The weight as read on the scales would represent the force applied by the test piece to the plunger.

The next issue is to keep the plunger vertical and make the selection of displacement simple.

The final design is a plunger passing unrestricted through a vertical channel other than by a wing nut which determines the maximum displacement.

Manufacture

All of the platforms were cut on a laser cutter. The vertical columns were cut using a drop saw. The distances between the platforms, and that of the plunger pieces is not critical. The plunger was assembled by screwing the pieces together whilst inside the channel. Only the top platform was unfixed and allowed to rotate. After the three pieces of the plunger were screwed together the top platform was nailed and glued into place.

Here is the assembled product:

(1) Is the single mass used to provide the force onto the item under test.

(2) Is the platform the mass is placed on.

(3) Is the top section of the plunger. This is a rigid piece that transfers force directly to the piece under test.

(4) Is the top reference. The difference between this platform and the mass platform is measured to determine the displacement. (This needs calibration to determine an offset).

(5) Adjustment platform. The wing nut on the threaded rod allows the displacement to be accurately adjusted. The normal adjustment is 1/2 a turn between readings. The actual adjustment can be calibrated, but I measured the displacement rather than by counting turns.

(6) Lower plunger. This is the 30mm x 30mm plunger used to press against the test piece. The pairs of platforms the wooden plunger pieces pass through prevent the plunger from rotating and maintain it in a vertical orientation. The threaded rod between the two wooden pieces is screwed about 50mm in to each piece.

(7) Is the piece under test. In this case it is the unsealed, uncooked meat.

(8) Is the scales used to measure the difference in force applied to the meat (it is tared after the test piece is placed on it)

(9) is the base board.

The distance between (2) and (4) is measured using the depth gauge of a digital micrometer.

Calibration

There are to calibrations required. The first one is to determine the constant offset between the measurement from the top of the mass platform (2) to the top of the top platform (4) compared to the measurement between the bottom of the plunger (6) to the top of the scales (8).

This is done by winding the wing nut to the top of its travel so that the plunger rests on the scales. The distance between the (2) and (4) is measured and subtracted from all subsequent measurements. In the case of this device, this offset is 12.13mm

The second calibration is to wind the wing nut to about mid-way and take a series of measurements between (2) and (4) as the wing nut is turned 1/2 a turn at a time. For this thread, each half turn lifted or dropped the plunger by 0.75mm

Use

(1) The plunger is wound up clear of the item under test
(2) The item to be tested is placed on the scales, weighed (if required) and the scales tared.
(3) A suitable mass is placed on the mass platform
(4) The plunger is lowered until the scales read between 20g and 50g. The location on the test piece should be chosen so that the entire plunger makes contact.
(5) The displacement (2) to (4) is measured and noted against the weight reading.
(6) The plunger is lowered 1/2 a turn at a time. After the reading on the scales has steadied, the displacement and weight are measured and recorded.

Example

Here is an example of the calculations performed to determine elasticity

Code:
g       mm (raw)   N           mm (corr)  area m^2    stress           strain           elasticity
20      53.93      0.196       41.83      0.0009
72      53.29      0.7056      41.19      0.0009      566.2222222      0.011867235      47713.06944
132     52.69      1.2936      40.59      0.0009      653.3333333      0.011259148      58026.88889
205     52.02      2.009       39.92      0.0009      794.8888889      0.012715885      62511.4859
285     51.42      2.793       39.32      0.0009      871.1111111      0.011534025      75525.33333
380     50.81      3.724       38.71      0.0009      1034.444444      0.011863088      87198.57923
488     50.09      4.7824      37.99      0.0009      1176             0.014170439      82989.66667
614     49.44      6.0172      37.34      0.0009      1372             0.012976642      105728.4308
760     48.71      7.448       36.61      0.0009      1589.777778      0.014765372      107669.3333

78420.34845
Let's look at the 5th row.

The first column represents the weight in grams shown on the scales (285g).

The second column is the raw height measured from the top of the mass platform to the top if the top platform. (51.52mm) These first 2 columns represent the raw data captured whilst measuring. The other columns are derived from these and from various constants.

The third column is a direct conversion from g to newtons. To do this, column 1 is divided by 1000 (to get kg) and then multiplied by 9.8 to convert to newtons. In this case the figure is 2.793

Column 4 is the distance between the bottom of the plunger and the scales. This is done by applying the calibration factor determined during calibration. 12.1 was used. The value was measured as 12.13, however the final digit was considered too unreliable). In this case the figure is 39.32mm.

The 4th column contains the area of the plunger in square metres. The plunger is 30mm square which is 0.0009 square metres.

The fifth column is the stress. This is calculated as the difference in pressure in N divided by the plunger area (871).

The sixth column is the strain. This is the difference in height between the previous and current reading expressed as a proportion of the previous height (0.0115)

The last column represents the elastic modulus, this is the stress divided by the strain. In this case it is 75500.

Finally all the elasticity measurements are averaged giving 78400 in this case.

Problems

The main problem is that as the displacement increases the item under test takes longer and longer to reach equilibrium.

After performing this experiment I discovered that the best way to reach equilibrium was to drop the plunger by 1 1/2 turns and then lift it by 1 turn. This results in a reading which stabilizes almost immediately.

Caution needs to be taken because some scales don't correctly deal with slowly changing weights. To test this, place a cup on the scales then pour a cup of sugar quickly into it. Note the weight. Then repeat the measurement but pour the sugar *VERY* slowly (over a minute perhaps) into the cup. If the difference in weight reading is more than a few grams (some will be out by over 100g) then DONT use this set of scales in this application.

3) Sous Vide Cooker (design, calibration, programming, and use)

The starting point is a slow cooker:

The controller is an arduino, and currently built on a breadboard:

The code was based on the Adafruit Sous Vide controller, but it has a number of changes.

1) Uses a standard LCD, switches, and LEDs rather than the special Adafruit LDC/keypad/backlight
2) Averages the temperature over time to give higher resolution.
3) Several bugs fixed
4) Enhanced logging
5) Added code to facilitate calibration
6) Dumb slewing to within a few degrees of the set point before enabling PID control

The source code documents the interface to the hardware.

Here is the source code:

Code:
/*
Lots of code based on AdaFruit SousVide controller.

This is the AdaCoreSousVide controller because it lacks some of the AdaFruit stuff.

Based on:
//-------------------------------------------------------------------
//
// Sous Vide Controller
// Bill Earl - for Adafruit Industries
//
// Based on the Arduino PID and PID AutoTune Libraries
// by Brett Beauregard
//------------------------------------------------------------------

The LCD circuit:

* LCD pin 1 (gnd) to gnd
* LCD pin 2 (Vcc) to +5v
* LCD pin 3 (VO) to wiper of 20k pot between Vcc and gnd (contrast adj)
* LCD pin 4 (RS) to digital pin 7
* LCD pin 5 (R/W) to gnd
* LCD pin 6 (clock/enable) to digital pin 6

* LCD pin 7 (D0) UNUSED
* LCD pin 8 (D1) UNUSED
* LCD pin 9 (D2) UNUSED
* LCD pin 10 (D3) UNUSED

* LCD pin 11 (D4) to digital pin 5
* LCD pin 12 (D5) to digital pin 4
* LCD pin 13 (D6) to digital pin 3
* LCD pin 14 (D7) to digital pin 2

http://www.arduino.cc/en/Tutorial/LiquidCrystal

- - - - - - - - - - - - - - - - - -

The DS18B20 circuit:

* DS18B20 pin 1 to gnd
* DS18B20 pin 2 to digital pin 9
* DS18B20 pin 3 to Vcc

http://www.hobbytronics.co.uk/ds18b20-arduino

- - - - - - - - - - - - - - - - - -

The 4 direction switches are on pins 14 to 17 all pulled low with 10k resistors

* left  - digital pin 14
* right - digital pin 15
* up    - digital pin 16
* down  - digital pin 17

And the shift button on 8

* shift - digital pin 8

And the relay output is on 10

* relay - digital pin 10

- - - - - - - - - - - - - - - - - -

* Red LED - digital pin 13
* Green LED - digital pin 12
* Blue LED - digital pin 11

*/
// PID Library
#include <PID_v1.h>
#include <PID_AutoTune_v0.h>

// include the library code for LCD:
#include <LiquidCrystal.h>
#include <SPI.h>
#include "printf.h"

// include library code for DS18B20
#include <OneWire.h>
#include <DallasTemperature.h>

// include the switch debouncing library
#include <Bounce2.h>

// So we can save and retrieve settings
#include <EEPROM.h>

// LCD constants and definitions
//------------------------------

const
uint8_t num_rows = 2;
uint8_t num_cols = 16;
uint8_t scan_wide = (num_rows - 1) * num_cols;

// initialize the library with the numbers of the interface pins
LiquidCrystal lcd(7, 6, 5, 4, 3, 2);

// DS18B20 constants and definitions
//----------------------------------

// Data wire is plugged into pin 2 on the Arduino
#define ONE_WIRE_BUS 9

// Setup a oneWire instance to communicate with any OneWire devices
// (not just Maxim/Dallas temperature ICs)
OneWire oneWire(ONE_WIRE_BUS);

// Pass our oneWire reference to Dallas Temperature.
DallasTemperature sensors(&oneWire);

// The address of the DS18B20
DeviceAddress tempSensor; // We'll use this variable to store a found device address

// Standard LED identification
//----------------------------

// Pin 13 has an LED connected on most Arduino boards.
// give it a name:
#define led 13;
//bool bLedOn = false;

#define LED_R 13
#define LED_G 12
#define LED_B 11

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

// Switch definitions
//-------------------

Bounce SW_Left(14, 75, true);
Bounce SW_Right(15, 75, true);
Bounce SW_Up(16, 75, true);
Bounce SW_Down(17, 75, true);
Bounce SW_Shift(8, 75, true);

// Output Relay
//-------------
#define RelayPin 10

// ************************************************
// PID Variables and constants
// ************************************************

//Define Variables we'll be connecting to
double Setpoint;
double Raw;
double Input, Input0, Input1, InputLast;
double Output;

long MaxTime, MinTime;
double MaxTemp, MinTemp;

volatile long onTime = 0;

// pid tuning parameters
double Kp;
double Ki;
double Kd;

// EEPROM addresses for persisted data

//Specify the links and initial tuning parameters
PID myPID(&Input1, &Output, &Setpoint, Kp, Ki, Kd, DIRECT);

// 10 second Time Proportional Output window
int WindowSize = 10000;
unsigned long windowStartTime;

// ************************************************
// Auto Tune Variables and constants
// ************************************************
byte ATuneModeRemember=2;

double aTuneStep=500;
double aTuneNoise=1;
unsigned int aTuneLookBack=20;

boolean tuning = false;

PID_ATune aTune(&Input, &Output);

unsigned long lastInput = 0; // last button press

byte degree[8] = // define the degree symbol
{
B00110,
B01001,
B01001,
B00110,
B00000,
B00000,
B00000,
B00000
};

byte progress_0[8] = // define a progress symbol
{
B00000,
B00000,
B00000,
B00100,
B00000,
B00000,
B00000,
B00000
};

byte progress_1[8] = // define a progress symbol
{
B00000,
B00000,
B00100,
B01010,
B00100,
B00000,
B00000,
B00000
};

byte progress_2[8] = // define a progress symbol
{
B00000,
B00100,
B01010,
B10001,
B01010,
B00100,
B00000,
B00000
};

// The highest temperature we want to ztep/zoom up to
#define HIGH_TEMP 85

// The start for step/zoom
#define LOW_TEMP 35

// Splash screen interval
#define SPLASH_TIME 3000

// log every 30 seconds in TIME mode
#define LOG_INTERVAL_MS 30000

// log every 1 degree in TEMP mode
#define LOG_INTERVAL_TEMP 0.125

// smooth temperatures over 32 readings
#define SMOOTH_TEMP 32

// delay temperatures over 128 readings
#define DELAY_TEMP 128

// ************************************************
// States for state machine
// ************************************************
bool TestSequence = false; // true if the tests are to be run as a sequence
enum operatingState { OFF = 0, SETP, RUN, TUNE_P, TUNE_I, TUNE_D, AUTO, ZOOM_UP, STEP_UP };
operatingState opState = OFF;

// ************************************************
// States for logging
// ************************************************
enum loggingState { TIME = 0, TEMP };
loggingState logState = TIME;

// how we scan the buttons
bool buttonScan()
{
bool changed = false;

// make sure we're ready to read the button state by doing this often
changed |= SW_Left.update();
changed |= SW_Right.update();
changed |= SW_Up.update();
changed |= SW_Down.update();
changed |= SW_Shift.update();

{
lastInput = millis();
}

return changed;
}

void SetLEDColour(int colour)
{
digitalWrite(LED_R, (colour & 1));
digitalWrite(LED_G, (colour & 2));
digitalWrite(LED_B, (colour & 4));
}

// returns the number of pressed buttons
int buttonsPressed()
{
buttonScan();

int pressed = 0;

return pressed;
}

{
lcd.clear();
lcd.setCursor(0, 0);
}

void showActivity()
{
static byte i = 0;

lcd.setCursor(15,1);

i++;

if (i == 1) lcd.print(' ');
else if (i == 2) lcd.print('\2');
else if (i == 3) lcd.print('\3');
else if (i == 4) lcd.print('\4');
else if (i == 5) lcd.print('\3');
else
{
lcd.print('\2');
i = 0;
}
}

// ************************************************
// Setup and diSplay initial screen
// ************************************************
void setup()
{
Serial.begin(9600);

Serial.println();

// Initialize Relay Control:

pinMode(RelayPin, OUTPUT);    // Output mode to drive relay
digitalWrite(RelayPin, LOW);  // make sure it is off to start

// initialize LED
pinMode(LED_R, OUTPUT);
digitalWrite(LED_R, LOW); // make sure it is off to start

pinMode(LED_G, OUTPUT);
digitalWrite(LED_G, LOW); // make sure it is off to start

pinMode(LED_B, OUTPUT);
digitalWrite(LED_B, LOW); // make sure it is off to start

// set up the LCD's number of columns and rows:
lcd.begin(num_cols, num_rows);

// scan the buttons
buttonScan();

// Initialize LCD DiSplay
lcd.clear();
lcd.createChar(1, degree); // create degree symbol from the binary

lcd.createChar(2, progress_0); // and now the progress characters
lcd.createChar(3, progress_1);
lcd.createChar(4, progress_2);

SetLEDColour(VIOLET);

// Start up the DS18B20 One Wire Temperature Sensor

sensors.begin();
{
ErrorFail('A');
}

sensors.setResolution(tempSensor, 12);    // max resolution
sensors.setWaitForConversion(false);      // don't wait for a temperature

while (!ReadTemp()){};                       // wait for a read, we might get a reset value
while (!ReadTemp()){};                       // wait for a read, this one will be a real temp
InputLast = Input0 = Input1 = Input = Raw; // initial values
MaxTemp = MinTemp = Input;
MaxTime = MinTime = millis();

// Splash screen delay, but keep reading temperatures
for (long i = millis() + SPLASH_TIME; i > millis(); ReadTemp());

// Initialize the PID and related variables
myPID.SetTunings(Kp,Ki,Kd);

myPID.SetSampleTime(1000);
myPID.SetOutputLimits(0, WindowSize);

// Run timer2 interrupt every 15 ms
TCCR2A = 0;
TCCR2B = 1<<CS22 | 1<<CS21 | 1<<CS20;

//Timer2 Overflow Interrupt Enable
TIMSK2 |= 1<<TOIE2;
}

// ************************************************
// Timer Interrupt Handler
// ************************************************
SIGNAL(TIMER2_OVF_vect)
{
if (opState == OFF)
{
digitalWrite(RelayPin, LOW);  // make sure relay is off
}
else
{
DriveOutput();
}
}

// ************************************************
// Main Control Loop
//
// All state changes pass through here
// ************************************************
void loop()
{
// wait for button release before changing state
while (buttonsPressed() != 0) {}

lcd.clear();

switch (opState)
{
case OFF:
Off();
break;

case SETP:
Tune_Sp();
break;

case RUN:
Run();
break;

case TUNE_P:
TuneP();
break;

case TUNE_I:
TuneI();
break;

case TUNE_D:
TuneD();
break;

case ZOOM_UP:
ZoomUp();
break;

case STEP_UP:
StepUp();
break;
}
}

void PrintStatus()
{
lcd.setCursor(5, 1);
lcd.print(Input, 3);
lcd.write(1);
lcd.print("C ");
lcd.print(TestSequence ? "s" : " ");
}

// ************************************************
// Initial State - press RIGHT to enter setpoint
// ************************************************
void Off()
{
myPID.SetMode(MANUAL);
SetLEDColour(BLACK);
digitalWrite(RelayPin, LOW);  // make sure it is off
uint8_t buttons = 0;

TestSequence = false; // if we get back here, the sequence stops.

lcd.setCursor(0, 1);
lcd.print("Off");

{
buttonScan();

{
PrintStatus();
showActivity();
LogTemps();
}
}

{
TestSequence = true;
opState = ZOOM_UP; // Do a zoom test
}
{
opState = STEP_UP; // Do a step test
}
else
{
// Prepare to transition to the RUN state
sensors.requestTemperatures(); // Start an asynchronous temperature reading

//turn the PID on
myPID.SetMode(AUTOMATIC);
windowStartTime = millis();
opState = RUN; // start control
}
}

bool WaitForTemp(float Temp, int Power, bool DirUp, bool & LedOn, int LedCol)
{
bool AtTemp = false;
Setpoint = Temp;
onTime = Output = Power;
digitalWrite(RelayPin, Power > 0 );

{
buttonScan();

{
if (LedOn)
{
SetLEDColour(BLACK);
}
else
{
SetLEDColour(LedCol);
}
LedOn = !LedOn;

lcd.setCursor(2, 1);
lcd.print(Setpoint, 0);
lcd.print(" ");

PrintStatus();
showActivity();
LogTemps();

if (DirUp)
{
AtTemp = Input >= Setpoint;
}
else
{
AtTemp = Input <= Setpoint;
}
}
}

Setpoint = 0;
onTime = Output = 0;
digitalWrite(RelayPin, FALSE);

return AtTemp;
}

// ************************************************
// Zoom up.  left or right to stop
// zooms to 85C from current temp then goes to OFF
// any button will terminate
// ************************************************
void ZoomUp()
{
myPID.SetMode(MANUAL);
bool LedOn = true;

bool abort = false;

lcd.setCursor(0, 1);
lcd.print("ZU");

logState = TEMP;

while (buttonsPressed() != 0) {} // wait for button release

// drop temperature to 35C or less
if (!abort) abort = !WaitForTemp(LOW_TEMP, 0, false, LedOn, BLUE);

// Now zoom up to 85C
if (!abort) abort = !WaitForTemp(HIGH_TEMP, 10000, true, LedOn, RED);

// allow temperature to drop again to 35C
if (!abort) abort = !WaitForTemp(LOW_TEMP, 0, false, LedOn, BLUE);

if (abort)
{
if (SW_Up.read()) // up from zoom to step
{
opState = STEP_UP;
}
else if (SW_Down.read()) // down to off
{
opState = OFF;
}
else
{
opState = OFF;
}
}
else // run to end
{
if (TestSequence)
{
opState = STEP_UP; // if we're running a sequence then go to the next step
}
else
{
opState = OFF; // otherwise just end
}
}

logState = TIME;
}

// ************************************************
// Step up.  left or right to stop
// steps up to next 10+5 degrees, then allows the
// temp to fall 0.5 degrees, then repeat until 85C
// ************************************************
void StepUp()
{
myPID.SetMode(MANUAL);
bool LedOn = true;

bool AtTemp = false;  // we are not at temperature yet
int abort = false;    // we have not aborted
float EndTemp = LOW_TEMP - 1;   // initial target is 34C
boolean goingUp = false; // initial direction is DOWN

lcd.setCursor(0, 1);
lcd.print("SU");

logState = TEMP;

while (buttonsPressed() != 0) {} // wait for button release

while (!(abort||AtTemp))
{
if (!(abort = !WaitForTemp(EndTemp, goingUp ? 10000 : 0, goingUp, LedOn, goingUp ? RED : BLUE)))
{
if (goingUp)
{
EndTemp = EndTemp - 1;
}
else
{
Setpoint = EndTemp = ceil(Input/10)*10 + 5;
}

goingUp = !goingUp;
}

AtTemp = Setpoint > (HIGH_TEMP + 1); // final end point
}

if (!abort) abort = !WaitForTemp(LOW_TEMP, 0, false, LedOn, BLUE); // ramp back down

if (abort)
{
if (SW_Up.read()) // up from step to off
{
opState = OFF;
}
else if (SW_Down.read()) // down to zoom
{
opState = ZOOM_UP;
}
else
{
opState = OFF;
}
}
else // run to end
{
if (TestSequence)
{
opState = OFF; // if we're running a sequence then go to the next step (which is off)
}
else
{
opState = OFF; // otherwise just end
}
}

logState = TIME;
}

// ************************************************
// Setpoint Entry State
// UP/DOWN to change setpoint
// RIGHT for tuning parameters
// LEFT for OFF
// SHIFT for 10x tuning
// ************************************************
void Tune_Sp()
{
SetLEDColour(TEAL);
lcd.print(F("Set Temperature:"));
uint8_t buttons = 0;
while(true)
{
buttonScan();

float resolution = 0.1;
float increment = resolution;
{
increment *= 10;
}
{
opState = RUN;
return;
}
{
opState = TUNE_P;
return;
}
{
Setpoint += increment;
Setpoint = floor((Setpoint / resolution) + 0.5) * resolution;
delay(200);
}
{
Setpoint -= increment;
Setpoint = floor((Setpoint / resolution) + 0.5) * resolution;
delay(200);
}

if ((millis() - lastInput) > 3000)  // return to RUN after 3 seconds idle
{
opState = RUN;
return;
}
lcd.setCursor(0,1);
lcd.print(Setpoint, 1);
lcd.print(" ");

LogTemps();
DoControl();
}
}

// ************************************************
// Proportional Tuning State
// UP/DOWN to change Kp
// RIGHT for Ki
// LEFT for setpoint
// SHIFT for 10x tuning
// ************************************************
void TuneP()
{
SetLEDColour(TEAL);
lcd.print(F("Set Kp"));

uint8_t buttons = 0;
while(true)
{
buttonScan();

float increment = 1.0;
{
increment *= 10;
}
{
opState = SETP;
return;
}
{
opState = TUNE_I;
return;
}
{
Kp += increment;
Kp = floor(Kp + 0.5);
delay(200);
}
{
Kp -= increment;
Kp = floor(Kp + 0.5);
delay(200);
}
if ((millis() - lastInput) > 3000)  // return to RUN after 3 seconds idle
{
opState = RUN;
return;
}
lcd.setCursor(0,1);
lcd.print(Kp, 0);
lcd.print(" ");

LogTemps();
DoControl();
}
}

// ************************************************
// Integral Tuning State
// UP/DOWN to change Ki
// RIGHT for Kd
// LEFT for Kp
// SHIFT for 10x tuning
// ************************************************
void TuneI()
{
SetLEDColour(TEAL);
lcd.print(F("Set Ki"));

uint8_t buttons = 0;
while(true)
{
buttonScan();

float resolution = 0.01;
float increment = resolution;
{
increment *= 10;
}
{
opState = TUNE_P;
return;
}
{
opState = TUNE_D;
return;
}
{
Ki += increment;
Ki = floor((Ki / resolution) + 0.5) * resolution;
delay(200);
}
{
Ki -= increment;
Ki = floor((Ki / resolution) + 0.5) * resolution;
delay(200);
}
if ((millis() - lastInput) > 3000)  // return to RUN after 3 seconds idle
{
opState = RUN;
return;
}
lcd.setCursor(0,1);
lcd.print(Ki);
lcd.print(" ");

LogTemps();
DoControl();
}
}

// ************************************************
// Derivative Tuning State
// UP/DOWN to change Kd
// RIGHT for setpoint
// LEFT for Ki
// SHIFT for 10x tuning
// ************************************************
void TuneD()
{
SetLEDColour(TEAL);
lcd.print(F("Set Kd"));

uint8_t buttons = 0;
while(true)
{
buttonScan();
float resolution = 0.01;
float increment = resolution;
{
increment *= 10;
}
{
opState = TUNE_I;
return;
}
{
opState = RUN;
return;
}
{
Kd += increment;
Kd = floor((Kd / resolution) + 0.5) * resolution;
delay(200);
}
{
Kd -= increment;
Kd = floor((Kd / resolution) + 0.5) * resolution;
delay(200);
}
if ((millis() - lastInput) > 3000)  // return to RUN after 3 seconds idle
{
opState = RUN;
return;
}
lcd.setCursor(0,1);
lcd.print(Kd);
lcd.print(" ");

LogTemps();
DoControl();
}
}

void LogTemps()
{
// No previous log entry
static long lastLogTime = 0;
static float lastLogTemp = 0;

if (logState == TIME)
{
// periodically log to serial port in csv format
if (millis() - lastLogTime > LOG_INTERVAL_MS)
{
LogTempLine();

while(millis() - lastLogTime > LOG_INTERVAL_MS)
{
lastLogTime += LOG_INTERVAL_MS;
}
}
}
else
{
// periodically log to serial port in csv format
if (abs(Input - lastLogTemp) >= LOG_INTERVAL_TEMP)
{
LogTempLine();

lastLogTemp = floor((Input / LOG_INTERVAL_TEMP) + (LOG_INTERVAL_TEMP / 2)) * LOG_INTERVAL_TEMP;
}
}
}

void LogTempLine()
{
unsigned long m = millis();

int th = m/3600000; // hours
m -= (long)th*3600000;

int tm = m/60000; // minutes
m -= (long)tm*60000;

int ts = (m + 50) / 100; // tenths of seconds)

if (th < 10) Serial.print('0');        // Time since start
Serial.print(th);
Serial.print(':');
if (tm < 10) Serial.print('0');
Serial.print(tm);
Serial.print(':');
if (ts < 100) Serial.print('0');
Serial.print((float)ts/10, 1);
Serial.print(",");

Serial.print(opState);
Serial.print(",");

Serial.print(",");
Serial.print(",");
Serial.print((float)Output/100,2);      // output power %
Serial.print(",");
Serial.print(Setpoint,1);               // target temperature
Serial.print(",");

Serial.print(myPID.GetKp(),0);          // PID Parameters
Serial.print(",");
Serial.print(myPID.GetKi());
Serial.print(",");
Serial.print(myPID.GetKd());
Serial.print(",");

Serial.print(Input0,3);                  // Delayed temperature
Serial.print(",");

Serial.print(Input1,3);                  // predicted temperature
Serial.print(",");

Serial.print(DELAY_TEMP);              // constant used to delay temperature
Serial.print(",");

Serial.print(Input - InputLast,3);      // delta T for last measurement
Serial.print(",");

Serial.print(millis());      // current time
Serial.print(",");

Serial.print(MaxTemp,3);      // highest temp since last reading
Serial.print(",");

Serial.print(MaxTime);      // time of highest temp
Serial.print(",");

Serial.print(MinTemp,3);      // lowest temp since last reading
Serial.print(",");

Serial.print(MinTime);      // time of lowest temp
Serial.println();

MaxTemp = MinTemp = Input;
MaxTime = MinTime = millis();

InputLast = Input;
}

// ************************************************
// PID COntrol State
// SHIFT and RIGHT for autotune
// RIGHT - Setpoint
// LEFT - OFF
// ************************************************
void Run()
{
bool LedOn = false;
double OldSetpoint = Setpoint;

while (buttonsPressed() != 0) {} // wait for button release

// first slew to close to the required temperature.
lcd.setCursor(0, 1);
lcd.print("Slew");

if (Input > Setpoint + 0.25)
{
myPID.SetMode(MANUAL);
WaitForTemp(Setpoint + 0.25, 0, false, LedOn, BLUE);
Setpoint = OldSetpoint;
myPID.SetMode(AUTOMATIC);
}
else if (Input < Setpoint - 5)
{
myPID.SetMode(MANUAL);
WaitForTemp(Setpoint - 5, 10000, true, LedOn, RED);
Setpoint = OldSetpoint;
myPID.SetMode(AUTOMATIC);
}

// set up the LCD's number of rows and columns:
lcd.clear();
lcd.print(F("Sp: "));
lcd.print(Setpoint, 1);
lcd.write(1);
lcd.print(F("C    "));

SaveParameters();
myPID.SetTunings(Kp,Ki,Kd);

uint8_t buttons = 0;
while(true)
{
setBacklight();  // set backlight based on state

buttonScan();
&& (abs(Input - Setpoint) < 0.5))  // Should be at steady-state
{
StartAutoTune();
}
{
opState = SETP;
return;
}
{
opState = OFF;
return;
}

DoControl();

lcd.setCursor(0,1);
lcd.print(Input, 3);
lcd.write(1);
lcd.print(F("C : "));

float pct = map(Output, 0, WindowSize, 0, 1000);
lcd.setCursor(10,1);
lcd.print(F("      "));
lcd.setCursor(10,1);
lcd.print(pct/10, 0);
lcd.print("%");

showActivity();

lcd.setCursor(15,0);
if (tuning)
{
lcd.print("T");
}
else
{
lcd.print(" ");
}

LogTemps();

delay(100);
}
}

{
static unsigned long lastTime = 0;

if (sensors.isConversionAvailable(0))
{
Raw = sensors.getTempC(tempSensor);

if ((abs(Raw - lastRead) > 0.001) || (lastTime+750 <= millis()))
{
lastTime = millis();

Input = ((Input * (SMOOTH_TEMP - 1)) + Raw)/SMOOTH_TEMP; // provide a slower response, but smoother
Input0 = ((Input0 * (DELAY_TEMP - 1)) + Raw)/DELAY_TEMP; // an even slower response to allow prediction of future temp
Input1 = 3*Input - 2*Input0; // prediction of future temp

if (Input > MaxTemp)
{
MaxTemp = Input;
MaxTime = millis();
}

if (Input < MinTemp)
{
MinTemp = Input;
MinTime = millis();
}

sensors.requestTemperatures(); // prime the pump for the next one - but don't wait

return true;
}
}
else
{
ErrorFail('R');
delay(1000);
}

return false;
}

void ErrorFail(char E)
{
// try to shut down first
myPID.SetMode(MANUAL);
Setpoint = 0;
Output = onTime = 0;
digitalWrite(RelayPin, LOW);  // make sure it is off

// Display the failure
lcd.setCursor(0, 1);
lcd.print(F("SENSOR FAILURE="));
lcd.print(E);

// Then log the faiure
Serial.print("SENSOR FAILURE=");
Serial.println(E);

// Make a unique display and loop forever so nothing else happens
while (true)
{
digitalWrite(RelayPin, LOW);  // make sure it is off (belt and braces)

SetLEDColour(RED);
delay(250);
SetLEDColour(GREEN);
delay(250);
SetLEDColour(BLUE);
delay(250);
}
}

// ************************************************
// Execute the control loop
// ************************************************
void DoControl()
{

if (tuning) // run the auto-tuner
{
if (aTune.Runtime()) // returns 'true' when done
{
FinishAutoTune();
}
}
else // Execute control algorithm
{
myPID.Compute();
}

// Time Proportional relay state is updated regularly via timer interrupt.
onTime = Output;
}

// ************************************************
// Called by ISR every 15ms to drive the output
// ************************************************
void DriveOutput()
{
unsigned long now = millis();
// Set the output
// "on time" is proportional to the PID output
if(now - windowStartTime > WindowSize)
{ //time to shift the Relay Window
windowStartTime += WindowSize;
}
if((onTime > 100) && (onTime > (now - windowStartTime)))
{
digitalWrite(RelayPin,HIGH);
}
else
{
digitalWrite(RelayPin,LOW);
}
}

// ************************************************
// Set Backlight based on the state of control
// ************************************************
void setBacklight()
{
if (tuning)
{
SetLEDColour(VIOLET); // Tuning Mode
}
else if (abs(Input - Setpoint) > 1.0)
{
SetLEDColour(RED);  // High Alarm - off by more than 1 degree
}
else if (abs(Input - Setpoint) > 0.2)
{
SetLEDColour(YELLOW);  // Low Alarm - off by more than 0.2 degrees
}
else
{
SetLEDColour(WHITE);  // We're on target!
}
}

// ************************************************
// Start the Auto-Tuning cycle
// ************************************************

void StartAutoTune()
{
// REmember the mode we were in
ATuneModeRemember = myPID.GetMode();

// set up the auto-tune parameters
aTune.SetNoiseBand(aTuneNoise);
aTune.SetOutputStep(aTuneStep);
aTune.SetLookbackSec((int)aTuneLookBack);
tuning = true;
}

// ************************************************
// ************************************************
void FinishAutoTune()
{
tuning = false;

// Extract the auto-tune calculated parameters
Kp = aTune.GetKp();
Ki = aTune.GetKi();
Kd = aTune.GetKd();

// Re-tune the PID and revert to normal control mode
myPID.SetTunings(Kp,Ki,Kd);
myPID.SetMode(ATuneModeRemember);

// Persist any changed parameters to EEPROM
SaveParameters();
}

// ************************************************
// Save any parameter changes to EEPROM
// ************************************************
void SaveParameters()
{
{
}
{
}
{
}
{
}
}

// ************************************************
// ************************************************
{

// Use defaults if EEPROM values are invalid
if (isnan(Setpoint))
{
Setpoint = 44.0;
}
if (isnan(Kp))
{
Kp = 1850;  // this is for me (850 was default)
}
if (isnan(Ki))
{
Ki = 1.0; // so far for me, 0.5 is default
}
if (isnan(Kd))
{
Kd = 0.0; // so far 0.1 was default
}
}

// ************************************************
// Write floating point values to EEPROM
// ************************************************
{
byte* p = (byte*)(void*)&value;
for (int i = 0; i < sizeof(value); i++)
{
}
}

// ************************************************
// Read floating point values from EEPROM
// ************************************************
{
double value = 0.0;
byte* p = (byte*)(void*)&value;
for (int i = 0; i < sizeof(value); i++)
{
}
return value;
}
I've made a few small changes in some of the libraries, so if you actually want to compile this contact me for the updated libraries.

The output drives a SSR which turns the slow cooker on and off.

A display tells you what's going on:

The logging looks like this:

But you can't read that, so here is a small sample:

Code:
00:00:30.0,0,30.125,30.172,0.00,61.0,1850,1.00,0.00,30.183,30.150,128,-0.015,30028,30.187,918,30.172,30008
00:01:00.3,0,30.125,30.138,0.00,61.0,1850,1.00,0.00,30.168,30.080,128,-0.034,60353,30.172,30060,30.138,60333
00:01:30.5,0,30.125,30.122,0.00,61.0,1850,1.00,0.00,30.154,30.058,128,-0.016,90506,30.138,60387,30.122,89883
00:02:00.6,2,30.062,30.091,100.00,54.0,1850,1.00,0.00,30.134,30.004,128,-0.031,120622,30.122,94273,30.091,120598
00:02:30.3,2,30.062,30.071,100.00,54.0,1850,1.00,0.00,30.115,29.981,128,-0.020,150273,30.091,120658,30.071,150249
00:03:00.0,2,30.062,30.062,100.00,54.0,1850,1.00,0.00,30.100,29.987,128,-0.008,180063,30.071,150310,30.062,172592
00:03:30.6,2,30.062,30.056,100.00,54.0,1850,1.00,0.00,30.087,29.996,128,-0.006,210579,30.062,180101,30.054,201588
00:04:00.4,2,30.062,30.060,100.00,54.0,1850,1.00,0.00,30.079,30.020,128,0.003,240368,30.060,240347,30.054,216172
00:04:30.7,2,30.125,30.077,100.00,54.0,1850,1.00,0.00,30.079,30.073,128,0.017,270763,30.077,270740,30.060,240406

The columns are:

1) Time since startup hh:mm:ss.s
2) Operating mode
5) Output power (%)
6) Target temperature
7) P gain
8) I gain
9) D gain
12) Temperature delay constant
13) Change in temperature since last reading (from (4))
14) Current time (ms)
15) Highest temp (4) since last reading
16) Time of highest temp
17) Lowest temp (4) since last reading

Here is the temperature profile for the slow cooker at 100% power.

As you can see, it takes *hours* to get up to its practical peak temperature of 85C. Fortunately sousvide of meat is done at lower temperatures (55C to 70C is the main range)

4) Steps in the cooking of the meat (from raw to meal in about 50 hours!)

Meat as purchased:

And then seasoned:

The seasoning is about 2 teaspoons of Shan Meat Masala spice. Very simple but very nice.

The two pieces of meat are stacked together to make a thicker "chunk" that's easier t measure the elasticity of:

Then it was placed in a sous vide bag:

It's actually cut from a roll of plastic "tube" and one end sealed.

At this point I measured the elasticity and the weight. The bag weighs about 10g and the total weight is 418g. That means the shop weighed the meat *AND* the packaging!

Then it was vacuum sealed:

Again, the elasticity was measured. This was to determine how the vacuum sealing affected the elasticity.

Then the meat was placed in the fridge overnight as the sous vide cooker was given plenty of time to come up to temperature.

After being taken out of the fridge:

And another elasticity measure to see what cooling did...

Then it was placed in the sous vide bath for 3 hours. At the end of 3 hours:

Then after 6.75 hours:

After 36 hours:

Contents of the bag, about 80ml of juices. Actually, only 60ml of juices and 20ml of water that entered the bag via osmosis(?).

The meat out of the bag. The colour of this meat is due to the seasoning, not the cooking.

Cut in half, the meat is edge to edge medium-rare.

See! Yum...

Served on a column of rice and with a large scoop of sauce on top.

And it tasted *really* good. Super tender and tasty.

Recipe:

1) Season 400g of gravy beef with 2tsp of Shan meat masala powder.
2) Seal in a bag and sous vide for 33 hours at 56.0C
3) Thinly slice 2 medium onions and fry with 1/2 tsp of salt and some oil until browned.
4) Add 1 heaped tsp chopped ginger and 2 tsp finely chopped garlic. Fry for a few minutes.
5) Add one tin of diced tomatoes and deglaze the pan.
6) Simmer on a low heat adding water as needed to keep the mix the consistency of a tomato paste.
7) Cook 2 cups of rice.
8) Remove meat from sous vide. Empty the juices into the sauce.
9) Prepare a column of rice by packing it into a biscuit cutter, egg ring, etc
10) Cut meat in half and place a piece on the rice column.
11) take a scoop of sauce and pile on the meat trying not to hide the cut edge of the meat.

Serve and eat immediately.

5) Raw data (data logging, weight, stress, strain, time, etc.)

Here is a dump of various data files used. If you're interested in them in more detail, please ask.

Rise.zip contains the raw data file and a spreadsheet containing a graph of the time vs temperature characteristics of my slow cooker at 100% power.

calib.zip contains the logging of the calibration run of my controller. It takes a little over 28 hours to complete. The steps are:

1) Full power to 85C
2) Zero power to 35C
3) Step rise from 35C to 85C
4) Zero power to 35C

The step rise steps the temperature up in 10C steps. Each step proceeds as follows:
1) Zero power until the temperature falls 1C below the start temp
2) 100% power until the temp rises to the next step.

The step rise test looks at overshoot and undershoot at various temperatures.

I intend to perform analysis of this data to determine appropriate P and I values for the controller. The ideal temperatures actually vary with temperature so a final implementation of the controller will probably trim the PID settings to suit the target temperature.

Compress test.zip is a spreadsheet listing the change in height of the plunger with 1/2 turns of the wing nut.

6) Results

The quick results:

1) Elasticity rises
2) Ignoring bad measurements it looks like it increases at a decreasing rate.
3) The mass increased! (osmosis of water through the bag?)

In more detail (see the attached spreadsheet for the raw data):

The change in elasticity with time can be described by the equation

Y = 670 X + 67700

Where Y is the elasticity in Pa and X represents the cooking time at 56C in hours.

Approximately 70% of the variation in elasticity is explained by this equation. If we ignore the three readings flagged earlier the equation is very similar, but over 95% of the variation is explained.

There is no reliable indication that the elasticity of the meat starts to decrease, and perhaps a longer cooking time would be required to show this (if indeed it happens).

The results are unsurprising because at 56C the enzymes cease activity (due to denaturing) and cannot be responsible for collagen breakdown. The breakdown of collagen to gelatin requires a significantly higher temperature.

There are lots of things which could be done to either improve this experiment or to investigate further.

I started with the Arduino PID_v1.01 library (see here). This has now need superseded, but I will attach it below in case you need it. I recommend you use the newer version and apply the changes that I will list below:

PID_v1.h
Code:
CWD 2012 cat 1-2 3.15 vs 3.17
Produced: 15/10/2014 7:10:31 AM

Mode:  Differences, With Context, Ignoring Unimportant
Right file: C:\Users\Steve\Dropbox\Arduino\Arduino\libraries\PID_v1\PID_v1.h
17 17  //commonly used functions **************************************************************************
18 18  PID(double*, double*, double*,  // * constructor.  links the PID to the Input, Output, and
19 19  double, double, double, int);  //  Setpoint.  Initial tuning parameters are also set here
20 20
21 21  void SetMode(int Mode);  // * sets PID to either Manual (0) or Auto (non-0)
22 22
------------------------------------------------------------------------
23  void Compute();  // * performs the PID calculation.  it should be
23  bool Compute();  // * performs the PID calculation.  it should be
------------------------------------------------------------------------
24 24  //  called every time loop() cycles. ON/OFF and
25 25  //  calculation frequency can be set using SetMode
26 26  //  SetSampleTime respectively
27 27
28 28  void SetOutputLimits(double, double); //clamps the output to a specific range. 0-255 by default, but
29 29                       //it's likely the user will want to change this depending on
------------------------------------------------------------------------
------------------------------------------------------------------------
69 69  double *mySetpoint;  //  PID, freeing the user from having to constantly tell us
70 70  //  what these values are.  with pointers we'll just know.
71 71
72 72    unsigned long lastTime;
73 73    double ITerm, lastInput;
74 74
------------------------------------------------------------------------
75     int SampleTime;
75    unsigned long SampleTime;
------------------------------------------------------------------------
76 76    double outMin, outMax;
77 77    bool inAuto;
78 78 };
79 79 #endif
80 80
------------------------------------------------------------------------

And PID_v1.cpp
Code:
CWD 2012 cat 1-2 3.15 vs 3.17
Produced: 15/10/2014 7:18:21 AM

Mode:  Differences, With Context
Right file: C:\Users\Steve\Dropbox\Arduino\Arduino\libraries\PID_v1\PID_v1.cpp
1  1  /**********************************************************************************************
------------------------------------------------------------------------
2  * Arduino PID Library - Version 1
2  * Arduino PID Library - Version 1.0.1
------------------------------------------------------------------------
3  3  * by Brett Beauregard <[email protected]> brettbeauregard.com
4  4  *
------------------------------------------------------------------------
------------------------------------------------------------------------
6  6  **********************************************************************************************/
7  7
------------------------------------------------------------------------
8  #include <WProgram.h>
8  #if ARDUINO >= 100
9  #include "Arduino.h"
10 #else
11  #include "WProgram.h"
12 #endif
13
------------------------------------------------------------------------
9  14 #include <PID_v1.h>
10 15
11 16 /*Constructor (...)*********************************************************
12 17  *  The parameters specified here are those for for which we can't set up
13 18  *  reliable defaults, so we need to have the user set them.
14 19  ***************************************************************************/
15 20 PID::PID(double* Input, double* Output, double* Setpoint,
16 21  double Kp, double Ki, double Kd, int ControllerDirection)
17 22 {
------------------------------------------------------------------------
23
24  myOutput = Output;
25  myInput = Input;
26  mySetpoint = Setpoint;
27    inAuto = false;
28
------------------------------------------------------------------------
18 29    PID::SetOutputLimits(0, 255);         //default output limit corresponds to
19 30                          //the arduino pwm limits
20 31
21 32  SampleTime = 100;               //default Controller Sample Time is 0.1 seconds
22 33
23 34  PID::SetControllerDirection(ControllerDirection);
24 35  PID::SetTunings(Kp, Ki, Kd);
25 36
26 37  lastTime = millis()-SampleTime;
------------------------------------------------------------------------
27  inAuto = false;
28  myOutput = Output;
29  myInput = Input;
30  mySetpoint = Setpoint;
31
------------------------------------------------------------------------
32 38 }
33 39
34 40
35 41 /* Compute() **********************************************************************
36 42  *  This, as they say, is where the magic happens.  this function should be called
37 43  *  every time "void loop()" executes.  the function will decide for itself whether a new
------------------------------------------------------------------------
38  *  pid Output needs to be computed
44  *  pid Output needs to be computed.  returns true when the output is computed,
45  *  false when nothing has been done.
------------------------------------------------------------------------
39 46  **********************************************************************************/
------------------------------------------------------------------------
40  void PID::Compute()
47 bool PID::Compute()
------------------------------------------------------------------------
41 48 {
------------------------------------------------------------------------
42  if(!inAuto) return;
49  if(!inAuto) return false;
------------------------------------------------------------------------
43 50  unsigned long now = millis();
------------------------------------------------------------------------
44  int timeChange = (now - lastTime);
51  unsigned long timeChange = (now - lastTime);
------------------------------------------------------------------------
45 52  if(timeChange>=SampleTime)
46 53  {
47 54  /*Compute all the working error variables*/
48 55     double input = *myInput;
49 56  double error = *mySetpoint - input;
50 57  ITerm+= (ki * error);
------------------------------------------------------------------------
------------------------------------------------------------------------
59 66  else if(output < outMin) output = outMin;
60 67     *myOutput = output;
61 68
62 69  /*Remember some variables for next time*/
63 70  lastInput = input;
64 71  lastTime = now;
------------------------------------------------------------------------
72     return true;
------------------------------------------------------------------------
65 73  }
------------------------------------------------------------------------
74  else return false;
------------------------------------------------------------------------
66 75 }
67 76
68 77
69 78 /* SetTunings(...)*************************************************************
70 79  * This function allows the controller's dynamic performance to be adjusted.
71 80  * it's called automatically from the constructor, but tunings can also
------------------------------------------------------------------------

Clearly I am not comparing against the actual original version I used. (I didn't change the license)

My changes allow the PID controller to maintain the state it was in, run for longer without the time wrapping, and return a state.

For those into sousvide:

Squid - 57C 3 hrs
Chicken Breast - 63C 3 to 4 hours

For fun, Lamb 48 hours 55C ends up with a consistency such that you can spread it with a knife. Horrible texture for meat, but possibly an alternative to pate.

It seems I used a very old version if the Bounce2 Library. I think I had to modify it in order to get the original code (the Adafruit code) to compile.

I have compared this code to the current version (here) but the differences are too many. It is also interesting to note that the code has changed from a GPL to an MIT license.

In any case, my entire library is attached - see discussion thread.

Here are all the non-arduino libraries I'm using (see discussion thread). They are probably old and possibly modified. They may help if you're having trouble compiling.
hevans1944 and Ian like this.
Continue to site