Click here to Skip to main content
Click here to Skip to main content

Industrial .NET - PID Controllers

, 29 Dec 2009 CPOL
Rate this:
Please Sign up or sign in to vote.
Implementing a robust PID controller in .NET.

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:

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

    //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:

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();

    //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)

Share

About the Author

Ron Beyer
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

 
QuestionThis is wrong PinmemberGLMnet12-Jun-12 1:06 

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

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

| Advertise | Privacy | Terms of Use | Mobile
Web04 | 2.8.1411023.1 | Last Updated 29 Dec 2009
Article Copyright 2009 by Ron Beyer
Everything else Copyright © CodeProject, 1999-2014
Layout: fixed | fluid