#!/usr/bin/env python3
"""diag_joint_hold.py — 実機推論の記録から, 関節ごとの「動いている途中の遅れ」と「止まったあとのずれ (垂れ)」を出す (2026-11-01 依頼 57).

読むだけ (runs/ には書かない). アームは使わない.

入力: runs/pi05_policy/{run_stem}/recording/states.csv と log.jsonl
  states.csv (依頼 46 のあとの記録) には send_angles 行 (送った時刻・目標・rpc_sec) と, 爪の rpc 行がある.
  依頼 46 より前の記録 (send_angles 行がない) は log.jsonl だけで見る (下の「log で見る」).

分け方 (states.csv で見る):
  - 指令 = 直前の send_angles の目標. 送ってから rpc が返るまで (daemon が着いたと判定するまで. 約 1.6 秒) は
    つながりがふさがり, 実測は残らない. rpc が返ったあとから次の送信までを「止まっているはずの区間」(hold) とする.
    爪の rpc (開け閉め. 指令は変わらない) でも区間を切る (爪の前・爪の後 を別に見る).
  - 区間の中のコマごと・関節ごとに, 前後 MOVE_WIN_SEC 以内の実測のふれが MOVE_EPS_DEG を超えていれば「動いている」.
    動いているコマの |実測 − 指令| を「動いている途中の遅れ」とする (着いたと判定されたあとも動いていた分も含む).
  - 動いていないコマの |実測 − 指令| を「止まったあとのずれ」とする. 関節ごとに, 区間の最後の動いていないコマの値を
    その区間のずれとし (区間の数で数える. 長く待った区間が重くならない), run ごとに中央値・90%・最大を出す.
    codex R1 必須: 動いているかは関節ごとに見る (j1 が動き続けていても, 止まった j4 のずれを見落とさない).
    その関節が区間の中で一度も止まっていなければ, その区間のずれは無し (NaN) とする.
  - 区間の最後の「止まりが続いたところ」(関節ごと. つながった動いていないコマ) の変化 (最後 − 最初) を
    「止まっている間の変化」(じわじわ垂れる) とし, 続いた秒も出す. MOVE_EPS_DEG に満たない速さで動き続けるものはここに出る.
  - 前後 0.3 秒の窓で動きが見えなかったコマを「動いていない」としている (区間の端・まばらな記録では 1 コマでも
    動いていないことになる). 止まりを確かめた値ではなく, この窓で動きを見つけられなかった値として読む (codex R1)
  - 粗い値 (依頼文と同じ): 送信のあとのすべての実測と直前の指令の差の最大.

ゲームパッドの補正 (2026-11-06 依頼 97. run_policy_client の p. コミット cd2fb46):
  - 送り方が違う: 1 刻みは動かす関節だけを send_angle で送り, 長押しは 15° 先へ 1 回送って離したら stop で止める.
    states.csv には rpc 行 (cmd=send_angle / cmd=stop) しか残らず, 目標は log.jsonl の type=pad の行にある.
    そこで rpc 行を pad の行と時刻で組にし, 指令をゲームパッドと同じ決まりで作る:
      1 刻み (pad_step)  : 指令 = その行の command (6 関節. 動かさない関節は補正を始めたときの指令のまま)
      長押し (pad_hold)  : 指令 = command (15° 先). 押した関節はそこへ着く気がないので, この区間ではその関節を
                           見ない (遅れ 0・止まりのずれ なし). ほかの関節は見る
      止める (pad_stop)  : 指令 = 直前の指令のうち, 止めた関節だけ止めたあとに読んだ角度 (measured_after_halt)
                           (gamepad_teleop の _apply_halt と同じ). 止めた関節が分からなければ, 読んだ角度そのもの
    組にできなかった rpc 行は pad_unknown として指令を持たない区間にする (値を出さない. 数だけ出す)
  - daemon が送ったらすぐ返す (2026-11-05 c7992ca のあと. rpc 0.03〜0.14 秒) と, rpc が返ったあとも腕は指令へ
    向かって動いている. その動きは「着いたはずなのに動いた」ではない. rpc が FAST_RPC_SEC より短く返った区間では:
      - 動いている途中の遅れは数えない (指令へ向かう途中. 途中で一度止まってまた向かうものも)
      - 代わりに関節ごとに「それまでにいちばん指令へ近づいた所から離れた分」(|実測−指令| − それまでの最小) を
        away として出し, EVENT_DEG を超えたら外れに数える (着いたあとに垂れた・向かう途中に逆へ動いた).
        すぐ返った区間がつづき, その関節の指令が変わらない間は, 区間をまたいで最小を引き継ぐ (codex R1 必須)
      - 止まったあとのずれは, その関節が区間の中で動いたなら動き出したあとの止まりだけで見る
        (動き出す前の止まりは前の姿勢のままなので, 新しい指令とのずれにしない)
    1.6 秒待って返っていた記録 (11-01 まで) はこれまでと同じ (向かう途中は rpc の間で, 実測がない)

log で見る (依頼 46 より前の記録とも比べられる):
  - 補正の 1 刻み: measured_after − after (全 6 関節)
  - チャンクの区切り: 次の推論で読んだ measured_state − 直前の指令 (チャンクの最後の sent_targets / 補正の after)

Usage:
  python3 pi05/diag_joint_hold.py --run pi05_20261101_190347 --run pi05_20261101_194042
  python3 pi05/diag_joint_hold.py --run ... --json docs/diag_joint_hold_20261101.json
"""
import argparse
import csv
import json
import sys
from pathlib import Path

import numpy as np

DATA_ROOT = Path("/Volumes/KIOXIA/r2r2r-data")
RUNS_ROOT = DATA_ROOT / "runs" / "pi05_policy"
JOINTS = ["j0", "j1", "j2", "j3", "j4", "j5"]
MOVE_WIN_SEC = 0.3      # 前後これだけの間の実測のふれで「動いている」を見る
MOVE_EPS_DEG = 0.3      # ふれがこれを超えたら動いている (読みの刻みは約 0.09°)
LONG_HOLD_SEC = 1.0     # 「止まっている間の変化」は, 動いていないコマがこれだけ続いた区間で見る
# 指令が変わらないのに動いた・止まったあとも残ったずれがこれを超えた区間を「外れ」として数える (依頼 57).
# 手首の外れのなかった回 (190347・190854・192101) の止まったあとの j4 のずれは最大 3.5° (中央値 0.3〜0.6°).
# Log17 (10-31) のチャンクの区切りでも最大 2.8° (182545 の 8.1° を除く)
EVENT_DEG = 4.0
# rpc がこれより短く返った区間は「送ったらすぐ返った」(着くのを待っていない) とみなす (依頼 97).
# 11-01 までは 1.5〜1.6 秒, 11-06 は 0.01〜0.14 秒 (send_angles・send_angle・stop)
FAST_RPC_SEC = 0.5
PAD_MATCH_SEC = 0.2     # rpc 行と pad の行 (t_send / t_halt) の時刻の差の上限


def load_rows(path):
    rows = []
    with Path(path).open(newline="") as f:
        for r in csv.DictReader(f):
            try:
                r["_ts"] = float(r["ts"])
            except Exception:
                continue
            rows.append(r)
    rows.sort(key=lambda r: r["_ts"])
    return rows


def _q(r):
    try:
        return np.array([float(r[j]) for j in JOINTS])
    except Exception:
        return None


def _rpc_sec(r):
    for tok in (r.get("note") or "").split():
        if tok.startswith("rpc_sec="):
            try:
                return float(tok.split("=", 1)[1])
            except ValueError:
                return 0.0
    return 0.0


def pad_events(log):
    """log.jsonl の type=pad の行から, 送ったもの (move / home) と止めたもの (halt) を時刻の順に集める (依頼 97)."""
    sends, halts = [], []
    for r in log or ():
        if r.get("type") != "pad":
            continue
        if r.get("kind") in ("move", "home") and r.get("sent") is True and r.get("command") is not None \
                and r.get("t_send") is not None:
            sends.append({"t": float(r["t_send"]), "cmd": np.asarray(r["command"][:6], float),
                          "joint": r.get("joint") if r.get("kind") == "move" else None,
                          "hold": bool(r.get("no_check")), "resends": len(r.get("resends") or []),
                          "corr": r.get("correction_id")})
        elif r.get("kind") == "halt" and r.get("t_halt") is not None:
            q = r.get("measured_after_halt")
            halts.append({"t": float(r["t_halt"]), "q": None if q is None else np.asarray(q[:6], float),
                          "joint": r.get("halt_joint"), "how": r.get("halt"), "corr": r.get("correction_id")})
    sends.sort(key=lambda x: x["t"]); halts.sort(key=lambda x: x["t"])
    return sends, halts


def _pad_send_for(sends, used, ts):
    """send_angle の rpc 行 (時刻 ts) に当たる pad の送信と, それが何回目か (0 = 初めて・1 以上 = 送り直し).
    codex R1 必須: 初めての送信は, まだ組にしていない送信のうち |t_send − ts| が PAD_MATCH_SEC 以内でいちばん近いもの.
    それがなければ, ts より前に組にした送信のうち送り直しの残りがあるいちばん新しいものの送り直しとする.
    どちらもなければ (None, 0). used = {送信の番号: 組にした回数}"""
    cand = [(abs(x["t"] - ts), k) for k, x in enumerate(sends) if k not in used and abs(x["t"] - ts) <= PAD_MATCH_SEC]
    if cand:
        k = min(cand)[1]
        used[k] = 1
        return sends[k], 0
    for k in sorted((k for k in used if sends[k]["t"] <= ts), key=lambda k: sends[k]["t"], reverse=True):
        if used[k] <= sends[k]["resends"]:
            used[k] += 1
            return sends[k], used[k] - 1
        break               # いちばん新しい送信の送り直しが尽きていれば, それより前のものにはしない
    return None, 0


def segments(rows, log=None):
    """hold の区間に分ける. 戻り値: [{kind, stage, cmd, t_cmd, t0 (rpc が返った時刻), fast, skip, ts[], q[], stages[]}]
    log (log.jsonl の行) があれば, ゲームパッドの rpc 行 (send_angle / stop) の指令を pad の行から作る (依頼 97)."""
    sends, halts = pad_events(log)
    used_sends, used_halts = {}, set()
    segs, cur = [], None
    nan6 = np.full(6, np.nan)
    for r in rows:
        ev = r.get("event_type")
        if ev == "send_angles":
            q = _q(r)
            if q is None:
                continue
            rs = _rpc_sec(r)
            cur = {"kind": "send", "stage": r.get("stage"), "cmd": q, "t_cmd": r["_ts"],
                   "t0": r["_ts"] + rs, "fast": rs < FAST_RPC_SEC, "skip": None,
                   "ts": [], "q": [], "stages": []}
            segs.append(cur)
        elif ev == "rpc" and cur is not None:
            cmd = (r.get("note") or "").split()[0].replace("cmd=", "")
            rs = _rpc_sec(r)
            base = {"stage": r.get("stage"), "t_cmd": r["_ts"], "t0": r["_ts"] + rs, "fast": rs < FAST_RPC_SEC,
                    "skip": None, "ts": [], "q": [], "stages": []}
            if cmd == "send_angle":
                x, n_used = _pad_send_for(sends, used_sends, r["_ts"])
                if x is None:
                    cur = dict(base, kind="pad_unknown", cmd=nan6.copy())
                else:
                    cur = dict(base, kind="pad_hold" if x["hold"] else "pad_step", cmd=x["cmd"].copy(),
                               skip=x["joint"] if x["hold"] else None, t_cmd=x["t"] if n_used == 0 else r["_ts"])
            elif cmd == "stop":
                # codex R1 推奨: いちばん近い・まだ組にしていない halt (一対一)
                hc = [(abs(x["t"] - r["_ts"]), k) for k, x in enumerate(halts)
                      if k not in used_halts and abs(x["t"] - r["_ts"]) <= PAD_MATCH_SEC]
                h = None
                if hc:
                    used_halts.add(min(hc)[1])
                    h = halts[min(hc)[1]]
                if h is None or h["q"] is None:
                    cur = dict(base, kind="pad_unknown", cmd=nan6.copy())
                else:
                    c = np.asarray(cur["cmd"], float).copy()
                    if h["joint"] is not None and not np.isnan(c).any():
                        c[int(h["joint"])] = h["q"][int(h["joint"])]
                    else:
                        c = h["q"].copy()
                    cur = dict(base, kind="pad_stop", cmd=c)
            else:
                cur = dict(base, kind="grip_after", grip_cmd=cmd, cmd=cur["cmd"], t_cmd=cur["t_cmd"],
                           fast=cur.get("fast", False))
            segs.append(cur)
        elif ev == "actual" and cur is not None:
            q = _q(r)
            if q is None or r["_ts"] < cur["t0"] - 0.005:
                continue
            cur["ts"].append(r["_ts"]); cur["q"].append(q); cur["stages"].append(r.get("stage"))
    for s in segs:
        s["ts"] = np.array(s["ts"]); s["q"] = np.array(s["q"]).reshape(-1, 6)
    return segs


def moving_mask(ts, q, win=MOVE_WIN_SEC, eps=MOVE_EPS_DEG):
    """(n, 6) 関節ごとに, 前後 win 秒以内の実測のふれが eps を超えるか."""
    n = len(ts)
    mv = np.zeros((n, 6), dtype=bool)
    for i in range(n):
        m = np.abs(ts - ts[i]) <= win
        mv[i] = (q[m].max(axis=0) - q[m].min(axis=0)) > eps
    return mv


def analyze_segments(segs):
    """区間ごとの値 (関節ごと. 止まっていない関節は NaN).
    戻り値: [{kind, stage, t, cmd, hold_err (6), lag_max (6), drift (6), still_sec (6)}]"""
    out = []
    nan6 = np.full(6, np.nan)
    # codex R1 必須: 送ったらすぐ返った区間がつづき, その関節の指令が変わらない間は, それまでの最小のずれを引き継ぐ
    # (ほかの関節の送信・送り直し・爪で区間が切れても, 0→3° と 3→6° に分かれた垂れを 6° として見る)
    carry_cmd, carry_min = nan6.copy(), nan6.copy()
    for k, s in enumerate(segs):
        n = len(s["ts"])
        rec = {"seg": k, "kind": s["kind"], "stage": s["stages"][-1] if n else s["stage"], "t": s["t_cmd"],
               "t0": s["t0"], "cmd": s["cmd"], "n": n, "hold_err": nan6.copy(), "lag_max": np.zeros(6),
               "drift": nan6.copy(), "still_sec": np.zeros(6), "grip_cmd": s.get("grip_cmd")}
        rec["away_max"] = np.zeros(6)
        rec["skip"] = s.get("skip")
        rec["fast"] = bool(s.get("fast"))
        # codex R2 必須: 引き継ぎを切るかは, 実測があるかに関係なく区間ごとに決める.
        # すぐ返らない区間・指令の分からない区間は全関節, 指令が変わった関節・長押しの関節はその関節を切る
        if not s.get("fast") or np.isnan(s["cmd"]).any():
            carry_cmd, carry_min = nan6.copy(), nan6.copy()
        else:
            changed = ~np.isclose(s["cmd"], carry_cmd, atol=1e-6, rtol=0)
            carry_min = np.where(changed, np.nan, carry_min)
            carry_cmd = s["cmd"].copy()
            if s.get("skip") is not None:
                carry_min[int(s["skip"])] = np.nan
        if n and not np.isnan(s["cmd"]).any():
            err = s["q"] - s["cmd"]
            mv = moving_mask(s["ts"], s["q"])
            if s.get("fast"):
                # 依頼 97: 送ったらすぐ返った区間. 指令へ向かう動き (途中で一度止まって また向かうものも) は遅れに
                # 数えない. 代わりに「それまでにいちばん指令へ近づいた所から離れた分」を away に出す
                # (着いたあとに垂れた・向かう途中に逆へ動いた. 関節ごと)
                a = np.abs(err)
                start = np.fmin(carry_min, a[0])                 # 切った関節 (NaN) は区間の頭から
                run_min = np.minimum.accumulate(np.vstack([start, a]), axis=0)[1:]
                rec["away_max"] = np.max(a - run_min, axis=0)
                carry_min = run_min[-1].copy()
                if s.get("skip") is not None:
                    carry_min[int(s["skip"])] = np.nan
                lag_mv = np.zeros_like(mv)
            else:
                lag_mv = mv
            rec["lag_max"] = np.where(lag_mv.any(axis=0), np.where(lag_mv, np.abs(err), 0).max(axis=0), 0.0)
            for i in range(6):
                if s.get("skip") is not None and i == int(s["skip"]):
                    continue        # 長押しの関節は 15° 先へ着く気がない (依頼 97)
                idx = np.flatnonzero(~mv[:, i])
                if s.get("fast") and mv[:, i].any():
                    # 依頼 97: すぐ返った区間では, 動き出す前の止まり (前の姿勢のまま) をずれに使わない
                    idx = idx[idx > np.flatnonzero(mv[:, i])[0]]
                if not len(idx):
                    continue
                last = idx[-1]
                first = last
                while first - 1 >= 0 and not mv[first - 1, i]:   # 最後の止まりが続いたところの頭
                    first -= 1
                rec["hold_err"][i] = err[last, i]
                rec["drift"][i] = s["q"][last, i] - s["q"][first, i]
                rec["still_sec"][i] = float(s["ts"][last] - s["ts"][first])
            if s.get("skip") is not None:
                rec["lag_max"][int(s["skip"])] = 0.0
                rec["away_max"][int(s["skip"])] = 0.0
            ce = np.abs(err)
            if s.get("skip") is not None:
                ce[:, int(s["skip"])] = 0.0
            rec["coarse_max"] = ce.max(axis=0)
            rec["first_q"] = s["q"][0]; rec["last_q"] = s["q"][-1]
        elif n:
            rec["first_q"] = s["q"][0]; rec["last_q"] = s["q"][-1]
        out.append(rec)
    return out


def _has(v):
    return v is not None and not np.isnan(v)


def log_hold_errors(log):
    """log.jsonl だけで見る止まったあとのずれ. 戻り値: {"correction": [(t, err6)], "chunk": [(t, err6)]}"""
    out = {"correction": [], "chunk": []}
    last_cmd = None
    for r in log:
        if r.get("type") is None and "chunk" in r:
            ms = r.get("measured_state")
            if last_cmd is not None and ms is not None and r.get("state_source") is not None:
                out["chunk"].append((float(r["t"]), np.asarray(ms[:6], float) - last_cmd))
            if r.get("executed") and r.get("sent_targets"):
                last_cmd = np.asarray(r["sent_targets"][-1][:6], float)
        elif r.get("type") == "correction" and r.get("action") == "move" and r.get("sent"):
            if r.get("measured_after") is not None:
                out["correction"].append((float(r["t"]), np.asarray(r["measured_after"][:6], float)
                                          - np.asarray(r["after"][:6], float)))
            last_cmd = np.asarray(r["after"][:6], float)
        elif r.get("type") == "rewind":
            last_cmd = None
    return out


def _stats(vals):
    a = np.abs(np.asarray(vals, float))
    if not len(a):
        return None
    return {"n": int(len(a)), "median": round(float(np.median(a)), 2),
            "p90": round(float(np.percentile(a, 90)), 2), "max": round(float(a.max()), 2)}


def summarize_run(run_dir):
    run_dir = Path(run_dir)
    stem = run_dir.name
    trial = {}
    try:
        trial = json.loads((run_dir / f"{stem}.json").read_text())
    except Exception:
        pass
    log = []
    lp = run_dir / "log.jsonl"
    if lp.exists():
        for line in lp.read_text().splitlines():
            try:
                log.append(json.loads(line))
            except Exception:
                pass
    res = {"run": stem, "outcome": trial.get("outcome"), "corrections": trial.get("corrections")}
    rows = load_rows(run_dir / "recording" / "states.csv")
    segs = analyze_segments(segments(rows, log))
    res["has_send_rows"] = bool(segs)
    lh = log_hold_errors(log)
    res["log"] = {k: {j: _stats([e[i] for _, e in v]) for i, j in enumerate(JOINTS)} for k, v in lh.items()}
    if not segs:
        return res, segs
    work = [s for s in segs if s["stage"] != "home"]   # HOME は学習に入らない. 別に出す
    res["segments"] = len(segs)
    res["work_segments"] = len(work)
    res["coarse_max"] = {j: round(float(max(s.get("coarse_max", np.zeros(6))[i] for s in segs)), 2)
                         for i, j in enumerate(JOINTS)}
    # codex R1 必須: HOME の区間しかない記録でも止まらない (学習に入る区間がなければ, その値は 0 / None)
    res["lag_max"] = {j: round(float(max([s["lag_max"][i] for s in work] or [0.0])), 2) for i, j in enumerate(JOINTS)}

    def holds(i, ss=work):
        return [float(s["hold_err"][i]) for s in ss if _has(s["hold_err"][i])]
    res["hold"] = {j: _stats(holds(i)) for i, j in enumerate(JOINTS)}
    res["hold_signed_median"] = ({j: round(float(np.median(holds(i))), 2) if holds(i) else None
                                  for i, j in enumerate(JOINTS)} if work else None)
    by = {}
    for s in work:
        key = "grip_after" if s["kind"] == "grip_after" else s["stage"]
        by.setdefault(key, []).append(s)

    def _mx(v):
        return round(float(max(np.abs(v))), 2) if v else None
    res["hold_by_context"] = {k: {"n": len(v), "j4_max": _mx(holds(4, v)),
                                  "j4_median": round(float(np.median(np.abs(holds(4, v)))), 2) if holds(4, v) else None,
                                  "j1_max": _mx(holds(1, v)), "j2_max": _mx(holds(2, v))}
                              for k, v in sorted(by.items())}
    # 学習に入る段 (HOME 以外) で, 止まったあとのずれ・動いている途中の遅れのどちらかが EVENT_DEG を超えた区間・関節の組
    ev = []
    for s in work:
        for i, j in enumerate(JOINTS):
            he = float(s["hold_err"][i]) if _has(s["hold_err"][i]) else None
            if (he is not None and abs(he) > EVENT_DEG) or s["lag_max"][i] > EVENT_DEG or s["away_max"][i] > EVENT_DEG:
                e = {"seg": s["seg"], "sec": round(s["t"] - segs[0]["t"], 1), "stage": s["stage"], "kind": s["kind"],
                     "joint": j, "cmd": round(float(s["cmd"][i]), 2),
                     "first": round(float(s["first_q"][i]), 2), "last": round(float(s["last_q"][i]), 2),
                     "hold_err": None if he is None else round(he, 2),
                     "lag_max": round(float(s["lag_max"][i]), 2)}
                if s["away_max"][i] > 0:
                    e["away_max"] = round(float(s["away_max"][i]), 2)      # 依頼 97 (送ったらすぐ返った区間だけ)
                ev.append(e)
    res["events"] = ev
    # 依頼 97: 送ったらすぐ返った区間の数・ゲームパッドの区間の数・組にできなかった rpc 行の数
    res["fast_segments"] = sum(1 for s in segs if s.get("fast"))
    res["pad_segments"] = {k: sum(1 for s in segs if s["kind"] == k) for k in ("pad_step", "pad_hold", "pad_stop",
                                                                             "pad_unknown")}
    res["away_max"] = {j: round(float(max([s["away_max"][i] for s in work] or [0.0])), 2) for i, j in enumerate(JOINTS)}
    res["event_segments"] = len({e["seg"] for e in ev})   # codex R1・R2 推奨: 区間の数 (区間の番号で数える. 関節は重ねない)
    res["drift_max"] = {j: round(float(max([abs(s["drift"][i]) for s in work
                                            if _has(s["drift"][i]) and s["still_sec"][i] >= LONG_HOLD_SEC] or [0.0])), 2)
                        for i, j in enumerate(JOINTS)}
    # 爪の開け閉めの前後: 爪の rpc の直前の区間の最後の実測 → 爪のあとの区間の最初の実測
    grips = []
    for a, b in zip(segs, segs[1:]):
        if b["kind"] == "grip_after" and a["n"] and b["n"]:
            grips.append({"t": round(b["t0"], 2), "cmd": b["grip_cmd"], "stage": b["stage"],
                          "delta": [round(float(v), 2) for v in b["first_q"] - a["last_q"]],
                          # codex R2 必須: 爪のあとに止まらなかった関節は None (最初の実測のずれで埋めない. それは別に出す)
                          "err_after": [round(float(h), 2) if _has(h) else None for h in b["hold_err"]],
                          "err_first": [round(float(v), 2) for v in b["first_q"] - b["cmd"]]})
    res["grip"] = grips
    homes = [s for s in segs if s["stage"] == "home" and s["n"]]
    res["home"] = [{"t": round(s["t"], 2), "err_last": [round(float(v), 2) for v in s["last_q"] - s["cmd"]],
                    "lag_max": [round(float(v), 2) for v in s["lag_max"]],
                    "drift": [round(float(v), 2) if _has(v) else None for v in s["drift"]],
                    "still_sec": [round(float(v), 1) for v in s["still_sec"]]} for s in homes]
    return res, segs


def timeline(segs, t_start, joint=4, min_abs=0.0):
    """区間ごとの (経過秒, 段, 種類, 止まったあとのずれ, 動いている途中の遅れ, 止まっている間の変化) の一覧."""
    out = []
    for s in segs:
        he = round(float(s["hold_err"][joint]), 2) if _has(s["hold_err"][joint]) else None
        if (he is None or abs(he) < min_abs) and s["lag_max"][joint] < min_abs:
            continue
        out.append({"sec": round(s["t"] - t_start, 1), "stage": s["stage"], "kind": s["kind"],
                    "cmd": round(float(s["cmd"][joint]), 2), "hold_err": he,
                    "lag_max": round(float(s["lag_max"][joint]), 2),
                    "drift": round(float(s["drift"][joint]), 2) if _has(s["drift"][joint]) else None,
                    "still_sec": round(float(s["still_sec"][joint]), 1)})
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--runs-root", default=str(RUNS_ROOT))
    ap.add_argument("--run", action="append", required=True)
    ap.add_argument("--timeline", action="append", default=[], help="区間ごとの一覧を出す run (j4 と j1・j2)")
    ap.add_argument("--json", help="結果を JSON で書く (runs/ の外. 既存のファイルには書かない)")
    args = ap.parse_args()
    root = Path(args.runs_root)
    if str(root).startswith("/Volumes/KIOXIA") and not Path("/Volumes/KIOXIA").is_mount():
        sys.exit("[ABORT] KIOXIA not mounted: /Volumes/KIOXIA")
    out = {"params": {"move_win_sec": MOVE_WIN_SEC, "move_eps_deg": MOVE_EPS_DEG, "event_deg": EVENT_DEG,
                      "long_hold_sec": LONG_HOLD_SEC, "fast_rpc_sec": FAST_RPC_SEC}, "runs": {}, "timelines": {}}
    for stem in args.run:
        res, segs = summarize_run(root / stem)
        out["runs"][stem] = res
        print(f"## {stem}  outcome={res['outcome']} corrections={res['corrections']}")
        if not res["has_send_rows"]:
            print("   send_angles 行なし (依頼 46 より前). log で見る値だけ")
        else:
            print("   粗い最大   " + " ".join(f"{j}={v:5.1f}" for j, v in res["coarse_max"].items()))
            print("   動き途中   " + " ".join(f"{j}={v:5.1f}" for j, v in res["lag_max"].items()))
            def _h(k):          # codex R2 推奨: 止まった区間のない関節は「-」(0.0 と取り違えない)
                return " ".join(f"{j}={'-' if res['hold'][j] is None else format(res['hold'][j][k], '5.1f'):>5}"
                                for j in JOINTS)
            print("   止まり最大 " + _h("max"))
            print("   止まり90%  " + _h("p90"))
            print("   止まり中央 " + _h("median"))
            print("   止まり符号 " + " ".join(f"{j}={'-' if v is None else f'{v:+.1f}':>5}" for j, v in (res["hold_signed_median"] or {}).items()))
            print("   じわじわ   " + " ".join(f"{j}={v:5.1f}" for j, v in res["drift_max"].items()))
            if res["fast_segments"]:
                print("   向かう途中に離れた " + " ".join(f"{j}={v:5.1f}" for j, v in res["away_max"].items())
                      + f"  (すぐ返った区間 {res['fast_segments']}・ゲームパッド {res['pad_segments']})")
            print(f"   段ごと {res['hold_by_context']}")
            print(f"   外れ (>{EVENT_DEG:.0f}°) {res['event_segments']} 区間 ({len(res['events'])} 区間・関節の組)")
            for e in res["events"]:
                print(f"     {e['sec']:6.1f}秒 {e['stage']:7s} {e['kind']:10s} {e['joint']} 指令 {e['cmd']:7.2f} "
                      f"区間の頭 {e['first']:7.2f} → 終わり {e['last']:7.2f}  止まりのずれ {e['hold_err']}  遅れ {e['lag_max']}"
                      + (f"  離れた {e['away_max']}" if "away_max" in e else ""))
            for gr in res["grip"]:
                print(f"   爪 {gr['cmd']} ({gr['stage']}) 前後の変化 {gr['delta']}  爪のあとのずれ {gr['err_after']}")
            for h in res["home"]:
                print(f"   HOME 最後のずれ {h['err_last']}  動き途中 {h['lag_max']}  止まり中の変化 {h['drift']}")
        for k in ("correction", "chunk"):
            st = res["log"][k]
            if st["j4"]:
                print(f"   log {k:10s} n={st['j4']['n']:2d} " + " ".join(
                    f"{j}={st[j]['max']:5.1f}" for j in JOINTS) + "  (最大)")
        if stem in args.timeline and segs:
            t0 = segs[0]["t"]
            tl = {j: timeline(segs, t0, joint=i) for i, j in ((1, "j1"), (2, "j2"), (4, "j4"))}
            out["timelines"][stem] = tl
            print("   区間ごと (j4): 秒 段 種類 指令 止まりのずれ 動き途中 止まり中の変化 止まった秒")
            for e in tl["j4"]:
                print(f"     {e['sec']:6.1f} {e['stage']:7s} {e['kind']:10s} {e['cmd']:7.2f} "
                      f"{'' if e['hold_err'] is None else e['hold_err']:>7} {e['lag_max']:6.2f} "
                      f"{'' if e['drift'] is None else e['drift']:>6} {e['still_sec']:5.1f}")
    if args.json:
        p = Path(args.json)
        if p.exists():
            sys.exit(f"[ABORT] 既にある: {p}")
        # 止まらなかった関節 (NaN) は null にする (JSON に NaN を書かない)
        p.write_text(json.dumps(out, indent=1, ensure_ascii=False, allow_nan=False,
                                default=lambda o: [None if isinstance(x, float) and np.isnan(x) else x for x in o.tolist()]))
        print(f"[JSON] {p}")


if __name__ == "__main__":
    main()
