opentop is v2 of openap-top. The package has been renamed and restructured: what used to be installed as openap-top and imported as openap.top now installs as opentop and imports as import opentop. The three headline changes in v2.0:
- New Opti-stack backend — NLP construction has moved to CasADi's Opti stack, removing ~400 lines of boilerplate and fixing several long-standing bugs.
- Standalone package —
opentopis no longer a namespace extension ofopenap; it installs as a top-level package and imports asimport opentop. - Command-line interface —
opentop optimizeandopentop gengridexpose the optimizer and grid-cache builder from the shell.
See What's New in 2.0 below for the full migration table.
Flight trajectory optimizer based on the OpenAP aircraft performance model.
opentop uses non-linear optimal control via direct collocation (CasADi + IPOPT) to generate optimal flight trajectories. It provides simple interfaces for:
- Complete flight trajectories (takeoff → cruise → landing)
- Individual phases: climb, cruise, descent
- Fuel-optimal, time-optimal, cost-index, and climate-optimal objectives
- Wind integration
- Custom 4D grid cost functions (contrails, weather, airspace)
- User-defined objective functions and constraints
Detailed guide and examples: https://openap.dev/optimize.
From PyPI:
pip install --upgrade opentopFrom the development branch:
pip install --upgrade git+https://github.com/junzis/openap
pip install --upgrade git+https://github.com/junzis/opentopopentop is a standalone package. Prior to v2.0 it shipped as openap.top, a namespace extension of openap; v2.0 drops that and installs as a top-level opentop package instead.
OpenTOP 2.6 uses continuous, piecewise-linear Mach, vertical-speed
and heading controls. With nodes=N intervals it optimizes N+1 boundary
controls, including an independent arrival control. Dynamics and objective
quadrature evaluate the interpolated control at every collocation point.
Phase rate limits cover every interval using its actual duration, including
variable timesteps. Cruise performance constraints also cover internal
collocation points; nonlinear feasibility still requires between-node checks.
For CompleteFlight, trajectory(path_constraint_points=[(interval, tau), ...])
adds force and energy constraints at selected points inside intervals. Interval
indices start at zero; tau is the local time fraction from 0 to 1. States use
the collocation polynomial and controls use linear interpolation. Select these
points from a path audit for the specific route, mesh, and objective, then audit
the new solution again. The optimizer does not select them automatically, and
these additional samples do not guarantee feasibility everywhere between them.
This replaces the previous piecewise-constant control transcription. Saved
trajectory DataFrames retain their columns and N+1 rows. The numeric export
helper still accepts historical N-column control arrays for replaying old data.
Continuity does not imply continuous control derivatives, a certified continuous
path envelope, or a complete acceleration-energy model.
import opentop
optimizer = opentop.CompleteFlight("A320", "EHAM", "LGAV", m0=0.85)
flight = optimizer.trajectory(objective="fuel")flight is a Pandas DataFrame with columns for position, altitude, mass,
Mach, TAS, vertical rate, heading, and per-segment fuel_cost.
optimizer.trajectory(objective="time") # minimum time
optimizer.trajectory(objective="ci:30") # cost index 30
optimizer.trajectory(objective="gwp100") # 100-yr global warming potential
optimizer.trajectory(objective="gtp100") # 100-yr global temperature potentialThe supported climate metrics are gwp20, gwp50, gwp100, gtp20, gtp50, gtp100.
optimizer = opentop.CompleteFlight(
"A320", "EHAM", "LGAV", m0=0.85, engine="CFM56-5B4"
)cruise = opentop.Cruise("A320", "EHAM", "LGAV", m0=0.85).trajectory()
climb = opentop.Climb("A320", "EHAM", "LGAV", m0=0.85).trajectory()
descent = opentop.Descent("A320", "EHAM", "LGAV", m0=0.85).trajectory()Cruise also supports constant-altitude, constant-Mach, and fixed-track modes:
opt = opentop.Cruise("A320", "EHAM", "LGAV", m0=0.85)
opt.fix_cruise_altitude()
opt.fix_mach_number()
opt.fix_track_angle()
flight = opt.trajectory()To hold the cruise at a specific Mach number instead, pass mach_value:
flight = opentop.Cruise("A320", "EHAM", "LGAV", m0=0.85, mach_value=0.78).trajectory()To follow a recorded cruise ground track while optimizing altitude and speed:
opt = opentop.Cruise("A320", "EHAM", "EDDF", m0=0.85, mach_value=0.78)
opt.follow_track(recorded.latitude, recorded.longitude, tolerance_m=1000)
flight = opt.trajectory()The reference is a cubic spline through at least four finite latitude/longitude
points, with no consecutive duplicates. Its endpoints must be within 1 km of the
flight endpoints and are snapped to them. Boundary nodes lie on the spline;
interior collocation and quarter-interval samples stay within tolerance_m
(default 1 km). This is a sampled constraint, not a continuous-path guarantee.
Sharper bends or tighter tolerances may require more nodes via opt.setup(nodes=...).
Turn-rate limits remain active, so an infeasible recorded turn can prevent convergence.
Check opt.success after solving. follow_track cannot be combined with
fix_track_angle; fixed Mach, wind, and fleet optimization remain supported
(subject to the existing fleet restrictions on wind and time-dependent grids).
MultiAircraft formulates several cruise trajectories in one CasADi Opti
problem and applies the default 5 NM / 1,000 ft high-order separation model:
import opentop
flights = [
opentop.FlightSpec(
"AC1", opentop.Cruise("A320", (52.3, 9.8), (49.5, 2.5)), start_time=0
),
opentop.FlightSpec(
"AC2", opentop.Cruise("B738", (52.9, 4.6), (49.5, 9.3)), start_time=140
),
]
result = opentop.MultiAircraft(flights).trajectory()
df_ac1 = result.trajectories["AC1"]The default constraint is
(dx / Rxy)^2 + (dy / Rxy)^2 + (dh / Rz)^8 >= 1.3. Pass a
SeparationConfig only to customize it. Fleet DataFrames preserve relative
ts and add absolute_ts using each flight's configured start time.
To study horizontal conflict avoidance at one shared flight level, first call
fix_cruise_altitude() on every child optimizer and then pass
common_altitude=True. The common altitude remains an optimization variable:
for flight in flights:
flight.optimizer.fix_cruise_altitude()
result = opentop.MultiAircraft(flights, common_altitude=True).trajectory()Descent optimizers can also be coordinated. List flights in the desired
landing order and use minimum_arrival_gap_s to enforce a runway sequence.
Shared final-approach points can be supplied with ordered waypoint constraints.
For structured arrivals, route_heading_tolerance_deg keeps each route leg
near its nominal bearing, while max_duration_s can impose an operational
descent-time bound without adding time to the objective.
See the
coordinated-arrivals notebook for a
three-aircraft runway-arrival example.
The implementation supports uniform-timestep Cruise and Descent
optimization, including ordered waypoint constraints. Wind, variable
timesteps, time-dependent 4D grid objectives, and other phases are rejected
explicitly until their fleet timing semantics are implemented.
Download ERA5 (or similar) meteorological data in GRIB format, then:
import opentop
windfield = opentop.tools.read_grids("wind.grib")
optimizer = opentop.CompleteFlight("A320", "EHAM", "LGAV", m0=0.85)
optimizer.enable_wind(windfield)
flight = optimizer.trajectory(objective="fuel")
opentop.vis.trajectory(flight, windfield=windfield, barb_steps=15)Build a CasADi interpolant from a DataFrame with columns longitude, latitude, height (m), cost, and optionally ts:
interpolant = opentop.tools.interpolant_from_dataframe(df_cost)
def contrail_objective(x, u, dt, **kwargs):
grid_cost = optimizer.obj_grid_cost(
x, u, dt, interpolant=kwargs["interpolant"], n_dim=3
)
fuel_cost = optimizer.obj_fuel(x, u, dt)
return grid_cost + fuel_cost
flight = optimizer.trajectory(
objective=contrail_objective,
interpolant=interpolant,
n_dim=3,
)See https://openap.dev/optimize/contrails.html for a full contrail + CO₂ example.
Any callable with signature (x, u, dt, **kwargs) -> ca.MX can be used:
def my_objective(x, u, dt, **kwargs):
# x: state [xp, yp, h, mass, ts]
# u: control [mach, vs, psi]
return your_cost_expression
flight = optimizer.trajectory(objective=my_objective)Trajectory optimization with grid costs, blended objectives, or tight constraints can have multiple local minima — a single solve lands in whichever basin is closest to the initial guess. For problems where robustness matters, multi_start_trajectory runs N solves from different randomized initial guesses and returns the best:
trajectory, candidates = optimizer.multi_start_trajectory(
objective=contrail_objective,
interpolant=interp,
max_fuel=6500,
n_starts=5,
lateral_jitter_km=100.0,
altitude_jitter_ft=3000.0,
seed=0,
)Start 0 uses the canonical initial guess (your initial_guess= if provided, otherwise the default great-circle). Starts 1..N-1 are random perturbations of the canonical: sinusoidal lateral bulges (endpoints preserved) and constant altitude offsets.
Returned is a (trajectory, candidates) tuple. trajectory is the winning DataFrame (feasibility-first, then lowest objective). candidates is a best-first ordered list of dicts with per-start metadata (objective, fuel, grid_cost, success, iters, perturbation, wall_time_s, trajectory).
n_starts=1 is identical to calling trajectory() directly, so adding multi_start_trajectory to existing code is additive.
Linear interpolation over a 4D contrail-cost grid has discontinuous derivatives at every grid cell boundary, which can cause IPOPT's line search to oscillate on non-convex blended objectives. The fix is to use a cubic B-spline interpolant, which has continuous derivatives. But building a bspline over a large grid can take several minutes, so we expose a cache utility:
from opentop.tools import cached_interpolant_from_dataframe
interpolant = cached_interpolant_from_dataframe(
df_cost, "cache/contrail.casadi", shape="bspline"
)First call builds the bspline and writes it to disk (~1-3 minutes for a 60k-point slice); subsequent calls load the cache in under a second.
If your grid only covers the altitude band where contrails actually form (typically FL200-FL440), extend it with zero-cost levels outside that band before building the interpolant — otherwise opentop.CompleteFlight trajectories that start and end on the ground will query the interpolant outside its data range. The opentop CLI has a helper for this:
opentop gengrid --in raw_grid.parquet --out grid.casadi \
--bbox 35:57,-9:7 --time 2022-02-20T10:00,2022-02-20T14:00 \
--pad-altitudes--pad-altitudes is on by default and adds zero-cost rows at altitudes from 0 to FL480 so the interpolant returns 0 (physically correct — no contrails below ~FL200 or above ~FL440) outside the data band.
Installing opentop also installs the opentop executable, which exposes two subcommands: optimize (run a trajectory optimization) and gengrid (precompute a grid-cost interpolant).
Run a trajectory optimization without writing any Python:
opentop optimize EHAM EDDF -a A320 --phase cruise --obj fuelA concise solver summary (status, iterations, objective, fuel burn, max altitude, flight time) is printed to stdout. Pass -o flight.parquet to also save the full trajectory DataFrame.
Supported objectives: fuel, time, ci:N (cost index, any integer), gwp20/gwp50/gwp100, gtp20/gtp50/gtp100, and grid (requires --grid FILE).
Blended objectives are written as a weighted sum:
opentop optimize EHAM EDDF -a A320 --phase all \
--obj "0.3*fuel+0.7*grid" \
--grid contrail.casadiCommon flags:
| flag | purpose |
|---|---|
-a, --aircraft |
aircraft type (required), e.g. A320 |
--phase |
all (CompleteFlight, default), cruise, climb, descent |
--obj |
objective expression — single term or weighted sum |
--m0 |
initial mass as a fraction of MTOW (default 0.85) |
--grid |
cost-grid file; .casadi cache preferred, .parquet accepted with a slow-path warning |
--max-iter |
IPOPT iteration cap (default 1500) |
-o, --output |
write the trajectory DataFrame to a parquet file |
-v, --debug |
verbose IPOPT output |
Build and cache a CasADi interpolant from a raw cost grid:
opentop gengrid --in raw_grid.parquet --out contrail.casadi \
--bbox 35:57,-9:7 \
--time 2022-02-20T10:00,2022-02-20T14:00 \
--shape bsplineThe resulting .casadi file loads in under a second (vs. minutes to rebuild a bspline from raw grid data), so keep it on disk and pass it to opentop optimize --grid.
Use opentop --help, opentop optimize --help, and opentop gengrid --help for the full option list.
Replay a real flight by callsign: pulls trajectory from OpenSky, ERA5 meteo from fastmeteo, and runs an optimization side-by-side with the actual flight.
# Install with the replay extras
pip install "opentop[replay]"
# Flag mode
opentop replay RYR880W --date 2023-01-05 --obj fuel -o ./results/
# Interactive wizard (no arguments)
opentop replayOutputs under the output directory:
actual.parquet— cleaned OpenSky traceoptimized.parquet— optimized trajectorytrajectory.png— actual-vs-optimized overlay
Requires OpenSky credentials at ~/.config/traffic/traffic.conf (see
traffic's docs). ERA5 data is fetched via fastmeteo's ArcoEra5; the default
local store is <system tmp>/opentop-era5 — first use will download several
GB on a large bbox.
After calling .trajectory(), the optimizer exposes:
optimizer.stats # solver statistics dict ("success", "iter_count", ...)
optimizer.success # True if the most recent solve succeeded
optimizer.objective_value # final objective value (float)For full structured access including status, iteration count, fuel, and grid cost in one dataclass:
result = optimizer.trajectory(objective="fuel", result_object=True)
# result.df, result.success, result.status, result.objective,
# result.iters, result.fuel, result.grid_cost, result.statsRun benchmarks across versions to verify performance:
./benchmark.sh # Benchmark HEAD (local dev code)
./benchmark.sh v2.0.0 # Benchmark a specific PyPI release
./benchmark.sh v1.11.0 v2.0.0 # Benchmark multiple versions sequentiallyReports land in tests/benchmarks/<version>.txt.
Version 2.2 makes several structural changes that may require small updates to existing code:
| v2.1 | v2.2 |
|---|---|
opentop.MultiPhase(...) |
opentop.CompleteFlight(...) — MultiPhase has been removed |
trajectory(objective="fuel", foo=bar) silently tolerates unknown kwargs |
Unknown kwargs raise TypeError; only documented names are accepted |
optimizer.solver.stats() |
optimizer.stats (dict) or optimizer.success (bool) |
opentop.vis.map(df, ...) |
opentop.vis.plot_map(df, ...) |
| — | New: trajectory(..., result_object=True) returns a TrajectoryResult dataclass |
| — | New: opentop replay CALLSIGN CLI — fetches real flight from OpenSky, runs comparison optimization (see ### opentop replay above) |
| — | New: `opentop.vis.trajectory(df |
Internally, opentop/ has been split into focused modules: _dynamics.py, _objectives.py, _trajectory.py, _options.py, _multi_start.py. If you were importing internal helpers, they have moved — use the public API on Base / Cruise / CompleteFlight where possible.
MultiPhase was rarely used and its functionality is fully covered by CompleteFlight. If you had code using MultiPhase, replace it with:
full = opentop.CompleteFlight("A320", "EHAM", "LGAV", m0=0.85).trajectory()Type annotations are now enforced in CI via pyright (basic mode). Public API signatures are fully annotated; see opentop/_options.py for the TrajectoryResult dataclass.
Version 2.0 is a major refactor. Most user code keeps the same shape, but a few things have moved:
| v1.x | v2.0 |
|---|---|
pip install openap-top |
pip install opentop |
from openap import top |
import opentop |
top.Cruise(...) |
opentop.Cruise(...) |
optimizer.change_engine() dropped |
opentop.Cruise(..., engine="CFM56-5B4") |
optimizer.solution["f"] |
optimizer.objective_value |
optimizer.solver was a ca.nlpsol callable |
use optimizer.stats and optimizer.success |
setup(max_iteration=...) |
setup(max_iter=...) |
| — | new CLI: opentop optimize ORIGIN DEST ... and opentop gengrid ... |
| — | new opentop.tools.cached_interpolant_from_dataframe() for disk-cached bspline interpolants |
The NLP construction moved to CasADi's Opti stack, which removed ~400 lines of boilerplate and cleaned up several bugs. The module rename from openap.top to opentop eliminates the namespace-extension install mode that used to require .pth tricks.
See the changelog for details.
MIT License. See LICENSE for details.

