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",
)
No description has been provided for this image
In [7]:
plot_flight_summary(
    complete_flight,
    "CompleteFlight waypoint detour from Amsterdam to Rome",
    "tab:green",
)
No description has been provided for this image
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
No description has been provided for this image