ShieldController

The ShieldController class is most suitable for the majority of users and use cases. Implementing a controller is done following a few steps.

  1. Create a class that inherits from ShieldController

  2. Implement the controller method and, optionally, the variables method.

  3. Execute your controller class’ run method, specifying the frequency and runtime.

This tutorial will run an experiment on the AeroShield with your controller. We will discuss each step in more detail below. The steps in this tutorial apply to all shield classes, however, the controller will need to be tuned differently to work.

Optionally, you can plot the results afterwards or add a LivePlotter instance to the run to see the data live. See the plotting examples for more information on plotters.

First, we import the AeroShield and ShieldController classes, as well as the numpy package.

1%%capture
2import numpy as np
3
4from automationshield import AeroShield, ShieldController

Simple Controller Example: Using the controller Method.

1class PotController(ShieldController):
2    def controller(self, t: float, dt: float, ref: float, pot: float, sensor: float) -> float:
3        return pot

Above is a very simple example to illustrate the working of the controller method. Beside the self parameter, pointing to the class instance, there are several inputs to the method:

  • t (s): The time since the start of the experiment run.

  • dt (s): The time step size of the current iteration. This should match the frequency set when running most of the time, but this is not guaranteed. There are usually a few instances during a run where dt is larger.

  • ref (-): In most scenarios for the AeroShield, this will be the reference angle, though you’re free to do with it what you want in your controller.

  • pot (-): The potentiometer value scaled on the interval [0, 100].

  • sensor (°): For the AeroShield, this is the current angle of the pendulum.

Whichever controller you build, the signature of the method must always contain these variables in this order. The return value of the controller method is sent to the Arduino as the motor value. The motor value is assumed to be in percent, and as a result, will be saturated between 0 and 100 before being converted to a bit value and sent to the Arduino. In the example above, you can see that that is the potentiometer value. Also note how all other variables go unused.

To run this controller on the Aeroshield, we create an instance of our PotController class and call it pot_controller. We create an instance of AeroShield called aero_shield and give it to the controller upon creation. By separately creating the AeroShield instance, we can more easily modify it (e.g. manually setting the port) before giving it to the controller.

Then, we call the run method on the controller. You must specify the frequency (freq) (Hz), and runtime (cycles) expressed as a number of cycles the loop should be run. The runtime is seconds is then roughly equal to cycles/freq.

When complete, run returns the results of the experiment in a numpy array. By default, the array contains 5 columns: [t, ref, pot, angle, motor]. They are identical to the values passed to controller, except for the motor value, which is saturated (limited between [0, 100]) in case the controller outputs a motor value that is too high. You’ll also notice the cycles being counted in the terminal.

1aero_shield = AeroShield()
2pot_controller = PotController(aero_shield)
3results = pot_controller.run(freq=200, cycles=1000)
4
5results.shape
(1000, 5)

The above controller is very similar to the controller we implemented in the AeroShield example. It also doesn’t look all that much shorter or simpler. However, a lot more is happening in the background here:

  • The frequency of the loop is controlled;

  • The results are automatically saved and returned in a convenient format.

And we haven’t talked about the option to plot results in real time (see the LivePlotter example for that). The convenience of using the ShieldController class might become more clear with a slightly more complex example.

Adding Persistent Variables

The code block below implements a PID controller. The controller method calculates the proportional, integral and differential componenents of the controller, and sums them to get a motor value.

This controller also implements a variables method. This method does nothing more than declare a few variables in the class instance which can be used by the controller. In this example, we define the proportional, integral and differential gains as well as the total error and previous error. It’s not necessary to define the gains in the variables method, but it helps clean up the controller implementation and prevents the variables to be assigned again every time the controller method is called. The total and previous errors are different. Their values must be initialised outside of the controller (so they can be set to 0) and persist beyond the scope of the controller method (so we can remember the value from the previous time we ran the controller). That is why we define them as instance variables to the class in the variables method. We do this by giving them the self. prefix. This method is run upon creation of the instance, making sure the variables exist in the scope of the class instance. It is advised to prefix all user-defined variables with var_, to avoid overriding variables from the base class.

A second thing is happening in the variables method. We are adding var_total_error as a tracked variable by calling add_tracked_variable with the variable name (as a string) as argument. This will add a column to the results we discussed earlier for var_total_error and return it at the end of the experiment. By default, one column is added per variable. If more columns are needed, you can specify that through the size keyword argument.

 1class PIDController(ShieldController):
 2    def variables(self) -> None:
 3        # preface every variable defined in subclass with var_ to avoid overrides.
 4        self.var_kp = 1
 5        self.var_ki = .25
 6        self.var_kd = .25
 7
 8        self.var_total_error = 0
 9        self.var_prev_error = 0
10
11        self.add_tracked_variable("var_total_error")
12
13    def controller(self, t: float, dt: float, ref: float, pot: float, sensor: float) -> float:
14        error = ref - sensor
15        self.var_total_error += error * dt
16
17        proportional = self.var_kp * error
18        integral = self.var_kp/self.var_ki * self.var_total_error
19
20        if dt > 0:
21            differential = self.var_kp*self.var_kd*(error - self.var_prev_error)/dt
22        else:
23            differential = 0
24
25        motor = proportional + integral + differential
26
27        self.var_prev_error = error
28
29        return motor

We run this controller the same way we did before, except we also provide a reference this time. we set the reference at a constant 45° throughout the experiment (in this case, instead of creating an array, we could also simply provide a single number). Because we added var_total_error as a tracked variable, the results array now features an extra column, totaling 6.

1pid_controller = PIDController(AeroShield())
2results = pid_controller.run(freq=200, cycles=1000, ref=np.ones(1000)*45)
3
4results.shape
(1000, 6)

Calculating the Reference using a Callback

Sometimes, you don’t just want a present reference. A common example is setting the reference as a function of the potentiometer value. This could be done in the controller method, but then you would have to change the controller each time you want a different reference. It is more convenient to define a function to calculate the reference each cycle instead.

In the example below, we define a function calc_ref_from_pot, which calculates the reference value as a function of the potentiometer. In this case, it returns an angle between 45° and 135° depending on the potentiometer value. The function must take three inputs: the cycle count, the elapsed time of the experiment and the potentiometer value.

1def calc_ref_from_pot(cntr: int, t: float, pot: float) -> float:
2    return 45 + .9 * pot

For even more complex references, for example when you want to switch between different types of references during an experiment, you can define a reference class. The class must expose a __call__ method with the same signature as the callback function defined above. Here, we defined a reference class that initially sets the reference as a function of the potentiometer and after 500 cycles switches to a preset reference.

 1class MyReference:
 2    def __init__(self, ref_list: list[float]) -> None:
 3        self.ref = ref_list
 4        self.i = -1
 5
 6    def __call__(self, cntr: int, t: float, pot: float) -> float:
 7        if cntr > 500:
 8            self.i += 1
 9            return self.ref[self.i]
10
11        return 45 + .9 * pot

The callback function or class can be provided to the run method like any other reference.

1pid_controller = PIDController(AeroShield())
2
3# callback function
4results = pid_controller.run(freq=200, cycles=1000, ref=calc_ref_from_pot)
5
6# class
7my_ref = MyReference(np.arange(500)/5)
8results = pid_controller.run(freq=200, cycles=1000, ref=my_ref)

An added benefit of using functions to calculate the ref over doing so in the controller method is that now, the calculated reference is included in the results array.