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_motionmoves 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 intry/exceptif 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, defaultNone): 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, defaultNone): a disambiguator when several objects share a name.joint_config(dict, defaultNone): joint name to angle in radians, for an articulated object. Left atNoneit 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, default0.10): peak tool speed along the path. The default is 100 mm/s.time_scale(float, default1.0): above 1 replays faster, below 1 slower. It scales the duration thatspeedandaccelproduce, so a value above 1 deliberately exceeds those two. It does not get pastmax_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 untilmax_total_accelstarts to bind, after which it makes no difference at all.apply_gripper(bool, defaultTrue): honour the gripper widths the keyposes carry. SetFalseto 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_eventscounts 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 reportsgripper_events: 1andlegs: 1. What tells you a split happened islegsabove 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², defaultNone): peak acceleration along the path. Left atNoneit is three timesspeed. This bounds only the tangential term, so the true acceleration is higher wherever the path curves.max_total_accel(float, m/s², defaultNone): 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 thanaccelwhen a tight corner is the thing you are worried about.blend_radius_mm(float, mm, defaultNone): 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.Nonepicks a safe default.
The rest
index(int, defaultNone): a disambiguator when several objects share a name.joint_config(dict, defaultNone): joint angles in radians for an articulated object.executor(str, defaultNone): leave it unset. Left atNoneit 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, defaultTrue): block until the motion finishes. Leave it alone.wait=Falsetakes effect only on a real arm replaying a motion that carries no gripper widths at all, meaningapply_gripper=Falseor 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. Whenwait=Falsedoes take effect, the endpoint is never checked, soverifiedcomes backFalse,endpoint_error_mmisNone, 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_sis 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 iscontroller_estimate_s. Only the simulated executor consumes the compiled profile directly and honoursduration_s. Do not time anything offduration_son a real arm.
When
legsis above 1, five of these keys describe only the last leg. A split replay reports the final segment'sduration_s,waypoints,controller_estimate_s,blend_radius_mmandapproach_m. Socontroller_estimate_sis not the arm's time for the whole replay, since it leaves out the earlier legs and the pauses between them, andapproach_mis 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,verifiedandendpoint_error_mm. The simulated executor splits the same way, so itsduration_sis 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.
| Cause | What happened | Real arm only |
|---|---|---|
| A pose cannot be reached | Compiled 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 start | Getting 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 faulted | Checked after the motion, because the command that runs the path reports nothing itself. | Yes |
| It did not finish where it should | The 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 failed | Only 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_gripperoff 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_motiondoes 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
Updated about 1 month ago