Panda Circle (Trajectory Generation)¶
examples/trajgen/python/panda/panda_circle.py
generates a trajectory that drives a Franka Panda arm's end effector around a circle on the yz-plane, optionally
constrained to stay within a square window inscribed in that circle. Where Pendubot tracked a fixed
target configuration, this example tracks a moving target pose in task space: at every stage, the cost doesn't
care what the joint angles are, only where the end effector ends up and how it's oriented, which is a much more
common way to specify a manipulation task. It's the best next step after Pendubot: frame-relative pose tracking, a
real (non-XML-derived) box constraint, and InverseDynamics mode.
Setup and the target trajectory¶
import ssqpy
import numpy as np
import argparse
from math import pi, sin, cos
"""
Panda Circle example
Perform a circular motion with the Panda's end effector on the yz-plane
Optionally, constrain the movement within a rectangle inside the circle
"""
def parse_args():
parser = argparse.ArgumentParser(
description="Panda end effector circle trajectory generation"
)
parser.add_argument(
"-p",
"--plot",
action=argparse.BooleanOptionalAction,
default=True,
help="Whether to plot error and trajectory",
)
parser.add_argument(
"-c",
"--constrain",
action=argparse.BooleanOptionalAction,
default=True,
help="Whether to add a box constraint within the circle for the end \
effector",
)
parser.add_argument(
"-o",
"--output",
default="../../data/panda_circle.json",
help="Path of the output JSON file",
)
return parser.parse_args()
args = parse_args()
PLOT = args.plot
CONSTRAIN = args.constrain
if PLOT:
import matplotlib.pyplot as plt
H = 400
dt = 0.01
# Weights
T_WGT = 3.0
R_WGT = 1.0
V_WGT = 3e-3
U_WGT = 3e-3
# Horizontal and vertical line coordinates for constraints
constraint_hlines = [0.1, 0.41]
constraint_vlines = [-0.15, 0.15]
# Circle properties
cx = 0.3
cy = 0
cz = 0.26
cr = 0.22
DISTANCE = 2 * pi
ω = DISTANCE / (H * dt)
φ0 = 0
# Trajectory
traj = []
# Keep x-coordinate constant
# Move on a circular path on the yz-plane
for k in range(H + 1):
φk = φ0 + ω * k * dt
traj.append(np.array([cx, cy + cr * sin(φk), cz + cr * cos(φk)]))
Rd = np.eye(3)
Rd[1, 1] = -1
Rd[2, 2] = -1
The circular path is precomputed as a plain Python list of 3D points, one per stage, parameterized by angular
velocity \(\omega\) so the end effector completes exactly one full circle over the horizon (\(H = 400\) stages at
\(dt = 0.01\text{s}\), i.e. four seconds). This is the target the cost will track, not a constraint: nothing stops the
solver from cutting corners if doing so is cheaper, which matters once --constrain clips the circle into a square
later on. Rd is the desired end-effector orientation (constant throughout, on the yz-plane the arm reaches
"forward" with a fixed wrist rotation), expressed as a rotation matrix rather than e.g. Euler angles, since that's
what the frame-pose costs expect and it avoids the singularities/ambiguities of angle-based representations.
The two command line flags, --plot and --constrain (both on by default, and turned off with --no-plot and
--no-constrain), are the two things worth toggling first if you want to see this example do something different
before reading further: --plot shows the tracking error and the resulting trajectory after solving; --constrain
decides whether the box constraint (below) is actually applied.
Building the model¶
# Create an SSQP model with H and dt, from the URDF at the below path
model = ssqpy.model.Model(
H,
dt,
from_preset=ssqpy.model.Preset.Panda,
solver_mode=ssqpy.model.SolverMode.InverseDynamics,
)
nq = model.getnq()
nv = model.getnv()
nu = model.getnu()
# Use the following initial state
# This will act as:
# 1) An initial state for the system at the first time step (x0); this cannot
# be changed by the solver
# 2) An initial guess for every time step xk; this will be changed by the
# solver to minimize the cost and satisfy the constraints
# If we wanted to help the solver converge faster, we could also choose to
# provide a more accurate initial guess
init_state = np.zeros(nq + nv)
init_state[:nq] = [
0.0013,
-0.9115,
-0.0010,
-2.6316,
-0.0008,
1.7431,
0.7862,
0.0,
0.0,
]
This model uses InverseDynamics solver mode, so controls are joint
accelerations rather than torques, which the example's comments note can simplify convergence for this kind of
task: accelerations enter the dynamics linearly, so the QP the solver builds at each iteration is a better local
approximation of the true problem than it would be with torques, where the mapping to acceleration is nonlinear in
the joint configuration.
init_state is a single, fixed arm configuration (not the resting/neutral one) chosen to already be reasonably
close to the circle. As the comment in the source explains, this array does double duty: it's the fixed initial
state \(\boldsymbol{x}_0\) (the arm has to actually start there), and the initial guess used to seed every other
stage before the solver improves on it. A better initial guess generally means fewer SSQP iterations to converge;
picking one near the expected solution, as here, is a common and effective way to help a local solver like SSQP.
See finalize arguments in Model.
A real constraint: keeping the end effector inside a box¶
# Create a box constraint for the position of the end effector
# This will constrain the position within `vlines[0]` <= p[1] <= `vlines[1]` in the
# y-axis and `hlines[0]` <= p[2] <= `hlines[1]` in the z-axis
# Note: here, we only create the constraint; for it to be applied to some stage
# of the horizon, we actually have to add it to the model
lb = np.array((-np.inf, constraint_vlines[0], constraint_hlines[0]))
ub = np.array((np.inf, constraint_vlines[1], constraint_hlines[1]))
box_constraint = ssqpy.model.constraints.FrameTranslationBound(
model, "panda_ee", lb, ub
)
FrameTranslationBound is a predefined constraint
bounding a frame's position component-wise; -np.inf/np.inf leaves the x-coordinate free (the plane the circle
lives on is already fixed by the cost, so the constraint only needs to act on y/z) while the y/z coordinates are
boxed in around the circle. Since the box is inscribed inside the circle, this constraint is genuinely active:
whenever the tracked point on the circle falls outside the box (near the four "corners" of the circle relative to
the square), the solver has to trade off cost against constraint satisfaction, and the resulting path visibly
flattens against the box edges instead of following the circle exactly. This is a good example to compare
--constrain against --no-constrain and look at the --plot output for; with it off, the trajectory
follows the circle everywhere, since nothing stops it from doing so.
The constraint object is created once and reused across every stage it's added to, since constraints don't need to be re-instantiated per stage unless their bounds actually differ.
Per-stage costs and constraints¶
for k in range(H):
# Add a squared pose error cost for the end effector (`"panda_ee"` frame) to
# follow the generated trajectory, while keeping its orientation at `Rd`
# We use weights `P_WGT` and `R_WGT` for the translation and rotation
# respectively
model.addCost(
k,
ssqpy.model.costs.FrameSquaredPoseErrorCost(
model, "panda_ee", traj[k], Rd, T_WGT, R_WGT
),
)
# Add regularization costs for joint velocity (v) and control (u)
model.addCost(k, ssqpy.model.costs.SquaredJointVelocityCost(model, V_WGT))
model.addCost(k, ssqpy.model.costs.SquaredControlCost(model, U_WGT))
if CONSTRAIN:
# Add the previously created box constraint to stage k
model.addConstraints(k, box_constraint)
# Add the same pose error for the terminal stage
model.addCost(
H,
ssqpy.model.costs.FrameSquaredPoseErrorCost(
model, "panda_ee", traj[H], Rd, T_WGT, R_WGT
),
)
# There are no controls (u) at the terminal stage, so we cannot add a control
# regularization cost; thus, we only add the joint velocity regularization cost
model.addCost(H, ssqpy.model.costs.SquaredJointVelocityCost(model, V_WGT))
if CONSTRAIN:
# Add box constraint to terminal stage as well
model.addConstraints(H, box_constraint)
FrameSquaredPoseErrorCost is added at every stage with that stage's own target point from traj and the fixed
orientation Rd, weighted separately for translation (T_WGT) and rotation (R_WGT); translation is weighted
three times as heavily here, so the solver prioritizes tracing the circle's shape over holding the wrist exactly
level, though both matter. Velocity and control regularization are added too, at every stage, for the same reason
as in Pendubot: without them, the solver has no reason to prefer a smooth, low-effort trajectory among the many
that satisfy the pose cost equally well. The box constraint is added too, if --constrain is set.
Note the terminal stage (H) only gets the velocity regularization, not the control one, since there are no
controls at the terminal stage (see
Horizon, states and controls in Model); it does still get its
own pose-tracking cost, added separately just below this block, so the very last point on the circle is tracked
too.
Solving¶
# Finalize the model
# This will generate the final model, ready for use by SSQP for optimization
# By passing a single configuration as an init_state, this configuration will
# be applied to all stages (including as an initial stage)
model.finalize(init_states=init_state)
# Initialize an SSQP solver object with the created model
# Parameters are passed as keyword arguments
# ADMM-specific parameters start with "admm_"
solver = ssqpy.solvers.ssqp(
model,
loose_success=False,
tolerance=1e-2,
# line_search_method=ssqpy.solvers.LineSearchMethod.Merit,
admm_abs_tolerance=1e-3,
admm_rel_tolerance=1e-3,
admm_adaptive_rho_tolerance=10,
# admm_rho_update_iter_freq=10,
# admm_max_iter=1000,
# qp_solver_type=ssqpy.solvers.QPSolverType.HPIPM,
hpipm_max_iter=250,
)
# Solve using the SSQP solver and the model we created
state, x, u = solver.solve()
# Write the solution to an output file
# The generated trajectory can be visualized using the `visualize.py` script
solver.write(args.output)
print(f"Solver state: {state}")
The solver parameters here are mostly tightened tolerances (admm_abs_tolerance/admm_rel_tolerance) rather than
a different QP backend; this is a much less pathological problem than Pendubot's swing-up (there's no local-minimum
trap to route around), so the default ADMM solver, given a reasonably good initial guess, is enough. After
solving, model.forwardKinematics(q, "panda_ee") is used (in the plotting code that follows, not shown here) to
recover the actual end-effector path from the solved joint trajectory, for comparison against the target traj,
using the same Pinocchio-backed algorithm used internally
by the frame-based costs and constraints; this is exactly how you'd check tracking accuracy for any frame-based
task, not just in plotting code.
Run it (from examples/trajgen/python/panda/, with the environment set up per Installation):
With --plot (the default), two figures appear after solving: end-effector tracking error over the horizon
on the left (small and roughly constant with --no-constrain, spiking near the box edges with --constrain), and
the traced-out trajectory against the target circle on the right, with the box drawn in when constrained. The
solver also prints its final SolverState to the terminal; Success (or LooseSuccess) is what you should see
for the default configuration.
Things to try¶
-
Toggle
--constrainand compare both plots: the tracking-error spikes and the flattened corners in the trajectory plot are a direct, visual consequence of the constraint being active. -
Loosen or tighten the box (
constraint_hlines/constraint_vlines) and watch how much of the circle gets clipped. -
Try
solver_mode=ForwardDynamicsinstead ofInverseDynamics(you'll need to drop thehpipm_max_iteroverride or retune it) and compare convergence; this is a good way to build intuition for when the choice of solver mode actually matters in practice. -
Replace
init_statewith the zero/neutral configuration (dropinit_states=init_statefrommodel.finalize) and see how much slower or less reliable convergence becomes from a worse initial guess.