Trajectory Variants#

A collector that replans the same task many times tends to return the same motion every time. Imitation learning then sees one way of doing the job, and reinforcement-learning post-training starts from a policy that has never been shown an alternative. Variant expansion keeps the waypoints a plan has already committed to and varies only the motion between them.

This is narrower than Affordance sampling, which changes where the robot makes contact. Here every grasp, placement and dwell stays exactly where planning put it, and only the free motion in between differs. The two compose: a sampled affordance produces a new set of waypoints, and variant expansion then produces several ways of executing that set.

Where the variation comes from#

Factor

Operator

What changes

spatial

joint_residual(), via_points()

The shape of the joint path between two waypoints. joint_residual adds one fixed-shape bump per phase, so only its amplitude and sign vary; via_points places several interior knots whose signed magnitudes vary along one sampled joint direction, so paths differ in shape without the joints decorrelating. spatial.method names one or both, and each becomes its own variant.

ik

nullspace_residual()

Arm posture, while holding the task rows the caller declares. This is what produces different elbow and wrist configurations for one tool path.

timing

retime()

Free-phase duration, and the velocity profile within a phase. The path is untouched; only when the robot is where it is changes.

approach

perturb_approach_direction()

The standoff pose a contact is reached from. This one produces Cartesian poses, so it is consumed before IK and planning, by whichever component builds the waypoints. Passing a configuration that enables it to the qpos helpers raises, rather than quietly producing no approach variation.

The contact, contact_timing and recovery factors are declared in the configuration schema but not implemented; enabling one is rejected.

What is and is not guaranteed#

Every qpos-level operator uses an envelope that is zero, with zero derivative, at both endpoints of the phase it modifies. Phases annotated contact or hold are never touched at all. So:

  • Guaranteed. Annotated waypoints, contact windows and dwell durations are bit-identical to the reference. Uncontrolled joints, such as a gripper, keep their schedule. Joint-limit violations among the joints an operator moved are rejected, and validate_motion_limits() can reject sampled speed and acceleration violations.

  • First order only. nullspace_residual holds the declared task rows by projecting onto the Jacobian null space at each sample. Interior samples drift as the linearization error grows; the phase endpoints stay exact regardless. Verify interior tool poses with forward kinematics if your task needs them.

  • Not guaranteed. Nothing here establishes collision freedom, dynamic feasibility or task success. Deduplication compares measured joint geometry and elapsed phase time, which is a similarity measure, not a validity proof. Each accepted variant must still be executed and validated by the host.

Running the tutorial#

The Place tutorial grows a --trajectory_variants flag, in the same shape the Affordance sampling tutorials use for --affordance_branches: one variant per simulation row, mapped one to one. Row zero always replays the plan unchanged, so the trajectory you already trust stays in the comparison. Run it in the environment where EmbodiChain is installed (see Installation):

python scripts/tutorials/atomic_action/place.py --num_envs 6 --device cuda

The tutorial declares only which of the action’s own named segments may move: the gripper-close and release segments are contact phases, and only the lift and the place motion may be retimed, because the executor clears the cube’s dynamics at one shared step index. Everything else comes from the default policy.

--trajectory_variants is the only variant flag an ordinary run needs; with none of the advanced flags set the tutorial calls the helpers without a configuration at all. The rest sit in an advanced variant options group in --help, each overriding one part of the default policy. Add --headless --auto_play to record a video without waiting for input.

The script logs the factors behind every accepted variant and the reason and count of every rejected proposal:

Trajectory variant 0: operator=none,               scale=1.0,  uniform, samples=239
Trajectory variant 1: operator=joint_residual,     scale=1.0,  uniform, samples=239
Trajectory variant 2: operator=via_points,         scale=1.0,  uniform, samples=239
Trajectory variant 3: operator=nullspace_residual, scale=1.0,  uniform, samples=239
Trajectory variant 4: operator=joint_residual,     scale=1.25, uniform, samples=277
Trajectory variant 5: operator=via_points,         scale=1.25, uniform, samples=277

Seeing what changed#

Add --variant_plot_dir <dir> to write two figures. Variants are generated from one reference, so their number is independent of how many environments replay them; ask for a hundred and run a single environment if you only want the figures:

python scripts/tutorials/atomic_action/place.py --headless --num_envs 1 --device cuda --trajectory_variants 100 --variant_plot_dir outputs/variants

The first figure plots every variant’s arm joints against time. Curves separate between waypoints and rejoin inside the shaded contact phases, and the title carries the largest contact-phase deviation of any variant from the reference, which is 0.00e+00 rad: contact windows are preserved exactly, not approximately. With a hundred variants the legend groups instead of naming each curve: colour is the operator that produced the path and the dash pattern is the timing signature, so two variants that share a geometry and differ only in their timing stay distinguishable.

Arm joints of 100 trajectory variants against time
Arm joints of 100 variants. Shaded spans are contact phases.

nullspace_residual is the wide green band on joint6: for a grasp taken from directly above, wrist roll is the freedom the declared task rows leave open, so that is where posture variants spend their budget.

The second figure draws every variant’s tool-centre path in the arena, beside the arm at its starting configuration and the object drawn to scale on the ground. Stars mark the annotated waypoints. Every variant draws its own, so they would scatter if an operator had moved one; the title reports how far apart they actually land, which is 0.0e+00 m, together with the lowest tool centre so ground clearance is stated rather than implied by a viewing angle.

Tool paths of 100 trajectory variants
Tool paths of 100 variants. Stars are the annotated waypoints.

The variants form a tube around the reference that pinches shut at every waypoint. Measured over those hundred: all hundred accepted with no rejected proposals and a hundred distinct geometry families, a median tool-path length of 1.09x the reference and 1.38x at worst, and three variants whose lowest tool centre dips under 2 cm against a median of 2.8 cm. Sampling more variants reaches further into those tails, which is a reason to keep a host-side clearance or collision check rather than to trust the generator alone.

Tuning the residual scale#

joint_offset_scale is a fraction of each joint’s declared range, so its effect depends on how generous the robot’s limits are. The tutorial’s arm declares plus/minus 2*pi on every joint, which makes even small fractions large in absolute terms. Measured on this PickUp task with six variants:

joint_offset_scale

Rows lifting the cube

Notes

0.005

6 of 6

Distinct only under a tighter deduplication tolerance.

0.01

6 of 6

Tutorial default; roughly 0.13 rad on this arm.

0.02

5 of 6

One via_points row disturbs the grasp.

0.03

5 of 6

Roughly 0.38 rad; clearly past the useful range here.

Keeping the knots of one via_points draw on a single joint-space direction matters as much as the scale. Sampling every joint of every knot independently decorrelates them, and the tool path that forward kinematics produces then wanders: on the Place reference it ran 1.51x the reference arc length, against 1.11x once the knots share a direction. Nothing about that extra length is useful to a downstream policy.

The window between “too small to register as a different geometry” and “large enough to drop the object” is narrow on a task this tightly constrained. Keep coverage.joint_dedup_normalized_tol well below joint_offset_scale, or variants that really do differ will be discarded as duplicates: at the shared default of 0.01 for both, the via_points variant collapses into the reference’s geometry family. The tutorial therefore defaults its --dedup_tolerance to 0.004.

Using it from code#

There is one entry point, and one number to choose:

from embodichain.lab.sim.motion.expansion import expand_trajectory_variants

result = expand_trajectory_variants(
    template,
    case=case,
    count=8,
    joint_limits=robot.get_qpos_limits()[0],
    control_dt=0.02,
)

That is the whole interface. Every augmentation method this package implements is enabled by default, because a caller who wants several ways to run a plan should not have to learn what a via point or a null-space projection is first. The factors that were actually used come back on result.cfg, so an auto-configured run stays reproducible.

Supplying task_jacobians additionally unlocks posture variants. Leave it out and that one factor is simply not offered, rather than proposing variants that would be rejected for missing inputs.

Advanced options#

Everything below is optional. Reach for it when the default policy does not suit the task.

Entry point

Use it when

default_variant_factors()

You want to read or adjust the policy the helpers resolve, rather than writing a configuration from scratch. Pass the result back as cfg.

cfg= on expand_trajectory_variants()

You need exact control. An explicit configuration is never overridden, and enabling a factor whose inputs are missing raises immediately.

plan_trajectory_variants() with apply_trajectory_variant()

You want the enumeration and the application separately, for example to log the intended assignment before planning. Unconfigured, this pair leaves the null-space factor off, because neither call sees whether you have Jacobians; pass default_variant_factors(redundancy=True) to both if you do.

velocity_limits and acceleration_limits

You want sampled motion-limit rejection on top of joint-limit rejection.

max_attempts

The default budget of four proposals per requested variant is not enough for a heavily constrained task.

The knobs inside a configuration are spatial.method, spatial.via_count, spatial.joint_offset_scale, ik.task_rows, ik.normalized_scale, timing.duration_scales, timing.profiles, coverage.target_per_cell and coverage.joint_dedup_normalized_tol. The two that most often need attention are the offset scale and the deduplication tolerance, measured above.

coverage.target_per_cell caps how many timing variants may share one geometry family. Timing-only expansion therefore cannot exceed that cap, because all of its variants have the same measured geometry. Raise it when timing is the only enabled factor.

Redundancy and the Jacobian frame#

nullspace_residual takes whatever task rows the caller declares, in the rows of the Jacobian the caller supplies. A six-row Jacobian on a six-joint arm generically leaves no redundancy, and the operator raises rather than silently returning the reference. Dropping the rows the task does not actually constrain is what creates alternative postures.

ik.task_rows selects which rows of the supplied Jacobian stay constrained, so pass every spatial row rather than reducing them first. Redundancy is then decided on the numerical rank of the selected rows: six independent rows on a six-joint arm raise, because the projector of a generic full-rank Jacobian is floating-point residue rather than a usable direction.

The frame convention is the caller’s responsibility. BaseSolver.get_jacobian returns a Jacobian in the arm’s base frame, so dropping its angular-z row removes rotation about base z, not about the tool axis. For a top-down grasp these coincide, which is why the tutorial keeps rows (0, 1, 2, 3, 4). A task that grasps from the side must choose its rows accordingly, or supply a Jacobian already expressed in the tool frame.

Column order matters too: solver Jacobian columns follow the solver’s own joint names, which need not match the robot’s public joint order. The simplest way to stay aligned is to declare controlled_joint_indices in solver order, as the tutorial does, so no permutation is needed anywhere.

What combines with what#

Rule

Why

joint_residual, via_points and nullspace_residual are mutually exclusive within one variant

A null-space projection is computed against the reference Jacobians; stacking it on an already displaced path would invalidate its task-row claim. All three can still be enabled together and appear as separate variants.

retime combines with any one of them

The two are orthogonal: one changes the geometry, the other changes only when the robot is where it is. Retiming runs after the joint-path operator.

approach is never applied by these helpers, and enabling it raises

It produces Cartesian standoff poses, which have to be replanned through IK before they become a trajectory. The configuration schema still accepts the factor, so an upstream planning stage can declare it in a shared job configuration; only the qpos entry points refuse it.

contact, contact_timing and recovery are rejected

They are declared in the schema but not implemented.

No operator touches a contact or hold phase

That is how fixed waypoints stay fixed.

An operator runs on a free phase only if that phase lists it

Hosts use this to keep a specific operator away from a specific phase. The tutorial allows retime only from the lift onward, so every row clears the cube’s dynamics on the same simulation step.

Limitations#

  • At most one joint-path operator runs per variant. A null-space projection stacked on an already displaced joint path would no longer correspond to the Jacobians it was computed from, so the combination is not offered.

  • Retiming requires the reference trajectory’s phase boundaries to already sit on the host command clock. An unaligned reference is rejected rather than silently resampled, which is why the tutorial plans with a deterministic interpolation strategy on an explicit --control_dt.

  • Variants retimed to different lengths are padded by repeating the final valid sample. A shorter variant holds its last command instead of running past its own end; use CandidateTrajectoryBatch.valid_mask before recording.

  • Task Program, Gym lifecycle, dataset persistence and the collection bookkeeping in ExpansionSession are deliberately out of scope. This is a direct-simulation contract: the host owns execution, reset, validation and storage.