A Step-by-Step Guide to Creating Physical PID Controllers in Python

I recently created an arbitrary waveform generator out of a DC power supply and an oscilloscope, by treating the system as a PID controller. In this post, I outline how I did it and assess its effectiveness.

Setup

First we will need to do the necessary imports. I am piggybacking on the instruments library outlined in my series on instrumentation.

import time, math
from decimal import Decimal

import matplotlib.pyplot as plt
import pandas as pd

from instruments import DS1000Z, DP800

Next, let’s create a PidDemo class that will house our PID parameters, and initialize instruments within __init__.

class PidDemo:

    def __init__(self):

        # Set parameters
        self.k_p = Decimal("0.2") # 0.2
        self.k_i = Decimal("4") # 4
        self.k_d = Decimal("0.001") # 0.001

        # Set limits and clip derivative
        self.o_max = Decimal("5")
        self.o_min = Decimal("0")
        self.d_max = Decimal("1")
        self.d_min = Decimal("0.001")

        # Set sampling rate
        self.freq = Decimal("100")

        self.initialize_oscilloscope()
        self.initialize_power_supply()

    def __del__(self):
        self.power_supply.disable_output()

Now we will define the initial state of the oscilloscope. This will serve as the sensing device of our PID controller. I chose the smallest timebase scale since this is theoretically more responsive, but there may be some trade-offs here.

    ...

    def initialize_oscilloscope(self):
        self.oscilloscope = DS1000Z('192.168.254.100')
        self.oscilloscope.reset()
        self.oscilloscope.set_probe_ratio(1)
        self.oscilloscope.set_channel_scale(1)
        self.oscilloscope.set_memory_depth(24000000)
        self.oscilloscope.set_timebase_scale(1e-9)
        self.oscilloscope.wait()

    ...

Let’s do the same thing for the power supply. I’m setting the current to a small value since there isn’t going to be much load in this case. I also set the view to WAVE so that we can see a time-dependent view of the settings.

    ...

    def initialize_power_supply(self):
        self.power_supply = DP800("192.168.254.101")
        self.power_supply.reset()
        self.power_supply.set_display_mode("WAVE")
        self.power_supply.select_channel(1)
        self.power_supply.set_overvoltage_protection_value(4)
        self.power_supply.enable_overvoltage_protection()
        self.power_supply.set_overcurrent_protection_value(0.01)
        self.power_supply.enable_overcurrent_protection()
        self.power_supply.enable_output()
        self.power_supply.wait()

    ...

The loop

Device connections.

Now, let’s create our control loop. I’m going to be storing the data as a list of dictionaries, since this will make it easy to plot as a pandas DataFrame later.

    ...

    def loop(self):
        m = self.oscilloscope.get_measurement("VAVG","CURR")
        self.data = [{
            "t":time.time(),
            "m":m,
            "r":Decimal("0"),
            "p":Decimal("0"),
            "i":Decimal("0"),
            "d":Decimal("0"),
            "u":Decimal("0"),
            "o":Decimal("0")
        }]

    ...

Let’s define our time-dependent setpoint. Creating a setpoint that changes in time will be a good stress-test for the stability of our PID parameters. We can also fetch the value from a dial on some sort of user interface. This can also be thought of as the arbitrary waveform function.

    ...

    def get_setpoint(self):
        return Decimal(math.sin(time.time()/2)/2+0.5)

    def loop(self):
        ...

Next, we create the main body of the loop.

        ...
        try:
            while True:
                t = time.time()
                r = self.get_setpoint()
                m = self.oscilloscope.get_measurement("VAVG","CURR")
                p = r - m
                t_d = Decimal(t-self.data[-1]["t"])
                self.data.append({
                    "t":t,
                    "r":r,
                    "m":m,
                    "p":p,
                    "i":p*t_d+self.data[-1]["i"],
                    "d":max(min((p-self.data[-1]["p"])/t_d,self.d_max),-self.d_max)
                })
                self.data[-1]["u"] = self.k_p*self.data[-1]["p"] + self.k_i*self.data[-1]["i"] + self.k_d*self.data[-1]["d"]
                self.data[-1]["o"] = max(min(self.data[-2]["o"]+max(min(self.data[-1]["u"]-self.data[-2]["o"],self.d_max),-self.d_max),self.o_max),self.o_min)
                if math.fabs(self.data[-1]["o"]-self.data[-2]["o"]) >= self.d_min:
                    self.power_supply.set_channel(voltage=self.data[-1]["u"], current=0.01)
                delay = max(Decimal("2")/self.freq-t_d,Decimal("1")/self.freq)
                time.sleep(delay) # Accounts for additional code-time
        except KeyboardInterrupt:
            pass
        ...

Let’s plot it!

        ...
        self.data = pd.DataFrame.from_dict(self.data).set_index("t")
        self.data.index = pd.to_datetime((self.data.index*1e9).astype(int))
        (self.k_p*self.data["p"]).astype(float).plot()
        (self.k_i*self.data["i"]).astype(float).plot()
        (self.k_d*self.data["d"]).astype(float).plot()
        self.data["o"].astype(float).plot()
        self.data["r"].astype(float).plot()
        self.data["m"].astype(float).plot()
        plt.show()

def main():
    demo = PidDemo()
    demo.loop()

if __name__ == "__main__":
    main()

Results

The final results are plotted below. Not bad, although this is at a sub-hertz frequency range (which is pretty slow from a function generator perspective).

We can see this effect getting worse as we speed up the frequency, and better if we slow it down

Fast target frequency results.
Slow target frequency results.

Conclusion

While we are limited by the update speed of the actuator, the ability to generate arbitrary waveforms gives us a great deal of control and demonstrates the abilities of our test system. In the case of a more complex PID system, there are many more considerations to be taken into account.


About the author



Hi, I'm Nathan. Thanks for reading! Keep an eye out for more content being posted soon.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *