15  Design Study: A Long-Range Transport Aircraft

16 Design Study: A Long-Range Transport Aircraft

This chapter sizes a complete 400-passenger, 7,500-nautical-mile transport aircraft — a 777-class twinjet — as a single optimization problem. Geometry, cruise aerodynamics (via asb.AeroBuildup), rubber-engine propulsion, a Raymer/TASOPT-style weight buildup, and mission performance all close simultaneously: no sizing loops, no fixed-point iteration, no MDO framework. You will learn how to structure a multidisciplinary sizing model so that it solves reliably (positivity by construction, NaN-safe reformulations, normalized constraints), and how to reuse one traced model for many design questions — including the headline stunt: converting the airplane from Jet A to liquid hydrogen by changing five numbers, then re-solving in seconds.

This model is distilled from the lh2_airplane.py study in the AeroSandbox repository (studies/SimulateAeroBuildupTrajectory/), pruned to its didactic core.

16.1 The design problem

The mission: carry 400 passengers 7,500 nautical miles at Mach 0.82, cruising at 35,000 ft, with two engines. That is roughly the design mission of a Boeing 777-300ER or 777-9, which gives us something rare and valuable in conceptual design: a published answer key.

The classic way to size such an aircraft is a sizing loop: guess a takeoff gross weight (TOGW), size the wing and engines to it, compute the fuel required, add up the weights, get a new TOGW, and iterate until the numbers stop moving. The optimization-native way is to instead declare TOGW as a design variable and write the loop’s convergence condition as a constraint:

\[ \underbrace{m_\text{empty} + m_\text{payload} + m_\text{fuel}}_{\text{what the airplane adds up to}} \leq \underbrace{m_\text{TOGW}}_{\text{what everything was sized to}} \]

The optimizer then closes the loop and optimizes the design in the same breath. Here is the full problem we will pose:

Role Quantity Notes
Minimize design takeoff gross weight \(m_\text{TOGW}\) the classic conceptual-design objective
With respect to \(m_\text{TOGW}\) log-transformed
wing span \(b\) log-transformed, \(\leq 65\) m (gate limit)
wing yehudi chord \(c_\text{yehudi}\) log-transformed; root chord derived
fuel tank interior length \(\ell_\text{tank}\) log-transformed; sets fuel load
cruise angle of attack \(\alpha\) bounded ±few degrees
cruise lift-to-drag handle \((L/D)\) log-transformed helper variable
Subject to mass buildup \(\leq m_\text{TOGW}\) weight closure
lift \(\geq\) weight at mid-cruise vertical force balance
Breguet range \(\geq\) mission range fuel closure (NaN-safe form)
\((L/D) \cdot C_D \leq C_L\) ties the handle to the aero analysis
approach speed \(\leq\) 145 kn field performance
Parameters mission range; fuel properties (5 numbers) swappable without re-tracing

Everything else — fuselage cabin dimensions (set by the passenger count), wing sweep, cruise Mach and altitude, tail geometry, engine count — is frozen at 777-like values. We will be explicit about those freezes and what it would take to relax them in Section 16.11.

16.2 Parameters vs. variables

A decision that pays off later: quantities we intend to sweep are declared as opti.parameter() rather than baked-in constants. A parameter is a symbolic leaf in the computational graph whose value can be changed after the problem is built — so one traced model can answer many design questions, each re-solve taking seconds. (This is the same mechanism behind the design sweeps in the solvers chapter.)

import aerosandbox as asb
import aerosandbox.numpy as np
from aerosandbox.tools import units as u

opti = asb.Opti()

### Mission definition
n_pax = 400
mach_cruise = 0.82
altitude_cruise = 35e3 * u.foot

mission_range = opti.parameter(value=7500 * u.naut_mile)

### Fuel properties, as parameters so we can swap fuels later without re-tracing.
# LH2 values from Brewer, "Hydrogen Aircraft Technology", pg. 203.
fuels = {
    "Jet A": dict(
        density=820,  # kg/m^3
        specific_energy=43.02e6,  # J/kg, lower heating value
        tank_fuel_mass_fraction=0.993,  # m_fuel / (m_fuel + m_tank)
        tank_wall_thickness=0.005,  # m
        system_mass_multiplier=1.0,  # fuel lines, pumps
    ),
    "LH2": dict(
        density=70,
        specific_energy=119.93e6,
        tank_fuel_mass_fraction=1 / (1 + 0.356),  # insulated cryogenic tank
        tank_wall_thickness=0.0612,  # includes insulation
        system_mass_multiplier=2.2,  # cryo pumps, vent lines
    ),
}

fuel = {k: opti.parameter(value=v) for k, v in fuels["Jet A"].items()}  # Jet A baseline

Now the design variables. Every one that is physically positive gets log_transform=True. This is not a style preference — it is what makes a model this nonlinear solve reliably. A log-transformed variable is positive at every iterate, including the infeasible ones the optimizer merely passes through, so downstream expressions like \((\text{wing area})^{0.649}\) can never see a negative base. (The full argument for this, with failure modes, is in the robust-optimization-models chapter.)

design_mass_TOGW = opti.variable(init_guess=300e3, log_transform=True)  # kg

# ICAO Code E gates cap the span at 65 m -- the actual reason the 777-300ER's
# span is 64.8 m (and why the 777-9 folds its wingtips). Without this bound,
# the optimizer runs to aspect ratios no airport could park.
wing_span = opti.variable(init_guess=60, log_transform=True, upper_bound=65)  # m

wing_yehudi_chord = opti.variable(init_guess=9, log_transform=True, upper_bound=20)  # m

# Interior length of EACH of the two fuselage fuel tanks. Making the *interior*
# length the variable (rather than the exterior) keeps the tank volume positive
# by construction, even at wall thicknesses the optimizer never sees.
tank_interior_length = opti.variable(init_guess=3, log_transform=True, upper_bound=20)  # m

alpha_cruise = opti.variable(init_guess=3, lower_bound=-5, upper_bound=10)  # deg

Note what is not a variable: the wing root chord. The planform has a straight inboard trailing edge (the “yehudi” of a Boeing-style wing), so the root chord is derived from the yehudi chord plus the sweep — guaranteeing every chord is positive at any iterate, with no constraint needed. As a rule: when quantity B is quantity A plus something nonnegative, make A the variable and derive B.

16.3 Geometry

We write the geometry as a plain function. Called with floats, it builds a numeric airplane we can draw; called with opti variables, it builds the symbolic airplane the optimizer sees. This backend-agnosticism is the core AeroSandbox idiom — the same code traces through CasADi or runs on NumPy (see the geometry chapter for the full Airplane stack).

### Frozen configuration constants (777-class)
cabin_diameter = 6.20  # m, twin-aisle cabin for ~400 pax
cabin_length = 37.6  # m
nose_fineness, tail_fineness = 1.67, 2.62  # length/diameter of nose, tailcone
wing_sweep_deg = 34  # leading-edge sweep
wing_dihedral_deg = 6
wing_airfoil = asb.Airfoil("b737d")  # 737 outboard transonic section, t/c ~ 10.8%
hstab_airfoil = asb.Airfoil("naca0012")
vstab_airfoil = asb.Airfoil("naca0008")


def make_airplane(wing_span, wing_yehudi_chord, tank_interior_length, tank_wall_thickness):
    """Build the airplane. Works symbolically (opti vars) or numerically (floats)."""
    cabin_radius = cabin_diameter / 2
    tank_length = tank_interior_length + 2 * tank_wall_thickness

    ### Fuselage: nose cone | fwd tank | cabin | aft tank | tail cone.
    # The tanks and cabin share one constant-radius cylinder; only the internal
    # bookkeeping distinguishes them.
    x_fwd_tank = nose_fineness * cabin_diameter  # nose ends here
    x_cabin = x_fwd_tank + tank_length
    x_aft_tank = x_cabin + cabin_length
    x_tail_start = x_aft_tank + tank_length
    x_tail_end = x_tail_start + tail_fineness * cabin_diameter

    t = np.sinspace(0, 1, 8)  # nondimensional arc coordinate for nose/tail shaping
    xf = np.concatenate([
        t * x_fwd_tank,
        np.array([x_tail_start]),
        x_tail_start + t[1:] * (x_tail_end - x_tail_start),
    ])
    zf = np.concatenate([  # centerline droop (nose) and upsweep (tail)
        -0.3 * (1 - t) ** 2 * cabin_radius,
        np.array([0]),
        t[1:] ** 1.5 * cabin_radius,
    ])
    rf = np.concatenate([
        (1 - (1 - t) ** 2) ** 0.5 * cabin_radius,
        np.array([cabin_radius]),
        (1 - t[1:] ** 1.5) * cabin_radius,
    ])

    fuselage = asb.Fuselage(
        name="Fuselage",
        xsecs=[
            asb.FuselageXSec(xyz_c=[xf[i], 0, zf[i]], radius=rf[i])
            for i in range(np.length(xf))
        ],
        analysis_specific_options={
            asb.AeroBuildup: dict(nose_fineness_ratio=nose_fineness)
        },
    )

    ### Wing: root -> yehudi (25% span) -> tip, with straight inboard trailing edge
    half_span = wing_span / 2
    yehudi_y = 0.25 * half_span
    root_chord = wing_yehudi_chord + yehudi_y * np.tand(wing_sweep_deg)

    def wing_xsec(y, chord):
        return asb.WingXSec(
            xyz_le=[y * np.tand(wing_sweep_deg), y, y * np.tand(wing_dihedral_deg)],
            chord=chord,
            airfoil=wing_airfoil,
        )

    wing = asb.Wing(
        name="Main Wing",
        symmetric=True,
        xsecs=[
            wing_xsec(0, root_chord),
            wing_xsec(yehudi_y, wing_yehudi_chord),
            wing_xsec(half_span, 0.14 * root_chord),
        ],
    ).translate([
        0.5 * (x_cabin + x_aft_tank) - 0.5 * root_chord,  # wing carries the cabin
        0,
        -0.5 * cabin_radius,  # low wing
    ])

    ### Tails: frozen 777-scale geometry, translated with the (stretchable) tailcone
    hstab = asb.Wing(
        name="Horizontal Stabilizer",
        symmetric=True,
        xsecs=[
            asb.WingXSec(xyz_le=[0, 0, 0], chord=7.0, airfoil=hstab_airfoil),
            asb.WingXSec(
                xyz_le=[10.8 * np.tand(35), 10.8, 0], chord=2.45, airfoil=hstab_airfoil
            ),
        ],
    ).translate([x_tail_end - 10.5, 0, 0.5 * cabin_radius])

    vstab = asb.Wing(
        name="Vertical Stabilizer",
        xsecs=[
            asb.WingXSec(xyz_le=[0, 0, 0], chord=7.6, airfoil=vstab_airfoil),
            asb.WingXSec(
                xyz_le=[10 * np.tand(35), 0, 10], chord=3.4, airfoil=vstab_airfoil
            ),
        ],
    ).translate([x_tail_end - 11.4, 0, cabin_radius])

    airplane = asb.Airplane(
        name="Transport",
        wings=[wing, hstab, vstab],
        fuselages=[fuselage],
    )
    stations = dict(
        x_fwd_tank=x_fwd_tank, x_cabin=x_cabin, x_aft_tank=x_aft_tank,
        x_tail_start=x_tail_start, x_tail_end=x_tail_end,
    )
    return airplane, stations

Build the symbolic airplane that the optimizer will shape:

airplane, stations = make_airplane(
    wing_span, wing_yehudi_chord, tank_interior_length, fuel["tank_wall_thickness"]
)
wing, hstab, vstab = airplane.wings
fuselage = airplane.fuselages[0]
wing_area = wing.area()

16.4 Cruise aerodynamics

asb.AeroBuildup gives us a full-airplane aerodynamics analysis — a component buildup that blends NeuralFoil section aerodynamics with finite-wing corrections, slender-body and wave-drag models for the fuselage, and component interference factors — and it is smooth and traceable end-to-end (see the aircraft-aerodynamics chapter). We evaluate it once, at the cruise condition, with the angle of attack as a design variable:

atmo = asb.Atmosphere(altitude=altitude_cruise)
V_cruise = mach_cruise * atmo.speed_of_sound()

aero = asb.AeroBuildup(
    airplane=airplane,
    op_point=asb.OperatingPoint(
        atmosphere=atmo,
        velocity=V_cruise,
        alpha=alpha_cruise,
    ),
).run()

The lift-to-drag ratio feeds two downstream disciplines: engine sizing and the Breguet range equation. Rather than passing the raw quotient aero["L"] / aero["D"] around (whose sign is untrustworthy at weird iterates — lift can momentarily be negative), we give the optimizer a handle: a log-transformed variable that is constrained to never exceed what the aerodynamics actually delivers. Since better \(L/D\) only ever helps the objective, the optimizer pulls the handle up until the constraint is tight — at the optimum, the handle is the aerodynamic \(L/D\), but at every iterate in between it is guaranteed positive, keeping the propulsion and range models NaN-free.

We write the constraint that ties the handle to the analysis in coefficient space, because \(C_L\) and \(C_D\) are already order-one — a free win for constraint scaling (multiplying out the denominator, \(L/D \le C_L / C_D\) becomes the product form below). We also add a 5% excrescence-and-leakage drag allowance, the standard conceptual-design tax for antennas, gaps, door seams, and everything else a clean CAD model doesn’t know about:

CD_excrescence_factor = 1.05

LD_cruise = opti.variable(init_guess=18, log_transform=True)
opti.subject_to(LD_cruise * CD_excrescence_factor * aero["CD"] <= aero["CL"])
MX(fabs(opti0_lam_g_6))

16.5 Propulsion: the rubber engine

Conceptual design classically uses a “rubber engine”: take a real reference engine and scale it photographically by the required thrust. Our reference is the GE90 (the 777’s engine family). Thrust requirements at this stage are set not by cruise but by the engine-out climb requirement of 14 CFR § 25.121: with one engine inoperative, a twin must sustain a 2.4% climb gradient. We estimate engine-out \(L/D\) as half of clean cruise \(L/D\) (flaps, gear, windmilling drag, imperfect flying — a TASOPT-style allowance).

g = 9.81
n_engines = 2
climb_gradient_engine_out = 0.024  # 14 CFR 25.121, two-engine aircraft
LD_engine_out = 0.5 * LD_cruise

design_thrust_per_engine = (
    design_mass_TOGW * g / LD_engine_out
    + design_mass_TOGW * g * climb_gradient_engine_out
) / (n_engines - 1)

ref_engine = dict(  # GE90
    thrust=97300 * u.lbf,
    outer_diameter=134 * u.inch,
    mass=17400 * u.lbm,
    TSFC_lb_lb_hr=0.520,  # cruise thrust-specific fuel consumption on Jet A
)

thrust_ratio = design_thrust_per_engine / ref_engine["thrust"]
engine_diameter = ref_engine["outer_diameter"] * thrust_ratio**0.5  # area ~ thrust
engine_mass = ref_engine["mass"] * thrust_ratio**1.1  # slight diseconomy of scale

# Specific impulse: convert TSFC, then scale by fuel energy content. A hydrogen-burning
# turbofan converts each joule about as efficiently as a kerosene one, so Isp scales
# with specific energy.
Isp = (3600 / ref_engine["TSFC_lb_lb_hr"]) * (fuel["specific_energy"] / 43.02e6)  # s

16.6 Weights

Now the discipline that makes or breaks a sizing study. We assemble the empty weight from about twenty components: Raymer’s transport-category regressions for structure, gear, and systems, plus TASOPT-style payload-proportional fractions for the cabin interior. Each formula wants specific inputs — areas in ft², masses in lbm — so we convert with aerosandbox.tools.units at the boundaries and keep everything else SI.

Two bookkeeping notes. First, we track only scalar masses here; the source study also carries centers of gravity and inertia tensors via asb.MassProperties for downstream stability work (see the dynamics chapter). Second, several formulas need the design gross weight — which is exactly the design_mass_TOGW variable, symbolically coupling every component weight back to the closure constraint.

m = {}  # mass of each component, kg
ult_load_factor = 1.5 * 2.5  # 2.5 g limit load, 1.5 safety factor

### Payload and cabin
m["passengers"] = (215 * u.lbm) * n_pax  # incl. carry-on and checked bags
m["payload_proportional"] = 0.485 * m["passengers"]
# ... = seats (0.10) + APU (0.035) + furnishings, galleys, crew, ECS (0.35), per TASOPT

# Pressurization: cabin air is denser than ambient at altitude, and the airplane
# must lift the difference.
cabin_atmo = asb.Atmosphere(altitude=8000 * u.foot)  # cabin pressure altitude
m["cabin_air"] = (
    (cabin_atmo.density() - atmo.density())
    * np.pi * (cabin_diameter / 2) ** 2 * cabin_length
)

### Lifting surfaces (Raymer transport regressions)
m["wing"] = (
    0.0051
    * (design_mass_TOGW / u.lbm * ult_load_factor) ** 0.557
    * (wing_area / u.foot**2) ** 0.649
    * wing.aspect_ratio() ** 0.5
    * wing_airfoil.max_thickness() ** -0.4
    * (1 + wing.taper_ratio()) ** 0.1
    * np.cosd(wing.mean_sweep_angle()) ** -1
    * (0.1 * wing_area / u.foot**2) ** 0.1  # control surface area
) * u.lbm

wing_AC_x = wing.aerodynamic_center()[0]
l_hstab = hstab.aerodynamic_center()[0] - wing_AC_x  # tail arms
l_vstab = vstab.aerodynamic_center()[0] - wing_AC_x

m["hstab"] = (
    0.0379
    * (1 + cabin_diameter / hstab.span()) ** -0.25
    * (design_mass_TOGW / u.lbm) ** 0.639
    * ult_load_factor**0.10
    * (hstab.area() / u.foot**2) ** 0.75
    * (l_hstab / u.foot) ** -1
    * (0.3 * l_hstab / u.foot) ** 0.704
    * np.cosd(hstab.mean_sweep_angle()) ** -1
    * hstab.aspect_ratio() ** 0.166
    * (1 + 0.1) ** 0.1  # elevator fraction
) * u.lbm

m["vstab"] = (
    0.0026
    * (design_mass_TOGW / u.lbm) ** 0.556
    * ult_load_factor**0.536
    * l_vstab**-0.5
    * (vstab.area() / u.foot**2) ** 0.5
    * (l_vstab / u.foot) ** 0.875
    * np.cosd(vstab.mean_sweep_angle()) ** -1
    * vstab.aspect_ratio() ** 0.35
    * vstab_airfoil.max_thickness() ** -0.5
) * u.lbm

### Fuselage
fuse_structural_length = stations["x_tail_start"]
K_ws = (  # wing/fuselage interconnect factor, Raymer
    0.75
    * ((1 + 2 * wing.taper_ratio()) / (1 + wing.taper_ratio()))
    * (wing_span / fuse_structural_length * np.tand(wing.mean_sweep_angle()))
)
m["fuselage"] = (
    0.3280
    * 1.25  # doors: 2 cargo + 1 aft clamshell
    * (design_mass_TOGW / u.lbm * ult_load_factor) ** 0.5
    * (fuse_structural_length / u.foot) ** 0.25
    * (fuselage.area_wetted() / u.foot**2) ** 0.302
    * (1 + K_ws) ** 0.04
    * 16**0.10  # L/D proxy term in Raymer's fit
) * u.lbm

### Propulsion group
m["engines"] = n_engines * engine_mass
m_engine_installed = (  # engine + accessories + thrust reverser, per engine
    2.331 * (engine_mass / u.lbm) ** 0.901 * 1.18
) * u.lbm
nacelle_height = 0.5 * engine_diameter
nacelle_width = 0.2 * engine_diameter
m["nacelles"] = (
    0.6724
    * 1.017  # pylon-mounted
    * (nacelle_height / u.foot) ** 0.10
    * (nacelle_width / u.foot) ** 0.294
    * ult_load_factor**0.119
    * (m_engine_installed / u.lbm) ** 0.611
    * n_engines**0.984
    * (nacelle_height * 0.5 * engine_diameter * 2.05 / u.foot**2) ** 0.224
) * u.lbm
m["engine_systems"] = (
    (5 * n_engines + 0.80 * (fuse_structural_length / 2 / u.foot) * n_engines)  # controls
    + 49.19 * (m["engines"] / u.lbm / 1000) ** 0.541  # starter
) * u.lbm

### Landing gear. Gear length must clear the engine nacelle (wing-mounted) and
# allow rotation at takeoff; np.softmax gives a smooth max of the two conditions.
gear_length = np.softmax(
    1.1 * engine_diameter,
    (fuselage.length() / 2) * np.tand(3.5),  # tail-strike geometry
    hardness=10,
)
m["main_gear"] = (
    0.0106
    * (design_mass_TOGW / u.lbm) ** 0.888
    * ult_load_factor**0.25
    * (gear_length / u.inch) ** 0.4
    * 6**0.321  # wheels per strut
    * 2**-0.5  # shock struts
    * (51) ** 0.1  # stall speed, kn
) * u.lbm
m["nose_gear"] = (
    0.032
    * (design_mass_TOGW / u.lbm) ** 0.646
    * ult_load_factor**0.2
    * (0.9 / 1.1 * gear_length / u.inch) ** 0.5
    * 2**0.45  # wheels
) * u.lbm

### Systems
control_surface_area = 0.15 * (wing_area + hstab.area() + vstab.area())
m["flight_controls"] = (
    145.9
    * 6**0.554  # number of control functions
    * (1 + 1 / 6) ** -1
    * (control_surface_area / u.foot**2) ** 0.20
    * (design_mass_TOGW * l_hstab**2 / (u.lbm * u.foot**2) * 1e-6) ** 0.07
) * u.lbm
m["instruments"] = (
    4.509 * 2**0.541 * n_engines  # 2 crew
    * (cabin_length / u.foot * wing_span / u.foot) ** 0.5
) * u.lbm
m["hydraulics"] = (
    0.2673 * 6 * (cabin_length / u.foot * wing_span / u.foot) ** 0.937
) * u.lbm
m["electrical"] = (
    7.291 * 48**0.782 * (cabin_length / u.foot) ** 0.346 * n_engines**0.10
) * u.lbm
m["avionics"] = 1.73 * 1100**0.983 * u.lbm
m["anti_ice"] = 0.002 * design_mass_TOGW
m["handling_gear"] = 3e-4 * design_mass_TOGW

Finally, the fuel system. Here is the architectural choice that makes the hydrogen conversion meaningful: fuel lives in two cylindrical fuselage tanks, fore and aft of the cabin (the standard liquid-hydrogen arrangement, per Brewer). The tank length is a design variable, so the optimizer sizes the tanks to hold exactly the fuel the mission needs — and the fuselage stretches to make room. We keep the same architecture for Jet A so the fuel comparison is apples-to-apples; a kerosene airplane would normally put fuel in the wing instead, making our Jet A empty weight slightly conservative.

tank_interior_radius = cabin_diameter / 2 - fuel["tank_wall_thickness"]
tank_volume = 2 * np.pi * tank_interior_radius**2 * tank_interior_length  # both tanks
m["fuel"] = fuel["density"] * tank_volume  # tanks fly full at takeoff

m["fuel_tanks"] = m["fuel"] * (
    (1 - fuel["tank_fuel_mass_fraction"]) / fuel["tank_fuel_mass_fraction"]
)
m["fuel_system"] = (  # lines and pumps, Raymer
    2.405
    * (tank_volume / u.gallon) ** 0.606
    * 0.5  # integral tanks
    * n_engines**0.5
) * u.lbm * fuel["system_mass_multiplier"]

mass_TOGW = sum(m.values())
mass_empty = mass_TOGW - m["fuel"] - m["passengers"]

16.7 Closing the design

Four constraints close the airplane. Each is written normalized — both sides divided by a quantity of the same magnitude — so IPOPT sees residuals of order one regardless of whether the underlying quantities are newtons or meganewtons. And none of them divides by an expression that could pass through zero.

Weight closure. The buildup must not exceed the gross weight everything was sized to:

opti.subject_to(mass_TOGW / design_mass_TOGW <= 1)
MX(fabs(opti0_lam_g_7))

Lift equals weight, evaluated at the mid-cruise weight (half the fuel burned):

mass_mid_cruise = design_mass_TOGW - 0.5 * m["fuel"]
opti.subject_to(
    aero["L"] / (g * design_mass_TOGW) >= mass_mid_cruise / design_mass_TOGW
)
MX(fabs(opti0_lam_g_8))

Fuel closure via the Breguet range equation. The textbook form, \(R = V \cdot I_\text{sp} \cdot (L/D) \cdot \ln(m_\text{initial}/m_\text{final})\), contains a trap: at iterates where trial fuel burn exceeds the trial gross weight, the logarithm’s argument is negative and the model returns NaN. The same physics, rearranged with an exp() instead, is defined everywhere:

\[ R \leq V \, I_\text{sp} \, \frac{L}{D} \, \ln\frac{m_i}{m_f} \quad\Longleftrightarrow\quad \frac{m_f}{m_i} \leq \exp\left(\frac{-R}{V \, I_\text{sp} \, (L/D)}\right) \]

We hold 5% of the fuel as reserve (a simple stand-in for the FAR-121 diversion and hold requirements):

fuel_burned = 0.95 * m["fuel"]
opti.subject_to(
    1 - fuel_burned / design_mass_TOGW
    <= np.exp(-mission_range / (V_cruise * Isp * LD_cruise))
)
MX(fabs(opti0_lam_g_9))

Field performance. A floor on wing area: at maximum landing weight, with landing flaps (\(C_{L,\max} = 2.6\)), the approach speed (1.23 times the stall speed, per FAR 25) must not exceed 145 kn:

V_stall_max = 145 * u.knot / 1.23
mass_landing = design_mass_TOGW - fuel_burned  # land with reserves aboard
opti.subject_to(
    0.5 * 1.225 * V_stall_max**2 * wing_area * 2.6 / (g * design_mass_TOGW)
    >= mass_landing / design_mass_TOGW
)
MX(fabs(opti0_lam_g_10))

And the objective:

opti.minimize(design_mass_TOGW / 100e3)  # scaled to order-one

Solve:

import time

t_start = time.time()
sol = opti.solve(verbose=False)
t_solve = time.time() - t_start

print(f"Converged: {sol.stats()['success']}, "
      f"{sol.stats()['iter_count']} iterations, {t_solve:.1f} s")
Converged: True, 8 iterations, 2.8 s

The entire airplane — aerodynamics, twenty-odd weight components, engine scaling, and mission — closes in 8 IPOPT iterations. For perspective: that is comparable to what a bare textbook sizing loop takes to converge its single weight variable, except here each iteration also carries six design degrees of freedom toward optimality.

Convergence of the sizing problem. Objective and constraint violation move together because the design variables and the closure state are solved simultaneously — there is no inner sizing loop to wait on.

16.8 Results: the Jet A baseline

To pull numbers out of a solution, call it like a function on any expression from the model — sol(expr) evaluates the whole upstream graph at the optimum. We wrap the outputs we care about in a helper, since we are about to generate many solutions:

def get_outputs(s, fuel_props):
    """Extract key design outputs from a solution `s`."""
    return dict(
        TOGW=s(design_mass_TOGW),
        empty=s(mass_empty),
        fuel=s(m["fuel"]),
        fuel_energy=s(m["fuel"]) * fuel_props["specific_energy"],
        span=s(wing_span),
        area=s(wing_area),
        AR=s(wing.aspect_ratio()),
        LD=s(LD_cruise),
        CL=s(aero["CL"]),
        alpha=s(alpha_cruise),
        thrust=s(design_thrust_per_engine),
        fuse_length=s(fuselage.length()),
        tank_length=s(tank_interior_length),
    )

out_jeta = get_outputs(sol, fuels["Jet A"])

First, the airplane itself:

airplane_solved, _ = make_airplane(
    sol(wing_span),
    sol(wing_yehudi_chord),
    sol(tank_interior_length),
    fuels["Jet A"]["tank_wall_thickness"],
)
three_view = airplane_solved.draw_three_view()

Three-view of the converged Jet A design. The optimizer chose the span, chords, and fuselage tank length; everything else follows from the frozen configuration.

How did we do against a real airplane? The natural benchmark is the Boeing 777-300ER, whose design mission (396 passengers, 7,370 nmi, GE90 engines) nearly matches ours:

Quantity This model 777-300ER
Wing span [m] 65.0 64.8
Wing aspect ratio [-] 11.0 9.6
Wing area [m²] 389 436.8
Cruise L/D [-] 20.1 ~19 (est.)
Empty mass fraction [-] 0.48 0.48
Fuel mass fraction [-] 0.35 0.41 (capacity)
Max takeoff mass [t] 237 351.5
Empty mass [t] 114 167.8
Fuel mass [t] 84 145.5 (capacity)
Fuselage length [m] 67.6 73.9
Thrust per engine [klbf] 64 115.3

Read this table in two halves. The shape of the airplane is strikingly right: the span slams into the 65 m gate-limit bound exactly as Boeing’s did (and the follow-on 777-9 grew its aspect ratio to 11 by folding its wingtips — the same escape our optimizer would take if we relaxed the bound). Cruise \(C_L\) and the empty-mass fraction land on top of the reference.

The absolute masses run about a third light, and the reasons are instructive rather than embarrassing: our mission is a single still-air Breguet cruise with a flat 5% reserve (no climb, no diversion, no winds — real route planning adds 10–20% to block fuel), the drag model is a clean-sheet estimate rather than flight-test data, and the engines are sized by a single criterion (engine-out climb) where real engines also carry takeoff-field-length and time-to-climb requirements — hence the thrust gap. Lighter fuel compounds into a lighter wing, lighter gear, and so on, through exactly the same feedback loop that makes the closure converge. A conceptual model’s absolute weights are only as good as its mission definition; its trends are what we use it for, as the sweeps below show.

Post-solve sanity checks — the things a design review would ask about first:

Check Value Typical long-range transport
Cruise \(C_L\) [-] 0.44 0.4–0.55
Cruise \(\alpha\) [deg] 2.4 1–3
Wing loading [kg/m²] 609 600–800
Horizontal tail volume \(V_h\) [-] 0.98 0.8–1.1
Vertical tail volume \(V_v\) [-] 0.058 0.05–0.09

16.9 Switching to hydrogen

Now the payoff of parameterizing the fuel. Liquid hydrogen carries 2.8 times the energy of kerosene per kilogram — but is 12 times less dense, must live in insulated cryogenic tanks that weigh a third as much as the fuel they hold, and needs a heavier fuel system. Which effect wins? Change five parameter values and re-solve — no re-tracing, warm-started from the Jet A optimum:

opti.set_initial_from_sol(sol)

t_start = time.time()
sol_lh2 = opti.solve(
    parameter_mapping={fuel[k]: fuels["LH2"][k] for k in fuel},
    verbose=False,
)
t_lh2 = time.time() - t_start

out_lh2 = get_outputs(sol_lh2, fuels["LH2"])
print(f"Converged: {sol_lh2.stats()['success']}, "
      f"{sol_lh2.stats()['iter_count']} iterations, {t_lh2:.1f} s")
Converged: True, 23 iterations, 2.6 s
Quantity Jet A LH2
Max takeoff mass [t] 237 176
Empty mass [t] 114 109
Fuel mass [t] 83.6 28.2
Fuel energy [GJ] 3597 3384
Tank interior length (each) [m] 1.7 6.9
Fuselage length [m] 67.6 78.3
Wing span [m] 65.0 58.9
Cruise L/D [-] 20.1 17.9

Component mass budgets for the two fuels. Hydrogen cuts the fuel line item by roughly two-thirds; the repayment shows up in the fuel containment & system group, which grows several-fold, and in a fuselage whose weight savings from the lighter airplane are partly eaten by the tank stretch.

The verdict at this design range: the hydrogen airplane takes off 26% lighter — kerosene’s fuel mass simply dwarfs hydrogen’s — and carries 6% less onboard energy. The catch is visible in the geometry: the fuselage stretches by 11 m to swallow the cryogenic tanks, and the tankage-plus-fuel-system group grows several-fold, clawing back part of the energy win as drag and structure. (Whether the trade is good also depends on how the hydrogen is made — a question outside this model’s boundary.)

16.10 Tradeoff sweeps

With parameters in place, sweeping the design space is a for loop. Each solve is warm-started from its neighbor, so the whole sweep costs less than a handful of cold solves.

sweep_ranges = np.array([3000, 4500, 6000, 7500, 8500]) * u.naut_mile

sweep = {}
for fuel_name in fuels:
    outs = []
    opti.set_initial_from_sol(sol)  # restart each fuel's sweep from the baseline
    for R in sweep_ranges:
        sol_i = opti.solve(
            parameter_mapping={
                mission_range: R,
                **{fuel[k]: fuels[fuel_name][k] for k in fuel},
            },
            verbose=False,
        )
        opti.set_initial_from_sol(sol_i)  # warm-start the next point
        outs.append(get_outputs(sol_i, fuels[fuel_name]))
    sweep[fuel_name] = outs

Design range sweeps. Top: gross weight grows super-linearly for kerosene as fuel-to-carry-fuel compounds, while hydrogen’s small fuel mass keeps TOGW nearly linear. Bottom: at short range hydrogen pays an energy premium for its tankage and fuselage stretch; at long range the weight compounding reverses the verdict.

The physics behind the crossing trends: kerosene fuel mass compounds — fuel burned early in cruise must itself be lifted and dragged for hours first, so TOGW grows super-linearly with design range. Hydrogen’s fuel mass is small enough that this compounding barely bites; its penalty is instead a fixed-rate tax of tank mass and fuselage stretch per unit fuel volume. A compounding cost versus a linear one: hydrogen’s case strengthens as the design range grows, which is exactly Brewer’s classic 1970s result recovered by a model you can read in one sitting.

16.11 What we simplified

An honest accounting of what this deliberately compact model freezes or omits, roughly in order of how much each would matter for a real study:

  • No pitch trim or stability closure. We check tail volumes post-solve but do not compute a CG envelope or trim the elevator; the tails are frozen at 777-scale. The source study (lh2_airplane.py) carries full MassProperties (CG + inertia) per component for exactly this purpose.
  • Wing sweep, cruise Mach, and altitude are frozen. The sweep/thickness/Mach transonic trade needs finer-grained wave-drag and structural models before an optimizer can be trusted to explore it.
  • Point-mass cruise, Breguet mission. No climb, descent, or step-cruise; reserves are a flat 5% of fuel rather than a diversion-mission calculation.
  • Rubber-engine propulsion. TSFC is constant with altitude and throttle; no thrust-lapse model (cruise thrust is checked implicitly through the engine-out sizing, which dominates for a twin).
  • Fuselage fuel tanks for both fuels. Physically right for LH2, conservative for Jet A (real kerosene transports use integral wing tanks).

Each of these is an extension, not a rewrite: every one couples into the same opti object through shared variables, which is the whole argument for building design models this way.

16.12 Where to go next

  • The aerostructural wing design study shows the same philosophy applied deep rather than wide: two disciplines coupled tightly through shared optimization variables.
  • The SimPleAC chapter is this chapter’s minimal sibling — the same closure logic in ~30 lines, small enough to reason about analytically.
  • The solar UAV design study swaps the mission energy source entirely: energy closure over a day-night cycle instead of a fuel tank.