mechArm 270 Pi 動的追従の限界を探る Log#43 | PLACEの位置は合った。だがアームの手首が捻れ、姿勢が深すぎた
LOG263R2R2R2026/7/2

mechArm 270 Pi 動的追従の限界を探る Log#43 | PLACEの位置は合った。だがアームの手首が捻れ、姿勢が深すぎた

PLACE の置き位置がずれる。白い箱を狙っているのに、ブロックが箱の縁の外へ落ちる。この一点を潰しにいったら、原因が一つ解けるたびに次の原因が顔を出す一日になった。円弧拘束、腕の捻り(j3)、グリッパーの捻り(j5)、そして関節の深さ。順に測り、順に直し、そのたびに次が出てきた。記録として、出てきた順に残す。

PLACE のずれを測る

まず測る。狙った箱の中心と、実際にブロックが落ちた位置の差を、実機ログから出すところから始めた。

PICK 側はすでに FK テーブル(85万点の全 6 軸姿勢を持つ対応表)を引いて降下している。stage4 の残差は 1mm 台で安定している。一方 PLACE の降下は、まだ古い方式(j0 を一軸だけ振って半径固定の弧で位置を合わせる方式)のままだった。この差がそのまま出た。

過去の失敗回 2 本の lookup 残差を並べる。X(左右)と Z(前後)の残差を mm で出した。

run_0001_20260629_163852:
stage8_place  target [-148.6, 227.7]  interp [-148.6, 264.2]  z_resid 36.4mm
run_0001_20260629_164021:
stage7_approach_place target [-187.6, 157.3] interp [-187.6, 234.7] z_resid 77.4
stage8_place          target [-187.6, 157.3] interp [-187.6, 240.9] z_resid 83.6

X 残差はゼロ。Z 残差だけが常にプラス(奥)方向に出て、しかも箱が手前にあるほど残差が膨らむ。1 本目は 36.4mm、2 本目は 83.6mm。

なぜ X は合うのに Z だけずれるのか。PLACE の姿勢素材(20 点の教示姿勢)の中身を見た。20 点すべてで j1〜j5 が固定されていて、j0 だけが動いていた。j0 を振ると爪先は半径一定の円弧を描く。X と Z が円弧の上で連動する。X を合わせにいった瞬間、Z は円弧が通る値に固定される。X と Z を独立に狙えない。

これが PLACE のずれの正体だった。爪先オフセットでも、カメラの歪みでも、素材のレンジ不足でもない。「置き場所を j0 一軸で決めていて、爪先が半径固定の円弧の上しか動けない」という構造。箱がその円弧から手前に離れているほど、奥へずれて落とす。

FK 方式へ切り替える

PICK の descend はすでに FK テーブルを引いて、x-z を独立に狙えている(残差 1mm 台)。PLACE も同じ方式に揃える。円弧を捨てて、FK テーブルから箱位置に届く姿勢を直接引く。

コードの切り替え点は一箇所に集約されていた。phase がどの集合に入るかで FK か円弧かを分岐している。

FK_LOOKUP_PHASES = ("pick",)

ここに place_withblock を足せば PLACE も FK 方式になる。ただし一行では済まない。FK テーブルは「爪先の高さ帯(ee_y)」でフィルタしてから x-z 最近傍を取る。PICK 帯は床すれすれの 10〜40mm。PLACE は箱の中にブロックを置くので、床ではなく箱の高さ基準の帯が要る。箱の物理高さは 43mm で固定。

まず箱を置く位置の周辺に、FK テーブルがどんな高さの点を持っているか測った。補正後の狙い(x=−0.010, z=+0.108)の近傍 30mm の点を出す。

box近傍30mm以内の点数: 86
ee_y mm  min/median/max: -108.9 / -72.0 / 323.9
ee_y分布(mm): [-109, -108, -108, ... (ほぼ全部マイナス)]

この箱位置の近傍点は ee_y がほぼ床下(−108mm 前後)だった。物理的にありえない高さの姿勢が混じっている。この位置は FK テーブルの PLACE 有効点が薄い。帯を広げても拾えるのは使えない姿勢になる。

そこで FK テーブル全体で、箱高さ帯(ee_y 35〜50mm)かつ送れる姿勢(j1≤88)が PLACE 側にどれだけあるかを測った。

ee_y 35-50mm かつ j1<=88 の点数: 8981
X範囲mm: -328 .. 305
Z範囲mm: 21 .. 339
うちX<0(PLACE側)の点数: 8507
PLACE側 X範囲mm: -328 .. -0
PLACE側 Z範囲mm: 21 .. 339

PLACE 領域全域に 8,507 点。テーブルは十分カバーしている。out_of_range になったのは「箱位置がテーブルの薄いゾーンの端だった」だけで、テーブル不足ではない。箱をテーブルの濃いゾーンに置けば FK で PLACE が成立する。

濃いゾーンの箱位置で fk_lookup 単体を叩いた。

$ python3 fk_lookup.py --phase place_withblock --x -0.22 --z 0.19 --ee-y-min 0.033 --ee-y-max 0.055
method: fk_nearest
x_residual_m: 0.00067   (0.67mm)
z_residual_m: 0.00002   (0.02mm)
ee_y: 40.9mm
joint_limit_ok: true

円弧時代に Z 残差 36〜84mm だったものが、FK で Z 残差 0.02mm。x-z を独立にほぼ完全に狙えている。measure で立てた仮説(円弧拘束が原因、FK で解ける)が裏取りできた。

ブロックを持って降りると高さが足りない

実機で通した。stage4(PICK)は FK で正確に降りる。だが stage8(PLACE)で爪が箱の縁に当たった。

stage8_place  method=fk_nearest  x_resid=0.53mm  z_resid=0.24mm
j1=85.00   ← 前傾で低い姿勢。ee_y が低い点を拾った

ee_y 帯を 33〜55mm(箱高さ 43mm を中心に取った仮値)にしていた。だが PLACE では爪がブロックを掴んで降りる。ブロックは爪先の下にぶら下がっている。爪先が 40mm だと、ブロックの下端は箱の縁(43mm)より下に来て、箱の壁に激突する。

爪先基準ではなく「ブロック下端が箱の縁を越える高さ」で帯を取り直す必要があった。爪先の ee_y は 43mm + ブロックの爪下寸法 + クリアランス。仮に 70〜100mm 帯で fk_lookup を叩いた。

$ python3 fk_lookup.py --phase place_withblock --x -0.218 --z 0.208 --ee-y-min 0.070 --ee-y-max 0.100
method: fk_nearest
x_resid=0.09mm  z_resid=0.07mm
ee_y: 84.9mm   ← 箱の縁43mmの約42mm上。ブロック下端が縁を越える
j1: 82.0
joint_limit_ok: true

70〜100mm 帯で成立。ee_y 84.9mm はブロックを縁の上から箱の中へ落とす高さ。この帯に直して通したら、ブロックが箱に入った。

[RESULT] 完走
[LEDGER] append OK: (in_box_geom=True dist=39.0)
stage8_place  method=fk_nearest  x_resid=0.53mm  z_resid=0.24mm  arrived=True

円弧時代は同じ箱位置で in_box=False だった。FK 化と帯の取り直しで、in_box=True・dist 39.0mm。ブロックが箱に入った。

補正記憶が置きズレを縮める

FK 化と同時に、補正記憶を PLACE にも回した。PICK では稼働している仕組みで、20mm 格子のセルごとに「狙いに対して実際どれだけずれたか」を貯め、次回その狙いを先に補正してから lookup を引く。

ただし補正記憶のセルキーは座標だけで作られていて、PICK と PLACE の区別がなかった。そのまま回すと pick の補正(空爪の爪先ずれ)と place の補正(ブロック保持の落下点ずれ)が同じセルに混ざる。物理的な意味が違うので混ぜたら壊れる。セルキーに phase の接頭辞を足して名前空間を分けた。

[CORR] pick_x180_z280   dx=+0.43 dz=-7.13  n=1
[CORR] place_x-220_z220 dx=+26.92 dz=+28.17 n=1

pick_ と place_ で別空間に入る。1 本目は学習だけ(n=1)。2 本目で同じ place セルに当たると、学習した補正が適用される。

置きズレの実測を並べる。

1本目: in_box dist 39.0mm
2本目: in_box dist 27.8mm  (補正適用後)

補正 1 回で 39→28mm。位置ずれは学習で縮んだ。「電源が入って動いている間、失敗を学んで動きが良くなっていくか」という評価軸に、数字で答えが出た局面だった。

腕が捻れる(j3)

2 本目は in_box dist が縮んだのに、箱に入らなかった。爪が箱に当たった。approach_place から place への姿勢を並べる。

approach_place: [+43.52, 57.39, 17.49,  +0.08, -89.20, -29.44]
place(stage8):  [+38.00, 85.00, -27.00, -32.00, -75.00, -30.00]
                  j0     j1     j2      j3(-32°) j4     j5

j3(腕の捻り。前腕が軸周りに回る)が +0.08° → −32.0°。approach_place から place は「アームを下げるだけ」で腕を捻る必要はないのに、32° 捻れている。前腕が捻れると、その先のグリッパーの向きごと回る。

原因は fk_lookup が x-z 2D 最近傍だけで 6 関節を選び、j3 を考慮しないこと。FK テーブルには j3 を捻った姿勢も含まれ、x-z 最近傍がたまたま捻れた点だった。

補正では直らない。補正が動かすのは狙いの x-z だけで、関節を直接いじらない。狙いを動かしても、動いた先の最近傍がまた j3 捻れ点なら、やはり捻れる。実際、2 本のデータで j3 は -27→-32 とむしろ悪化していた。位置と j3 は独立している。

j3 を縛る必要があった。FK テーブルで、箱位置に対し j3 許容幅を変えて最近傍を測った。

j3許容±5° : 候補623点  最近傍dist=5.2mm  そのj3=-2.0°
j3許容±10° : 候補1142点  最近傍dist=5.2mm  そのj3=-2.0°
j3許容±15° : 候補1489点  最近傍dist=4.6mm  そのj3=-12.0°
j3許容±999° : 候補2606点  最近傍dist=0.7mm  そのj3=-32.0°

制約なしだと dist 0.7mm の点を選ぶが、その代償が j3=−32° の捻り。j3±5° に絞れば、j3=−2°(ほぼ捻らない)で dist 5.2mm。位置精度はわずかに落ちるが、5.2mm は補正が吸収できる範囲。腕の捻りが消えて位置も合う。

fk_lookup に j3 帯フィルタを足した。ee_y 帯フィルタと同じ構造で、候補マスクに「approach の j3 から ±5° 以内」を AND する。

if j3_ref is not None:
    try:
        j3_ref_f = float(j3_ref); j3_tol_f = float(j3_tol_deg)
    except (TypeError, ValueError):
        j3_ref_f = None
    if j3_ref_f is not None:
        mask_j3 = (joints[:, 3] >= j3_ref_f - j3_tol_f) & (joints[:, 3] <= j3_ref_f + j3_tol_f)
        mask = mask & mask_j3

j3_ref=None のとき従来と完全に同じ挙動になる。PICK は j3_ref を渡さないので回帰ゼロ。place_withblock 呼び出しで approach_place の実 j3(≈0)を渡す。実装は Claude Code、外部レビューを codex に通してから実機へ。

実機で j3 は −2.4° に収まった。approach から place で j3 がほぼ動かない。「下げるだけ・腕を捻らない」が実現した。

グリッパーが捻れる(j5)

j3 を直したら、次は j5 が捻れた。j3 が前腕の捻りだったのに対し、j5 はグリッパー自体の捻り。軸が違うので、j3 を縛っても j5 は素通りだった。

approach_place: j5=-29.44
stage8 place:   j5= 0.00   ← 29.4°の捻り

j3 と同じ穴だった。fk_lookup は j5 も見ていない。j5=0 の点(グリッパーの捻りが 30° 違う姿勢)を x-z 最近傍で拾った。爪の開閉方向が箱に対して回り、縁に当たる。

同じように測った。j3±5° に加えて j5 許容幅を変える。

j3±5 ∩ j5±5: 545点 dist=6.5mm j3=3.0 j5=-30.0
j3±5 ∩ j5±10: 545点 dist=6.5mm j3=3.0 j5=-30.0
j3±5 ∩ j5±15: 545点 dist=6.5mm j3=3.0 j5=-30.0
j3±5 ∩ j5±999: 601点 dist=5.6mm j3=-2.1 j5=0.0

j5±5° で候補 545 点・dist 6.5mm・j5=−30.0(捻らない)。制約なしだと dist 5.6mm だが j5=0(30° 捻り)。位置精度はほぼ同じで捻りが消える。

j3 帯フィルタと完全に同型の j5 帯フィルタを足した。approach_place の j5(≈−29.44)を基準に ±5° で絞る。これも実装 → codex レビュー → 実機。実機で j5 は −30 に揃った。

置きズレを測るタイミングが間違っていた

補正記憶が学習に使う置きズレ Δ の測り方に、根本的な間違いがあった。これまで Δ は、ブロックを離してアームが退避した後の画像から測っていた。

爪を開く前(ブロックを落とす前)に画像を取得して位置を出さないとズレ位置はわからないよ。

退避後に測ると、「置いたズレ」に「離してから箱の縁で転がったズレ」が混ざる。補正の教師データが濁る。正しくは、PLACE 姿勢に到達して爪を開く前、ブロックを保持したままで撮影し、その位置と箱中心の差を測る。離す前ならブロックは爪に固定されて転がらない。上から撮っているのでブロックは写る。

撮影のタイミングだけを変えた。stage8 到達後・stage9(グリッパー open)の直前に 1 枚撮り、その離す前ブロック位置を補正学習の入力にする。物理姿勢・送角・成否判定・台帳の列は一切触らない。

実機で離す前撮影が動いた。

[CAP-VLM] place_pre 保存: ...place_pre.jpg
[PLACE_PRE] block xz_m=(-0.2181,+0.2442)

ブロックを掴んだまま撮影し、検出も成立。転がりを含まない純粋な置きズレを測る基盤ができた。

姿勢が深すぎて届かない

j3・j5 を縛り、離す前 Δ の撮影も入れた状態で通した。すると今度は stage8 が未到達で止まった。

[LOOKUP] stage8_place  method=fk_nearest  x_resid=0.00215m  z_resid=0.00862m  joint_ok=True
[SEND] stage8_place: send_angles [ +43.00, +87.00, -10.00, +3.00, -101.00, -30.00]
[SEND] [TIMEOUT] 8.0s 内に未到達: max|Δ|=27.84° actual=[ +41.57, +59.50, +17.84, -0.43, -75.32, -29.26]
[SEND] リトライしません: pre→last max|Δ| 13.53° >= 1.0° (部分移動/ストール疑い)
[MOVE  ] stage8_place  arrived=False retried=False post_max|Δ|=27.840°
[ABORT ] stage=8  reason=到達失敗

j5 は −30 に揃った(狙い通り)。j3 も +3° に収まった。だが j4=−101°・j1=87° という極端に深い姿勢が選ばれ、アームがそこまで曲がりきれず 27° 未到達で物理的に止まった。ブロックを離す前に止まったので、箱に入れられなかった。

構造が見えてきた。fk_lookup は「ee_y 帯 ∩ j3 帯 ∩ j5 帯の中で x-z 最近傍」を取るだけで、選ばれた姿勢が approach から無理なく届くか(6 軸の移動量が小さいか)は見ていない。捻り(j3=腕・j5=グリッパー)を縛っても、残った曲げの自由度(j1=肩・j2=肘・j4=手首ピッチ)が極端な深さに振れる点を選んでしまう。

一つ縛ると次が振れる。今日はこの連鎖をそのまま踏んだ。円弧拘束を FK で解いたら腕の捻りが出て、j3 を縛ったらグリッパーの捻り(j5)が出て、j5 を縛ったら曲げの深さ(j1・j4)が出た。捻りと曲げは別の軸で、1 軸ずつ帯フィルタを足す対症療法では追いつかない。FK 点の選択に「approach からの姿勢連続性(6 軸の移動量最小)」を入れる、という根本の設計が次に要る。PLACE の descend は、次のセッションで再度そこから修正する。

実装コード全量

この日の PLACE 改修(FK 化・補正 pick/place 分離・j3 帯フィルタ・j5 帯フィルタ・置きズレ測定の離す前撮影)が入った run_pick_place.py の全量。後から本人や AI が見返して再現できるよう、省略せず全て残す。fk_lookup.py(j3/j5 帯フィルタの本体側)は別途追記する。

run_pick_place.py(全量)

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""run_pick_place.py — 通し Pick & Place 本体(j0 対応表方式・実装単位2)

赤ブロックを上カメラで検出 → j0 対応表で各 phase の j0 を引く → 単発 send_angles で
基準姿勢へ移動 → グリッパー閉/開 → 箱(DAY232 確定固定値)へ運ぶ。
段ごとに lookup 結果と移動結果を構造化ログに全量記録する。

設計の要点(codex/gemini レビュー反映版):
- 流用: j0_lookup / j0_sweep_scan._send_and_wait_arrival / visual_servo_pick の検出と daemon I/F
- 姿勢: lookup の target_angles をそのまま送る。J5 を -30 へ強制上書きしない
- 送信: 単発 send_angles のみ(_send_and_wait_arrival 経由)。ストリーミングしない
- ガード: joint_limit_ok / method=interp / x_residual<=THRESH の3条件を送信前に検査
- z_residual: 工程跨ぎ問い合わせでは構造的に大きい(pick弧 vs 箱z)。情報ログのみで ABORT しない
- グリッパー: daemon_set_gripper の戻り値は判定に使わない。各操作後に sleep>=2s(DAY175 対策)
- 工程跨ぎ位置データ禁止: block_xz は pick 系のみ、BOX_XZ は place 系のみ

絶対ルール:
- send_coords() / release_all_servos() を呼ばない
- Stiffness/Damping を触らない
- fk_scan_motion_based_v2.csv を書き換えない

CLI:
  python3 run_pick_place.py                  # 一本通し実機実行
  python3 run_pick_place.py --dry-run        # 送信せず lookup+ガード判定だけ出力
  python3 run_pick_place.py --dry-run --dummy-block-x 0.05 --dummy-block-z 0.30
"""

from __future__ import print_function

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


# ---- パス解決 ----------------------------------------------------------------
_THIS = Path(__file__).resolve()
_PLACE_MODULES_DIR = _THIS.parent
_SCRIPTS_ROOT = _THIS.parents[3]
_DRAG_TEACHING_DIR = _SCRIPTS_ROOT / "dynamic_tracking_limits" / "drag_teaching"
sys.path.insert(0, str(_SCRIPTS_ROOT))
sys.path.insert(0, str(_PLACE_MODULES_DIR))
sys.path.insert(0, str(_DRAG_TEACHING_DIR))


# ---- pymycobot スタブ(waypoint_replay 経由の import を Mac 上で通すため) --
if "pymycobot" not in sys.modules:
    _pkg = types.ModuleType("pymycobot")
    _mod = types.ModuleType("pymycobot.mycobot")

    class _MyCobotStub(object):
        def __init__(self, *args, **kwargs):
            raise RuntimeError(
                "run_pick_place.py: MyCobot stub is not callable. "
                "アーム制御は daemon TCP 経由のみ。")

    _mod.MyCobot = _MyCobotStub
    sys.modules["pymycobot"] = _pkg
    sys.modules["pymycobot.mycobot"] = _mod


# ---- 流用 import ------------------------------------------------------------
# 段3/4/7/8 の j0 決定(B 案 = x のみマッチ)
from j0_lookup import j0_lookup  # noqa: E402  (全 stage で利用 / DAY257 復旧)
from fk_lookup import fk_lookup  # noqa: E402  (将来の実験用に温存。現状未呼出)

# DAY257 後段: descend (stage4_pick) のみ fk_lookup に切替。
# DAY262: place_withblock も fk_lookup 化 (円弧 lookup では x-z が円弧上のみで Z を狙えない.
#   in_box=False 2 本 (Z 残差 +36.4/+83.6mm・両方奥) の解決策として X-Z 独立化)。
# approach_pick / approach_place は j0_lookup 維持 (中間姿勢を壊さない)。
# fk_lookup は j1<=88 safety 復活版で、descend で z を動かせるようにする目的。
# OFF にする場合は () に戻すか、"pick" / "place_withblock" を抜く。
FK_LOOKUP_PHASES = ("pick", "place_withblock")

# DAY262: place_withblock 用の ee_y 帯 (PICK 用 10-40mm 床近く帯とは別).
# 箱高さ 43mm を中心に ±10mm 強. 仮値 (measure-first: 初回実機で out_of_range 頻度と
# 落下点を見て調整). 帯外 → fk_lookup が method="out_of_range" を返して _check_guard が
# ABORT する (送角しない). PICK 側 (EE_Y_MIN/MAX_DEFAULT) は fk_lookup.py 側で保持.
PLACE_EE_Y_MIN = 0.070   # m (33mm)
PLACE_EE_Y_MAX = 0.100   # m (55mm)

# 段1/3/4/6/7/8/10 の送信・到達確認(単発 send_angles + 沈黙リトライ最大1回)
from j0_sweep_scan import (  # noqa: E402
    _send_and_wait_arrival,
    _daemon_get_angles_filtered,
)

# 段2 の block+box 同時検出(detect_positions が内部で camera_resolver →
# cv2.VideoCapture → warm-up → setup_homography_and_mat_range → detect_red_block と
# 06f detect_white_box_once を一括で叩く。中央値集約 repeat 対応)
from detect_positions_once import detect_positions  # noqa: E402
from visual_servo_pick import (  # noqa: E402
    get_daemon_socket,
    close_daemon_socket,
    daemon_set_gripper,
    daemon_set_gripper_value,   # DAY261: close の push 保持用 (open は state(0) のまま)
    daemon_set_fresh_mode,
    detect_red_block,
    pixel_to_unity,
)

# VLM 判定用のフレーム撮影と Anthropic API 呼び出し
import cv2  # noqa: E402
import numpy as np  # noqa: E402
from camera_resolver import resolve_cameras  # noqa: E402
from vlm_judge import judge as vlm_judge_call  # noqa: E402

# 観測 emit(脇 UDP・fire-and-forget・例外は emit 側で握る)
# 観測モジュールが理由で制御が止まらないよう、import を guard する
try:
    from observe.emit import emit as _observe_emit  # noqa: E402
except Exception:
    def _observe_emit(*_args, **_kwargs):  # 観測無効でも制御は動く
        pass


# ---- 定数(仕様準拠) -------------------------------------------------------
HOME = [0, 0, 0, 0, 0, -30]
SPEED = 20
BOX_XZ = (-0.010, 0.108)          # DAY232 実測値。dry-run のデフォルト + 緊急時固定値として残置
                                  # 実機 run では段2 で実検出した box_xz を使う(DAY236〜)
X_RESID_THRESH = 0.02             # m。これ超で ABORT
BOX_SCORE_THRESH = 0.5            # 06f のテンプレ score。これ未満は箱位置不確実として段2 ABORT
                                  # 根拠: DAY230 実績 score 0.618、06f 既定 TEMPLATE_SCORE_THRESHOLD=0.5
GRIPPER_SLEEP = 2.0               # s。DAY175 対策(>=2s)
GRIP_VALUE = 20                   # close の push 保持目標値(DAY261 tour 実測の暫定値)。
                                  # 閉じ切り state(1) は「位置で止まり握り直さず落ちる」(DAY259)。
                                  # set_gripper_value(20) で閉じ切らず押し込み力を残す。
                                  # gv は 開=97 / 把持厚み相当≈36 / これより小さいほど押し込み深い。
                                  # 効かなければこの値のみ振る(descend 把持は tour と条件が違う点に留意)。
STAGE9_RELEASE_PRE_SLEEP = 2.0    # s。stage9 release は直前 PLACE 後に一呼吸置く
STAGE9_RELEASE_OPEN_ATTEMPTS = 2  # open→sleep→open で stage9 の離しを確実化
TOL_APPROACH = 2.0                # approach 系
TOL_APPROACH_PLACE = 3.0          # stage7 approach_place 専用(大移動で最後の数度が埋まらない対策)
TOL_SWEPT_RETREAT = 3.0           # stage9b swept_retreat 専用(残差2.55°で未到達になる対策・他stage非共有)
TOL_LIFT_BACK = 3.0               # stage10 lift_back 専用。実測残差 2.46〜2.55°(j2主因)を吸収。
                                  # stage9b TOL_SWEPT_RETREAT=3.0 と同形。他stageと非共有
TOL_PICK_PLACE = 3.0              # pick / place 系
TOL_HOME = 2.5                    # HOME 復帰
SEND_WAIT_SEC = 1.0               # daemon 側送信直後の get_angles ログ遅延
DETECT_REPEAT = 3                 # 段2 検出の中央値集約サンプル数
SWEPT_J0_FOR_PLACE_JUDGE = 85.0   # 段9b の退避 j0(approach_place 採用版スイープ端=採用済み値)
MAX_PICK_ATTEMPTS = 3             # pick=no 掴み直しの最大試行回数(初回含む)
SWEPT_J0_FOR_PICK_RETREAT = 85.0  # 掴み直し前のカメラ退避 j0(approach_pick base で振る・place の退避と同形)
VLM_CAPTURE_WARMUP_FRAMES = 30    # VLM 撮影前の warm-up
VLM_CAPTURE_DROP_FRAMES = 5       # 撮影直前のバッファクリア

# JSON・画像の出力先(KIOXIA・1 run = 1 フォルダで集約)。
# LEDGER_DIR と同じドライブ。未マウント時の誤書き込みは run() 冒頭ガードで防ぐ。
LOG_ROOT = Path("/Volumes/KIOXIA/r2r2r-data/runs/run_pick_place")


# ---- 試行台帳 CSV(KIOXIA SSD 専用・ローカルへフォールバックしない)-------
LEDGER_DIR = Path("/Volumes/KIOXIA/r2r2r-data/runs")
LEDGER_CSV_PATH = LEDGER_DIR / "pick_place_ledger_v3.csv"
# in_box 判定の閾値(mm): 白箱半幅 40mm + ブロック半幅 12mm ≈ 52mm 目安.
# 実機を見て調整可能な 1 定数.
IN_BOX_TOL_MM = 52.0

# ---- 試行間補正記憶 (DAY257) ---------------------------------------------
# tip 測定で得た Δ(dx,dz) をブロック (x,z) のセル別に蓄え、次回 lookup 直前に
# block_xz から Δ を引いて狙いを補正する。EMA で 1 セル 1 値に集約。
# 記憶 JSON は KIOXIA SSD の diag/ 配下。実機ローカルには書き込まない。
CELL_MM = 20.0                           # セル幅 (mm)
EMA_ALPHA = 0.5                          # EMA 重み (新 : 旧)
MAX_CORRECTION_MM = 40.0                 # 補正量の絶対値クランプ (mm)
CORR_MEM_PATH = Path(
    "/Volumes/KIOXIA/r2r2r-data/diag/correction_memory.json")
USE_CORRECTION = True                    # False で補正完全無効 (回帰確認用)

# DAY257 後段: pick 判定の二重チェック閾値。VLM yes でも tip 計測の dist がこれを
# 超えていれば「掴めていない」として no に倒す (over-pickの誤学習を防ぐ)。
# 単位 mm。実機データを見て振る前提の可変定数。
PICK_DIST_MAX_MM = 30.0
LEDGER_COLS = [
    "timestamp", "run_stem", "trial_no",
    "pick_verdict", "pick_reason", "pick_confidence",
    "place_verdict", "place_reason", "place_confidence",
    "block_pick_x", "block_pick_z",
    "block_place_x", "block_place_z",
    "box_x", "box_z",
    "in_box_geom", "in_box_dist_mm",
    "final_j1", "final_j2", "final_j3",
    "final_j4", "final_j5", "final_j6",
    "gripper", "place_capture_path",
    "tip_pick_x", "tip_pick_z",
    "tip_block_dx_mm", "tip_block_dz_mm", "tip_block_dist_mm",
    "run_dir",
    "tip_capture_path", "pick_capture_path",
    # v3 ヘッダ 34-36(align 系:本実装では出所なし・空欄固定)
    "align_j0_final", "align_red_x_mm", "align_converged",
    # v3 ヘッダ 37-43(pick で参照した姿勢と到達時の実測角)
    "pick_pose_id",
    "pick_used_j0", "pick_used_j1", "pick_used_j2",
    "pick_used_j3", "pick_used_j4", "pick_used_j5",
    # v3 ヘッダ 44-50(place_withblock で参照した姿勢と到達時の実測角)
    "place_pose_id",
    "place_used_j0", "place_used_j1", "place_used_j2",
    "place_used_j3", "place_used_j4", "place_used_j5",
    # DAY257 試行間補正の記録 (5 列追加・v3 ヘッダ末尾 51-55)
    "corr_cell_key",        # 例: "x180_z260"
    "corr_applied_dx",      # 適用した補正 mm (x)
    "corr_applied_dz",      # 適用した補正 mm (z)
    "target_before",        # 補正前 (block) [mm]: "(+196.1,+252.5)" 等
    "target_after",         # 補正後 (lookup へ渡した値) [mm]
    # DAY257 後段: pick 判定の VLM / dist override 内訳 (2 列追加)
    "vlm_pick_verdict",     # VLM 単独判定 (override 前) "yes/no/unknown/..."
    "pick_dist_override",   # tip_dist 二重チェックで no に倒したか "True"/"False"/""
]


# 爪先(青シール)検出パラメータ(detect_blue_markers.py DAY168 のHSV閾値を流用)
# 座標変換は本線の pixel_to_unity に統一(このモジュール独自の homography は使わない)
TIP_BLUE_LOWER = (100, 100, 50)
TIP_BLUE_UPPER = (130, 255, 255)
TIP_MIN_AREA   = 80
TIP_MAX_AREA   = 8000


# ---- 観測 emit 用の状態(run() が更新、_do_move が読む)-----------------
# 状態は run() スコープ所有(_observe_phase / _observe_gripper ヘルパ経由で更新)。
# 内側関数(stage_lookup_and_move)には phase 更新を持たせない。
# emit は _do_move(移動完了時)に加え、非移動 stage(stage2/5/9/9b・regrasp 再検出)でも
# _observe_emit_now() で通知する。全 stage を履歴表示するため 1 サイト集約は意図的に解除済み。
_OBSERVE_STATE = {
    "phase": None,
    "phase_t0_ms": None,
    "gripper": None,
    "verdict": None,
    "block_xz": None,
    "box_xz": None,
    "block_place_xz": None,
    "tip_pick": None,   # PICK到達時の爪先vs block差分(_append_ledger が読む)
    "run_dir": None,    # この run の出力フォルダ(KIOXIA)。画面のファイル情報表示用
    "cap_tip": None,    # stage4_pick_tip 画像パス(画面/CSV用)
    "cap_pick": None,   # pick 判定画像パス
    "cap_place": None,  # place 判定画像パス
    # 非移動 stage の通知専用 emit が再利用する angles キャッシュ.
    # 直近 _do_move の post_actual で更新する(新規 daemon I/O ゼロ).
    "last_angles": None,
    "run_stem": None,
    "task": "pick_and_place_with_j0_table",
}


def _observe_phase(phase_str):
    """run() からフェーズ遷移を記録する。phase_t0 も自動更新."""
    _OBSERVE_STATE["phase"] = phase_str
    _OBSERVE_STATE["phase_t0_ms"] = int(time.time() * 1000)


def _observe_gripper(state):
    """run() から gripper 状態を記録する(0=open / 1=close)."""
    _OBSERVE_STATE["gripper"] = state


def _detect_block_in_place_img(image_path):
    """place_img(保存済み jpg)から赤ブロックの Unity 座標 [x, z] を検出する.
    stage2 と同じ detect_red_block + pixel_to_unity を使う(同 homography).
    全例外を握り、失敗時 None を返す(制御を絶対止めない).
    """
    if image_path is None:
        return None
    try:
        frame = cv2.imread(str(image_path))
        if frame is None:
            return None
        pixel = detect_red_block(frame)
        if pixel is None:
            return None
        cx, cy = pixel
        ux, uz = pixel_to_unity(cx, cy)
        return [float(ux), float(uz)]
    except Exception:
        return None


def _detect_blue_tips_px(frame):
    """フレームから青シールを検出し、面積上位3点のpixel重心 [(cx,cy),...] を返す.
    変換はしない(pixel座標のみ)。3点未満なら None。全例外を握って None.
    """
    try:
        hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
        lower = np.array(TIP_BLUE_LOWER)
        upper = np.array(TIP_BLUE_UPPER)
        mask = cv2.inRange(hsv, lower, upper)
        k = np.ones((5, 5), np.uint8)
        mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k)
        mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k)
        cnts, _ = cv2.findContours(
            mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        pts = []
        for c in cnts:
            a = cv2.contourArea(c)
            if a < TIP_MIN_AREA or a > TIP_MAX_AREA:
                continue
            m = cv2.moments(c)
            if m["m00"] == 0:
                continue
            cx = m["m10"] / m["m00"]
            cy = m["m01"] / m["m00"]
            pts.append((cx, cy, a))
        if len(pts) < 3:
            return None
        pts.sort(key=lambda t: t[2], reverse=True)   # 面積降順
        top3 = [(p[0], p[1]) for p in pts[:3]]        # 上位3点(ノイズ除去)
        return top3
    except Exception:
        return None


def _measure_tip_vs_block(image_path):
    """pick到達フレームから 爪先中点 と 赤block を同一frame・同一 pixel_to_unity
    で検出し差分を返す. 失敗時 None(制御を絶対止めない).
    戻り値 dict: tip_x, tip_z, block_x, block_z(Unity m), dx_mm, dz_mm, dist_mm

    姿勢非依存の爪先判定: 上位3点のうち「互いに最も近い2点=爪先」、
    残り1点=手首. これは横/上カメラやアーム向きに依存しない(DAY168 の
    「Y下方2点」は姿勢依存なので採用しない).
    ガード: 爪先間距離 < 手首までの距離 が崩れたら(開きすぎ等で前提破綻)
    差分を None 記録してそのまま grip へ進む(測定が動作に干渉しない).
    """
    if image_path is None:
        return None
    try:
        frame = cv2.imread(str(image_path))
        if frame is None:
            return None

        # --- 爪先(青シール上位3点 → 最近2点の中点)---
        top3 = _detect_blue_tips_px(frame)
        if top3 is None:
            return None
        import itertools
        pairs = list(itertools.combinations(range(3), 2))
        def _d(p, q):
            return math.hypot(p[0] - q[0], p[1] - q[1])
        # 最近ペア=爪先
        i, j = min(pairs, key=lambda ij: _d(top3[ij[0]], top3[ij[1]]))
        tip1, tip2 = top3[i], top3[j]
        wrist = top3[3 - i - j]   # 0+1+2=3 → 残り1点
        mid_px = ((tip1[0] + tip2[0]) / 2.0, (tip1[1] + tip2[1]) / 2.0)
        tip_pair_d = _d(tip1, tip2)
        wrist_d = _d(mid_px, wrist)
        if not (tip_pair_d < wrist_d):   # 前提破綻ガード
            return None

        # --- block(同フレーム・同 pixel_to_unity で再検出)---
        red_px = detect_red_block(frame)
        if red_px is None:
            return None

        tip_ux, tip_uz = pixel_to_unity(mid_px[0], mid_px[1])
        blk_ux, blk_uz = pixel_to_unity(red_px[0], red_px[1])
        dx_mm = (tip_ux - blk_ux) * 1000.0
        dz_mm = (tip_uz - blk_uz) * 1000.0
        dist_mm = math.hypot(dx_mm, dz_mm)
        return {
            "tip_x": float(tip_ux), "tip_z": float(tip_uz),
            "block_x": float(blk_ux), "block_z": float(blk_uz),
            "dx_mm": float(dx_mm), "dz_mm": float(dz_mm),
            "dist_mm": float(dist_mm),
        }
    except Exception:
        return None


def _observe_verdict(raw_verdict, confidence, reason):
    """run() から VLM 判定結果を記録する。
    raw_verdict ∈ {'yes','no','unknown',...} を success ∈ {True,False,None} にマップ.
    reward は判定器側で未算出のため None 固定(VLM-as-Reward 実装時に充填).
    """
    if raw_verdict == "yes":
        success = True
    elif raw_verdict == "no":
        success = False
    else:
        success = None
    _OBSERVE_STATE["verdict"] = {
        "success": success,
        "reward": None,
        "reason": reason,
        "confidence": confidence,
        "raw_verdict": raw_verdict,
    }


def _observe_emit_now():
    """非移動 stage 用の通知専用 emit. シリアル/daemon 読み取りを伴わない.
    angles は _do_move でキャッシュ済みの last_angles をそのまま再利用(新規 I/O ゼロ).
    fire-and-forget. send_angles / グリッパー / sleep / 制御フローには一切触れない.
    """
    # in_box は台帳と同じ _ledger_in_box_geometry を再利用(判定の二重実装を避ける).
    # 戻り値は ("True"/"False"/"", dist_str). bool に正規化し、欠落時は None.
    # 同じ IN_BOX_TOL_MM・同じ関数を画面/台帳が共有するため値の食い違いが起きない.
    _ibg, _ = _ledger_in_box_geometry(
        _OBSERVE_STATE["block_place_xz"], _OBSERVE_STATE["box_xz"])
    _in_box = (True if _ibg == "True" else False if _ibg == "False" else None)
    _observe_emit(
        angles=_OBSERVE_STATE["last_angles"],
        phase=_OBSERVE_STATE["phase"],
        phase_t0_ms=_OBSERVE_STATE["phase_t0_ms"],
        gripper=_OBSERVE_STATE["gripper"],
        trial=({"id": _OBSERVE_STATE["run_stem"],
                "task": _OBSERVE_STATE["task"]}
               if _OBSERVE_STATE["run_stem"] else None),
        verdict=_OBSERVE_STATE["verdict"],
        block_xz=_OBSERVE_STATE["block_xz"],
        box_xz=_OBSERVE_STATE["box_xz"],
        block_place_xz=_OBSERVE_STATE["block_place_xz"],
        in_box=_in_box,
        tip_pick=_OBSERVE_STATE["tip_pick"],
        run_dir=_OBSERVE_STATE["run_dir"],
        cap_tip=_OBSERVE_STATE["cap_tip"],
        cap_pick=_OBSERVE_STATE["cap_pick"],
        cap_place=_OBSERVE_STATE["cap_place"],
    )


# ---- ヘルパ ------------------------------------------------------------------
def _next_log_path():
    LOG_ROOT.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    for n in range(1, 10000):
        stem = "run_{:04d}_{}".format(n, ts)
        run_dir = LOG_ROOT / stem
        if not run_dir.exists():
            run_dir.mkdir(parents=True, exist_ok=True)
            return run_dir / (stem + ".json")
    raise RuntimeError("log seq 9999 超過")


def _atomic_write_json(path, data):
    tmp = Path(str(path) + ".tmp")
    with open(tmp, "w") as f:
        json.dump(data, f, ensure_ascii=False, indent=2, default=str)
    tmp.replace(path)


def _ensure_ssd_mounted():
    """KIOXIA SSD の LEDGER_DIR が存在・書込可なら True、それ以外 False.
    fire-and-forget: 例外を握り False を返す."""
    try:
        if not LEDGER_DIR.exists():
            return False
        if not LEDGER_DIR.is_dir():
            return False
        # 書込テスト(write_test ファイルを作って即削除)
        test = LEDGER_DIR / ".write_test"
        try:
            test.write_text("")
            test.unlink()
            return True
        except Exception:
            return False
    except Exception:
        return False


# ---- 試行間補正記憶 (DAY257) ---------------------------------------------
def _corr_load():
    """補正記憶 JSON をロード。失敗時は {} を返す(制御を絶対止めない)。"""
    if not CORR_MEM_PATH.exists():
        return {}
    try:
        with open(CORR_MEM_PATH, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception as e:
        print("[CORR ] [WARN] 読み込み失敗 ({}). 空辞書で続行".format(e))
        return {}


def _corr_save(mem):
    """補正記憶を atomic に保存。KIOXIA 未マウント時は False を返してスキップ。"""
    if not _ensure_ssd_mounted():
        print("[CORR ] KIOXIA 未マウント → 補正記憶の保存をスキップ")
        return False
    try:
        CORR_MEM_PATH.parent.mkdir(parents=True, exist_ok=True)
        tmp = CORR_MEM_PATH.with_suffix(CORR_MEM_PATH.suffix + ".tmp")
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(mem, f, ensure_ascii=False, indent=2)
        os.replace(str(tmp), str(CORR_MEM_PATH))
        return True
    except Exception as e:
        print("[CORR ] [WARN] 保存失敗: {}".format(e))
        return False


def _corr_key(phase, x_m, z_m):
    """(phase, x, z)[m] → セルキー文字列 (例: 'pick_x180_z260' / 'place_x-40_z100')。
    DAY262: phase プレフィックスを付けて pick/place の名前空間を分離
    (同一 (x,z) セルでも掴み残差と置き残差は別物なので学習を汚染しない).
    CELL_MM=20 で round(中心) して mm 化."""
    cx = int(round(float(x_m) * 1000.0 / CELL_MM) * CELL_MM)
    cz = int(round(float(z_m) * 1000.0 / CELL_MM) * CELL_MM)
    return "{}_x{}_z{}".format(phase, cx, cz)


def _corr_get(mem, key):
    """セルキー → {'dx_mm','dz_mm','n'} or None"""
    return mem.get(key)


def _corr_update(mem, key, dx_mm_new, dz_mm_new):
    """EMA で 1 セル 1 値に集約 (新規は値そのまま・n=1)。"""
    e = mem.get(key)
    # 破損ガード: 既存エントリが dict 以外 (str/list/null 等) なら捨てて新規扱いに
    if e is not None and not isinstance(e, dict):
        print("[CORR ] [WARN] mem[{}] が dict 以外 ({}). 破棄して再初期化"
              .format(key, type(e).__name__))
        e = None
    if e is None:
        mem[key] = {"dx_mm": float(dx_mm_new), "dz_mm": float(dz_mm_new), "n": 1}
    else:
        a = EMA_ALPHA
        mem[key] = {
            "dx_mm": a * float(dx_mm_new) + (1 - a) * float(e.get("dx_mm", 0.0)),
            "dz_mm": a * float(dz_mm_new) + (1 - a) * float(e.get("dz_mm", 0.0)),
            "n":     int(e.get("n", 0)) + 1,
        }
    return mem[key]


def _apply_correction(phase, block_xz):
    """(phase, block_xz) から補正を適用し (target_xz, meta) を返す。
    DAY262: phase 引数を追加 (pick/place の名前空間分離). PICK は phase="pick",
    PLACE (stage8 place_withblock) は phase="place" で呼ぶ.
    USE_CORRECTION=False / KIOXIA 未マウント / 記憶なし → 素通り (meta は空欄).
    MAX_CORRECTION_MM で絶対値クランプ. 狙い = block - Δ.
    Δ の符号: PICK は (爪先 - ブロック), PLACE は (落下点 - 箱). どちらも「実測 - 目標」."""
    bx_m = float(block_xz[0]); bz_m = float(block_xz[1])
    meta = {
        "phase": phase,
        "target_before_mm": (bx_m * 1000.0, bz_m * 1000.0),
        "target_after_mm":  (bx_m * 1000.0, bz_m * 1000.0),
        "cell_key": "",
        "dx_mm": None, "dz_mm": None, "n": 0,
        "applied": False,
    }
    if not USE_CORRECTION:
        return (bx_m, bz_m), meta
    if not _ensure_ssd_mounted():
        print("[CORR ] KIOXIA 未マウント → 補正スキップ (素通り)")
        return (bx_m, bz_m), meta

    key = _corr_key(phase, bx_m, bz_m)
    meta["cell_key"] = key
    mem = _corr_load()
    entry = _corr_get(mem, key)
    if entry is None:
        return (bx_m, bz_m), meta
    # 破損ガード: エントリが dict 以外 (str/list 等) は補正せず素通り。
    # 次回の _corr_update が新規扱いで上書きするので、自動で正常状態に戻る。
    if not isinstance(entry, dict):
        print("[CORR ] [WARN] mem[{}] が dict 以外 ({}). 補正せず素通り"
              .format(key, type(entry).__name__))
        return (bx_m, bz_m), meta

    dx_mm = float(entry.get("dx_mm", 0.0))
    dz_mm = float(entry.get("dz_mm", 0.0))
    n = int(entry.get("n", 0))
    # MAX_CORRECTION_MM クランプ (記憶破損ガード)
    dx_mm = max(-MAX_CORRECTION_MM, min(MAX_CORRECTION_MM, dx_mm))
    dz_mm = max(-MAX_CORRECTION_MM, min(MAX_CORRECTION_MM, dz_mm))

    # 狙い = block - Δ (Δ は 爪先 - ブロック の符号)
    corrected_x_m = bx_m - dx_mm / 1000.0
    corrected_z_m = bz_m - dz_mm / 1000.0

    meta["target_after_mm"] = (corrected_x_m * 1000.0, corrected_z_m * 1000.0)
    meta["dx_mm"] = dx_mm
    meta["dz_mm"] = dz_mm
    meta["n"] = n
    meta["applied"] = True
    print("[CORR ] key={} dx={:+.2f}mm dz={:+.2f}mm n={}  "
          "target ({:+.1f},{:+.1f}) -> ({:+.1f},{:+.1f}) [mm]".format(
              key, dx_mm, dz_mm, n,
              bx_m * 1000.0, bz_m * 1000.0,
              corrected_x_m * 1000.0, corrected_z_m * 1000.0))
    return (corrected_x_m, corrected_z_m), meta


def _ledger_extract_trial_no(run_stem):
    """run_stem ("run_0001_YYYYMMDD_HHMMSS" 想定) の先頭数字を int で返す。
    取得失敗時は空文字列."""
    try:
        for part in str(run_stem).split("_"):
            if part.isdigit():
                return int(part)
    except Exception:
        pass
    return ""


def _ledger_mm_or_blank(arr, idx):
    """[x, z] m リストから idx 要素を mm 化して文字列に。取れなければ空."""
    if not isinstance(arr, (list, tuple)) or len(arr) <= idx:
        return ""
    v = arr[idx]
    if not isinstance(v, (int, float)):
        return ""
    return "{:.1f}".format(v * 1000.0)


def _ledger_in_box_geometry(block_place_xz, box_xz):
    """block_place と box 中心の距離 mm + IN_BOX_TOL_MM 比較.
    戻り値: (in_box_geom_str, dist_mm_str). どちらか欠落で ('','') を返す."""
    if not (isinstance(block_place_xz, (list, tuple)) and len(block_place_xz) == 2
            and isinstance(box_xz, (list, tuple)) and len(box_xz) == 2):
        return "", ""
    vals = list(block_place_xz) + list(box_xz)
    if not all(isinstance(v, (int, float)) for v in vals):
        return "", ""
    dx_mm = (block_place_xz[0] - box_xz[0]) * 1000.0
    dz_mm = (block_place_xz[1] - box_xz[1]) * 1000.0
    dist = math.hypot(dx_mm, dz_mm)
    return str(dist < IN_BOX_TOL_MM), "{:.1f}".format(dist)


def _append_ledger(log_record, run_stem):
    """試行 1 行を KIOXIA SSD の CSV 台帳へ追記.
    fire-and-forget: 例外で制御を絶対止めない. ローカルへのフォールバック書き込みはしない.
    既存ヘッダ無ければ新規作成、あれば append.
    """
    try:
        if not _ensure_ssd_mounted():
            print("[LEDGER] KIOXIA SSD ({}) 未マウント or 書込不可 → CSV スキップ"
                  .format(LEDGER_DIR))
            return

        judgments = log_record.get("judgments") or {}
        pick = judgments.get("pick") or {}
        place = judgments.get("place") or {}

        # block/box は _OBSERVE_STATE の最終値 (regrasp 反映済み・stage9b 検出反映済み)
        block_xz       = _OBSERVE_STATE.get("block_xz")
        box_xz         = _OBSERVE_STATE.get("box_xz")
        block_place_xz = _OBSERVE_STATE.get("block_place_xz")
        gripper        = _OBSERVE_STATE.get("gripper")
        tip_pick       = _OBSERVE_STATE.get("tip_pick")

        # 最後の move の post_actual = 試行終了時の関節角
        final_angles = ["", "", "", "", "", ""]
        for m in reversed(log_record.get("moves", []) or []):
            pa = m.get("post_actual")
            if isinstance(pa, (list, tuple)) and len(pa) == 6:
                for i in range(6):
                    v = pa[i]
                    final_angles[i] = ("{:.2f}".format(v)
                                       if isinstance(v, (int, float)) else "")
                break

        # place 撮影画像のフルパス
        place_capture_path = ""
        for c in reversed(log_record.get("vlm_captures", []) or []):
            if c.get("label") == "place" and c.get("image"):
                place_capture_path = str(c["image"])
                break

        in_box_geom, in_box_dist_mm = _ledger_in_box_geometry(
            block_place_xz, box_xz)

        row = {
            "timestamp":         log_record.get("ended_at") or datetime.now().isoformat(),
            "run_stem":          run_stem,
            "trial_no":          _ledger_extract_trial_no(run_stem),
            "pick_verdict":      (pick.get("verdict") or ""),
            "pick_reason":       (pick.get("reason") or ""),
            "pick_confidence":   (pick.get("confidence")
                                  if isinstance(pick.get("confidence"), (int, float))
                                  else ""),
            "place_verdict":     (place.get("verdict") or ""),
            "place_reason":      (place.get("reason") or ""),
            "place_confidence":  (place.get("confidence")
                                  if isinstance(place.get("confidence"), (int, float))
                                  else ""),
            "block_pick_x":      _ledger_mm_or_blank(block_xz, 0),
            "block_pick_z":      _ledger_mm_or_blank(block_xz, 1),
            "block_place_x":     _ledger_mm_or_blank(block_place_xz, 0),
            "block_place_z":     _ledger_mm_or_blank(block_place_xz, 1),
            "box_x":             _ledger_mm_or_blank(box_xz, 0),
            "box_z":             _ledger_mm_or_blank(box_xz, 1),
            "in_box_geom":       in_box_geom,
            "in_box_dist_mm":    in_box_dist_mm,
            "final_j1":          final_angles[0],
            "final_j2":          final_angles[1],
            "final_j3":          final_angles[2],
            "final_j4":          final_angles[3],
            "final_j5":          final_angles[4],
            "final_j6":          final_angles[5],
            "gripper":           ("" if gripper is None else str(gripper)),
            "place_capture_path": place_capture_path,
            "tip_pick_x":        ("{:.1f}".format(tip_pick["tip_x"] * 1000.0)
                                  if tip_pick else ""),
            "tip_pick_z":        ("{:.1f}".format(tip_pick["tip_z"] * 1000.0)
                                  if tip_pick else ""),
            "tip_block_dx_mm":   ("{:.1f}".format(tip_pick["dx_mm"])
                                  if tip_pick else ""),
            "tip_block_dz_mm":   ("{:.1f}".format(tip_pick["dz_mm"])
                                  if tip_pick else ""),
            "tip_block_dist_mm": ("{:.1f}".format(tip_pick["dist_mm"])
                                  if tip_pick else ""),
            "run_dir":           (_OBSERVE_STATE.get("run_dir") or ""),
            "tip_capture_path":  (_OBSERVE_STATE.get("cap_tip") or ""),
            "pick_capture_path": (_OBSERVE_STATE.get("cap_pick") or ""),
        }

        # v3 ヘッダ 34-50: align 系 3 列(本実装では出所なし・空欄固定)と
        # pick/place の pose_id・到達時実測角 14 列。
        pose_used = log_record.get("pose_used") or {}
        pick_used = pose_used.get("pick_used") or [None] * 6
        place_used = pose_used.get("place_used") or [None] * 6

        def _fmt_j(v):
            return ("{:.2f}".format(v) if isinstance(v, (int, float)) else "")

        row.update({
            "align_j0_final":  "",
            "align_red_x_mm":  "",
            "align_converged": "",
            "pick_pose_id":    (pose_used.get("pick_pose_id") or ""),
            "pick_used_j0":    _fmt_j(pick_used[0]),
            "pick_used_j1":    _fmt_j(pick_used[1]),
            "pick_used_j2":    _fmt_j(pick_used[2]),
            "pick_used_j3":    _fmt_j(pick_used[3]),
            "pick_used_j4":    _fmt_j(pick_used[4]),
            "pick_used_j5":    _fmt_j(pick_used[5]),
            "place_pose_id":   (pose_used.get("place_pose_id") or ""),
            "place_used_j0":   _fmt_j(place_used[0]),
            "place_used_j1":   _fmt_j(place_used[1]),
            "place_used_j2":   _fmt_j(place_used[2]),
            "place_used_j3":   _fmt_j(place_used[3]),
            "place_used_j4":   _fmt_j(place_used[4]),
            "place_used_j5":   _fmt_j(place_used[5]),
        })

        # DAY257 補正の記録 (5 列)
        corr = (log_record.get("correction") or {}).get("last") or {}
        def _fmt_xz(t):
            if not (isinstance(t, (list, tuple)) and len(t) == 2):
                return ""
            return "({:+.1f},{:+.1f})".format(float(t[0]), float(t[1]))
        row.update({
            "corr_cell_key":   corr.get("cell_key", ""),
            "corr_applied_dx": ("{:.2f}".format(corr["dx_mm"])
                                if isinstance(corr.get("dx_mm"), (int, float))
                                else ""),
            "corr_applied_dz": ("{:.2f}".format(corr["dz_mm"])
                                if isinstance(corr.get("dz_mm"), (int, float))
                                else ""),
            "target_before":   _fmt_xz(corr.get("target_before_mm")),
            "target_after":    _fmt_xz(corr.get("target_after_mm")),
        })

        # DAY257 後段: VLM/dist override の内訳 2 列。最終 pick_verdict は既存
        # "pick_verdict" 列 (override 後の結論)。最新 (=最終) pick_attempt から拾う。
        attempts = log_record.get("pick_attempts") or []
        last_pick = attempts[-1] if attempts else {}
        vlm_pv = last_pick.get("vlm_verdict") or ""
        if "dist_override" in last_pick:
            dist_ov = "True" if last_pick["dist_override"] else "False"
        else:
            dist_ov = ""
        row.update({
            "vlm_pick_verdict":   vlm_pv,
            "pick_dist_override": dist_ov,
        })

        is_new = not LEDGER_CSV_PATH.exists()
        # codex 指摘ガード: 既存ヘッダと LEDGER_COLS が不一致なら追記しない。
        # 不一致のまま append すると列ズレでデータが汚染されるため、ここで中断する
        # (制御は止めず台帳記録だけスキップ)。
        if not is_new:
            try:
                with open(LEDGER_CSV_PATH, "r", newline="") as fchk:
                    existing_header = next(csv.reader(fchk), None)
            except Exception as e:
                print("[LEDGER] [WARN] ヘッダ読込失敗 ({}). append スキップ"
                      .format(e))
                return
            if existing_header != LEDGER_COLS:
                print("[LEDGER] [ABORT-APPEND] 既存ヘッダ ({} 列) と LEDGER_COLS "
                      "({} 列) が不一致 → append スキップ".format(
                          len(existing_header) if existing_header is not None else 0,
                          len(LEDGER_COLS)))
                if existing_header is not None:
                    diff_extra = set(existing_header) - set(LEDGER_COLS)
                    diff_missing = set(LEDGER_COLS) - set(existing_header)
                    if diff_extra:
                        print("[LEDGER] 既存にのみ存在: {}".format(sorted(diff_extra)))
                    if diff_missing:
                        print("[LEDGER] LEDGER_COLS にのみ存在: {}".format(sorted(diff_missing)))
                return
        with open(LEDGER_CSV_PATH, "a", newline="") as f:
            w = csv.DictWriter(f, fieldnames=LEDGER_COLS)
            if is_new:
                w.writeheader()
            w.writerow(row)
        print("[LEDGER] append OK: {} (in_box_geom={} dist={})".format(
            LEDGER_CSV_PATH, in_box_geom, in_box_dist_mm))
    except Exception as e:
        print("[LEDGER] [WARN] append 例外(制御は継続): {}".format(e))


def _max_delta(target, actual):
    if target is None or actual is None:
        return None
    return max(abs(float(target[i]) - float(actual[i])) for i in range(6))


def _check_guard(r, label):
    """送信前ガード(レビュー反映版)。
      1. joint_limit_ok == False → ABORT
      2. method != "interp"      → ABORT(clamp = 弧 x 範囲外)
      3. x_residual_m > X_RESID_THRESH → ABORT
      4. z_residual_m         → ログのみ(ここでは判定しない)
    戻り値: (ok: bool, reason: str | None)
    """
    if not r.get("joint_limit_ok"):
        return False, "joint_limit違反: {}".format(r.get("joint_limit_violations"))
    # 許可 method: "interp" (j0_lookup の補間成功) / "fk_nearest" (fk_lookup の帯内最近傍成功)
    # 不許可: "clamp" (j0_lookup 弧端張り付き) / "out_of_range" (fk_lookup 帯外 or 距離超過)
    if r.get("method") not in ("interp", "fk_nearest"):
        return False, "method={}(弧の x 範囲外 / FK 帯外=届かない位置)".format(
            r.get("method"))
    xr = r.get("x_residual_m")
    if xr is None or xr > X_RESID_THRESH:
        return False, "x_residual_m={} > {}".format(xr, X_RESID_THRESH)
    return True, None


def _do_move(target_angles, label, tol, log_record, *, dry_run):
    """target_angles へ移動し、結果を log_record["moves"] に追加。
    dry_run=True の場合は送信せず計画のみ記録。
    戻り値: (success: bool, post_actual: list | None)
    """
    pre_actual = None if dry_run else _daemon_get_angles_filtered()
    pre_max = _max_delta(target_angles, pre_actual)

    move_rec = {
        "label": label,
        "target_angles": list(target_angles),
        "reach_tol_deg": tol,
        "pre_actual": list(pre_actual) if pre_actual is not None else None,
        "pre_max_delta_deg": pre_max,
        "dry_run": dry_run,
    }

    if dry_run:
        move_rec["arrived"] = None
        move_rec["retried"] = None
        move_rec["post_actual"] = None
        move_rec["post_max_delta_deg"] = None
        log_record.setdefault("moves", []).append(move_rec)
        print("[MOVE  ] {}  dry_run(送信なし)".format(label))
        return True, None

    arrived, retried = _send_and_wait_arrival(
        target_angles, label, tol, send_wait_sec=SEND_WAIT_SEC)
    post_actual = _daemon_get_angles_filtered()
    # 観測 emit(脇 UDP・fire-and-forget・例外は emit 側で握る)
    # post_actual を再利用するので daemon I/O は増えない。print 出力にも影響しない
    _observe_emit(
        angles=post_actual,
        phase=_OBSERVE_STATE["phase"],
        phase_t0_ms=_OBSERVE_STATE["phase_t0_ms"],
        gripper=_OBSERVE_STATE["gripper"],
        trial=({"id": _OBSERVE_STATE["run_stem"],
                "task": _OBSERVE_STATE["task"]}
               if _OBSERVE_STATE["run_stem"] else None),
        verdict=_OBSERVE_STATE["verdict"],
        block_xz=_OBSERVE_STATE["block_xz"],
        box_xz=_OBSERVE_STATE["box_xz"],
        block_place_xz=_OBSERVE_STATE["block_place_xz"],
        tip_pick=_OBSERVE_STATE["tip_pick"],
        run_dir=_OBSERVE_STATE["run_dir"],
        cap_tip=_OBSERVE_STATE["cap_tip"],
        cap_pick=_OBSERVE_STATE["cap_pick"],
        cap_place=_OBSERVE_STATE["cap_place"],
    )
    # 非移動 stage の通知 emit 用に最後の post_actual をキャッシュ(新規 I/O ゼロ)
    _OBSERVE_STATE["last_angles"] = list(post_actual) if post_actual is not None else None
    post_max = _max_delta(target_angles, post_actual)
    move_rec["arrived"] = bool(arrived)
    move_rec["retried"] = bool(retried)
    move_rec["post_actual"] = list(post_actual) if post_actual is not None else None
    move_rec["post_max_delta_deg"] = post_max
    log_record.setdefault("moves", []).append(move_rec)
    print("[MOVE  ] {}  arrived={} retried={} post_max|Δ|={}".format(
        label, arrived, retried,
        "{:.3f}°".format(post_max) if post_max is not None else "None"))
    return arrived, post_actual


def _record_lookup(r, label, target_xz, log_record):
    """j0_lookup 結果を log_record["lookups"] に追加 + コンソールに出力."""
    rec = {
        "label": label,
        "phase": r["phase"],
        "source_file": r["source_file"],
        "pose_id": r.get("pose_id"),
        "target_xz_m": list(target_xz),
        "j0": r["j0"],
        "method": r["method"],
        "x_residual_m": r["x_residual_m"],
        "z_residual_m": r["z_residual_m"],
        "interp_xz_m": list(r["interp_xz_m"]),
        "joint_limit_ok": r["joint_limit_ok"],
        "joint_limit_violations": r["joint_limit_violations"],
        "target_angles": list(r["target_angles"]),
        "base_angles": list(r["base_angles"]),
        "nearest_pts": r["nearest_pts"],
    }
    if "interp_t" in r:
        rec["interp_t"] = r["interp_t"]
        rec["interp_seg_idx"] = r["interp_seg_idx"]
    log_record.setdefault("lookups", []).append(rec)
    print("[LOOKUP] {}  phase={}  target_xz=({:+.4f},{:+.4f})  → j0={:+.3f}°  "
          "method={}  x_resid={:.5f}m  z_resid={:.5f}m  joint_ok={}".format(
              label, r["phase"], target_xz[0], target_xz[1], r["j0"],
              r["method"], r["x_residual_m"], r["z_residual_m"],
              r["joint_limit_ok"]))
    return rec


def _do_gripper(action, stage_label, log_record, dry_run):
    """グリッパー操作。戻り値(result=-1 含む)は DAY175 仕様で判定に使わない。
    一方、daemon TCP 例外は本物の失敗として bool False を返す(close 未遂のまま
    LIFT などの事故を防ぐ)。

    DAY261: close は set_gripper_value(GRIP_VALUE) で push 保持に切替(位置で
    止まらず押し込み力を残す)。open は従来どおり set_gripper_state(0)。

    戻り値: True = 正常 or dry_run / False = daemon 例外(呼び出し側で ABORT 連動)
    """
    state = 1 if action == "close" else 0
    is_close = (action == "close")
    print("[GRIP  ] {}  action={}  method={}  sleep={:.1f}s  dry_run={}".format(
        stage_label, action,
        "value({})".format(GRIP_VALUE) if is_close else "state(0)",
        GRIPPER_SLEEP, dry_run))
    rec = {
        "stage": stage_label, "action": action, "state": state,
        "sleep_sec": GRIPPER_SLEEP, "dry_run": dry_run,
        # DAY261: 把持方式の判別用。dry-run でも残るよう rec 構築時に書く。
        "grip_method": "value" if is_close else "state",
    }
    if is_close:
        rec["grip_value"] = GRIP_VALUE
    ok = True
    if not dry_run:
        try:
            if is_close:
                rec["daemon_return"] = daemon_set_gripper_value(GRIP_VALUE)
            else:
                rec["daemon_return"] = daemon_set_gripper(state)
            # 戻り値(result=-1 等)は判定に使わない(DAY175・成否無関係)
        except Exception as e:
            rec["error"] = str(e)
            ok = False
            print("[GRIP  ] [ERROR] {}: daemon 例外: {}".format(stage_label, e))
        time.sleep(GRIPPER_SLEEP)
    log_record.setdefault("gripper", []).append(rec)
    return ok


def _do_stage9_release_gripper(log_record, dry_run):
    """stage9 専用のグリッパー開放。

    既知問題(戻り値が成否と無関係、連続コマンド無視、sleep 必須)に合わせ、
    stage9 だけ open を二重送信する。把持 close 側や他 stage の open には触れない。
    """
    print("[GRIP  ] stage9_release  action=open  pre_sleep={:.1f}s  "
          "attempts={}  sleep_each={:.1f}s  dry_run={}".format(
              STAGE9_RELEASE_PRE_SLEEP, STAGE9_RELEASE_OPEN_ATTEMPTS,
              GRIPPER_SLEEP, dry_run))
    rec = {
        "stage": "stage9_release",
        "action": "open",
        "state": 0,
        "pre_sleep_sec": STAGE9_RELEASE_PRE_SLEEP,
        "sleep_sec": GRIPPER_SLEEP,
        "attempt_count": STAGE9_RELEASE_OPEN_ATTEMPTS,
        "dry_run": dry_run,
        "attempts": [],
    }
    ok = True
    if not dry_run:
        time.sleep(STAGE9_RELEASE_PRE_SLEEP)
        ok = False
        for attempt in range(1, STAGE9_RELEASE_OPEN_ATTEMPTS + 1):
            attempt_rec = {"attempt": attempt}
            try:
                attempt_rec["daemon_return"] = daemon_set_gripper(0)
                ok = True
            except Exception as e:
                attempt_rec["error"] = str(e)
                print("[GRIP  ] [ERROR] stage9_release attempt {}: daemon 例外: {}"
                      .format(attempt, e))
            rec["attempts"].append(attempt_rec)
            time.sleep(GRIPPER_SLEEP)
    log_record.setdefault("gripper", []).append(rec)
    return ok


def _abort(log_record, stage, reason):
    log_record["abort"] = {"stage": stage, "reason": reason}
    print("[ABORT ] stage={}  reason={}".format(stage, reason))


def _capture_for_vlm(label, frames_dir, log_record, dry_run, run_stem=None):
    """VLM 判定用に上カメラから 1 フレーム取得して frames_dir/{run_stem}_{label}.jpg に保存.
    run_stem が None なら旧仕様 {label}.jpg にフォールバック(後方互換)。
    dry_run の場合は撮影せず None を返す。
    戻り値: 保存パス or None(取得失敗時)"""
    cap_rec = {"label": label, "image": None, "dry_run": bool(dry_run)}
    if dry_run:
        log_record.setdefault("vlm_captures", []).append(cap_rec)
        return None

    try:
        frames_dir.mkdir(parents=True, exist_ok=True)
    except Exception as e:
        print("[CAP-VLM] {} frames_dir 作成失敗: {}".format(label, e))
        cap_rec["error"] = "mkdir:{}".format(type(e).__name__)
        log_record.setdefault("vlm_captures", []).append(cap_rec)
        return None

    try:
        cams = resolve_cameras(require_top=True, require_side=False)
    except Exception as e:
        print("[CAP-VLM] {} camera_resolver 例外: {}".format(label, e))
        cap_rec["error"] = "resolver:{}".format(type(e).__name__)
        log_record.setdefault("vlm_captures", []).append(cap_rec)
        return None
    top_idx = cams.get("top") if cams else None
    if top_idx is None:
        print("[CAP-VLM] {} 上カメラ未解決".format(label))
        cap_rec["error"] = "no_top_camera"
        log_record.setdefault("vlm_captures", []).append(cap_rec)
        return None

    cap = cv2.VideoCapture(top_idx)
    if not cap.isOpened():
        print("[CAP-VLM] {} cap open 失敗 (index={})".format(label, top_idx))
        cap_rec["error"] = "cap_open"
        log_record.setdefault("vlm_captures", []).append(cap_rec)
        return None

    out_path = 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-VLM] {} フレーム取得失敗".format(label))
            cap_rec["error"] = "read_fail"
            return None
        fn = ("{}_{}.jpg".format(run_stem, label)
              if run_stem else "{}.jpg".format(label))
        out_path = frames_dir / fn
        if not cv2.imwrite(str(out_path), frame):
            print("[CAP-VLM] {} 保存失敗: {}".format(label, out_path))
            cap_rec["error"] = "imwrite_fail"
            out_path = None
            return None
        cap_rec["image"] = str(out_path)
        cap_rec["top_camera_index"] = int(top_idx)
        print("[CAP-VLM] {} 保存: {}".format(label, out_path))
    finally:
        try:
            cap.release()
        except Exception:
            pass
        log_record.setdefault("vlm_captures", []).append(cap_rec)
    return out_path


def _ask_human_verdict(image_path, vlm_judgment, kind):
    """人による目視判定を取得(pick/place 共通)。
    画像パスと VLM 判定を並べて表示し、y/n/u を受ける。
    戻り値: "yes" / "no" / "unknown" のいずれか(不正入力は再入力ループ)。
    """
    question = "掴めている?" if kind == "pick" else "箱に入った?"
    print()
    print("=" * 70)
    print("[HUMAN ] 目視判定をお願いします(kind={})".format(kind))
    print("=" * 70)
    print("  画像: {}".format(image_path))
    print("  VLM : verdict={}  confidence={}  reason={}".format(
        vlm_judgment.get("verdict"),
        vlm_judgment.get("confidence"),
        vlm_judgment.get("reason")))
    while True:
        try:
            ans = input("{} [y/n/u(unknown)] > ".format(question)).strip().lower()
        except EOFError:
            print("  stdin EOF → unknown 扱いで進む")
            return "unknown"
        if ans in ("y", "yes"):
            return "yes"
        if ans in ("n", "no"):
            return "no"
        if ans in ("u", "unknown"):
            return "unknown"
        print("  入力が無効。y / n / u のいずれかを入力してください")


def _judge_image(image_path, kind, dry_run, dummy_verdict):
    """VLM 判定を呼び出す薄ラッパー。dry_run / image 無し は unknown ダミーを返す.
    image_path: str | Path (1 画像) or list/tuple (複数画像。pick の 2 枚比較で利用)。
    戻り値の dict には "image" キー(参照画像パス・複数なら最後の 1 枚)を追加して返す."""
    if dry_run:
        verdict = dummy_verdict if dummy_verdict else "unknown"
        return {
            "verdict": verdict, "confidence": 0.0,
            "reason": "dry_run", "model": None,
            "image": None, "raw": None,
        }
    # 画像参照の正規化 (str/list/tuple 受け入れ)
    if isinstance(image_path, (list, tuple)):
        if not image_path or any(p is None for p in image_path):
            return {
                "verdict": "unknown", "confidence": 0.0,
                "reason": "no_image", "model": None,
                "image": None, "raw": None,
            }
        paths_for_api = [str(p) for p in image_path]
        last_path = paths_for_api[-1]
    else:
        if image_path is None:
            return {
                "verdict": "unknown", "confidence": 0.0,
                "reason": "no_image", "model": None,
                "image": None, "raw": None,
            }
        paths_for_api = str(image_path)
        last_path = str(image_path)
    try:
        r = vlm_judge_call(paths_for_api, kind)
    except Exception as e:
        # vlm_judge 側で例外は吸収しているはずだが念のため二重に
        return {
            "verdict": "unknown", "confidence": 0.0,
            "reason": "judge_exception:{}".format(type(e).__name__),
            "model": None, "image": last_path, "raw": None,
        }
    r["image"] = last_path
    return r


# ---- 段ごと処理 -------------------------------------------------------------
def stage1_home_and_open(log_record, dry_run):
    """段1: HOME 復帰 + グリッパー開."""
    print("[STAGE 1] HOME 復帰 + グリッパー開")
    log_record["stages"].append("stage1")
    ok, _ = _do_move(HOME, "stage1_to_home", TOL_HOME, log_record, dry_run=dry_run)
    if not ok:
        _abort(log_record, 1, "HOME 到達失敗")
        return False
    if not _do_gripper("open", "stage1_grip_open", log_record, dry_run):
        _abort(log_record, 1, "グリッパー開 失敗")
        return False
    return True


def stage2_detect_block_and_box(log_record, dry_run, dummy_block_xz, dummy_box_xz,
                                require_box=True):
    """段2: 赤ブロック + 白箱の **同時検出**(detect_positions(repeat=DETECT_REPEAT)).

    - dry_run: カメラに触れず、block/box ともダミー値(指定なければ block=(0.05,0.30) /
      box=BOX_XZ デフォルト)を返す(require_box は無視)
    - 実機: detect_positions が camera_resolver → cap → warm-up → homography →
      detect_red_block と 06f detect_white_box_once をワンショットで叩き、xz 中央値で集約
    - ガード:
        block_xz=None         → 常に ABORT(赤ブロック未検出)
        require_box=True 時:
          box_xz=None           → ABORT(白箱未検出)
          box_score<THRESH      → ABORT(低スコア箱位置で place に降りない)
        require_box=False 時:
          box の 2 ガードはスキップ。box は取れていれば値、取れなければ None を返す
          (掴み直しの再検出では block だけ要るため。box は段2 初回の値を保持する想定)
    戻り値: (block_xz: tuple|None, box_xz: tuple|None)
    """
    print("[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)")
    log_record["stages"].append("stage2")

    if dry_run:
        block_xz = (tuple(dummy_block_xz) if dummy_block_xz is not None
                    else (0.05, 0.30))
        box_xz = (tuple(dummy_box_xz) if dummy_box_xz is not None
                  else tuple(BOX_XZ))
        log_record.setdefault("detect", []).append({
            "stage": 2, "source": "dry_run_dummy",
            "block_xz_m": list(block_xz),
            "box_xz_m": list(box_xz),
            "block_score": None,
            "box_score": None,
            "n_captures": 0,
        })
        print("[DETECT] dry_run dummy block_xz=({:+.4f},{:+.4f})  "
              "box_xz=({:+.4f},{:+.4f})".format(
                  block_xz[0], block_xz[1], box_xz[0], box_xz[1]))
        return block_xz, box_xz

    # 実機検出(detect_positions が内部で全部やる)
    try:
        r = detect_positions(repeat=DETECT_REPEAT)
    except Exception as e:
        _abort(log_record, 2, "detect_positions 例外: {}".format(e))
        return None, None

    block = r.get("block_xz_m")
    box = r.get("box_xz_m")
    scores = r.get("detect_score") or {}
    block_score = scores.get("block")
    box_score = scores.get("box")
    rec = {
        "stage": 2, "source": "live",
        "block_xz_m": list(block) if block else None,
        "box_xz_m": list(box) if box else None,
        "block_score": block_score,
        "box_score": box_score,
        "n_captures": r.get("n_captures"),
        "top_camera_index": r.get("resolved_top_index"),
    }
    log_record.setdefault("detect", []).append(rec)
    print("[DETECT] block_xz={} block_score={}".format(block, block_score))
    print("[DETECT] box_xz={} box_score={}".format(box, box_score))

    # 段2 ガード(block は常に必須、box は require_box=True 時のみ必須)
    if block is None:
        _abort(log_record, 2, "赤ブロック未検出")
        return None, None
    if require_box:
        if box is None:
            _abort(log_record, 2, "白箱未検出")
            return None, None
        if box_score is None or box_score < BOX_SCORE_THRESH:
            _abort(log_record, 2,
                   "white_box score {} < {}(低スコアの箱位置で place に降りない)".format(
                       box_score, BOX_SCORE_THRESH))
            return None, None
        return (float(block[0]), float(block[1])), (float(box[0]), float(box[1]))

    # require_box=False: block のみ必須。box は取れていれば値・無ければ None
    block_t = (float(block[0]), float(block[1]))
    box_t = (float(box[0]), float(box[1])) if box is not None else None
    return block_t, box_t


def stage_lookup_and_move(phase, target_xz, label, tol, log_record, dry_run, stage_no,
                          j3_ref=None, j5_ref=None):
    """lookup → 送信前ガード → 移動。共通段(段3/4/7/8).
    DAY262: j3_ref / j5_ref を受け place_withblock のときのみ fk_lookup へ転送
    (j3=手首捻り抑制・j5=grip-axis 保持). 他の phase (pick / approach_*) では使わない
    (PICK 回帰ゼロ担保).
    戻り値: (success: bool, lookup_result: dict | None)
    """
    log_record["stages"].append(label)
    # pick descend + DAY262 place_withblock は FK テーブル全引き、
    # 中間姿勢 (approach_pick / approach_place) は j0_lookup。
    use_fk = phase in FK_LOOKUP_PHASES
    lookup_name = "fk_lookup" if use_fk else "j0_lookup"
    try:
        if use_fk:
            # DAY262: place_withblock だけは箱高さに合わせた ee_y 帯を渡し、
            # さらに j3_ref / j5_ref (approach_place の j3/j5) 帯で
            # 手首捻り + grip-axis を抑える.
            # pick は fk_lookup.py 側の EE_Y_MIN/MAX_DEFAULT (10-40mm) を使い、
            # j3_ref / j5_ref も渡さない (=None 既定・従来コードと bit-perfect 一致).
            if phase == "place_withblock":
                r = fk_lookup(phase, target_xz,
                              ee_y_min=PLACE_EE_Y_MIN,
                              ee_y_max=PLACE_EE_Y_MAX,
                              j3_ref=j3_ref,
                              j5_ref=j5_ref)
            else:
                r = fk_lookup(phase, target_xz)
        else:
            r = j0_lookup(phase, target_xz)
    except (ValueError, FileNotFoundError, RuntimeError) as e:
        _abort(log_record, stage_no, "{} 失敗: {}".format(lookup_name, e))
        return False, None

    rec = _record_lookup(r, label, target_xz, log_record)

    ok_guard, reason = _check_guard(r, label)
    rec["guard_pass"] = bool(ok_guard)
    rec["guard_reason"] = reason
    if not ok_guard:
        _abort(log_record, stage_no, "送信前ガード NG: " + reason)
        return False, r

    # z_residual はログのみ(ABORT しない)
    if r["z_residual_m"] > 0.05:
        print("[INFO  ] {} z_residual_m={:.4f}m (情報のみ・ABORT しない。"
              "工程跨ぎや弧高さ差で構造的に大きいことがある)".format(
                  label, r["z_residual_m"]))

    ok_move, _ = _do_move(
        r["target_angles"], label, tol, log_record, dry_run=dry_run)
    if not ok_move:
        _abort(log_record, stage_no, "到達失敗")
        return False, r
    return True, r


# ---- メイン --------------------------------------------------------------
def run(dry_run=False, dummy_block_xz=None, dummy_box_xz=None,
        dummy_pick_verdict=None, dummy_place_verdict=None,
        human_judge=False):
    # KIOXIA 未マウント時に Mac 内蔵ディスクへ誤書き込みするのを防ぐ.
    # dry_run は撮影/保存しないので素通し. 実機 run のみガード.
    if not dry_run and not _ensure_ssd_mounted():
        print("[ABORT ] KIOXIA SSD 未マウント。run データの出力先が無いため中断します。"
              "({} を確認)".format(LEDGER_DIR))
        return False, None
    log_path = _next_log_path()
    run_stem = log_path.stem
    # 画像も JSON と同じ run フォルダ直下に置く(_frames サブ階層は廃止)
    frames_dir = log_path.parent
    # 画面のファイル情報表示用に run フォルダパスを観測状態へ
    _OBSERVE_STATE["run_dir"] = str(log_path.parent)
    # 観測 emit 用の状態を初期化(このプロセスで複数 run() を回す場合の前回残置を消す)
    _OBSERVE_STATE["phase"] = None
    _OBSERVE_STATE["phase_t0_ms"] = None
    _OBSERVE_STATE["gripper"] = None
    _OBSERVE_STATE["verdict"] = None
    _OBSERVE_STATE["block_xz"] = None
    _OBSERVE_STATE["box_xz"] = None
    _OBSERVE_STATE["block_place_xz"] = None
    _OBSERVE_STATE["tip_pick"] = None
    _OBSERVE_STATE["cap_tip"] = None
    _OBSERVE_STATE["cap_pick"] = None
    _OBSERVE_STATE["cap_place"] = None
    _OBSERVE_STATE["last_angles"] = None
    _OBSERVE_STATE["run_stem"] = run_stem
    log_record = {
        "started_at": datetime.now().isoformat(),
        "dry_run": bool(dry_run),
        "frames_dir": str(frames_dir),
        "constants": {
            "HOME": list(HOME), "SPEED": SPEED,
            "BOX_XZ_FALLBACK": list(BOX_XZ),
            "X_RESID_THRESH": X_RESID_THRESH,
            "BOX_SCORE_THRESH": BOX_SCORE_THRESH,
            "GRIPPER_SLEEP": GRIPPER_SLEEP,
            "TOL_APPROACH": TOL_APPROACH,
            "TOL_PICK_PLACE": TOL_PICK_PLACE,
            "TOL_HOME": TOL_HOME,
            "SEND_WAIT_SEC": SEND_WAIT_SEC,
            "DETECT_REPEAT": DETECT_REPEAT,
            "SWEPT_J0_FOR_PLACE_JUDGE": SWEPT_J0_FOR_PLACE_JUDGE,
            "MAX_PICK_ATTEMPTS": MAX_PICK_ATTEMPTS,
            "SWEPT_J0_FOR_PICK_RETREAT": SWEPT_J0_FOR_PICK_RETREAT,
        },
        "stages": [],
        "detect": [],
        "lookups": [],
        "moves": [],
        "gripper": [],
        "vlm_captures": [],
        "judgments": {},
        "pick_attempts": [],
    }

    print("=" * 70)
    print("[RUN] run_pick_place.py  dry_run={}  log={}".format(
        dry_run, log_path.name))
    print("=" * 70)

    # 実機モードのみ daemon 接続 + fresh_mode=1
    if not dry_run:
        try:
            get_daemon_socket()
        except Exception as e:
            _abort(log_record, 0, "daemon ping 失敗: {}".format(e))
            _atomic_write_json(log_path, log_record)
            return False, log_path
        try:
            daemon_set_fresh_mode(1)
            print("[INIT  ] fresh_mode=1")
        except Exception as e:
            print("[WARN  ] fresh_mode=1 失敗: {}".format(e))

    success = False
    user_interrupted = False
    try:
        # 段1
        _observe_phase("stage1")
        if not stage1_home_and_open(log_record, dry_run):
            return False, log_path
        # stage1 で gripper open まで完了
        _observe_gripper(0)

        # 段2
        _observe_phase("stage2")
        block_xz, box_xz = stage2_detect_block_and_box(
            log_record, dry_run, dummy_block_xz, dummy_box_xz)
        if block_xz is None or box_xz is None:
            return False, log_path
        # 観測状態を更新(メートル/Unity 系のまま、検出値を素通し)
        _OBSERVE_STATE["block_xz"] = [block_xz[0], block_xz[1]]
        _OBSERVE_STATE["box_xz"]   = [box_xz[0],   box_xz[1]]
        # 非移動 stage の通知 emit(移動なし→画面に stage2 行を出す)
        _observe_emit_now()

        # 段3〜判定①: 「pick 判定 verdict=no なら掴み直し」ループ(最大 MAX_PICK_ATTEMPTS 回)
        # 初回ラベル接尾辞は ""(既存ログ解析と互換)、2 回目以降は "_a{attempt}"
        cur_block_xz = block_xz
        pick_ok = False
        final_pick_judgment = None
        for attempt in range(1, MAX_PICK_ATTEMPTS + 1):
            suf = "" if attempt == 1 else "_a{}".format(attempt)

            # 試行間補正の適用 (lookup 呼び出し直前)。
            # USE_CORRECTION / KIOXIA / 記憶 が揃っているときだけ block_xz を
            # 補正する。素通り時は cur_block_xz と完全一致 (回帰確認可)。
            target_xz_pick, corr_meta = _apply_correction("pick", cur_block_xz)
            log_record.setdefault("correction", {})["last"] = corr_meta

            # 段3 APPROACH_PICK
            _observe_phase("stage3_approach_pick" + suf)
            ok, r_app_pick = stage_lookup_and_move(
                "approach_pick", target_xz_pick, "stage3_approach_pick" + suf,
                TOL_APPROACH, log_record, dry_run, 3)
            if not ok:
                return False, log_path
            approach_pick_target = list(r_app_pick["target_angles"])

            # 段4 PICK
            _observe_phase("stage4_pick" + suf)
            ok, r_pick = stage_lookup_and_move(
                "pick", target_xz_pick, "stage4_pick" + suf,
                TOL_PICK_PLACE, log_record, dry_run, 4)
            if not ok:
                return False, log_path

            # 台帳の pick_pose_id / pick_used_j0..j5 用に lookup の pose_id と
            # 直近 _do_move の post_actual を log_record へ寄せる(新規 I/O ゼロ・
            # 既存 moves[-1].post_actual を再利用)。attempt loop なので最後の試行が残る。
            pose_used = log_record.setdefault("pose_used", {})
            pose_used["pick_pose_id"] = (r_pick.get("pose_id")
                                          if r_pick else None)
            moves = log_record.get("moves") or []
            if moves:
                pa = moves[-1].get("post_actual")
                if isinstance(pa, (list, tuple)) and len(pa) == 6:
                    pose_used["pick_used"] = list(pa)

            # 段4.5 爪先(青シール) vs block 差分測定(GRIP前・測定専用・動作非干渉)
            # 関節は動かさず pick到達姿勢のまま上カメラ1枚を撮り、同フレームで
            # 爪先中点と赤blockを検出。差分は台帳へ。失敗は None 記録で素通し。
            tip_img = _capture_for_vlm(
                "stage4_pick_tip" + suf, frames_dir, log_record,
                dry_run, run_stem=run_stem)
            _OBSERVE_STATE["cap_tip"] = (str(tip_img) if tip_img else None)
            tip_diff = _measure_tip_vs_block(tip_img)
            _OBSERVE_STATE["tip_pick"] = tip_diff
            log_record.setdefault("tip_measurements", []).append(
                {"attempt": attempt, "image": (str(tip_img) if tip_img else None),
                 "diff": tip_diff})
            if tip_diff is not None:
                print("[TIP   ] attempt={} dist={:.1f}mm "
                      "(dx={:.1f} dz={:.1f})".format(
                          attempt, tip_diff["dist_mm"],
                          tip_diff["dx_mm"], tip_diff["dz_mm"]))
                # 補正記憶の更新は、VLM 判定 + tip_dist override 後の
                # final_verdict=="yes" 時のみに移動した (この下の判定ブロック後で実行)。
            else:
                print("[TIP   ] attempt={} 測定スキップ(検出不足/前提破綻)"
                      .format(attempt))

            # 段5 グリッパー閉
            _observe_phase("stage5" + suf)
            log_record["stages"].append("stage5" + suf)
            if not _do_gripper(
                    "close", "stage5_grip_close" + suf, log_record, dry_run):
                _abort(log_record, 5, "グリッパー閉 失敗")
                return False, log_path
            _observe_gripper(1)
            # 非移動 stage の通知 emit(移動なし→画面に stage5 行を出す)
            _observe_emit_now()

            # 段6 LIFT(その試行の approach_pick target_angles へ戻る)
            _observe_phase("stage6" + suf)
            log_record["stages"].append("stage6" + suf)
            ok, _ = _do_move(
                approach_pick_target, "stage6_lift" + suf, TOL_APPROACH,
                log_record, dry_run=dry_run)
            if not ok:
                _abort(log_record, 6, "LIFT 到達失敗")
                return False, log_path

            # 判定①: stage4_pick_tip (リフト前) と stage6_lift 後の現フレームを撮り、
            # 2 枚比較で「赤ブロックが浮いて持ち上がったか」を判定する (DAY257 後段)。
            # その後 tip_dist 二重チェックで VLM yes でも dist > PICK_DIST_MAX_MM なら
            # no に倒す。最終 verdict が yes のときだけ補正記憶を更新する。
            vlm_label = "pick" if attempt == 1 else "pick_a{}".format(attempt)
            pick_img = _capture_for_vlm(
                vlm_label, frames_dir, log_record, dry_run, run_stem=run_stem)
            _OBSERVE_STATE["cap_pick"] = (str(pick_img) if pick_img else None)
            # tip_img は stage4 直後に撮影済み (L1351 付近・_OBSERVE_STATE["cap_tip"])。
            # 両画像が揃っていれば 2 枚渡し、欠けていれば従来通り pick_img だけ。
            pick_imgs_for_vlm = (
                [tip_img, pick_img] if (tip_img is not None and pick_img is not None)
                else pick_img)
            pj = _judge_image(
                pick_imgs_for_vlm, "pick", dry_run, dummy_pick_verdict)
            vlm_verdict = pj["verdict"]
            human_verdict = None
            if human_judge and not dry_run:
                human_verdict = _ask_human_verdict(pick_img, pj, kind="pick")
            # tip_dist 二重チェック: VLM yes でも dist > PICK_DIST_MAX_MM なら no に倒す。
            # tip 測定失敗 (tip_diff is None) のときは override 判定保留 (False)。
            dist_override = False
            final_verdict = vlm_verdict
            if (vlm_verdict == "yes" and tip_diff is not None
                    and tip_diff.get("dist_mm") is not None
                    and float(tip_diff["dist_mm"]) > PICK_DIST_MAX_MM):
                final_verdict = "no"
                dist_override = True
                print("[OVERRIDE] vlm=yes だが tip_dist={:.1f}mm > {}mm "
                      "→ pick_verdict=no に倒す".format(
                          tip_diff["dist_mm"], PICK_DIST_MAX_MM))
            adopted = final_verdict   # 補正記憶の判定はこの final を見る
            _observe_verdict(adopted, pj["confidence"], pj["reason"])
            log_record["pick_attempts"].append({
                "attempt": attempt,
                "verdict": adopted,
                "vlm_verdict": vlm_verdict,
                "human_verdict": human_verdict,
                "dist_override": dist_override,
                "confidence": pj["confidence"],
                "reason": pj["reason"],
                "block_xz_m": list(cur_block_xz),
                "x_residual_m": r_pick["x_residual_m"],
                "z_residual_m": r_pick["z_residual_m"],
            })
            print("[JUDGE ] pick attempt={} adopted={} (vlm={} human={} "
                  "dist_override={}) confidence={} reason={}".format(
                      attempt, adopted, vlm_verdict, human_verdict,
                      dist_override, pj["confidence"], pj["reason"]))
            # judgments["pick"] は最終試行の dict を保存(verdict は adopted・VLM/人を併記)
            final_pick_judgment = dict(pj)
            final_pick_judgment["verdict"] = adopted
            final_pick_judgment["vlm_verdict"] = vlm_verdict
            final_pick_judgment["human_verdict"] = human_verdict
            final_pick_judgment["dist_override"] = dist_override

            # 補正記憶の更新: 最終 yes (掴み成功確定) かつ tip 測定成功時のみ。
            # override で no に倒した試行 / VLM no / tip 測定失敗 のΔは
            # 記憶に入れない (掴めていない試行で学習を汚さない)。
            # KIOXIA 未マウントや USE_CORRECTION=False 時は _corr_save がスキップ。
            if (USE_CORRECTION and adopted == "yes"
                    and tip_diff is not None
                    and tip_diff.get("dx_mm") is not None
                    and tip_diff.get("dz_mm") is not None):
                key = _corr_key("pick", cur_block_xz[0], cur_block_xz[1])
                mem = _corr_load()
                e_new = _corr_update(mem, key,
                                      tip_diff["dx_mm"], tip_diff["dz_mm"])
                if _corr_save(mem):
                    print("[CORR ] mem key={} dx={:+.2f} dz={:+.2f} n={}  "
                          "(EMA α={}・最終 yes のみ更新)".format(
                              key, e_new["dx_mm"], e_new["dz_mm"],
                              e_new["n"], EMA_ALPHA))

            if adopted == "yes":
                pick_ok = True
                break
            if adopted != "no":
                # unknown 等(VLM/撮影/API 失敗 or 人が unknown 入力)は「掴めていない証拠」ではない。
                # ここで open すると掴めていたブロックを落とす機序があるため regrasp しない。
                # ループを抜けて pick_failed 経路(HOME 復帰)へ流す。
                print("[REGRASP] verdict={} は no ではないため掴み直さず終了"
                      .format(adopted))
                break
            if attempt == MAX_PICK_ATTEMPTS:
                # 最終試行で yes でなければ break(外側で pick 失敗処理)
                break

            # ---- 掴み直し準備: open → 退避 → 再検出 ----
            if not _do_gripper(
                    "open", "regrasp_open_a{}".format(attempt),
                    log_record, dry_run):
                _abort(log_record, "regrasp_open",
                       "掴み直し グリッパー open 失敗")
                return False, log_path
            _observe_gripper(0)

            # 退避(approach_pick base の j0 を ±85 に振る・place 退避と同形)
            retreat = ([SWEPT_J0_FOR_PICK_RETREAT]
                       + list(r_app_pick["base_angles"][1:]))
            ok, _ = _do_move(
                retreat, "regrasp_retreat_a{}".format(attempt),
                TOL_APPROACH, log_record, dry_run=dry_run)
            if not ok:
                # 退避失敗 → アームがカメラ前に残り安全に再検出不可。break して外側 HOME 復帰へ
                print("[REGRASP] 退避失敗。掴み直し中止")
                break

            # block のみ再検出(box は段2 初回の値を保持)
            new_block_xz, _ = stage2_detect_block_and_box(
                log_record, dry_run, dummy_block_xz, dummy_box_xz,
                require_box=False)
            if new_block_xz is None:
                print("[REGRASP] 再検出でブロック無し。掴み直し中止")
                break
            cur_block_xz = new_block_xz
            _OBSERVE_STATE["block_xz"] = [new_block_xz[0], new_block_xz[1]]
            _observe_phase("stage2_redetect" + suf)   # regrasp 中の再検出を履歴に出す
            _observe_emit_now()
            # ループ次イテレーション

        # 既存キー互換: judgments["pick"] は最終試行の判定で埋める
        log_record["judgments"]["pick"] = final_pick_judgment

        # pick 失敗時は place を行わず HOME へ
        if not pick_ok:
            log_record["pick_failed"] = {
                "attempts": len(log_record.get("pick_attempts", [])),
                "final_verdict": (final_pick_judgment["verdict"]
                                  if final_pick_judgment else None),
            }
            print("[RESULT] pick 失敗({}回試行)。place をスキップして HOME 復帰"
                  .format(log_record["pick_failed"]["attempts"]))
            _observe_phase("pickfail_to_home")
            log_record["stages"].append("pickfail_to_home")
            ok_home, _ = _do_move(
                HOME, "pickfail_to_home", TOL_HOME,
                log_record, dry_run=dry_run)
            if not ok_home:
                # HOME 沈黙/未到達 → アームが中途姿勢で止まっている可能性。
                # 「pick 失敗(HOME 戻り済)」と「pick 失敗かつ HOME にも戻れていない」を分ける。
                _abort(log_record, "pickfail_to_home",
                       "pick 失敗後の HOME 復帰に失敗(アームが中途姿勢の可能性)")
                log_record["result"] = "pick_failed_home_failed"
                return False, log_path
            log_record["result"] = "pick_failed"
            return False, log_path

        # 段7 APPROACH_PLACE(段2 で実検出した box_xz を使う。BOX_XZ 固定値は使わない)
        _observe_phase("stage7_approach_place")
        ok, r_app_place = stage_lookup_and_move(
            "approach_place", box_xz, "stage7_approach_place",
            TOL_APPROACH_PLACE, log_record, dry_run, 7)
        if not ok:
            return False, log_path
        approach_place_target = list(r_app_place["target_angles"])

        # 段8 PLACE降下(place_withblock。実検出 box_xz を使う).
        # DAY262: place 補正記憶を適用. pick の "last" は上書きせず "place_last" に格納.
        # approach_place (段7) は raw box_xz のまま (approach は円弧概略位置決めなので補正不要).
        target_xz_place, place_corr_meta = _apply_correction("place", box_xz)
        log_record.setdefault("correction", {})["place_last"] = place_corr_meta
        # DAY262: 手首捻り抑制 (j3) + grip-axis 保持 (j5).
        # approach_place の j3 (実測 ≈0) と j5 (実測 ≈-89) を基準に
        # ±j*_tol_deg (fk_lookup 既定 5.0°) で place 候補を絞る.
        # approach_place_target[i] が万一 float 化できなくても None フォールバック
        # (fk_lookup 側の型ガードでも二重に守るが、呼び出し側でも明示的に落とす).
        try:
            _j3_ref_place = float(approach_place_target[3])
        except (TypeError, ValueError, IndexError):
            _j3_ref_place = None
        try:
            _j5_ref_place = float(approach_place_target[5])
        except (TypeError, ValueError, IndexError):
            _j5_ref_place = None
        _observe_phase("stage8_place")
        ok, r_place = stage_lookup_and_move(
            "place_withblock", target_xz_place, "stage8_place",
            TOL_PICK_PLACE, log_record, dry_run, 8,
            j3_ref=_j3_ref_place, j5_ref=_j5_ref_place)
        if not ok:
            return False, log_path

        # 台帳の place_pose_id / place_used_j0..j5 用に lookup の pose_id と
        # 直近 _do_move の post_actual を寄せる(離す直前の関節角。新規 I/O ゼロ)。
        pose_used = log_record.setdefault("pose_used", {})
        pose_used["place_pose_id"] = (r_place.get("pose_id")
                                       if r_place else None)
        moves = log_record.get("moves") or []
        if moves:
            pa = moves[-1].get("post_actual")
            if isinstance(pa, (list, tuple)) and len(pa) == 6:
                pose_used["place_used"] = list(pa)

        # DAY262 (仕様1): 補正記憶の学習入力を「離す前」に測る (置きズレ純度を上げる).
        # stage9 open より前・stage8 到達姿勢のまま上カメラで1枚撮影しブロック位置を
        # 検出し block_place_pre_xz へ. dry_run 時は _capture_for_vlm が None を返し、
        # _detect_block_in_place_img も None を返すので自動で学習 skip.
        # 送角/グリッパー/sleep は追加しない (fire-and-forget の観測処理).
        # 保存先: frames_dir / "{run_stem}_place_pre.jpg".
        # VLM 成否判定 (stage9b) は退避後 place.jpg のまま・触らない.
        place_pre_img = _capture_for_vlm(
            "place_pre", frames_dir, log_record, dry_run, run_stem=run_stem)
        block_place_pre_xz = _detect_block_in_place_img(place_pre_img)
        if block_place_pre_xz is not None:
            print("[PLACE_PRE] block xz_m=({:+.4f},{:+.4f})".format(
                block_place_pre_xz[0], block_place_pre_xz[1]))
        else:
            print("[PLACE_PRE] block 検出失敗 (補正学習は skip・制御は継続)")

        # 段9 グリッパー開(離す)
        _observe_phase("stage9")
        log_record["stages"].append("stage9")
        if not _do_stage9_release_gripper(log_record, dry_run):
            _abort(log_record, 9, "グリッパー離す 失敗")
            return False, log_path
        _observe_gripper(0)
        # 非移動 stage の通知 emit(移動なし→画面に stage9 行を出す)
        _observe_emit_now()

        # 判定②: 段9 と段10 の間に挟む。退避(j0=+85, approach_place base)→ 撮影 → 判定。
        # 退避移動が沈黙/ABORT → place 判定 skip・ログ記録のみ。stage 10 (HOME 復帰) は続行。
        _observe_phase("stage9b_judge_place")
        log_record["stages"].append("stage9b_judge_place")
        swept_target = [SWEPT_J0_FOR_PLACE_JUDGE] + list(r_app_place["base_angles"][1:])
        ok_swept, _ = _do_move(
            swept_target, "stage9b_swept_retreat", TOL_SWEPT_RETREAT,
            log_record, dry_run=dry_run)
        if not ok_swept:
            log_record["judgments"]["place"] = {
                "verdict": "unknown", "confidence": 0.0,
                "reason": "swept_retreat_failed", "model": None,
                "image": None, "raw": None,
            }
            _observe_verdict("unknown", 0.0, "swept_retreat_failed")
            # 非移動 stage の通知 emit(stage9b 失敗ブランチ→画面に verdict=unknown 行)
            _observe_emit_now()
            print("[JUDGE ] place: swept_retreat 失敗のため判定スキップ。"
                  "HOME 復帰へ続行")
        else:
            place_img = _capture_for_vlm(
                "place", frames_dir, log_record, dry_run, run_stem=run_stem)
            _OBSERVE_STATE["cap_place"] = (str(place_img) if place_img else None)
            # 観測: 同じ画像に赤ブロック HSV 検出を脇でかける(既存判定/分岐は不変)
            _OBSERVE_STATE["block_place_xz"] = _detect_block_in_place_img(place_img)
            place_vlm = _judge_image(
                place_img, "place", dry_run, dummy_place_verdict)
            place_vlm_verdict = place_vlm["verdict"]
            place_human_verdict = None
            if human_judge and not dry_run:
                place_human_verdict = _ask_human_verdict(
                    place_img, place_vlm, kind="place")
            place_adopted = place_vlm_verdict   # 分岐は常に VLM。記録のみ人
            _observe_verdict(place_adopted, place_vlm["confidence"], place_vlm["reason"])
            # 非移動 stage の通知 emit(stage9b 成功ブランチ→画面に verdict 行)
            _observe_emit_now()
            place_judgment = dict(place_vlm)
            place_judgment["verdict"] = place_adopted
            place_judgment["vlm_verdict"] = place_vlm_verdict
            place_judgment["human_verdict"] = place_human_verdict
            log_record["judgments"]["place"] = place_judgment
            print("[JUDGE ] place: adopted={} (vlm={} human={}) "
                  "confidence={} reason={}".format(
                      place_adopted, place_vlm_verdict, place_human_verdict,
                      place_vlm["confidence"], place_vlm["reason"]))

            # DAY262 (仕様1): place 補正記憶の更新.
            # 学習入力は「離す前」検出 (block_place_pre_xz・stage9 open 前に撮影済み).
            # 退避後 _OBSERVE_STATE["block_place_xz"] は observe 表示・台帳 block_place_x/z
            # で引き続き使うが、学習には転がりズレが混じるので使わない.
            # VLM 判定 yes かつ block_place_pre_xz / box_xz が両方揃うときのみ更新.
            # どちらか None (検出失敗 / detect_positions 未取得) は PICK 側 tip_diff None と
            # 同型で学習スキップ. Δ = (離す前 - 箱) = 「実測 - 目標」・PICK と符号整合.
            # KIOXIA 未マウント時は _corr_save がスキップし自動素通り.
            if (USE_CORRECTION and place_adopted == "yes"
                    and isinstance(block_place_pre_xz, (list, tuple))
                    and len(block_place_pre_xz) == 2
                    and isinstance(box_xz, (list, tuple))
                    and len(box_xz) == 2
                    and block_place_pre_xz[0] is not None
                    and block_place_pre_xz[1] is not None):
                dx_mm_place = (float(block_place_pre_xz[0]) - float(box_xz[0])) * 1000.0
                dz_mm_place = (float(block_place_pre_xz[1]) - float(box_xz[1])) * 1000.0
                key_place = _corr_key("place", box_xz[0], box_xz[1])
                mem_place = _corr_load()
                e_new_place = _corr_update(
                    mem_place, key_place, dx_mm_place, dz_mm_place)
                if _corr_save(mem_place):
                    print("[CORR ] mem key={} dx={:+.2f} dz={:+.2f} n={}  "
                          "(EMA α={}・place VLM yes・離す前 Δ で更新)".format(
                              key_place, e_new_place["dx_mm"],
                              e_new_place["dz_mm"], e_new_place["n"],
                              EMA_ALPHA))

        # 段10 LIFT(approach_place へ戻る)→ HOME
        _observe_phase("stage10")
        log_record["stages"].append("stage10")
        ok, _ = _do_move(
            approach_place_target, "stage10_lift_back", TOL_LIFT_BACK,
            log_record, dry_run=dry_run)
        if not ok:
            _abort(log_record, 10, "lift_back 到達失敗")
            return False, log_path
        ok, _ = _do_move(
            HOME, "stage10_to_home", TOL_HOME,
            log_record, dry_run=dry_run)
        if not ok:
            _abort(log_record, 10, "HOME 復帰失敗")
            return False, log_path

        log_record["result"] = "completed"
        success = True
        print("[RESULT] 完走")
        return True, log_path
    except KeyboardInterrupt:
        user_interrupted = True
        _abort(log_record, log_record["stages"][-1] if log_record["stages"] else 0,
               "KeyboardInterrupt")
        print("[INTERRUPT] Ctrl-C 検出。ログを保存して終了")
        return False, log_path
    finally:
        # 異常終了時の HOME 復帰(中途姿勢の解消)。
        # 除外: 正常完走 / pick_failed 系(HOME 復帰試行済み)/ Ctrl-C(意図的停止に大移動を被せない)/ dry_run
        # 注意: HOME 復帰は daemon が使えるうちに(daemon_set_fresh_mode(0)/close より先に)走らせる
        skip_recover = (
            success
            or user_interrupted
            or dry_run
            or log_record.get("result") in ("pick_failed", "pick_failed_home_failed")
        )
        if not skip_recover:
            _observe_phase("abort_recover_home")
            log_record["stages"].append("abort_recover_home")
            try:
                ok_h, _ = _do_move(
                    HOME, "abort_recover_home", TOL_HOME,
                    log_record, dry_run=dry_run)
                log_record["home_recovery"] = {"attempted": True, "ok": bool(ok_h)}
                print("[RECOVER] ABORT 後 HOME 復帰: {}".format(
                    "OK" if ok_h else "失敗(中途姿勢の可能性・リトライしない)"))
            except Exception as e:
                # 例外は握りつぶしてログ保存まで必ず到達させる(リトライしない)
                log_record["home_recovery"] = {
                    "attempted": True, "ok": False, "error": str(e)}
                print("[RECOVER] HOME 復帰中に例外: {}".format(e))

        log_record["ended_at"] = datetime.now().isoformat()
        if not success and "result" not in log_record:
            log_record["result"] = "aborted"
        if not dry_run:
            try:
                daemon_set_fresh_mode(0)
            except Exception:
                pass
            try:
                close_daemon_socket()
            except Exception:
                pass
        try:
            _atomic_write_json(log_path, log_record)
        except Exception as e:
            print("[WARN  ] ログ書き出し失敗: {}".format(e))
        print("[LOG   ] {}".format(log_path))
        # 試行台帳 CSV を KIOXIA SSD に追記(fire-and-forget・未マウントなら skip).
        # 既存 JSON 出力は維持・CSV は別物.
        # dry_run の試行は実機データではないため台帳に残さない(CSV を清浄に保つ).
        if not dry_run:
            _append_ledger(log_record, run_stem)


def main():
    ap = argparse.ArgumentParser(
        description="通し Pick & Place(j0 対応表方式)")
    ap.add_argument(
        "--dry-run", action="store_true",
        help="送信せず、各段の lookup 結果とガード判定だけを出力",
    )
    ap.add_argument(
        "--dummy-block-x", type=float, default=None,
        help="dry_run 用 block_xz の x(指定時は --dummy-block-z とセット)",
    )
    ap.add_argument(
        "--dummy-block-z", type=float, default=None,
        help="dry_run 用 block_xz の z(指定時は --dummy-block-x とセット)",
    )
    ap.add_argument(
        "--dummy-box-x", type=float, default=None,
        help="dry_run 用 box_xz の x(指定時は --dummy-box-z とセット)。"
             "未指定なら BOX_XZ={} をデフォルト".format(BOX_XZ),
    )
    ap.add_argument(
        "--dummy-box-z", type=float, default=None,
        help="dry_run 用 box_xz の z(指定時は --dummy-box-x とセット)",
    )
    ap.add_argument(
        "--dummy-pick-verdict", choices=["yes", "no", "unknown"], default=None,
        help="dry_run 用 pick 判定の verdict 注入(デフォルト unknown)",
    )
    ap.add_argument(
        "--dummy-place-verdict", choices=["yes", "no", "unknown"], default=None,
        help="dry_run 用 place 判定の verdict 注入(デフォルト unknown)",
    )
    ap.add_argument(
        "--human-judge", action="store_true",
        help="pick/place 判定で人による目視判定を入力させる(dry_run 時は無効)。"
             "校正フェーズ向け。VLM 判定は記録のみで分岐は人の verdict を採用",
    )
    args = ap.parse_args()

    dummy_block = None
    dummy_box = None
    if args.dry_run:
        if args.dummy_block_x is not None and args.dummy_block_z is not None:
            dummy_block = (args.dummy_block_x, args.dummy_block_z)
        else:
            dummy_block = (0.05, 0.30)
            print("[INIT  ] dry_run dummy block_xz default = {}".format(dummy_block))
        if args.dummy_box_x is not None and args.dummy_box_z is not None:
            dummy_box = (args.dummy_box_x, args.dummy_box_z)
        else:
            dummy_box = tuple(BOX_XZ)
            print("[INIT  ] dry_run dummy box_xz default (BOX_XZ) = {}".format(dummy_box))

    success, log_path = run(
        dry_run=args.dry_run,
        dummy_block_xz=dummy_block,
        dummy_box_xz=dummy_box,
        dummy_pick_verdict=args.dummy_pick_verdict,
        dummy_place_verdict=args.dummy_place_verdict,
        human_judge=args.human_judge,
    )
    sys.exit(0 if success else 1)


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