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")
from opentop.plotting import (  # noqa: E402
    NEUTRAL_COLOR,
    OKABE_ITO,
    apply_publication_style,
)

apply_publication_style()
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=NEUTRAL_COLOR,
        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=OKABE_ITO[1],
        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=NEUTRAL_COLOR,
        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",
    OKABE_ITO[0],
)
No description has been provided for this image
In [7]:
plot_flight_summary(
    complete_flight,
    "CompleteFlight waypoint detour from Amsterdam to Rome",
    OKABE_ITO[2],
)
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, OKABE_ITO[0]),
    ("CompleteFlight", complete_flight, OKABE_ITO[2]),
):
    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=OKABE_ITO[1], ls="--", lw=1)
for limit in (-0.5, 0.5):
    axes[2].axhline(limit, color=OKABE_ITO[1], 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