Introduction
Often times, .NET isn't realized as an industrial language, that is, one that can be trusted to control critical processes in near real-time performance. This article explains the design of a PID controller in .NET that can be used to control an industrial process.
PID Controller Basics
A PID controller stands for Proportional, Integral, and Derivative, named after the three basic elements of a PID controller. There are a number of PID controller designs out there, each manufacturer taking a slightly different approach to the design.
First, a quick glossary:
- Proportional - the "P" element of the PID controller (more on this later)
- Integral - the "I" element of the PID controller (more later)
- Derivative - the "D" element of the PID controller (more later)
- Process Variable - the controllable variable that affects the output
- Set Point - Desired output value
I will use the old "cruise control" example throughout the article to explain how this works, since a cruise control is the most observable type of PID controller out there.
The proportional term identifies what the PID controller's reaction to the error (difference between set point and output) will be. Think of this as how much gas the cruise controller gives the car for some amount of error.
The integral term is how the PID controller reacts to prolonged periods of error. If we just had a P controller, the car would not accelerate going up hills or into the wind. This is an amount to add to the output per period of error.
Derivative is how much reaction the controller has versus the rate of change of the error. If we just used a PI controller, then the P term would make the speed shoot past the target, the I term would accumulate and pull it back, but it wouldn't "anticipate" approaching the set point, and would shoot past it again.
There is an entire theory behind PID controllers despite their simplicity, so I would suggest popping open Google and searching for PID controllers. You can find different implementations of them for particular situations, and learn about fun things like anti-windup reset.
Goals and Technologies
The goal of this article is to develop an easy to use PID controller. We will look at a couple different technologies implemented in this article, including delegates, threading, timing, and automation. We will look at how to use delegates so the implementing class doesn't need to worry about "feeding" the PID controller with data, and the PID controller can automatically update the output function.
Using the Code
First, let's start off looking at the delegate setup. If you don't know what delegates are, they are function pointers that can be passed around and stored, and you use them just like any other function. Our PID controller uses two types of delegates:
namespace PIDLibrary
{
public delegate double GetDouble();
public delegate void SetDouble(double value);
}
Here, we identify two delegates, or function pointers, that our application will use. The first one, GetDouble
, is a function that takes no arguments and returns a double
. The second one, SetDouble
, takes a single double
argument and doesn't return anything.
Now, let's look at the PID
class:
public class PID
{
#region Fields
private double kp;
private double ki;
private double kd;
private DateTime lastUpdate;
private double lastPV;
private double errSum;
private GetDouble readPV;
private GetDouble readSP;
private SetDouble writeOV;
private double pvMax;
private double pvMin;
private double outMax;
private double outMin;
private double computeHz = 1.0f;
private Thread runThread;
#endregion
#region Properties
public double PGain
{
get { return kp; }
set { kp = value; }
}
public double IGain
{
get { return ki; }
set { ki = value; }
}
public double DGain
{
get { return kd; }
set { kd = value; }
}
public double PVMin
{
get { return pvMin; }
set { pvMin = value; }
}
public double PVMax
{
get { return pvMax; }
set { pvMax = value; }
}
public double OutMin
{
get { return outMin; }
set { outMin = value; }
}
public double OutMax
{
get { return outMax; }
set { outMax = value; }
}
public bool PIDOK
{
get { return runThread != null; }
}
#endregion
#region Construction / Deconstruction
public PID(double pG, double iG, double dG,
double pMax, double pMin, double oMax, double oMin,
GetDouble pvFunc, GetDouble spFunc, SetDouble outFunc)
{
kp = pG;
ki = iG;
kd = dG;
pvMax = pMax;
pvMin = pMin;
outMax = oMax;
outMin = oMin;
readPV = pvFunc;
readSP = spFunc;
writeOV = outFunc;
}
~PID()
{
Disable();
readPV = null;
readSP = null;
writeOV = null;
}
#endregion
#region Public Methods
public void Enable()
{
if (runThread != null)
return;
Reset();
runThread = new Thread(new ThreadStart(Run));
runThread.IsBackground = true;
runThread.Name = "PID Processor";
runThread.Start();
}
public void Disable()
{
if (runThread == null)
return;
runThread.Abort();
runThread = null;
}
public void Reset()
{
errSum = 0.0f;
lastUpdate = DateTime.Now;
}
#endregion
#region Private Methods
private double ScaleValue(double value, double valuemin,
double valuemax, double scalemin, double scalemax)
{
double vPerc = (value - valuemin) / (valuemax - valuemin);
double bigSpan = vPerc * (scalemax - scalemin);
double retVal = scalemin + bigSpan;
return retVal;
}
private double Clamp(double value, double min, double max)
{
if (value > max)
return max;
if (value < min)
return min;
return value;
}
private void Compute()
{
if (readPV == null || readSP == null || writeOV == null)
return;
double pv = readPV();
double sp = readSP();
pv = Clamp(pv, pvMin, pvMax);
pv = ScaleValue(pv, pvMin, pvMax, -1.0f, 1.0f);
sp = Clamp(sp, pvMin, pvMax);
sp = ScaleValue(sp, pvMin, pvMax, -1.0f, 1.0f);
double err = sp - pv;
double pTerm = err * kp;
double iTerm = 0.0f;
double dTerm = 0.0f;
double partialSum = 0.0f;
DateTime nowTime = DateTime.Now;
if (lastUpdate != null)
{
double dT = (nowTime - lastUpdate).TotalSeconds;
if (pv >= pvMin && pv <= pvMax)
{
partialSum = errSum + dT * err;
iTerm = ki * partialSum;
}
if (dT != 0.0f)
dTerm = kd * (pv - lastPV) / dT;
}
lastUpdate = nowTime;
errSum = partialSum;
lastPV = pv;
double outReal = pTerm + iTerm + dTerm;
outReal = Clamp(outReal, -1.0f, 1.0f);
outReal = ScaleValue(outReal, -1.0f, 1.0f, outMin, outMax);
writeOV(outReal);
}
#endregion
#region Threading
private void Run()
{
while (true)
{
try
{
int sleepTime = (int)(1000 / computeHz);
Thread.Sleep(sleepTime);
Compute();
}
catch (Exception e)
{
}
}
}
#endregion
}
You can see that the implementation is rather short and sweet, but we'll take a closer look at how the PID works...
First off, the constructor:
public PID(double pG, double iG, double dG,
double pMax, double pMin, double oMax, double oMin,
GetDouble pvFunc, GetDouble spFunc, SetDouble outFunc)
{
kp = pG;
ki = iG;
kd = dG;
pvMax = pMax;
pvMin = pMin;
outMax = oMax;
outMin = oMin;
readPV = pvFunc;
readSP = spFunc;
writeOV = outFunc;
}
It takes quite a few arguments, which I'll explain in detail. The first argument, pG
, is the proportional gain, which identifies how much output to apply versus the percentage error. The second argument iG
and the third argument dG
do the same for the integral and derivative terms, respectively. The next two arguments, pMax
and pMin
, identify the process variable maximum and process variable minimum. This isn't to say that the process variable can't go above these values, but it will be clipped in the computation function to be within those extremes. The oMax
and oMin
arguments perform a similar action for the output variable.
The next three arguments are the delegates that tell the PID
controller where it can find the data it needs to be able to process it. pvFunc
is a function that returns the value of the process variable (thing being measured). spFunc
returns the value of the set point (what we want the process variable to equal), and outFunc
tells the PID controller what to call to set the output value.
I'll skip most of the basics of the implementation, like the destructor, properties, public functions, etc.
Let's take a look at the threading loop:
private void Run()
{
while (true)
{
try
{
int sleepTime = (int)(1000 / computeHz);
Thread.Sleep(sleepTime);
Compute();
}
catch (Exception e)
{
}
}
}
We can see that the loop is very simple; it makes a rough approximation of the amount of time that it needs to sleep (this isn't true time, because we would need to take in the amount of time it takes to run the calculation, but it's close enough, and the Compute
routine compensates by using the actual time between measurements). All the function does is loop, sleep, and call Compute
.
private void Compute()
{
if (readPV == null || readSP == null || writeOV == null)
return;
double pv = readPV();
double sp = readSP();
pv = Clamp(pv, pvMin, pvMax);
pv = ScaleValue(pv, pvMin, pvMax, -1.0f, 1.0f);
sp = Clamp(sp, pvMin, pvMax);
sp = ScaleValue(sp, pvMin, pvMax, -1.0f, 1.0f);
double err = sp - pv;
double pTerm = err * kp;
double iTerm = 0.0f;
double dTerm = 0.0f;
double partialSum = 0.0f;
DateTime nowTime = DateTime.Now;
if (lastUpdate != null)
{
double dT = (nowTime - lastUpdate).TotalSeconds;
if (pv >= pvMin && pv <= pvMax)
{
partialSum = errSum + dT * err;
iTerm = ki * partialSum;
}
if (dT != 0.0f)
dTerm = kd * (pv - lastPV) / dT;
}
lastUpdate = nowTime;
errSum = partialSum;
lastPV = pv;
double outReal = pTerm + iTerm + dTerm;
outReal = Clamp(outReal, -1.0f, 1.0f);
outReal = ScaleValue(outReal, -1.0f, 1.0f, outMin, outMax);
writeOV(outReal);
}
The Compute
routine is where the meat of the algorithm lies. Basically, it starts out reading the process variable (pv
) and set point (sp
). It then Clamp
's them to pvMin
and pvMax
, then scales them so they are a percentage between -100% and 100% of the scale. It then figures out the error percentage and starts running the PID calculation.
The calculation is pretty simple; it starts out finding the pTerm
, which is the error times the gain (kp
). Then, inside the if
statement, we do what is called anti-windup reset, where we only calculate the iTerm
if the process variable isn't pegged at or above the process variable range. This helps to limit the output of the system, and keeps the error from blowing up when the process variable gets out of range.
The last thing it does is simply sum the three terms to obtain the output value, clamp it to +/-100% of the output range, then scale it to come up with a real output number. It then uses the SetDouble
delegate called writeOV
(write output variable) to set the output value.
And there you have it. If we have a more real-time or critical process, we can set the runThread
priority to something higher, but I wouldn't recommend going above "High" since it will cause other things to become preempted too often.
This is a very versatile class; setting the I gain term to zero will give you a PD controller, and if you wanted, you could have a strictly P controller by setting both the I and D gains to zero.
Tuning a PID
Tuning a PID controller is beyond the scope of this article; again, the best place to learn about tuning PIDs is Google, just pop open your browser and search for "PID Tuning" or similar terms. There are a lot of interesting properties about PID controllers, and they can be used to perform some pretty amazing and almost intelligent control applications.
Points of Interest
This PID controller works great for implementing processes that can be modeled linear or near linear, but processes that are a lot more complicated and need a multi-parameter PID. I've implemented PID controllers that use up to 18 terms, with great results, using the same simple framework. Tuning is the hard part...
History