mechArm 270 Pi 動的追従の限界を探る Log#36 | Pick&Place が止まる原因を測る。手首 j4 は浅い側に戻れなかった
LOG238R2R2R2026/6/15

mechArm 270 Pi 動的追従の限界を探る Log#36 | Pick&Place が止まる原因を測る。手首 j4 は浅い側に戻れなかった

通しを最後まで走らせたくて、pick が失敗したら掴み直すループを先に入れた。

ところが実機で回すと、掴み直しが働く前に止まった。止めていたのは手首の関節だった。送信した配列の5番目、ここでは j4 と呼ぶ。指令した角度に届かず、タイムアウトでアボートする。

原因の候補は4つあった。特定の角度域で止まるのか、速度やトルクが足りないのか、一発で大きく動かすのが無理なのか、ブロックを持った荷重で甘くなるのか。j4 だけを動かす単軸テストを書いて測った。

結果は、そのどれでもなかった。深い側に届かないと思っていた手首は、実際には浅い側へ戻れなくなっていた。

掴み直しループを先に入れた

通しの pick は、視覚サーボで位置を合わせてグリッパーを閉じ、持ち上げてから VLM で「掴めているか」を判定する。これまで判定は観察するだけで、結果によって動作を変えていなかった。

ここに、判定が no のときグリッパーを開いてカメラ前から退避し、ブロックを再検出して掴み直す分岐を足した。最大3回。新しい関節角は作らず、既存の段(approach_pick → pick → 閉 → lift)をもう一度呼ぶだけにした。退避は place 判定で使っている j0=+85 の退避をそのまま流用した。

同時に3つ足した。撮影画像のファイル名に run のタイムスタンプを入れて run をまたいでも一意になるようにした。VLM 判定が正しく見えているかを後で検証できるよう、人が目視で入れた判定を human_verdict として VLM 判定と並べて記録するようにした(分岐は VLM 判定で動かし、人判定は記録だけ)。そして、各段の到達失敗で止まったときアームを中途姿勢に残さず HOME へ戻すようにした。

掴み直しループの本体を全文で残す。判定が no のときだけ掴み直し、unknown(撮影や API の失敗)では掴み直さない。掴めているかもしれないブロックを、開いて落とさないためにこの区別を置いた。pick が最後まで yes にならなければ place に進まず HOME へ戻す。

        # 段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)

            # 段3 APPROACH_PICK
            ok, r_app_pick = stage_lookup_and_move(
                "approach_pick", cur_block_xz, "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
            ok, r_pick = stage_lookup_and_move(
                "pick", cur_block_xz, "stage4_pick" + suf,
                TOL_PICK_PLACE, log_record, dry_run, 4)
            if not ok:
                return False, log_path

            # 段5 グリッパー閉
            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

            # 段6 LIFT(その試行の approach_pick target_angles へ戻る)
            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

            # 判定①: 現姿勢のまま 1 フレーム撮影 → VLM 判定(必要なら人判定も)
            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)
            pj = _judge_image(
                pick_img, "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")
            adopted = vlm_verdict   # 分岐は常に VLM。human_verdict は記録のみ
            log_record["pick_attempts"].append({
                "attempt": attempt,
                "verdict": adopted,
                "vlm_verdict": vlm_verdict,
                "human_verdict": human_verdict,
                "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={}) "
                  "confidence={} reason={}".format(
                      attempt, adopted, vlm_verdict, human_verdict,
                      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

            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

            # 退避(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
            # ループ次イテレーション

        # 既存キー互換: 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"]))
            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

実機で回したら、掴む前に止まった

掴み直しが回るか確かめるために通しを実機で回した。2回とも、掴み直しの手前で止まった。

1回目のログ。pick まで進み、持ち上げ(lift)でアボートしている。

[SEND] stage4_pick: send_angles (single) [ -38.19,  +88.00,  -21.44,   +1.40,  -70.48,  -32.87]  speed=20 wait=1.0s
[SEND] arrival OK: max|Δ|=1.23° (tol 3.0°)
[MOVE  ] stage4_pick  arrived=True retried=False post_max|Δ|=1.230°
[GRIP  ] stage5_grip_close  action=close  sleep=2.0s  dry_run=False
  [Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift: send_angles (single) [ -31.30,  +87.74,  -38.33,   +3.60,  -70.11,  -30.00]  speed=20 wait=1.0s
[SEND] [TIMEOUT] 8.0s 内に未到達: max|Δ|=22.13° actual=[ -31.46,  +89.03,  -37.17,   +4.04,  -47.98,  -30.32]
[SEND] リトライしません: pre→last max|Δ| 19.78° >= 1.0° (部分移動/ストール疑い)
[MOVE  ] stage6_lift  arrived=False retried=False post_max|Δ|=22.130°
[ABORT ] stage=6  reason=LIFT 到達失敗

lift の指令は j4=-70.11。pick 時点で j4 は既に -70.48 にいて、ほぼ動かさない指令のはずだった。なのに実測は -47.98。22°、浅い側へ流れている。

2回目のログ。pick は成功し(VLM が yes、confidence 0.85)、その次の approach_place で止まった。

[JUDGE ] pick attempt=1 verdict=yes confidence=0.85 reason=爪の先端部分に赤い四角いブロックが挟まれており、グリッパーが閉じた状態で保持しているように見える
[LOOKUP] stage7_approach_place  phase=approach_place  target_xz=(-0.2117,+0.2053)  → j0=+42.192°  method=interp  x_resid=0.00000m  z_resid=0.01150m  joint_ok=True
[SEND] stage7_approach_place: send_angles (single) [ +42.19,  +57.39,  +17.49,   +0.08,  -89.20,  -29.44]  speed=20 wait=1.0s
[SEND] [TIMEOUT] 13.0s 内に未到達: max|Δ|=12.39° actual=[ +42.53,  +59.32,  +17.66,   +0.26,  -76.81,  -29.61]
[SEND] リトライしません: pre→last max|Δ| 73.46° >= 1.0° (部分移動/ストール疑い)
[MOVE  ] stage7_approach_place  arrived=False retried=False post_max|Δ|=12.390°
[ABORT ] stage=7  reason=到達失敗

approach_place の指令は j4=-89.20。実測は -76.81。12°手前で止まっている。

1回目は「保持すべき所で勝手に浅い側へ流れた」、2回目は「深い側へ動かしたいのに手前で止まった」。掴み直しのコードは正しく入っていて、回っている別 run では pick=no から2回目に入る挙動も確認できた。ただ、その手前で j4 が指令に従わない。掴み直し以前の問題だった。

止まっているのは j4。原因の候補は4つあった

j4 が指令通り動かない。理由として4つ考えた。特定の角度域で止まる、速度かトルクが足りない、一発で大きく動かすのが無理(刻めば届く)、ブロックを保持して荷重がかかると甘くなる。

どれも筋は通る。ただ、どれが効いているかは推測では決められない。先に測る手段を作る。

j4 だけを動かすテストを書いた

通しから切り離して、j4 だけを動かす単軸のテストにした。カメラも lookup も使わない。実機で到達実績のある PICK_BASE 姿勢 [-38.31, +88.00, -21.44, +1.40, -70.48, -32.87] から、配列の5番目(j4)だけを変える。他の5軸は固定する。

4つの候補をそれぞれ切り分ける。A は j4 を -45 から -95 まで 10°刻みで送って、どの角度で止まるか。B は同じ移動を speed 20 と 10 で比べる。C は一発送信と5°刻み送信で比べる。D はグリッパー開(無負荷)と閉(ブロック保持)で比べる。各3回。送信ごとに指令・実測・誤差・到達可否を CSV に書く。j4 は [-95, +5] にクランプし、各送信は測定値で到達確認して、届かなくてもアボートせず次へ進む(全点測るため)。

テストの全文を残す。安全のために、setup が基準姿勢へ戻れなかったら測定をスキップし、荷重テストでグリッパーが閉じられなかったら以降を中止し、中断時にはグリッパーを開いて HOME へ戻す分岐を入れてある。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""j4_probe.py — j4 単軸ストール診断(4 候補切り分け)

目的: 通しが TIMEOUT で落ちる原因 = j4(送信配列 index4・実機で -70〜-89 を取る関節)
が指令通り動かない件を、4 候補に切り分けて測定する。

  候補1 特定角度域でストール     → Test A: idx4 を -45/-55/-65/-75/-85/-95 でスキャン
  候補2 速度/トルク不足          → Test B: M1 を SPEED 20 vs 10 で比較
  候補3 一発送信の限界           → Test C: M1 を oneshot vs 5° 刻み stepped で比較
  候補4 荷重の影響               → Test D: M1 を gripper open vs closed で比較

基準姿勢(PICK・run2 stage4_pick で到達 OK 実績あり):
  PICK_BASE = [-38.31, +88.00, -21.44, +1.40, -70.48, -32.87]
  index4 だけを動かす。他 5 軸は全テストで PICK_BASE 値に固定。

代表移動:
  M1 = PICK_BASE で index4 → -89.20(approach_place で止まった移動)

絶対ルール:
- send_angles() のみ。send_coords() / release_all_servos() 禁止
- index4 は [-95, +5] にハードクランプ。範囲外は送信せず記録のみ(mode=clamped_skip)
- index 0,1,2,3,5 は PICK_BASE 値に固定。一切変更しない
- 開始時 HOME → PICK_BASE(slow)、全テスト終了後 PICK_BASE → HOME(slow)
- 到達失敗(タイムアウト)は record して次へ進む(abort で止めない=全点測る)
- Stiffness/Damping を触らない。fk_scan_motion_based_v2.csv を書き換えない

出力:
  ~/r2r2r-scripts/dynamic_tracking_limits/diag/logs/j4_probe_{YYYYMMDD_HHMMSS}.csv
  1 送信 1 行(測定送信のみ。setup として走る PICK_BASE 戻し送信は CSV 対象外)

CLI:
  python3 j4_probe.py --dry-run     # 送信せず計画と行数だけ出す
  python3 j4_probe.py               # 実機実行(A→B→C→D→終了)
  python3 j4_probe.py --skip-d      # Test D(荷重・人手介入)をスキップ

スコープ外(別テスト):
  run1 の j4 ドリフト(j2 移動時に j4 が +22° ズレた件)は j2→j4 連動の問題で、
  この単軸スクリプトでは再現できない。本診断には含めない。
"""

from __future__ import print_function

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


# ---- パス解決 + pymycobot スタブ -------------------------------------------
_THIS = Path(__file__).resolve()
_SCRIPTS_ROOT = _THIS.parents[2]  # ~/r2r2r-scripts
_DRAG_TEACHING_DIR = _SCRIPTS_ROOT / "dynamic_tracking_limits" / "drag_teaching"
sys.path.insert(0, str(_SCRIPTS_ROOT))
sys.path.insert(0, str(_DRAG_TEACHING_DIR))

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(
                "j4_probe.py: MyCobot stub is not callable. "
                "アーム制御は daemon TCP 経由のみ。")

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


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,
)


# ---- 定数 --------------------------------------------------------------------
HOME = [0, 0, 0, 0, 0, -30]
PICK_BASE = [-38.31, +88.00, -21.44, +1.40, -70.48, -32.87]

SPEED_MEAS = 20                  # 測定時の既定 SPEED
SPEED_SLOW = 10                  # HOME ↔ PICK_BASE 用(ゆっくり)

TOL_REACH_DEG = 2.0              # 6 軸 max|Δ| でこれ以下なら到達
TIMEOUT_S = 10.0                 # 各測定送信の到達確認タイムアウト
TIMEOUT_SLOW_FACTOR = 1.5        # slow 移動はタイムアウトも長め
POLL_SLEEP = 0.2
GET_ANGLES_RETRY = 8
GET_ANGLES_RETRY_SLEEP = 0.15
GRIPPER_SLEEP = 2.0              # DAY175 対策
# 沈黙リトライ: pre→last 移動量が SILENCE_THRESHOLD_DEG 未満なら 1 回だけ再送。
# 部分移動(動いたが届かない)では再送しない(j4 の真の未到達を潰さないため)。
SILENCE_THRESHOLD_DEG = 1.0
SILENCE_RETRY_PAUSE_SEC = 1.0

J4_CLAMP_LO = -95.0              # ハードクランプ下限
J4_CLAMP_HI = +5.0               # ハードクランプ上限

# 代表移動 M1: PICK_BASE で index4 → M1_J4
M1_J4 = -89.20                   # approach_place で止まった j4

# Test A: idx4 スキャン点
TEST_A_J4_TARGETS = [-45.0, -55.0, -65.0, -75.0, -85.0, -95.0]

# Test C: stepped 刻み幅
STEP_DEG = 5.0

OUT_DIR = (
    Path.home() / "r2r2r-scripts" / "dynamic_tracking_limits"
    / "diag" / "logs"
)

CSV_COLS = [
    "test", "condition", "rep",
    "cmd_idx4", "actual_idx4", "err_deg", "reached_bool",
    "speed", "mode", "gripper", "timeout_s",
    "full_actual_angles",
]


# ---- ヘルパ -----------------------------------------------------------------
def _fmt(a):
    if a is None:
        return "None"
    return "[" + ", ".join("{:+.2f}".format(x) for x in a) + "]"


def _read_angles_safe():
    """daemon_get_angles を最大 GET_ANGLES_RETRY 回・retry sleep で読む.
    6 要素の数値リストが返れば float化して返す。全失敗で None."""
    for _ in range(GET_ANGLES_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(GET_ANGLES_RETRY_SLEEP)
    return None


def _in_clamp(j4):
    return J4_CLAMP_LO <= j4 <= J4_CLAMP_HI


def _try_send_once(target, speed, timeout_s, pre_actual, label):
    """1 回送信 → polling。戻り値 (reached, last_actual, elapsed_s)."""
    try:
        daemon_send_angles(target, speed=speed, wait=0)
    except Exception as e:
        print("[SEND] [ERROR] daemon_send_angles 例外 ({}): {}".format(label, e))
        return False, pre_actual, 0.0
    t0 = time.time()
    last_actual = pre_actual
    while time.time() - t0 < timeout_s:
        a = _read_angles_safe()
        if a is not None:
            last_actual = a
            max_err = max(abs(a[i] - target[i]) for i in range(6))
            if max_err <= TOL_REACH_DEG:
                return True, a, time.time() - t0
        time.sleep(POLL_SLEEP)
    return False, last_actual, time.time() - t0


def _send_one(target, speed, timeout_s, label, dry_run):
    """target を 1 送信 → reach 確認まで polling。
    タイムアウト時に「沈黙(pre→last 移動量 < SILENCE_THRESHOLD_DEG)」と判定
    できれば 1 回だけ再送する。部分移動した場合は再送しない(真の未到達を潰さない)。

    戻り値: (reached, last_actual, elapsed_s)
      reached: True=到達 / False=タイムアウト未到達 / None=dry_run
      last_actual: 最後に観測した実測角度 6 要素 or None
    dry_run 時は送信せず (None, None, 0.0) を返す。"""
    if dry_run:
        print("[SEND-PLAN] {}  cmd_idx4={:+.2f}  speed={}  timeout={:.1f}s"
              .format(label, target[4], speed, timeout_s))
        return None, None, 0.0

    pre_actual = _read_angles_safe()
    reached, last_actual, elapsed = _try_send_once(
        target, speed, timeout_s, pre_actual, label)
    if reached:
        return True, last_actual, elapsed

    # 沈黙判定: pre→last 移動量を見る。判定できないケース(pre/last が None)は再送しない
    if pre_actual is not None and last_actual is not None:
        move = max(abs(last_actual[i] - pre_actual[i]) for i in range(6))
        if move < SILENCE_THRESHOLD_DEG:
            print("[SEND] [SILENCE-RETRY] {} 沈黙疑い(pre→last max|Δ|={:.2f}° < "
                  "{:.1f}° 閾値)→ {:.1f}s 待って 1 回だけ再送".format(
                      label, move, SILENCE_THRESHOLD_DEG, SILENCE_RETRY_PAUSE_SEC))
            time.sleep(SILENCE_RETRY_PAUSE_SEC)
            pre_actual_r = _read_angles_safe()
            reached_r, last_actual_r, elapsed_r = _try_send_once(
                target, speed, timeout_s, pre_actual_r, label + "_retry")
            return reached_r, last_actual_r, elapsed + elapsed_r
        else:
            print("[SEND] {} 部分移動(pre→last max|Δ|={:.2f}° >= {:.1f}°)→ 再送しない"
                  .format(label, move, SILENCE_THRESHOLD_DEG))

    return reached, last_actual, elapsed


def _record(writer, test, condition, rep, cmd_target, actual,
            reached, speed, mode, gripper, timeout_s):
    """1 行を CSV に書く。dry_run の場合は reached=None / actual=None で空欄."""
    err = (abs(actual[4] - cmd_target[4]) if actual is not None else None)
    writer.writerow({
        "test": test,
        "condition": condition,
        "rep": rep if rep is not None else "",
        "cmd_idx4": "{:+.3f}".format(cmd_target[4]),
        "actual_idx4": ("{:+.3f}".format(actual[4]) if actual is not None else ""),
        "err_deg": ("{:.3f}".format(err) if err is not None else ""),
        "reached_bool": ("" if reached is None else str(bool(reached))),
        "speed": speed,
        "mode": mode,
        "gripper": gripper,
        "timeout_s": "{:.1f}".format(timeout_s),
        "full_actual_angles": (_fmt(actual) if actual is not None else ""),
    })
    print("[REC  ] test={} cond={} rep={} cmd_j4={:+.2f} actual_j4={} "
          "err={} reached={} speed={} mode={}".format(
              test, condition, rep, cmd_target[4],
              "{:+.2f}".format(actual[4]) if actual is not None else "—",
              "{:.2f}".format(err) if err is not None else "—",
              ("—" if reached is None else reached), speed, mode))


def _setup_to_pick_base(dry_run, label):
    """PICK_BASE へ戻す(測定外。CSV には記録しない).
    戻り値: True=到達 / False=未到達 / None=dry_run。
    呼び出し側は False のとき測定をスキップして mode=setup_failed_skip で記録すること。"""
    print("[SETUP] {} → PICK_BASE (slow)".format(label))
    reached, actual, elapsed = _send_one(
        PICK_BASE, SPEED_SLOW, TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
        "setup_" + label, dry_run)
    if reached is False:
        print("[SETUP] [WARN] PICK_BASE 未到達 actual={} elapsed={:.1f}s "
              "→ この rep の測定はスキップする"
              .format(_fmt(actual), elapsed))
    return reached


def _wait_enter(prompt):
    print(prompt)
    try:
        input()
    except EOFError:
        print("[ABORT] stdin EOF")
        sys.exit(1)


# ---- テスト本体 -------------------------------------------------------------
def run_test_a(writer, dry_run):
    """A: どこで止まるか(候補1=特定角度域).
    idx4 を -45/-55/-65/-75/-85/-95 で各 3 回・SPEED=20・gripper open.
    各点ごとに PICK_BASE に戻してから送信。"""
    print()
    print("=" * 70)
    print("Test A: idx4 スキャン(候補1=特定角度域)")
    print("=" * 70)
    for cmd_j4 in TEST_A_J4_TARGETS:
        for rep in (1, 2, 3):
            setup_ok = _setup_to_pick_base(
                dry_run, "A_j4{:+.0f}_r{}".format(cmd_j4, rep))
            target = list(PICK_BASE)
            target[4] = cmd_j4
            cond = "j4={:+.2f}".format(cmd_j4)
            if setup_ok is False:
                _record(writer, "A", cond, rep, target, None, False,
                        SPEED_MEAS, "setup_failed_skip", "open", TIMEOUT_S)
                continue
            if not _in_clamp(cmd_j4):
                print("[A] clamp 範囲外 cmd_j4={:+.2f} → 送信せず記録のみ"
                      .format(cmd_j4))
                _record(writer, "A", cond, rep, target, None, False,
                        SPEED_MEAS, "clamped_skip", "open", TIMEOUT_S)
                continue
            reached, actual, _ = _send_one(
                target, SPEED_MEAS, TIMEOUT_S,
                "A_j4{:+.2f}_r{}".format(cmd_j4, rep), dry_run)
            _record(writer, "A", cond, rep, target, actual, reached,
                    SPEED_MEAS, "oneshot", "open", TIMEOUT_S)


def run_test_b(writer, dry_run):
    """B: 速度で届くか(候補2=速度/トルク).
    M1 を SPEED=20 と SPEED=10 で各 3 回."""
    print()
    print("=" * 70)
    print("Test B: SPEED 比較(候補2=速度/トルク)M1: idx4 → {:+.2f}"
          .format(M1_J4))
    print("=" * 70)
    target = list(PICK_BASE)
    target[4] = M1_J4
    if not _in_clamp(M1_J4):
        print("[B] [ABORT] M1_J4={} が clamp 範囲外。Test B 全スキップ"
              .format(M1_J4))
        return
    for spd in (20, 10):
        for rep in (1, 2, 3):
            setup_ok = _setup_to_pick_base(
                dry_run, "B_spd{}_r{}".format(spd, rep))
            cond = "speed={}".format(spd)
            if setup_ok is False:
                _record(writer, "B", cond, rep, target, None, False,
                        spd, "setup_failed_skip", "open", TIMEOUT_S)
                continue
            reached, actual, _ = _send_one(
                target, spd, TIMEOUT_S,
                "B_spd{}_r{}".format(spd, rep), dry_run)
            _record(writer, "B", cond, rep, target, actual, reached,
                    spd, "oneshot", "open", TIMEOUT_S)


def run_test_c(writer, dry_run):
    """C: 刻むと届くか(候補3=一発送信の限界).
    M1 を「oneshot」と「5° 刻み stepped」で各 3 回。
    stepped は各 sub 送信も 1 行ずつ CSV 記録(mode=stepped)。"""
    print()
    print("=" * 70)
    print("Test C: oneshot vs stepped(候補3=一発送信の限界)M1: idx4 → {:+.2f}"
          .format(M1_J4))
    print("=" * 70)
    if not _in_clamp(M1_J4):
        print("[C] [ABORT] M1_J4 が clamp 範囲外。Test C 全スキップ")
        return

    target = list(PICK_BASE)
    target[4] = M1_J4

    # oneshot 3 reps
    for rep in (1, 2, 3):
        setup_ok = _setup_to_pick_base(dry_run, "C_oneshot_r{}".format(rep))
        if setup_ok is False:
            _record(writer, "C", "oneshot", rep, target, None, False,
                    SPEED_MEAS, "setup_failed_skip", "open", TIMEOUT_S)
            continue
        reached, actual, _ = _send_one(
            target, SPEED_MEAS, TIMEOUT_S,
            "C_oneshot_r{}".format(rep), dry_run)
        _record(writer, "C", "oneshot", rep, target, actual, reached,
                SPEED_MEAS, "oneshot", "open", TIMEOUT_S)

    # stepped(5° 刻み)の中間目標列を作る
    start_j4 = PICK_BASE[4]  # -70.48
    end_j4 = M1_J4           # -89.20
    step = -STEP_DEG if end_j4 < start_j4 else STEP_DEG
    intermediates = []
    cur = start_j4
    while abs(cur - end_j4) > STEP_DEG:
        cur = cur + step
        intermediates.append(cur)
    intermediates.append(end_j4)
    print("[C] stepped 中間目標 ({}): {}".format(
        len(intermediates),
        ", ".join("{:+.2f}".format(v) for v in intermediates)))

    # stepped 3 reps
    for rep in (1, 2, 3):
        setup_ok = _setup_to_pick_base(dry_run, "C_stepped_r{}".format(rep))
        if setup_ok is False:
            # stepped trial 全体を 1 行で skip 記録(最終目標 = M1_J4)
            final_target = list(PICK_BASE)
            final_target[4] = M1_J4
            _record(writer, "C", "stepped_setup_failed", rep,
                    final_target, None, False,
                    SPEED_MEAS, "setup_failed_skip", "open", TIMEOUT_S)
            continue
        n = len(intermediates)
        for k, j4_val in enumerate(intermediates, start=1):
            sub_target = list(PICK_BASE)
            sub_target[4] = j4_val
            cond = "stepped_step{}_of_{}".format(k, n)
            if not _in_clamp(j4_val):
                _record(writer, "C", cond, rep, sub_target, None, False,
                        SPEED_MEAS, "clamped_skip", "open", TIMEOUT_S)
                continue
            reached, actual, _ = _send_one(
                sub_target, SPEED_MEAS, TIMEOUT_S,
                "C_stepped_r{}_k{}".format(rep, k), dry_run)
            _record(writer, "C", cond, rep, sub_target, actual, reached,
                    SPEED_MEAS, "stepped", "open", TIMEOUT_S)
            if reached is False:
                # 明示 False(dry_run の None ではない)→ stepped 中で詰まった
                print("[C] stepped 中で到達失敗 step={}/{}。残りスキップ"
                      .format(k, n))
                break


def run_test_d(writer, dry_run):
    """D: 荷重の影響(候補4=荷重).
    M1 を「gripper open」3 回 と「gripper closed+ブロック保持」3 回."""
    print()
    print("=" * 70)
    print("Test D: 荷重比較(候補4=荷重)M1: idx4 → {:+.2f}".format(M1_J4))
    print("=" * 70)
    if not _in_clamp(M1_J4):
        print("[D] [ABORT] M1_J4 が clamp 範囲外。Test D 全スキップ")
        return

    target = list(PICK_BASE)
    target[4] = M1_J4

    # Phase 1: gripper open(無負荷)
    print("[D] Phase 1: gripper=open(無負荷)3 reps")
    for rep in (1, 2, 3):
        setup_ok = _setup_to_pick_base(dry_run, "D_open_r{}".format(rep))
        if setup_ok is False:
            _record(writer, "D", "gripper_open", rep, target, None, False,
                    SPEED_MEAS, "setup_failed_skip", "open", TIMEOUT_S)
            continue
        reached, actual, _ = _send_one(
            target, SPEED_MEAS, TIMEOUT_S,
            "D_open_r{}".format(rep), dry_run)
        _record(writer, "D", "gripper_open", rep, target, actual, reached,
                SPEED_MEAS, "oneshot", "open", TIMEOUT_S)

    # Phase 2: gripper closed(ブロック保持)準備
    # close 前の PICK_BASE 戻り失敗時は荷重テストの基準が崩れるので closed phase 全体スキップ。
    # gripper はまだ閉じていないので末尾の gripper open(早期 return)は不要。
    before_close_ok = _setup_to_pick_base(dry_run, "D_before_close")
    if before_close_ok is False:
        print("[D] close 前 PICK_BASE 未到達 → closed phase をスキップ")
        for rep in (1, 2, 3):
            _record(writer, "D", "gripper_closed", rep, target, None, False,
                    SPEED_MEAS, "setup_failed_skip", "closed", TIMEOUT_S)
        return
    print()
    print("=" * 70)
    print("[D] Phase 2 準備: 爪に赤ブロックを置いてください")
    print("=" * 70)
    close_ok = True
    if not dry_run:
        _wait_enter("→ ブロックを置いたら Enter(スクリプトが gripper を閉じます)")
        try:
            daemon_set_gripper(1)
            print("[D] gripper close → sleep {:.1f}s".format(GRIPPER_SLEEP))
            time.sleep(GRIPPER_SLEEP)
        except Exception as e:
            print("[D] [WARN] gripper close 失敗 → closed phase 全体をスキップ: {}"
                  .format(e))
            close_ok = False
        if close_ok:
            # 保持確認 Enter(人が目視で保持を確認してから closed 測定に入る)
            _wait_enter(
                "→ ブロックを保持できているか目視確認したら Enter"
                "(できていなければ Ctrl-C で中止)")
    else:
        print("[D] dry_run: 人手介入と gripper close をスキップ")

    if not close_ok:
        # close 失敗 → 3 rep を close_failed_skip で記録(実行はしない)
        print("[D] Phase 2: close 失敗のため 3 rep を close_failed_skip で記録")
        for rep in (1, 2, 3):
            _record(writer, "D", "gripper_closed", rep, target, None, False,
                    SPEED_MEAS, "close_failed_skip", "closed", TIMEOUT_S)
    else:
        print("[D] Phase 2: gripper=closed(荷重あり)3 reps")
        for rep in (1, 2, 3):
            setup_ok = _setup_to_pick_base(
                dry_run, "D_closed_r{}".format(rep))
            if setup_ok is False:
                _record(writer, "D", "gripper_closed", rep, target, None, False,
                        SPEED_MEAS, "setup_failed_skip", "closed", TIMEOUT_S)
                # closed phase で setup 失敗 → 荷重がかかった状態で基準に戻れていない。
                # 以降の closed rep を中止
                print("[D] closed phase setup 失敗 → 残りの closed rep を中止")
                # 残りも記録だけ
                for skip_rep in range(rep + 1, 4):
                    _record(writer, "D", "gripper_closed", skip_rep, target, None, False,
                            SPEED_MEAS, "setup_failed_skip", "closed", TIMEOUT_S)
                break
            reached, actual, _ = _send_one(
                target, SPEED_MEAS, TIMEOUT_S,
                "D_closed_r{}".format(rep), dry_run)
            _record(writer, "D", "gripper_closed", rep, target, actual, reached,
                    SPEED_MEAS, "oneshot", "closed", TIMEOUT_S)

    # 終了: gripper open(ブロック解放。後段の HOME 復帰前に)
    print("[D] 終了: gripper を開きます(ブロックが落ちます。手で受けてください)")
    if not dry_run:
        try:
            daemon_set_gripper(0)
        except Exception:
            pass
        time.sleep(GRIPPER_SLEEP)


# ---- メイン -----------------------------------------------------------------
def main():
    ap = argparse.ArgumentParser(
        description="j4 単軸ストール診断(A/B/C/D 4 候補切り分け)")
    ap.add_argument("--dry-run", action="store_true",
                    help="送信せず、送信計画と CSV 行数だけ出す")
    ap.add_argument("--skip-d", action="store_true",
                    help="Test D(荷重・人手介入)をスキップ")
    args = ap.parse_args()

    OUT_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    out_path = OUT_DIR / "j4_probe_{}.csv".format(ts)
    print("=" * 70)
    print("[INIT ] j4_probe  ts={}".format(ts))
    print("[INIT ] PICK_BASE = {}".format(_fmt(PICK_BASE)))
    print("[INIT ] HOME      = {}".format(_fmt(HOME)))
    print("[INIT ] M1: PICK_BASE[4]={:+.2f} → {:+.2f}".format(PICK_BASE[4], M1_J4))
    print("[INIT ] CSV 出力: {}".format(out_path))
    print("[INIT ] dry_run={}  skip_d={}".format(args.dry_run, args.skip_d))
    print("=" * 70)

    # daemon 接続(dry_run はスキップ)
    if not args.dry_run:
        print("[INIT ] daemon 接続中 ...")
        try:
            get_daemon_socket()
        except Exception as e:
            print("[ABORT] daemon ping 失敗: {}".format(e))
            sys.exit(1)
        try:
            daemon_set_fresh_mode(1)
            print("[INIT ] fresh_mode=1")
        except Exception as e:
            print("[WARN ] set_fresh_mode(1) 例外: {}".format(e))
        try:
            daemon_set_gripper(0)
            time.sleep(GRIPPER_SLEEP)
            print("[INIT ] gripper open")
        except Exception as e:
            print("[WARN ] set_gripper(0) 例外: {}".format(e))

    # CSV 書き出し
    with open(str(out_path), "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=CSV_COLS)
        writer.writeheader()
        normal_finished = False
        try:
            # 開始: HOME → PICK_BASE(slow)
            print()
            print("[STEP ] 開始: HOME → PICK_BASE (slow)")
            _send_one(HOME, SPEED_SLOW, TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
                      "init_to_home", args.dry_run)
            _send_one(PICK_BASE, SPEED_SLOW, TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
                      "init_to_pick_base", args.dry_run)

            run_test_a(writer, args.dry_run)
            run_test_b(writer, args.dry_run)
            run_test_c(writer, args.dry_run)
            if not args.skip_d:
                run_test_d(writer, args.dry_run)
            else:
                print()
                print("[D] --skip-d 指定によりスキップ")

            # 終了: PICK_BASE → HOME(slow)
            print()
            print("[STEP ] 終了: PICK_BASE → HOME (slow)")
            _send_one(PICK_BASE, SPEED_SLOW, TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
                      "final_to_pick_base", args.dry_run)
            home_reached, _, _ = _send_one(
                HOME, SPEED_SLOW, TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
                "final_to_home", args.dry_run)
            # HOME 到達が確定したときだけ正常完了。is True 比較で
            # dry_run (None) や未到達 (False) を正常扱いにしない
            normal_finished = (home_reached is True)

        except KeyboardInterrupt:
            print()
            print("[INTERRUPT] Ctrl-C 検出。これまでの CSV を保存して終了")
        finally:
            # gripper 開放は常に実行(正常完了でも害なし・保険)。
            # PICK_BASE / HOME 復帰移動は normal_finished=False のとき(ABORT/Ctrl-C 経路)だけ。
            # 正常完了は既に HOME 済みなので、無駄な大移動(HOME→PICK_BASE→HOME)を避ける。
            if not args.dry_run:
                try:
                    daemon_set_gripper(0)
                    time.sleep(GRIPPER_SLEEP)
                    print("[CLEANUP] gripper open")
                except Exception as e:
                    print("[CLEANUP] gripper open 例外: {}(continue)".format(e))
                if not normal_finished:
                    # 異常終了のみ復帰移動。1 回ずつ・例外/未到達でも進む。
                    try:
                        _send_one(
                            PICK_BASE, SPEED_SLOW,
                            TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
                            "cleanup_to_pick_base", False)
                    except Exception as e:
                        print("[CLEANUP] PICK_BASE 復帰例外: {}(continue)".format(e))
                    try:
                        _send_one(
                            HOME, SPEED_SLOW,
                            TIMEOUT_S * TIMEOUT_SLOW_FACTOR,
                            "cleanup_to_home", False)
                    except Exception as e:
                        print("[CLEANUP] HOME 復帰例外: {}(continue)".format(e))
                try:
                    daemon_set_fresh_mode(0)
                    print("[EXIT ] fresh_mode=0")
                except Exception:
                    pass
                try:
                    close_daemon_socket()
                except Exception:
                    pass

    # CSV 行数集計
    with open(str(out_path)) as f:
        n_rows = max(0, sum(1 for _ in f) - 1)  # header 除く
    print()
    print("=" * 70)
    print("[DONE ] CSV 行数: {}(測定送信のみ・setup 送信は CSV 対象外)"
          .format(n_rows))
    print("[DONE ] 出力: {}".format(out_path))


if __name__ == "__main__":
    main()

実装後 codex に通し、setup 失敗時のスキップ、荷重テストの保持確認、中断時の復帰の3点を直してから実機に出した。荷重テスト D は人の立ち会いが要るので、まず --skip-d で A/B/C(無負荷)だけを回した。

回した

--skip-d で実機を回したコンソール。setup(基準姿勢への戻し)が何度か未到達になっていて、その rep は測定をスキップしている。

[STEP ] 開始: HOME → PICK_BASE (slow)

======================================================================
Test A: idx4 スキャン(候補1=特定角度域)
======================================================================
[SETUP] A_j4-45_r1 → PICK_BASE (slow)
[SEND] A_j4-45.00_r1 部分移動(pre→last max|Δ|=7.56° >= 1.0°)→ 再送しない
[REC  ] test=A cond=j4=-45.00 rep=1 cmd_j4=-45.00 actual_j4=-61.34 err=16.34 reached=False speed=20 mode=oneshot
[SETUP] A_j4-45_r2 → PICK_BASE (slow)
[SEND] A_j4-45.00_r2 部分移動(pre→last max|Δ|=7.30° >= 1.0°)→ 再送しない
[REC  ] test=A cond=j4=-45.00 rep=2 cmd_j4=-45.00 actual_j4=-61.78 err=16.78 reached=False speed=20 mode=oneshot
[SETUP] A_j4-45_r3 → PICK_BASE (slow)
[SEND] [SILENCE-RETRY] setup_A_j4-45_r3 沈黙疑い(pre→last max|Δ|=0.00° < 1.0° 閾値)→ 1.0s 待って 1 回だけ再送
[SEND] A_j4-45.00_r3 部分移動(pre→last max|Δ|=1.84° >= 1.0°)→ 再送しない
[REC  ] test=A cond=j4=-45.00 rep=3 cmd_j4=-45.00 actual_j4=-70.48 err=25.48 reached=False speed=20 mode=oneshot
[SETUP] A_j4-55_r1 → PICK_BASE (slow)
[SEND] A_j4-55.00_r1 部分移動(pre→last max|Δ|=7.82° >= 1.0°)→ 再送しない
[REC  ] test=A cond=j4=-55.00 rep=1 cmd_j4=-55.00 actual_j4=-62.66 err=7.66 reached=False speed=20 mode=oneshot
[SETUP] A_j4-55_r2 → PICK_BASE (slow)
[SEND] setup_A_j4-55_r2 部分移動(pre→last max|Δ|=1.14° >= 1.0°)→ 再送しない
[SETUP] [WARN] PICK_BASE 未到達 actual=[-38.14, +88.59, -21.70, +1.66, -62.05, -32.43] elapsed=15.0s → この rep の測定はスキップする
[REC  ] test=A cond=j4=-55.00 rep=2 cmd_j4=-55.00 actual_j4=— err=— reached=False speed=20 mode=setup_failed_skip
[SETUP] A_j4-55_r3 → PICK_BASE (slow)
[SEND] A_j4-55.00_r3 部分移動(pre→last max|Δ|=6.51° >= 1.0°)→ 再送しない
[REC  ] test=A cond=j4=-55.00 rep=3 cmd_j4=-55.00 actual_j4=-62.57 err=7.57 reached=False speed=20 mode=oneshot
[SETUP] A_j4-65_r1 → PICK_BASE (slow)
[SEND] setup_A_j4-65_r1 部分移動(pre→last max|Δ|=1.49° >= 1.0°)→ 再送しない
[SETUP] [WARN] PICK_BASE 未到達 actual=[-37.88, +88.68, -21.88, +1.58, -61.08, -32.51] elapsed=15.0s → この rep の測定はスキップする
[REC  ] test=A cond=j4=-65.00 rep=1 cmd_j4=-65.00 actual_j4=— err=— reached=False speed=20 mode=setup_failed_skip
[SETUP] A_j4-65_r2 → PICK_BASE (slow)
[SEND] setup_A_j4-65_r2 部分移動(pre→last max|Δ|=1.23° >= 1.0°)→ 再送しない
[SETUP] [WARN] PICK_BASE 未到達 actual=[-37.88, +88.68, -21.79, +1.58, -62.31, -32.60] elapsed=15.0s → この rep の測定はスキップする
[REC  ] test=A cond=j4=-65.00 rep=2 cmd_j4=-65.00 actual_j4=— err=— reached=False speed=20 mode=setup_failed_skip
[SETUP] A_j4-65_r3 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-65.00 rep=3 cmd_j4=-65.00 actual_j4=-66.79 err=1.79 reached=True speed=20 mode=oneshot
[SETUP] A_j4-75_r1 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-75.00 rep=1 cmd_j4=-75.00 actual_j4=-74.26 err=0.74 reached=True speed=20 mode=oneshot
[SETUP] A_j4-75_r2 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-75.00 rep=2 cmd_j4=-75.00 actual_j4=-74.26 err=0.74 reached=True speed=20 mode=oneshot
[SETUP] A_j4-75_r3 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-75.00 rep=3 cmd_j4=-75.00 actual_j4=-74.35 err=0.65 reached=True speed=20 mode=oneshot
[SETUP] A_j4-85_r1 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-85.00 rep=1 cmd_j4=-85.00 actual_j4=-83.84 err=1.16 reached=True speed=20 mode=oneshot
[SETUP] A_j4-85_r2 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-85.00 rep=2 cmd_j4=-85.00 actual_j4=-84.02 err=0.98 reached=True speed=20 mode=oneshot
[SETUP] A_j4-85_r3 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-85.00 rep=3 cmd_j4=-85.00 actual_j4=-84.02 err=0.98 reached=True speed=20 mode=oneshot
[SETUP] A_j4-95_r1 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-95.00 rep=1 cmd_j4=-95.00 actual_j4=-93.51 err=1.49 reached=True speed=20 mode=oneshot
[SETUP] A_j4-95_r2 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-95.00 rep=2 cmd_j4=-95.00 actual_j4=-93.69 err=1.31 reached=True speed=20 mode=oneshot
[SETUP] A_j4-95_r3 → PICK_BASE (slow)
[REC  ] test=A cond=j4=-95.00 rep=3 cmd_j4=-95.00 actual_j4=-93.77 err=1.23 reached=True speed=20 mode=oneshot

======================================================================
Test B: SPEED 比較(候補2=速度/トルク)M1: idx4 → -89.20
======================================================================
[SETUP] B_spd20_r1 → PICK_BASE (slow)
[REC  ] test=B cond=speed=20 rep=1 cmd_j4=-89.20 actual_j4=-88.41 err=0.79 reached=True speed=20 mode=oneshot
[SETUP] B_spd20_r2 → PICK_BASE (slow)
[REC  ] test=B cond=speed=20 rep=2 cmd_j4=-89.20 actual_j4=-88.33 err=0.87 reached=True speed=20 mode=oneshot
[SETUP] B_spd20_r3 → PICK_BASE (slow)
[REC  ] test=B cond=speed=20 rep=3 cmd_j4=-89.20 actual_j4=-88.41 err=0.79 reached=True speed=20 mode=oneshot
[SETUP] B_spd10_r1 → PICK_BASE (slow)
[REC  ] test=B cond=speed=10 rep=1 cmd_j4=-89.20 actual_j4=-88.06 err=1.14 reached=True speed=10 mode=oneshot
[SETUP] B_spd10_r2 → PICK_BASE (slow)
[SEND] B_spd10_r2 部分移動(pre→last max|Δ|=1.14° >= 1.0°)→ 再送しない
[REC  ] test=B cond=speed=10 rep=2 cmd_j4=-89.20 actual_j4=-70.75 err=18.45 reached=False speed=10 mode=oneshot
[SETUP] B_spd10_r3 → PICK_BASE (slow)
[REC  ] test=B cond=speed=10 rep=3 cmd_j4=-89.20 actual_j4=-88.06 err=1.14 reached=True speed=10 mode=oneshot

======================================================================
Test C: oneshot vs stepped(候補3=一発送信の限界)M1: idx4 → -89.20
======================================================================
[SETUP] C_oneshot_r1 → PICK_BASE (slow)
[REC  ] test=C cond=oneshot rep=1 cmd_j4=-89.20 actual_j4=-88.33 err=0.87 reached=True speed=20 mode=oneshot
[SETUP] C_oneshot_r2 → PICK_BASE (slow)
[REC  ] test=C cond=oneshot rep=2 cmd_j4=-89.20 actual_j4=-88.33 err=0.87 reached=True speed=20 mode=oneshot
[SETUP] C_oneshot_r3 → PICK_BASE (slow)
[SEND] setup_C_oneshot_r3 部分移動(pre→last max|Δ|=1.49° >= 1.0°)→ 再送しない
[SETUP] [WARN] PICK_BASE 未到達 actual=[-37.96, +88.94, -20.83, +1.14, -89.82, -32.87] elapsed=15.2s → この rep の測定はスキップする
[REC  ] test=C cond=oneshot rep=3 cmd_j4=-89.20 actual_j4=— err=— reached=False speed=20 mode=setup_failed_skip
[C] stepped 中間目標 (4): -75.48, -80.48, -85.48, -89.20
[SETUP] C_stepped_r1 → PICK_BASE (slow)
[SEND] C_stepped_r1_k1 部分移動(pre→last max|Δ|=2.11° >= 1.0°)→ 再送しない
[REC  ] test=C cond=stepped_step1_of_4 rep=1 cmd_j4=-75.48 actual_j4=-66.18 err=9.30 reached=False speed=20 mode=stepped
[C] stepped 中で到達失敗 step=1/4。残りスキップ
[SETUP] C_stepped_r2 → PICK_BASE (slow)
[REC  ] test=C cond=stepped_step1_of_4 rep=2 cmd_j4=-75.48 actual_j4=-74.09 err=1.39 reached=True speed=20 mode=stepped
[SEND] C_stepped_r2_k2 部分移動(pre→last max|Δ|=8.35° >= 1.0°)→ 再送しない
[REC  ] test=C cond=stepped_step2_of_4 rep=2 cmd_j4=-80.48 actual_j4=-65.74 err=14.74 reached=False speed=20 mode=stepped
[C] stepped 中で到達失敗 step=2/4。残りスキップ
[SETUP] C_stepped_r3 → PICK_BASE (slow)
[SEND] [SILENCE-RETRY] setup_C_stepped_r3 沈黙疑い(pre→last max|Δ|=0.44° < 1.0° 閾値)→ 1.0s 待って 1 回だけ再送
[REC  ] test=C cond=stepped_step1_of_4 rep=3 cmd_j4=-75.48 actual_j4=-74.17 err=1.31 reached=True speed=20 mode=stepped
[REC  ] test=C cond=stepped_step2_of_4 rep=3 cmd_j4=-80.48 actual_j4=-79.36 err=1.12 reached=True speed=20 mode=stepped
[REC  ] test=C cond=stepped_step3_of_4 rep=3 cmd_j4=-85.48 actual_j4=-84.19 err=1.29 reached=True speed=20 mode=stepped
[REC  ] test=C cond=stepped_step4_of_4 rep=3 cmd_j4=-89.20 actual_j4=-88.24 err=0.96 reached=True speed=20 mode=stepped

[D] --skip-d 指定によりスキップ

[STEP ] 終了: PICK_BASE → HOME (slow)
  [Pi] GRIPPER: OPEN result= None
[CLEANUP] gripper open
  [Pi] fresh_mode=0
[EXIT ] fresh_mode=0
[DONE ] CSV 行数: 34

渋っていたのは、深い側ではなく浅い側だった

測った結果は、立てた4つのどれにも当てはまらなかった。

特定の角度域で引っかかるわけではなかった(候補1)。止まるのは点ではなく、-61 から 0 に向かう浅い側の全域で、それより 0 に近づけない。速度やトルクの不足(候補2)も Test B で否定された。深い側の -89.20 は speed 20 でも 10 でも基本届いている。一発で大きく動かす限界(候補3)も薄い。oneshot で届いていて、むしろ刻んで送る stepped のほうが途中で止まっている回がある。荷重(候補4)は今回 --skip-d で測っていないが、無負荷の時点で浅い側に壁があるので、荷重を足す前に説明がつく。

仮説と測定結果を並べる。

仮説

測ったこと

結果

判断

特定角度域で止まる

j4=-45〜-95 を10°刻み

浅い側だけ -61 付近で頭打ち

角度点ではなく方向の壁

速度・トルク不足

speed 20 と 10 で -89.20

基本どちらも到達

速度では説明できない

一発送信が無理

oneshot と 5°刻み

oneshot で到達、stepped は途中失敗あり

刻めば届く問題ではない

荷重の影響

今回は skip

無負荷で既に壁あり

次回の測定対象

Test A を、浅い側から深い側へ並べ直す。基準は j4=-70.48。-45 / -55 / -65 は基準から浅くする(0 に近づける)方向、-75 / -85 / -95 は深くする方向。

指令 j4

実測 j4(3回)

到達

-45.00

-61.34 / -61.78 / -70.48

全て未到達

-55.00

-62.66 / (skip) / -62.57

全て未到達

-65.00

(skip) / (skip) / -66.79

1回到達

-75.00

-74.26 / -74.26 / -74.35

全て到達(誤差 0.7°前後)

-85.00

-83.84 / -84.02 / -84.02

全て到達(誤差 1°前後)

-95.00

-93.51 / -93.69 / -93.77

全て到達(誤差 1.3°前後)

浅い側を指令したときの実測が、-61〜-62 のあたりで頭打ちになっている。-45 を指令しても -61 までしか動かない。-55 でも -62 まで。それより 0 に近づけない。一方で深い側は -95 まで誤差 1°台で素直に届く。この姿勢の j4 は、深くする方向(マイナス側)には -95 まで動くのに、浅くする方向(0 へ戻る側)は -61 付近で壁になる。

CSV を全量で残す。actual_idx4 が実測の j4、err_deg が指令との差、reached_bool が到達可否、full_actual_angles がその時の全6軸。

test,condition,rep,cmd_idx4,actual_idx4,err_deg,reached_bool,speed,mode,gripper,timeout_s,full_actual_angles
A,j4=-45.00,1,-45.000,-61.340,16.340,False,20,oneshot,open,10.0,"[-38.49, +88.59, -22.06, +1.75, -61.34, -32.34]"
A,j4=-45.00,2,-45.000,-61.780,16.780,False,20,oneshot,open,10.0,"[-38.32, +88.59, -22.06, +1.66, -61.78, -32.51]"
A,j4=-45.00,3,-45.000,-70.480,25.480,False,20,oneshot,open,10.0,"[-38.32, +88.94, -20.83, +1.14, -70.48, -33.04]"
A,j4=-55.00,1,-55.000,-62.660,7.660,False,20,oneshot,open,10.0,"[-38.05, +88.59, -21.70, +1.75, -62.66, -33.57]"
A,j4=-55.00,2,-55.000,,,False,20,setup_failed_skip,open,10.0,
A,j4=-55.00,3,-55.000,-62.570,7.570,False,20,oneshot,open,10.0,"[-37.88, +88.68, -21.70, +1.75, -62.57, -32.51]"
A,j4=-65.00,1,-65.000,,,False,20,setup_failed_skip,open,10.0,
A,j4=-65.00,2,-65.000,,,False,20,setup_failed_skip,open,10.0,
A,j4=-65.00,3,-65.000,-66.790,1.790,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.91, +1.05, -66.79, -33.57]"
A,j4=-75.00,1,-75.000,-74.260,0.740,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.91, +1.14, -74.26, -33.48]"
A,j4=-75.00,2,-75.000,-74.260,0.740,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.91, +1.14, -74.26, -33.48]"
A,j4=-75.00,3,-75.000,-74.350,0.650,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.91, +1.14, -74.35, -33.48]"
A,j4=-85.00,1,-85.000,-83.840,1.160,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -83.84, -33.39]"
A,j4=-85.00,2,-85.000,-84.020,0.980,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -84.02, -33.31]"
A,j4=-85.00,3,-85.000,-84.020,0.980,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -84.02, -33.22]"
A,j4=-95.00,1,-95.000,-93.510,1.490,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -93.51, -33.13]"
A,j4=-95.00,2,-95.000,-93.690,1.310,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -93.69, -33.04]"
A,j4=-95.00,3,-95.000,-93.770,1.230,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -93.77, -33.04]"
B,speed=20,1,-89.200,-88.410,0.790,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.41, -32.95]"
B,speed=20,2,-89.200,-88.330,0.870,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.33, -32.95]"
B,speed=20,3,-89.200,-88.410,0.790,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.41, -32.95]"
B,speed=10,1,-89.200,-88.060,1.140,True,10,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.06, -32.95]"
B,speed=10,2,-89.200,-70.750,18.450,False,10,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -70.75, -32.95]"
B,speed=10,3,-89.200,-88.060,1.140,True,10,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.06, -32.87]"
C,oneshot,1,-89.200,-88.330,0.870,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.33, -32.87]"
C,oneshot,2,-89.200,-88.330,0.870,True,20,oneshot,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.33, -32.87]"
C,oneshot,3,-89.200,,,False,20,setup_failed_skip,open,10.0,
C,stepped_step1_of_4,1,-75.480,-66.180,9.300,False,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -66.18, -32.43]"
C,stepped_step1_of_4,2,-75.480,-74.090,1.390,True,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -74.09, -33.13]"
C,stepped_step2_of_4,2,-80.480,-65.740,14.740,False,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -65.74, -32.60]"
C,stepped_step1_of_4,3,-75.480,-74.170,1.310,True,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -74.17, -32.87]"
C,stepped_step2_of_4,3,-80.480,-79.360,1.120,True,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -79.36, -32.87]"
C,stepped_step3_of_4,3,-85.480,-84.190,1.290,True,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -84.19, -32.87]"
C,stepped_step4_of_4,3,-89.200,-88.240,0.960,True,20,stepped,open,10.0,"[-37.96, +88.94, -20.83, +1.14, -88.24, -32.87]"

まだ残る組み合わせ問題

最初の通しで j4 が -70 から -47.98 へ浅い側に流れた現象は、この単軸テストとつながる。浅い側へ戻す方向に壁がある。

一方で、2回目の通しで -89.20 に届かなかった現象は、単軸テストだけでは説明できない。単軸では -89.20 に届いている。深い側の未到達は、j4 単独の限界ではなく、他の軸と同時に動かしたときの姿勢依存として、別に切り分ける必要がある。

次にやること

浅くする方向だけを単独で測る。基準 -70.48 から -65、-60、-55、-50 と、深い側から浅い側へ片道で刻んで、どこから戻れなくなるかを見る。今回は基準への戻しを毎回挟んだせいで、戻し自体の失敗がデータに混ざった。戻しを挟まず浅い方向の片道だけを連続で取れば、方向の依存がもっとはっきり出る。

それと、通しで止まった深い目標は単軸では届いたので、他軸を同時に動かしたときの j4 の保持を別に測る。

候補を4つ立てて、そのどれかだと思って測りに行った。返ってきた数字はどれでもなく、5つ目を指していた。測らなければ、4つのどれかを選んで、直したつもりになっていた。

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