Waypoint Constraints¶
This example constrains Amsterdam Schiphol (EHAM) to Rome Fiumicino (LIRF) through named fixes from OpenAP's packaged navigation database. The fixes intentionally create a one-sided convex detour away from the direct route, and the optimizer is constrained to pass within 2 km of each fix.
In [1]:
import warnings
import matplotlib.pyplot as plt
import openap
from cartopy import crs as ccrs
from cartopy.feature import BORDERS, LAND, OCEAN
import numpy as np
import opentop as top
warnings.filterwarnings("ignore")
In [2]:
actype = "A320"
origin = "EHAM"
destination = "LIRF"
m0 = 0.85
fix_names = ("SIGEN", "FUSSE", "ROKIB")
waypoint_tolerance_m = 2_000
waypoints = []
for fix_name in fix_names:
lat, lon, _ = openap.nav.fix(fix_name)
waypoints.append((float(lat), float(lon)))
list(zip(fix_names, waypoints))
Out[2]:
[('SIGEN', (50.846111, 8.279444)),
('FUSSE', (47.574289, 10.429847)),
('ROKIB', (45.935556, 11.692222))]
In [3]:
cruise_optimizer = top.Cruise(actype, origin, destination, m0)
cruise_optimizer.setup()
cruise_flight = cruise_optimizer.trajectory(
objective="fuel",
waypoints=waypoints,
waypoint_tolerance_m=waypoint_tolerance_m,
)
complete_optimizer = top.CompleteFlight(actype, origin, destination, m0)
complete_optimizer.setup()
complete_flight = complete_optimizer.trajectory(
objective="fuel",
waypoints=waypoints,
waypoint_tolerance_m=waypoint_tolerance_m,
)
In [4]:
def closest_distances(flight, waypoints):
distances = []
for waypoint in waypoints:
fix_distances = [
openap.aero.distance(lat, lon, waypoint[0], waypoint[1])
for lat, lon in zip(flight.latitude, flight.longitude)
]
distances.append(min(fix_distances))
return distances
print("Route: " + origin + " -> " + " -> ".join(fix_names) + " -> " + destination)
for label, optimizer, flight in (
("Cruise", cruise_optimizer, cruise_flight),
("CompleteFlight", complete_optimizer, complete_flight),
):
fuel_burn = flight.mass.iloc[0] - flight.mass.iloc[-1]
elapsed_time = flight.ts.iloc[-1] / 60
print(f"\n{label}")
print(f" success: {optimizer.success}")
print(f" fuel burn: {fuel_burn:.1f} kg")
print(f" elapsed time: {elapsed_time:.1f} min")
for fix_name, distance in zip(fix_names, closest_distances(flight, waypoints)):
print(f" {fix_name}: closest trajectory point {distance / 1000:.1f} km")
Route: EHAM -> SIGEN -> FUSSE -> ROKIB -> LIRF Cruise success: True fuel burn: 4382.8 kg elapsed time: 97.2 min SIGEN: closest trajectory point 2.0 km FUSSE: closest trajectory point 2.0 km ROKIB: closest trajectory point 2.0 km CompleteFlight success: True fuel burn: 4811.7 kg elapsed time: 108.4 min SIGEN: closest trajectory point 2.0 km FUSSE: closest trajectory point 2.0 km ROKIB: closest trajectory point 2.0 km
In [5]:
origin_info = openap.nav.airport(origin)
destination_info = openap.nav.airport(destination)
airport_points = [
(origin, float(origin_info["lat"]), float(origin_info["lon"])),
(destination, float(destination_info["lat"]), float(destination_info["lon"])),
]
all_lons = [*cruise_flight.longitude, *complete_flight.longitude]
all_lats = [*cruise_flight.latitude, *complete_flight.latitude]
all_lons.extend(lon for _, lon in waypoints)
all_lats.extend(lat for lat, _ in waypoints)
all_lons.extend(lon for _, _, lon in airport_points)
all_lats.extend(lat for _, lat, _ in airport_points)
map_extent = [
min(all_lons) - 1.0,
max(all_lons) + 1.0,
min(all_lats) - 0.8,
max(all_lats) + 0.8,
]
def plot_flight_summary(flight, title, color):
projection = ccrs.TransverseMercator(
central_longitude=9,
central_latitude=47,
)
fig = plt.figure(figsize=(14, 7))
grid = fig.add_gridspec(2, 2, width_ratios=(1.4, 1))
ax = fig.add_subplot(grid[:, 0], projection=projection)
altitude_ax = fig.add_subplot(grid[0, 1])
speed_ax = fig.add_subplot(grid[1, 1], sharex=altitude_ax)
ax.set_extent(map_extent, crs=ccrs.PlateCarree())
ax.add_feature(LAND, facecolor="#f3f0e8", edgecolor="none")
ax.add_feature(OCEAN, facecolor="#d9ecf2", edgecolor="none")
ax.add_feature(BORDERS, lw=0.5, color="0.45")
ax.coastlines(resolution="50m", lw=0.5, color="0.45")
ax.gridlines(draw_labels=True, color="0.6", alpha=0.5, ls="--")
ax.plot(
[airport_points[0][2], airport_points[1][2]],
[airport_points[0][1], airport_points[1][1]],
color="tab:gray",
lw=1.0,
ls="--",
label="Direct airport line",
transform=ccrs.PlateCarree(),
zorder=1,
)
ax.plot(
flight.longitude,
flight.latitude,
marker="o",
color=color,
lw=1.5,
ms=4,
label="Optimized trajectory",
transform=ccrs.PlateCarree(),
zorder=3,
)
ax.scatter(
[lon for _, lon in waypoints],
[lat for lat, _ in waypoints],
marker="x",
s=90,
color="tab:red",
label="Fixes",
transform=ccrs.PlateCarree(),
zorder=6,
)
ax.scatter(
[lon for _, _, lon in airport_points],
[lat for _, lat, _ in airport_points],
marker="^",
s=80,
color="tab:gray",
label="Airports",
transform=ccrs.PlateCarree(),
zorder=7,
)
for fix_name, (lat, lon) in zip(fix_names, waypoints):
ax.text(
lon + 0.08,
lat + 0.05,
fix_name,
fontsize=9,
transform=ccrs.PlateCarree(),
zorder=8,
)
for airport, lat, lon in airport_points:
ax.text(
lon + 0.08,
lat + 0.05,
airport,
fontsize=9,
transform=ccrs.PlateCarree(),
zorder=8,
)
ax.set_title("Ground track")
ax.legend(loc="lower left")
time_min = flight.ts / 60
altitude_ax.plot(time_min, flight.altitude, marker="o", ms=4, color=color)
altitude_ax.set_ylabel("Altitude [ft]")
altitude_ax.set_title("Altitude profile")
altitude_ax.grid(True, alpha=0.3)
speed_ax.plot(time_min, flight.tas, marker="o", ms=4, color=color)
speed_ax.set_xlabel("Elapsed time [min]")
speed_ax.set_ylabel("TAS [kt]")
speed_ax.set_title("Speed profile")
speed_ax.grid(True, alpha=0.3)
fig.suptitle(title)
plt.tight_layout(rect=(0, 0, 1, 0.96))
plt.show()
In [6]:
plot_flight_summary(
cruise_flight,
"Cruise waypoint detour from Amsterdam to Rome",
"tab:blue",
)
In [7]:
plot_flight_summary(
complete_flight,
"CompleteFlight waypoint detour from Amsterdam to Rome",
"tab:green",
)
In [8]:
# Altitude and speed profiles are included beside each ground-track plot.
Time-normalized smoothness constraints¶
With optimized interval durations, adjacent-control changes are constrained as physical rates rather than fixed changes per node. Vertical acceleration is limited to 5 fpm/s and turn rate to 0.5 deg/s. This allows a larger change across a long interval without relaxing the rate limit on short intervals.
In [9]:
def control_rate_diagnostics(flight):
timestamps = flight.ts.to_numpy()
interval_dt = np.diff(timestamps)
time_midpoint = (timestamps[:-1] + timestamps[1:]) / 120
vertical_rate = flight.vertical_rate.to_numpy()
heading = np.unwrap(np.deg2rad(flight.heading.to_numpy()))
return {
"time_midpoint": time_midpoint,
"interval_dt": interval_dt,
"vertical_acceleration": np.diff(vertical_rate) / interval_dt,
"turn_rate": np.rad2deg(np.diff(heading)) / interval_dt,
"vertical_rate_change": np.diff(vertical_rate),
"heading_change": np.rad2deg(np.diff(heading)),
}
fig, axes = plt.subplots(3, 1, figsize=(9, 8), sharex=True)
for label, flight, color in (
("Cruise", cruise_flight, "tab:blue"),
("CompleteFlight", complete_flight, "tab:green"),
):
rates = control_rate_diagnostics(flight)
print(label)
print(
f" optimized dt range: {rates['interval_dt'].min():.2f} to "
f"{rates['interval_dt'].max():.2f} s"
)
print(
" max |vertical acceleration|: "
f"{np.abs(rates['vertical_acceleration']).max():.4f} fpm/s"
)
print(f" max |turn rate|: {np.abs(rates['turn_rate']).max():.4f} deg/s")
print(
" max per-node changes: "
f"{np.abs(rates['vertical_rate_change']).max():.1f} fpm, "
f"{np.abs(rates['heading_change']).max():.2f} deg"
)
axes[0].step(
rates["time_midpoint"],
rates["interval_dt"],
where="mid",
color=color,
label=label,
)
axes[1].plot(
rates["time_midpoint"],
rates["vertical_acceleration"],
marker="o",
ms=3,
color=color,
label=label,
)
axes[2].plot(
rates["time_midpoint"],
rates["turn_rate"],
marker="o",
ms=3,
color=color,
label=label,
)
axes[0].set_ylabel("Interval dt [s]")
axes[1].set_ylabel("Vertical acceleration [fpm/s]")
axes[2].set_ylabel("Turn rate [deg/s]")
axes[2].set_xlabel("Elapsed time [min]")
for limit in (-5, 5):
axes[1].axhline(limit, color="tab:red", ls="--", lw=1)
for limit in (-0.5, 0.5):
axes[2].axhline(limit, color="tab:red", ls="--", lw=1)
for ax in axes:
ax.grid(True, alpha=0.3)
ax.legend()
axes[0].set_title("Variable timesteps and time-normalized control changes")
plt.tight_layout()
plt.show()
Cruise optimized dt range: 168.51 to 427.75 s max |vertical acceleration|: 0.1899 fpm/s max |turn rate|: 0.0699 deg/s max per-node changes: 39.0 fpm, 29.89 deg CompleteFlight optimized dt range: 156.00 to 332.93 s max |vertical acceleration|: 4.4176 fpm/s max |turn rate|: 0.1733 deg/s max per-node changes: 1302.0 fpm, 31.27 deg