Two arms, one robot¶
Walks through examples/multi_robot/dual_arm_demo.py
— a dual-arm kitting cell: one robot, two arms, one program per arm.
Two arms, one belt put two robots in one cell. This tutorial
puts two arms on one robot: two UR5e on a shared torso, modelled the way a
dual-arm product (an OpenArm, a YuMi) or a pair of arms on one controller is —
as a single Robot whose planning groups are its arms. Everything you know
takes group=, and the rollout drives the arms' joints independently.
The cell is a kitting station. A tray holds four parts, two per side; each arm
picks its own column and drops the parts into one bin between them. That bin's
airspace is contested, so the two arms' programs interlock the way a PLC would.
One part changes hands on the way. With --carry, the cycle ends by moving
the tray with both hands.
ur5e_robot_dual: 12 DOF, arms ['left', 'right'] (ur5e rig)
cycle time: 43.54s
left moving 27.18s of 43.54s (62%)
right moving 35.08s of 43.54s (81%)
both arms in motion for 18.7s of it
kitted 4 of 4 parts: L1, L2, R1, R2 (L2 handed over)
exported to dual_arm_kitting.usda — view with: usdview dual_arm_kitting.usda
wrote dual_arm_kitting_left.script (left arm, 6 axes)
wrote dual_arm_kitting_right.script (right arm, 6 axes)
The UR5e comes from the catalog;
--robot simple runs the same code on two primitive arms from the checkout,
with no downloads (see the offline rig below).

One robot, two groups¶
# One robot, two arms: the composite is a single 12-DOF model whose
# planning groups are the two arms, named by the mount.
pair = bt.Robot.dual_arm(
arm, arm,
left_position=(0.0, geometry["span"], 0.0), left_quaternion=yaw,
right_position=(0.0, -geometry["span"], 0.0), right_quaternion=yaw,
)
Robot.dual_arm welds two single arms to a bare body and returns one model
of 12 DOF whose groups are left and right, each with its own tip
(left_tool0, right_tool0). It is the composite the rest of the script
talks to — pair.group("left").joints names the six left joints, and every
call that moves the robot takes group=. The joint vector interleaves the
arms (it is the tree's breadth-first order), which is why the script reads
joints by name and never by position. The
Robots guide has the other ways in: a
product's own two-arm URDF, or define_group.
Teaching an arm¶
def at(arm: str, position, seed=None) -> list[float]:
"""The arm at `position`, tool down, in a configuration free of
collision — IK is seeded from the pose it continues, then from the
rig's alternative postures, the way a teacher jogs an arm out of a
fold before saving the point."""
seeds = [seed or q_home] + [posed(arm, s) for s in rig.seeds]
short, touching = None, set()
for q_seed in seeds:
scene.set_joint_positions(q_seed)
result = scene.set_tcp_target(position, DOWN, group=arm)
if not result.converged:
short = result.pos_error
continue
pairs = scene.check_collisions()
if not pairs:
return list(scene.joint_positions)
touching.update(f"{a[1]} × {b[1]}" for a, b in pairs)
raise SystemExit(
f"{rig.kind} rig: the {arm} arm cannot reach {position} collision-free"
+ (f" ({short * 1000:.1f} mm short)" if short is not None else "")
+ (f"; it touches {', '.join(sorted(touching))}" if touching else "")
)
set_tcp_target(..., group="left") solves the left arm's joints and leaves
the right arm exactly where it is — the mask is the group. IK knows nothing
about collisions, so the teach retries a few seed postures and keeps the first
solution whose check_collisions() is empty: the way a teacher jogs an arm out
of a fold before saving a point. Every pick is an approach plus a straight
descent, every place a straight lift and a move over the destination —
add_segment(..., group=arm) makes each motion the arm's own, and the planner
treats the other arm as a frozen obstacle when it plans.
Two programs, one robot¶
The cell is written as two PLC programs, one per arm, sharing the robot:
def cycle(sq, arm: str, tag: str, part: str, *, into_bin: bool, wait=None,
after=(), cleared=()) -> None:
sq.step(f"{arm} pick {tag}", actions=[seq.motion(f"{arm}_pick_{tag}")], transition=seq.done())
sq.step(f"{arm} grasp {tag}", actions=[seq.attach(part, group=arm)], transition=seq.immediately())
if into_bin:
sq.step(f"{arm} wait bin {tag}", transition=wait if interlocked else seq.immediately())
sq.step(f"{arm} place {tag}", actions=[seq.motion(f"{arm}_place_{tag}")], transition=seq.done())
sq.step(f"{arm} release {tag}", actions=[seq.detach(part), *after], transition=seq.immediately())
sq.step(f"{arm} clear {tag}", actions=[seq.motion(f"{arm}_clear_{tag}")], transition=seq.done())
if cleared:
# Announced once the arm is out of the way, not at the release:
# a flag the other arm moves in on.
sq.step(f"{arm} cleared {tag}", actions=list(cleared), transition=seq.immediately())
A program may own an arm of a dual-arm robot the way it owns a robot — the
single-owner rule that keeps two programs from commanding the same resource is
enforced per arm. Grasps hang off the arm (attach(part, group=arm)), and the
rollout drives the two arms' joints side by side: a motion on one and a ramp
on the other bake together, and a second driver on a joint already in flight
is a hard error naming the move, never a silent overwrite.
The bin is the contested resource. One zone sensor per arm watches the column of air over its mouth — a zone reports somebody is inside, not who, so each arm needs its own — and the programs interlock through those zones and one flag:
for arm in (LEFT, RIGHT):
scene.add_zone_sensor(
f"zone_{arm}",
position=(bx, by, BENCH_TOP + BIN_HEIGHT + ZONE[2] / 2), size=ZONE,
watch_groups=[(robot, arm)],
)
# The left program yields the bin to the right arm: it enters only
# while the right arm neither is inside nor has announced a drop.
left_turn = seq.all_of(seq.signal("right_needs_bin", False), seq.signal("zone_right", False))
right_turn = seq.signal("zone_left", False)
left = scene.sequence(LEFT)
cycle(left, LEFT, "L1", "L1", into_bin=True, wait=left_turn)
if rig.hands_over:
cycle(left, LEFT, "L2", "L2", into_bin=False, cleared=[seq.set_signal("handoff_ready")])
else:
cycle(left, LEFT, "L2", "L2", into_bin=True, wait=left_turn)
right = scene.sequence(RIGHT)
rounds = [("R1", "R1"), ("R2", "R2")] + ([("H", "L2")] if rig.hands_over else [])
for tag, part in rounds:
if tag == "H":
right.step("right await handoff", transition=seq.signal("handoff_ready"))
right.step(f"right claim {tag}", actions=[seq.set_signal("right_needs_bin")],
transition=seq.immediately())
last = tag == rounds[-1][0]
cycle(right, RIGHT, tag, part, into_bin=True, wait=right_turn,
after=[seq.set_signal("right_needs_bin", False)],
cleared=[seq.set_signal("right_done")] if last else ())
The right arm has priority: it announces a drop before it even picks
(right_needs_bin), and only enters while the left arm's zone is clear. The
left arm enters only while the right arm neither is inside nor has announced.
The zone is deliberately a narrow column: an arm reaching past the bin for its
tray column must not read as inside, or the two programs deadlock waiting on
each other — which is exactly what a wider zone did while this cell was being
written.
The hand-off is a flag too, raised once the giving arm has cleared, not at the release: the taking arm's approach would otherwise be planned straight into the tool that just let go.
Carrying the tray with both hands¶
cycle time: 63.45s
left moving 42.29s of 63.45s (67%)
right moving 45.79s of 63.45s (72%)
both arms in motion for 24.7s of it
kitted 4 of 4 parts: L1, L2, R1, R2 (L2 handed over)
tray carried 0.12 m to (0.67, 0.00) with both hands
if carry:
left.step("left await right", transition=seq.signal("right_done"))
left.step("left to tray", actions=[seq.motion("left_tray")], transition=seq.done())
left.step("left grasp tray", actions=[seq.attach("tray", group=LEFT)], transition=seq.immediately())
left.step("left lift tray", actions=[seq.motion("left_lift"), seq.set_signal("tray_up")],
transition=seq.all_of(seq.done(), seq.signal("follower_on")))
left.step("left carry", actions=[seq.motion("left_carry")], transition=seq.done())
left.step("left set down", actions=[seq.detach("tray"), seq.set_signal("carried")],
transition=seq.immediately())
left.step("left home", actions=[seq.motion("left_home")], transition=seq.done())
right.step("right await tray", transition=seq.signal("tray_up"))
right.step("right to tray", actions=[seq.motion("right_tray")], transition=seq.done())
right.step("right hold tray", actions=[seq.track("tray", group=RIGHT), seq.set_signal("follower_on")],
transition=seq.signal("carried"))
right.step("right let go", actions=[seq.untrack(group=RIGHT)], transition=seq.immediately())
right.step("right home", actions=[seq.motion("right_home")], transition=seq.done())
Nothing plans the two arms together. The left arm attaches the tray and
lifts it; the right arm then takes the far edge of the raised tray and
tracks it — from that instant every tick solves the right arm's joints to
keep its hand where it latched on, wherever the left arm's planned carry takes
the tray. The order matters: the leader lifts first, so its lift is planned
against a follower that is beside the load, not in its way.
What happens without the interlock¶
unarbitrated, the arms are caught:
arms `left` and `right` of `ur5e_robot_dual` collide at t = 9.230s
(left_wrist_2_link × right_wrist_3_link); add an interlock
(robot_done(group=) / a zone sensor on one arm) so one arm waits for the other
--clash drops both wait conditions. The arms head for the bin together, and
the rollout — which re-checks the two arms against each other every tick —
stops at the tick they meet, with the arms and the links named. A meeting is
not a warning; the bake fails.
One controller program per arm¶
# The I/O each arm's controller sees: the other arm's zone and flags are
# inputs, its own flags are outputs. Signals both programs share are one
# wire, so a flag one program raises is the other program's input.
LEFT_IO = {"inputs": {"zone_right": 0, "right_needs_bin": 1, "right_done": 2},
"outputs": {"handoff_ready": 0}}
RIGHT_IO = {"inputs": {"zone_left": 0, "handoff_ready": 1},
"outputs": {"right_needs_bin": 0, "right_done": 1}}
if not (carry and rig.carries):
# One controller program per arm: the left program's waits on the
# right arm read the right controller's flags on inputs.
for arm, io in ((LEFT, LEFT_IO), (RIGHT, RIGHT_IO)):
path = out.with_name(f"{out.stem}_{arm}.script")
timeline.export_script(path, sequence=arm, group=arm, **io)
print(f"wrote {path} ({arm} arm, 6 axes)")
export_script(..., group="left") lowers the left program to a 6-axis
URScript: the left arm's joints only, its own moves only, the other arm's
zone and flags read on inputs. Leaving group= off a dual-arm robot is an
error, not a 12-axis program.
def left():
# Generated by botrail (units: rad, m, s)
# joints: left_shoulder_pan_joint, left_shoulder_lift_joint, left_elbow_joint, left_wrist_1_joint, left_wrist_2_joint, left_wrist_3_joint
movej([0, -1.9, 1.8, -1.5, -1.57, 0], a=6.283185, v=3.141593, r=0)
# step 1: left pick L1
movej([-0.461808, -1.219699, 2.18276, -2.533857, -1.570796, -2.032605], a=6.283185, v=3.141593, r=0)
The deliverables know the arms¶
The cell report lists each arm's busy time under the robot's own row — the line-balancing figure between the arms:
| robot | busy (s) | utilization |
|---|---|---|
| ur5e_robot_dual | 43.51 | 100 % |
| ur5e_robot_dual/left | 27.18 | 62 % |
| ur5e_robot_dual/right | 35.08 | 81 % |
The layout sheet draws a reach circle per arm, centred on the arm's own base,
from the UR5e's catalog reach_mm. The interlock table names the arm that
drives each row (motion left_place_L1 (ur5e_robot_dual/left), with the
condition (NOT right_needs_bin AND NOT zone_right) beside it). And
scene.requirements() sizes the arms from their own bases — here one BOM line
for the two identical arms, ur5e_robot_dual/left (x2), asking for
reach_mm >= 706.9 ("farthest taught target 0.64 m from the left arm's base
(flange), +10%") against the 850 the catalog provides.
The offline rig¶
simple_arm_dual: 12 DOF, arms ['left', 'right'] (simple rig)
cycle time: 25.35s
left moving 18.89s of 25.35s (75%)
right moving 22.41s of 25.35s (88%)
both arms in motion for 16.0s of it
kitted 4 of 4 parts: L1, L2, R1, R2
The same code on examples/assets/simple_arm.urdf twice, on a tighter cell
laid out for the primitive arm's reach. It kits, interlocks, clashes
(--clash collides at 5.82 s) and exports per arm; the hand-off spot and the
tray's edges fold it into itself, so the hand-over and the two-handed carry
are the UR5e rig's to show. This is the rig the regression suite runs
(python/tests/test_dual_arm_demo.py); the carry and the hand-over are
checked there too, whenever the catalog is cached.
The complete script¶
examples/multi_robot/dual_arm_demo.py
— --studio opens the cell in the studio, where the
TCP panel switches the gizmo between the arms and the timeline dock shows a
lane per arm.
Next¶
- Robots — arms of one robot for
the group model,
define_group, and a product's own dual-arm description. - Sequences — two arms of one robot
for
robot_done(..., group=)inside one program. - Two arms, one belt for the two-robot build of a two-arm cell.