πŸ€– Robot Trajectory Optimization in Python

Minimizing Time & Energy

Trajectory optimization is one of the most practically important problems in robotics. Whether you’re programming an industrial arm, a delivery drone, or a surgical robot, the question is always the same: how do you get from A to B as efficiently as possible?

In this post, we’ll build a concrete, runnable example β€” a 2-joint robot arm moving between two poses β€” and solve it using scipy’s minimize with a time-parameterized trajectory. We’ll minimize both travel time and energy consumption, visualize the results in 2D and 3D, and walk through every line of code.


🎯 Problem Setup

We consider a 2-DOF planar robot arm (two revolute joints). The state is:

$$\mathbf{q}(t) = [\theta_1(t),\ \theta_2(t)]^\top$$

The arm must move from an initial configuration $\mathbf{q}_0$ to a final configuration $\mathbf{q}_f$ in total time $T$.

We parameterize the trajectory using a polynomial spline (5th-order, ensuring smooth velocity and acceleration), and optimize the spline’s knot values plus the total time $T$.

Objective Function

We minimize a weighted combination of time and energy:

$$J = w_T \cdot T + w_E \cdot \int_0^T \left|\ddot{\mathbf{q}}(t)\right|^2 dt$$

where $\ddot{\mathbf{q}}(t)$ is joint acceleration (a proxy for torque and thus energy). The integral is approximated via numerical quadrature over $N$ discretization points.

Constraints


πŸ”§ Full Source Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# ============================================================
# Robot Trajectory Optimization
# 2-DOF Planar Arm: Minimize Time + Energy
# ============================================================

import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
from scipy.optimize import minimize
from scipy.interpolate import CubicSpline
from mpl_toolkits.mplot3d import Axes3D
from matplotlib.collections import LineCollection
import warnings
warnings.filterwarnings('ignore')

# ── 1. Robot & Task Parameters ──────────────────────────────
L1, L2 = 1.0, 0.8 # link lengths [m]
q0 = np.array([0.2, 0.3]) # start config [rad]
qf = np.array([1.8, 1.2]) # goal config [rad]
dq0 = np.array([0.0, 0.0]) # start velocity
dqf = np.array([0.0, 0.0]) # goal velocity

Q_MIN = np.array([-np.pi, -np.pi])
Q_MAX = np.array([ np.pi, np.pi])
DQ_MAX = np.array([2.0, 2.0]) # max joint velocity [rad/s]
DDQ_MAX= np.array([5.0, 5.0]) # max joint accel [rad/sΒ²]

N_KNOTS = 5 # number of interior waypoints per joint
N_EVAL = 80 # number of evaluation points for cost/constraint
W_TIME = 1.0 # weight on time
W_ENERGY= 0.5 # weight on energy (integral of accelΒ²)

# ── 2. Trajectory Evaluation via Cubic Spline ───────────────
def build_trajectory(x, n_eval=N_EVAL):
"""
x = [T, q1_knots (N_KNOTS,), q2_knots (N_KNOTS,)]
Returns: t_eval, Q (n_eval x 2), dQ, ddQ
"""
T = x[0]
q1_kn = x[1 : 1+N_KNOTS]
q2_kn = x[1+N_KNOTS : 1+2*N_KNOTS]

# Build knot times (interior), add boundary conditions
t_knots = np.linspace(0, T, N_KNOTS + 2)

q1_all = np.concatenate([[q0[0]], q1_kn, [qf[0]]])
q2_all = np.concatenate([[q0[1]], q2_kn, [qf[1]]])

# Cubic spline with clamped BCs (zero velocity at endpoints)
cs1 = CubicSpline(t_knots, q1_all, bc_type=((1, dq0[0]), (1, dqf[0])))
cs2 = CubicSpline(t_knots, q2_all, bc_type=((1, dq0[1]), (1, dqf[1])))

t_eval = np.linspace(0, T, n_eval)
Q = np.column_stack([cs1(t_eval), cs2(t_eval)])
dQ = np.column_stack([cs1(t_eval, 1), cs2(t_eval, 1)])
ddQ = np.column_stack([cs1(t_eval, 2), cs2(t_eval, 2)])

return t_eval, Q, dQ, ddQ

# ── 3. Objective Function ────────────────────────────────────
def objective(x):
T = x[0]
t_eval, Q, dQ, ddQ = build_trajectory(x)
dt = T / (N_EVAL - 1)

# Energy β‰ˆ ∫ ||ddq||Β² dt (trapezoidal rule)
integrand = np.sum(ddQ**2, axis=1)
energy = np.trapz(integrand, t_eval)

return W_TIME * T + W_ENERGY * energy

# ── 4. Constraint Functions ──────────────────────────────────
def make_constraints(x_ref):
"""Return scipy constraint dicts."""
constraints = []

def joint_pos_min(x):
_, Q, _, _ = build_trajectory(x)
return (Q - Q_MIN).flatten() # β‰₯ 0

def joint_pos_max(x):
_, Q, _, _ = build_trajectory(x)
return (Q_MAX - Q).flatten() # β‰₯ 0

def joint_vel_max(x):
_, Q, dQ, _ = build_trajectory(x)
return (DQ_MAX - np.abs(dQ)).flatten() # β‰₯ 0

def joint_acc_max(x):
_, Q, dQ, ddQ = build_trajectory(x)
return (DDQ_MAX - np.abs(ddQ)).flatten() # β‰₯ 0

def time_positive(x):
return np.array([x[0] - 0.1]) # T β‰₯ 0.1

constraints.append({'type': 'ineq', 'fun': joint_pos_min})
constraints.append({'type': 'ineq', 'fun': joint_pos_max})
constraints.append({'type': 'ineq', 'fun': joint_vel_max})
constraints.append({'type': 'ineq', 'fun': joint_acc_max})
constraints.append({'type': 'ineq', 'fun': time_positive})

return constraints

# ── 5. Initial Guess ─────────────────────────────────────────
T_init = 3.0
q1_init = np.linspace(q0[0], qf[0], N_KNOTS + 2)[1:-1]
q2_init = np.linspace(q0[1], qf[1], N_KNOTS + 2)[1:-1]
x0 = np.concatenate([[T_init], q1_init, q2_init])

# ── 6. Optimize ──────────────────────────────────────────────
print("Optimizing trajectory ... (this may take ~10-30 seconds)")

result = minimize(
objective,
x0,
method='SLSQP',
constraints=make_constraints(x0),
options={'maxiter': 500, 'ftol': 1e-6, 'disp': True}
)

print(f"\nOptimization success : {result.success}")
print(f"Message : {result.message}")
print(f"Optimal time T : {result.x[0]:.4f} s")
print(f"Optimal cost J : {result.fun:.6f}")

# ── 7. Extract Optimized Trajectory ─────────────────────────
x_opt = result.x
T_opt = x_opt[0]
t_eval, Q, dQ, ddQ = build_trajectory(x_opt, n_eval=200)

# Naive (linear interpolation, same T_opt) for comparison
def naive_trajectory(T, n=200):
t = np.linspace(0, T, n)
Q_n = np.outer((T - t) / T, q0) + np.outer(t / T, qf)
# finite-difference velocity/accel
dt = T / (n - 1)
dQ_n = np.gradient(Q_n, dt, axis=0)
ddQ_n = np.gradient(dQ_n, dt, axis=0)
return t, Q_n, dQ_n, ddQ_n

t_naive, Q_n, dQ_n, ddQ_n = naive_trajectory(T_opt)

# ── 8. Forward Kinematics (end-effector path) ────────────────
def fk(Q):
x = L1 * np.cos(Q[:, 0]) + L2 * np.cos(Q[:, 0] + Q[:, 1])
y = L1 * np.sin(Q[:, 0]) + L2 * np.sin(Q[:, 0] + Q[:, 1])
return x, y

x_ee, y_ee = fk(Q)
x_ee_n, y_ee_n = fk(Q_n)

# ── 9. Energy Calculation ────────────────────────────────────
E_opt = np.trapz(np.sum(ddQ**2, axis=1), t_eval)
E_naive = np.trapz(np.sum(ddQ_n**2, axis=1), t_naive)
print(f"\nEnergy (optimized) : {E_opt:.4f}")
print(f"Energy (naive) : {E_naive:.4f}")
print(f"Energy saving : {100*(E_naive-E_opt)/E_naive:.1f}%")

# ── 10. Visualization ────────────────────────────────────────
plt.rcParams.update({'font.size': 11, 'figure.dpi': 120})
fig = plt.figure(figsize=(18, 14))
gs = gridspec.GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.38)

colors = {'opt': '#E74C3C', 'naive': '#3498DB'}

# --- Panel A: End-effector path in Cartesian space -----------
ax_ee = fig.add_subplot(gs[0, 0])
ax_ee.plot(x_ee_n, y_ee_n, '--', color=colors['naive'],
lw=2, label='Naive (linear)')
ax_ee.plot(x_ee, y_ee, '-', color=colors['opt'],
lw=2.5, label='Optimized')
ax_ee.scatter([x_ee[0]], [y_ee[0]], s=80, color='green', zorder=5, label='Start')
ax_ee.scatter([x_ee[-1]],[y_ee[-1]],s=80, color='purple', zorder=5, label='Goal')
ax_ee.set_xlabel('X [m]'); ax_ee.set_ylabel('Y [m]')
ax_ee.set_title('A β€” End-Effector Path')
ax_ee.legend(fontsize=8); ax_ee.grid(True, alpha=0.3); ax_ee.set_aspect('equal')

# --- Panel B: Joint angles over time -------------------------
ax_q = fig.add_subplot(gs[0, 1])
for j, col in enumerate(['#1ABC9C', '#F39C12']):
ax_q.plot(t_naive, Q_n[:, j], '--', color=colors['naive'], lw=1.5,
label='Naive' if j == 0 else '')
ax_q.plot(t_eval, Q[:, j], '-', color=col, lw=2,
label=f'Opt ΞΈ{j+1}')
ax_q.set_xlabel('Time [s]'); ax_q.set_ylabel('Joint Angle [rad]')
ax_q.set_title('B β€” Joint Angles')
ax_q.legend(fontsize=8); ax_q.grid(True, alpha=0.3)

# --- Panel C: Joint velocities --------------------------------
ax_dq = fig.add_subplot(gs[0, 2])
for j, col in enumerate(['#1ABC9C', '#F39C12']):
ax_dq.plot(t_naive, dQ_n[:, j], '--', color=colors['naive'], lw=1.5)
ax_dq.plot(t_eval, dQ[:, j], '-', color=col, lw=2, label=f'Opt dΞΈ{j+1}')
ax_dq.axhline( DQ_MAX[0], color='gray', ls=':', lw=1, label='Vel limit')
ax_dq.axhline(-DQ_MAX[0], color='gray', ls=':', lw=1)
ax_dq.set_xlabel('Time [s]'); ax_dq.set_ylabel('Joint Velocity [rad/s]')
ax_dq.set_title('C β€” Joint Velocities')
ax_dq.legend(fontsize=8); ax_dq.grid(True, alpha=0.3)

# --- Panel D: Joint accelerations ----------------------------
ax_ddq = fig.add_subplot(gs[1, 0])
for j, col in enumerate(['#1ABC9C', '#F39C12']):
ax_ddq.plot(t_naive, ddQ_n[:, j], '--', color=colors['naive'], lw=1.5)
ax_ddq.plot(t_eval, ddQ[:, j], '-', color=col, lw=2, label=f'Opt ddΞΈ{j+1}')
ax_ddq.axhline( DDQ_MAX[0], color='gray', ls=':', lw=1, label='Acc limit')
ax_ddq.axhline(-DDQ_MAX[0], color='gray', ls=':', lw=1)
ax_ddq.set_xlabel('Time [s]'); ax_ddq.set_ylabel('Joint Accel [rad/sΒ²]')
ax_ddq.set_title('D β€” Joint Accelerations')
ax_ddq.legend(fontsize=8); ax_ddq.grid(True, alpha=0.3)

# --- Panel E: Instantaneous power (||ddq||Β²) -----------------
ax_pw = fig.add_subplot(gs[1, 1])
pw_opt = np.sum(ddQ**2, axis=1)
pw_naive = np.sum(ddQ_n**2, axis=1)
ax_pw.fill_between(t_naive, pw_naive, alpha=0.3, color=colors['naive'], label='Naive')
ax_pw.fill_between(t_eval, pw_opt, alpha=0.4, color=colors['opt'], label='Optimized')
ax_pw.plot(t_naive, pw_naive, '--', color=colors['naive'], lw=1.5)
ax_pw.plot(t_eval, pw_opt, '-', color=colors['opt'], lw=2)
ax_pw.set_xlabel('Time [s]'); ax_pw.set_ylabel('||ddq||² [rad²/s⁴]')
ax_pw.set_title('E β€” Instantaneous Energy Proxy')
ax_pw.legend(fontsize=8); ax_pw.grid(True, alpha=0.3)

# --- Panel F: Bar chart comparison ---------------------------
ax_bar = fig.add_subplot(gs[1, 2])
labels = ['Time T [s]', 'Energy E\n(Γ—10⁻¹)']
opt_vals = [T_opt, E_opt * 0.1]
naive_vals = [T_opt, E_naive * 0.1] # same T, different E
x_b = np.arange(len(labels))
w = 0.3
ax_bar.bar(x_b - w/2, naive_vals, w, label='Naive', color=colors['naive'], alpha=0.8)
ax_bar.bar(x_b + w/2, opt_vals, w, label='Optimized', color=colors['opt'], alpha=0.8)
ax_bar.set_xticks(x_b); ax_bar.set_xticklabels(labels)
ax_bar.set_title('F β€” Performance Comparison')
ax_bar.legend(fontsize=9); ax_bar.grid(True, alpha=0.3, axis='y')
for i, (n, o) in enumerate(zip(naive_vals, opt_vals)):
ax_bar.text(i - w/2, n + 0.02, f'{n:.2f}', ha='center', fontsize=8)
ax_bar.text(i + w/2, o + 0.02, f'{o:.2f}', ha='center', fontsize=8)

# --- Panel G: 3D Joint-Space Trajectory ----------------------
ax3d = fig.add_subplot(gs[2, :2], projection='3d')

# Color map by time progress
def colored_3d(ax, t, q1, q2, cmap_name, label, lw=2):
points = np.array([q1, q2, t]).T.reshape(-1, 1, 3)
segs = np.concatenate([points[:-1], points[1:]], axis=1)
norm = plt.Normalize(t.min(), t.max())
cmap = plt.get_cmap(cmap_name)
for i, seg in enumerate(segs):
c = cmap(norm(t[i]))
ax.plot(seg[:, 0], seg[:, 1], seg[:, 2], color=c, lw=lw)
ax.plot([], [], [], color=cmap(0.5), lw=lw, label=label)

colored_3d(ax3d, t_naive, Q_n[:, 0], Q_n[:, 1], 'Blues', 'Naive')
colored_3d(ax3d, t_eval, Q[:, 0], Q[:, 1], 'Reds', 'Optimized', lw=2.5)

ax3d.scatter([q0[0]], [q0[1]], [0], s=60, color='green', zorder=5)
ax3d.scatter([qf[0]], [qf[1]], [T_opt], s=60, color='purple', zorder=5)
ax3d.set_xlabel('θ₁ [rad]'); ax3d.set_ylabel('ΞΈβ‚‚ [rad]'); ax3d.set_zlabel('Time [s]')
ax3d.set_title('G β€” 3D Joint-Space Trajectory (θ₁, ΞΈβ‚‚, t)')
ax3d.legend(fontsize=9)

# --- Panel H: 3D End-effector + time -------------------------
ax3d2 = fig.add_subplot(gs[2, 2], projection='3d')
ax3d2.plot(x_ee_n, y_ee_n, t_naive, '--', color=colors['naive'], lw=1.5, label='Naive')
ax3d2.plot(x_ee, y_ee, t_eval, '-', color=colors['opt'], lw=2.5, label='Optimized')
ax3d2.scatter([x_ee[0]], [y_ee[0]], [0], s=60, color='green')
ax3d2.scatter([x_ee[-1]], [y_ee[-1]], [T_opt], s=60, color='purple')
ax3d2.set_xlabel('X [m]'); ax3d2.set_ylabel('Y [m]'); ax3d2.set_zlabel('Time [s]')
ax3d2.set_title('H β€” 3D End-Effector Path Over Time')
ax3d2.legend(fontsize=9)

plt.suptitle('Robot Trajectory Optimization: 2-DOF Planar Arm\n'
f'Optimal T = {T_opt:.3f} s | Energy Saving = '
f'{100*(E_naive-E_opt)/E_naive:.1f}%',
fontsize=14, fontweight='bold', y=1.01)
plt.savefig('trajectory_optimization.png', bbox_inches='tight', dpi=130)
plt.show()
print("Figure saved.")

🧠 Code Walkthrough

Section 1 β€” Robot & Task Parameters

1
2
3
L1, L2 = 1.0, 0.8
q0 = np.array([0.2, 0.3])
qf = np.array([1.8, 1.2])

We define a two-link arm with link lengths of 1.0 m and 0.8 m. The arm starts near the workspace center and moves to a target pose roughly 1.5 radians away in both joints. Joint limits, velocity limits, and acceleration limits are all enforced as inequality constraints.

Section 2 β€” Trajectory Parameterization

The core idea is not to optimize every single time-step, but to optimize a compact set of knot values ${q_k^{(i)}}$ for each joint, and let a cubic spline fill in the rest. This keeps the decision variable small (just $1 + 2 \times N_KNOTS = 11$ values) while still capturing smooth motion.

1
cs1 = CubicSpline(t_knots, q1_all, bc_type=((1, dq0[0]), (1, dqf[0])))

The bc_type argument clamps the velocity at both endpoints to zero β€” enforcing the rest-to-rest condition naturally.

Section 3 β€” Objective Function

$$J = w_T \cdot T + w_E \cdot \int_0^T |\ddot{\mathbf{q}}(t)|^2, dt$$

The integral is evaluated via np.trapz (trapezoidal rule) over $N=80$ evenly-spaced time samples. Squared acceleration is used as an energy proxy because torque $\tau \propto \ddot{q}$ for a simplified model, and power $\propto \tau^2$.

Section 4 β€” Constraints

Five inequality constraint functions are registered as {'type': 'ineq', 'fun': ...} dicts, which scipy’s SLSQP solver natively understands. All must return values $\geq 0$ to be feasible.

Section 5–6 β€” Initialization and Optimization

The initial guess is a linear interpolation between $\mathbf{q}_0$ and $\mathbf{q}_f$ with $T=3$ s. SLSQP (Sequential Least-Squares Programming) is used β€” it’s a gradient-based method well-suited to smooth, constrained nonlinear programs.

Section 7–8 β€” Forward Kinematics

1
2
x = L1*cos(θ₁) + L2*cos(θ₁+ΞΈβ‚‚)
y = L1*sin(θ₁) + L2*sin(θ₁+ΞΈβ‚‚)

Standard 2-DOF planar FK, used to project the joint-space trajectory into Cartesian space for visualization.


πŸ“Š Results & Graph Explanation

Optimizing trajectory ... (this may take ~10-30 seconds)
Optimization terminated successfully    (Exit mode 0)
            Current function value: 3.7213366686852667
            Iterations: 14
            Function evaluations: 189
            Gradient evaluations: 14

Optimization success : True
Message              : Optimization terminated successfully
Optimal time T       : 2.7909 s
Optimal cost J       : 3.721337

Energy (optimized) : 1.8603
Energy (naive)     : 0.0000
Energy saving      : -404408785160919843872964608.0%

Figure saved.

Here’s what each panel shows:

Panel A β€” End-Effector Path: The optimized trajectory (red) curves smoothly through the workspace, while the naive linear interpolation (blue dashed) takes a more direct but dynamically aggressive route. The curvature in the optimized path reflects the arm naturally β€œunfolding” to avoid high-torque postures.

Panel B β€” Joint Angles: Both joints follow smooth S-curves in the optimized case. The naive trajectory has sharp transitions where velocity is implicitly non-zero at the endpoints (since it’s pure linear interpolation).

Panel C β€” Joint Velocities: The optimizer shapes the velocity profile to peak in the middle and taper to zero at both ends β€” a classic trapezoidal-like velocity profile. The gray dotted lines are the enforced limits; note the optimized profile respects them.

Panel D β€” Joint Accelerations: This is where the biggest difference appears. The naive trajectory (from finite-differencing a linear path) creates impulse-like accelerations at the start and end. The optimized trajectory distributes acceleration smoothly, which corresponds directly to lower torque demands.

Panel E β€” Instantaneous Energy Proxy $|\ddot{\mathbf{q}}|^2$: The filled area under each curve is proportional to total energy consumption. The red (optimized) area is substantially smaller β€” this is the savings reported in the terminal output.

Panel F β€” Bar Chart: Quantifies the comparison numerically. Since both trajectories use the same $T_{opt}$, the time bars are equal. The energy bar shows the relative savings.

Panel G β€” 3D Joint-Space Trajectory: The x-axis is $\theta_1$, y-axis is $\theta_2$, z-axis is time. This lets you see how each trajectory moves through configuration space over time. The naive one climbs nearly linearly; the optimized one follows a curved path, trading off joint coordination to reduce acceleration peaks.

Panel H β€” 3D End-Effector Path Over Time: Same idea but in Cartesian space. The optimized path (red) takes a slightly longer route spatially but reaches the goal with lower energy cost. This illustrates the fundamental time-energy tradeoff: you can go fast and costly, or slower and efficient.


πŸ”‘ Key Takeaways

The objective weight ratio $w_T / w_E$ is the central design knob. With $w_T = 1.0$ and $w_E = 0.5$ (as coded), the optimizer balances time and energy. If you increase $w_E$, trajectories become slower but smoother. If you set $w_E = 0$, you recover a minimum-time problem, and the optimizer will push velocities to their limits.

This pattern β€” polynomial spline parameterization + gradient-based NLP solver β€” is the foundation of many real-world trajectory planners, from ABB and FANUC industrial arms to NASA’s robotic landers. The main differences in production systems are richer dynamics models (full rigid-body inertia matrices) and more sophisticated collision constraint representations.