mechArm 270 Pi 動的追従の限界を探る Log#47 | 仮説が三度データに殺された日。PLACEサーボが一度も動いていなかった理由
PLACEの視覚サーボの符号を確認しようとして、丸一日詰まった。原因は最後に分かる。サーボは一度も送角していなかった。存在しない関数名を呼んで、毎回1ループ目で例外死していた。そのバグに辿り着くまで、設計側(Claude)は「stageに届いていないのでは」「保持ブロックが検出できていないのでは」と外し続け、実機側が画面・画像・jsonを順に出して真因に引き戻した。この記事は、その過程で出た実機ログ・実行記録・修正コードを、省略せず全量で残す。デバッグとは、正しい問いに辿り着くまで間違った問いを潰していく作業で、その潰した跡と、判断の根拠になった生データこそ、後で見返す価値がある。
以下、対話とそこで参照した実データを全量で記録する。要約はしない。外した仮説も、それを否定した実データも、そのまま置く。
発端:PLACEでサーボは効いているのか、補正記憶は活用できているのか
台帳とコードを見ながら、実機側から問いが投げられた。
PLACEでサーボ効いてるの?補正記憶活用できてる?
現在のフラグ状態を、コードの現物で確認した。
# run_pick_place.py
PLACE_SERVO_OBSERVE_ONCE = True # L224
PLACE_CORR_MEASURE_MODE = True # L240この2つがTrueである限り、どちらも本来の働きをしていない。壊れているのではなく、切り分けのために意図的にこの状態にしたものだった。
PLACE_SERVO_OBSERVE_ONCE = Trueの間、stage8.5の再照準は1ループだけ動いて即抜ける(符号と箱接触を目視確認するための観測モード)。収束するまで締める本番動作はしていない。PLACE_CORR_MEASURE_MODE = Trueの間、PLACE補正は apply も update もスキップする(L662)。計測モードで、再照準単独の効果を素で測るために、わざと補正を止めている。
補正スキップの現物は L662:
if phase == "place" and PLACE_CORR_MEASURE_MODE:
# 計測モード: PLACE補正の apply / update をスキップ
...だから答えはこうなった。サーボも補正も今は効いていない(意図的に無効化中)。両方を効かせる前に、符号確認とその手前のj4抜け解決が要る。
符号確認へ、のはずだった
実機側の判断はこうだった。
j4抜けは一旦無視。PLACEサーボの符号確認こっちすぐに進めて。
符号確認は新しい実装が要らない。観測モード(PLACE_SERVO_OBSERVE_ONCE=True)は既に仕込んであり、codexレビューも通過済み。実機で1ループ回して [SERVO] verdict: 行を見るだけ、と案内した。
ところが実機側から返ってきたのは、想定していない一言だった。
[SERVO]ログってどう出すの?そんなのないけど?
空回りの始まり
[SERVO] はコードの print() 文なので、stage8.5の再照準が実行されれば自動でターミナルに出る。そう説明した上で、出ていない理由を3つ挙げ、Claude Codeにgrep確認を投げようとした。実機側が止めた。
Claude Code への確認指示 ん?何回もやってどうすんの?
同じgrep確認指示を何度も出していた。ループに入っていた。一度立ち止まって「実機で回したとき、ターミナルに出たのは stage いくつまでか」と事実だけを聞いた。返ってきたのは observe ダッシュボードのスクリーンショットで、そこには全ステージ通過の履歴が写っていた。
STAGE10までいってるけど?
到達していた ― observe ダッシュボードの実データ(全量)
RUN_0001_20260705_182346 のステージ履歴。全ステージが通過していた。
STAGE HISTORY — OLDEST TOP
18:23:47.930 STAGE1 — J:[-0.17, 1.49, 1.93, -0.26, 0.08, -29.79]
18:24:14.168 STAGE2 OPEN J:[-0.17, 1.49, 1.93, -0.26, 0.08, -29.79]
18:24:19.818 STAGE3 APPROACH_PICK OPEN J:[-34.01, 89.03, -36.91, 4.48, -69.43, -30.32]
18:24:21.345 STAGE4 PICK OPEN J:[-31.46, 89.03, -36.91, 17.31, -47.54, -30.93]
18:24:25.192 STAGE5 CLOSE J:[-31.46, 89.03, -36.91, 17.31, -47.54, -30.93]
18:24:28.574 STAGE6 CLOSE J:[-34.62, 89.03, -37.61, 4.48, -68.55, -30.32]
18:24:36.749 STAGE7 APPROACH_PLACE CLOSE J:[38.49, 60.02, 17.75, -0.61, -88.41, -29.53]
18:24:39.145 STAGE8 PLACE CLOSE J:[46.40, 51.41, 28.12, -1.58, -80.77, -29.53]
18:25:00.649 STAGE9 OPEN J:[46.40, 51.41, 28.12, -1.58, -80.77, -29.53]
18:25:02.883 STAGE9B JUDGE_PLACE OPEN J:[85.07, 56.07, 18.80, -0.35, -86.66, -29.44]
18:25:08.292 STAGE10 OPEN J:[38.14, 58.27, 18.80, 0.35, -88.59, -29.35]グリッパーパネルの数値(全量):
GRIPPER OPEN
block pick 216.3, 253.9
block place -201.8, 209.1
box -194.7, 178.4
in_box true
tip Δdist 8.2 mm
tip Δx, Δz -5.8, -5.7
place Δdist 31.5 mm
place Δx, Δz -7.1, 30.7
run dir /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_20260705_182346
tip jpg run_0001_20260705_182346_stage4_pick_tip.jpg
pick jpg run_0001_20260705_182346_pick.jpg
place jpg run_0001_20260705_182346_place.jpgSTAGE8 PLACE に到達し、STAGE9 でリリースまで行っている。PLACE姿勢には届いていた。「stage8未到達では」という疑いは、この画面で消えた。
一度は「block place が空だ」と読み違えて「保持ブロックが検出できていないのでは」と疑ったが、実機側が place_pre.jpg・place.jpg を提示し、保持ブロックが写っていることが確認された。block place = -201.8, 209.1 と、検出は成立していた。2つ目の仮説も外れた。
真因 ― 実行記録 json の servo_place(全量)
RUN_0001_20260705_182346 の json から、servo_place を全量で貼る。
{
"history": [],
"exit_reason": "send_angle_error",
"converged": false,
"iter_count": 1,
"elapsed_sec": 2.1457672119140625e-06,
"j0_start": 46.4,
"j0_final": 46.4,
"target_x_mm": -194.71,
"note": "daemon_send_angles 例外: name 'daemon_send_angles' is not defined",
"servo_base_j1_j5": [
51.41,
28.12,
-1.58,
-80.77,
-29.53
]
}これが真因だった。stage8.5の再照準は呼ばれている。target_x_mm: -194.71(箱中心X)も計算できている。j0_start: 46.4 から締める準備もできている。だが note にあるとおり、1回目のj0送角で daemon_send_angles is not defined の例外で即死していた。exit_reason: send_angle_error、iter_count: 1、history: []、elapsed_sec: 2.1e-06(実質ゼロ秒)。サーボは一度も送角していなかった。
同じ json の judgments(全量)。VLM の判定は3つとも記録されている。
{
"pick": {
"verdict": "yes",
"confidence": 0.82,
"reason": "2枚目でグリッパーの爪の間に赤ブロックが挟まれており、1枚目と比較してブロックの位置がグリッパーと共に移動・上昇しているため、床から持ち上げられていると判断できる",
"model": "claude-sonnet-4-6",
"vlm_verdict": "yes",
"human_verdict": null,
"dist_override": false
},
"place_side": {
"verdict": "no",
"confidence": 0.82,
"reason": "赤いブロックはロボットアームのグリッパーに把持されており、箱の縁より上方・箱の外側の位置にある。まだ箱の内部に収まっていない。",
"model": "claude-sonnet-4-6"
},
"place": {
"verdict": "yes",
"confidence": 0.97,
"reason": "白い四角い箱の内側に赤い四角形のブロックが置かれているのが明確に確認できる",
"model": "claude-sonnet-4-6",
"vlm_verdict": "yes",
"human_verdict": null
}
}到達している。検出できている(block place=-201.8,209.1 / box=-194.7,178.4)。置けている(place verdict=yes, confidence 0.97)。前の2つの仮説は両方とも間違いだった。それでも [SERVO] が出なかったのは、送角関数名が未定義で1ループ目に例外死し、収束判定にも観測モードにも到達しなかったからだ。符号確認以前の問題だった。
参照した json の他の全量(constants・stage7 lookup)
同じ json の constants(全量)。SEND_WAIT_SEC=1.0 等、この日の運用定数の記録。
{
"HOME": [0, 0, 0, 0, 0, -30],
"SPEED": 20,
"BOX_XZ_FALLBACK": [-0.01, 0.108],
"X_RESID_THRESH": 0.02,
"BOX_SCORE_THRESH": 0.5,
"GRIPPER_SLEEP": 2.0,
"TOL_APPROACH": 2.0,
"TOL_PICK_PLACE": 3.0,
"TOL_HOME": 2.5,
"SEND_WAIT_SEC": 1.0,
"DETECT_REPEAT": 3,
"SWEPT_J0_FOR_PLACE_JUDGE": 85.0,
"MAX_PICK_ATTEMPTS": 3,
"SWEPT_J0_FOR_PICK_RETREAT": 85.0
}stage7_approach_place の lookup(全量)。PLACE側でj4が -88.41° 付近の深い折りに入る姿勢帯の記録。
{
"label": "stage7_approach_place",
"phase": "approach_place",
"source_file": "approach_place_NA_20pt_adopted_DAY235_01.json",
"pose_id": "approach_place_NA_DAY235_01",
"target_xz_m": [-0.1947127685546875, 0.17837307739257813],
"j0": 38.42861467706842,
"method": "interp",
"x_residual_m": 0.0,
"z_residual_m": 0.051583506994074296,
"joint_limit_ok": true,
"target_angles": [38.42861467706842, 57.39, 17.49, -0.61, -88.41, -29.53]
}修正 ― import 一行の漏れ(現物・全量)
真因は daemon_send_angles という関数が run_pick_place.py に import されていなかったこと。PICK側の視覚サーボ(visual_servo_pick.py)では同じ関数が実運用されている。PLACE側は、その関数を import し忘れていた。修正後の import 文(全量):
from visual_servo_pick import ( # noqa: E402
get_daemon_socket,
close_daemon_socket,
daemon_set_gripper,
daemon_set_gripper_value, # DAY261: close の push 保持用 (open は state(0) のまま)
daemon_set_fresh_mode,
daemon_send_angles, # DAY273 (fix): PLACE 再照準 (_visual_servo_loop_place)
# で使用. run_0001_20260705_182346 が NameError で 1 ループ目
# に即死していた (未 import). PICK 側 visual_servo_loop でも
# 実運用中の関数を同じ名前で流用する.
daemon_get_angles, # DAY273 (fix): 同上. 再照準ループ内の j0 実測用.
# 既存 _daemon_get_angles_filtered (j0_sweep_scan 由来) は
# base 軸フィルタ用途で意味論が異なるため raw を使う
...
)サーボの送角箇所(_visual_servo_loop_place 内、L1436)は次のとおりで、ここで呼ぶ daemon_send_angles が import されていなかったために NameError になっていた。
daemon_send_angles(new_angles, wait=2)
except Exception as e:
servo_rec["note"] = "daemon_send_angles 例外: {}".format(e)なぜレビューを通り抜けたか
このバグは、codexの静的レビューも python3 -m py_compile も通り抜けていた。理由は、NameError(未定義の名前を呼ぶエラー)は実行時にしか出ないからだ。コンパイル時には「その名前が実行時に存在するか」までは検査されない。静的レビューも、符号やクランプやガードのロジックは見たが、呼んでいる関数が実在するかまでは追わなかった。サーボも検出も target 計算も全部正しく、壊れていたのは一度も import されていなかった関数名ひとつだった。しかもそれは、同じ関数が毎日実運用で動いている PICK 側サーボのすぐ隣にあった。
この日、他に潰したもの(実測データつき)
符号確認の詰まりに辿り着く前に、いくつかの独立した問題も扱った。すべて実測に基づいて処理し、いくつかの仮説はデータに否定された。記録として残す。
j4抜けの切り分け ― 三度外した仮説
動的追従で、掴んで持ち上げてからPLACEへ向かう間にj4が抜ける(絶対値54°付近で止まる)現象を切り分けようとした。
仮説1「他軸協調で越え、単軸で止まる」→ 否定。 stage別のj4未到達実測:
stage Δj1 成功率
stage3 — 成功多数
stage6 約0(肩ほぼ動かない) 9/10 成功 ← 単軸に近いのに越える
stage7 -31(中程度) 7/10 最悪 ← 他軸が動いても止まる
stage6(肩ほぼ動かない)が越え、stage7(他軸が動く)が最悪。「協調の勢いで越える」説はこの2つの反例で崩れた。
仮説2「wait時間が長いと越える」→ 否定。 対照実験でj4単体実験の起点送角waitを 4.0s → 1.0s に落として再実行した結果:
実験 wait j4到達 elapsed
前回 4.0s 6/6 reached(-67〜-69°) 約4.0s
今回 1.0s 6/6 reached(-66.8〜-67.1°) 約3.1s
wait=1.0s(通しと同じ)でも6/6が-54°を越えて-67°まで到達。wait差は主因でなかった。
仮説3「連続稼働・サーボ発熱で悪化」→ 調べる場所がずれていた。 三度目の仮説に移ったところで、実機側から指摘された。実験の起点姿勢がstage3(PICK側・抜けない場所)で、抜けると指摘されていたstage6→stage7遷移(PLACE側)を実験していなかった。いちばん抜ける場所でなく、抜けない場所を実験していた。仮説を三度立てて三度データに殺されるのは、MASTERの原則「同じ修正を繰り返して改善しなければ根本原因が別にある」の実演だった。
j4実験の床突入
切り分け実験の初回は、実機でアームが床に突き刺さって緊急停止した。ターミナルログ(全量):
[COND ] 条件 stage6_center mode=stage6 j0_target=0.0 trials(max)=3
[TRIAL] --- stage6_center trial 1/3 開始 ---
[HOME ] 試行前 HOME 送角
[POSE ] DESCEND 姿勢 送角 (j4=0 保持) target=[0.0, 87.74, -38.33, 3.6, 0.0, -30.0]
^C
[INT] SIGINT 受信. 送角追加せず現在角記録してクリーン終了に入ります.
[POSE ] outcome=interrupted note=送角直後 SIGINT
[ABORT] 試行後 HOME 復帰未到達. 停止して報告.
救出(08_home_from_anywhere.py)のログ(全量):
[PRE] 現在姿勢: [0.35, 88.33, -38.05, 3.95, -36.12, -29.53]
[SEND] HOME へ移動指令
[WAIT] t=0.9s max_err=66.70°
[WAIT] t=1.4s max_err=50.97°
[WAIT] t=1.9s max_err=35.15°
[WAIT] t=2.4s max_err=19.24°
[WAIT] t=3.0s max_err=10.72°
[WAIT] t=3.5s max_err=9.58°
[WAIT] t=4.0s max_err=8.70°
[WAIT] t=4.5s max_err=7.82°
[WAIT] t=5.1s max_err=6.94°
[WAIT] t=5.6s max_err=6.15°
[WAIT] t=6.1s max_err=5.53°
[WAIT] t=6.6s max_err=4.83°
[WAIT] t=7.1s max_err=4.21°
[POST] 最終姿勢: [-0.08, 1.49, 0.79, 0.43, -4.04, -29.35]設計(Claude)が、[0.0, 87.74, -38.33, 3.6, 0.0, -30.0](j1=87.74 の深い肩角に j4=0 を保持)という中間姿勢を作り、HOMEからそこへ直接送角した。j1深部で j4=0 だと爪先が床下を向く。実機側の目視によるCtrl-Cで止まった。経路を「到達済み実測姿勢を起点にする」形に組み直し、さらにcodexレビューで正方向(+54°)の条件が同じ床突入を再現すると判明(F3指摘)、負方向のみに絞って通した。物理リスクのある変更が、設計→実装→レビュー→実機のどの段でも捕まりうることを示した一連だった。
PLACE補正記憶の掃除
計測モードで素の着地を測った結果、補正を切ったら着地距離の平均が改善した。実測:
補正なし(計測モード)の着地 dist:38.1 / 30.2 / 30.7 / 7.9 / 14.3 / 44.8 → 平均 27.7mm
補正あり(直近)の着地 dist:42.3 / 46.3 / 42.7 / 48.7 / 49.2 → 平均 45.8mm
補正を切ったら平均で18mm良くなった。補正記憶が有害在庫化していた(PLACE降下高さを+15mm上げた変更の前の世界で学んだ、Z正方向への過大補正)。有害在庫を破棄し(place_ 19キー削除・archive退避・pick_ 32キー保全)、削除リストを見るとΔzが19件中17件で正・うち8件が+19mm以上と、過大補正の実態が数字で確認できた。
tip_dist override の誤爆
PICK後にグリッパーを開いてブロックを落とす現象があった。実機ターミナルログ(全量):
[Pi] GRIPPER: VALUE=20 speed=100 result= None
[SEND] stage6_lift: send_angles (single) [ -33.32, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=18.00°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.42° (tol 2.0°)
[MOVE ] stage6_lift arrived=True retried=False post_max|Δ|=1.420°
[CAM] 上カメラ(iPhone) index=2
[CAP-VLM] pick 保存 (上カメラ): /Volumes/KIOXIA/r2r2r-data/runs/run_pick_place/run_0001_20260705_183800/run_0001_20260705_183800_pick.jpg
[OVERRIDE] vlm=yes だが tip_dist=362.8mm > 30.0mm → pick_verdict=no に倒す
[JUDGE ] pick attempt=1 adopted=no (vlm=yes human=None dist_override=True) confidence=0.82 reason=2枚目でグリッパーの爪の間に赤ブロックが挟まれており、1枚目と比べてブロックの位置がグリッパーと共に移動・上昇しているため、持ち上げられていると判断
[GRIP ] regrasp_open_a1 action=open method=state(0) sleep=2.0s dry_run=False
[Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a1: send_angles (single) [ +85.00, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=117.78°, post-send timeout 19.7s)
[SEND] arrival OK: max|Δ|=1.42° (tol 2.0°)
[MOVE ] regrasp_retreat_a1 arrived=True retried=False post_max|Δ|=1.420°VLMは「掴めて持ち上がっている」と正しく yes と判定している(reason も正確)のに、tip_dist=362.8mm という破綻値が出て、それが正しい yes を no へ強制的に倒し、regrasp_open_a1 でグリッパーを開いて落としていた。
override の判定コード(現物・L2120付近):
# tip_dist 二重チェック: VLM yes でも dist > PICK_DIST_MAX_MM なら no に倒す。
# tip 測定失敗 (tip_diff is None) のときは override 判定保留 (False)。
dist_override = False
final_verdict = vlm_verdict
if (vlm_verdict == "yes" and tip_diff is not None
and tip_diff.get("dist_mm") is not None
and float(tip_diff["dist_mm"]) > PICK_DIST_MAX_MM):
final_verdict = "no"
dist_override = True
print("[OVERRIDE] vlm=yes だが tip_dist={:.1f}mm > {}mm "
"→ pick_verdict=no に倒す".format(
tip_diff["dist_mm"], PICK_DIST_MAX_MM))PICK_DIST_MAX_MM = 30.0(L253)。override は「本当に掴めていない時にno」という安全弁なので残し、物理的にありえない破綻値では発火させずVLM判定を信じる、という3分岐に修正する方針を立てた(30mm以下→VLMを信じる/30〜150mm→noに倒す/150mm超→測定破綻でoverride無効・VLMを信じる、新定数 PICK_DIST_BREAKDOWN_MM = 150.0)。これも「正しい判定を、壊れた物差しが上書きして握り潰す」構造で、MASTERの「二重エラーの相殺に注意」の変種だった。
この日の記録
符号確認をしようとして、サーボが一度も動いていなかった。設計側は現物を見ずに「届いていないのでは」「検出できていないのでは」と仮説を並べ、実機側が画面・画像・jsonを順に出して真因(import漏れ)に引き戻した。バグ自体はimport一行で、静的レビューもcompileも通り抜けるNameErrorだった。servo_place の json(exit_reason: send_angle_error / iter_count: 1 / history: [] / note: name 'daemon_send_angles' is not defined)が、それを一行で示していた。
外部レビューの項目に「呼んでいる関数が実在するか(def のgrep確認)」を足せば、この種の実行時バグは実機前に捕まえられる。それは後日、落ち着いてからテンプレートに反映する。
この日、仮説は三度データに否定され、実験は一度床に突き刺さり、補正記憶は破棄され、そして本題のサーボは一度も動いていなかった。
