Motions

List, read, and replay a motion stored on an object.

A motion is a named tool path stored on an object, recorded by hand-guiding an arm through it. An anchor names a place; a motion names a path through places. Both are stored in the object's own frame, so both follow the object when it is re-localized.

Three functions cover them, all imported with from execution.execution_functions import *. Two read, one drives the arm.

For what a motion is and how one gets recorded, read Recording a motion.

🦾

play_object_motion moves an arm. On real hardware it replays the recorded path at speed. Nothing in the call confirms the workspace is clear.

⚠️

These three raise. Most of the runtime API fails soft and hands you a result to branch on. These do not: on any problem they raise. There is no {"success": False} to check for, so wrap them in try / except if a failure should not end the run.

Listing what an object has

list_object_motions(object_name, index=None)

Returns the object's motion names as a sorted list of strings, or an empty list if it has none. It does not move the arm.

Parameters

  • object_name (str): the object's name in its metadata, or its id.
  • index (int, default None): a disambiguator when several objects share a name.

The object has to be in the loaded world, because that is what says which type it is. The motions themselves come from that type's object model, so two instances of the same type always return the same list.

for name in list_object_motions("plate_reader"):
    print_log("motion:", name)

Reading a motion

load_object_motion(object_name, motion_name, index=None, joint_config=None)

Returns a motion's keyposes in world coordinates. Read-only: it resolves and reports, and never moves the arm.

Parameters

  • object_name (str): the object's name in its metadata.
  • motion_name (str): the motion to read.
  • index (int, default None): a disambiguator when several objects share a name.
  • joint_config (dict, default None): joint name to angle in radians, for an articulated object. Left at None it uses the object's current joint configuration from world state.

Returns

{
    "object_id": "...",
    "parent_link": "base_link",
    "description": "Lift the lid clear of the deck",
    "duration_s": 1.35,
    "n_keyposes": 3,
    "keyposes": [
        {
            "xyz":  [0.41, -0.12, 0.28],   # metres, world frame
            "rpy":  [3.14, 0.0, 1.57],     # radians
            "wxyz": [0.0, 1.0, 0.0, 0.0],  # quaternion, scalar first
            "t": 0.0,                      # seconds from the motion's start
            "gripper": 0.03,               # metres, or None
        },
        # ...
    ],
}

Keyposes come back in order, from the start of the motion. The poses are already rebased onto wherever the object is now and onto its current joint configuration, so you can move to one directly.

Naming a motion the object does not have raises KeyError, and the message lists the names it does have.

Replaying a motion

play_object_motion(
    arm,
    object_name,
    motion_name,
    *,
    speed=0.10,
    accel=None,
    max_total_accel=None,
    time_scale=1.0,
    index=None,
    joint_config=None,
    blend_radius_mm=None,
    apply_gripper=True,
    executor=None,
    wait=True,
)

Replays the motion on one arm. The path is recompiled on every call against the object's current pose, so re-localizing the object moves the whole path with it. That is the same guarantee an anchor gives.

Everything after motion_name is keyword-only.

Parameters you will usually set

  • arm (str): "left_arm" or "right_arm".
  • object_name (str), motion_name (str): which motion, on which object.
  • speed (float, m/s, default 0.10): peak tool speed along the path. The default is 100 mm/s.
  • time_scale (float, default 1.0): above 1 replays faster, below 1 slower. It scales the duration that speed and accel produce, so a value above 1 deliberately exceeds those two. It does not get past max_total_accel, which is applied afterwards and can only stretch the duration back out. So below 1 always does what you asked; above 1 buys speed only until max_total_accel starts to bind, after which it makes no difference at all.
  • apply_gripper (bool, default True): honour the gripper widths the keyposes carry. Set False to replay the path alone and leave the jaws wherever they are.
🤏

A gripper change part-way along splits the replay. The arm runs to that keypose and stops, the jaws actuate while it is parked, then the next leg starts from there. That is deliberate: the path goes to the controller as one command that cannot be interrupted, and actuating mid-sweep would close the jaws on a moving target.

So a motion that grips something in the middle replays correctly, at the cost of pausing at each change.

gripper_events counts every authored width command the replay honoured, including one on the first keypose and one on the last. Neither of those splits anything: the first sets the jaws before the arm moves, the last after it has finished. So a motion holding one constant width reports gripper_events: 1 and legs: 1. What tells you a split happened is legs above 1.

A gripper command that fails stops the motion. The replay raises rather than carrying on with the jaws in the wrong state. The message names the arm side and whether the command went to real hardware or simulation, and carries the device's own error. It does not say which keypose.

Where that leaves the arm depends on which command failed:

  • The first keypose's width is set before the arm moves, so the arm is still where it started.
  • A change part-way along fails with the arm parked at that keypose, part of the path traced.
  • The last keypose's width is set after the final leg, so the whole path has already run.

Tuning the motion profile

  • accel (float, m/s², default None): peak acceleration along the path. Left at None it is three times speed. This bounds only the tangential term, so the true acceleration is higher wherever the path curves.
  • max_total_accel (float, m/s², default None): a cap on true Cartesian acceleration, including the part that comes from curvature. It is enforced by stretching the motion's duration, and it is applied last, so it wins over everything above. Reach for this rather than accel when a tight corner is the thing you are worried about.
  • blend_radius_mm (float, mm, default None): how much the controller rounds each corner. It is clamped per corner against that corner's own two segments, so a request the geometry cannot honour is reduced rather than refused. None picks a safe default.

The rest

  • index (int, default None): a disambiguator when several objects share a name.
  • joint_config (dict, default None): joint angles in radians for an articulated object.
  • executor (str, default None): leave it unset. Left at None it is chosen from the arm actually in hand, which is what keeps a replay working on a machine that has no physical arms attached even when the run is in real mode.
  • wait (bool, default True): block until the motion finishes. Leave it alone. wait=False takes effect only on a real arm replaying a motion that carries no gripper widths at all, meaning apply_gripper=False or a motion that commands none. Any recorded width, even a single constant one, routes through the split path, where every leg blocks regardless of what you passed. In simulation it is ignored outright. When wait=False does take effect, the endpoint is never checked, so verified comes back False, endpoint_error_mm is None, and the call returns while the path is still running.

Returns

A dict. The keys differ by which executor ran.

On a real arm:

{
    "success": True,
    "executor": "...",            # which executor ran
    "samples": 214,               # compiled path samples
    "waypoints": 37,              # points handed to the controller
    "duration_s": 1.35,           # the compiled profile's duration
    "controller_estimate_s": 2.1, # how long the arm should take
    "blend_radius_mm": 5.0,       # the largest corner radius actually used
    "approach_m": 0.012,          # how far the tool was from the first keypose
    "verified": True,             # whether the endpoint was checked
    "endpoint_error_mm": 1.2,     # how far off the final keypose it finished
    "path_length_m": 0.31,
    "peak_speed_m_s": 0.10,
    "peak_accel_m_s2": 0.28,
    "gripper_events": 0,          # authored width commands honoured
    "object_id": "...",
}

legs joins them whenever gripper_events is above zero, carrying the number of segments the path ran as. It is absent when the motion commands no gripper at all, or when apply_gripper is off.

verified says whether the endpoint check actually ran, and endpoint_error_mm is None, not a number, whenever it is False. Check verified first. Neither key exists on the simulated executor, so reach for them with result.get(...) if your skill runs in both.

One further diagnostic key rides along on a real arm. Branch on the ones above.

In simulation:

{
    "success": True,
    "executor": "sim",
    "samples": 214,
    "duration_s": 1.35,
    "path_length_m": 0.31,
    "peak_speed_m_s": 0.10,
    "peak_accel_m_s2": 0.28,
    "gripper_events": 0,
    "object_id": "...",
}
⏱️

On real hardware duration_s is not how long the arm takes. It is the duration of the profile that was compiled. The controller is handed the geometry plus a single speed for the whole path and runs its own profile over it, so the arm's own timing is controller_estimate_s. Only the simulated executor consumes the compiled profile directly and honours duration_s. Do not time anything off duration_s on a real arm.

🧩

When legs is above 1, five of these keys describe only the last leg. A split replay reports the final segment's duration_s, waypoints, controller_estimate_s, blend_radius_mm and approach_m. So controller_estimate_s is not the arm's time for the whole replay, since it leaves out the earlier legs and the pauses between them, and approach_m is zero by construction because each leg starts where the last one ended.

Whole-motion either way: samples, path_length_m, peak_speed_m_s, peak_accel_m_s2, gripper_events, legs, verified and endpoint_error_mm. The simulated executor splits the same way, so its duration_s is the last leg's too.

What makes it raise

Every one of these raises RuntimeError. Only the second is guaranteed to raise before the arm has moved.

CauseWhat happenedReal arm only
A pose cannot be reachedCompiled samples are solved for joint angles before they are driven, seeded with the angles recorded during the demonstration so replay stays on the same elbow and wrist configuration the operator used.No
The tool is too far from the startGetting to the first keypose is a straight line that nothing has collision-checked, so it is bounded: more than 150 mm or more than 45° away and the replay refuses. Move near the start first.Yes
The controller faultedChecked after the motion, because the command that runs the path reports nothing itself.Yes
It did not finish where it shouldThe tool is compared against the final keypose, to 5 mm and 0.05 rad (about 2.9°). This is how a motion that was stopped part-way, or that was silently truncated, gets caught.Yes
A gripper command failedOnly when apply_gripper is on. See the callout above for where each case leaves the arm.No
🧗

"Nothing moved" only holds for a single-leg replay. The reachability solve covers the whole path when the replay runs as one leg, which is the case with apply_gripper off or with no mid-path width change. When a gripper change splits it, each leg is solved as it starts, so an unreachable pose in a later leg raises after the arm has already driven the earlier ones. And a width on the first keypose is commanded before the first solve runs, so even a "nothing moved" reachability failure can leave the jaws already actuated.

A motion or object that does not exist raises KeyError, ValueError, or FileNotFoundError before any of this.

Putting it together

Because a replay refuses to travel more than 150 mm to reach its first keypose, get the arm onto the start yourself. load_object_motion gives you that pose in world coordinates:

def open_reader_lid(reader: str):
    if "lid_lift" not in list_object_motions(reader):
        print_log(f"{reader} has no lid_lift motion", runlog=True)
        return {"success": False}

    motion = load_object_motion(reader, "lid_lift")
    start = motion["keyposes"][0]

    # Get onto the first keypose. Replay refuses to travel far to reach it.
    move_arm("right_arm", start["xyz"], start["rpy"])

    result = play_object_motion(
        "right_arm", reader, "lid_lift",
        speed=0.05,             # 50 mm/s
        max_total_accel=0.10,   # bound the corners too
    )
    # `samples` and `legs` are there whatever ran. `controller_estimate_s` is a
    # real-arm key, and on a split replay it covers only the final leg.
    print_log(
        f"replayed {result['samples']} samples in {result.get('legs', 1)} leg(s)",
        runlog=True,
    )
    return {"success": True}
🤖

A motion records which gripper was fitted when it was demonstrated, and play_object_motion does not check it. Replaying under a different gripper traces a path offset by the difference between the two tools, because the tool tip sits somewhere else, and the call does this silently. The annotation editor's Play on arm does warn about a mismatch; a skill gets no such warning. If a skill replays a motion, make sure the arm is wearing what the motion was recorded with. See Recording a motion and Hardware Setup.

Related


Did this page help you?