Isaac Sim 實戰筆記 GitHub

關節命令送不到:把驅動鏈路切成四段來查

症狀:ROS2 節點一路印「命令已送出」,Isaac 裡的關節卻文風不動,而且沒有任何錯誤訊息。 這篇講怎麼在四段之間定位斷點,不靠猜。

為什麼要切段

從「程式決定要抬升」到「關節真的動了」,中間有四段獨立的機制,任何一段斷掉都是同一個外觀 —— 命令看起來發了、關節沒反應、沒有錯誤:

①  控制器內部狀態          ②  ROS2 topic            ③  ActionGraph              ④  關節
   (它以為現在幾公分)   →   /joint_command      →   Subscribe → Controller  →   PhysX drive

單看任一端都會誤判。控制器印的「current=0.732」可能只是它自己記的數字; 關節停著也可能是命令根本沒出門。必須兩端都量,而且要能單獨戳中間。

第一性原理:開環控制器的 current 不是量測值

先分清一件事,否則後面全錯。控制器 log 印的

FORK_COMMAND current=(z2=0.732) target=(z2=1.147) feedback=disabled published=16.00s

裡的 current,在 feedback=disabled是它自己維護的內部狀態,不是從 /joint_states 讀回來的真值。開環控制器的職責只到「把命令發出去」,它沒有義務、也沒有能力知道關節有沒有動。

所以 published=16.00s 只證明「發了 16 秒」,不證明「關節收到」,更不證明「關節動了」。 要判斷物理有沒有發生,唯一可信的是 /joint_states 或 PhysX 剛體位姿。

四段的查法

④ 關節本身:繞過所有上游,直接戳

最有價值的一步,而且最快。自己發一則只帶目標關節的 JointState,看它動不動:

import rclpy, time
from sensor_msgs.msg import JointState

rclpy.init()
n = rclpy.create_node("poke")
pub = n.create_publisher(JointState, "/joint_command", 10)
st = {"pos": None}

def cb(m):
    d = dict(zip(m.name, m.position))
    if "z2" in d:
        st["pos"] = d["z2"]

n.create_subscription(JointState, "/joint_states", cb, 10)
for _ in range(20):
    rclpy.spin_once(n, timeout_sec=0.1)
print("起始 =", st["pos"])

msg = JointState()
msg.name = ["z2"]
msg.position = [1.0]
t0 = time.time()
while time.time() - t0 < 20:
    msg.header.stamp = n.get_clock().now().to_msg()
    pub.publish(msg)
    rclpy.spin_once(n, timeout_sec=0.05)
    time.sleep(0.03)
print("20 秒後 =", st["pos"])

要持續發,不是發一次 —— ROS2SubscribeJointState 是每 tick 取最新值,單則訊息容易錯過 tick。

動了 → ③④ 都正常,斷點在 ①②,往上查。 不動 → 往下查 ③ 與關節的 drive 設定。

③ ActionGraph:節點在不在、接沒接、收到幾個關節

Isaac 端要有這條鏈:ROS2SubscribeJointStateIsaacArticulationController。 dump 出來確認三件事:

NODE .../SubscriberJointState [isaacsim.ros2.bridge.ROS2SubscribeJointState] disabled=False
  outputs:jointNames     = len=4      ← 最近一則命令帶了幾個關節
  outputs:positionCommand = len=4
NODE .../ArticulationController [isaacsim.core.nodes.IsaacArticulationController] disabled=False
  inputs:jointNames      <- [...SubscriberJointState.outputs:jointNames]
  inputs:positionCommand <- [...SubscriberJointState.outputs:positionCommand]
  inputs:execIn          <- [...OnPlaybackTick.outputs:tick]

execInOnPlaybackTick 而不是 subscriber 的 execOut,代表 controller 每 tick 都跑、 用 subscriber 的最新值 —— 所以 execOut = 0 不代表壞掉。

② topic:攔截實際送出的內容

同時訂閱 /joint_command(上游發什麼)與 /joint_states(關節怎麼回應),印出來對照:

def ccb(m):
    print("CMD", {k: round(v, 3) for k, v in zip(m.name, m.position)})

比對控制器 log 宣稱的 target 與這裡實際看到的值。兩者不一致就是 ① 的 bug。

常見的錯位寫法:

msg.name = controlled_joints
msg.position = [
    float(target_positions[self.joint_names.index(name)])   # ← 索引取自另一個清單
    for name in controlled_joints
]

target_positionsjoint_names 的長度或順序一旦不一致,值就悄悄錯位到別的關節上 —— name 對、值錯,而且完全不報錯。

① 控制器:只剩它了

前三段都正常就往這裡查。重點不是它印了什麼,是它實際塞進 message 的值

兩個會讓你查錯方向的陷阱

ros2 CLI 可能整組壞掉。 這台環境 ros2 topic echo / ros2 topic info 會噴 xmlrpc daemon 的 traceback,但 python 直接訂閱完全正常。CLI 不通不代表 topic 不通 —— 別用 CLI 的失敗當證據,換 python 再判斷。

確認任務真的下發了,再解讀「零則命令」。 監聽期間一則命令都沒有,可能是上游根本沒被觸發。 我就因為上游任務被業務邏輯擋掉(而且回傳的是描述性訊息、不是錯誤), 把「零則命令」誤讀成「控制器不發命令」,白繞一圈。 先確認觸發成功,再讀監聽結果。

一句話

四段獨立,外觀相同。先戳最下游那段(直接發 topic 給關節)—— 一次測試就能把嫌疑範圍砍半,比從上游逐行讀程式快得多。