Click here to Skip to main content
15,867,141 members
Articles / Programming Languages / C#

Industrial .NET - PID Controllers

Rate me:
Please Sign up or sign in to vote.
4.79/5 (21 votes)
29 Dec 2009CPOL6 min read 143.4K   6.3K   62   30
Implementing a robust PID controller in .NET.

Image 1

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:

  1. Proportional - the "P" element of the PID controller (more on this later)
  2. Integral - the "I" element of the PID controller (more later)
  3. Derivative - the "D" element of the PID controller (more later)
  4. Process Variable - the controllable variable that affects the output
  5. 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:

C#
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:

C#
public class PID
{
    #region Fields

    //Gains
    private double kp;
    private double ki;
    private double kd;

    //Running Values
    private DateTime lastUpdate;
    private double lastPV;
    private double errSum;

    //Reading/Writing Values
    private GetDouble readPV;
    private GetDouble readSP;
    private SetDouble writeOV;

    //Max/Min Calculation
    private double pvMax;
    private double pvMin;
    private double outMax;
    private double outMin;

    //Threading and Timing
    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();

        //We need to scale the pv to +/- 100%, but first clamp it
        pv = Clamp(pv, pvMin, pvMax);
        pv = ScaleValue(pv, pvMin, pvMax, -1.0f, 1.0f);

        //We also need to scale the setpoint
        sp = Clamp(sp, pvMin, pvMax);
        sp = ScaleValue(sp, pvMin, pvMax, -1.0f, 1.0f);

        //Now the error is in percent...
        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;

            //Compute the integral if we have to...
            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;

        //Now we have to scale the output value to match the requested scale
        double outReal = pTerm + iTerm + dTerm;

        outReal = Clamp(outReal, -1.0f, 1.0f);
        outReal = ScaleValue(outReal, -1.0f, 1.0f, outMin, outMax);

        //Write it out to the world
        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:

C#
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:

C#
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.

C#
private void Compute()
{
    if (readPV == null || readSP == null || writeOV == null)
        return;

    double pv = readPV();
    double sp = readSP();

    //We need to scale the pv to +/- 100%, but first clamp it
    pv = Clamp(pv, pvMin, pvMax);
    pv = ScaleValue(pv, pvMin, pvMax, -1.0f, 1.0f);

    //We also need to scale the setpoint
    sp = Clamp(sp, pvMin, pvMax);
    sp = ScaleValue(sp, pvMin, pvMax, -1.0f, 1.0f);

    //Now the error is in percent...
    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;

        //Compute the integral if we have to...
        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;

    //Now we have to scale the output 
    //value to match the requested scale
    double outReal = pTerm + iTerm + dTerm;

    outReal = Clamp(outReal, -1.0f, 1.0f);
    outReal = ScaleValue(outReal, -1.0f, 1.0f, outMin, outMax);

    //Write it out to the world
    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

  • 1.0: Initial version.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
President 6D Systems LLC
United States United States
I studied Software Engineering at Milwaukee School of Engineering for 2 years before switching to Management of Information Systems for a more business oriented approach. I've been developing software since the age of 14, and have waded through languages such as QBasic, TrueBasic, C, C++, Java, VB6, VB.NET, C#, etc. I've been developing professionally since 2002 in .NET.

Comments and Discussions

 
Questiontuning PID control? Pin
Member 1019550510-Jan-21 16:02
professionalMember 1019550510-Jan-21 16:02 
QuestionIf you are using in VB.NET Pin
Nirav Kukadiya12-Jul-18 23:45
Nirav Kukadiya12-Jul-18 23:45 
QuestionSetDouble outFunc Pin
Member 1109343722-Dec-14 13:42
Member 1109343722-Dec-14 13:42 
QuestionD-term Pin
micke.andersson2-Nov-14 22:57
micke.andersson2-Nov-14 22:57 
QuestionModify/Use your Code with a project and OpenSource the project under GNU GPL v3.0 License Pin
Umer javaid21-Oct-14 2:36
Umer javaid21-Oct-14 2:36 
I modified your code for my university project. The Project was to develop an artificial intelligent control for electro-mechainical systems for Robocup/Robosprint participating teams. The code was developed and works fine in normal circumstances and now I want to open source my project code under GPL (GNU V3.0) on git-hub or other repository. I want your approval to use your code with mine. Plus if you have any particulars that you want me to follow kindly let me know ..
waiting for your response
Regards.
AnswerRe: Modify/Use your Code with a project and OpenSource the project under GNU GPL v3.0 License Pin
Ron Beyer21-Oct-14 4:02
professionalRon Beyer21-Oct-14 4:02 
GeneralRe: Modify/Use your Code with a project and OpenSource the project under GNU GPL v3.0 License Pin
Umer javaid21-Oct-14 5:12
Umer javaid21-Oct-14 5:12 
GeneralRe: Modify/Use your Code with a project and OpenSource the project under GNU GPL v3.0 License Pin
Umer javaid24-Jun-15 3:51
Umer javaid24-Jun-15 3:51 
QuestionAn error is hidden in the code Pin
winnypolo12-Mar-13 0:55
winnypolo12-Mar-13 0:55 
AnswerRe: An error is hidden in the code Pin
JudeC27-Oct-13 23:33
JudeC27-Oct-13 23:33 
GeneralRe: An error is hidden in the code Pin
Pera Zdera19-Jun-14 0:18
Pera Zdera19-Jun-14 0:18 
GeneralRe: An error is hidden in the code Pin
Member 1122384511-Jan-15 9:26
Member 1122384511-Jan-15 9:26 
GeneralRe: An error is hidden in the code Pin
W G6-Dec-15 14:13
W G6-Dec-15 14:13 
QuestionThis is wrong Pin
GLMnet12-Jun-12 0:06
GLMnet12-Jun-12 0:06 
AnswerRe: This is wrong Pin
Member 813414328-May-18 14:19
Member 813414328-May-18 14:19 
QuestionCan we include PV Tracking into this code ? Pin
bhargav30-May-12 18:20
bhargav30-May-12 18:20 
GeneralMy vote of 4 Pin
Mohammad Said Hefny10-Mar-12 4:42
Mohammad Said Hefny10-Mar-12 4:42 
QuestionThanks for easy understandable code :) Pin
Temp37818-Oct-11 10:26
Temp37818-Oct-11 10:26 
AnswerRe: Thanks for easy understandable code :) Pin
bigbrother2511-May-12 21:54
bigbrother2511-May-12 21:54 
GeneralNice Pin
sam.hill29-Dec-09 15:40
sam.hill29-Dec-09 15:40 
GeneralIntersting article, but Pin
Rob Graham29-Dec-09 14:40
Rob Graham29-Dec-09 14:40 
GeneralRe: Intersting article, but Pin
Ron Beyer29-Dec-09 14:51
professionalRon Beyer29-Dec-09 14:51 
GeneralRe: Intersting article, but Pin
Tim Craig29-Dec-09 18:00
Tim Craig29-Dec-09 18:00 
GeneralRe: Intersting article, but Pin
supercat930-Dec-09 6:34
supercat930-Dec-09 6:34 
GeneralRe: Intersting article, but Pin
Tim Craig30-Dec-09 8:42
Tim Craig30-Dec-09 8:42 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.