Skip to content

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.

python examples/multi_robot/dual_arm_demo.py
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).

The dual-arm cell in the studio: the tray carried with both hands, and a timeline lane per arm

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

python examples/multi_robot/dual_arm_demo.py --carry
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

python examples/multi_robot/dual_arm_demo.py --clash
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

python examples/multi_robot/dual_arm_demo.py --robot simple
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