Skip to main content

Hang Glider

The hang glider problem is a classical benchmark in optimal control. The problem seeks to determine the flight path that maximizes the horizontal distance traveled while descending between two specified altitudes. The glider is modeled as a point mass subject to three forces: lift LL, drag DD, and weight W=mgW = mg. The glider flies through an atmospheric thermal updraft field that provides position-dependent vertical wind assistance.

The pilot controls the glider through the lift coefficient CLC_L, which determines the balance between lift and drag forces. The final flight time tft_f is a free optimization variable with bounds. The optimization must determine both the control history CL(t)C_L(t) and the flight duration tft_f that maximize the final horizontal distance.

Optimal Control Formulation​

Objective​

Maximize the horizontal distance at final time:

max⁡CL(t), tfJ=x(tf)\max_{C_L(t), \, t_f} \quad J = x(t_f)

State Variables​

The system state is described by four variables:

q=[x, y, vx, vy]T∈R4\mathbf{q} = [x, \, y, \, v_x, \, v_y]^T \in \mathbb{R}^4

where:

  • xx : horizontal distance (m)
  • yy : altitude (m)
  • vxv_x : horizontal velocity (m/s)
  • vyv_y : vertical velocity (m/s)

Equations of Motion​

The glider dynamics are governed by Newton's second law applied to the forces acting on the point mass:

x˙=vxy˙=vyv˙x=−Lsin⁡η−Dcos⁡ηmv˙y=Lcos⁡η−Dsin⁡η−mgm\begin{aligned} \dot{x} &= v_x \\ \dot{y} &= v_y \\ \dot{v}_x &= \frac{-L \sin\eta - D \cos\eta}{m} \\ \dot{v}_y &= \frac{L \cos\eta - D \sin\eta - mg}{m} \end{aligned}
Hang Glider Free Body Diagram

Figure 1. Forces and velocity components.


Aerodynamic Forces​

The lift and drag forces depend on the relative airspeed vrv_r and are given by:

L=12ρSCLvr2,D=12ρSCDvr2L = \frac{1}{2} \rho S C_L v_r^2, \quad D = \frac{1}{2} \rho S C_D v_r^2

The drag coefficient follows a quadratic drag polar:

CD=C0+kCL2C_D = C_0 + k C_L^2

Thermal Updraft Model​

The atmosphere contains a localized region of rising air (thermal) that the glider can exploit. The vertical wind velocity is a function of horizontal position:

ua(x)=uM(1−X)e−X,X=(xR−2.5)2u_a(x) = u_M (1 - X) e^{-X}, \quad X = \left(\frac{x}{R} - 2.5\right)^2

This creates a bell-shaped updraft profile centered at x=250x = 250 m.

Relative Velocity and Flight Path Angle​

The aerodynamic forces depend on the glider's velocity relative to the moving air mass:

Vy=vy−ua(x),vr=vx2+Vy2V_y = v_y - u_a(x), \quad v_r = \sqrt{v_x^2 + V_y^2}

The flight path angle η\eta (angle of the velocity vector relative to horizontal) is defined through:

sin⁡η=Vyvr,cos⁡η=vxvr\sin\eta = \frac{V_y}{v_r}, \quad \cos\eta = \frac{v_x}{v_r}

Control and Constraints​

The control variable is the lift coefficient:

CL(t)∈[0,1.4]C_L(t) \in [0, 1.4]

The final time is a free optimization variable with bounds:

tf∈[50,200] secondst_f \in [50, 200] \text{ seconds}

System Parameters​

ParameterSymbolValueDescription
Massmm100kg
Gravitational accelerationgg9.80665m/s²
Wing areaSS14m²
Air densityρ\rho1.13kg/m³
Parasitic drag coefficientC0C_00.034—
Induced drag factorkk0.069662—
Peak updraft strengthuMu_M2.5m/s
Thermal radiusRR100m

Boundary Conditions​

Initial conditions at t=0t = 0:

x(0)=0 my(0)=1000 mvx(0)=13.227567500 m/svy(0)=−1.2875005200 m/s\begin{aligned} x(0) &= 0 \text{ m} \\ y(0) &= 1000 \text{ m} \\ v_x(0) &= 13.227567500 \text{ m/s} \\ v_y(0) &= -1.2875005200 \text{ m/s} \end{aligned}

Terminal conditions at t=tft = t_f:

y(tf)=900 mvx(tf)=13.227567500 m/svy(tf)=−1.2875005200 m/sx(tf)=free (to be maximized)\begin{aligned} y(t_f) &= 900 \text{ m} \\ v_x(t_f) &= 13.227567500 \text{ m/s} \\ v_y(t_f) &= -1.2875005200 \text{ m/s} \\ x(t_f) &= \text{free (to be maximized)} \end{aligned}

The terminal velocity constraints return the glider to its initial flight condition at the lower altitude.

Implementation in Amigo​

We now demonstrate how to solve this optimal control problem using Amigo's direct collocation framework. The implementation uses trapezoidal collocation with free final time, where the time step Δt=tf/N\Delta t = t_f / N depends on the optimization variable tft_f.

Import the required packages:

import amigo as am
import numpy as np
import matplotlib.pyplot as plt

Component: Glider Dynamics​

This component implements the equations of motion, aerodynamic forces, and thermal updraft model:

class GliderDynamics(am.Component):
def __init__(self, scaling):
super().__init__()
self.scaling = scaling

# Constants (SI units)
self.add_constant("uM", value=2.5) # Max updraft [m/s]
self.add_constant("m", value=100.0) # Mass [kg]
self.add_constant("R", value=100.0) # Thermal radius [m]
self.add_constant("S", value=14.0) # Wing area [m²]
self.add_constant("C0", value=0.034) # Parasitic drag
self.add_constant("rho", value=1.13) # Air density [kg/m³]
self.add_constant("k", value=0.069662) # Induced drag factor
self.add_constant("g", value=9.80665) # Gravity [m/s²]

# Inputs and constraints
self.add_input("CL", label="Control")
self.add_input("q", shape=4, label="state variables")
self.add_input("qdot", shape=4, label="state derivatives")
self.add_constraint("res", shape=4, label="dynamics")

def compute(self):
# Extract constants and inputs
uM, m, R, S = self.constants["uM"], self.constants["m"], \
self.constants["R"], self.constants["S"]
C0, rho, k, g = self.constants["C0"], self.constants["rho"], \
self.constants["k"], self.constants["g"]

CL = self.inputs["CL"]
q = self.inputs["q"]
qdot = self.inputs["qdot"]

# State variables (with scaling)
x = self.scaling["distance"] * q[0]
y = self.scaling["distance"] * q[1]
vx = self.scaling["velocity"] * q[2]
vy = self.scaling["velocity"] * q[3]

# Thermal updraft model
X = ((x / R) - 2.5)**2
ua = uM * (1.0 - X) * am.exp(-X)

# Relative velocity (airspeed)
Vy = vy - ua
vr = (vx*vx + Vy*Vy)**0.5

# Flight path angle
sin_eta = Vy / vr
cos_eta = vx / vr

# Aerodynamic forces
CD = C0 + k * CL * CL
q_dyn = 0.5 * rho * S * vr * vr # Dynamic pressure × area
D = q_dyn * CD
L = q_dyn * CL

# Dynamics residuals
res = [
qdot[0] - vx / self.scaling["distance"],
qdot[1] - vy / self.scaling["distance"],
qdot[2] - (-L*sin_eta - D*cos_eta) / (m * self.scaling["velocity"]),
qdot[3] - (L*cos_eta - D*sin_eta - m*g) / (m * self.scaling["velocity"])
]

self.constraints["res"] = res

Component: Trapezoidal Integration with Free Final Time​

class TrapezoidRule(am.Component):
def __init__(self, scaling):
super().__init__()
self.scaling = scaling

# Final time is an optimization variable
self.add_input("tf")
self.add_input("q1")
self.add_input("q2")
self.add_input("q1dot")
self.add_input("q2dot")
self.add_constraint("res")

def compute(self):
# Time step depends on final time
tf = self.scaling["time"] * self.inputs["tf"]
dt = tf / num_time_steps

q1, q2 = self.inputs["q1"], self.inputs["q2"]
q1dot, q2dot = self.inputs["q1dot"], self.inputs["q2dot"]

# Trapezoidal integration
self.constraints["res"] = q2 - q1 - 0.5*dt*(q1dot + q2dot)

Boundary Conditions and Objective​

class InitialConditions(am.Component):
def __init__(self, scaling):
super().__init__()
self.scaling = scaling
self.add_input("q", shape=4)
self.add_constraint("res", shape=4)

def compute(self):
x0, y0, vx0, vy0 = 0.0, 1000.0, 13.227567500, -1.2875005200
q = self.inputs["q"]
self.constraints["res"] = [
q[0] - x0 / self.scaling["distance"],
q[1] - y0 / self.scaling["distance"],
q[2] - vx0 / self.scaling["velocity"],
q[3] - vy0 / self.scaling["velocity"]
]

class FinalConditions(am.Component):
def __init__(self, scaling):
super().__init__()
self.scaling = scaling
self.add_input("q", shape=4)
self.add_constraint("res", shape=3)

def compute(self):
yF, vxF, vyF = 900.0, 13.227567500, -1.2875005200
q = self.inputs["q"]
self.constraints["res"] = [
q[1] - yF / self.scaling["distance"],
q[2] - vxF / self.scaling["velocity"],
q[3] - vyF / self.scaling["velocity"]
]

class RangeObjective(am.Component):
def __init__(self, scaling):
super().__init__()
self.scaling = scaling
self.add_input("xf")
self.add_objective("obj")

def compute(self):
self.objective["obj"] = -self.inputs["xf"]

Model Assembly​

Model Creation and Linking​

scaling = {"velocity": 10.0, "distance": 100.0, "time": 10.0}
num_time_steps = 100

# Create components
gd = GliderDynamics(scaling)
trap = TrapezoidRule(scaling)
obj = RangeObjective(scaling)
ic = InitialConditions(scaling)
fc = FinalConditions(scaling)

model = am.Model("hang_glider")

# Add components
model.add_component("gd", num_time_steps + 1, gd)
model.add_component("trap", 4 * num_time_steps, trap)
model.add_component("obj", 1, obj)
model.add_component("ic", 1, ic)
model.add_component("fc", 1, fc)

# Link states and derivatives
for i in range(4):
start, end = i * num_time_steps, (i + 1) * num_time_steps
model.link(f"gd.q[:{num_time_steps}, {i}]", f"trap.q1[{start}:{end}]")
model.link(f"gd.q[1:, {i}]", f"trap.q2[{start}:{end}]")
model.link(f"gd.qdot[:-1, {i}]", f"trap.q1dot[{start}:{end}]")
model.link(f"gd.qdot[1:, {i}]", f"trap.q2dot[{start}:{end}]")

# Link boundary conditions and objective
model.link("gd.q[0, :]", "ic.q[0, :]")
model.link(f"gd.q[{num_time_steps}, :]", "fc.q[0, :]")
model.link(f"gd.q[{num_time_steps}, 0]", "obj.xf[0]")

# CRITICAL: Link all final times together
model.link("trap.tf[1:]", "trap.tf[0]")

# Compile and initialize
model.build_module()
model.initialize()

Initial Guess and Bounds​

x = model.create_vector()
tf_guess = 100.0
t_frac = np.linspace(0, 1, num_time_steps + 1)

# State initial guess (linear interpolation)
x["gd.q[:, 0]"] = (t_frac * 1250.0) / scaling["distance"]
x["gd.q[:, 1]"] = (1000 - 100*t_frac) / scaling["distance"]
x["gd.q[:, 2]"] = 13.23 / scaling["velocity"]
x["gd.q[:, 3]"] = -1.29 / scaling["velocity"]

# State derivative initial guess
x["gd.qdot[:, 0]"] = (1250.0 / tf_guess) / scaling["distance"]
x["gd.qdot[:, 1]"] = (-100.0 / tf_guess) / scaling["distance"]
x["gd.qdot[:, 2]"] = 0.0
x["gd.qdot[:, 3]"] = 0.0

# Control and final time initial guess
x["gd.CL"] = 1.0
x["trap.tf"] = tf_guess / scaling["time"]

# Variable bounds
lower, upper = model.create_vector(), model.create_vector()
lower["gd.CL"], upper["gd.CL"] = 0.0, 1.4
lower["trap.tf"], upper["trap.tf"] = 50.0/scaling["time"], 200.0/scaling["time"]
lower["gd.q"], upper["gd.q"] = -float("inf"), float("inf")
lower["gd.qdot"], upper["gd.qdot"] = -float("inf"), float("inf")

Solution​

Optimization​

opt = am.Optimizer(model, x, lower=lower, upper=upper)

data = opt.optimize({
"barrier_strategy": "monotone",
"initial_barrier_param": 0.1,
"max_iterations": 500,
"convergence_tolerance": 1e-8,
"init_least_squares_multipliers": True,
})

# Extract optimal solution
tf_opt = x["trap.tf[0]"] * scaling["time"]
xf_opt = x[f"gd.q[{num_time_steps}, 0]"] * scaling["distance"]

print(f"Optimal final time: {tf_opt:.2f} seconds")
print(f"Maximum range: {xf_opt:.2f} meters")

Numerical Results​

The optimizer converges to the following solution:

  • Optimal flight time: tf∗=98.45t_f^* = 98.45 seconds
  • Maximum range: xf∗=1247.75x_f^* = 1247.75 meters

Optimal Trajectory​

Hang Glider Solution

Figure 2. Optimal state trajectories and control history.


References​

  1. Betts, J. T. (2010). Practical Methods for Optimal Control and Estimation Using Nonlinear Programming (2nd ed.). SIAM. Chapter 10, Problem 10.2.

  2. Bulirsch, R., Nerz, E., Pesch, H. J., & von Stryk, O. (1993). Combining direct and indirect methods in optimal control: Range maximization of a hang glider. Optimal Control: Calculus of Variations, Optimal Control Theory and Numerical Methods, 273-288.