mechArm 270 Pi 動的追従の限界を探る Log#38 | ドラッグティーチングで取ったZ別固定姿勢を、カメラが自動選択してPick&Place
LOG244R2R2R2026/6/19

mechArm 270 Pi 動的追従の限界を探る Log#38 | ドラッグティーチングで取ったZ別固定姿勢を、カメラが自動選択してPick&Place

弧の上しか掴めなかった。これがこのログの出発点になる。

mechArm 270-Pi の Pick&Place は、赤いブロックの位置を上カメラで検出し、j0 という基部の回転関節を振って爪先を左右に合わせ、降りて掴む。左右(X)方向はこの j0 で合う。問題は奥行き(Z)だった。掴む姿勢が「j0 を振って測った一本の弧」の上にしか無いため、その弧が通る奥行きから外れた場所にブロックがあると、爪が届かない。弧の上だけが掴める帯で、その外側は空白になる。

この空白を埋める方法として、これまで「弧をもう一本増やす」案を検討してきた。新しい基準姿勢を決めて、そこで j0 スイープをもう一度実機で測れば、別の奥行きを通る弧が手に入る。だが弧を積み増すのはコストが高い。別の道を選んだ。各奥行きで掴める姿勢を、ドラッグティーチングで直接アームに教える。教えた姿勢を再生し、その姿勢がどの奥行きを掴むのかをカメラで実測し、最後は検出した奥行きから姿勢を自動で選んで Pick&Place まで通す。このログはその一連の記録になる。途中で mechArm の関節が持つ物理的な上限にぶつかり、教えた姿勢の一部が再生できないことも分かった。失敗と訂正を含めて、起きた順に残す。

弧の外側で何が起きるか

最初に、弧から外れた奥行きで実際に何が起きるかを確かめた。ここでまず見たのはベースに近づく側、Z− の空白だ(カメラ側 Z+ の空白は同じ考え方で別に見る)。今まで安定して掴めていた核(z≈255〜265mm)から、ブロックを画面の上方向、つまりアームのベースに近づく側(Z−)へ 10mm ずつ動かして、現行の弧のまま Pick を走らせた。

結果は予想と違う壊れ方をした。3回分の実機ログを残す。ブロックの奥行き(block_z)は 208 → 197 → 180mm と、核より手前まで下がっている。

run_0001_20260619_191715  block_z=208mm  dz=+20.3  pick=no   regrasp退避でj4が25°未到達
run_0001_20260619_191850  block_z=197mm  dz=+26.4  abort     stage6 LIFTでj4が24.6°未達
run_0001_20260619_191951  block_z=180mm  測定失敗  abort     stage6 LIFTでj4が28.5°未達

読み取れたことが二つある。一つは dz(爪先とブロックの奥行きずれ)の符号が、ベースに寄せると + に転じたこと。カメラ側(Z+)に外したときは爪が手前で止まって dz がマイナスだったが、ベース寄りでは爪が行き過ぎて +20.3、+26.4 とプラスに振れ、Z− へ動かすほど絶対値が増えた。系統的なずれだった。

もう一つが重要だった。本当の壁は dz ではなく、掴んだ後の持ち上げにあった。3回とも stage6 の LIFT、つまり掴んだ姿勢から上空の APPROACH 姿勢へ戻る動作で、j4 が 20〜28° も未到達のまま止まっている。掴み精度の話ですらなく、持ち上げ動作そのものが破綻していた。ブロックをベースに寄せると、それを掴むためにアームは深く折りたたんだ窮屈な姿勢になる。その姿勢から持ち上げに移ろうとすると、関節が動ききらない。ベース寄りの奥は「掴めても持ち上げで姿勢が破綻する」領域だった。弧を一本足せば済む話ではない、と分かった。

弧を増やすのをやめ、姿勢を手で教える

弧を増やす代わりに、各奥行きで掴める姿勢をドラッグティーチングで直接教えることにした。アームを脱力させ、手で爪先をブロックの掴み位置へ持っていき、その瞬間の関節角を記録する。FK テーブルから姿勢を引くと、Unity デジタルツイン内の値と実機の間に生まれる sim-real のずれを持ち込んでしまう。勘で姿勢を作るのは禁止している。手で教えれば、実機で確実に届く姿勢がそのまま手に入る。

教示には既存の drag_teach_v2.py(LOG226 で確立した版)をそのまま使った。脱力は release_all_servos() を引数なしの damping mode でのみ呼び、アームを手で支えながら行う。このスクリプトは脱力前に「アームを手で支えてください」とプロンプトで止まり、記録停止後の finally で必ず通電復帰する。Pi 上で直接実行し、daemon は停止しておく。これらは過去に確立した手順で、新しく書いたものではない。

ブロックを Z 方向に少しずつずらしながら、各位置で掴める姿勢を 9 つ教えた。一つ問題があった。教示時の block 検出が Pi 側の OpenCV 例外で 9 本すべて None になり、「どの姿勢をどの奥行きで取ったか」の数値がファイルに残らなかった。各姿勢の奥行きラベルは、後で再生してカメラで実測することにした。教示した 9 姿勢の、グリッパー静定時の関節角。

seq  j1      j2      j3      j4      j5      j6
1   -40.25  +93.16  -23.99   +0.26  -71.19  -28.65
2   -45.35  +90.70   -6.94   +0.87  -86.48  -29.53
3   -50.88  +89.73   +0.00   -3.60  -91.66  -30.14
4   -55.45  +85.95   +5.88   -6.85  -91.58  -29.79
5   -55.89  +81.65  +13.53   -5.18  -91.84  -29.88
6   -56.16  +77.34  +20.91   -1.93  -91.93  -29.00
7   -64.07  +77.78  +20.30  -10.28  -92.37  -33.39
8   -65.74  +70.13  +28.38   -6.32  -88.76  -30.84
9   -63.98  +59.85  +35.68   +3.42  -78.39  -29.00

j1 が seq を追ってほぼ単調に減り、j2 が +93 から +60 へ単調に減っている。腕を伸ばした姿勢から、だんだん折りたたんでベースへ引き寄せる動きそのものだった。最後の seq9 だけ毛色が違う。j1 が単調を崩して増え、j5 が他(-88〜-91)より浅い -78。一番奥のブロックに対して、同じ握り方では掴めず、手で別の角度を作ったのだと思う。

教示姿勢を再生して奥行きを実測する

教えた姿勢が「どの奥行きを掴む姿勢なのか」を確定するため、各姿勢を実機で再生し、そのとき上カメラに写るブロックの位置を測ることにした。再生は本線の観測系を流用して到達確認や安全ガードを備えた replay_z_postures.py を先に作ったが、教示姿勢に到達できず、send_angles が弾く挙動が出た。重い作りが目的に合っていなかったので、層を全部はがした簡素版に作り直した。やることは、各姿勢へ送って数秒待ち、実際に到達した角度を読み、上カメラで block を検出して 1 行 CSV に書くだけ。掴まない、持ち上げない、到達判定で止めない。安全は目視で見る前提にした。

作り直した replay_z_simple.py で 9 姿勢を再生した。送った目標(tgt)と実際に到達した角度(post)の乖離が、境界をはっきり示した。

seq  block_z(mm)  tgt_j2  post_j2  最大ずれ  判定
1    (動かず)    +93.2    +1.5    91.9°    送信棄却(HOMEのまま)
2    233.9        +90.7   +91.1    0.4°     到達
3    165.3        +89.7   +83.2    6.5°     半端(j2上限ぎりぎり)
4    151.2        +86.0   +86.4    0.4°     到達
5    148.2        +81.7   +82.2    0.5°     到達
6    137.0        +77.3   +82.2    4.8°     半端
7    126.6        +77.8   +77.5    0.3°     到達
8    102.1        +70.1   +70.1    0.0°     到達
9     84.9        +59.9   +60.3    0.4°     到達

seq1 が、送ったのに全く動いていない。実到達が HOME のまま(j2≈1.5)だった。send_angles が受理した顔をして物理的に動かない。seq1 は j2 が +93。seq3 以降は j2 が 90 以下で、おおむね 1〜2° で到達している。境界は j2≈90° のあたりにあった。

j2 の物理上限は約 91°

seq1 が動かなかった原因を、別の角度から確かめた。スクリプトの層を全部はがし、daemon を直接叩いて j2 を 85 → 88 → 90 → 92 → 93 と 1 度ずつ上げ、各段で実際にどこまで到達するかを読む。他の関節は seq1 付近に固定した。ファイルにはせず、ターミナルにヒアドキュメントで直接貼って実行した。そのコードと実行結果をそのまま残す。

import sys
sys.path.insert(0, ".")
from visual_servo_pick import get_daemon_socket, close_daemon_socket, daemon_send_angles, daemon_get_angles, daemon_set_fresh_mode
import time

get_daemon_socket()
daemon_set_fresh_mode(1)

def send_and_read(angles, label):
    print("\n[SEND]", label, "->", angles)
    daemon_send_angles(angles, 20)
    time.sleep(3)
    print("[POST]", daemon_get_angles())

# まずHOME
send_and_read([0,0,0,0,0,-30], "home")
# j2 を 85 -> 88 -> 90 -> 92 と1つずつ上げて、どこで止まるか見る(他関節はseq1付近で固定)
send_and_read([-40, 85, -24, 0, -71, -29], "j2=85")
send_and_read([-40, 88, -24, 0, -71, -29], "j2=88")
send_and_read([-40, 90, -24, 0, -71, -29], "j2=90")
send_and_read([-40, 92, -24, 0, -71, -29], "j2=92")
send_and_read([-40, 93, -24, 0, -71, -29], "j2=93")

daemon_set_fresh_mode(0)
close_daemon_socket()

実行結果。

[SEND] home -> [0, 0, 0, 0, 0, -30]
[POST] [0.08, 1.49, 1.66, -0.43, 0.7, -29.26]

[SEND] j2=85 -> [-40, 85, -24, 0, -71, -29]
[POST] [-39.55, 86.13, -22.67, 0.0, -68.2, -29.35]

[SEND] j2=88 -> [-40, 88, -24, 0, -71, -29]
[POST] [-39.55, 89.03, -22.85, 0.0, -69.34, -29.26]

[SEND] j2=90 -> [-40, 90, -24, 0, -71, -29]
[POST] [-39.55, 91.14, -22.85, 0.0, -70.22, -29.26]

[SEND] j2=92 -> [-40, 92, -24, 0, -71, -29]
[POST] [-39.55, 91.14, -22.85, 0.0, -70.22, -29.26]

[SEND] j2=93 -> [-40, 93, -24, 0, -71, -29]
[POST] [-39.55, 91.14, -22.85, 0.0, -70.92, -29.17]

j2 は 90 まで送った値に追従して上がり、92・93 を送っても 91.14 で頭打ちになった。実到達が 91.14 でぴたりと止まり、それ以上は何度送っても動かない。これが j2 の機械的な上限だった。脱力して手で曲げれば j2=93 まで行けるが、send_angles はそこへ送らせてくれない。教示で手の力で作れた姿勢が、自動送信では再現できない領域に入っていた。これで、seq1(j2=93)は再生不能、seq2(j2=90.7)は到達するが j2 上限にかなり近く余裕が薄い、と分かった。安定して確実に使えるのは seq3〜9 だと見た。

教えた姿勢で掴んで持ち上げる

奥行きと姿勢の対応が分かったので、次は「その姿勢で本当に掴んで持ち上げられるか」を確かめた。これは「弧の外側で何が起きるか」で見た、stage6 LIFT が破綻した動作に正面から踏み込むことになる。教示姿勢で掴み、通常の APPROACH 上空姿勢へ戻す grasp_lift_test.py を作った。掴むところまでは通し、持ち上げ(HOME 復帰)は人が目視で安全を確認してから別コマンドで行う設計にした。LIFT が未到達なら、それ以上動かさず掴んだまま停止する。

中間の奥行きにあたる seq5(z≈148)で実機にかけた。実行ログを残す。

[STEP4] 教示姿勢 seq=5 へ移動
[SEND] to_teaching_seq5 arrived max|Δ|=1.32° (elapsed 2.3s)
[STEP5] gripper 閉
[STEP6] LIFT (通常 APPROACH 上空へ) target = [-56.07, +87.74, -38.33, +3.60, -70.11, -30.00]
[SEND] to_lift_seq5 arrived max|Δ|=1.25° (elapsed 0.9s)
[STEP7] HOME 復帰(掴んだまま)
[SEND] to_home_post TIMEOUT max|Δ|=88.94°
[HOLD ] HOME 未到達 max|Δ|=88.94°. 掴んだまま停止。

教示姿勢への到達(1.32°)、掴み、そして LIFT が 1.25° で到達した。「弧の外側で何が起きるか」で破綻していた持ち上げが、教示姿勢からは通った。奥行き z≈148 という、弧では届かない領域で、掴んで上空まで持ち上げられた。止まったのはその後の HOME 復帰で、これは破綻ではなく、LIFT 姿勢から HOME への 88.94° という大移動が一度の send_angles では動ききらずタイムアウトしたものだった。掴んだまま安全に停止し、回収コマンドで戻した。HOME への大移動をどう刻むかが、次の通しでの課題として残った。

カメラが奥行きから姿勢を選び、Pick&Place まで通す

最後に、ここまでを一本に繋いだ。カメラでブロックの奥行きを検出し、教えた姿勢の中から最も近いものを自動で選び、掴んで、従来の PLACE で運んで置き、VLM で成否を判定して HOME まで戻る。grasp_lift_pick_then_place.py がそれにあたる。PICK は教示 Z 姿勢、PLACE は本線 run_pick_place.py の従来ロジックをそのまま import して使う。本線は一行も改変していない。

姿勢の自動選択には、再生で実測した奥行きと姿勢の対応表(pose_z_map)を使う。検出した block_z に最も近い姿勢を選び、到達する姿勢(reached)を優先する。対応表のすぐ近くに姿勢がないとき、つまり最も近い姿勢でも 35mm 以上ずれるときは、掴んでも届かない可能性が高いので掴みに進まず中断する。教えた姿勢の奥行きは飛び飛びで、z165〜234 の間に大きな空白があるため、このガードを入れた。

seq5 の LIFT で見えた HOME 大移動の問題には、中継分割で対処した。現在姿勢から目標までの最大の関節差が 60° を超えるとき、全 6 軸を線形補間した中継点を挟んで刻む。最初に作った版は j0 だけを刻んで最後に残り 5 軸を一度に動かしていたが、これでは j5 が 89° 動くような場面で大移動が残る。外部レビューでこの欠陥を指摘され、全 6 軸を均等に刻む形に直した。

実機にかけた。ブロックの奥行きは 192.7mm。到達済みの姿勢だけを見ると、最も近い seq2 でも 41.3mm 離れていて、35mm のガードで止まる位置だった。だが半端到達の seq3(z=165、ずれ 27.7mm)はそれより近く、こちらが選ばれた。block_z=192.7 は教えた姿勢の奥行きの空白帯にあたる。到達済みの姿勢だけに頼っていたら、ガードで中断していた位置だった。

1 回目は中継の途中で止まった。そのときのログ。

[STEP3.5] block_z = 192.7mm → seq 自動選択
[STEP3.5] 候補: reached_nearest=seq2 gap=41.3mm  partial_nearest=seq3 gap=27.7mm
[STEP3.5] 採用: seq=3 (z=165.0mm verdict=partial) gap=27.7mm rationale=partial_clearly_closer
[STEP7] 中継: PICK 終端 → approach_place_target の大移動分割
[RELAY] pkp_relay_pre_place full max|Δ| = 91.03° > 60° → 中継 2 step に分割(6軸補間)
[SEND] pkp_relay_pre_place_relay_step1_of_2: ... post-send timeout 8.8s
[SEND] [TIMEOUT] 8.8s 内に未到達: max|Δ|=3.03° actual=[-4.57, +76.81, -9.05, +1.40, -78.66, -29.79]
[RELAY] pkp_relay_pre_place_relay_step1_of_2 未到達 max|Δ|=3.03°. 残り 1 段中止
[HOLD] 中継 (PICK→PLACE) 未到達. 掴んだまま停止。

中継の 1 段目が、到達判定の許容(3.0°)をわずかに超える 3.03° でタイムアウトした。掴んだまま安全に停止し、回収した。

2 回目、同じ条件で通った。中継の 1 段目が 2.95° で到達し、そのまま PLACE まで進んだ。完走ログの全量。

======================================================================
[INIT] grasp_lift_pick_then_place.py run (auto-select) dry_run=False
[INIT] block_z 自動選択モード (--seq 廃止). seq は STEP3.5 で決定
======================================================================

[INIT] daemon 接続中 ...
  [Pi] fresh_mode=1
[INIT] fresh_mode=1

[STEP1] HOME 移動
[STEP1] 現在 [-4.57, +76.81, -9.05, +1.40, -79.80, -29.79] → HOME 大|Δ|=79.80°
[SEND] pkp_to_home_pre arrived max|Δ|=1.75° (1.8s)

[STEP2] gripper 開
  [Pi] GRIPPER: OPEN result= None
[STEP2] gripper open + sleep 2.0s

[STEP3] block + box 同時検出 detect_positions(repeat=3)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[378, 1607] Y=[104, 999]
[06f] TEMPLATES_DIR overridden to: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/place/templates
[06f] ROTATION_ANGLES_DEG overridden to: [0, 15, 30, 45, 60, 75]
[06f] SCALES overridden to: [0.85, 1.0, 1.15]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1394,646) score=0.657 angle=0° scale=1.15 size=(226x211) template=white_box_20260520_163838_005.png (eval 4.33s)
[DETECT][1/3] box Unity=(-0.206,+0.191) score=0.6569207310676575
  赤ブロック pixel=(581, 654) → Unity=(0.2079, 0.1888)
[DETECT][1/3] block Unity=(+0.208,+0.189) score=null
[06f] DETECT: center=(1393,646) score=0.657 angle=0° scale=1.15 size=(226x211) template=white_box_20260520_163838_005.png (eval 4.34s)
[DETECT][2/3] box Unity=(-0.206,+0.191) score=0.6572920680046082
  赤ブロック pixel=(581, 654) → Unity=(0.2079, 0.1888)
[DETECT][2/3] block Unity=(+0.208,+0.189) score=null
[06f] DETECT: center=(1394,646) score=0.657 angle=0° scale=1.15 size=(226x211) template=white_box_20260520_163838_005.png (eval 4.38s)
[DETECT][3/3] box Unity=(-0.206,+0.191) score=0.6572579741477966
  赤ブロック pixel=(582, 654) → Unity=(0.2074, 0.1888)
[DETECT][3/3] block Unity=(+0.207,+0.189) score=null
[STEP3] block_xz=[0.2079222412109375, 0.18878211975097656] box_xz=[-0.20640762329101564, 0.19090420532226562]

[STEP3.5] block_z = 188.8mm → seq 自動選択
[STEP3.5] 候補: reached_nearest=seq4 gap=37.8mm  partial_nearest=seq3 gap=23.8mm
[STEP3.5] 採用: seq=3 (z=165.0mm verdict=partial) gap=23.8mm rationale=partial_clearly_closer
[STEP3.5] teaching = [-50.88, +89.73, +0.00, -3.60, -91.66, -30.14]

[STEP4] 教示姿勢 seq=3
[STEP4] 現在 → target 大|Δ|=91.40°
[SEND] pkp_to_teaching_seq3 arrived max|Δ|=0.87° (1.8s)

[STEP5] gripper 閉
  [Pi] GRIPPER: CLOSE result= None
[STEP5] gripper close + sleep 2.0s

[STEP6] LIFT → [-50.53, +87.74, -38.33, +3.60, -70.11, -30.00]
[SEND] pkp_to_lift_seq3 arrived max|Δ|=2.26° (0.0s)

[STEP7] 中継: PICK 終端 → approach_place_target の大移動分割
[STEP7] approach_place_target = [+40.94, +57.39, +17.49, +0.08, -89.20, -29.44]
[RELAY] pkp_relay_pre_place full max|Δ| = 91.38° > 60° → 中継 2 step に分割(6軸補間)
[SEND] pkp_relay_pre_place_relay_step1_of_2: send_angles (single) [  -4.75,  +73.69,   -9.76,   +1.89,  -78.70,  -29.66]  speed=20 wait=1.0s  (pre max|Δ|=45.69°, post-send timeout 8.9s)
[SEND] arrival OK: max|Δ|=2.95° (tol 3.0°)
[MOVE  ] pkp_relay_pre_place_relay_step1_of_2  arrived=True retried=False post_max|Δ|=2.945°
[SEND] pkp_relay_pre_place_relay_step2_of_2: send_angles (single) [ +40.94,  +57.39,  +17.49,   +0.08,  -89.20,  -29.44]  speed=20 wait=1.0s  (pre max|Δ|=45.59°, post-send timeout 8.8s)
[SEND] arrival OK: max|Δ|=2.72° (tol 3.0°)
[MOVE  ] pkp_relay_pre_place_relay_step2_of_2  arrived=True retried=False post_max|Δ|=2.720°

[STEP8] approach_place 最終フィニッシュ (tol=3.0)
[SEND] pkp_stage7_approach_place_final: send_angles (single) [ +40.94,  +57.39,  +17.49,   +0.08,  -89.20,  -29.44]  speed=20 wait=1.0s  (pre max|Δ|=2.72°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=2.72° (tol 3.0°)
[MOVE  ] pkp_stage7_approach_place_final  arrived=True retried=False post_max|Δ|=2.720°

[STEP9] place_withblock 降下
[LOOKUP] pkp_stage8_place  phase=place_withblock  target_xz=(-0.2064,+0.1909)  → j0=+40.934°  method=interp  x_resid=0.00000m  z_resid=0.03672m  joint_ok=True
[SEND] pkp_stage8_place: send_angles (single) [ +40.93,  +59.15,  +12.83,   -1.40,  -78.04,  -29.53]  speed=20 wait=1.0s  (pre max|Δ|=10.11°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.94° (tol 3.0°)
[MOVE  ] pkp_stage8_place  arrived=True retried=False post_max|Δ|=1.940°

[STEP10] gripper open (release)
[GRIP  ] stage9_release  action=open  pre_sleep=2.0s  attempts=2  sleep_each=2.0s  dry_run=False
  [Pi] GRIPPER: OPEN result= None
  [Pi] GRIPPER: OPEN result= None

[STEP11] swept_retreat → [+85.00, +57.39, +17.49, +0.08, -89.20, -29.44]
[SEND] pkp_stage9b_swept_retreat: send_angles (single) [ +85.00,  +57.39,  +17.49,   +0.08,  -89.20,  -29.44]  speed=20 wait=1.0s  (pre max|Δ|=44.05°, post-send timeout 8.6s)
[SEND] arrival OK: max|Δ|=2.72° (tol 3.0°)
[MOVE  ] pkp_stage9b_swept_retreat  arrived=True retried=False post_max|Δ|=2.720°

[STEP12] ★VLM place 判定(必須・Anthropic API 呼び出し)
[CAM] 接続中カメラ一覧:
  index=0  name=FaceTime HDカメラ
  index=1  name=Iriun Camera
  index=2  name=‎iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone)  index=2
[CAM] 横カメラ(Iriun)   index=1
[CAP-VLM] place 保存: /Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243/pick_then_place/pick_then_place_auto_20260619_221255_place.jpg
[STEP12] place verdict=yes confidence=0.97 reason=白い箱の内側に赤い四角形のブロックが明確に確認できる

[STEP13] lift_back → approach_place_target
[SEND] pkp_stage10_lift_back: send_angles (single) [ +40.94,  +57.39,  +17.49,   +0.08,  -89.20,  -29.44]  speed=20 wait=1.0s  (pre max|Δ|=44.13°, post-send timeout 8.6s)
[SEND] arrival OK: max|Δ|=2.72° (tol 3.0°)
[MOVE  ] pkp_stage10_lift_back  arrived=True retried=False post_max|Δ|=2.720°

[STEP14] HOME 復帰(必要なら中継分割)
[RELAY] pkp_relay_to_home full max|Δ| = 88.50° > 60° → 中継 2 step に分割(6軸補間)
[SEND] pkp_relay_to_home_relay_step1_of_2: send_angles (single) [ +20.52,  +30.05,   +8.70,   +0.13,  -44.25,  -29.80]  speed=20 wait=1.0s  (pre max|Δ|=44.25°, post-send timeout 8.6s)
[SEND] arrival OK: max|Δ|=2.50° (tol 2.5°)
[MOVE  ] pkp_relay_to_home_relay_step1_of_2  arrived=True retried=False post_max|Δ|=2.500°
[SEND] pkp_relay_to_home_relay_step2_of_2: send_angles (single) [  +0.00,   +0.00,   +0.00,   +0.00,   +0.00,  -30.00]  speed=20 wait=1.0s  (pre max|Δ|=46.58°, post-send timeout 9.0s)
[SEND] arrival OK: max|Δ|=2.10° (tol 2.5°)
[MOVE  ] pkp_relay_to_home_relay_step2_of_2  arrived=True retried=False post_max|Δ|=2.100°

======================================================================
[DONE] seq=3 pick→place 完走
       PICK arrived=True LIFT arrived=True relay_pre=True
       PLACE app=True place=True release=True swept=True
       VLM verdict=yes conf=0.97
       lift_back=True home=True
======================================================================

block_z=192.7 から seq3 が選ばれ、教示姿勢へ到達し、掴み、中継分割で大移動を 2 段に刻んで PLACE 側へ移り、従来の PLACE で箱へ運んで離し、VLM が「白い箱の内側に赤い四角形のブロックが明確に確認できる」と判定して verdict=yes、confidence=0.97。最後は HOME への 88.50° の大移動も中継分割で 2 段に刻んで戻った。弧の外側の奥行きを、カメラが検出した値から姿勢を選んで掴み、置くところまで通った。

1 回目と 2 回目で、同じ中継が 0.1° の差で成否を分けた。3.03° で止まり、2.95° で通った。中継分割は正しく効いているが、到達判定のぎりぎりで運に左右される部分が残っている。安定して通すには、ここの余裕の取り方を見直す必要がある。

選ばれた seq3 は半端到達の姿勢で、reached だけならガードで中断していた位置だった。半端到達の姿勢でも掴んで PLACE まで通ったのは収穫だが、同時に、教えた姿勢の奥行きが飛び飛びで z165〜234 に空白があるという弱点の裏返しでもある。ブロックがその空白に来ると、半端な姿勢に頼ることになる。

残ったもの

弧の外側の奥行きを、手で教えた姿勢とカメラの自動選択で掴めるようになった。一本の弧の上だけだった掴める帯が、教えた 8 姿勢ぶんの奥行き(z≈85〜234)へ広がり、最後はカメラが奥行きから姿勢を選んで Pick&Place まで通した。

残っている課題を事実として並べておく。中継分割は完走させたが、1 回目と 2 回目が 0.1° 差で分かれたとおり、到達判定の余裕がぎりぎりで安定していない。教えた姿勢の奥行きは飛び飛びで、z165〜234 の空白にブロックが来ると半端到達の姿勢に頼ることになる。seq1・seq2 は j2 の物理上限で再現できず、一番伸ばした奥行きが欠けている。これらは次に手をつける場所になる。

この検証で使ったコードを、後で見返して使える記録として無修正で置く。再生の簡素版、作り直す前の重い版、掴んで持ち上げる版、自動選択で通す版、そして j2 上限を確かめた段階送りの五つ。

コード全量

replay_z_simple.py(教示姿勢を再生して奥行きを測る簡素版)

教えた姿勢を順に送り、実到達角を読み、上カメラで block を検出して CSV に書くだけのスクリプト。到達判定で止めず、掴まず、持ち上げない。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""replay_z_simple.py — 教示9姿勢を順に再現して block 位置を測るだけの簡素版

やること(これだけ):
  各姿勢へ send_angles で送る → 数秒待つ → 実到達角(get_angles)を読む
  → 上カメラで block 検出 → 1行 CSV に記録
やらないこと:
  到達判定で止めない / 掴まない / 持ち上げない / 確認プロンプト出さない
安全はオペレータが目視で見る前提。

絶対ルール:
  send_angles のみ(send_coords / release_all_servos は呼ばない)
  Stiffness/Damping/fk_csv は触らない

使い方(daemon 起動済み・上カメラ接続済みで):
  python3 replay_z_simple.py            # seq1..9 を順に
  python3 replay_z_simple.py --seq 3    # 1姿勢だけ
  python3 replay_z_simple.py --home     # 最後にHOMEへ戻す(任意)
"""
from __future__ import print_function
import argparse, csv, sys, time
from datetime import datetime
from pathlib import Path

_THIS = Path(__file__).resolve()
_SCRIPTS_ROOT = _THIS.parents[2]
_PLACE = _SCRIPTS_ROOT / "dynamic_tracking_limits" / "place" / "place_modules"
sys.path.insert(0, str(_SCRIPTS_ROOT))
sys.path.insert(0, str(_PLACE))

from visual_servo_pick import (  # noqa: E402
    get_daemon_socket, close_daemon_socket,
    daemon_send_angles, daemon_get_angles, daemon_set_fresh_mode,
)
from detect_positions_once import detect_positions  # noqa: E402

HOME = [0, 0, 0, 0, 0, -30]
SPEED = 20
SETTLE_SEC = 3.0   # 送信後の静定待ち
OUT_DIR = Path("/Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243/replay_simple")

# 教示9姿勢(j1..j6)。seq順。index.csv の close時関節角。
POSES = {
    1: [-40.25, 93.16, -23.99,  0.26, -71.19, -28.65],
    2: [-45.35, 90.70,  -6.94,  0.87, -86.48, -29.53],
    3: [-50.88, 89.73,   0.00, -3.60, -91.66, -30.14],
    4: [-55.45, 85.95,   5.88, -6.85, -91.58, -29.79],
    5: [-55.89, 81.65,  13.53, -5.18, -91.84, -29.88],
    6: [-56.16, 77.34,  20.91, -1.93, -91.93, -29.00],
    7: [-64.07, 77.78,  20.30,-10.28, -92.37, -33.39],
    8: [-65.74, 70.13,  28.38, -6.32, -88.76, -30.84],
    9: [-63.98, 59.85,  35.68,  3.42, -78.39, -29.00],
}


def _send(angles, label):
    print("[SEND] {} -> {}".format(label, ["{:+.2f}".format(a) for a in angles]))
    daemon_send_angles(angles, SPEED)
    time.sleep(SETTLE_SEC)
    post = None
    try:
        post = daemon_get_angles()
    except Exception as e:
        print("[WARN] get_angles 失敗: {}".format(e))
    print("[POST] 実到達 = {}".format(["{:+.2f}".format(a) for a in post] if post else "読めず"))
    return post


def _detect_block():
    try:
        det = detect_positions(repeat=3)
        block_xz = det.get("block_xz_m")
        return list(block_xz) if block_xz else None
    except Exception as e:
        print("[WARN] block 検出失敗: {}".format(e))
        return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--seq", type=int, default=None, help="1..9 を1つだけ。未指定で全部")
    ap.add_argument("--home", action="store_true", help="最後にHOMEへ戻す")
    args = ap.parse_args()

    seqs = [args.seq] if args.seq else sorted(POSES.keys())

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    out_csv = OUT_DIR / "replay_simple_{}.csv".format(ts)
    cols = ["seq", "block_x_mm", "block_z_mm",
            "post_j1", "post_j2", "post_j3", "post_j4", "post_j5", "post_j6",
            "tgt_j1", "tgt_j2", "tgt_j3", "tgt_j4", "tgt_j5", "tgt_j6"]

    get_daemon_socket()
    try:
        daemon_set_fresh_mode(1)
    except Exception:
        pass

    rows = []
    try:
        # まずHOMEから始める(安全な既知姿勢へ)
        _send(HOME, "to_home")
        for s in seqs:
            tgt = POSES[s]
            print("\n===== seq={} =====".format(s))
            post = _send(tgt, "seq{}".format(s))
            blk = _detect_block()
            bx = round(blk[0] * 1000, 1) if blk else ""
            bz = round(blk[1] * 1000, 1) if blk else ""
            print("[BLOCK] seq={} block_xz_mm = ({}, {})".format(s, bx, bz))
            row = {"seq": s, "block_x_mm": bx, "block_z_mm": bz}
            for i in range(6):
                row["post_j{}".format(i + 1)] = round(post[i], 2) if post else ""
                row["tgt_j{}".format(i + 1)] = tgt[i]
            rows.append(row)
        if args.home:
            _send(HOME, "to_home_end")
    finally:
        with open(out_csv, "w", newline="") as f:
            w = csv.DictWriter(f, fieldnames=cols)
            w.writeheader()
            w.writerows(rows)
        print("\n[CSV] {}".format(out_csv))
        try:
            daemon_set_fresh_mode(0)
        except Exception:
            pass
        close_daemon_socket()


if __name__ == "__main__":
    main()

replay_z_postures.py

最初に作った再生版。本線の観測系を流用し到達確認や安全ガードを備えたが、教示姿勢に到達できず、目的に対して重すぎたため簡素版に作り直した。作り直しの過程の記録として残す。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""replay_z_postures.py — 教示 Z 姿勢の再生・Z実測(1 姿勢ずつ単発・本線非改変)

ドラッグティーチで取得した 9 把持姿勢(KIOXIA pick_z_postures_DAY243)を
1 姿勢ずつ再生し、到達・爪先 Z・把持可否を本線観測系で実測する。

絶対ルール(コードでも守る):
- 新規スクリプト。run_pick_place.py / fk_scan_motion_based_v2.csv は一切改変しない
- send_angles のみ。send_coords / release_all_servos は呼ばない
- Stiffness / Damping を触らない
- 1 姿勢ずつ単発実行。9 連続自動再生はしない(--seq N で 1 本のみ)
- 到達確認必須。失敗時は掴まず HOME 復帰
- 掴んだら停止(持ち上げは別コマンド recover-home)
- KIOXIA 未マウントなら出力前に中断(ローカルへフォールバック書き込みなし)

CLI:
  # 単発再生(実機)
  python3 replay_z_postures.py run --seq 1
  python3 replay_z_postures.py run --seq 5 --yes              # 大移動プロンプト自動 yes
  python3 replay_z_postures.py run --seq 9 --dry-run          # 送信なし・計画のみ

  # 掴んだ後の HOME 復帰(人の目視確認後)
  python3 replay_z_postures.py recover-home
  python3 replay_z_postures.py recover-home --release         # 復帰前に gripper 開放

入力:
  /Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243/
    pick_z_postures_index.csv (列: seq, src_file, close_idx, j1..j6, count, note)

出力:
  /Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243/replay/
    seq{N}_{timestamp}.json          # 1 試行の全結果
    seq{N}_{timestamp}_tip.jpg       # 撮影フレーム(_measure_tip_vs_block 用)
    replay_index.csv                 # 追記式・横断索引
"""
from __future__ import print_function

import argparse
import csv
import json
import math
import sys
import time
from datetime import datetime
from pathlib import Path

# ---- パス追加 ---------------------------------------------------------------
_THIS = Path(__file__).resolve()
_SCRIPTS_ROOT = _THIS.parents[2]                   # ~/r2r2r-scripts
_PLACE_MODULES = _THIS.parents[1] / "place" / "place_modules"
sys.path.insert(0, str(_SCRIPTS_ROOT))
sys.path.insert(0, str(_PLACE_MODULES))

# ---- 既存モジュールから import(読み取り import のみ・改変しない)---------
import cv2  # noqa: E402
import numpy as np  # noqa: E402

# 本線 visual_servo_pick の daemon ヘルパと検出関数(読み取り import)
from visual_servo_pick import (  # noqa: E402
    get_daemon_socket,
    close_daemon_socket,
    daemon_send_angles,
    daemon_get_angles,
    daemon_set_gripper,
    daemon_set_fresh_mode,
)
from camera_resolver import resolve_cameras  # noqa: E402

# detect_positions は KIOXIA homography セットアップ用(副作用で _homography 設定)
from detect_positions_once import detect_positions  # noqa: E402

# 爪先 vs block 差分測定は本線 run_pick_place の確定実装を読み取り import
# (関数定義のみ取り込む。実行時状態は触らない。run_pick_place.py 自体は無改変)
from run_pick_place import _measure_tip_vs_block  # noqa: E402

# ---- 定数(本線 run_pick_place と整合・コピー値)---------------------------
HOME = [0, 0, 0, 0, 0, -30]
SPEED = 20
TOL_HOME = 2.5                       # HOME 到達判定
TOL_PICK_PLACE = 3.0                 # 把持姿勢到達判定(本線 pick/place と同値)
SEND_WAIT_SEC = 1.0                  # daemon 送信直後のログ遅延吸収
ARRIVE_TIMEOUT_S = 12.0              # 到達 polling タイムアウト
ARRIVE_POLL_S = 0.2
GRIPPER_SLEEP = 2.0                  # DAY175 対策(≥2s)
LARGE_MOVE_WARN_DEG = 60.0           # この max|Δ| 超で大移動警告

# カメラフレーム取得(_capture_for_vlm と同値)
VLM_CAPTURE_WARMUP_FRAMES = 30
VLM_CAPTURE_DROP_FRAMES = 5

DETECT_REPEAT = 3                    # block 検出回数(中央値集約)

# ---- KIOXIA 出力先 ----------------------------------------------------------
CALIB_DIR = Path("/Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243")
INDEX_CSV = CALIB_DIR / "pick_z_postures_index.csv"
REPLAY_DIR = CALIB_DIR / "replay"
REPLAY_INDEX_CSV = REPLAY_DIR / "replay_index.csv"

REPLAY_INDEX_COLS = [
    "timestamp", "seq", "src_file",
    "arrived", "max_residual_deg",
    "block_x_mm", "block_z_mm",
    "tip_x_mm", "tip_z_mm",
    "dx_mm", "dz_mm", "dist_mm",
    "gripped", "note",
]


# ============================================================================
# ガード・ユーティリティ
# ============================================================================
def ensure_ssd_mounted():
    """KIOXIA pick_z_postures_DAY243 が存在・書込可なら True. ローカルへ
    フォールバック書き込みはしない."""
    try:
        if not CALIB_DIR.exists() or not CALIB_DIR.is_dir():
            return False
        test = CALIB_DIR / ".write_test_replay"
        try:
            test.write_text("")
            test.unlink()
            return True
        except Exception:
            return False
    except Exception:
        return False


def confirm(prompt, auto_yes):
    """[Y/Enter] 待ち. auto_yes=True なら即 True. EOF/n で False."""
    if auto_yes:
        print("  → auto-yes: 続行")
        return True
    try:
        ans = input(prompt + " [Enter で続行 / 'n' で中断]: ").strip().lower()
    except EOFError:
        print("  → EOF: 中断")
        return False
    return ans != "n"


def fmt_angles(a):
    if a is None:
        return "None"
    return "[" + ", ".join("{:+.2f}".format(x) for x in a) + "]"


def max_delta(target, actual):
    if target is None or actual is None:
        return None
    if len(target) != 6 or len(actual) != 6:
        return None
    return max(abs(actual[i] - target[i]) for i in range(6))


def get_angles_safe(max_retry=8, retry_sleep=0.15):
    """daemon_get_angles を retry. 6 float リスト or None."""
    for _ in range(max_retry):
        try:
            a = daemon_get_angles()
        except Exception:
            a = None
        if a is not None:
            try:
                if len(a) == 6:
                    return [float(x) for x in a]
            except (TypeError, ValueError):
                pass
        time.sleep(retry_sleep)
    return None


def send_and_wait(target_angles, label, tol):
    """send_angles → polling で到達確認.
    戻り値: (arrived: bool, post_actual: list|None, max_residual_deg: float|None)
    sleep / retry / 制御フローは本線と独立だが、_send_and_wait_arrival と同形.
    """
    try:
        daemon_send_angles(target_angles, speed=SPEED, wait=0)
    except Exception as e:
        print("[SEND] [ERROR] daemon_send_angles 例外 ({}): {}".format(label, e))
        return False, None, None
    time.sleep(SEND_WAIT_SEC)
    t0 = time.time()
    last_actual = None
    while time.time() - t0 < ARRIVE_TIMEOUT_S:
        a = get_angles_safe()
        if a is not None:
            last_actual = a
            max_d = max(abs(a[i] - target_angles[i]) for i in range(6))
            if max_d <= tol:
                print("[SEND] {} arrived max|Δ|={:.2f}° (elapsed {:.1f}s)".format(
                    label, max_d, time.time() - t0))
                return True, a, max_d
        time.sleep(ARRIVE_POLL_S)
    max_d = max_delta(target_angles, last_actual)
    print("[SEND] {} TIMEOUT max|Δ|={}".format(
        label, "{:.2f}°".format(max_d) if max_d is not None else "None"))
    return False, last_actual, max_d


def capture_for_tip(out_jpg_path):
    """上カメラ 1 フレーム取得 → JPG 保存. 返り値: 保存パス or None.
    _capture_for_vlm の流儀踏襲(warm-up + drop + 1 read)."""
    try:
        cams = resolve_cameras(require_top=True, require_side=False)
    except Exception as e:
        print("[CAP] camera_resolver 例外: {}".format(e))
        return None
    top_idx = cams.get("top") if cams else None
    if top_idx is None:
        print("[CAP] 上カメラ未解決")
        return None
    cap = cv2.VideoCapture(top_idx)
    if not cap.isOpened():
        print("[CAP] cap open 失敗 index={}".format(top_idx))
        return None
    try:
        for _ in range(VLM_CAPTURE_WARMUP_FRAMES):
            cap.read()
        for _ in range(VLM_CAPTURE_DROP_FRAMES):
            cap.read()
        ret, frame = cap.read()
        if not ret or frame is None:
            print("[CAP] フレーム取得失敗")
            return None
        if not cv2.imwrite(str(out_jpg_path), frame):
            print("[CAP] imwrite 失敗: {}".format(out_jpg_path))
            return None
        print("[CAP] 保存: {}".format(out_jpg_path))
        return out_jpg_path
    finally:
        try:
            cap.release()
        except Exception:
            pass


# ============================================================================
# 索引 CSV 読み込み
# ============================================================================
def load_pose(seq):
    """index.csv から seq の j1..j6 を float リストで返す.
    src_file / note も同時に返す. 見つからなければ None."""
    if not INDEX_CSV.exists():
        print("[LOAD] [ABORT] index.csv が存在しない: {}".format(INDEX_CSV))
        return None
    with open(INDEX_CSV) as f:
        r = csv.DictReader(f)
        for row in r:
            try:
                if int(row["seq"]) == seq:
                    angles = [float(row[k]) for k in ["j1","j2","j3","j4","j5","j6"]]
                    return {
                        "seq": seq,
                        "src_file": row.get("src_file", ""),
                        "angles": angles,
                        "note": row.get("note", ""),
                    }
            except (ValueError, KeyError):
                continue
    print("[LOAD] [ABORT] seq={} が index.csv に無い".format(seq))
    return None


# ============================================================================
# replay_index.csv 追記
# ============================================================================
def append_replay_index(record):
    is_new = not REPLAY_INDEX_CSV.exists()
    with open(REPLAY_INDEX_CSV, "a", newline="") as f:
        w = csv.DictWriter(f, fieldnames=REPLAY_INDEX_COLS)
        if is_new:
            w.writeheader()
        w.writerow(record)
    print("[INDEX] append OK: {}".format(REPLAY_INDEX_CSV))


def save_replay_json(seq, ts, payload):
    out_path = REPLAY_DIR / "seq{}_{}.json".format(seq, ts)
    with open(out_path, "w") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2, default=str)
    print("[JSON] {}".format(out_path))
    return str(out_path)


# ============================================================================
# サブコマンド: run
# ============================================================================
def cmd_run(seq, dry_run, auto_yes):
    print("=" * 70)
    print("[INIT] replay_z_postures.py run --seq {} dry_run={}".format(seq, dry_run))
    print("=" * 70)

    # 1. KIOXIA ガード
    if not ensure_ssd_mounted():
        print("[ABORT] KIOXIA {} 未マウント or 書込不可。"
              "出力前に中断します(ローカルへフォールバックなし)".format(CALIB_DIR))
        return 2

    # 2. seq 読み込み
    pose = load_pose(seq)
    if pose is None:
        return 2

    target_angles = pose["angles"]
    print("[POSE ] seq={}  src={}".format(seq, pose["src_file"]))
    print("[POSE ] target {} = {}".format(["j1","j2","j3","j4","j5","j6"], fmt_angles(target_angles)))

    # 3. 安全警告: seq=9 は他と異質
    if seq == 9:
        print("\n[SAFETY] seq=9 (191313) は教示時に j1 増・j5 浅で他と異質。")
        print("[SAFETY] ベース寄り最深 Z で破綻領域の可能性。最初に回さない推奨。")
        if not confirm("[SAFETY] それでも続行しますか", auto_yes):
            print("[ABORT] ユーザ中断")
            return 1

    # 4. 出力ディレクトリ準備
    REPLAY_DIR.mkdir(parents=True, exist_ok=True)

    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    record = {
        "timestamp": datetime.now().isoformat(timespec="seconds"),
        "seq": seq,
        "src_file": pose["src_file"],
        "target_angles": target_angles,
        "home_arrived": None,
        "detect": None,
        "send_to_target": None,
        "tip": None,
        "gripped": False,
        "aborted_at": None,
        "abort_reason": None,
        "dry_run": dry_run,
    }

    if dry_run:
        print("\n[DRY  ] 送信せず計画のみ:")
        print("[DRY  ] 1) HOME へ移動 (tol={})".format(TOL_HOME))
        print("[DRY  ] 2) block 検出(detect_positions, repeat={})".format(DETECT_REPEAT))
        print("[DRY  ] 3) 教示姿勢へ移動 target={} (tol={})".format(
            fmt_angles(target_angles), TOL_PICK_PLACE))
        print("[DRY  ] 4) 上カメラ 1 フレーム → _measure_tip_vs_block で爪先 vs block")
        print("[DRY  ] 5) グリッパー閉 (sleep={}s)".format(GRIPPER_SLEEP))
        print("[DRY  ] 6) 持ち上げず停止。recover-home サブコマンドへ案内")
        record["dry_run_plan_ok"] = True
        save_replay_json(seq, ts, record)
        return 0

    # 5. daemon 接続
    print("\n[INIT] daemon 接続中 ...")
    try:
        get_daemon_socket()
    except Exception as e:
        print("[ABORT] daemon ping 失敗: {}".format(e))
        return 2
    try:
        daemon_set_fresh_mode(1)
        print("[INIT] fresh_mode=1")
    except Exception as e:
        print("[WARN] set_fresh_mode(1) 例外: {}(continue)".format(e))
    try:
        daemon_set_gripper(0)
        time.sleep(GRIPPER_SLEEP)
        print("[INIT] gripper open + sleep {:.1f}s".format(GRIPPER_SLEEP))
    except Exception as e:
        print("[WARN] gripper open 例外: {}(continue)".format(e))

    final_return = 0
    try:
        # 6. HOME 移動(大移動警告つき)
        print("\n[STEP1] HOME 移動")
        cur = get_angles_safe()
        md = max_delta(HOME, cur)
        print("[STEP1] 現在 {} → HOME 大|Δ|={}".format(
            fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
        if md is None:
            # 現在角度が読めない=状態不明。大移動か否か判定不能なので送信しない.
            # --yes 指定時のみ「明示確認済み」として続行を許可(運用の逃げ道).
            if not auto_yes:
                print("[ABORT] 現在角度を取得できず大移動判定不能 (HOME 前)。"
                      "状態不明のまま送信しません(--yes で明示続行可)")
                record["aborted_at"] = "before_home"
                record["abort_reason"] = "current_angles_unreadable"
                save_replay_json(seq, ts, record)
                return 1
            print("[WARN] 現在角度不明だが --yes で続行")
        elif md > LARGE_MOVE_WARN_DEG:
            if not confirm("[WARN] 大移動 (>{:.0f}°) です".format(LARGE_MOVE_WARN_DEG), auto_yes):
                print("[ABORT] ユーザ中断 (HOME 前)")
                record["aborted_at"] = "before_home"
                record["abort_reason"] = "user_cancelled_large_move"
                save_replay_json(seq, ts, record)
                return 1
        arrived_h, post_h, max_d_h = send_and_wait(HOME, "replay_to_home", TOL_HOME)
        record["home_arrived"] = {"arrived": bool(arrived_h),
                                   "post_actual": post_h,
                                   "max_residual_deg": max_d_h}
        if not arrived_h:
            print("[ABORT] HOME 未到達 max|Δ|={}".format(max_d_h))
            record["aborted_at"] = "home"
            record["abort_reason"] = "home_arrival_failed"
            save_replay_json(seq, ts, record)
            return 1

        # 7. block 検出(homography セットアップも兼ねる)
        print("\n[STEP2] block 検出 detect_positions(repeat={})".format(DETECT_REPEAT))
        try:
            det = detect_positions(repeat=DETECT_REPEAT)
        except Exception as e:
            print("[WARN] detect_positions 例外: {}(continue・block_xz=None)".format(e))
            det = {"block_xz_m": None, "box_xz_m": None, "detect_score": {}}
        block_xz = det.get("block_xz_m")
        record["detect"] = {
            "block_xz_m": block_xz,
            "box_xz_m": det.get("box_xz_m"),
            "detect_score": det.get("detect_score"),
        }
        print("[STEP2] block_xz_m = {}".format(block_xz))
        if block_xz is None:
            print("[STEP2] [NOTE] block 未検出。tip 差分は dx/dz 不能になる可能性"
                  "(tip 単独座標は記録)")

        # 8. 目標姿勢へ移動
        print("\n[STEP3] 教示姿勢 seq={} へ移動".format(seq))
        cur = get_angles_safe()
        md = max_delta(target_angles, cur)
        print("[STEP3] 現在 {} → target 大|Δ|={}".format(
            fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
        if md is None:
            # 現在角度が読めない=状態不明。target は把持姿勢で危険性が大きいため
            # 通常は中断。--yes 指定時のみ続行を許可.
            if not auto_yes:
                print("[ABORT] 現在角度を取得できず大移動判定不能 (target 前)。"
                      "状態不明のまま把持姿勢へ送信しません(--yes で明示続行可)")
                record["aborted_at"] = "before_target"
                record["abort_reason"] = "current_angles_unreadable"
                save_replay_json(seq, ts, record)
                return 1
            print("[WARN] 現在角度不明だが --yes で続行")
        elif md > LARGE_MOVE_WARN_DEG:
            if not confirm("[WARN] 大移動 (>{:.0f}°) です".format(LARGE_MOVE_WARN_DEG), auto_yes):
                print("[ABORT] ユーザ中断 (target 前). HOME 復帰へ.")
                arrived_h2, _, _ = send_and_wait(HOME, "abort_to_home", TOL_HOME)
                record["aborted_at"] = "before_target"
                record["abort_reason"] = "user_cancelled_large_move"
                save_replay_json(seq, ts, record)
                return 1
        arrived, post, max_d_t = send_and_wait(
            target_angles, "replay_seq{}".format(seq), TOL_PICK_PLACE)
        record["send_to_target"] = {
            "arrived": bool(arrived),
            "post_actual": post,
            "max_residual_deg": max_d_t,
            "residuals_per_joint": ([round(post[i] - target_angles[i], 3) for i in range(6)]
                                     if post is not None else None),
        }
        if not arrived:
            print("[STEP3] [ABORT] 未到達 max|Δ|={}. 掴まず HOME 復帰へ.".format(max_d_t))
            record["aborted_at"] = "target"
            record["abort_reason"] = "target_arrival_failed"
            arrived_h2, post_h2, _ = send_and_wait(HOME, "abort_to_home", TOL_HOME)
            record["recover_home"] = {"arrived": bool(arrived_h2), "post_actual": post_h2}
            save_replay_json(seq, ts, record)
            final_return = 1
            # ledger 記録
            _write_replay_index_row(record, gripped=False, note="target_arrival_failed")
            return final_return

        # 9. 上カメラ撮影 → 爪先 vs block 測定
        print("\n[STEP4] 上カメラ撮影 + 爪先 (青シール) 測定")
        jpg_path = REPLAY_DIR / "seq{}_{}_tip.jpg".format(seq, ts)
        captured = capture_for_tip(jpg_path)
        tip_diff = _measure_tip_vs_block(captured) if captured else None
        record["tip"] = {
            "image": str(captured) if captured else None,
            "diff": tip_diff,
        }
        if tip_diff is None:
            print("[STEP4] tip 測定スキップ(検出不足 or 前提破綻)。"
                  "把持に進む(ユーザ判断)")
        else:
            print("[STEP4] tip=({:+.4f},{:+.4f}) block=({:+.4f},{:+.4f}) "
                  "dx={:+.1f}mm dz={:+.1f}mm dist={:.1f}mm".format(
                      tip_diff["tip_x"], tip_diff["tip_z"],
                      tip_diff["block_x"], tip_diff["block_z"],
                      tip_diff["dx_mm"], tip_diff["dz_mm"], tip_diff["dist_mm"]))

        # 10. グリッパー閉(DAY175: sleep ≥2s)
        print("\n[STEP5] グリッパー閉")
        try:
            daemon_set_gripper(1)
            time.sleep(GRIPPER_SLEEP)
            record["gripped"] = True
            print("[STEP5] gripper close + sleep {:.1f}s".format(GRIPPER_SLEEP))
        except Exception as e:
            print("[STEP5] [ERROR] gripper close 例外: {}".format(e))
            record["gripped"] = False

        # 11. 停止(持ち上げない)
        print()
        print("=" * 70)
        print("[HOLD ] 把持完了。掴んだ状態で停止します。")
        print("[HOLD ] 持ち上げ可否を **目視確認** してください。")
        print("[HOLD ] 安全なら別コマンドで HOME 復帰してください:")
        print("        python3 replay_z_postures.py recover-home")
        print("        python3 replay_z_postures.py recover-home --release   "
              "# 復帰前に gripper 開放")
        print("[HOLD ] 危険なら手で支えながら別途処置してください。")
        print("=" * 70)
        record["aborted_at"] = None
        record["abort_reason"] = None

        # ledger 記録(grip 成功で停止)
        _write_replay_index_row(record, gripped=record["gripped"],
                                  note="held_at_target")
        save_replay_json(seq, ts, record)

    except KeyboardInterrupt:
        print("\n[INTERRUPT] Ctrl-C 検出")
        record["aborted_at"] = record.get("aborted_at") or "interrupted"
        record["abort_reason"] = "KeyboardInterrupt"
        save_replay_json(seq, ts, record)
        final_return = 1
    finally:
        # 注意: 持ち上げ・HOME 復帰は finally で自動実行しない(仕様)
        # daemon socket は明示クローズしない(recover-home 待ち中も繋いだまま運用可能)
        try:
            daemon_set_fresh_mode(0)
        except Exception:
            pass

    return final_return


def _write_replay_index_row(record, gripped, note):
    """record dict から replay_index.csv 1 行を組立て append."""
    block_xz = (record.get("detect") or {}).get("block_xz_m") or (None, None)
    tip_diff = (record.get("tip") or {}).get("diff") or {}
    send = record.get("send_to_target") or {}
    def mm(v, scale=1000.0):
        return "{:.1f}".format(v * scale) if isinstance(v, (int, float)) else ""
    row = {
        "timestamp": record.get("timestamp", ""),
        "seq": record.get("seq", ""),
        "src_file": record.get("src_file", ""),
        "arrived": str(send.get("arrived")) if send.get("arrived") is not None else "",
        "max_residual_deg": ("{:.2f}".format(send["max_residual_deg"])
                              if isinstance(send.get("max_residual_deg"), (int, float))
                              else ""),
        "block_x_mm": mm(block_xz[0] if block_xz else None),
        "block_z_mm": mm(block_xz[1] if block_xz else None),
        "tip_x_mm":   mm(tip_diff.get("tip_x")),
        "tip_z_mm":   mm(tip_diff.get("tip_z")),
        "dx_mm":      "{:.1f}".format(tip_diff["dx_mm"]) if "dx_mm" in tip_diff else "",
        "dz_mm":      "{:.1f}".format(tip_diff["dz_mm"]) if "dz_mm" in tip_diff else "",
        "dist_mm":    "{:.1f}".format(tip_diff["dist_mm"]) if "dist_mm" in tip_diff else "",
        "gripped":    str(gripped),
        "note":       note,
    }
    try:
        append_replay_index(row)
    except Exception as e:
        print("[INDEX] [WARN] append 例外(制御は継続): {}".format(e))


# ============================================================================
# サブコマンド: recover-home
# ============================================================================
def cmd_recover_home(release, auto_yes):
    print("=" * 70)
    print("[INIT] replay_z_postures.py recover-home release={}".format(release))
    print("=" * 70)
    if not ensure_ssd_mounted():
        print("[WARN] KIOXIA 未マウント(ログ書き出せないが復帰は実施可)。"
              "復帰しますか?")
        if not confirm("", auto_yes):
            return 1

    try:
        get_daemon_socket()
    except Exception as e:
        print("[ABORT] daemon ping 失敗: {}".format(e))
        return 2
    try:
        daemon_set_fresh_mode(1)
    except Exception:
        pass

    if release:
        print("[RECOVER] gripper 開放 (release=True)")
        try:
            daemon_set_gripper(0)
            time.sleep(GRIPPER_SLEEP)
        except Exception as e:
            print("[RECOVER] [WARN] gripper 開放例外: {}(continue)".format(e))

    cur = get_angles_safe()
    md = max_delta(HOME, cur)
    print("[RECOVER] 現在 {} → HOME 大|Δ|={}".format(
        fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
    if md is None:
        # 現在角度が読めない=状態不明。recover は把持中の HOME 復帰なので
        # 通常は中断。--yes 指定時のみ続行を許可.
        if not auto_yes:
            print("[ABORT] 現在角度を取得できず大移動判定不能。"
                  "状態不明のまま HOME 復帰送信しません(--yes で明示続行可)")
            return 1
        print("[WARN] 現在角度不明だが --yes で続行")
    elif md > LARGE_MOVE_WARN_DEG:
        if not confirm("[WARN] 大移動 (>{:.0f}°) です".format(LARGE_MOVE_WARN_DEG), auto_yes):
            print("[ABORT] ユーザ中断")
            return 1
    arrived, post, max_d = send_and_wait(HOME, "recover_to_home", TOL_HOME)
    if arrived:
        print("[RECOVER] HOME 到達 OK max|Δ|={:.2f}°".format(max_d))
    else:
        print("[RECOVER] [WARN] HOME 未到達 max|Δ|={}".format(max_d))

    try:
        daemon_set_fresh_mode(0)
    except Exception:
        pass
    try:
        close_daemon_socket()
    except Exception:
        pass
    return 0 if arrived else 1


# ============================================================================
# main
# ============================================================================
def main():
    ap = argparse.ArgumentParser(
        description="教示Z姿勢の再生・Z実測(1姿勢ずつ単発・本線非改変)")
    sub = ap.add_subparsers(dest="cmd")
    sub.required = True

    p_run = sub.add_parser("run", help="1 姿勢 (--seq) を再生して把持し停止")
    p_run.add_argument("--seq", type=int, required=True, help="姿勢番号 (1〜9)")
    p_run.add_argument("--dry-run", action="store_true",
                       help="送信せず計画のみ表示")
    p_run.add_argument("--yes", action="store_true",
                       help="大移動・seq=9 警告を自動 yes")

    p_rec = sub.add_parser("recover-home", help="HOME へ復帰(持ち上げ可否目視確認後)")
    p_rec.add_argument("--release", action="store_true",
                       help="復帰前に gripper 開放")
    p_rec.add_argument("--yes", action="store_true", help="警告を自動 yes")

    args = ap.parse_args()

    if args.cmd == "run":
        if not (1 <= args.seq <= 9):
            print("[ABORT] --seq は 1〜9 の整数")
            sys.exit(2)
        sys.exit(cmd_run(args.seq, args.dry_run, args.yes))
    elif args.cmd == "recover-home":
        sys.exit(cmd_recover_home(args.release, args.yes))


if __name__ == "__main__":
    main()

grasp_lift_test.py(教示姿勢で掴んで持ち上げる)

教示姿勢で掴み、通常の APPROACH 上空へ戻す。LIFT 未到達なら掴んだまま停止し、回収は別コマンドで行う。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""grasp_lift_test.py — 教示姿勢で掴み→通常APPROACH上空へ戻す動作テスト
                       (新規・本線非改変・1 姿勢ずつ単発)

stage6 で今日破綻した「奥の折りたたみ姿勢 → 上空復帰」を意図的に再現し、
seq ごとにどこで破綻するか・通るかの境界を可視化する。

絶対ルール(コードでも守る):
- 新規スクリプト。run_pick_place.py / fk_csv / Stiffness / Damping は触らない
- send_angles のみ。send_coords / release_all_servos は呼ばない
- 1 姿勢ずつ単発実行。自動連続なし
- APPROACH 復帰(LIFT)が未到達なら、掴んだまま安全停止する
- KIOXIA 未マウントなら出力前に中断(ローカルへフォールバックなし)

CLI:
  python3 grasp_lift_test.py run --seq 5
  python3 grasp_lift_test.py run --seq 9 --yes              # 大移動プロンプト自動 yes
  python3 grasp_lift_test.py run --seq 3 --dry-run          # 送信なし・計画のみ

  python3 grasp_lift_test.py recover-home
  python3 grasp_lift_test.py recover-home --release         # 復帰前に gripper 開放

入力:
  教示姿勢 j1..j6 は本コードに埋め込み(pose_z_map.csv の seq2..9)。
  seq1 は j2=93° で物理上限超過のため対象外(KeyError でガード)。

出力:
  /Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243/grasp_lift_test/
    seq{N}_{timestamp}.json
    grasp_lift_index.csv
"""
from __future__ import print_function

import argparse
import csv
import json
import sys
import time
from datetime import datetime
from pathlib import Path

# ---- パス追加 ---------------------------------------------------------------
_THIS = Path(__file__).resolve()
_SCRIPTS_ROOT = _THIS.parents[2]
_PLACE_MODULES = _THIS.parents[1] / "place" / "place_modules"
sys.path.insert(0, str(_SCRIPTS_ROOT))
sys.path.insert(0, str(_PLACE_MODULES))

# ---- daemon helpers と検出関数(読み取り import のみ)-----------------------
from visual_servo_pick import (  # noqa: E402
    get_daemon_socket,
    close_daemon_socket,
    daemon_send_angles,
    daemon_get_angles,
    daemon_set_gripper,
    daemon_set_fresh_mode,
)
from detect_positions_once import detect_positions  # noqa: E402


# ---- 定数 -------------------------------------------------------------------
HOME = [0, 0, 0, 0, 0, -30]
SPEED = 20

TOL_HOME = 2.5
TOL_PICK = 3.0       # 教示姿勢到達判定
TOL_LIFT = 3.0       # 通常 APPROACH 上空 到達判定

SEND_WAIT_SEC = 1.0
ARRIVE_TIMEOUT_S = 12.0
ARRIVE_POLL_S = 0.2
GRIPPER_SLEEP = 2.0  # DAY175 対策(>=2s)
LARGE_MOVE_WARN_DEG = 60.0

DETECT_REPEAT = 3

# 通常 APPROACH 上空姿勢の j1..j5(j0 = base swing は seq の post_j1 を引き継ぐ)
# 構造: [<seq_post_j1>, 87.74, -38.33, 3.60, -70.11, -30.00]
LIFT_J2 = 87.74
LIFT_J3 = -38.33
LIFT_J4 = 3.60
LIFT_J5 = -70.11
LIFT_J6 = -30.00


# 教示9姿勢(pose_z_map.csv の seq2..9 の tgt_j1..j6 をコピー)
# seq1 は j2=93.16° で j2 物理上限 ~91° を超え rejected 確定のため対象外。
POSES = {
    2: [-45.35, 90.70,  -6.94,  0.87, -86.48, -29.53],
    3: [-50.88, 89.73,   0.00, -3.60, -91.66, -30.14],
    4: [-55.45, 85.95,   5.88, -6.85, -91.58, -29.79],
    5: [-55.89, 81.65,  13.53, -5.18, -91.84, -29.88],
    6: [-56.16, 77.34,  20.91, -1.93, -91.93, -29.00],
    7: [-64.07, 77.78,  20.30,-10.28, -92.37, -33.39],
    8: [-65.74, 70.13,  28.38, -6.32, -88.76, -30.84],
    9: [-63.98, 59.85,  35.68,  3.42, -78.39, -29.00],
}


# ---- KIOXIA 出力先 ---------------------------------------------------------
CALIB_DIR = Path("/Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243")
OUT_DIR = CALIB_DIR / "grasp_lift_test"
INDEX_CSV = OUT_DIR / "grasp_lift_index.csv"

INDEX_COLS = [
    "timestamp", "seq",
    "home_pre_arrived", "home_pre_max_deg",
    "block_x_mm", "block_z_mm",
    "target_arrived", "target_max_deg",
    "target_post_j1", "target_post_j2", "target_post_j3",
    "target_post_j4", "target_post_j5", "target_post_j6",
    "gripped",
    "lift_arrived", "lift_max_deg",
    "lift_post_j1", "lift_post_j2", "lift_post_j3",
    "lift_post_j4", "lift_post_j5", "lift_post_j6",
    "home_post_arrived", "home_post_max_deg",
    "aborted_at", "abort_reason", "note",
]


# ============================================================================
# ガード・ユーティリティ
# ============================================================================
def ensure_ssd_mounted():
    try:
        if not CALIB_DIR.exists() or not CALIB_DIR.is_dir():
            return False
        test = CALIB_DIR / ".write_test_grasp_lift"
        try:
            test.write_text("")
            test.unlink()
            return True
        except Exception:
            return False
    except Exception:
        return False


def confirm(prompt, auto_yes):
    if auto_yes:
        print("  → auto-yes: 続行")
        return True
    try:
        ans = input(prompt + " [Enter で続行 / 'n' で中断]: ").strip().lower()
    except EOFError:
        print("  → EOF: 中断")
        return False
    return ans != "n"


def fmt_angles(a):
    if a is None:
        return "None"
    return "[" + ", ".join("{:+.2f}".format(x) for x in a) + "]"


def max_delta(target, actual):
    if target is None or actual is None:
        return None
    if len(target) != 6 or len(actual) != 6:
        return None
    return max(abs(actual[i] - target[i]) for i in range(6))


def get_angles_safe(max_retry=8, retry_sleep=0.15):
    for _ in range(max_retry):
        try:
            a = daemon_get_angles()
        except Exception:
            a = None
        if a is not None:
            try:
                if len(a) == 6:
                    return [float(x) for x in a]
            except (TypeError, ValueError):
                pass
        time.sleep(retry_sleep)
    return None


def send_and_wait(target_angles, label, tol):
    """send_angles → polling. 戻り値: (arrived, post_actual, max_residual)."""
    try:
        daemon_send_angles(target_angles, speed=SPEED, wait=0)
    except Exception as e:
        print("[SEND] [ERROR] daemon_send_angles 例外 ({}): {}".format(label, e))
        return False, None, None
    time.sleep(SEND_WAIT_SEC)
    t0 = time.time()
    last_actual = None
    while time.time() - t0 < ARRIVE_TIMEOUT_S:
        a = get_angles_safe()
        if a is not None:
            last_actual = a
            md = max(abs(a[i] - target_angles[i]) for i in range(6))
            if md <= tol:
                print("[SEND] {} arrived max|Δ|={:.2f}° (elapsed {:.1f}s)".format(
                    label, md, time.time() - t0))
                return True, a, md
        time.sleep(ARRIVE_POLL_S)
    md = max_delta(target_angles, last_actual)
    print("[SEND] {} TIMEOUT max|Δ|={}".format(
        label, "{:.2f}°".format(md) if md is not None else "None"))
    return False, last_actual, md


# ============================================================================
# index 追記
# ============================================================================
def _idx_val(v, fmt=None):
    if v is None:
        return ""
    if fmt and isinstance(v, (int, float)):
        return fmt.format(v)
    return v


def append_index_row(record):
    is_new = not INDEX_CSV.exists()
    send_t = record.get("target") or {}
    send_l = record.get("lift") or {}
    send_h2 = record.get("home_post") or {}
    home_pre = record.get("home_pre") or {}
    block = (record.get("detect") or {}).get("block_xz_m") or (None, None)
    tpost = send_t.get("post_actual") or [None] * 6
    lpost = send_l.get("post_actual") or [None] * 6

    def mm(v): return "{:.1f}".format(v * 1000) if isinstance(v, (int, float)) else ""

    row = {
        "timestamp": record.get("timestamp", ""),
        "seq": record.get("seq", ""),
        "home_pre_arrived": _idx_val(home_pre.get("arrived")),
        "home_pre_max_deg": _idx_val(home_pre.get("max_residual_deg"), "{:.2f}"),
        "block_x_mm": mm(block[0] if block else None),
        "block_z_mm": mm(block[1] if block else None),
        "target_arrived": _idx_val(send_t.get("arrived")),
        "target_max_deg": _idx_val(send_t.get("max_residual_deg"), "{:.2f}"),
        "gripped": _idx_val(record.get("gripped")),
        "lift_arrived": _idx_val(send_l.get("arrived")),
        "lift_max_deg": _idx_val(send_l.get("max_residual_deg"), "{:.2f}"),
        "home_post_arrived": _idx_val(send_h2.get("arrived")),
        "home_post_max_deg": _idx_val(send_h2.get("max_residual_deg"), "{:.2f}"),
        "aborted_at": _idx_val(record.get("aborted_at")),
        "abort_reason": _idx_val(record.get("abort_reason")),
        "note": _idx_val(record.get("note")),
    }
    for i in range(6):
        row["target_post_j{}".format(i + 1)] = (
            "{:.2f}".format(tpost[i]) if isinstance(tpost[i], (int, float)) else "")
        row["lift_post_j{}".format(i + 1)] = (
            "{:.2f}".format(lpost[i]) if isinstance(lpost[i], (int, float)) else "")
    with open(INDEX_CSV, "a", newline="") as f:
        w = csv.DictWriter(f, fieldnames=INDEX_COLS)
        if is_new:
            w.writeheader()
        w.writerow(row)
    print("[INDEX] append OK: {}".format(INDEX_CSV))


def save_record_json(seq, ts, payload):
    out = OUT_DIR / "seq{}_{}.json".format(seq, ts)
    with open(out, "w") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2, default=str)
    print("[JSON] {}".format(out))
    return str(out)


# ============================================================================
# サブコマンド: run
# ============================================================================
def cmd_run(seq, dry_run, auto_yes):
    print("=" * 70)
    print("[INIT] grasp_lift_test.py run --seq {} dry_run={}".format(seq, dry_run))
    print("=" * 70)

    if seq not in POSES:
        print("[ABORT] --seq {} は対象外 (有効: {})".format(seq, sorted(POSES.keys())))
        print("        seq1 は j2=93° で物理上限超過のため除外済み")
        return 2

    if not ensure_ssd_mounted():
        print("[ABORT] KIOXIA {} 未マウント or 書込不可。"
              "出力前に中断(ローカルへフォールバックなし)".format(CALIB_DIR))
        return 2

    target_angles = POSES[seq]
    print("[POSE ] seq={}  teaching target = {}".format(seq, fmt_angles(target_angles)))

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    record = {
        "timestamp": datetime.now().isoformat(timespec="seconds"),
        "seq": seq,
        "teaching_target_angles": target_angles,
        "lift_recipe": "[seq_post_j1, {}, {}, {}, {}, {}]".format(
            LIFT_J2, LIFT_J3, LIFT_J4, LIFT_J5, LIFT_J6),
        "home_pre": None,
        "detect": None,
        "target": None,
        "gripped": False,
        "lift": None,
        "home_post": None,
        "aborted_at": None,
        "abort_reason": None,
        "note": "",
        "dry_run": dry_run,
    }

    if dry_run:
        print("\n[DRY  ] 送信せず計画のみ:")
        print("[DRY  ] 1) HOME へ移動 (tol={})".format(TOL_HOME))
        print("[DRY  ] 2) gripper 開 (sleep={}s)".format(GRIPPER_SLEEP))
        print("[DRY  ] 3) block 検出 (detect_positions, repeat={})".format(DETECT_REPEAT))
        print("[DRY  ] 4) 教示姿勢へ移動 target={} (tol={}, 半端到達でも続行)".format(
            fmt_angles(target_angles), TOL_PICK))
        print("[DRY  ] 5) gripper 閉 (sleep={}s)".format(GRIPPER_SLEEP))
        print("[DRY  ] 6) LIFT へ移動 (tol={})".format(TOL_LIFT))
        print("[DRY  ]    target = [post_j1, {}, {}, {}, {}, {}]".format(
            LIFT_J2, LIFT_J3, LIFT_J4, LIFT_J5, LIFT_J6))
        print("[DRY  ]    未到達なら掴んだまま停止し recover-home へ案内")
        print("[DRY  ] 7) HOME へ復帰 (tol={}・LIFT 到達時のみ)".format(TOL_HOME))
        record["note"] = "dry_run_plan_ok"
        save_record_json(seq, ts, record)
        return 0

    # daemon 接続
    print("\n[INIT] daemon 接続中 ...")
    try:
        get_daemon_socket()
    except Exception as e:
        print("[ABORT] daemon ping 失敗: {}".format(e))
        return 2
    try:
        daemon_set_fresh_mode(1)
        print("[INIT] fresh_mode=1")
    except Exception as e:
        print("[WARN] set_fresh_mode(1) 例外: {}(continue)".format(e))

    final_return = 0
    try:
        # STEP1: HOME 移動(大移動ガード)
        print("\n[STEP1] HOME 移動")
        cur = get_angles_safe()
        md = max_delta(HOME, cur)
        print("[STEP1] 現在 {} → HOME 大|Δ|={}".format(
            fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
        if md is None:
            if not auto_yes:
                print("[ABORT] 現在角度を取得できず大移動判定不能 (HOME 前)。"
                      "状態不明のまま送信しません(--yes で明示続行可)")
                record["aborted_at"] = "before_home_pre"
                record["abort_reason"] = "current_angles_unreadable"
                save_record_json(seq, ts, record)
                append_index_row(record)
                return 1
            print("[WARN] 現在角度不明だが --yes で続行")
        elif md > LARGE_MOVE_WARN_DEG:
            if not confirm("[WARN] 大移動 (>{:.0f}°) です".format(LARGE_MOVE_WARN_DEG), auto_yes):
                print("[ABORT] ユーザ中断 (HOME 前)")
                record["aborted_at"] = "before_home_pre"
                record["abort_reason"] = "user_cancelled_large_move"
                save_record_json(seq, ts, record)
                append_index_row(record)
                return 1
        ah, ph, mdh = send_and_wait(HOME, "to_home_pre", TOL_HOME)
        record["home_pre"] = {"arrived": bool(ah), "post_actual": ph,
                              "max_residual_deg": mdh}
        if not ah:
            print("[ABORT] HOME 未到達 max|Δ|={}. 教示姿勢へは進まない".format(mdh))
            record["aborted_at"] = "home_pre"
            record["abort_reason"] = "home_pre_arrival_failed"
            save_record_json(seq, ts, record)
            append_index_row(record)
            return 1

        # STEP2: gripper 開
        print("\n[STEP2] gripper 開")
        try:
            daemon_set_gripper(0)
            time.sleep(GRIPPER_SLEEP)
            print("[STEP2] gripper open + sleep {:.1f}s".format(GRIPPER_SLEEP))
        except Exception as e:
            print("[STEP2] [WARN] gripper open 例外: {}(continue)".format(e))

        # STEP3: block 検出
        print("\n[STEP3] block 検出 detect_positions(repeat={})".format(DETECT_REPEAT))
        try:
            det = detect_positions(repeat=DETECT_REPEAT)
        except Exception as e:
            print("[WARN] detect_positions 例外: {}(continue・block_xz=None)".format(e))
            det = {"block_xz_m": None, "box_xz_m": None, "detect_score": {}}
        block_xz = det.get("block_xz_m")
        record["detect"] = {
            "block_xz_m": list(block_xz) if block_xz else None,
            "box_xz_m": list(det.get("box_xz_m")) if det.get("box_xz_m") else None,
            "detect_score": det.get("detect_score"),
        }
        print("[STEP3] block_xz_m = {}".format(block_xz))

        # STEP4: 教示姿勢へ移動(大移動ガード)
        print("\n[STEP4] 教示姿勢 seq={} へ移動".format(seq))
        cur = get_angles_safe()
        md = max_delta(target_angles, cur)
        print("[STEP4] 現在 {} → target 大|Δ|={}".format(
            fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
        if md is None:
            if not auto_yes:
                print("[ABORT] 現在角度を取得できず大移動判定不能 (target 前)。"
                      "把持姿勢へ送信しません(--yes で明示続行可)")
                record["aborted_at"] = "before_target"
                record["abort_reason"] = "current_angles_unreadable"
                save_record_json(seq, ts, record)
                append_index_row(record)
                return 1
            print("[WARN] 現在角度不明だが --yes で続行")
        elif md > LARGE_MOVE_WARN_DEG:
            if not confirm("[WARN] 大移動 (>{:.0f}°) です".format(LARGE_MOVE_WARN_DEG), auto_yes):
                print("[ABORT] ユーザ中断 (target 前)")
                record["aborted_at"] = "before_target"
                record["abort_reason"] = "user_cancelled_large_move"
                save_record_json(seq, ts, record)
                append_index_row(record)
                return 1
        at, pt, mdt = send_and_wait(
            target_angles, "to_teaching_seq{}".format(seq), TOL_PICK)
        record["target"] = {
            "arrived": bool(at), "post_actual": pt,
            "max_residual_deg": mdt,
            "residuals_per_joint": ([round(pt[i] - target_angles[i], 3) for i in range(6)]
                                     if pt is not None else None),
        }
        if not at:
            # 仕様: 未到達(半端含む)でも post を記録して掴みは試す
            print("[STEP4] 未到達 max|Δ|={}. 仕様により続行(半端到達でも掴み試行)"
                  .format(mdt))
        if pt is None:
            # post が読めない=j0 不明で LIFT が組めない。安全のため中断
            print("[ABORT] target 後の post_actual が読めず LIFT 目標を構成不能")
            record["aborted_at"] = "after_target_no_post"
            record["abort_reason"] = "post_actual_unreadable_after_target"
            save_record_json(seq, ts, record)
            append_index_row(record)
            return 1

        # STEP5: gripper 閉(DAY175 sleep>=2s)
        print("\n[STEP5] gripper 閉")
        try:
            daemon_set_gripper(1)
            time.sleep(GRIPPER_SLEEP)
            record["gripped"] = True
            print("[STEP5] gripper close + sleep {:.1f}s".format(GRIPPER_SLEEP))
        except Exception as e:
            print("[STEP5] [WARN] gripper close 例外: {}(continue)".format(e))
            record["gripped"] = False

        # STEP6: LIFT(通常 APPROACH 上空へ) ★ 今回の本命
        # base swing (j0=post_j1) は教示姿勢のところに残し、他5関節を APPROACH 上空姿勢へ
        lift_angles = [pt[0], LIFT_J2, LIFT_J3, LIFT_J4, LIFT_J5, LIFT_J6]
        print("\n[STEP6] LIFT (通常 APPROACH 上空へ) target = {}".format(fmt_angles(lift_angles)))
        al, pl, mdl = send_and_wait(lift_angles, "to_lift_seq{}".format(seq), TOL_LIFT)
        record["lift"] = {
            "target_angles": lift_angles,
            "arrived": bool(al), "post_actual": pl,
            "max_residual_deg": mdl,
            "residuals_per_joint": ([round(pl[i] - lift_angles[i], 3) for i in range(6)]
                                     if pl is not None else None),
        }
        if not al:
            # 仕様: LIFT 未到達なら掴んだまま停止。それ以上動かさない
            print()
            print("=" * 70)
            print("[HOLD ] LIFT未到達 max|Δ|={}. 掴んだまま停止。".format(
                "{:.2f}°".format(mdl) if mdl is not None else "None"))
            print("[HOLD ] 目視確認しrecover-homeで戻すこと:")
            print("        python3 grasp_lift_test.py recover-home")
            print("        python3 grasp_lift_test.py recover-home --release  # 開放してから戻す")
            print("=" * 70)
            record["aborted_at"] = "lift"
            record["abort_reason"] = "lift_arrival_failed"
            record["note"] = "held_at_target_or_partial; manual_recover_home_required"
            save_record_json(seq, ts, record)
            append_index_row(record)
            return 1

        # STEP7: HOME 復帰(掴んだブロックを保持したまま)
        print("\n[STEP7] HOME 復帰(掴んだまま)")
        ah2, ph2, mdh2 = send_and_wait(HOME, "to_home_post", TOL_HOME)
        record["home_post"] = {"arrived": bool(ah2), "post_actual": ph2,
                                "max_residual_deg": mdh2}
        if not ah2:
            print()
            print("=" * 70)
            print("[HOLD ] HOME 未到達 max|Δ|={}. 掴んだまま停止。".format(
                "{:.2f}°".format(mdh2) if mdh2 is not None else "None"))
            print("[HOLD ] recover-home --release で目視確認後に開放→HOME")
            print("=" * 70)
            record["aborted_at"] = "home_post"
            record["abort_reason"] = "home_post_arrival_failed"
            record["note"] = "lifted_ok_but_home_failed"
            save_record_json(seq, ts, record)
            append_index_row(record)
            return 1

        # 完走
        print()
        print("=" * 70)
        print("[DONE ] seq={} 完走: HOME→pick→grip→LIFT→HOME 全段到達".format(seq))
        print("[DONE ] 掴んだブロックを HOME 姿勢で保持中。"
              "目視確認後 recover-home --release で開放")
        print("=" * 70)
        record["note"] = "full_cycle_ok"
        save_record_json(seq, ts, record)
        append_index_row(record)

    except KeyboardInterrupt:
        print("\n[INTERRUPT] Ctrl-C 検出")
        record["aborted_at"] = record.get("aborted_at") or "interrupted"
        record["abort_reason"] = "KeyboardInterrupt"
        save_record_json(seq, ts, record)
        append_index_row(record)
        final_return = 1
    finally:
        try:
            daemon_set_fresh_mode(0)
        except Exception:
            pass

    return final_return


# ============================================================================
# サブコマンド: recover-home
# ============================================================================
def cmd_recover_home(release, auto_yes):
    print("=" * 70)
    print("[INIT] grasp_lift_test.py recover-home release={}".format(release))
    print("=" * 70)
    if not ensure_ssd_mounted():
        print("[WARN] KIOXIA 未マウント(ログは書き出さないが復帰は実施可)")
        if not confirm("[CONT] それでも復帰しますか", auto_yes):
            return 1

    try:
        get_daemon_socket()
    except Exception as e:
        print("[ABORT] daemon ping 失敗: {}".format(e))
        return 2
    try:
        daemon_set_fresh_mode(1)
    except Exception:
        pass

    if release:
        print("[RECOVER] gripper 開放 (release=True)")
        try:
            daemon_set_gripper(0)
            time.sleep(GRIPPER_SLEEP)
        except Exception as e:
            print("[RECOVER] [WARN] gripper 開放例外: {}(continue)".format(e))

    cur = get_angles_safe()
    md = max_delta(HOME, cur)
    print("[RECOVER] 現在 {} → HOME 大|Δ|={}".format(
        fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
    if md is None:
        # None 安全化: 現在角度が読めない=状態不明。--yes 無しは中断
        if not auto_yes:
            print("[ABORT] 現在角度を取得できず大移動判定不能。"
                  "状態不明のまま HOME 復帰送信しません(--yes で明示続行可)")
            return 1
        print("[WARN] 現在角度不明だが --yes で続行")
    elif md > LARGE_MOVE_WARN_DEG:
        if not confirm("[WARN] 大移動 (>{:.0f}°) です".format(LARGE_MOVE_WARN_DEG), auto_yes):
            print("[ABORT] ユーザ中断")
            return 1
    arrived, post, mdr = send_and_wait(HOME, "recover_to_home", TOL_HOME)
    if arrived:
        print("[RECOVER] HOME 到達 OK max|Δ|={:.2f}°".format(mdr))
    else:
        print("[RECOVER] [WARN] HOME 未到達 max|Δ|={}".format(mdr))

    try:
        daemon_set_fresh_mode(0)
    except Exception:
        pass
    try:
        close_daemon_socket()
    except Exception:
        pass
    return 0 if arrived else 1


# ============================================================================
# main
# ============================================================================
def main():
    ap = argparse.ArgumentParser(
        description="教示姿勢で掴み→通常APPROACH上空へ戻す動作テスト"
                    "(新規・本線非改変・1姿勢ずつ単発)")
    sub = ap.add_subparsers(dest="cmd")
    sub.required = True

    p_run = sub.add_parser("run", help="1 姿勢 (--seq) を掴み→LIFT→HOME 単発実行")
    p_run.add_argument("--seq", type=int, required=True, help="姿勢番号 (2〜9)")
    p_run.add_argument("--dry-run", action="store_true", help="送信せず計画のみ")
    p_run.add_argument("--yes", action="store_true",
                       help="大移動・状態不明などの警告を自動 yes")

    p_rec = sub.add_parser("recover-home", help="掴んだまま停止からの HOME 復帰")
    p_rec.add_argument("--release", action="store_true",
                       help="復帰前に gripper 開放")
    p_rec.add_argument("--yes", action="store_true", help="警告を自動 yes")

    args = ap.parse_args()

    if args.cmd == "run":
        if not (2 <= args.seq <= 9):
            print("[ABORT] --seq は 2〜9 の整数(seq1 は j2=93° で除外)")
            sys.exit(2)
        sys.exit(cmd_run(args.seq, args.dry_run, args.yes))
    elif args.cmd == "recover-home":
        sys.exit(cmd_recover_home(args.release, args.yes))


if __name__ == "__main__":
    main()

grasp_lift_pick_then_place.py(奥行き自動選択で Pick&Place まで通す)

カメラの block_z から姿勢を自動選択し、掴んで、中継分割で大移動を刻み、従来 PLACE と VLM 判定まで通す。本線 run_pick_place.py は import のみで無改変。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""grasp_lift_pick_then_place.py — 教示Z姿勢PICK → 従来PLACE 通し(案B・本線非改変)

前半 (PICK): grasp_lift_test.py と同じ流儀。教示姿勢 seq N へ → 掴む → LIFT
中継: LIFT 完了姿勢 → approach_place_target の max|Δ| > 60° なら j0 を先に
      ステップ送りで分割(j2.. j5 はその後の最終ショットで動く)。stage10 HOME も同じ
後半 (PLACE): run_pick_place.py から段関数を import して呼び出す(読み取り import のみ)

絶対ルール:
- 新規スクリプト。run_pick_place.py / fk_csv / Stiffness / Damping は触らない
- send_angles のみ。send_coords / release_all_servos は呼ばない
- 1 姿勢ずつ単発実行。自動連続なし
- LIFT 未到達・中継未到達なら、掴んだまま安全停止
- KIOXIA 未マウントなら出力前に中断

CLI (block_z 自動選択):
  python3 grasp_lift_pick_then_place.py run
  python3 grasp_lift_pick_then_place.py run --yes
  python3 grasp_lift_pick_then_place.py run --dry-run

  python3 grasp_lift_pick_then_place.py recover-home
  python3 grasp_lift_pick_then_place.py recover-home --release

出力:
  /Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243/pick_then_place/
    seq{N}_{timestamp}.json
    pick_then_place_index.csv
"""
from __future__ import print_function

import argparse
import csv
import json
import math
import sys
import time
from datetime import datetime
from pathlib import Path

# ---- パス追加 ---------------------------------------------------------------
_THIS = Path(__file__).resolve()
_SCRIPTS_ROOT = _THIS.parents[2]
_PLACE_MODULES = _THIS.parents[1] / "place" / "place_modules"
sys.path.insert(0, str(_SCRIPTS_ROOT))
sys.path.insert(0, str(_PLACE_MODULES))

# ---- daemon helpers と検出関数 ---------------------------------------------
from visual_servo_pick import (  # noqa: E402
    get_daemon_socket,
    close_daemon_socket,
    daemon_send_angles,
    daemon_get_angles,
    daemon_set_gripper,
    daemon_set_fresh_mode,
)
from detect_positions_once import detect_positions  # noqa: E402

# ---- 本線 PLACE 段関数(読み取り import のみ・改変なし)-------------------
import run_pick_place as rpp  # noqa: E402


# ---- 定数 -------------------------------------------------------------------
HOME = [0, 0, 0, 0, 0, -30]
SPEED = 20

TOL_HOME = 2.5
TOL_PICK = 3.0       # 教示姿勢到達判定
TOL_LIFT = 3.0       # LIFT 到達判定
TOL_RELAY = 3.0      # 中継到達判定

# PLACE 側は run_pick_place.py の値を流用(本線と同値)
TOL_APPROACH_PLACE = rpp.TOL_APPROACH_PLACE
TOL_PICK_PLACE = rpp.TOL_PICK_PLACE
TOL_SWEPT_RETREAT = rpp.TOL_SWEPT_RETREAT
TOL_LIFT_BACK = rpp.TOL_LIFT_BACK
SWEPT_J0_FOR_PLACE_JUDGE = rpp.SWEPT_J0_FOR_PLACE_JUDGE

SEND_WAIT_SEC = 1.0
ARRIVE_TIMEOUT_S = 12.0
ARRIVE_POLL_S = 0.2
GRIPPER_SLEEP = 2.0  # DAY175 対策(>=2s)
LARGE_MOVE_THRESHOLD_DEG = 60.0    # >この値なら中継分割
DETECT_REPEAT = 3

# LIFT 受け皿(grasp_lift_test と同値)
LIFT_J2 = 87.74
LIFT_J3 = -38.33
LIFT_J4 = 3.60
LIFT_J5 = -70.11
LIFT_J6 = -30.00

# 教示9姿勢(pose_z_map.csv の seq2..9 の tgt_j1..j6 をコピー)。
# seq1 は j2=93° で物理上限超過のため対象外
POSES = {
    2: [-45.35, 90.70,  -6.94,  0.87, -86.48, -29.53],
    3: [-50.88, 89.73,   0.00, -3.60, -91.66, -30.14],
    4: [-55.45, 85.95,   5.88, -6.85, -91.58, -29.79],
    5: [-55.89, 81.65,  13.53, -5.18, -91.84, -29.88],
    6: [-56.16, 77.34,  20.91, -1.93, -91.93, -29.00],
    7: [-64.07, 77.78,  20.30,-10.28, -92.37, -33.39],
    8: [-65.74, 70.13,  28.38, -6.32, -88.76, -30.84],
    9: [-63.98, 59.85,  35.68,  3.42, -78.39, -29.00],
}

# block_z 自動選択用: pose_z_map.csv の実測 block_z (mm) と verdict
POSE_Z_MAP = {
    2: {"z_mm": 234.0, "verdict": "reached"},
    3: {"z_mm": 165.0, "verdict": "partial"},   # j2 上限ぎりぎり
    4: {"z_mm": 151.0, "verdict": "reached"},
    5: {"z_mm": 148.0, "verdict": "reached"},   # seq4 とほぼ重複 (Δ=3mm)
    6: {"z_mm": 137.0, "verdict": "partial"},
    7: {"z_mm": 127.0, "verdict": "reached"},
    8: {"z_mm": 102.0, "verdict": "reached"},
    9: {"z_mm":  85.0, "verdict": "reached"},
}
Z_GAP_LIMIT_MM = 35.0             # この値より gap が大きいと教示姿勢で届かない可能性が高い
PARTIAL_PREFER_MARGIN_MM = 5.0    # partial を採用する条件: gap_partial + margin < gap_reached

# ---- KIOXIA 出力先 ----------------------------------------------------------
CALIB_DIR = Path("/Volumes/KIOXIA/r2r2r-data/mecharm_calibration/pick_z_postures_DAY243")
OUT_DIR = CALIB_DIR / "pick_then_place"
INDEX_CSV = OUT_DIR / "pick_then_place_index.csv"

INDEX_COLS = [
    "timestamp", "seq", "selected_seq", "selected_verdict", "z_gap_mm",
    "selection_rationale",
    "home_pre_arrived", "home_pre_max_deg",
    "block_x_mm", "block_z_mm", "box_x_mm", "box_z_mm",
    "target_arrived", "target_max_deg",
    "target_post_j1", "target_post_j2", "target_post_j3",
    "target_post_j4", "target_post_j5", "target_post_j6",
    "gripped",
    "lift_arrived", "lift_max_deg",
    "lift_post_j1", "lift_post_j2", "lift_post_j3",
    "lift_post_j4", "lift_post_j5", "lift_post_j6",
    "relay_pre_place_steps", "relay_pre_place_arrived",
    "approach_place_arrived", "approach_place_max_deg",
    "place_arrived", "place_max_deg",
    "release_ok",
    "swept_arrived", "swept_max_deg",
    "place_verdict", "place_confidence",
    "lift_back_arrived", "lift_back_max_deg",
    "relay_to_home_steps", "relay_to_home_arrived",
    "home_post_arrived", "home_post_max_deg",
    "aborted_at", "abort_reason", "note",
]


# ============================================================================
# ユーティリティ
# ============================================================================
def ensure_ssd_mounted():
    try:
        if not CALIB_DIR.exists() or not CALIB_DIR.is_dir():
            return False
        test = CALIB_DIR / ".write_test_pick_then_place"
        try:
            test.write_text(""); test.unlink()
            return True
        except Exception:
            return False
    except Exception:
        return False


def confirm(prompt, auto_yes):
    if auto_yes:
        print("  → auto-yes: 続行")
        return True
    try:
        ans = input(prompt + " [Enter で続行 / 'n' で中断]: ").strip().lower()
    except EOFError:
        return False
    return ans != "n"


def fmt_angles(a):
    if a is None:
        return "None"
    return "[" + ", ".join("{:+.2f}".format(x) for x in a) + "]"


def max_delta(target, actual):
    if target is None or actual is None:
        return None
    if len(target) != 6 or len(actual) != 6:
        return None
    return max(abs(actual[i] - target[i]) for i in range(6))


def get_angles_safe(max_retry=8, retry_sleep=0.15):
    for _ in range(max_retry):
        try:
            a = daemon_get_angles()
        except Exception:
            a = None
        if a is not None:
            try:
                if len(a) == 6:
                    return [float(x) for x in a]
            except (TypeError, ValueError):
                pass
        time.sleep(retry_sleep)
    return None


def send_and_wait(target_angles, label, tol):
    """grasp_lift_test と同形。PICK 前半で使用(rpp._do_move とは別系で観測ログを汚さない)."""
    try:
        daemon_send_angles(target_angles, speed=SPEED, wait=0)
    except Exception as e:
        print("[SEND] [ERROR] daemon_send_angles 例外 ({}): {}".format(label, e))
        return False, None, None
    time.sleep(SEND_WAIT_SEC)
    t0 = time.time()
    last = None
    while time.time() - t0 < ARRIVE_TIMEOUT_S:
        a = get_angles_safe()
        if a is not None:
            last = a
            md = max(abs(a[i] - target_angles[i]) for i in range(6))
            if md <= tol:
                print("[SEND] {} arrived max|Δ|={:.2f}° ({:.1f}s)".format(
                    label, md, time.time() - t0))
                return True, a, md
        time.sleep(ARRIVE_POLL_S)
    md = max_delta(target_angles, last)
    print("[SEND] {} TIMEOUT max|Δ|={}".format(
        label, "{:.2f}°".format(md) if md is not None else "None"))
    return False, last, md


# ============================================================================
# block_z 自動選択(pose_z_map 最近傍・reached 優先・partial は明確に近いときのみ)
# ============================================================================
def select_seq_for_block_z(block_z_mm):
    """検出した block_z (mm) から最適な教示姿勢 seq を自動選択する.

    アルゴリズム:
      - reached を優先(最近傍 reached を基準とする)
      - partial (seq3, seq6) は gap_partial + PARTIAL_PREFER_MARGIN_MM < gap_reached
        のときのみ採用(reached より明確に近い場合のみ)
      - 採用 seq の |z_gap| > Z_GAP_LIMIT_MM なら None を返す(届かない可能性が高い)

    戻り値: (selected_seq | None, z_gap_mm | None, info_dict)
    """
    reached = {s: e for s, e in POSE_Z_MAP.items() if e["verdict"] == "reached"}
    partial = {s: e for s, e in POSE_Z_MAP.items() if e["verdict"] == "partial"}

    def _nearest(d):
        if not d:
            return None, None
        s = min(d.keys(), key=lambda x: abs(d[x]["z_mm"] - block_z_mm))
        return s, abs(d[s]["z_mm"] - block_z_mm)

    s_r, gap_r = _nearest(reached)
    s_p, gap_p = _nearest(partial)

    info = {
        "block_z_mm": block_z_mm,
        "reached_nearest": {"seq": s_r, "gap_mm": gap_r},
        "partial_nearest": {"seq": s_p, "gap_mm": gap_p},
        "z_gap_limit_mm": Z_GAP_LIMIT_MM,
        "partial_prefer_margin_mm": PARTIAL_PREFER_MARGIN_MM,
    }

    # partial が reached より明確に近ければ partial 採用
    if (s_p is not None and gap_p is not None and gap_r is not None
            and gap_p + PARTIAL_PREFER_MARGIN_MM < gap_r):
        sel, gap, rationale = s_p, gap_p, "partial_clearly_closer"
    else:
        sel, gap, rationale = s_r, gap_r, "reached_nearest"
    info["rationale"] = rationale
    info["selected_seq"] = sel
    info["selected_gap_mm"] = gap
    info["selected_verdict"] = POSE_Z_MAP[sel]["verdict"] if sel is not None else None

    # ガード: gap が Z_GAP_LIMIT_MM を超えるなら届かない判定で None
    if gap is None or gap > Z_GAP_LIMIT_MM:
        info["selected_seq"] = None
        return None, gap, info
    return sel, gap, info


# ============================================================================
# 中継分割: 全6軸の最大Δで判定し、6軸線形補間で waypoint を生成(A4 修正)
# ============================================================================
def plan_relay(cur, target, threshold=LARGE_MOVE_THRESHOLD_DEG):
    """全6軸の最大Δで分割を判断し、6軸線形補間で waypoint を生成する.

    - cur / target いずれかが None → [] (caller が状態不明として中断する想定)
    - max|Δ| <= threshold → [(target, "single")]
    - else → n_steps = ceil(max|Δ| / threshold) 個の waypoint を線形補間で生成.
      各 waypoint は前 waypoint から max|Δ| <= threshold に収まる.
      最終 waypoint は target に完全一致.
    """
    if cur is None or target is None:
        return []
    max_d = max(abs(target[i] - cur[i]) for i in range(6))
    if max_d <= threshold:
        return [(list(target), "single")]
    n_steps = max(2, int(math.ceil(max_d / threshold)))
    plan = []
    for k in range(1, n_steps + 1):
        if k == n_steps:
            wp = list(target)   # 最終は target に完全一致
        else:
            t = k / float(n_steps)
            wp = [cur[i] + (target[i] - cur[i]) * t for i in range(6)]
        plan.append((wp, "relay_step{}_of_{}".format(k, n_steps)))
    return plan


def execute_relay(cur, target, label_prefix, tol, log_record, auto_yes=False):
    """中継分割を実行. cur (現在角度) を起点に target へ.

    A4 修正: cur is None → 送信せず中断 (--yes 指定時のみ単発で target を試行).
    戻り値: (all_arrived, steps_done, last_post_actual, last_max_deg, plan_for_log).
    """
    if cur is None:
        if not auto_yes:
            print("[RELAY] [ABORT] {} cur (現在角度) None → 送信しない(状態不明)"
                  .format(label_prefix))
            return False, 0, None, None, []
        print("[RELAY] [WARN] {} cur None だが --yes で続行 → 単発 target を試行"
              .format(label_prefix))
        ok, post = rpp._do_move(
            target, "{}_single_no_cur".format(label_prefix),
            tol, log_record, dry_run=False)
        md = max_delta(target, post)
        return ok, 1, post, md, [(list(target), "single_no_cur")]

    plan = plan_relay(cur, target)
    if not plan:
        return False, 0, None, None, []
    if len(plan) > 1:
        full_md = max_delta(cur, target)
        print("[RELAY] {} full max|Δ| = {:.2f}° > {:.0f}° → 中継 {} step に分割(6軸補間)"
              .format(label_prefix, full_md, LARGE_MOVE_THRESHOLD_DEG, len(plan)))
    else:
        print("[RELAY] {} 単発 (max|Δ| <= {:.0f}°)".format(label_prefix, LARGE_MOVE_THRESHOLD_DEG))

    last_post = cur
    last_md = None
    for i, (wp, lbl) in enumerate(plan, 1):
        full_label = "{}_{}".format(label_prefix, lbl)
        ok, post = rpp._do_move(wp, full_label, tol, log_record, dry_run=False)
        if not ok:
            md = max_delta(wp, post)
            print("[RELAY] {} 未到達 max|Δ|={}. 残り {} 段中止".format(
                full_label,
                "{:.2f}°".format(md) if md is not None else "None",
                len(plan) - i))
            return False, i, post, md, plan
        last_post = post
        last_md = max_delta(wp, post)
    return True, len(plan), last_post, last_md, plan


# ============================================================================
# index 追記
# ============================================================================
def _v(v, fmt=None):
    if v is None:
        return ""
    if fmt and isinstance(v, (int, float)):
        return fmt.format(v)
    return v


def append_index_row(record):
    is_new = not INDEX_CSV.exists()
    home_pre = record.get("home_pre") or {}
    detect = record.get("detect") or {}
    block = detect.get("block_xz_m") or (None, None)
    box = detect.get("box_xz_m") or (None, None)
    target = record.get("target") or {}
    lift = record.get("lift") or {}
    relay_pre = record.get("relay_pre_place") or {}
    app_place = record.get("approach_place") or {}
    place = record.get("place") or {}
    release = record.get("release") or {}
    swept = record.get("swept") or {}
    # C3 修正: judgments["place"] が正. 後方互換で place_judgment も読む
    place_judge = ((record.get("judgments") or {}).get("place")
                   or record.get("place_judgment") or {})
    lift_back = record.get("lift_back") or {}
    relay_post = record.get("relay_to_home") or {}
    home_post = record.get("home_post") or {}

    def mm(v): return "{:.1f}".format(v * 1000) if isinstance(v, (int, float)) else ""
    tpost = target.get("post_actual") or [None] * 6
    lpost = lift.get("post_actual") or [None] * 6

    selection = record.get("selection") or {}
    row = {
        "timestamp": record.get("timestamp", ""),
        "seq": record.get("seq", ""),
        "selected_seq": _v(selection.get("selected_seq")),
        "selected_verdict": _v(selection.get("selected_verdict")),
        "z_gap_mm": _v(selection.get("selected_gap_mm"), "{:.1f}"),
        "selection_rationale": _v(selection.get("rationale")),
        "home_pre_arrived": _v(home_pre.get("arrived")),
        "home_pre_max_deg": _v(home_pre.get("max_residual_deg"), "{:.2f}"),
        "block_x_mm": mm(block[0] if block else None),
        "block_z_mm": mm(block[1] if block else None),
        "box_x_mm": mm(box[0] if box else None),
        "box_z_mm": mm(box[1] if box else None),
        "target_arrived": _v(target.get("arrived")),
        "target_max_deg": _v(target.get("max_residual_deg"), "{:.2f}"),
        "gripped": _v(record.get("gripped")),
        "lift_arrived": _v(lift.get("arrived")),
        "lift_max_deg": _v(lift.get("max_residual_deg"), "{:.2f}"),
        "relay_pre_place_steps": _v(relay_pre.get("steps_done")),
        "relay_pre_place_arrived": _v(relay_pre.get("arrived")),
        "approach_place_arrived": _v(app_place.get("arrived")),
        "approach_place_max_deg": _v(app_place.get("max_residual_deg"), "{:.2f}"),
        "place_arrived": _v(place.get("arrived")),
        "place_max_deg": _v(place.get("max_residual_deg"), "{:.2f}"),
        "release_ok": _v(release.get("ok")),
        "swept_arrived": _v(swept.get("arrived")),
        "swept_max_deg": _v(swept.get("max_residual_deg"), "{:.2f}"),
        "place_verdict": _v(place_judge.get("verdict")),
        "place_confidence": _v(place_judge.get("confidence"), "{:.2f}"),
        "lift_back_arrived": _v(lift_back.get("arrived")),
        "lift_back_max_deg": _v(lift_back.get("max_residual_deg"), "{:.2f}"),
        "relay_to_home_steps": _v(relay_post.get("steps_done")),
        "relay_to_home_arrived": _v(relay_post.get("arrived")),
        "home_post_arrived": _v(home_post.get("arrived")),
        "home_post_max_deg": _v(home_post.get("max_residual_deg"), "{:.2f}"),
        "aborted_at": _v(record.get("aborted_at")),
        "abort_reason": _v(record.get("abort_reason")),
        "note": _v(record.get("note")),
    }
    for i in range(6):
        row["target_post_j{}".format(i + 1)] = (
            "{:.2f}".format(tpost[i]) if isinstance(tpost[i], (int, float)) else "")
        row["lift_post_j{}".format(i + 1)] = (
            "{:.2f}".format(lpost[i]) if isinstance(lpost[i], (int, float)) else "")
    with open(INDEX_CSV, "a", newline="") as f:
        w = csv.DictWriter(f, fieldnames=INDEX_COLS)
        if is_new:
            w.writeheader()
        w.writerow(row)
    print("[INDEX] append OK: {}".format(INDEX_CSV))


def save_record_json(seq, ts, payload):
    # seq 未確定(自動選択前 ABORT 等)は "auto_no_seq" に
    label = "seq{}".format(seq) if seq is not None else "auto_no_seq"
    out = OUT_DIR / "{}_{}.json".format(label, ts)
    with open(out, "w") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2, default=str)
    print("[JSON] {}".format(out))
    return str(out)


# ============================================================================
# サブコマンド: run
# ============================================================================
def cmd_run(dry_run, auto_yes):
    print("=" * 70)
    print("[INIT] grasp_lift_pick_then_place.py run (auto-select) dry_run={}"
          .format(dry_run))
    print("[INIT] block_z 自動選択モード (--seq 廃止). seq は STEP3.5 で決定")
    print("=" * 70)

    if not ensure_ssd_mounted():
        print("[ABORT] KIOXIA {} 未マウント or 書込不可".format(CALIB_DIR))
        return 2

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    run_stem = "pick_then_place_auto_{}".format(ts)
    frames_dir = OUT_DIR

    record = {
        "timestamp": datetime.now().isoformat(timespec="seconds"),
        "seq": None,                            # STEP3.5 で確定
        "teaching_target_angles": None,         # STEP3.5 で確定
        "selection": None,                      # STEP3.5 で確定(自動選択の根拠)
        "lift_recipe": "[seq_post_j1, {}, {}, {}, {}, {}]".format(
            LIFT_J2, LIFT_J3, LIFT_J4, LIFT_J5, LIFT_J6),
        "dry_run": dry_run,
        "home_pre": None, "detect": None,
        "target": None, "gripped": False, "lift": None,
        "relay_pre_place": None,
        "approach_place": None, "place": None,
        "release": None, "swept": None,
        "lift_back": None, "relay_to_home": None, "home_post": None,
        "aborted_at": None, "abort_reason": None, "note": "",
        # rpp._do_move が書く log_record スキーマも兼ねる
        "stages": [], "moves": [], "gripper": [], "lookups": [],
        "vlm_captures": [], "judgments": {},
    }
    # 安全のための save shorthand(seq は record["seq"] を引く・None 可)
    def _save(): save_record_json(record.get("seq"), ts, record)
    seq = None             # STEP3.5 で確定(auto-select)
    target_angles = None   # STEP3.5 で確定

    if dry_run:
        print("\n[DRY ] 送信せず計画のみ:")
        print("[DRY ]  ★block_z 自動選択ロジック:")
        print("[DRY ]   POSE_Z_MAP (seq → block_z mm / verdict):")
        for s in sorted(POSE_Z_MAP.keys()):
            e = POSE_Z_MAP[s]
            print("[DRY ]     seq{}: z={:>6.1f}mm  {}".format(s, e["z_mm"], e["verdict"]))
        print("[DRY ]   Z_GAP_LIMIT_MM = {}mm(最近傍でもこの値超なら ABORT)".format(Z_GAP_LIMIT_MM))
        print("[DRY ]   PARTIAL_PREFER_MARGIN_MM = {}mm".format(PARTIAL_PREFER_MARGIN_MM))
        print("[DRY ]   ルール: reached 優先。partial は gap_partial+{} < gap_reached のみ採用"
              .format(PARTIAL_PREFER_MARGIN_MM))
        print("[DRY ]   選択シミュレーション (検出 block_z ダミー値):")
        for bz in [85, 100, 120, 130, 140, 150, 170, 180, 200, 220, 234,
                   50, 275, 300]:
            sel, gap, info = select_seq_for_block_z(bz)
            r_seq = info["reached_nearest"]["seq"]
            r_gap = info["reached_nearest"]["gap_mm"]
            p_seq = info["partial_nearest"]["seq"]
            p_gap = info["partial_nearest"]["gap_mm"]
            if sel is None:
                print("[DRY ]     block_z={:>5}mm → ABORT (gap_limit={}超)  "
                      "[reached_nearest=seq{} gap={:.1f}mm]"
                      .format(bz, Z_GAP_LIMIT_MM, r_seq, r_gap))
            else:
                p_disp = "seq{} gap={:.1f}".format(p_seq, p_gap) if p_seq else "なし"
                print("[DRY ]     block_z={:>5}mm → seq{} ({:>7s}) gap={:.1f}mm  "
                      "[reached=seq{} gap={:.1f} / partial={}]"
                      .format(bz, sel, info["selected_verdict"], gap,
                              r_seq, r_gap, p_disp))
        print()
        print("[DRY ]  実行フロー (auto-select 成功後の通し):")
        print("[DRY ]   1) HOME (tol={})".format(TOL_HOME))
        print("[DRY ]   2) gripper open (sleep={}s)".format(GRIPPER_SLEEP))
        print("[DRY ]   3) detect_positions(repeat={}) → block_xz + box_xz".format(DETECT_REPEAT))
        print("[DRY ]   3.5) ★auto-select: block_z → POSE_Z_MAP 最近傍 → seq 決定 "
              "(gap_limit 超なら ABORT)")
        print("[DRY ]   4) 選択 seq の教示姿勢へ (tol={}, 半端でも続行)".format(TOL_PICK))
        print("[DRY ]   5) gripper close (sleep={}s)".format(GRIPPER_SLEEP))
        print("[DRY ]   6) LIFT [post_j0, {}, {}, {}, {}, {}] (tol={})".format(
            LIFT_J2, LIFT_J3, LIFT_J4, LIFT_J5, LIFT_J6, TOL_LIFT))
        print("[DRY ]      未到達 → 掴んだまま停止 (recover-home へ)")
        print("\n[DRY ]  中継 (大移動分割) ロジック [A4 修正後・全6軸線形補間]:")
        print("[DRY ]   - max|Δ| (全6軸) > {:.0f}° で分割発動".format(LARGE_MOVE_THRESHOLD_DEG))
        print("[DRY ]   - n_steps = ceil(max|Δ| / {:.0f}°)".format(LARGE_MOVE_THRESHOLD_DEG))
        print("[DRY ]   - 各 waypoint = cur + (target - cur) * (k / n_steps)、全6軸を線形補間")
        print("[DRY ]   - 最終 waypoint は target 完全一致")
        print("[DRY ]   - 各 leg で到達確認 (tol={})。未到達なら停止".format(TOL_RELAY))
        print("[DRY ]   - cur is None なら送信せず中断 (--yes 指定時のみ単発で target を試行)")
        print("[DRY ]  例: cur=[-55, 87, -38, 3, -70, -30] → tgt=[+30, 30, -10, 5, -85, -30]")
        sample_plan = plan_relay(
            [-55.0, 87.74, -38.33, 3.60, -70.11, -30.00],
            [30.0, 30.0, -10.0, 5.0, -85.0, -30.0])
        for i, (wp, lbl) in enumerate(sample_plan, 1):
            print("[DRY ]    step{} ({}) → {}".format(i, lbl, fmt_angles(wp)))
        print("\n[DRY ]  PLACE 後半 (run_pick_place 流用):")
        print("[DRY ]   7) approach_place final (tol={}) — 未到達: 掴んだまま停止".format(
            TOL_APPROACH_PLACE))
        print("[DRY ]   8) stage_lookup_and_move('place_withblock', box_xz, tol={}) "
              "— 未到達: 掴んだまま停止".format(TOL_PICK_PLACE))
        print("[DRY ]   9) _do_stage9_release_gripper (★ ここで block を離す)")
        print("[DRY ]  10) swept_retreat j0={} (tol={}) "
              "— 未到達: 未到達停止 (release 済・block 未保持)".format(
                  SWEPT_J0_FOR_PLACE_JUDGE, TOL_SWEPT_RETREAT))
        print("[DRY ]  11) ★VLM place judge 必須 (_capture_for_vlm + _judge_image)")
        print("[DRY ]      - swept 成功時のみ実行: 撮影 → Anthropic API → judgments['place'] に記録")
        print("[DRY ]      - swept 失敗時: judgments['place']=unknown reason='swept_retreat_failed' を記録して停止")
        print("[DRY ]  12) lift_back → approach_place_target (tol={}) "
              "— 未到達: 未到達停止".format(TOL_LIFT_BACK))
        print("[DRY ]  13) HOME (tol={}・大移動なら中継分割) "
              "— 未到達: 未到達停止".format(TOL_HOME))
        record["note"] = "dry_run_plan_ok"
        _save()
        return 0

    # daemon 接続
    print("\n[INIT] daemon 接続中 ...")
    try:
        get_daemon_socket()
    except Exception as e:
        print("[ABORT] daemon ping 失敗: {}".format(e))
        return 2
    try:
        daemon_set_fresh_mode(1)
        print("[INIT] fresh_mode=1")
    except Exception as e:
        print("[WARN] fresh_mode(1) 例外: {}(continue)".format(e))

    # 観測 emit 用に run_pick_place の OBSERVE_STATE を初期化(このスクリプト主導)
    rpp._OBSERVE_STATE["run_dir"] = str(OUT_DIR)
    rpp._OBSERVE_STATE["run_stem"] = run_stem
    rpp._OBSERVE_STATE["task"] = "grasp_lift_pick_then_place"
    rpp._OBSERVE_STATE["phase"] = None
    rpp._OBSERVE_STATE["phase_t0_ms"] = None
    rpp._OBSERVE_STATE["gripper"] = None
    rpp._OBSERVE_STATE["verdict"] = None
    rpp._OBSERVE_STATE["block_xz"] = None
    rpp._OBSERVE_STATE["box_xz"] = None
    rpp._OBSERVE_STATE["block_place_xz"] = None
    rpp._OBSERVE_STATE["tip_pick"] = None
    rpp._OBSERVE_STATE["cap_tip"] = None
    rpp._OBSERVE_STATE["cap_pick"] = None
    rpp._OBSERVE_STATE["cap_place"] = None
    rpp._OBSERVE_STATE["last_angles"] = None

    final_return = 0
    try:
        # ========== PICK 前半 ===============================================
        # STEP1: HOME
        rpp._observe_phase("pkp_stage1_home")
        print("\n[STEP1] HOME 移動")
        cur = get_angles_safe()
        md = max_delta(HOME, cur)
        print("[STEP1] 現在 {} → HOME 大|Δ|={}".format(
            fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))
        if md is None:
            if not auto_yes:
                print("[ABORT] 現在角度を取得できず(--yes で明示続行可)")
                record["aborted_at"] = "before_home_pre"
                record["abort_reason"] = "current_angles_unreadable"
                _save(); append_index_row(record)
                return 1
            print("[WARN] 現在角度不明だが --yes で続行")
        ah, ph, mdh = send_and_wait(HOME, "pkp_to_home_pre", TOL_HOME)
        record["home_pre"] = {"arrived": bool(ah), "post_actual": ph,
                              "max_residual_deg": mdh}
        if not ah:
            print("[ABORT] HOME 未到達")
            record["aborted_at"] = "home_pre"
            record["abort_reason"] = "home_pre_arrival_failed"
            _save(); append_index_row(record)
            return 1
        rpp._observe_gripper(None)

        # STEP2: gripper 開
        rpp._observe_phase("pkp_stage1_gripper_open")
        print("\n[STEP2] gripper 開")
        try:
            daemon_set_gripper(0); time.sleep(GRIPPER_SLEEP)
            print("[STEP2] gripper open + sleep {:.1f}s".format(GRIPPER_SLEEP))
        except Exception as e:
            print("[STEP2] [WARN] gripper open 例外: {}".format(e))
        rpp._observe_gripper(0)

        # STEP3: block + box 同時検出
        rpp._observe_phase("pkp_stage2_detect")
        print("\n[STEP3] block + box 同時検出 detect_positions(repeat={})".format(DETECT_REPEAT))
        try:
            det = detect_positions(repeat=DETECT_REPEAT)
        except Exception as e:
            print("[WARN] detect_positions 例外: {}".format(e))
            det = {"block_xz_m": None, "box_xz_m": None, "detect_score": {}}
        block_xz = det.get("block_xz_m")
        box_xz = det.get("box_xz_m")
        scores = det.get("detect_score") or {}
        record["detect"] = {
            "block_xz_m": list(block_xz) if block_xz else None,
            "box_xz_m": list(box_xz) if box_xz else None,
            "block_score": scores.get("block"),
            "box_score": scores.get("box"),
        }
        print("[STEP3] block_xz={} box_xz={}".format(block_xz, box_xz))
        if block_xz is None:
            print("[ABORT] block 未検出")
            record["aborted_at"] = "detect"
            record["abort_reason"] = "block_not_detected"
            _save(); append_index_row(record)
            return 1
        if box_xz is None:
            print("[ABORT] box 未検出(PLACE で必須)")
            record["aborted_at"] = "detect"
            record["abort_reason"] = "box_not_detected"
            _save(); append_index_row(record)
            return 1
        box_score = scores.get("box")
        if box_score is None or box_score < rpp.BOX_SCORE_THRESH:
            print("[ABORT] box score {} < {}(PLACE 不可)".format(
                box_score, rpp.BOX_SCORE_THRESH))
            record["aborted_at"] = "detect"
            record["abort_reason"] = "box_score_too_low"
            _save(); append_index_row(record)
            return 1
        block_xz = (float(block_xz[0]), float(block_xz[1]))
        box_xz = (float(box_xz[0]), float(box_xz[1]))
        rpp._OBSERVE_STATE["block_xz"] = list(block_xz)
        rpp._OBSERVE_STATE["box_xz"] = list(box_xz)

        # STEP3.5: ★block_z から教示姿勢 seq を自動選択
        rpp._observe_phase("pkp_stage3_5_auto_select")
        block_z_mm = block_xz[1] * 1000.0
        print("\n[STEP3.5] block_z = {:.1f}mm → seq 自動選択".format(block_z_mm))
        selected_seq, z_gap, sel_info = select_seq_for_block_z(block_z_mm)
        record["selection"] = sel_info
        r_seq = sel_info["reached_nearest"]["seq"]
        r_gap = sel_info["reached_nearest"]["gap_mm"]
        p_seq = sel_info["partial_nearest"]["seq"]
        p_gap = sel_info["partial_nearest"]["gap_mm"]
        print("[STEP3.5] 候補: reached_nearest=seq{} gap={:.1f}mm  partial_nearest={}"
              .format(r_seq,
                      r_gap if r_gap is not None else float("nan"),
                      "seq{} gap={:.1f}mm".format(p_seq, p_gap) if p_seq else "なし"))
        if selected_seq is None:
            print("[ABORT] block_z={:.1f}mm に近い教示姿勢がない "
                  "(最近傍 seq{}, gap={:.1f}mm > Z_GAP_LIMIT_MM={:.1f}mm)".format(
                      block_z_mm, r_seq,
                      r_gap if r_gap is not None else float("nan"),
                      Z_GAP_LIMIT_MM))
            record["aborted_at"] = "auto_select"
            record["abort_reason"] = "no_seq_within_z_gap_limit"
            _save(); append_index_row(record)
            return 1
        # 確定: seq, target_angles, record 反映
        seq = selected_seq
        target_angles = POSES[seq]
        record["seq"] = seq
        record["teaching_target_angles"] = target_angles
        print("[STEP3.5] 採用: seq={} (z={:.1f}mm verdict={}) gap={:.1f}mm rationale={}"
              .format(seq, POSE_Z_MAP[seq]["z_mm"], POSE_Z_MAP[seq]["verdict"],
                      z_gap, sel_info["rationale"]))
        print("[STEP3.5] teaching = {}".format(fmt_angles(target_angles)))

        # STEP4: 教示姿勢へ
        rpp._observe_phase("pkp_stage3_teaching")
        print("\n[STEP4] 教示姿勢 seq={}".format(seq))
        cur = get_angles_safe()
        md = max_delta(target_angles, cur)
        print("[STEP4] 現在 → target 大|Δ|={}".format(
            "{:.2f}°".format(md) if md is not None else "None"))
        if md is None and not auto_yes:
            print("[ABORT] 現在角度を取得できず target 前で中断(--yes で続行可)")
            record["aborted_at"] = "before_target"
            record["abort_reason"] = "current_angles_unreadable"
            _save(); append_index_row(record)
            return 1
        at, pt, mdt = send_and_wait(
            target_angles, "pkp_to_teaching_seq{}".format(seq), TOL_PICK)
        record["target"] = {
            "arrived": bool(at), "post_actual": pt, "max_residual_deg": mdt,
        }
        if not at:
            print("[STEP4] 未到達 max|Δ|={}. 仕様により続行(半端でも掴み試行)"
                  .format(mdt))
        if pt is None:
            print("[ABORT] target 後 post_actual None → LIFT 構成不能")
            record["aborted_at"] = "after_target_no_post"
            record["abort_reason"] = "post_actual_unreadable_after_target"
            _save(); append_index_row(record)
            return 1

        # STEP5: gripper 閉
        rpp._observe_phase("pkp_stage4_gripper_close")
        print("\n[STEP5] gripper 閉")
        try:
            daemon_set_gripper(1); time.sleep(GRIPPER_SLEEP)
            record["gripped"] = True
            print("[STEP5] gripper close + sleep {:.1f}s".format(GRIPPER_SLEEP))
        except Exception as e:
            print("[STEP5] [WARN] gripper close 例外: {}".format(e))
            record["gripped"] = False
        rpp._observe_gripper(1)

        # STEP6: LIFT
        rpp._observe_phase("pkp_stage5_lift")
        lift_angles = [pt[0], LIFT_J2, LIFT_J3, LIFT_J4, LIFT_J5, LIFT_J6]
        print("\n[STEP6] LIFT → {}".format(fmt_angles(lift_angles)))
        al, pl, mdl = send_and_wait(lift_angles, "pkp_to_lift_seq{}".format(seq), TOL_LIFT)
        record["lift"] = {
            "target_angles": lift_angles,
            "arrived": bool(al), "post_actual": pl, "max_residual_deg": mdl,
        }
        if not al:
            print()
            print("=" * 70)
            print("[HOLD] LIFT 未到達 max|Δ|={}. 掴んだまま停止。".format(
                "{:.2f}°".format(mdl) if mdl is not None else "None"))
            print("[HOLD] recover-home --release で目視確認後に開放→HOME")
            print("=" * 70)
            record["aborted_at"] = "lift"
            record["abort_reason"] = "lift_arrival_failed"
            record["note"] = "held_at_partial_lift"
            _save(); append_index_row(record)
            return 1

        # ========== 中継 ====================================================
        # PICK 終端姿勢 → approach_place_target の大移動分割
        # まず j0_lookup で approach_place_target を得る(送信なし・計算のみ)
        rpp._observe_phase("pkp_relay_pre_place")
        print("\n[STEP7] 中継: PICK 終端 → approach_place_target の大移動分割")
        try:
            r_app_place_lookup = rpp.j0_lookup("approach_place", box_xz)
        except Exception as e:
            print("[ABORT] j0_lookup('approach_place', {}) 失敗: {}".format(box_xz, e))
            record["aborted_at"] = "lookup_approach_place"
            record["abort_reason"] = "j0_lookup_failed: {}".format(e)
            _save(); append_index_row(record)
            return 1
        ok_guard, reason = rpp._check_guard(r_app_place_lookup, "approach_place_pre")
        if not ok_guard:
            print("[ABORT] approach_place 送信前ガード NG: {}".format(reason))
            record["aborted_at"] = "guard_approach_place"
            record["abort_reason"] = "guard_failed: " + reason
            _save(); append_index_row(record)
            return 1
        approach_place_target = list(r_app_place_lookup["target_angles"])
        print("[STEP7] approach_place_target = {}".format(fmt_angles(approach_place_target)))
        relay_ok, relay_steps, relay_post, relay_md, relay_plan = execute_relay(
            pl, approach_place_target, "pkp_relay_pre_place", TOL_RELAY, record,
            auto_yes=auto_yes)
        record["relay_pre_place"] = {
            "arrived": bool(relay_ok), "steps_done": relay_steps,
            "plan": [{"wp": wp, "label": lbl} for (wp, lbl) in relay_plan],
            "post_actual": relay_post, "last_max_residual_deg": relay_md,
        }
        if not relay_ok:
            print()
            print("=" * 70)
            print("[HOLD] 中継 (PICK→PLACE) 未到達. 掴んだまま停止。")
            print("[HOLD] recover-home --release で目視確認後に開放→HOME")
            print("=" * 70)
            record["aborted_at"] = "relay_pre_place"
            record["abort_reason"] = "relay_arrival_failed"
            record["note"] = "held_at_relay_pre_place"
            _save(); append_index_row(record)
            return 1

        # ========== PLACE 後半(本線流用)==================================
        # STEP8: stage7 approach_place(lookup は同じなので _do_move 直で送る)
        # 既に lookup を取得済み・guard 済み・relay で到達済み. ここでは
        # approach_place_target そのものに最終フィニッシュ移動(relay 最後で送り済みなので
        # 実質的に確認段)。tol を本線と同値に揃え再到達確認だけ取る.
        rpp._observe_phase("pkp_stage7_approach_place")
        print("\n[STEP8] approach_place 最終フィニッシュ (tol={})".format(TOL_APPROACH_PLACE))
        ok_app, _ = rpp._do_move(
            approach_place_target, "pkp_stage7_approach_place_final",
            TOL_APPROACH_PLACE, record, dry_run=False)
        record["approach_place"] = {"arrived": bool(ok_app),
                                     "max_residual_deg": _last_move_max(record)}
        if not ok_app:
            print()
            print("=" * 70)
            print("[HOLD] approach_place 最終未到達. 掴んだまま停止。")
            print("=" * 70)
            record["aborted_at"] = "approach_place"
            record["abort_reason"] = "approach_place_arrival_failed"
            _save(); append_index_row(record)
            return 1

        # STEP9: stage8 place_withblock (降下)
        rpp._observe_phase("pkp_stage8_place")
        print("\n[STEP9] place_withblock 降下")
        ok_place, _ = rpp.stage_lookup_and_move(
            "place_withblock", box_xz, "pkp_stage8_place",
            TOL_PICK_PLACE, record, False, 8)
        record["place"] = {"arrived": bool(ok_place),
                            "max_residual_deg": _last_move_max(record)}
        if not ok_place:
            print()
            print("=" * 70)
            print("[HOLD] place 降下未到達. 掴んだまま停止")
            print("=" * 70)
            record["aborted_at"] = "place"
            record["abort_reason"] = "place_arrival_failed"
            _save(); append_index_row(record)
            return 1

        # STEP10: stage9 release
        rpp._observe_phase("pkp_stage9_release")
        print("\n[STEP10] gripper open (release)")
        ok_release = rpp._do_stage9_release_gripper(record, False)
        record["release"] = {"ok": bool(ok_release)}
        record["gripped"] = False
        rpp._observe_gripper(0)
        if not ok_release:
            print("[WARN] release 失敗。続行(手で取り外す必要があるかも)")

        # STEP11: stage9b swept_retreat j0=85
        rpp._observe_phase("pkp_stage9b_swept")
        swept_target = [SWEPT_J0_FOR_PLACE_JUDGE] + list(
            r_app_place_lookup["base_angles"][1:])
        print("\n[STEP11] swept_retreat → {}".format(fmt_angles(swept_target)))
        ok_swept, _ = rpp._do_move(
            swept_target, "pkp_stage9b_swept_retreat",
            TOL_SWEPT_RETREAT, record, dry_run=False)
        record["swept"] = {"arrived": bool(ok_swept),
                            "max_residual_deg": _last_move_max(record)}
        if not ok_swept:
            # A3 修正: swept_retreat 未到達は停止. release 済 = block 未保持
            # C3 修正: 本線と同形 (judgments["place"]) に unknown を記録
            rpp._observe_phase("pkp_judge_place")
            place_judgment = {
                "verdict": "unknown", "confidence": 0.0,
                "reason": "swept_retreat_failed", "model": None,
                "image": None, "raw": None,
            }
            record.setdefault("judgments", {})["place"] = place_judgment
            rpp._observe_verdict("unknown", 0.0, "swept_retreat_failed")
            rpp._observe_emit_now()
            print()
            print("=" * 70)
            print("[STOP] swept_retreat 未到達. release 済のため block 未保持.")
            print("[STOP] 未到達停止. recover-home で HOME へ戻すこと:")
            print("       python3 grasp_lift_pick_then_place.py recover-home")
            print("=" * 70)
            record["aborted_at"] = "swept"
            record["abort_reason"] = "swept_arrival_failed"
            record["note"] = "stopped_after_release_at_swept"
            _save(); append_index_row(record)
            return 1

        # STEP12: ★VLM judge ② 必須(本線 run_pick_place の stage9b 後と同位置・同形)
        # C3 修正: label="place" (本線同一)、judgments["place"] に記録 (本線同一キー)
        rpp._observe_phase("pkp_judge_place")
        print("\n[STEP12] ★VLM place 判定(必須・Anthropic API 呼び出し)")
        place_img = rpp._capture_for_vlm(
            "place", frames_dir, record, False, run_stem=run_stem)
        rpp._OBSERVE_STATE["cap_place"] = (str(place_img) if place_img else None)
        # 本線同様、判定とは別系統で HSV 検出を脇でかける
        try:
            rpp._OBSERVE_STATE["block_place_xz"] = rpp._detect_block_in_place_img(place_img)
        except Exception as e:
            print("[STEP12] [WARN] block_place_xz 観測検出例外: {}(continue)".format(e))
            rpp._OBSERVE_STATE["block_place_xz"] = None
        pj = rpp._judge_image(place_img, "place", False, None)
        place_judgment = {
            "verdict": pj.get("verdict"),
            "confidence": pj.get("confidence"),
            "reason": pj.get("reason"),
            "model": pj.get("model"),
            "image": str(place_img) if place_img else None,
            "raw": pj.get("raw"),
        }
        record.setdefault("judgments", {})["place"] = place_judgment
        rpp._observe_verdict(
            pj.get("verdict"), pj.get("confidence"), pj.get("reason"))
        rpp._observe_emit_now()
        print("[STEP12] place verdict={} confidence={} reason={}".format(
            pj.get("verdict"), pj.get("confidence"), pj.get("reason")))

        # STEP13: stage10 lift_back → approach_place_target
        # A3 修正: 未到達なら停止. release 済 = block 未保持
        rpp._observe_phase("pkp_stage10_lift_back")
        print("\n[STEP13] lift_back → approach_place_target")
        ok_lb, _ = rpp._do_move(
            approach_place_target, "pkp_stage10_lift_back",
            TOL_LIFT_BACK, record, dry_run=False)
        record["lift_back"] = {"arrived": bool(ok_lb),
                                "max_residual_deg": _last_move_max(record)}
        if not ok_lb:
            print()
            print("=" * 70)
            print("[STOP] lift_back 未到達. release 済のため block 未保持.")
            print("[STOP] 未到達停止. recover-home で HOME へ戻すこと")
            print("=" * 70)
            record["aborted_at"] = "lift_back"
            record["abort_reason"] = "lift_back_arrival_failed"
            record["note"] = "stopped_after_release_at_lift_back"
            _save(); append_index_row(record)
            return 1

        # STEP14: HOME(大移動なら中継分割)
        # A3 修正: 未到達なら停止. release 済 = block 未保持
        rpp._observe_phase("pkp_stage10_to_home")
        print("\n[STEP14] HOME 復帰(必要なら中継分割)")
        cur_home_pre = get_angles_safe()
        relay2_ok, relay2_steps, post_home, _, relay2_plan = execute_relay(
            cur_home_pre, HOME, "pkp_relay_to_home", TOL_HOME, record,
            auto_yes=auto_yes)
        record["relay_to_home"] = {
            "arrived": bool(relay2_ok), "steps_done": relay2_steps,
            "plan": [{"wp": wp, "label": lbl} for (wp, lbl) in relay2_plan],
        }
        mdh2 = max_delta(HOME, post_home)
        record["home_post"] = {
            "arrived": bool(relay2_ok and mdh2 is not None and mdh2 <= TOL_HOME),
            "post_actual": post_home, "max_residual_deg": mdh2,
        }
        if not record["home_post"]["arrived"]:
            print()
            print("=" * 70)
            print("[STOP] HOME 復帰未到達 max|Δ|={}. release 済のため block 未保持.".format(
                "{:.2f}°".format(mdh2) if mdh2 is not None else "None"))
            print("[STOP] 未到達停止. recover-home で残り距離を戻すこと")
            print("=" * 70)
            record["aborted_at"] = "home_post"
            record["abort_reason"] = "home_post_arrival_failed"
            record["note"] = "stopped_after_release_at_home"
            _save(); append_index_row(record)
            return 1

        # 完走
        pj = ((record.get("judgments") or {}).get("place")) or {}
        print()
        print("=" * 70)
        print("[DONE] seq={} pick→place 完走".format(seq))
        print("       PICK arrived={} LIFT arrived={} relay_pre={}".format(
            record["target"]["arrived"], record["lift"]["arrived"],
            record["relay_pre_place"]["arrived"]))
        print("       PLACE app={} place={} release={} swept={}".format(
            record["approach_place"]["arrived"], record["place"]["arrived"],
            record["release"]["ok"], record["swept"]["arrived"]))
        print("       VLM verdict={} conf={}".format(
            pj.get("verdict"), pj.get("confidence")))
        print("       lift_back={} home={}".format(
            record["lift_back"]["arrived"], record["home_post"]["arrived"]))
        print("=" * 70)
        record["note"] = "full_cycle_done"
        _save(); append_index_row(record)

    except KeyboardInterrupt:
        print("\n[INTERRUPT] Ctrl-C 検出")
        record["aborted_at"] = record.get("aborted_at") or "interrupted"
        record["abort_reason"] = "KeyboardInterrupt"
        _save(); append_index_row(record)
        final_return = 1
    finally:
        # A5 修正: 全 return 経路で fresh_mode(0) + socket close を必ず通す
        try:
            daemon_set_fresh_mode(0)
        except Exception:
            pass
        try:
            close_daemon_socket()
        except Exception:
            pass

    return final_return


def _last_move_max(record):
    """直近 rpp._do_move が記録した post_max_delta_deg を返す(無ければ None)"""
    moves = record.get("moves") or []
    if not moves:
        return None
    return moves[-1].get("post_max_delta_deg")


# ============================================================================
# サブコマンド: recover-home
# ============================================================================
def cmd_recover_home(release, auto_yes):
    print("=" * 70)
    print("[INIT] recover-home release={}".format(release))
    print("=" * 70)
    if not ensure_ssd_mounted():
        print("[WARN] KIOXIA 未マウント。ログ書き出さないが復帰は実施可")
        if not confirm("[CONT] 復帰しますか", auto_yes):
            return 1
    try:
        get_daemon_socket()
    except Exception as e:
        print("[ABORT] daemon ping 失敗: {}".format(e))
        return 2

    # A5 修正: 早期 return (cur=None 中断等) でも finally で fresh_mode(0)+socket close を必ず通す
    try:
        try:
            daemon_set_fresh_mode(1)
        except Exception:
            pass

        if release:
            print("[RECOVER] gripper 開放")
            try:
                daemon_set_gripper(0); time.sleep(GRIPPER_SLEEP)
            except Exception as e:
                print("[RECOVER] [WARN] gripper 例外: {}".format(e))

        cur = get_angles_safe()
        md = max_delta(HOME, cur)
        print("[RECOVER] 現在 {} → HOME 大|Δ|={}".format(
            fmt_angles(cur), "{:.2f}°".format(md) if md is not None else "None"))

        if cur is None:
            if not auto_yes:
                print("[ABORT] 現在角度を取得できず大移動判定不能(--yes で続行可)")
                return 1
            print("[WARN] cur None だが --yes で続行 → 単発 target を試行")
            arrived, _, mdr = send_and_wait(
                HOME, "recover_single_no_cur", TOL_HOME)
            if arrived:
                print("[RECOVER] HOME 到達 OK max|Δ|={:.2f}°".format(mdr))
                return 0
            print("[RECOVER] [WARN] HOME 未到達 max|Δ|={}".format(
                "{:.2f}°".format(mdr) if mdr is not None else "None"))
            return 1

        # A4 修正の plan_relay (全6軸線形補間) を recover-home でも使う
        plan = plan_relay(cur, HOME)
        if len(plan) > 1:
            print("[RECOVER] max|Δ|={:.2f}° > {:.0f}° → 中継 {} step に分割(6軸補間)"
                  .format(md, LARGE_MOVE_THRESHOLD_DEG, len(plan)))
        else:
            print("[RECOVER] 単発 (max|Δ| <= {:.0f}°)"
                  .format(LARGE_MOVE_THRESHOLD_DEG))

        for i, (wp, lbl) in enumerate(plan, 1):
            arrived, _, mdwp = send_and_wait(
                wp, "recover_{}_{}".format(i, lbl), TOL_HOME)
            if not arrived:
                print("[RECOVER] {} 未到達 max|Δ|={}".format(
                    lbl, "{:.2f}°".format(mdwp) if mdwp is not None else "None"))
                return 1

        print("[RECOVER] HOME 復帰 OK ({} step{})".format(
            len(plan), " (中継分割)" if len(plan) > 1 else ""))
        return 0
    finally:
        try:
            daemon_set_fresh_mode(0)
        except Exception:
            pass
        try:
            close_daemon_socket()
        except Exception:
            pass


# ============================================================================
# main
# ============================================================================
def main():
    ap = argparse.ArgumentParser(
        description="教示Z姿勢 PICK → 従来 PLACE 通し(案B・本線非改変)")
    sub = ap.add_subparsers(dest="cmd")
    sub.required = True

    p_run = sub.add_parser("run", help="block_z 自動選択で PICK→PLACE 通し実行")
    p_run.add_argument("--dry-run", action="store_true",
                       help="送信せず全 step の計画+自動選択ロジック+中継分割を表示")
    p_run.add_argument("--yes", action="store_true",
                       help="現在角度 None の警告を自動 yes")

    p_rec = sub.add_parser("recover-home", help="掴んだまま停止からの HOME 復帰")
    p_rec.add_argument("--release", action="store_true", help="復帰前に gripper 開放")
    p_rec.add_argument("--yes", action="store_true", help="警告を自動 yes")

    args = ap.parse_args()
    if args.cmd == "run":
        sys.exit(cmd_run(args.dry_run, args.yes))
    elif args.cmd == "recover-home":
        sys.exit(cmd_recover_home(args.release, args.yes))


if __name__ == "__main__":
    main()
※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。