 # 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

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

## 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. 1. c says: