This chapter builds a complete sizing tool for a solar-electric high-altitude long-endurance (HALE) aircraft — an “atmospheric satellite” that stays aloft indefinitely by harvesting sunlight — in roughly a hundred lines of model code. Along the way, you’ll see how to couple solar energy collection, battery storage, aerodynamics, propulsion, and structural weight through a single asb.Opti instance, and how to use AeroSandbox’s built-in solar, wind, and mass-model libraries. The formulation here is written fresh for this book, in the spirit of the MIT Dawn aircraft — a flight-proven solar HALE design whose AeroSandbox-based conceptual sizing is documented publicly in the open-source Dawn Design Tool — but deliberately simplified to a single design point so that the whole thing fits on a page and solves in seconds.
16.1 The mission: an airplane that never lands
A solar HALE aircraft loiters in the stratosphere for weeks or months, carrying a small payload — a climate-science instrument, a communications relay, a wildfire-monitoring camera. The requirements we’ll design to are modeled on the publicly-documented Dawn mission:
Payload: 30 kg science package, drawing 250 W continuously.
Altitude: at or above 18,288 m (60,000 ft) — above weather, clouds, and nearly all air traffic.
Station-keeping: able to hold position over a target at 35° N latitude, which requires airspeed to exceed the local 95th-percentile wind speed.
Endurance: indefinite. The aircraft must end every 24-hour cycle with at least as much stored energy as it started with.
That last requirement — diurnal energy closure — is the crux of the whole problem. During the day, the wing’s solar cells collect more power than the aircraft consumes; the surplus charges a battery. At night, the battery alone must carry the aircraft through until sunrise. If the design can bank enough energy by sunset to survive the night, it can (in principle) fly forever. If it can’t, no amount of piloting skill will save it.
One requirement will look odd: we will minimize wingspan, not weight or cost. This choice comes straight from the Dawn program’s experience: historically, the dominant failure mode of large solar aircraft has been aeroelastic (Helios, Solara 50, Aquila, and Zephyr all suffered in-flight structural or aeroelastic failures), and span is a good first-order proxy for that risk — as well as for very practical headaches like runway width, ground handling, and transport.
16.2 The physics: cheap to collect, expensive to store
Before writing the optimization problem, let’s build intuition for the energy balance using AeroSandbox’s solar library (aerosandbox.library.power_solar). Its main entry point is solar_flux(), which computes the solar power per unit area falling on a (possibly tilted) panel as a function of latitude, day of year, time after solar noon, and altitude — accounting for Earth’s orbital eccentricity, axial tilt, atmospheric absorption and scattering along the slant path, and diffuse re-scattered illumination.
import aerosandbox as asbimport aerosandbox.numpy as npimport aerosandbox.tools.units as ufrom aerosandbox.library import power_solar as solarlatitude =35# [deg] station-keeping latitudeday_of_year =172# June 21, the summer solstice# Sanity checks against well-known values:flux_toa = solar.solar_flux_outside_atmosphere_normal(day_of_year)flux_noon_sea_level = solar.solar_flux(latitude, day_of_year, time=0)flux_noon_stratosphere = solar.solar_flux(latitude, day_of_year, time=0, altitude=20e3)night_hours =24- solar.length_day(latitude, day_of_year) / u.hourprint(f"Top-of-atmosphere normal flux on this day: {flux_toa:4.0f} W/m^2")print(f"Noon flux on a horizontal panel, sea level: {flux_noon_sea_level:4.0f} W/m^2")print(f"Noon flux on a horizontal panel, 20 km MSL: {flux_noon_stratosphere:4.0f} W/m^2")print(f"Night duration at 35 deg N on the solstice: {night_hours:.1f} hours")
Top-of-atmosphere normal flux on this day: 1321 W/m^2
Noon flux on a horizontal panel, sea level: 1001 W/m^2
Noon flux on a horizontal panel, 20 km MSL: 1253 W/m^2
Night duration at 35 deg N on the solstice: 9.6 hours
These numbers behave as documented: at sea level the model reproduces the familiar ~1 kW/m² “peak sun”, and at 20 km — above 95% of the atmosphere’s mass — the panel recovers 95% of the top-of-atmosphere flux (the small remaining deficit is the slight off-normal sun angle at 35° N plus residual atmospheric absorption). Figure 16.1 shows the full picture: the daily flux profile at altitude, and how the daily integrated energy varies across the year. The seasonal cliff in the second panel is the single most important feature of the solar-aircraft design space: a mission that closes comfortably in June may be hopeless in December.
Code
import _commonimport matplotlib.pyplot as pltfig, ax = plt.subplots(2, 1, figsize=(7, 6.5))### Top panel: flux vs. time of day, at two altitudest = np.linspace(0, 86400, 500)hours = t / u.hourfor alt, color, name, x_lab, dy_lab in [ (20e3, _common.SERIES[2], "20 km MSL", 1.5, 80), (0, _common.TEXT_SECONDARY, "Sea level", 4.2, -90),]: flux = solar.solar_flux(latitude, day_of_year, time=t, altitude=alt) ax[0].plot(hours, flux, color=color) _common.label_line(ax[0], x=x_lab, y=solar.solar_flux(latitude, day_of_year, time=x_lab * u.hour, altitude=alt), text=name, color=color, dx=0.4, dy=dy_lab)sunset = solar.length_day(latitude, day_of_year) /2/ u.hourax[0].axvspan(sunset, 24- sunset, color=_common.GRID, alpha=0.35, zorder=0)ax[0].text(12, 620, f"night: {night_hours:.1f} h\n(battery only)", ha="center", color=_common.TEXT_SECONDARY, fontsize=9)ax[0].set_xlim(0, 24)ax[0].set_xticks(np.arange(0, 25, 3))ax[0].set_xlabel("Time after local solar noon [hours]")ax[0].set_ylabel("Solar flux [W/m²]")ax[0].set_title("Flux on a horizontal panel (35° N, summer solstice)")### Bottom panel: daily integrated energy vs. day of year, at three latitudesdays = np.arange(1, 366, 3)t_col = t[:, None] # broadcast: (time, day) griddt = np.diff(t)[:, None]for lat_i, color in [(0, _common.SERIES[1]), (35, _common.SERIES[0]), (60, _common.SERIES[4])]: flux = solar.solar_flux(lat_i, days[None, :], time=t_col, altitude=20e3) daily_energy = np.sum((flux[1:] + flux[:-1]) /2* dt, axis=0) # [J/m^2] ax[1].plot(days, daily_energy / (u.kilo * u.hour), color=color) _common.label_line(ax[1], x=days[-1], y=daily_energy[-1] / (u.kilo * u.hour), text=f"{lat_i}° N", color=color, dx=4)ax[1].axvline(day_of_year, color=_common.TEXT_SECONDARY, linewidth=0.8, linestyle="--", zorder=0)ax[1].text(day_of_year, 1.5, " summer solstice (our design day)", color=_common.TEXT_SECONDARY, fontsize=8)ax[1].set_xlim(0, 395)ax[1].set_xlabel("Day of year")ax[1].set_ylabel("Daily solar energy [kWh/m²]")ax[1].set_title("Daily energy on a horizontal panel at 20 km altitude")plt.tight_layout()plt.show()
Figure 16.1: Solar energy available to a horizontal panel, from aerosandbox.library.power_solar.solar_flux(). Top: flux over the day at 35° N on the summer solstice. Flying at 20 km recovers the ~25–30% of energy that the atmosphere absorbs and scatters at sea level. Bottom: daily integrated energy at 20 km altitude across the year. Winter at high latitude is the graveyard of solar-aircraft missions.
Now the punchline that shapes every solar aircraft. Collecting energy is cheap: with 24.3%-efficient cells at 0.45 kg/m² installed, a square meter of wing generates roughly 305 W at noon up high. Storing energy is brutally expensive: a battery pack at 300 Wh/kg (pack level) with 85% usable depth of discharge stores just 0.26 kWh per kilogram.
# Mass cost of collecting vs. storing 1 kW:solar_specific_power = flux_noon_stratosphere *0.243/0.45# [W per kg of cells]battery_usable_specific_energy =300*0.85# [Wh per kg of pack]kg_collect =1000/ solar_specific_power # cells to collect 1 kW at noonkg_store =1000* night_hours / battery_usable_specific_energy # battery for 1 kW overnightprint(f"Collecting 1 kW at noon: ~{kg_collect:.1f} kg of solar cells")print(f"Sustaining 1 kW through the night: ~{kg_store:.0f} kg of battery")
Collecting 1 kW at noon: ~1.5 kg of solar cells
Sustaining 1 kW through the night: ~38 kg of battery
Sustaining a kilowatt overnight costs roughly 26× more mass than collecting it at noon. Solar panels are light; batteries are heavy; nights are long. Nearly every macroscopic feature of a solar HALE design follows from this asymmetry — including, as we’ll see, the counterintuitive fact that solar aircraft get smaller toward the pole in summer, where nights are short.
The second piece of intuition is structural. AeroSandbox ships a wing-spar mass model, aerosandbox.library.mass_structural.mass_wing_spar(), which is a closed-form fit (R² > 0.995) to a family of physics-based spar designs: each underlying design point is itself a beam-optimization problem — a cantilevered tube spar under an elliptical lift distribution, sized against strength and a tip-dihedral limit at the ultimate load factor. (The study that generated it lives in AeroSandbox/studies/MultiBoomSparMass_v2; the beam methods behind it are the subject of Chapter 9.) Figure 16.2 shows the fit: spar mass grows like span\(^{1.6}\), so doubling the span triples the spar mass, even before accounting for the extra weight it must then carry.
Code
import _commonimport matplotlib.pyplot as pltfrom aerosandbox.library import mass_structural as structurefig, ax = plt.subplots(figsize=(7, 3.6))spans_plot = np.geomspace(10, 80, 100)for m_supported, color in [(1000, _common.SERIES[3]), (300, _common.SERIES[0]), (100, _common.SERIES[1])]: spar_mass = structure.mass_wing_spar(span=spans_plot, mass_supported=m_supported) ax.loglog(spans_plot, spar_mass, color=color) _common.label_line(ax, x=spans_plot[-1], y=spar_mass[-1], text=f"{m_supported} kg supported", color=color, dx=2)ax.set_xlim(10, 200)ax.set_xlabel("Wing span [m]")ax.set_ylabel("Spar mass [kg]")ax.set_title("Structure punishes span: spar mass ~ span$^{1.6}$")plt.show()
Figure 16.2: Wing spar mass vs. span, from mass_wing_spar() — a closed-form fit to a family of optimized cantilevered tube-spar designs. On these log-log axes the model is a straight line of slope ≈1.6: structure punishes span superlinearly. Curves shown for three values of supported (non-wing) mass.
These two scaling laws close a feedback loop that either converges or doesn’t: more battery → more weight → more wing → more drag and structure → more power → more battery. Whether that spiral converges to an airplane — and how big that airplane is — is exactly what the optimizer will tell us.
16.3 The sizing problem
We formulate the design as a single-design-point optimization: one cruise condition, flown identically day and night, with the day/night asymmetry captured entirely in the battery’s state of charge. (The real Dawn problem optimized the full 24-hour trajectory with ~150 collocation points and discovered altitude-cycling strategies; that extension uses the tools of Section 12.1, and we’ll note where it would attach.)
Table 16.1: Formulation of the solar HALE sizing problem.
Objective
minimize wing span \(b\)
Design variables
span \(b\), wing area \(S\), airspeed \(V\), altitude \(h\), gross mass \(m\), battery capacity \(E_\mathrm{batt}\), angle of attack \(\alpha\), tail boom length \(\ell\), wing rib count, and battery state-of-charge \(\mathrm{SoC}(t)\) at 97 points over the day — 106 variables total
Constraints
lift = weight; battery SoC dynamics (energy closure over 24 h, SoC within depth-of-discharge limits, periodic); airspeed ≥ 95th-percentile wind; altitude ≥ 60,000 ft; mass closure (gross mass ≥ sum of component masses)
Parameters
latitude, day of year, payload mass & power, battery technology
The disciplines couple exactly as in the Dawn design tool’s XDSM: the atmosphere feeds density to aerodynamics and propulsion; aerodynamics produces the drag that sets propulsion power; propulsion power plus payload power sets the electrical load on the power system, whose solar input depends on altitude; structures & weights turn geometry and gross mass into component masses; and two closure constraints — energy closure and mass closure — tie the loop shut. There is no nested “multidisciplinary analysis” iteration anywhere: as in Chapter 14, the couplings are just shared opti variables, and IPOPT converges the whole system simultaneously (the simultaneous analysis and design architecture).
A few modeling choices deserve explanation before you read the code:
Stability is pinned by tail volume coefficients. At the conceptual level, fixing the horizontal and vertical tail volume coefficients (\(V_H = 0.45\), \(V_V = 0.02\), sailplane-typical values) is equivalent to fixing static margin — it sizes the tail surfaces as the wing and boom change. A proper neutral-point analysis via stability derivatives is shown in Section 7.4.
The airfoil polar comes from NeuralFoil, live inside the optimization. We use the Drela DAE-11 (a low-Reynolds, high-lift section from the MIT Daedalus program) and query its lift and profile drag at the current \(\alpha\) and Reynolds number on every optimizer iteration, as in Section 6.1. The optimizer — not us — picks the operating \(c_\ell\), trading profile drag against induced drag and wing size.
Battery dynamics are written as an inequality. The state of charge is allowed to increase by at most the net charging energy in each interval. This one-character trick (<= instead of ==) lets the optimizer shed surplus solar power at midday when the battery is full, without any explicit “dump load” logic. Shedding energy never helps the objective, so the constraint is tight whenever the energy actually matters. Note also how the constraint is written: multiplied through by battery_capacity rather than dividing by it. Dividing by a decision variable inside 96 constraints injects nasty \(1/x\) curvature; the multiplied form is merely bilinear, and in testing this reformulation alone cut typical solve iteration counts by several-fold (the lesson of Section 3.4, live in the field).
Weights come from the AeroSandbox library. Wing secondary structure, tail surfaces, and boom use the Daedalus-derived correlations of Cruz (mass_hpa_wing, mass_hpa_stabilizer, mass_hpa_tail_boom); the spar uses mass_wing_spar (conservatively fed the full gross mass as supported mass); battery, motor, ESC, and MPPT masses come from propulsion_electric and power_solar. The wing rib count is a design variable — the model trades rib weight against leading-edge sheeting weight, a trade the library docstring explicitly invites you to optimize.
Everything positive and scale-varying is log-transformed (log_transform=True), following Section 3.4: masses, areas, speeds, and battery capacity range over orders of magnitude across the sweeps we’ll run, and log-space keeps them positive and well-scaled.
16.4 The model
Here is the complete sizing tool. It’s a single function so that we can call it repeatedly for design sweeps; read it top to bottom — the section banners mirror the discipline map above.
from aerosandbox.library import ( propulsion_electric as prop_elec, propulsion_propeller as prop_prop, mass_structural as structure, winds,)airfoil = asb.Airfoil("dae11") # Drela DAE-11: low-Re, high-lift sectiondef size_solar_uav( latitude=35, # [deg] station-keeping latitude day_of_year=172, # June 21: summer solstice mass_payload=30, # [kg] power_payload=250, # [W] continuous min_altitude=18288, # [m] (= 60,000 ft) battery_cell_specific_energy=400, # [Wh/kg] at cell level n_timesteps=97, # points discretizing the 24 h cycle initial_guesses=None, # dict; warm-start from a prior solve):"""Sizes a minimum-span solar-electric HALE UAV for perpetual flight at a single design point. Returns a dict of solved design quantities.""" guess = { # generic order-of-magnitude starting point"span": 40, "wing_area": 80, "airspeed": 30, "altitude": 20e3,"mass_total": 300, "battery_capacity": 250e6, "alpha": 4,"boom_length": 4, "n_ribs": 150, } guess.update(initial_guesses or {})### Technology assumptions eta_prop, eta_motor, eta_esc =0.85, 0.92, 0.97# efficiencies [-] solar_cell_efficiency =0.243# SunPower C60-class cells [-] eta_solar_system =0.90*0.96# installation losses x MPPT [-] solar_margin =0.95# flat 5% energy-generation margin solar_area_fraction =0.80# fraction of wing covered by cells solar_areal_density =0.45# cells + encapsulation [kg/m^2] battery_pack_cell_fraction =0.75# cell mass / pack mass [-] max_depth_of_discharge =0.85# usable fraction of capacity [-] power_avionics =50# [W] g =9.81# [m/s^2] opti = asb.Opti()### Design variables span = opti.variable(init_guess=guess["span"], lower_bound=1) # [m] wing_area = opti.variable( # [m^2] init_guess=guess["wing_area"], log_transform=True) airspeed = opti.variable( # [m/s] init_guess=guess["airspeed"], log_transform=True) altitude = opti.variable( # [m] init_guess=guess["altitude"], lower_bound=min_altitude, upper_bound=30e3) mass_total = opti.variable( # [kg] init_guess=guess["mass_total"], log_transform=True) battery_capacity = opti.variable( # [J] init_guess=guess["battery_capacity"], log_transform=True) alpha = opti.variable( # [deg] init_guess=guess["alpha"], lower_bound=-5, upper_bound=12) boom_length = opti.variable( # [m] init_guess=guess["boom_length"], log_transform=True) n_ribs = opti.variable( # [-] init_guess=guess["n_ribs"], lower_bound=10, log_transform=True) aspect_ratio = span**2/ wing_area mean_chord = wing_area / span### Atmosphere + aerodynamics atmo = asb.Atmosphere(altitude=altitude) q =0.5* atmo.density() * airspeed**2# dynamic pressure [Pa] Re = atmo.density() * airspeed * mean_chord / atmo.dynamic_viscosity() wing_aero = airfoil.get_aero_from_neuralfoil( # sectional polar, live alpha=alpha, Re=Re, model_size="medium" ) CL = wing_aero["CL"] opti.subject_to(mass_total * g == q * wing_area * CL) # lift = weight# Tail sizing: fixed tail volume coefficients pin static stability. V_H, V_V =0.45, 0.02 S_hstab = V_H * wing_area * mean_chord / boom_length S_vstab = V_V * wing_area * span / boom_length drag = q * ( wing_area * ( wing_aero["CD"] # wing profile drag+ CL**2/ (np.pi *0.95* aspect_ratio) # induced drag, e = 0.95 )+ (S_hstab + S_vstab) *0.010# tail profile drag+0.02# payload pod drag area [m^2] )### Propulsion + electrical loads power_shaft = drag * airspeed / eta_prop power_out = (power_shaft / (eta_motor * eta_esc)+ power_payload + power_avionics) # total draw [W] power_rating =2* power_shaft # installed power: margin for climb/gusts### Solar input over one 24 h cycle (t = 0 at solar noon) time = np.linspace(0, 86400, n_timesteps) # [s] solar_area = solar_area_fraction * wing_area power_in = solar.solar_flux( latitude=latitude, day_of_year=day_of_year, time=time, altitude=altitude, ) * solar_area * solar_cell_efficiency * eta_solar_system * solar_margin### Battery energy accounting: the heart of the problem soc = opti.variable( # battery state of charge over the day [-] init_guess=0.5, n_vars=n_timesteps, lower_bound=1- max_depth_of_discharge, upper_bound=1, ) net_power = power_in - power_out # [W] opti.subject_to( # inequality, so surplus solar power may be shed: battery_capacity * (soc[1:] - soc[:-1])<= (net_power[1:] + net_power[:-1]) /2* np.diff(time) ) opti.subject_to(soc[-1] >= soc[0]) # diurnal energy closure### Station-keeping: outfly the 95th-percentile wind wind_speed = winds.wind_speed_world_95(altitude, latitude, day_of_year) opti.subject_to(airspeed >= wind_speed)### Mass buildup q_never_exceed =2* q mass_wing = structure.mass_wing_spar( # primary structure (spar) span=span, mass_supported=mass_total, # conservative: full gross mass ) + structure.mass_hpa_wing( # secondary: Daedalus-derived span=span, chord=mean_chord, vehicle_mass=mass_total, n_ribs=n_ribs, t_over_c=0.128, include_spar=False, ) mass_battery = prop_elec.mass_battery_pack( battery_capacity_Wh=battery_capacity / u.hour, battery_cell_specific_energy_Wh_kg=battery_cell_specific_energy, battery_pack_cell_fraction=battery_pack_cell_fraction, ) tail_AR =6# assumed aspect ratio of tail surfaces masses = {"Battery pack": mass_battery,"Wing structure": mass_wing,"Payload": mass_payload,"Solar cells + MPPT": ( solar_area * solar_areal_density+ solar.mass_MPPT(power_in[0] / solar_margin) # peak solar power ),"Pod structure": 0.10* (mass_payload + mass_battery),"Tails + boom": (sum( structure.mass_hpa_stabilizer( span=(tail_AR * S) **0.5, chord=(S / tail_AR) **0.5, dynamic_pressure_at_manuever_speed=q_never_exceed, n_ribs=(tail_AR * S) **0.5/0.20, # one rib per 20 cm )for S in [S_hstab, S_vstab] )+ structure.mass_hpa_tail_boom( length_tail_boom=boom_length + mean_chord, # wing to tail dynamic_pressure_at_manuever_speed=q_never_exceed, mean_tail_surface_area=(S_hstab + S_vstab) /2, ) ),"Avionics": 5,"Motors + props + ESC": (2* prop_elec.mass_motor_electric(max_power=power_rating /2)+2* prop_prop.mass_hpa_propeller(diameter=2.5, max_power=power_rating /2)+ prop_elec.mass_ESC(max_power=power_rating) ), } opti.subject_to(mass_total >=sum(masses.values())) # mass closure opti.minimize(span /40) # scaled to O(1) sol = opti.solve(verbose=False)return {"span": sol(span), "wing_area": sol(wing_area),"aspect_ratio": sol(aspect_ratio), "mass_total": sol(mass_total),"airspeed": sol(airspeed), "wind_speed": sol(wind_speed),"altitude": sol(altitude), "CL": sol(CL), "alpha": sol(alpha),"L_over_D": sol(mass_total * g / drag), "Re": sol(Re),"battery_capacity": sol(battery_capacity), "power_out": sol(power_out),"boom_length": sol(boom_length), "n_ribs": sol(n_ribs),"analysis_confidence": sol(wing_aero["analysis_confidence"]),"masses": {k: sol(v) for k, v in masses.items()},"time": time, "soc": sol(soc), "power_in": sol(power_in),"n_iterations": sol.stats()["iter_count"], }
Two closure constraints do the multidisciplinary work. The mass closuremass_total >= sum(masses.values()) is written as an inequality for solver robustness, but it behaves as an equality at the optimum: every spare kilogram of mass_total costs lift, power, and ultimately span, so the optimizer squeezes it tight. The energy closuresoc[-1] >= soc[0] is the “flies forever” requirement — with the SoC bounds, it forces the battery, solar array, and power draw into mutual consistency.
16.5 The design point
Let’s size the baseline aircraft.
import time as timerstart = timer.time()dp = size_solar_uav()solve_time = timer.time() - startprint(f"Solved in {solve_time:.1f} s wall time "f"({dp['n_iterations']} IPOPT iterations).")print(f"Minimum-span design: {dp['span']:.1f} m span, "f"{dp['mass_total']:.0f} kg gross mass.")
Solved in 3.0 s wall time (37 IPOPT iterations).
Minimum-span design: 28.7 m span, 229 kg gross mass.
The optimizer converges in seconds, from a generic initial guess, on a problem coupling six disciplines — this is the payoff of the differentiable, simultaneous formulation. Here’s the design it found, next to the published MIT Dawn baseline for reference:
Quantity
This chapter
MIT Dawn (published)
Wing span
28.7 m
39.9 m
Gross mass
229 kg
375 kg
Wing area
39.1 m²
67.9 m²
Aspect ratio
21.1
23.4
Wing loading
57.5 Pa
54.2 Pa
Cruise airspeed (true)
27.1 m/s
30.4 m/s
Cruise altitude
18.3 km
18.7–19.7 km
Cruise \(C_L\)
1.36
1.11
Cruise \(L/D\)
30.3
30.8
Wing Reynolds number
299k
383k
Battery capacity
38.5 kWh
75.4 kWh
Total electrical draw
2.95 kW
5.07 kW (peak)
The comparison is encouraging in exactly the way it should be. (Dawn’s baseline values here are from the program’s published conceptual design, as documented in Peter Sharpe’s MIT PhD thesis and the Dawn Design Tool.) The intensive quantities land close to the published Dawn values — wing loading within a few pascals, \(L/D\) within a few percent, cruise speed within a few m/s, and a cruise \(C_L\) in the same high-lift regime — because they’re set by the same physics: low-Reynolds airfoil performance, minimum-power flight, stratospheric density. The extensive quantities come out smaller, and each gap is traceable to a requirement we simplified: Dawn closes energy on August 31 (the end of a 6-week mission window, with 11.2 hours of night at this latitude versus our solstice-day 9.6), carries a payload that draws 500 W by day, must station-keep anywhere over the continental U.S. rather than at one benign latitude, and carries structural margins for gust loads and a full \(V\)–\(n\) envelope. Requirements, not physics, are what make solar airplanes big.
Note also analysis_confidence = 0.96 from NeuralFoil — worth checking whenever an optimizer is free to exploit an aerodynamic surrogate (Section 6.5).
Figure 16.3 shows the day in the life of this aircraft that the optimizer implicitly planned:
Code
import _commonimport matplotlib.pyplot as plthours = dp["time"] / u.hourfig, ax = plt.subplots(2, 1, figsize=(7, 6), sharex=True)### Top: power in vs. power outax[0].plot(hours, dp["power_in"] /1e3, color=_common.SERIES[2])i_label = np.argmin(np.abs(hours -3.3)) # anchor label to the curve at ~3.3 h_common.label_line(ax[0], x=hours[i_label], y=dp["power_in"][i_label] /1e3, text="solar power in", color=_common.SERIES[2], dx=0.3, dy=0.5)ax[0].axhline(dp["power_out"] /1e3, color=_common.SERIES[0], linewidth=2)_common.label_line(ax[0], x=13.5, y=dp["power_out"] /1e3, text="electrical draw", color=_common.SERIES[0], dy=0.6)ax[0].fill_between( hours, dp["power_in"] /1e3, dp["power_out"] /1e3, where=dp["power_in"] < dp["power_out"], color=_common.SERIES[0], alpha=0.12, linewidth=0,)ax[0].set_ylabel("Power [kW]")ax[0].set_title("Power balance over the design day")### Bottom: battery state of chargeax[1].plot(hours, dp["soc"] *100, color=_common.SERIES[0])ax[1].axhline(15, color=_common.TEXT_SECONDARY, linewidth=0.8, linestyle="--")ax[1].text(0.3, 16.5, "15% minimum state of charge (85% max depth of discharge)", color=_common.TEXT_SECONDARY, fontsize=8)ax[1].set_xlim(0, 24)ax[1].set_xticks(np.arange(0, 25, 3))ax[1].set_ylim(0, 105)ax[1].set_xlabel("Time after local solar noon [hours]")ax[1].set_ylabel("Battery state of charge [%]")ax[1].set_title("Battery state of charge")plt.tight_layout()plt.show()
Figure 16.3: The optimized design’s day, starting at solar noon. Top: solar power collected vs. total electrical draw. The morning surplus recharges the battery; the shaded gap is what the battery must bridge. Bottom: battery state of charge. The battery tops out just before the afternoon flux collapses, rides down through the night, and touches its 15% floor exactly at the dawn crossover — the energy-closure constraint is active, as a minimum-span design demands.
And Figure 16.4 shows where the mass went. The battery pack is 56% of gross mass — echoing the published Dawn breakdown, where the battery is nearly half the aircraft — while the payload, the entire point of the mission, is just 13%.
Code
import _commonimport matplotlib.pyplot as pltitems =sorted(dp["masses"].items(), key=lambda kv: kv[1])names = [k for k, v in items]values = np.array([v for k, v in items])colors = [_common.SERIES[0] if k =="Battery pack"else"#bdbdbd"for k in names]fig, ax = plt.subplots(figsize=(7, 3.6))ax.barh(names, values, color=colors, edgecolor="white")for i, v inenumerate(values): ax.text(v +1, i, f"{v:.1f} kg", va="center", fontsize=9, color=_common.TEXT_PRIMARY)ax.set_xlim(0, values.max() *1.18)ax.set_xlabel("Component mass [kg]")ax.set_title(f"Mass budget: {dp['mass_total']:.0f} kg gross")ax.grid(axis="y", visible=False)plt.show()
Figure 16.4: Mass breakdown of the optimized design. Color marks the component that dominates the design problem: the battery. A solar HALE aircraft is best understood as a flying battery with a science instrument attached.
Three details of the solution are worth interrogating, because they show the optimizer reasoning rather than just filling in numbers:
The altitude constraint is active: the aircraft flies at exactly 18.29 km, the 60,000 ft floor. Altitude buys slightly more solar flux but costs power as density falls, and at this latitude the 95th-percentile wind (19 m/s) is already below the min-power airspeed (27 m/s), so there’s no wind incentive to climb either. The optimizer parks on the bound.
The airspeed constraint is not active here — the aircraft flies its aerodynamic sweet spot, not the wind floor. We’ll see in the latitude sweep that this flips near the subtropical jet.
The rib count lands at 115 ribs — a rib every 25 cm, right in the range of built Daedalus-class wings, from a variable that was free to be anything.
Since we discretized the day, we should verify the answer isn’t an artifact of the grid — the same refinement discipline as in Section 12.1:
dp_coarse = size_solar_uav(n_timesteps=49)rel_change =abs(dp_coarse["span"] - dp["span"]) / dp["span"]print(f"Halving the time resolution changes the span by {rel_change:.2%}.")
Halving the time resolution changes the span by 0.12%.
16.6 Interrogating the design space
A sizing function that solves in seconds is a question-answering machine. The Dawn program’s most valuable early artifact was not any single design, but a map: 2,400 optimized aircraft across a latitude–season grid, answering “how big an airplane does this mission need?” in one figure. We’ll reproduce two 1-D slices of that map.
Since each sweep point is a nearby variant of the baseline, we warm-start every solve from the baseline solution — re-centering the initial guesses on a known-good design cuts iterations and makes far-from-baseline points solve much more reliably (more on sweep strategies in Section 5.5):
warm = { k: dp[k]for k in ["span", "wing_area", "airspeed", "altitude", "mass_total","battery_capacity", "alpha", "boom_length", "n_ribs"]}
Where can a small aircraft fly? Sweep latitude, re-optimizing the whole aircraft at each point:
latitudes = np.arange(0, 61, 10)lat_designs = [ size_solar_uav(latitude=lat, initial_guesses=warm) for lat in latitudes]
Code
import _commonimport matplotlib.pyplot as pltspans = np.array([d["span"] for d in lat_designs])speeds = np.array([d["airspeed"] for d in lat_designs])wind = np.array([d["wind_speed"] for d in lat_designs])fig, ax = plt.subplots(2, 1, figsize=(7, 6), sharex=True)ax[0].plot(latitudes, spans, ".-", color=_common.SERIES[0])ax[0].set_ylabel("Minimum feasible span [m]")ax[0].set_title("Smaller aircraft toward the summer pole")ax[0].set_ylim(bottom=0)ax[1].plot(latitudes, speeds, ".-", color=_common.SERIES[0])_common.label_line(ax[1], x=latitudes[-1], y=speeds[-1], text="airspeed", color=_common.SERIES[0], dx=1)ax[1].plot(latitudes, wind, ".--", color=_common.SERIES[3])_common.label_line(ax[1], x=latitudes[-1], y=wind[-1], text="95th-pct wind", color=_common.SERIES[3], dx=1)ax[1].annotate("station-keeping binds\n(subtropical jet)", xy=(10, wind[1]), xytext=(17, wind[1] +6), color=_common.TEXT_SECONDARY, fontsize=8, arrowprops=dict(arrowstyle="->", color=_common.TEXT_SECONDARY))ax[1].set_xlabel("Station-keeping latitude [°N]")ax[1].set_ylabel("Speed [m/s]")ax[1].set_title("Which constraint is driving?")ax[1].set_ylim(0, 45)plt.tight_layout()plt.show()
Figure 16.5: Re-optimized minimum-span designs vs. station-keeping latitude (northern-hemisphere summer solstice). Top: required span. Bottom: why — near the subtropical jet the station-keeping constraint binds (airspeed pinned to the 95th-percentile wind), while toward the pole the shortening night shrinks the battery and the whole aircraft.
Two competing effects shape Figure 16.5. Toward the pole, summer nights shorten, the battery shrinks, and with it the entire aircraft — at 60° N the minimum span is 22 m versus 35 m at the equator, despite weaker peak sun. This is the “solar panels are light, batteries are heavy” asymmetry made visible, and it’s why the Dawn studies found Arctic summer missions surprisingly easy. But equatorward of about 25° N, the subtropical jet keeps the 95th-percentile wind above the aircraft’s preferred loiter speed: the bottom panel shows airspeed pinned to the wind line there (worst near the jet’s core around 10° N), and the aircraft must grow to carry the extra propulsive power. The design driver changes identity across the sweep — exactly the kind of insight a one-point design study can’t give you. (Remember these are solstice numbers: run the same sweep with day_of_year=355 and the polar advantage reverses catastrophically, as Figure 16.1 forewarned.)
How much does battery technology matter? Battery cell specific energy is the most consequential technology assumption in the model:
cell_energies = np.arange(300, 501, 50) # [Wh/kg]batt_designs = [ size_solar_uav(battery_cell_specific_energy=e, initial_guesses=warm)for e in cell_energies]
Code
import _commonimport matplotlib.pyplot as pltbatt_spans = np.array([d["span"] for d in batt_designs])fig, ax = plt.subplots(figsize=(7, 3.6))ax.plot(cell_energies, batt_spans, ".-", color=_common.SERIES[0])ax.axvline(450, color=_common.TEXT_SECONDARY, linewidth=0.8, linestyle="--", zorder=0)ax.text(450, batt_spans.max(), " Dawn baseline\n assumption", color=_common.TEXT_SECONDARY, va="top", fontsize=8)ax.set_xlabel("Battery cell specific energy [Wh/kg]")ax.set_ylabel("Minimum feasible span [m]")ax.set_title("Battery technology is the biggest lever")plt.show()
Figure 16.6: Minimum feasible span vs. assumed battery cell specific energy, all else fixed. The Dawn program’s baseline assumption of 450 Wh/kg cells (silicon-anode / lithium-sulfur class) is marked. Battery technology is the single biggest lever in the entire problem.
Going from 300 to 500 Wh/kg cells shrinks the airplane from 39 m to 24 m of span — a 39% reduction (roughly 8 cm of wingspan per Wh/kg), worth more than most aerodynamic refinements combined. Sensitivity information like this is what design optimization is for: it tells you where to spend engineering effort (or R&D dollars) before you commit to hardware. For first-order sensitivities of a solved problem, you don’t even need a sweep — the Lagrange duals give them for free, as shown in Section 11.5.
16.7 What this model leaves out
About a hundred lines of model buys the right first-order answers, but be honest with yourself (and your stakeholders) about the simplifications before trusting it further:
Single design point, not a trajectory. The aircraft flies one speed at one altitude, day and night. The full Dawn formulation optimizes a 24-hour collocated trajectory and discovers altitude cycling — climbing on afternoon solar surplus and gliding down overnight, using gravity as a second battery. That version bolts the machinery of Section 12.1 and Section 13.1 onto exactly this energy model.
Solstice sizing. We sized on the easiest day of the year. A real mission must close energy on the worst day of its window (Dawn’s closure day was the end of its six-week mission), and the seasonality panel of Figure 16.1 shows how punishing that can be.
Horizontal-panel solar model. Real wings have camber, dihedral, and bank; cells see the sun off-normal, and heading matters at dawn and dusk. solar_flux() accepts panel tilt and azimuth angles if you want to model this.
Frozen efficiencies. Propeller, motor, and ESC efficiencies are constants here; the library’s motor_electric_performance() and propeller_shaft_power_from_thrust() support physics-based, operating-point-dependent versions.
Statistical structures at HPA scale. The Daedalus-heritage weight correlations are extrapolated to a heavier, faster aircraft, the spar model conservatively carries full gross mass, and there is no gust-load or aeroelastic analysis — the very failure mode that motivated the min-span objective. A real program graduates to beam models (Chapter 9) and aeroelastic checks (Chapter 14).
Each of these is an upgrade within the same formulation — add variables, swap a model, tighten a constraint, re-solve. None requires restructuring the code, which is the quiet superpower of writing sizing problems this way.
16.8 Where to go next
Section 12.1 shows the trajectory-optimization machinery (collocation, constrain_derivative) needed to turn this single-point sizing into a full 24-hour mission optimization with altitude cycling.
Chapter 14 dives into the coupled aerostructural analysis that would replace this chapter’s statistical wing-weight models.
Section 16.1 applies the same closure-constraint sizing pattern to a conventional transport aircraft — comparing the two chapters side-by-side is a good way to see what’s universal about the method.
For the full-fidelity version of this problem — mission-space maps over latitude and season, trajectory optimization, and the models behind the flight-proven Dawn aircraft — see the open-source Dawn Design Tool and the solar-aircraft design study in Peter Sharpe’s MIT PhD thesis.