Introduction to Arduino PID Simulator
This project is an Arduino PID Simulator. Basically, the Arduino will send a process variable to a PID controller. In this case, the signal is 0 to 5v. Additionally, the Arduino reads the control variable from the PID controller, and simulates a real world process. Realize this is not a PID controller. This is a field device to simulate a process. I’ve written this project so simulate various types of processes.
You can adjust load, losses,, lag, delay, process time, and noise. Additionally, there is a setting to adjust the process time. Because some real loads might take a very long time to tune, this project has the ability to speed up those processes for the purpose of training. Unlike some software simulators, this project already has some inherent noise. This is due to both the resolution of the analog output, converting PWM to analog, and some small amount of line noise. This project is for the purpose of training and practice only.
I would recommend building two of these units if you wish to practice cascading control. The main difference with the second controller is that you will use a 5v Zener instead of one of the 5k resistors to limit the incoming control variable to 5v.
What you Need
For this project, you will need a few components.
- Arduino Board (or equivalent)
- Two 5K resistors (for the voltage divider circuit on the Control Variable) Use these if you anticipate a 10v signal on the control variable. These resistors will be in series from the ControlVariable input to ground. Take your CV signal from the point between the two resistors. This will effectively be a 0 to 5v signal.
- 3.9k Resistor
- 1Uf Capacitor (The resistor and capacitor are to convert the PWM to analog)
- If you want to use Feed Forward, you need an additional 3.9k Resistor with another .1uF Capacitor.k
- A TM1638 Lock & Key unit. This is a display with 8 segments, 8 buttons, and 8 LED’s. Strobe connects to GPIO4, Clock to 6, and Data (DIO) to 7.
- You also need some jumpers, and wire to connect things together.
- A PID controller, such as a ControlLogix, SLC, or PLC-5 with Analog Input and Output ability (voltage)
- A Breadboard
- For Cascading, you also need a 5v Zener Diode. In this case, you would build a second simulator, and the Control Variable would have a 5k resistor in series with the reverse biased Zener diode to ground. Your CV signal will come from the point between the Zener and the 5k resistor.
- You can filter the PV, and FF with a 100uF to 1000uF Capacitor. 100uF gives a faster response with a little more noise, whereas 1000uF gives a slower response and a smoother signal.
- If you build a second unit for cascading, I would also recommend using a 220uF to 1000uF capacitor across the Control Variable input. For cascading, wire the PV of the first simulator to the CV of the second simulator.
- Note, your PID controller, such as a PLC will only write to the ControlVariable of the first simulator. The PID controller will then read the Process Variable, and Feed Forward as analog inputs.
In my own project, I just used an Atmega328 processor with a 16MHz resonator. This saved some money vs. purchasing an Arduino board.
How the Arduino PID Simulator works
Compared to software simulators, this simulator needs a real voltage for the control variable. Keep in mind, your limit is 5v. If you plant to use a 10v output, simply set up a voltage divider. I’ve tried to keep this controller as simple as possible to make it easy to customize the code.
- To begin with, the Arduino reads the CV from the controller.
- This CV goes through an array of 100 elements.
- At this time, the lag setting will choose which element of the array to use. A lower setting is faster. Likewise, a higher lag setting is slower.
- Next, based on the CV, we’ll add 1 unit to the bulkpv variable for each percent of the CV per time unit.
- On the other hand, loss and load will subtract from the bulkpv. This is a sliding scale. For example, if the PV is at 0, even though loss is 100, there will be no loss. This is because if there is no pressure or temperature, the loss is minimum. Likewise, the load works the same way.
- At this point, the bulkpv variable goes into another array. The delay variable selects which element of this array to use. A lower delay will select a more recent PV to use.
- After that, we inject noise into the PV. This is simply a random value based on the noise setting. For example, if the PV is 50, and the noise is 5, you will get a randomized value between 45 and 55 for the PV.
- At this time, the program writes the PV to the analog output channel of the Arduino. Obviously, this will be the analog input of your PID controller.
- ** The Test button allows you to inject a PV of 0 and PV of 100. This is to calibrate your scaling in the controller. In addition, you can change the process time. For example, a base setting of 60 could inject up to 100 units per minute into the bulkpv, and remove the same with combined loss and load at 100. If you press the test button again, you have the option to reset the Atmega328.
- ** If you go to your Serial Monitor, you will see different values of the process. The Serial Monitor updates every 1 Second. Set it at 9600 baud.
Tuning the PID
To illustrate, you simply tune this PID like any other. As you cause disturbances, simply increase the controller gain until it becomes unstable. At this time, record the natural period of the oscillations (peak to peak). Next, cut the Kp in half. You will use the natural period to calculate the starting Integral and Derivative settings. Most of the time, however, you may not need derivative. It might, in fact, be detrimental to your process. Especially if you have a noisy process variable.
For information on tuning PID, check out the CotnrolLogix PID post. It’s important to realize that different controllers have different units for your gains. For example, if your controller uses minutes per repeat, the natural period itself is a good starting point for integral. Derivative would be 1/8th of that in minutes. For other units, you will have to convert the values to match your processor, and PID equation type.
Note: To eliminate the noise on the simulator’s PV, you could add a 1000uF capacitor across the PV terminals. Be sure the polarity is correct.
Putting together the Arduino PID Simulator
Here, I am mounting the TM1638 display unit into a 3d printed box. I simply found a good TM1638 case and project box on thingiverse. You might use tinkercad to to put your own face on the box as I did in this case.
In this case, I just put an atmega328 onto prototype paper.
Definitions I’ve used for the Arduino PID Simulator
Load: The amount of load on the system. Obviously, the more load you have, the more output you will need. In this case, the range is 0 to 100%.
Lag: Basically, this is the process lag. In the simulator, an array samples the control variable. The last X samples will be averaged, while X equals the lag setting. For example, a lag setting of 50 creates a lag time of 5 seconds.
Loss: This will simulate ambient losses. The range is 0 to 100%.
Noise: The noise setting is the % of random noise added to the process variable.
Delay: In this case, we simulate the delay in reading the feedback (the process variable).
Test: There are several options under test that I did not have enough buttons for on the display. Initially, the first step of test will send out a control variable of 0%. In the same way, pressing test again sends out a control variable of 100%. This allows you to calibrate the controller. Other settings are the time base, forward acting, and reset. The time base is essentially the number of of real seconds in which one minute passes on the simulator.
Process Variable: This is the feedback from the simulator, and connects to the controllers analog input.
Control Variable: This is the analog output from the controller to the simulator.
Feed Forward: This is simply the load percentage, and connects to another analog input on the controller.
Here is the response under 80% load with otherwise default settings. Expect a little bit of noise since the Atmega328 has a fairly low resolution, and we are using a 0 to 5v signal for the process variable.
Cascading Configuration
Keep in mind, that if you wish to use a cascading configuration, you need a second controller. Remember the changes on the second controller. Instead of a voltage divider for the PV, we’ll use a 5K resistor in series with a 5v Zener diode. This just protects the simulator from over voltage. Additionally, you can add a 220uF to 1000uF capacitor across the incoming Control Variable. This just filters the PWM signal from the first simulator. Connect the PV of the first controller to the CV of the second controller.
The code for the Arduino PID Simulator
Although I’m sure there are better ways to write the code, this seems to work well. I’m sure I will be changing the code, and updating this in the future. The code is simple, and should be easy to follow. Some of the variables are not currently in use anymore. I will remove them in the future if I decide not to use them.
If you are using two simulators in a cascading configuration, download to the project to the first controller. Next, change the standalone value to 0, then download to the second controller. This simply changes the default settings that are better for the cascading controller.
// Written by Ricky Bryce with addition of TM1638Plus library;
// Version 0.5.3
//******* Set this definition before you download ********// Normal 1
#define standalone 1 //1 for Standalone or slave, 0 for master in cascading loop
//********************************************************//
#if standalone != 1
// Standalone or Slave Mode
int timeunits = 2; // Default 2
int timeconstant = 500; // default 500
int processtype = 1;
int lag = 0; // default 1
#else
// Level Mode
int timeunits = 2; // default 2
int timeconstant = 250; // default 250
int processtype = 0;
int lag = 15; // default 15
#endif
// Default Settings for Simulation
int forwardacting = 0; // default 0 (Reverse acting)
int load = 50; // default 50
int loss = 10; // default 10
int pvnoise = 0; // default 0
int pvdelay = 0; // default 1
int maxpv = 120;
// Display
char displayformat[13];
int cvdisplay = 0;
int pvdisplay = 0;
// Buttons / Pins
int resetPin = 12;
int processvariablePin = 9;
int feedforwardPin = 10;
uint8_t tmbuttons = 0;
int decbutton = 0;
int prevtmbuttons = 0;
// Note: TM1638 Strobe:4 Clock: 6 DIO: 7
// LED wired to GPIO 13 for future use
// Modes
int mode = 0;
int prevmode = 0;
int teststep = 0;
int lockchannels = 0;
bool modeflag = 0;
int modereq = 0;
int resetproc = 0;
// PID Variables
float controlvariable = 50;
float processvariable = 0;
float actualpower = 0;
float actualload = 0;
float bulkpv = 0;
long lagcvsum = 0;
float pvafterdelay = 0;
float cvafterlag = 0;
float pvafternoise = 0;
float pvdelayarray[101];
float cvlagarray[101];
float loadeffect = 0;
// Timers
long currentmillis = 0;
long prevmillis = 0;
long lagdelaymillis = 0;
long displaymillis = 0;
long calculationmillis = 0;
long pvdelaymillis = 0;
long buttonrepeatmillis = 0;
long serialmillis = 0;
#include <TM1638plus.h>
#define STROBE_TM 4 // strobe = GPIO connected to strobe line of module
#define CLOCK_TM 6 // clock = GPIO connected to clock line of module
#define DIO_TM 7 // data = GPIO connected to data line of module
bool high_freq = false; //default false, If using a high freq CPU > ~100 MHZ set to true.
//Constructor object
TM1638plus tm(STROBE_TM, CLOCK_TM , DIO_TM, high_freq);
void setup() {
digitalWrite(resetPin, HIGH);
delay(200);
pinMode(resetPin, OUTPUT);
pinMode(feedforwardPin, OUTPUT);
pinMode(processvariablePin, OUTPUT);
Serial.begin(9600);
delay(100);
Serial.println("--Comms UP -- PID Simulation -- Reverse Acting--");
tm.displayBegin();
tm.brightness(1);
tm.displayText("pid rdy ");
delay(1000);
}
void loop() {
currentmillis = millis();
tmbuttons = tm.readButtons();
if (tmbuttons == 0) {
decbutton = 0;
prevmillis = currentmillis;
}
if (tmbuttons != prevtmbuttons) {
switch (tmbuttons)
{
case 0: decbutton = 0; break; // No Buttons Pressed
case 1: decbutton = 1; break; // Change Load
case 2: decbutton = 2; break; // Change Lag
case 4: decbutton = 3; break; // Change Loss
case 8: decbutton = 4; break; // Change Noise
case 16: decbutton = 5; break; // Change Delay
case 32: decbutton = 6; break; // Run Test
case 64: decbutton = 7; break; // Up Button
case 128: decbutton = 8; break; // Down Button
}
} else {
decbutton = 0;
}
prevtmbuttons = tmbuttons;
//set up repeating up/down keys
if (((currentmillis - prevmillis) > 1000) and ((tmbuttons == 64) or (tmbuttons == 128))) {
if ((tmbuttons == 64) & ((currentmillis - buttonrepeatmillis) > 40)) {
decbutton = 7;
buttonrepeatmillis = currentmillis;
}
if ((tmbuttons == 128) & ((currentmillis - buttonrepeatmillis) > 40)) {
decbutton = 8;
buttonrepeatmillis = currentmillis;
}
} else {
buttonrepeatmillis = currentmillis;
}
if ((decbutton > 0) & (decbutton < 7)) {
mode = decbutton;
if (mode != prevmode) {
modeflag = 0;
tm.setLED((mode - 1), 1);
for (int x = 0; x < 7; x++) {
if (x != (mode - 1)) {
tm.setLED(x, 0);
}
}
prevmode = mode;
}
}
switch (mode)
{
case 0: modePID(); break;
case 1: modeLoad(); break;
case 2: modeLag(); break;
case 3: modeLoss(); break;
case 4: modeNoise(); break;
case 5: modeDelay(); break;
case 6: modeTest(); break;
}
if (mode == 0) {
for (int x = 0; x < 7; x++) {
tm.setLED(x, 0);
}
}
// Unlock PV channel if not in test mode.
if (mode != 6) {
teststep = 0;
lockchannels = 0;
}
if ((currentmillis - calculationmillis) > timeconstant) {
CalculateLoad();
CalculatePower();
CalculateLoss();
CalculateNoise();
ProcessPID();
WriteSerial();
calculationmillis = currentmillis;
}
if ((currentmillis - lagdelaymillis) > (timeconstant / 100)) {
CalculateLagDelay();
}
}
//********** Modes **********//
void modeLoad () {
int mymode = 1;
tm.setLED((mymode - 1), 1);
if (decbutton == 0) {
modeflag = 1;
}
sprintf(displayformat, "load %03d", load);
tm.displayText(displayformat);
if (decbutton == 7) {
if (load < 100) {
load ++;
}
} else if (decbutton == 8) {
if (load > 0) {
load --;
}
}
if ((decbutton == mymode) & (modeflag == 1)) {
modeflag = 0;
mode = 0; // Switch to PID Mode
tm.setLED((mymode - 1), 0);
}
}
void modeLag () {
int mymode = 2;
tm.setLED((mymode - 1), 1);
if (decbutton == 0) {
modeflag = 1;
}
sprintf(displayformat, " lag %03d", lag);
tm.displayText(displayformat);
if (decbutton == 7) {
if (lag < 100) {
lag ++;
}
} else if (decbutton == 8) {
if (lag > 0) {
lag --;
}
}
if ((decbutton == mymode) & (modeflag == 1)) {
modeflag = 0;
mode = 0; // Switch to PID Mode
tm.setLED((mymode - 1), 0);
}
}
void modeLoss () {
int mymode = 3;
tm.setLED((mymode - 1), 1);
if (decbutton == 0) {
modeflag = 1;
}
sprintf(displayformat, "loss %03d", loss);
tm.displayText(displayformat);
if (decbutton == 7) {
if (loss < 100) {
loss ++;
}
} else if (decbutton == 8) {
if (loss > 0) {
loss --;
}
}
if ((decbutton == mymode) & (modeflag == 1)) {
modeflag = 0;
mode = 0; // Switch to PID Mode
tm.setLED((mymode - 1), 0);
}
}
void modeNoise () {
int mymode = 4;
tm.setLED((mymode - 1), 1);
if (decbutton == 0) {
modeflag = 1;
}
sprintf(displayformat, " nse %03d", pvnoise);
tm.displayText(displayformat);
if (decbutton == 7) {
if (pvnoise < 100) {
pvnoise ++;
}
} else if (decbutton == 8) {
if (pvnoise > 0) {
pvnoise --;
}
}
if ((decbutton == mymode) & (modeflag == 1)) {
modeflag = 0;
mode = 0; // Switch to PID Mode
tm.setLED((mymode - 1), 0);
}
}
void modeDelay () {
int mymode = 5;
tm.setLED((mymode - 1), 1);
if (decbutton == 0) {
modeflag = 1;
}
sprintf(displayformat, " dly %03d", pvdelay);
tm.displayText(displayformat);
if (decbutton == 7) {
if (pvdelay < 100) {
pvdelay ++;
}
} else if (decbutton == 8) {
if (pvdelay > 0) {
pvdelay --;
}
}
if ((decbutton == mymode) & (modeflag == 1)) {
modeflag = 0;
mode = 0; // Switch to PID Mode
tm.setLED((mymode - 1), 0);
}
}
void modeTest () {
int mymode = 6;
tm.setLED((mymode - 1), 1);
if (decbutton == 0) {
modeflag = 1;
}
if (teststep == 0) {
lockchannels = 1;
processvariable = 0;
pvdisplay = processvariable;
cvdisplay = controlvariable;
sprintf(displayformat, "%03d %03d", pvdisplay, cvdisplay);
tm.displayText(displayformat);
}
if (teststep == 1) {
lockchannels = 1;
processvariable = 100;
pvdisplay = processvariable;
cvdisplay = controlvariable;
sprintf(displayformat, "%03d %03d", pvdisplay, cvdisplay);
tm.displayText(displayformat);
}
if (teststep == 2) {
lockchannels = 0;
sprintf(displayformat, "base %03d", timeunits);
tm.displayText(displayformat);
if (decbutton == 7) {
if (timeunits < 999) {
timeunits ++;
}
} else if (decbutton == 8) {
if (timeunits > 0) {
timeunits --;
}
}
}
if (teststep == 3) {
lockchannels = 0;
sprintf(displayformat, " frd %2d", forwardacting);
tm.displayText(displayformat);
if (decbutton == 7) {
if (forwardacting < 1) {
forwardacting ++;
}
} else if (decbutton == 8) {
if (forwardacting > 0) {
forwardacting --;
}
}
}
if (teststep == 4) {
lockchannels = 0;
sprintf(displayformat, "type %2d", processtype);
tm.displayText(displayformat);
if (decbutton == 7) {
if (processtype < 1) {
processtype ++;
}
} else if (decbutton == 8) {
if (processtype > 0) {
processtype --;
}
}
}
if (teststep == 5) {
lockchannels = 0;
sprintf(displayformat, "reset %2d", resetproc);
tm.displayText(displayformat);
if (decbutton == 7) {
if (resetproc < 1) {
resetproc ++;
}
} else if (decbutton == 8) {
if (resetproc > 0) {
resetproc --;
}
}
}
if (teststep == 6) {
if (resetproc == 1) {
Serial.println("Resetting");
delay(10);
digitalWrite(resetPin, LOW);
} else {
resetproc = 0;
}
teststep = 0;
mode = 0;
modeflag = 0;
lockchannels = 0;
tm.setLED((mymode - 1), 0);
}
if ((decbutton == mymode) & (modeflag == 1) & (teststep < 6)) {
teststep ++;
}
}
void modePID () {
if ((currentmillis - displaymillis) > 500) {
pvdisplay = processvariable;
cvdisplay = controlvariable;
if (!processtype) {
sprintf(displayformat, "%03d %03d", pvdisplay, cvdisplay);
} else {
sprintf(displayformat, "%03d LEL", pvdisplay, cvdisplay);
}
tm.displayText(displayformat);
displaymillis = currentmillis;
}
}
//********** Calculations **********//
void CalculateLoad() {
if (actualload < load) {
actualload ++;
} else if (actualload > load) {
actualload --;
}
//loadeffect = (((actualload) / ((1000.0 / timeconstant) * timeunits)) * (pvafternoise / 50.0)); // Max Load = 100/minute
loadeffect = (((actualload) / ((1000.0 / timeconstant) * timeunits))); // Max Load = 100/minute
bulkpv = bulkpv - loadeffect;
CheckBulkPV();
}
void CalculatePower() {
actualpower = ((cvafterlag) / ((1000.0 / timeconstant) * timeunits)); //Add 100 Per Minute
bulkpv = 0.0 + bulkpv + actualpower * 1;
CheckBulkPV();
}
void CalculateLoss() {
bulkpv = bulkpv - (((loss) / ((1000 / timeconstant) * timeunits) * pvafternoise / 100)); // Max Loss = 100/minute
CheckBulkPV();
}
void CalculateLagDelay() {
if (lag > 0) {
if (currentmillis - lagdelaymillis > 100) {
for (int y = 100; y >= 0; y--) {
cvlagarray[y] = cvlagarray[y - 1];
}
// Average the Lag Array
lagcvsum = 0;
cvlagarray[0] = controlvariable;
for (int z = 0; z <= lag; z++) {
lagcvsum = lagcvsum + cvlagarray[z] + 0.0;
}
//cvafterlag = cvlagarray[lag];
cvafterlag = lagcvsum / (lag + 0.0);
lagdelaymillis = currentmillis;
}
} else {
cvafterlag = controlvariable;
}
if (pvdelay > 0) {
if (currentmillis - pvdelaymillis > 10) {
for (int x = 100; x >= 0; x--) {
pvdelayarray[x] = pvdelayarray[x - 1];
}
pvdelayarray[0] = bulkpv;
pvafterdelay = pvdelayarray[pvdelay];
pvdelaymillis = currentmillis;
} else {
}
}else{
pvafterdelay = bulkpv;
}
}
void CalculateNoise() {
pvafternoise = pvafterdelay + random(-pvnoise, pvnoise);
}
void ProcessPID() {
if (lockchannels == 0) {
if (!forwardacting) {
processvariable = pvafternoise;
} else {
processvariable = 100 - pvafternoise;
}
}
if (processvariable > 120) {
processvariable = 120;
} else if (processvariable < 0) {
processvariable = 0;
}
analogWrite(processvariablePin, map(processvariable, 0, maxpv, 0, 255));
analogWrite(feedforwardPin, map(load, 0, maxpv, 0, 255));
if (!processtype) {
controlvariable = map(analogRead(A0), 0, 1024, 0, 100);
} else {
controlvariable = (map(analogRead(A0), 0, 1024, 0, 100)) * 1.2;
}
if (controlvariable > 100) {
controlvariable = 100;
} else if (controlvariable < 0) {
controlvariable = 0;
}
}
void WriteSerial() {
if (currentmillis - serialmillis > 2000) {
Serial.print(F("Actual Load (from load): "));
Serial.println(actualload);
Serial.print(F("Load Effect: "));
Serial.println(loadeffect);
Serial.print(F("Actual Power (from lag): "));
Serial.println(actualpower);
Serial.print(F("Lag Sum: "));
Serial.println(lagcvsum);
Serial.print(F("Process Variable: "));
Serial.println(processvariable);
Serial.print(F("Control Variable: "));
Serial.println(controlvariable);
Serial.print(F("BulkPV: "));
Serial.println(bulkpv);
Serial.print(F("Mode: "));
Serial.println(mode);
Serial.print(F("Loss: "));
Serial.println(loss);
Serial.print(F("Lag: "));
Serial.println(lag);
Serial.print(F("Noise: "));
Serial.println(pvnoise);
Serial.print(F("Delay: "));
Serial.println(pvdelay);
Serial.print(F("Test Step: "));
Serial.println(teststep);
Serial.println(F("---------------------------------------------------"));
serialmillis = currentmillis;
}
}
void CheckBulkPV() {
if (bulkpv > maxpv) {
bulkpv = maxpv;
} else if (bulkpv < 0) {
bulkpv = 0;
}
}
If you find any errors, or have suggestions, please post in the comment section below.
For more information, check out the main page at https://bryceautomation.com!
— Ricky Bryce