This project is an exploration of solving trajectory motion planning as a constrained optimization, taking into account speed, acceleration and jerk constraints, including effects that depend on the current speed itself.
To avoid dealing with the initially unknown time dynamics, we solve the problem in the phase space, where time is implicitly defined from the current speed and position. Here is a sneak peek of the solver finding a jerk-limited S-curve for a simple 1D motion
Let’s simply assume a moving mass on a 1D axis. We want to solve the time trajectory given a start and end positions and , where is the total travel time.
We can define the speed and acceleration . We can also define the jerk . Limiting the jerk prevents instant changes in acceleration, which are a known issue that causes shaking on heavier machines.
We want to solve and figure out given:
Note that and are not necessarily constant: real machines have limited force as a function of the current speed . We’ll explore this generalized case in the next sections.
The initial/final conditions are:
The issue with solving , and in the time domain is that the time function is itself a function of the dynamics.
We instead choose to solve and as functions of in a phase space model:
In that representation, time is implicit and has a deep relation to the curve:
The travel time can be found through integration:
The total travel time is found by taking this integral up to . The acceleration can be found from as follows:
Note that this is only valid for non-zero , which is the case over the inner domain of the problem . For the previous speed curve, this might look something like this:
The problem can now be formulated as a search for a curve that has the following properties:
We’ll follow the intuition that has to be “inflated” as much as can be, until the constraints are touched for every .
Instead of tackling the continuous problem, we can discretize and solve a discrete vector , with . We define a time increment by using the mean speed over a segment:
As long as no two sequential values of are zero, is always defined. Given accelerations limits over a segment, we can define cones of possible values for future and past speeds:
Note that the existence of this cone is merely an artifact of the discretization of time, but we can use it to our advantage. Accelerations are subject to a similar
Here is a naive but effective approach to solve our problem:
Let’s implement step 3:
def constraint_a(x, v, acc_model, jerk_max, a_start=None, a_end=None):
"""
inputs: position vector x, speed vector v
returns: min. and max. acceleration for each segment
"""
dx = x[1:] - x[:-1]
x_mid = (x[:-1] + x[1:]) * 0.5
v_mid = (v[:-1] + v[1:]) * 0.5
dt = dx / v_mid
a = (v[1:] - v[:-1]) / t_delta
dt_a = (dt[:-1] + dt[1:]) * 0.5
a_max_next = a[:-1] + jerk_max * dt_a
a_min_next = a[:-1] - jerk_max * dt_a
a_max_prev = a[1:] + jerk_max * dt_a
a_min_prev = a[1:] - jerk_max * dt_a
a_min = np.zeros_like(a)
a_max = np.zeros_like(a)
if a_start is None:
a_min[0] = a_min_prev[0]
a_max[0] = a_max_prev[0]
else:
# constrain end acceleration
a_max[0] = a_start + jerk_max * t_delta_a[0]
a_min[0] = a_start - jerk_max * t_delta_a[0]
if a_end is None:
a_min[-1] = a_min_next[-1]
a_max[-1] = a_max_next[-1]
else:
# constrain end acceleration
a_max[-1] = a_end + jerk_max * t_delta_a[-1]
a_min[-1] = a_end - jerk_max * t_delta_a[-1]
a_min[1:-1] = np.maximum(a_min_next[:-1], a_min_prev[1:])
a_max[1:-1] = np.minimum(a_max_next[:-1], a_max_prev[1:])
a_neg_bound, a_pos_bound = acc_model(v_mid)
a_min = np.maximum(a_min, a_neg_bound)
a_max = np.minimum(a_max, a_pos_bound)
a_min = np.minimum(a_min, a)
a_max = np.maximum(a_max, a)
return a_min, a_max
Step 4 is similar:
def constraint_v(x, v, a_min, a_max, v_abs_max, v_start=None, v_end=None):
"""
inputs: position vector x, speed vector v
returns: min. and max. speed for each segment
"""
dx = x[1:] - x[:-1]
v_mid = (v[:-1] + v[1:]) * 0.5
dt = dx / v_mid
v_max_next = v[:-1] + a_max * dt
v_min_next = v[:-1] + a_min * dt
v_max_prev = v[1:] - a_min * dt
v_min_prev = v[1:] - a_max * dt
v_min = np.zeros_like(v)
v_max = np.zeros_like(v)
if v_start is None:
v_max[0] = v_max_prev[0]
v_min[0] = v_min_prev[0]
else:
# constrain start speed
v_max[0] = v_start
v_min[0] = v_start
if v_end is None:
v_max[-1] = v_max_next[-1]
v_min[-1] = v_min_next[-1]
else:
# constrain end speed
v_max[-1] = v_end
v_min[-1] = v_end
v_min[1:-1] = np.maximum(v_min_next[:-1], v_min_prev[1:])
v_max[1:-1] = np.minimum(v_max_next[:-1], v_max_prev[1:])
v_min = np.maximum(v_min, -v_abs_max)
v_max = np.minimum(v_max, v_abs_max)
return v_min, v_max
Now we can put it all together:
# acceleration horizon
a_min, a_max = constraint_a(x, v, acc_model_simple, jerk_max)
# speed horizon
v_min, v_max = constraint_v(x, v, a_min, a_max)
# update
v[:] = v_max * lambd + v * (1 - lambd)
It turns the whole method can be unstable if is too optimistic. One big flaw of the method is that modifying also changes the duration of a step. Ignoring this can lead to invalid speed horizons in step 4. We can improve this using an updated estimate of and solving for :
def constraint_v(x, v, a_min, a_max, v_abs_max, v_start=None, v_end=None):
"""
inputs: position vector x, speed vector v
returns: min. and max. speed for each segment
"""
dx = x[1:] - x[:-1]
v_max_next = np.sqrt(np.maximum(v[:-1]**2 + 2 * a_max * dx, 0))
v_min_next = np.sqrt(np.maximum(v[:-1]**2 + 2 * a_min * dx, 0))
v_min_prev = np.sqrt(np.maximum(v[1:]**2 - 2 * a_max * dx, 0))
v_max_prev = np.sqrt(np.maximum(v[1:]**2 - 2 * a_min * dx, 0))
v_min = np.zeros_like(v)
v_max = np.zeros_like(v)
if v_start is None:
v_max[0] = v_max_prev[0]
v_min[0] = v_min_prev[0]
else:
# constrain start speed
v_max[0] = v_start
v_min[0] = v_start
if v_end is None:
v_max[-1] = v_max_next[-1]
v_min[-1] = v_min_next[-1]
else:
# constrain end speed
v_max[-1] = v_end
v_min[-1] = v_end
v_min[1:-1] = np.maximum(v_min_next[:-1], v_min_prev[1:])
v_max[1:-1] = np.minimum(v_max_next[:-1], v_max_prev[1:])
v_min = np.maximum(v_min, -v_abs_max)
v_max = np.minimum(v_max, v_abs_max)
return v_min, v_max
This tweak improved the stability of the solver. In the following, unless otherwise specified, to ensure proper convergence.
Let’s explore a few scenarios with and variable parameters.
The S-curve is what’s usually used for controlling generic CNC machines. There are three phases: acceleration, constant speed and deceleration. The jerk is not handled: we can simulate this by setting an arbitrarily large jerk limit.
Here is a simulation for:
This simulation was made with:
We can introduce jerk limitation to see a smoothing effect on the acceleration curves:
This simulation was made with:
Real motors have dynamic constraints on their torque. Here’s a basic model that shows a decrease in force at higher speeds:
Note the asymmetry between braking and acceleration to illustrate the difference between those two stages. Here’s a simple function used for this model:
def acc_model_simple(v, v1=2.5, a0=3.0, ap=0.0, am=3.0):
n = len(v)
a_minus = np.zeros((n,))
a_plus = np.zeros((n,))
alpha1 = np.minimum(v[v > 0]/v1, 1)
alpha2 = np.minimum(-v[v <= 0]/v1, 1)
a_minus[v > 0] = -(alpha1 * am + (1-alpha1) * a0)
a_plus[v > 0] = alpha1 * ap + (1-alpha1) * a0
a_minus[v <= 0] = -(alpha2 * ap + (1-alpha2) * a0)
a_plus[v <= 0] = alpha2 * am + (1-alpha2) * a0
return a_minus, a_plus
Here is the solved curve, note the slow acceleration and non-constant boundaries:
Here is an example with , the acceleration quickly becomes nonsensical leading to a convergence to a suboptimal curve:
Real machines usually have more than one degree of freedom, and acceleration/speed constraints are intertwined in a complex way. Let’s explore the general case and use the tools we discovered in the previous sections.
Let’s assume we have a vector motion parametrized by . By adjusting the rate of the parameter, we can tweak the local speed of the curve. The speed vector of can be expressed from derivatives in only:
This shows is simply scaling the speed vector. The acceleration vector of is less straightforward:
The first term is caused by the curvature of , while the second is an acceleration along the curve. As in the previous 1D case, we want to solve and subject to constraints. The speed constraint is straightforward:
The acceleration constraint, on the other hand, is far from trivial. There is nothing that forces those constraints to be similar in different dimensions. A cartesian machine usually has motors actuating separate axes, which prescribes individual constraints on each axis. Let’s assume this is the case: we can define a cuboid of min/max accelerations around the current acceleration. The shape of this cuboid can also be a function of the vectorial speed:
More complex horizon shapes can be introduced in this model to represent different machine configurations. We’ll assume cuboid horizons in the following, leading this form of constraint on the acceleration:
We also want to limit the norm of the jerk per axis:
Vectorial jerk constraints are also possible, similar to the reasoning on the acceleration.
As in the 1D case, we choose to solve a reformulation of the problem in a phase space. The equivalent of the position is now , and the speed takes the role of the speed :
The time is again implicitly defined by:
The problem can be summarized as:
Minimize
Subject to:
With:
Let’s work with a discrete version of the parameter and the parameter speed , with .
The steps in the solver are adapted directly from the 1D version:
Step 3 is similar to the 1D case, but there is now a vectorial acceleration constraint:
def constraints_a(p_prime, p_prime2, u, u_dot, jerk_max, a_start=None, a_end=None, show=False):
n, n_dims = p_prime.shape
a = np.zeros((n-1, n_dims))
a_minus = np.zeros((n-1, n_dims))
a_plus = np.zeros((n-1, n_dims))
u_mid = 0.5 * (u[:-1] + u[1:])
u_dot_mid = 0.5 * (u_dot[:-1] + u_dot[1:])
p_prime_mid = 0.5 * (p_prime[:-1, :] + p_prime[1:, :])
p_prime2_mid = 0.5 * (p_prime2[:-1, :] + p_prime2[1:, :])
u_dot_prime = (u_dot[1:] - u_dot[:-1]) / (u[1:] - u[:-1])
dt = (u[1:] - u[:-1]) / u_dot_mid
dt_a = (dt[:-1] + dt[1:]) * 0.5
u_dot2_min_all = -np.inf * np.ones((n - 1, n_dims))
u_dot2_max_all = np.inf * np.ones((n - 1, n_dims))
v = np.zeros((n-1, n_dims))
for i in range(n_dims):
v[:, i] = p_prime_mid[:, i] * u_dot_mid
a[:, i] += p_prime2_mid[:, i] * u_dot_mid * u_dot_mid
a[:, i] += p_prime_mid[:, i] * u_dot_mid * u_dot_prime
if a_start is None:
a_minus[0, i] = a_minus_prev[0]
a_plus[0, i] = a_plus_prev[0]
else:
a_minus[0] = a_start[i] - jerk_max * dt_a[0]
a_plus[0] = a_start[i] + jerk_max * dt_a[0]
if a_end is None:
a_minus[-1, i] = a_minus_next[-1]
a_plus[-1, i] = a_plus_next[-1]
else:
a_minus[-1] = a_end[i] - jerk_max * dt_a[-1]
a_plus[-1] = a_end[i] + jerk_max * dt_a[-1]
a_plus_next = a[:-1, i] + jerk_max * dt_a
a_minus_next = a[:-1, i] - jerk_max * dt_a
a_plus_prev = a[1:, i] + jerk_max * dt_a
a_minus_prev = a[1:, i] - jerk_max * dt_a
a_minus[1:-1, i] = np.maximum(a_minus_next[:-1], a_minus_prev[1:])
a_plus[1:-1, i] = np.minimum(a_plus_next[:-1], a_plus_prev[1:])
# min and max prescribed by speed
a_neg_bound, a_pos_bound = a_mp_func(v[:, i])
a_minus[:, i] = np.minimum(a_minus[:, i], a_pos_bound)
a_minus[:, i] = np.maximum(a_minus[:, i], a_neg_bound)
a_plus[:, i] = np.minimum(a_plus[:, i], a_pos_bound)
a_plus[:, i] = np.maximum(a_plus[:, i], a_neg_bound)
# acceleration along the curve only
a_plus_along = a_plus[:, i] - p_prime2_mid[:, i] * u_dot_mid*u_dot_mid
a_minus_along = a_minus[:, i] - p_prime2_mid[:, i] * u_dot_mid*u_dot_mid
mask = np.abs(p_prime_mid[:, i]) > 1e-7
u_dot2_1 = a_plus_along[mask]/p_prime_mid[mask, i]
u_dot2_2 = a_minus_along[mask]/p_prime_mid[mask, i]
u_dot2_min_all[mask, i] = np.minimum(u_dot2_1, u_dot2_2)
u_dot2_max_all[mask, i] = np.maximum(u_dot2_1, u_dot2_2)
u_dot2_min = np.max(u_dot2_min_all, axis=-1)
u_dot2_max = np.min(u_dot2_max_all, axis=-1)
return u_dot2_min, u_dot2_max
Step 4 is more straightforward, as we only wanted to limit the scalar norm of . As explained in the 1D scenario, we use an updated to improve the precision of the horizon on :
def constraints_v(p_prime, u, u_dot, u_dot2_min, u_dot2_max, v_abs_max, v_start=None, v_end=None, show=False):
n, n_dims = p_prime.shape
u_dot_min = np.zeros((n,))
u_dot_max = np.zeros((n,))
# v = np.zeros((n - 1, n_dims))
du = u[1:] - u[:-1]
u_dot_max_next = np.sqrt(np.maximum(u_dot[:-1]**2 + 2 * u_dot2_max * du, 0))
u_dot_min_next = np.sqrt(np.maximum(u_dot[:-1]**2 + 2 * u_dot2_min * du, 0))
u_dot_min_prev = np.sqrt(np.maximum(u_dot[1:]**2 - 2 * u_dot2_max * du, 0))
u_dot_max_prev = np.sqrt(np.maximum(u_dot[1:]**2 - 2 * u_dot2_min * du, 0))
if v_start is None:
u_dot_max[0] = u_dot_max_prev[0]
u_dot_min[0] = u_dot_min_prev[0]
else:
# constrain start speed
u_dot_max[0] = v_start/np.linalg.norm(p_prime[0])
u_dot_min[0] = u_dot_max[0]
if v_end is None:
u_dot_max[-1] = u_dot_max_next[-1]
u_dot_min[-1] = u_dot_min_next[-1]
else:
# constrain start speed
u_dot_max[-1] = v_end/np.linalg.norm(p_prime[-1])
u_dot_min[-1] = u_dot_max[-1]
u_dot_min[1:-1] = np.maximum(u_dot_min_next[:-1], u_dot_min_prev[1:])
u_dot_max[1:-1] = np.minimum(u_dot_max_next[:-1], u_dot_max_prev[1:])
u_dot_min = np.minimum(u_dot_min, u_dot_max)
p_prime_norm = np.linalg.norm(p_prime, axis=-1)
u_dot_min[:] = np.maximum(u_dot_min, 0)
u_dot_max[:] = np.minimum(u_dot_max, v_abs_max / p_prime_norm)
if show:
plt.figure()
plt.plot(u, u_dot, 'k')
plt.plot(u, u_dot_min, 'b')
plt.plot(u, u_dot_max, 'r')
plt.title("u_dot")
return u_dot_min, u_dot_max
Putting it all together:
# acceleration horizon
u_dot2_min, u_dot2_max = constraints_a(p_prime, p_prime2, u, u_dot, jerk_max)
# speed horizon
u_dot_min, u_dot_max = constraints_v(p_prime, u, u_dot, u_dot2_min, u_dot2_max, v_abs_max)
# update
u_dot = lambd * u_dot_max + (1 - lambd) * u_dot
Let’s take as a trajectory a bezier spline with handles [0, 0], [2.5, 0], [-0.5, 1], [2, 1] and the regular parametrization :
Here’s a solved curve:
Note the zero-acceleration part in the center due to the curve being almost linear and the speed maximized there. By increasing the jerk constraint, we get a completely different solution:
Here is visualization of the position over time with speed and acceleration vectors displayed on top:
We have shown phase space solvers for both 1D and ND trajectories. While the results are able to find known results in machine control as well as handle dynamic acceleration and jerk within a very general framework. However, the stepwise update rule is very slow, and is an indirect way to handle the constrained optimization problem. Other optimizers should be considered, which can also bring more stability in the results.
Acceleration models more closely inspired by real machines should also be considered.
Finally, the discrete nature of the solution is an issue for using the curve as a solution, which calls for either an interpolation method or a compressed representation. The latter is especially promising for streaming the solution to a machine live.