Dual-arm pick and place

ms-examples/02_dual_arm_pick_and_place uses the same Kinova and gripper definitions twice without copying their source models.

1. Distinguish semantic instances

The shared models/common/dual_arm_table.scenex, also used by the handover example, declares four independent instances:

ktree inst (ns=dat) kinova1 of <kinova_tree>
ktree inst (ns=dat) gripper1 of <gripper_tree>
ktree inst (ns=dat) kinova2 of <kinova_tree>
ktree inst (ns=dat) gripper2 of <gripper_tree>

kinova1, kinova2, gripper1, and gripper2 are semantic identities. Generated runtime prefixes keep repeated MJCF element names separate; the instance names are not string-parsing conventions. Each arm and each gripper is its own agent, because each is commanded through its own solver.

Both arms attach to frames authored on the table body. The second mount has a 180-degree extrinsic XYZ yaw, so the arms face each other:

frame kinova2_mount {
    pose arm2_on_table {
        wrt: <table.table_top>
        xyz: (0.7, 0.0, 0.0) m
        orientation: euler { axes: xyz extrinsic angles: (0, 0, 180) unit: deg }
    }
}

No auxiliary MJCF anchor asset is required; arbitrary semantic frames are valid fixed-joint endpoints.

2. Generate and run

motion-spec run \
  src/ms-examples/02_dual_arm_pick_and_place/dual_arm_pick_and_place.robmot \
  -o /tmp/dual-arm-pick-and-place \
  --prefix /path/to/workspace/install \
  --run-id tutorial

The MuJoCo viewer opens by default. Add --headless only for unattended runs.

3. Follow the two control paths

The shared world context declares one pose and twist per arm. handler-home declares one ACHD solver per agent. Later handlers explicitly route each controller to the matching solver:

arm1-solver: serial-chain {
    agent:     <agents.arm1>,
    algorithm: achd,
    limits {   torque: saturation { max: <shared.spec.arm-torque-limit> } },
    gravity:   (0.0, 0.0, 9.81) m/s^2
},
arm2-solver: serial-chain {
    agent:     <agents.arm2>,
    algorithm: achd,
    limits {   torque: saturation { max: <shared.spec.arm-torque-limit> } },
    gravity:   (0.0, 0.0, 9.81) m/s^2
}

The two gripper agents get their own command-forwarding solvers:

gripper1-solver: command-forwarding {
    agent: <agents.gripper1>
},
gripper2-solver: command-forwarding {
    agent: <agents.gripper2>
}

4. Inspect velocity-profile propagation

The pick motion creates one lowering profile per arm. Each profile names limits and the measured TCP z velocity. Its PID controller attaches the profile and the same measured derivative:

velocity-profile lower1-profile = profile {
    max-velocity: <spec.max-lower-velocity>,
    max-acceleration: <spec.max-lower-acceleration>,
    measured-velocity: <shared.world.twist-ee1-base>.linvel.z,
    max-jerk: <spec.max-lower-jerk>,
    shape: s-curve
}

pid ctrl-pick-lower1-z { constraint: <pick.lower1-z>, profile: <pick.spec.lower1-profile>, measured-derivative: <shared.world.twist-ee1-base>.linvel.z, Kp: 12, Ki: 5, Kd: 15, decay: 0 } via <handler-home.arm1-solver>

In generated/model/ir.json, find lower1-profile; then find the same profile on ctrl-pick-lower1-z in the generated controller. This checks model-to-IR-to-codegen propagation without relying only on the observed motion.