Introduction
Continuing from the first post, where we hooked up X-Plane to our Python code, we will build a wing leveler today. The first post was just about making sure we could get data into and out of X-Plane. Today will add a few features to our XPlane autopilot written in Python.
- A control loop timer (we will be targeting a loop frequency of 10 Hz, or 10 updates per second)
- Additional data feeds from X-Plane
- Two PID controllers, one for roll, one for pitch
- Some debugging output to keep track of what the PIDs are doing
The full code will be at the end of this post.
Video Link
Python Tutorial: code a wing leveler in X-Plane using PID loops
Contents
- Control loop timer/limiter
- Obtaining current pitch/roll values from X-Plane
- Initializing the PID controllers
- Feeding the PID controllers within the control loop
- Controlling the aircraft with the new PID output
- Monitoring the control loops via debug prints
1 – Control loop timer/limiter
Since we will be targeting a 10 Hz update rate, we need to develop a method to ensure the loop does not run more frequent than once every 100 milliseconds. We do not want the loop running uninhibited, because that will result in variable loop execution times and we like to keep those things constant. It could potentially execute thousands of times per second, which is entirely unnecessary. Most control loop algorithms run in the 10-100 Hz range (10-100 times per second). For reference, my RC plane running ArduPlane on Pixhawk uses 50 Hz as a standard update frequency.
To accomplish this task, we need to set up some timing variables.
First of all, add an import statment for datetime and timedelta:
from datetime import datetime, timedelta
Next, define the timing variables:
update_interval = 0.100 # this value is in seconds. 1/0.100 = 10 which is the update interval in Hz # start is set to the time the line of code is executed, which is essential when the program started start = datetime.now() # last_update needs to be set to start for the first execution of the loop to successfully run. # alternatively, we could've set start to something in the past. last_update = start # last update needs to be defined as a global within the monitor() function: def monitor(): global last_update
That handles the variables. Now we need to limit the loop execution. To do so requires wrapping all of the loop code into a new if statement that evaluates the time and only executes if the current time is 100 milliseconds greater than the last loop execution:
# loop is the "while True:" statement while True: # this if statement is evaluated with every loop execution if (datetime.now() > last_update + timedelta(milliseconds = update_interval * 1000)): # when the if statement evaluates to true, the first thing we'll do is set the last update to the current time so the next iteration fires at the correct time last_update = datetime.now() # rest of the loop code goes here
2 – Obtaining current roll/pitch values from X-Plane
This task is pretty straightforward. In the first post, the monitorExample.py code included obtaining the position with posi = client.getPOSI(). There are 7 elements returned with that method, and two of them are roll and pitch.
Put the below after the .getPOSI() call.
current_roll = posi[4] current_pitch = posi[3]
3 – Initializing the PID controllers
First you need to get the PID control file from the Ivmech (Ivmech Mechatronics Ltd.) GitHub page. Direct link PID.py file here. Put the file in the same working directory as everything else then import it with import PID at the top with the rest of the imports.
Then we can initialize the control instances. PID controllers are magic. But they do need to be set with smart values for the initial run. The default values with the PID library are P=0.2, I=0, D=0. This essentially means make the output be 20% of the error between the setpoint and the current value. For example, if the aircraft has a roll of 10 degrees to the left (-10), and the P=0.2, the output from the PID controller will be -2.
When setting PID values, it is almost always a good idea to start small and work your way up. If your gains are too high, you could get giant oscillations and other undesirable behaviors. In the YouTube video, I talk about the various types of PID controllers. They will basically always have a P (proportional) set. Many will also have an I (integral) value set, but not many use the D term (derivative).
Going with the “less is more” truism with PID controllers, I started with a P value of 0.1, and an I value of 0.01 (P/10). The I term (integral) is meant to take care of accumulated errors (which are usually long term errors). An example is your car’s cruise control going up a hill. If your cruise control is set to 65 mph, it will hold 65 no problem on flat roads. If you start going up a hill, the controller will slowly apply more throttle. With an I of 0, your car would never get to 65. It would probably stay around 62 or so. With the integrator going, the error will accumulate and boost the output (throttle) to get you back up to your desired 65. In the linked YouTube video, I show what happens when the I value is set to 0 and why it is necessary to correct long-term errors.
These values (P=0.1, I=0.01, D=0) turned out to work perfectly.
Place the following before the monitor function:
# defining the initial PID values P = 0.1 # PID library default = 0.2 I = P/10 # default = 0 D = 0 # default = 0 # initializing both PID controllers roll_PID = PID.PID(P, I, D) pitch_PID = PID.PID(P, I, D) # setting the desired values # roll = 0 means wings level # pitch = 2 means slightly nose up, which is required for level flight desired_roll = 0 desired_pitch = 2 # setting the PID set points with our desired values roll_PID.SetPoint = desired_roll pitch_PID.SetPoint = desired_pitch
4 – Updating the PID controllers within the control loop
With the PIDs created, they will need to be updated with the new, current pitch and roll values with every loop execution.
Place the following after current_roll and current_pitch are assigned:
roll_PID.update(current_roll) pitch_PID.update(current_pitch)
5 – Controlling the aircraft with the new PID output
Updating the PID controller instances will generate new outputs. We can use those outputs to set the control surfaces. You can place these two lines directly below the update lines:
new_ail_ctrl = roll_PID.output new_ele_ctrl = pitch_PID.output
So we have new control surface values – now we need to actually move the control surfaces. This is accomplished by sending an array of 4 floats with .sendCTRL():
# ele, ail, rud, thr. -998 means don't set/change ctrl = [new_ele_ctrl, new_ail_ctrl, 0.0, -998] client.sendCTRL(ctrl)
6 – Monitoring the control loops via debug prints
The last bit to tie a bunch of this together is printing out values with every loop execution to ensure things are heading the right direction. We will turn these into graphs in the next post or two.
We will be concatenating strings because it’s easy and we aren’t working with enough strings for it to be a performance problem.
In newer Python versions (3.6+), placing ‘f’ before the quotes in a print statement (f””) means the string is interpolated. This means you can basically put code in the print statement, which makes creating print statements much easier and cleaner.
The first line will print out the current roll and pitch value (below). We are using current_roll and current_pitch interpolated. The colon, then blank space with 0.3f is a string formatter. It rounds the value to 3 decimal places and leaves space for a negative. It results in things being lined up quite nicely.
output = f"current values -- roll: {current_roll: 0.3f}, pitch: {current_pitch: 0.3f}"
The next code statement will add a new line to the previous output, and also add the PID outputs for reference:
output = output + "\n" + f"PID outputs -- roll: {roll_PID.output: 0.3f}, pitch: {pitch_PID.output: 0.3f}"
The final line will just add another new line to keep each loop execution’s print statements grouped together:
output = output + "\n"
Finally, we print the output with print(output), which will look like this:
loop start - 2021-10-19 14:24:26.208945 current values -- roll: 0.000, pitch: 1.994 PID outputs -- roll: -0.000, pitch: 0.053
Full code of pitch_roll_autopilot.py
import sys import xpc import PID from datetime import datetime, timedelta update_interval = 0.100 # seconds start = datetime.now() last_update = start # defining the initial PID values P = 0.1 # PID library default = 0.2 I = P/10 # default = 0 D = 0 # default = 0 # initializing both PID controllers roll_PID = PID.PID(P, I, D) pitch_PID = PID.PID(P, I, D) # setting the desired values # roll = 0 means wings level # pitch = 2 means slightly nose up, which is required for level flight desired_roll = 0 desired_pitch = 2 # setting the PID set points with our desired values roll_PID.SetPoint = desired_roll pitch_PID.SetPoint = desired_pitch def monitor(): global last_update with xpc.XPlaneConnect() as client: while True: if (datetime.now() > last_update + timedelta(milliseconds = update_interval * 1000)): last_update = datetime.now() print(f"loop start - {datetime.now()}") posi = client.getPOSI(); ctrl = client.getCTRL(); current_roll = posi[4] current_pitch = posi[3] roll_PID.update(current_roll) pitch_PID.update(current_pitch) new_ail_ctrl = roll_PID.output new_ele_ctrl = pitch_PID.output ctrl = [new_ele_ctrl, new_ail_ctrl, 0.0, -998] # ele, ail, rud, thr. -998 means don't change client.sendCTRL(ctrl) output = f"current values -- roll: {current_roll: 0.3f}, pitch: {current_pitch: 0.3f}" output = output + "\n" + f"PID outputs -- roll: {roll_PID.output: 0.3f}, pitch: {pitch_PID.output: 0.3f}" output = output + "\n" print(output) if __name__ == "__main__": monitor()
Using the autopilot / Conclusion
To use the autopilot, fire up XPlane, hop in a small-ish plane (gross weight less than 10k lb), take off, climb 1000′, then execute the code. Your plane should level off within a second or two in both axis.
Here is a screenshot of the output after running for a few seconds:
The linked YouTube video shows the aircraft snapping to the desired pitch and roll angles very quickly from a diving turn. The plane is righted to within 0.5 degrees of the setpoints within 2 seconds.
A note about directly setting the control surfaces
If you are familiar with PIDs and/or other parts of what I’ve discussed here, you’ll realize that we could be setting large values for the control surfaces (i.e. greater than 1 or less than -1). We will address that next post with a normalization function. It will quickly become a problem when a pitch of 100+ is commanded for altitude hold. I have found that XPlane will allow throttle values of more than 100% (I’ve seen as high as ~430%) if sent huge throttle values.