AeroController

The AeroController 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 AeroController

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

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

This will run an experiment on the AeroShield with your controller. We will discuss each step in more detail below. 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 AeroController classes, as well as the numpy package.

1import numpy as np
2
3from aeroshield import AeroShield, AeroController

Simple Controller Example: Using the controller Method.

Below 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, 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, 1).

  • angle [°]: 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. In the example below, 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 as the values passed to controller, except for the motor value, which is saturated (limited between [0, 256) ). You’ll also notice the cycles being counted in the terminal.

 1class PotController(AeroController):
 2    def controller(self, t: float, dt: float, ref: float, pot: float, angle: float) -> float:
 3        return pot
 4
 5
 6aero_shield = AeroShield()
 7pot_controller = PotController(aero_shield)
 8results = pot_controller.run(freq=200, cycles=1000)
 9
10results.shape
999
(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 AeroController class might become more clear with a slightly more complex example.

Complex Controller Example: Adding 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 defined 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(AeroController):
 2    def variables(self) -> None:
 3        # preface every variable defined in subclass with var_ to avoid overwrites.
 4        self.var_kp = 2.5
 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, angle: float) -> float:
14        error = ref - angle
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        try:
21            differential = self.var_kp*self.var_kd*(error - self.var_prev_error)/dt
22        except ZeroDivisionError:
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
999
(1000, 6)