#!/usr/bin/env python3
"""
Arm tracking measurement — PASSIVE, commands nothing, only listens.

Run this, then start a replay in another terminal. It captures both sides of the loop:

  rt/arm_sdk    the recorder's COMMANDED q / dq / tau / kp / kd
  rt/lowstate   the robot's ACTUAL q / dq / tau_est

and separates the causes that a single "mean error" number hides:

  CLAMP-STARVED  tau feedforward pinned at TAU_CLAMP -> integral cannot supply the torque
                 the joint needs, error explodes. Fix: raise that joint's clamp.
  LAG            actual is a delayed copy of commanded -> time-shifting collapses the error.
                 Fix: feedforward / higher send rate. NOT more kp.
  DROOP          actual sits below commanded even at rest. Fix: more gravity feedforward.
  dynamic        error grows with speed but is none of the above.

  conda activate g1_env
  cd ~/Robotics_workspace/yslootahtech/G1/G1_Lootah/Manual_Recorder
  python3 arm_track_measure.py enp3s0 90          # terminal 1, then replay in terminal 2

Writes arm_track_raw.npz (re-analysable offline, no robot needed) and arm_track_report.txt
next to this script. Do NOT put these under /tmp — systemd-tmpfiles-clean wipes it.
"""
import sys, os, time, math
import numpy as np
from unitree_sdk2py.core.channel import ChannelSubscriber, ChannelFactoryInitialize
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_, LowState_

HERE = os.path.dirname(os.path.abspath(__file__))
RAW = os.path.join(HERE, "arm_track_raw.npz")
REPORT = os.path.join(HERE, "arm_track_report.txt")

IFACE = sys.argv[1] if len(sys.argv) > 1 else "enp3s0"
SECS = float(sys.argv[2]) if len(sys.argv) > 2 else 90.0

JN = {15: "L_sh_pitch", 16: "L_sh_roll", 17: "L_sh_yaw", 18: "L_elbow",
      19: "L_wr_roll", 20: "L_wr_pitch", 21: "L_wr_yaw",
      22: "R_sh_pitch", 23: "R_sh_roll", 24: "R_sh_yaw", 25: "R_elbow",
      26: "R_wr_roll", 27: "R_wr_pitch", 28: "R_wr_yaw"}
ARM = list(JN)

# Mirror TAU_CLAMP from g1_record_replay.py so saturation can be detected.
CLAMP = {**{j: 20.0 for j in (15, 16, 17, 18, 22, 23, 24, 25)},
         **{j: 12.0 for j in (19, 26)},
         **{j: 4.0 for j in (20, 21, 27, 28)}}

_out = open(REPORT, "w")
def P(*a):
    s = " ".join(str(x) for x in a)
    print(s); _out.write(s + "\n"); _out.flush()

ChannelFactoryInitialize(0, IFACE)
cmd, act = [], []
# Each callback appends exactly ONE tuple, so any snapshot of the list is self-consistent.
# Building separate per-field arrays while callbacks are still firing gives arrays of
# different lengths — that raced and crashed an earlier version of this script.
ChannelSubscriber("rt/arm_sdk", LowCmd_).Init(lambda m: cmd.append((
    time.time(),
    [float(m.motor_cmd[i].q) for i in ARM],
    [float(m.motor_cmd[i].dq) for i in ARM],
    [float(m.motor_cmd[i].tau) for i in ARM],
    float(m.motor_cmd[15].kp), float(m.motor_cmd[15].kd))), 50)
ChannelSubscriber("rt/lowstate", LowState_).Init(lambda m: act.append((
    time.time(),
    [float(m.motor_state[i].q) for i in ARM],
    [float(m.motor_state[i].dq) for i in ARM],
    [float(m.motor_state[i].tau_est) for i in ARM])), 50)

P(f"listening on {IFACE} for up to {SECS:.0f}s  ---  START THE REPLAY NOW")
t0 = time.time()
while not cmd and time.time() - t0 < SECS:
    time.sleep(0.1)
if not cmd:
    sys.exit("no rt/arm_sdk traffic seen - was a replay actually running?")
P("commands detected, recording until they stop\n")

last, quiet, t1 = len(cmd), 0, time.time()
while time.time() - t1 < SECS:
    time.sleep(0.5)
    quiet = quiet + 1 if len(cmd) == last else 0
    last = len(cmd)
    if quiet >= 6:                       # 3s with no new commands => replay finished
        break

C, S = list(cmd), list(act)              # freeze one snapshot each before building arrays
P(f"captured {len(C)} commands, {len(S)} states")

ct = np.array([c[0] for c in C]); cq = np.array([c[1] for c in C])
ctau = np.array([c[3] for c in C])
at = np.array([s[0] for s in S]); aq = np.array([s[1] for s in S])
adq = np.array([s[2] for s in S]); atau = np.array([s[3] for s in S])
np.savez_compressed(RAW, ct=ct, cq=cq, cdq=np.array([c[2] for c in C]), ctau=ctau,
                    kp=np.array([c[4] for c in C]), kd=np.array([c[5] for c in C]),
                    at=at, aq=aq, adq=adq, atau=atau, arm=np.array(ARM))
P(f"raw saved -> {RAW}\n")

A = np.stack([np.interp(ct, at, aq[:, j]) for j in range(14)], 1)
V = np.stack([np.interp(ct, at, adq[:, j]) for j in range(14)], 1)
err = cq - A
dt = np.median(np.diff(ct))
t = ct - ct[0]
mid = (t > 2.5) & (t < t[-1] - 4)        # exclude the ease-in and the home return

P(f"kp={C[0][4]:.0f} kd={C[0][5]:.1f}   cmd {1/dt:.1f} Hz   state {len(S)/(at[-1]-at[0]):.0f} Hz")
P(f"tau ff {'ON' if np.abs(ctau).max() > 0.01 else 'OFF'}  (max seen {np.abs(ctau).max():.2f} Nm)\n")

P("=" * 116)
P(f"{'joint':12s} {'mean':>7s} {'peak':>7s} {'@rest':>7s} {'@move':>7s} {'lag ms':>7s} "
  f"{'drop':>6s} {'%clamp':>7s} {'e|clamp':>8s} {'e|free':>7s}  verdict")
P("=" * 116)
worst = []
for k, j in enumerate(ARM):
    e = err[mid, k]; v = V[mid, k]; tau = np.abs(ctau[mid, k])
    mv, rs = np.abs(v) > 0.15, np.abs(v) < 0.02
    me, pe = math.degrees(np.abs(e).mean()), math.degrees(np.abs(e).max())
    er = math.degrees(np.abs(e[rs]).mean()) if rs.sum() > 5 else float("nan")
    em = math.degrees(np.abs(e[mv]).mean()) if mv.sum() > 5 else float("nan")
    bs, be = 0, np.abs(e).mean()
    for s in range(1, 25):
        sh = np.abs(cq[mid][:-s, k] - A[mid][s:, k]).mean()
        if sh < be: be, bs = sh, s
    drop = (1 - be / max(np.abs(e).mean(), 1e-12)) * 100
    sat = tau >= CLAMP[j] - 1e-6
    ec = math.degrees(np.abs(e[sat]).mean()) if sat.sum() > 3 else float("nan")
    ef = math.degrees(np.abs(e[~sat]).mean()) if (~sat).sum() > 3 else float("nan")
    vd = ("CLAMP-STARVED" if sat.mean() > 0.002 and ec == ec and ec > ef * 2 else
          "LAG" if drop > 50 else
          "DROOP" if (er == er and er > 0.5 and em == em and em < er * 1.5) else
          "dynamic" if (em == em and er == er and em > er * 2) else "small")
    worst.append((pe, JN[j], me, vd))
    P(f"{JN[j]:12s} {me:7.2f} {pe:7.2f} {er:7.2f} {em:7.2f} {bs*dt*1000:7.0f} "
      f"{drop:5.0f}% {sat.mean()*100:6.1f}% {ec:8.2f} {ef:7.2f}  {vd}")
P("=" * 116)
P("(mid-playback only; ease-in and home return excluded)\n")
allm = math.degrees(np.abs(err[mid]).mean())
P(f"OVERALL mean {allm:.2f} deg    worst peaks: " +
  ", ".join(f"{n} {p:.2f}d" for p, n, m, v in sorted(worst, reverse=True)[:4]))
starved = [n for p, n, m, v in worst if v == "CLAMP-STARVED"]
P("clamp-starved joints: " + (", ".join(starved) if starved else "NONE - no joint is hitting its limit"))
_out.close()
