mechArm 270 Pi 動的追従シリーズ Log#31 | 動く前例を離れたら、8回かかった。単発の send_angles が無視された実機テストの記録
位置入りサンプルの動きを確認したかった。
やることは単純だった。手で教えた939姿勢のteachデータから4つの代表姿勢だけを抜き、最短経路で再生する。HOME・アプローチPICK・PICK・アプローチPLACE・PLACE・HOME。途中の細かな姿勢再現は省く。1日で終わる確認のはずだった。
だが、この確認は8回の実機テストに化けた。
原因は軌道抽出ではない。グリッパーでもない。実機で動いていた前例から離れ、新しい送信方式を一から作ったことだった。この記事は、その8回の記録と、「動く前例に乗る」という教訓に辿り着くまでの話だ。
何を作ろうとしたか
teach JSONには、手で教えた関節角度の列(939姿勢)と、工程ラベル(APPROACH_PICK / PICK / APPROACH_PLACE / PLACE)、グリッパーの開閉印が入っている。ここから工程ごとの代表姿勢を4点だけ抜き、それを順に send_angles で送る。
最初の抽出ルールはシンプルに組んだ。
APPROACH_PICK:phase APPROACH_PICK の終端の姿勢
PICK:close 印が打たれた時点の姿勢
APPROACH_PLACE:phase APPROACH_PLACE の終端の姿勢
PLACE:open 印が打たれた時点の姿勢
実行シーケンスは11ステップになる。PICKで掴んだあと一度アプローチ姿勢へ戻して持ち上げ、PLACEで離したあとも一度退避する。新しい姿勢は増やさず、抽出した4点とHOMEの再利用だけで構成した。
HOME → 開 → APPROACH_PICK → PICK → 閉
→ APPROACH_PICK(リフト・再利用)
→ APPROACH_PLACE → PLACE → 開
→ APPROACH_PLACE(退避・再利用)→ HOME抽出は実データで検証できた。APPROACH_PICK end_idx=194、close idx=270、APPROACH_PLACE end_idx=503、open idx=565。4点ともJOINT_LIMITS違反なし。ここまでは順調だった。
take1〜4 ― グリッパーの後で send_angles が消える
最初の実機テストで、STEP 1(112°の長距離移動)は通った。問題はSTEP 3だった。
[STEP 3] MOVE → APPROACH_PICK speed=20 timeout=15.7s
[WP] [ERROR] 到達確認タイムアウト (15.7s 経過, max |Δ|=90.17° > tol 2.00°)
[STEP 3] actual = [ +0.08, +1.49, +0.87, -0.26, -0.61, -29.70]
[STEP 3] per-axis |Δ| = [36.11, 90.17, 22.22, 1.66, 70.05, 3.08]
[WP] [ABORT] 到達確認失敗のため停止。次の移動には進みません15.7秒待ってもアームはHOMEからほとんど動いていない。遅くて間に合わなかったのではなく、STEP 3の send_angles が実機に届いていない。コマンドが落ちたのは11ステップ中、グリッパー直後のSTEP 3だけ。グリッパーを挟まないSTEP 1は正常に動いている。
過去に記録した既知問題がある。グリッパーコマンドの直後は2秒以上のsleepを置かないと、次のコマンドが落ちる。この時のdwellは0.5秒だった。dwellを2.0秒に延ばし、あわせて「送信したのにアームが動かない」場合に同じ姿勢を最大3回まで送り直す再送機構を入れた。
take2は、別の挙動で止まった。送信のあと、姿勢を読むための get_angles が12回連続で失敗した。さらに起動直後の現在姿勢に、あり得ない異常値が返ってきた。これは実際の姿勢ではなく、読み取りの化けた値だった。シリアル通信は「コマンドが落ちる」だけでなく、「読みが数秒沈黙する」「値が化ける」という事象も平気で起きる。
take3では、読み耐性(読めない間も到達確認のタイムアウト窓内はポーリングを続ける)と、ゴミ読みフィルタ(JOINT_LIMITSを大きく外れた読み値を捨てる)を入れた。読みは完全に直った。起動時の異常値は消え、現在姿勢はHOME付近をきれいに読めた。STEP 3の間も読みは正常。だが send_angles は3回送って3回とも届かなかった。ABORT後、アームはHOMEのままだった。書き込みも実機に届いていない。グリッパー直後はシリアルの書き込みも読み出しも数秒沈黙する、ということだった。
take4では、実機で完走実績のある別スクリプト(play-A)が0.1秒間隔で姿勢を流し続けている点に着目し、同じ姿勢を0.1秒間隔で10回連打する方式に変えた。
結果はこうだった。
[STEP 3] MOVE → APPROACH_PICK speed=20 timeout=15.7s
[WP] [WARN] 無進捗のため send_angles を再送 (attempt 2/3) ... (進捗 0.00° < 2.00°)
[WP] [WARN] 無進捗のため send_angles を再送 (attempt 3/3) ... (進捗 0.00° < 2.00°)
[STEP 3] per-axis |Δ| = [35.68, 90.17, 22.22, 1.23, 70.40, 2.99]
[WP] [ABORT] 到達確認失敗のため停止。次の移動には進みません連打30発(0.1秒×10発×3回)が全部無視された。「送信周期が理由」という仮説はこれで反証された。dwell延長でも、再送でも、連打でも回復しない。時間を置いて解決するような問題ではなかった。
最小構成に戻る ― 犯人はどこにいるのか
—— send_angles が悪いのか、それとも実行文脈なのか。
ここまでの修正はすべて、本番のスクリプトに仕掛けを足す方向だった。一度それをやめて、裸の最小構成で「アームは send_angles で動くのか」「グリッパーの後だと動かないのか」だけを確かめた。
HOME近傍を±15°動かすだけの単体テストを書いた。素の send_angles と、グリッパー開閉の直後に send_angles。
両方とも動いた。グリッパー直後でも、素の send_angles はアームを動かす。実機もファームも健全だった。
続けて、本番スクリプトとの差分を1つずつ足すテストを書いた。set_fresh_mode(1) を打ってからグリッパーを挟んで send_angles。送信直後から get_angles を0.2秒間隔で連打。
[TEST 3] set_fresh_mode(1) → グリッパー開閉 → send_angles。
send_angles([15, 0, 0, 0, 0, -30], 20)
[3-1 +15deg (fresh+gripper)] get_angles = [15.11, 1.49, 0.87, 0.17, -0.26, -29.79]これも動いた。fresh_mode単体も、読み連打も、犯人ではなかった。
ここで奇妙な状態になった。
dwell不足ではない。
グリッパー単体でもない。
fresh_modeでもない。
get_angles連打でもない。
単体テストでは全部動く。
本番だけが動かない。
問題は機能ではなく、文脈にあった。要素を1つずつ足して追い詰めようとしても、本番スクリプトの実行文脈に潜む組み合わせが相手では、テストを重ねるたびに容疑の数が増える。消耗戦になる。
take5 ― 動く前例に乗せ換える
方針を変えた。動く送り方を探すのをやめて、動くことが実証済みの送り方に、データだけ差し替えて乗せる。
play-Aはストリーミング再生だ。0.1秒周期で姿勢を流し続ける。この送信文脈をまるごと再現し、入力だけを「4点から合成した最短軌道」に変える。4つのウェイポイントの間を線形補間して細かい姿勢列に展開し、play-Aと同じ0.1秒周期で流す。送信まわりはplay-Aの流儀をそのまま写した。set_fresh_mode を再生前に(1)、終了時に(0)へ戻す。グリッパーの速度とdwellもplay-Aの実績値に合わせた。ウェイポイントの抽出ロジックは、それまでの実機テストで毎回正しく動いていた資産なので、そのまま流用した。
take5で、グリッパー後の壁を越えた。
[STEP 2] GRIPPER OPEN → set_gripper_state(0, 50)
[STEP 2] gripper dwell 0.5s(v2 play と同値)
[STEP 3] MOVE → APPROACH_PICK segment HOME → APPROACH_PICK (46 ticks @ 0.1s, speed=30)
[WPSR] arrival OK (HOME → APPROACH_PICK, max|Δ|=1.32° <= tol 2.0°)
...
[WPSR] 完了。elapsed = 53.31s4回阻まれたSTEP 3が、play-Aと同じ送信文脈に載せた途端に通った。11ステップが初めて最後まで流れた。単独の犯人は最後まで特定していない。送信の文脈ごと変えることで回避した。
take5の動き ― アプローチが消えていた
完走したものの、目視で動きを見ると、アプローチPICKもアプローチPLACEも無かった。上空に構えてから降りる動作がなく、ほぼ把持位置に直行していた。
抽出された角度を見ると理由が分かる。
姿勢 | j1 | j2 | j3 | j4 | j5 | j6 |
|---|---|---|---|---|---|---|
APPROACH_PICK | -36.03 | 91.66 | -21.35 | 1.40 | -70.66 | -32.78 |
PICK | -36.03 | 91.66 | -21.44 | 1.40 | -70.48 | -32.87 |
APPROACH_PICKとPICKの差は最大0.18°。teachの時点で、上空のアプローチ姿勢と降りた把持姿勢がほぼ同じ場所に記録されていた。
原因はteachの操作実態にあった。前回の記事に残したteachログを見ると、工程キーの押された順序はこうだ。
工程 | idx | rt(秒) |
|---|---|---|
APPROACH_PICK | 108 | 12.90 |
PICK | 195 | 23.26 |
グリッパー閉印 | 270 | 32.16 |
APPROACH_PICK phaseは idx 108〜194。そして「2(PICK開始)」を押したのは、アームがすでに降下を終えて把持位置に着いた後だった。降下の動作はAPPROACH_PICK phaseの中で起きていて、phaseの終端には降り切った姿勢しか残っていない。「phaseの最後の姿勢」を代表点にとる抽出ルールが、ここで噛み合わなかった。
上空姿勢はidx 108〜194のどこかに記録されている。データはある。抜く場所を変えればいい。
take6〜7 ― 抽出をデータ駆動に変える
まずphaseの中間点を試した(take6)。
APPROACH_PICK <- phase APPROACH_PICK mid_idx=151 (start=108 end=194)
angles = [ -35.59, +91.66, -22.50, +2.63, -68.90, -31.55]
[WP] [WARN] APPROACH_PICK と PICK の max 軸差が 1.58° < 5.0°。中間点でもまだ降り切っていた。gapは1.58°。降下はphaseのかなり前半から始まっていた。固定のインデックスで「ここがアプローチ姿勢のはず」と当てる推定では拾えない。
phase内の全姿勢からPICK姿勢と最も離れた1点を選ぶargmax方式に変えた(take7)。
APPROACH_PICK <- phase APPROACH_PICK argmax_idx=108 (start=108 end=194, gap=33.48°)
angles = [ -36.29, +58.18, +2.28, +2.81, -61.17, -31.81]
APPROACH_PLACE <- phase APPROACH_PLACE argmax_idx=440 (start=440 end=503, gap=17.93°)
angles = [ +42.45, +48.60, +14.94, +0.52, -67.14, -34.01]argmaxが選んだのはphaseの先頭(idx=108、'1'を押した直後で上空にいた瞬間)だった。gapはPICK側で33.48°、PLACE側で17.93°。アプローチの上空姿勢が復活した。take7はアプローチを経由して完走した。
take8 ― 爪が開き切る時間
take7では、PLACEで爪が開き切る前に退避が始まり、ブロックがぎりぎり箱に落ちる状態だった。STEP 9の set_gripper_state(0) は送られていて例外も無い。コマンドは出ているのに開き切らない。
原因は爪が負荷を持っていたことだった。dwellの0.5秒はplay-Aと同値だが、play-Aは空荷の再生だった。今回はブロックを実際に挟んでいて、負荷のかかった爪は開くのに時間がかかる。dwellを0.5→1.5秒に延ばした。
take8で、爪が全開してから退避が始まり、ブロックが箱の中へ素直に落ちた。全11ステップが、アプローチを経由し、掴み、運び、置いて、HOMEへ戻るところまで通った。
[STEP 9] GRIPPER OPEN → set_gripper_state(0, 50)
[STEP 9] gripper dwell 1.5s(v2 play と同値)
[STEP 10] MOVE → APPROACH_PLACE (retreat) ...
[WPSR] arrival OK (PLACE → APPROACH_PLACE (retreat), max|Δ|=2.90° <= tol 3.0°)
[STEP 11] MOVE → HOME ...
[WPSR] arrival OK (APPROACH_PLACE → HOME, max|Δ|=2.37° <= tol 3.0°)
[WPSR] 完了。elapsed = 42.04s振り返り ― なぜ8回かかったか
ここが本稿の本体だ。詰まった原因は3つ重なっていた。
問題1は、グリッパー後の単発 send_angles が実機に無視されることだった(take1〜4)。最大の壁。グリッパーコマンドの後にsend_anglesを1発送っても、実機が処理しない。dwell延長、再送3回、0.1秒連打10発、すべて効かなかった。最終的な解は、play-Aと同じストリーミング文脈に載せること。単独の犯人は特定できず、送信の文脈ごと変えて回避した。
問題2は、アプローチ姿勢が抽出できていなかったことだった(take5〜7)。APPROACH_PICKを「phaseの最後の姿勢」で抜いていたが、teach時に工程キー'2'を押したのは降下し終わった後。phase終端がほぼPICKと同じ姿勢になり、アプローチ動作が消えた。中間点でもまだ降下後で、最終的に「PICKから最も遠い姿勢」をデータから選ぶargmaxで解決した。
問題3は、細かい詰めだった(take7〜8)。到達判定の許容差2.0°が僅差で落ちる。3.0°に緩和。負荷のかかった爪がdwell0.5秒で開き切らない。1.5秒に延長。
時間がかかった本当の理由
単発でウェイポイントを送るという、このプロジェクトで前例のない送信方式を選んだことだ。
play-A(ストリーミング)は実機で動く実績があった。今回はそこから離れて新方式を作り、実機の未知の癖(グリッパー後の単発無視)を正面から踏んだ。take1〜4は新方式をなんとか動かすための消耗戦で、take5で「動く前例に戻る」決断をした瞬間に壁が消えた。
教訓はシンプルだ。実機で動いている実績があるなら、まずその文脈に乗せる。新しい方式は、差分が1つだけになるように小さく試す。動く前例から離れた瞬間に、未知の実機問題と戦うことになる。
無駄ではなかったもの
8回は遠回りだったが、残ったものがある。
実機の癖:単発は無視され、ストリーミングは通る。play-B以降ずっと使う
安全装置:読み耐性、ゴミ読みフィルタ、グリッパー直前区間での到達確認ABORTは、本番品質の保険としてそのまま残る
teachの押しどころ:工程キーは動作の前に押す。'2'(PICK開始)は降下を始める前に押せば、phase終端の抽出でも自然にアプローチが残る
当初の「位置入りサンプルの動きを最短で確認したい」は達成した。8回かかったが、送信方式・抽出ルール・安全装置・teachの押しどころ、どれも次の段階(位置への汎化)の土台になる。
