Wing aerodynamic optimization

5de7d5d5f9ca407eaae5cd2213fe21b5

In this example we consider a 50-dimensional nonlinear constrained optimization problem to optimize the shape of a wing to minimize induced drag, subject to a wing-area equality constraint. We also compare the performance of modern automatic differentiation to the use of (classical) finite-difference methods.

[1]:
import torchmdo
import torch
import matplotlib.pyplot as plt
from time import time
from pdb import set_trace
%matplotlib inline

Model of an aircraft wing

First, create an class to define a wing and to compute its induced drag using a lifting-line model. Note that we must inherit torchmdo.Model and define a compute method that computes all objectives and constraints (which we’ll talk about later). In the __init__ constructor, we initialize the wing as a square wing with constant chord.

[2]:
class Wing(torchmdo.Model):
    def __init__(self, Cl_target: float, num_discretize: int):
        self.span = 10.0 # 10 m span
        # establish a computational grid (using the lifting-line change of variables)
        self.section_endpoints = -self.span/2 * torch.cos(
            torch.linspace(torch.pi / 2, torch.pi, num_discretize + 1))
        self.section_lengths = torch.diff(self.section_endpoints)
        self.section_midpoints = (self.section_endpoints[:-1] + self.section_endpoints[1:])/2
        # initialize the chords variable which we will optimize
        self.chords =  1.0 * torch.ones_like(self.section_midpoints) # initially 1 m constant chord
        # specify that the airfoil gives zero lift at -3deg and that it is untwisted
        self.alpha_sections = torch.zeros_like(self.section_midpoints)
        self.alpha_0s = torch.deg2rad(-3.0*torch.ones_like(self.section_midpoints))
        # specify a target Cl
        self.Cl_target = torch.as_tensor(Cl_target)

    def compute(self) -> None:
        # compute the wing area for the full wing (both halves)
        self.wing_area = 2*torch.sum(self.section_lengths * self.chords)
        # compute the induced drag coefficient and span efficiency factor
        wing = torchmdo.lifting_line.LiftingLineWing(
            self.section_midpoints, chords=self.chords,
            alpha_sections=self.alpha_sections, alpha_0s=self.alpha_0s,
            span=self.span, wing_area=self.wing_area
        )
        self.Cd, self.span_efficiency_factor = wing.induced_drag(Cl=self.Cl_target)[:2]
        self.Cl_section = wing.section_lift_coeff(Cl=self.Cl_target)

Don’t worry about this block, it simply defines a function to plot the wing planview and a callback function for creating a gif.

[3]:
def plot(wing: Wing, save_fname=None):
    """ plot the wing assuming the spar is at 1/4 chord. """
    plt.figure("wing", figsize=(8,3))
    plt.clf(); plt.cla();
    plt.title("Wing planview")
    x = torch.cat([wing.section_midpoints, torch.flip(wing.section_midpoints, (0,))])
    y = torch.cat([0.25*wing.chords, -0.75*torch.flip(wing.chords, (0,))])
    x[0] = x[-1] = 0.0 # make sure the root is closed
    plt.fill_between(x, y.detach(), color='k')
    plt.xlabel("Spanwise position (m)")
    plt.xlim([0, 5.1])
    plt.ylim([-1.1, 0.5])
    if save_fname is not None:
        plt.savefig(save_fname, dpi=150, bbox_inches='tight')

def gif_callback(xk, res, optimizer):
    optimizer.variables_tensor = torch.as_tensor(xk)
    plot(wing=optimizer.model, save_fname="%03d_tmp.jpg" % res["niter"])
    return optimizer.callback(xk, res)

Optimize

Optimize the chord distribution by maximizing the span efficiency factor (equivalent to minimizing induced drag) subject to a wing area equality constraint. Based on the parameterization we choose, this gives a \(50\)-dimensional optimization problem.

For setup we will arbitrarily choose a target lift coefficient of \(C_L = 0.5\) and enforce the wing area to be \(10 m^2\) for this aircraft whose span is \(10 m\). Note that the maximum span efficiency factor is 1 when an elliptical lift distribution is recovered so we expect this to be our optimal value. Also, note that the wing area is linear in the design variables, therefore, we flag this constraint as linear.

[4]:
wing = Wing(Cl_target=0.5, num_discretize=50)
optimizer = torchmdo.Optimizer(
    initial_design_variables=[torchmdo.DesignVariable(name="chords", lower=0.)],
    model=wing,
    objective=torchmdo.Maximize(name="span_efficiency_factor"),
    constraints=[
        torchmdo.Constraint(name="wing_area", lower=10.0, upper=10.0, linear=True),
    ],
)
optimizer.optimize(display_step=10)
plot(wing)

# the code below is only for creating a gif:
# import imageio, os
# res = optimizer.optimize(display_step=10,
#     callback=lambda xk, res: gif_callback(xk, res, optimizer), maxiter=1000)
# images = [imageio.v2.imread("%03d_tmp.jpg" % (i+1)) for i in range(res["niter"])]
# imageio.mimwrite("wing_aerodynamic_optimization.gif", images, loop=0,
#                  duration=[1.0] + [0.1]*(res["niter"]-1))
# for i in range(res["niter"]): os.remove("%03d_tmp.jpg" % (i+1))
[ 15:21:19 ] INFO: Initial objective: 0.921.
[ 15:21:19 ] INFO: Beginning optimization.
[ 15:21:19 ] INFO: niter: 0001, fun: 0.9209, constr_violation: 0, execution_time: 0.0s
[ 15:21:20 ] INFO: niter: 0010, fun: 0.9646, constr_violation: 0, execution_time: 1.0s
[ 15:21:22 ] INFO: niter: 0020, fun: 0.9985, constr_violation: 0, execution_time: 2.7s
[ 15:21:23 ] INFO: niter: 0030, fun: 0.9993, constr_violation: 1.78e-15, execution_time: 4.4s
[ 15:21:25 ] INFO: niter: 0040, fun: 0.9994, constr_violation: 1.78e-15, execution_time: 6.0s
[ 15:21:27 ] INFO: niter: 0050, fun: 0.9998, constr_violation: 0, execution_time: 7.8s
[ 15:21:28 ] INFO: niter: 0060, fun: 0.9999, constr_violation: 0, execution_time: 9.3s
[ 15:21:30 ] INFO: niter: 0070, fun: 0.9999, constr_violation: 1.78e-15, execution_time: 11.1s
[ 15:21:32 ] INFO: niter: 0080, fun: 0.9999, constr_violation: 0, execution_time: 12.5s
[ 15:21:33 ] INFO: niter: 0090, fun: 0.9999, constr_violation: 1.78e-15, execution_time: 14.1s
[ 15:21:35 ] INFO: niter: 0100, fun: 1.0000, constr_violation: 0, execution_time: 15.9s
[ 15:21:37 ] INFO: niter: 0110, fun: 1.0000, constr_violation: 0, execution_time: 17.9s
[ 15:21:39 ] INFO: niter: 0120, fun: 1.0000, constr_violation: 0, execution_time: 19.6s
[ 15:21:40 ] INFO: niter: 0130, fun: 1.0000, constr_violation: 0, execution_time: 21.2s
[ 15:21:42 ] INFO: niter: 0140, fun: 1.0000, constr_violation: 0, execution_time: 22.9s
[ 15:21:44 ] INFO: niter: 0150, fun: 1.0000, constr_violation: 0, execution_time: 24.6s
[ 15:21:45 ] INFO: niter: 0160, fun: 1.0000, constr_violation: 0, execution_time: 26.4s
[ 15:21:47 ] INFO: niter: 0170, fun: 1.0000, constr_violation: 0, execution_time: 27.9s
[ 15:21:47 ] INFO: Optimization completed.
[ 15:21:47 ] INFO: Function Evals: 180. Exit status: 1. Objective: -1. Constraint Violation: 0
[ 15:21:47 ] INFO: Execution time: 28.1s
`gtol` termination condition is satisfied.
../_images/examples_wing_aerodynamic_optimization_7_1.png

Plot the span-wise sectional lift coefficient and lift distribution over the optimized wing. We will assume a sea-level air density (\(1.21kg/m^3\)), and a flight speed of \(50 m/s\).

As expected, we have recovered an elliptical lift distribution (blue), and the lift-coefficient is constant across the wing since we have no twist and the induced downwash is constant across the wing for an elliptical lift distribution.

[5]:
q = 0.5*1.21*50**2 # dynamic pressure
with torch.no_grad():
    wing.compute()
    fig, ax1 = plt.subplots(figsize=(8,3))
    ax1.set_title("Wing lift distribution")
    ax1.plot(wing.section_midpoints, wing.Cl_section*wing.chords*q, 'b')
    ax1.set_xlabel("Spanwise position (m)")
    ax1.set_ylabel("Lift per meter (N/m)", color='b')
    ax1.tick_params(axis='y', labelcolor='b')
    ax2 = ax1.twinx()
    ax2.plot(wing.section_midpoints, wing.Cl_section, 'r')
    ax2.set_ylabel(r"Sectional Lift Coeff ($C_L$)", color='r')
    ax2.tick_params(axis='y', labelcolor='r')
    fig.tight_layout() # otherwise right label is clipped
../_images/examples_wing_aerodynamic_optimization_9_0.png

Compare the speed to finite-difference gradients

We now compare the computation time required to compute the gradient of the objective between PyTorch’s automatic differentiation and finite-difference approximation to the gradient which is what would be used if the gradient of the objective was not available (for example, in scipy.optimize).

The number of design variables are varied by considering a different discretization of wing to give a number of design variables that vary from \(10\) to \(500\). For finite differentiation, we consider a 2-step (a forward- or backward-step) method.

The results are ploted on both a linear scale and a log-log scale. It is evident that the automatic differentiation method is much faster, particularly when there are a large number of design variables. For \(400\) design variables, automatic differentiation is \(~100\times\) faster than finite differentiation. This is because the time to compute the gradient by automatic differentiation is effectively constant with respect to the number of design variables, assuming the cost of the objective doesn’t increase with the dimensionality. This assumption is violated in our case; however, the favorable scaling is still evident.

[6]:
num_design_variables = torch.arange(10, 500, step=100)
num_trial = 10
t_finite_diff = torch.zeros((num_design_variables.numel(), num_trial))
t_auto_diff = torch.zeros((num_design_variables.numel(), num_trial))
for i,ndv in enumerate(num_design_variables):
    # setup model and optimization problem
    wing = Wing(Cl_target=0.5, num_discretize=ndv)
    optimizer = torchmdo.Optimizer(
        initial_design_variables=[torchmdo.DesignVariable(name="chords", lower=0.)],
        model=wing,
        objective=torchmdo.Maximize(name="span_efficiency_factor"),
        constraints=[],
    )
    x = optimizer.variables_tensor.detach()
    # run the trials for each gradient computation
    for trial in range(num_trial):
        # time the computation of the value and gradient by autograd
        optimizer.reset_state()
        t0 = time()
        optimizer.objective_fun(x)
        optimizer.objective_grad(x)
        t_auto_diff[i, trial] = time() - t0
        # time the finite difference gradient
        # for two-step forward finite difference this requires (1 + ndv) objective evaluations
        # so we will just evaluate it once and then multiply the time by this factor
        optimizer.reset_state()
        with torch.no_grad(): # ensure no overhead
            t0 = time()
            optimizer.objective_fun(x)
            t_finite_diff[i, trial] = (time() - t0)*(1 + ndv)
# plot
for plot_type in [plt.plot, plt.loglog]:
    plt.figure(figsize=(7,5))
    plot_type(num_design_variables, t_auto_diff.mean(dim=1), 'b', label="Automatic differentiation")
    plot_type(num_design_variables, t_finite_diff.mean(dim=1), 'r', label="Finite differentiation")
    plt.legend(loc=0)
    plt.title("Time for objective and gradient computation (s)")
    plt.xlabel("Number of design variables");
../_images/examples_wing_aerodynamic_optimization_11_0.png
../_images/examples_wing_aerodynamic_optimization_11_1.png