Files
DCU_DesktopController/scripts/test_motor_pair_n2_n3.py
T

1133 lines
44 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""N2/N3 对接电机诊断发令(ECAT)。
前提:用户保证机构处于安全状态(无障碍、可运动)。
默认参数偏保守:span=200°,vel=80°/s,行程 hold 短,全程可 Ctrl+C 急停。
子命令:
status 只读:链路 / 状态 / CONFIG
align 下发统一 CONFIG(mode/limitV/PID)+ 双机 CMD
openloop 开环速度对比:双机同 v,采 速度/电流/积分
stop 巡航后同时刹停,测停车滞后与 ∫(v2+v3)
trip 位置行程(mode3):同向或镜像,采全程积分
full 依次跑 align → openloop → stop → trip,并汇总 JSON
stopnow 立即发速度=0(急停)
示例:
python scripts/test_motor_pair_n2_n3.py status
python scripts/test_motor_pair_n2_n3.py align --limit-v 22.0 --pid 100
python scripts/test_motor_pair_n2_n3.py stop --vel 80 --cruise 1.2
python scripts/test_motor_pair_n2_n3.py full --mode-mirror --span 200
python scripts/test_motor_pair_n2_n3.py stopnow
"""
from __future__ import annotations
import argparse
import csv
import http.client
import json
import os
import struct
import sys
import threading
import time
from collections import defaultdict
HOST, PORT = "127.0.0.1", 8090
NODES = (2, 3)
LAB = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lab")
PERIOD = 0.25 # ms,以 motor/state 为准
_tls = threading.local()
_lock = threading.Lock()
_stop_flag = threading.Event()
_rows: list = []
_seen: dict = {}
# ───────────────────────── HTTP / ECAT ─────────────────────────
def post(path: str, body: bytes | None = None, timeout: float = 8.0) -> dict:
if body is None:
body = b"{}"
c = getattr(_tls, "c", None)
if c is None:
c = _tls.c = http.client.HTTPConnection(HOST, PORT, timeout=timeout)
for _ in range(2):
try:
c.request("POST", path, body=body,
headers={"Content-Type": "application/json"})
return json.loads(c.getresponse().read().decode("utf-8", "replace"))
except Exception:
try:
c.close()
except Exception:
pass
_tls.c = c = http.client.HTTPConnection(HOST, PORT, timeout=timeout)
raise RuntimeError(f"POST {path} failed")
def ensure_ecat() -> tuple[float, dict]:
st = post("/api/motor/state")
period = st.get("period_ms") or PERIOD
wkc = str(st.get("wkc") or "")
motors = st.get("motors") or []
if wkc not in ("3", "3/3") or len(motors) < 2:
print("ECAT 未就绪 wkc=%s motors=%d → motor/start" % (wkc, len(motors)))
r = post("/api/motor/start",
json.dumps({"period_ms": 0.25, "dc_cycle_us": 250}).encode(),
timeout=35.0)
print(" start:", json.dumps(r, ensure_ascii=False)[:240])
time.sleep(0.4)
st = post("/api/motor/state")
period = st.get("period_ms") or PERIOD
return period, st
# ───────────────────────── CMD 编码 ─────────────────────────
# ───────────────── 安全:已机械对接,硬联锁 ─────────────────
# 规则(不可绕过,除非命令行 --i-understand-coupled-risk 且仍受硬限位约束):
# 1) 只允许 mode3 + 有限角度目标,两轴目标必须互为相反数(镜像)。
# 2) 禁止 mode2 开环持续转(对接后会一直拧)。
# 3) 速度只表示「走向目标的速率」,符号必须 = sign(target - current)。
# 4) 发令前若 |a2+a3| 过大(错位)→ 拒绝运动,要求人工回零。
# 5) 硬限位:|ang|、目标、单次行程超限 → 拒绝。
# 6) 运行中看门狗:一台已停(|v|<8)另一台仍 |v|>40 超过 300ms → 双机急停。
FORCE_MIRROR = True
MAX_ABS_ANGLE = 2500.0 # 编码器绝对角限(多圈保护)
MAX_STROKE = 1200.0 # 单次目标相对当前位置最大行程
MAX_ASUM_ERR = 40.0 # |a2+a3| 超过则判定错位,禁止运动
STOP_VEL_EPS = 8.0
SPIN_VEL_WARN = 40.0
ASYMMETRY_MS = 300.0
def _read_pair():
snap = snap_motors()
a2 = (snap.get(2) or {}).get("ang")
v2 = (snap.get(2) or {}).get("vel")
a3 = (snap.get(3) or {}).get("ang")
v3 = (snap.get(3) or {}).get("vel")
return a2, v2, a3, v3, snap
def safety_precheck_motion(target2, target3, speed, force=False):
"""运动前硬检查。返回 (ok, msg, items)。items 为带符号速度的 CMD。"""
a2, v2, a3, v3, snap = _read_pair()
if a2 is None or a3 is None:
return False, "N2/N3 状态缺失,拒绝运动", None
if abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE:
return False, ("角度超硬限位 a2=%.1f a3=%.1f(限±%.0f)——请人工回位"
% (a2, a3, MAX_ABS_ANGLE)), None
if abs(target2) > MAX_ABS_ANGLE or abs(target3) > MAX_ABS_ANGLE:
return False, "目标超硬限位", None
if abs(target2 + target3) > 1.0:
return False, "目标必须镜像:target2+target3=0(得到 %s/%s)" % (target2, target3), None
if abs(target2 - a2) > MAX_STROKE or abs(target3 - a3) > MAX_STROKE:
return False, ("单次行程过大 Δ2=%.1f Δ3=%.1f(限±%.0f)"
% (target2 - a2, target3 - a3, MAX_STROKE)), None
asum = a2 + a3
if abs(asum) > MAX_ASUM_ERR and not force:
return False, ("错位过大 a2+a3=%.1f°(a2=%.1f a3=%.1f)。"
"禁止电控继续跑,请先人工把机构回到对称位(建议 a2≈-a3,和≈0)再测。"
% (asum, a2, a3)), None
# 方向:必须朝向各自目标
v2s = 0.0 if abs(target2 - a2) < 0.5 else (speed if target2 > a2 else -speed)
v3s = 0.0 if abs(target3 - a3) < 0.5 else (speed if target3 > a3 else -speed)
# 对接刚性镜像:位移方向应相反。若当前接近对称却要求同向位移 → 拒绝
if abs(asum) <= MAX_ASUM_ERR:
d2, d3 = target2 - a2, target3 - a3
if abs(d2) > 1 and abs(d3) > 1 and d2 * d3 > 0:
return False, ("拒绝:镜像机构不允许同向位移 Δ2=%.1f Δ3=%.1f"
% (d2, d3)), None
# 速度也应反号
if abs(v2s) > 0.5 and abs(v3s) > 0.5 and v2s * v3s > 0:
return False, "拒绝:速度同号 v2=%s v3=%s" % (v2s, v3s), None
items = [(2, float(v2s), float(target2)), (3, float(v3s), float(target3))]
return True, ("precheck ok a2=%.2f a3=%.2f asum=%.2f cmd=%s"
% (a2, a3, asum, items)), items
def _check_pair(items, allow_same=False):
"""双机指令必须:角度目标镜像;速度与目标方向一致且反号。"""
if not FORCE_MIRROR:
return
d = {int(n): (float(v), float(a)) for n, v, a in items}
if 2 not in d or 3 not in d:
return
v2, t2 = d[2]
v3, t3 = d[3]
if abs(t2 + t3) > 1.0 and (abs(t2) > 1 or abs(t3) > 1):
raise RuntimeError("SAFETY: 角度目标必须镜像 t2=%s t3=%s" % (t2, t3))
if abs(v2) > 0.5 and abs(v3) > 0.5 and v2 * v3 > 0:
raise RuntimeError("SAFETY: 速度禁止同号 v2=%s v3=%s(会拧线)" % (v2, v3))
a2, cur_v2, a3, cur_v3, _ = _read_pair()
if a2 is not None and a3 is not None:
if abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE:
raise RuntimeError("SAFETY: 编码器超限 a2=%.1f a3=%.1f" % (a2, a3))
if abs(v2) > 0.5 and abs(t2 - a2) > 0.5:
if v2 * (t2 - a2) < 0:
raise RuntimeError("SAFETY: N2 速度方向与目标相反 v=%s a=%.1f t=%.1f"
% (v2, a2, t2))
if abs(v3) > 0.5 and abs(t3 - a3) > 0.5:
if v3 * (t3 - a3) < 0:
raise RuntimeError("SAFETY: N3 速度方向与目标相反 v=%s a=%.1f t=%.1f"
% (v3, a3, t3))
def _watchdog_stop(reason):
print("!! WATCHDOG STOP:", reason)
for _ in range(6):
try:
post("/api/motor/send", json.dumps({"frames": [
{"ch": 2, "id": hex((2 << 16) | 0x0200), "dlc": 32,
"data": pack_cmd(mode=3, voltage=24, velocity=0,
angle=(_read_pair()[0] or 0), pid=(100, 100, 100))},
{"ch": 2, "id": hex((3 << 16) | 0x0200), "dlc": 32,
"data": pack_cmd(mode=3, voltage=24, velocity=0,
angle=(_read_pair()[2] or 0), pid=(100, 100, 100))},
]}).encode())
except Exception:
pass
time.sleep(0.03)
a2, v2, a3, v3, _ = _read_pair()
print(" after stop a2=%s v2=%s a3=%s v3=%s" % (a2, v2, a3, v3))
def pack_cmd(mode=3, voltage=24.0, current=0.0, velocity=100.0,
angle=0.0, pid=(100, 100, 100)) -> str:
"""与 pages/motor.html buildCmdBytes 一致(DLC32)。"""
b = bytearray(32)
b[0] = int(mode) & 0xFF
struct.pack_into("<H", b, 1, int(round(voltage * 1000)) & 0xFFFF)
struct.pack_into("<f", b, 3, float(current))
struct.pack_into("<f", b, 7, float(velocity))
struct.pack_into("<f", b, 11, float(angle))
struct.pack_into("<h", b, 15, int(pid[0]))
struct.pack_into("<h", b, 17, int(pid[1]))
struct.pack_into("<h", b, 19, int(pid[2]))
return bytes(b).hex()
def pack_config(mode=3, limit_v=22.0, current=0.0, velocity=100.0,
angle=0.0) -> str:
"""CONFIG 帧与 CMD 同布局(回声/写参数用)。"""
return pack_cmd(mode=mode, voltage=limit_v, current=current,
velocity=velocity, angle=angle, pid=(0, 0, 0))
def burst_cmds(items, mode=3, voltage=24.0, pid=(100, 100, 100),
allow_same_dir=False):
"""items: [(node, velocity, angle)] 一次 POST 同一 seq 沿。"""
_check_pair(items, allow_same=allow_same_dir)
frames = []
for n, vel, ang in items:
frames.append({
"ch": 2,
"id": hex((int(n) << 16) | 0x0200),
"dlc": 32,
"data": pack_cmd(mode=mode, voltage=voltage, velocity=vel,
angle=ang, pid=pid),
})
return post("/api/motor/send", json.dumps({"frames": frames}).encode())
def send_config(cfg: dict, nodes=NODES):
"""下发统一 CONFIG。cfg: {mode, limit_v, velocity, angle}"""
frames = []
for n in nodes:
frames.append({
"ch": 2,
"id": hex((int(n) << 16) | 0x0200),
"dlc": 32,
"data": pack_config(mode=cfg.get("mode", 3),
limit_v=cfg.get("limit_v", 22.0),
velocity=cfg.get("velocity", 100.0),
angle=cfg.get("angle", 0.0)),
})
return post("/api/motor/send", json.dumps({"frames": frames}).encode())
# ───────────────────────── 状态 ─────────────────────────
def decode_status(raw: bytes):
if len(raw) < 21:
return None
ang = struct.unpack_from("<f", raw, 13)[0]
vel = struct.unpack_from("<f", raw, 17)[0]
if ang != ang or vel != vel:
return None
d = {
"ang": ang,
"vel": vel,
"pwr": struct.unpack_from("<H", raw, 11)[0] * 0.001 if len(raw) >= 13 else None,
"ucur": struct.unpack_from("<h", raw, 4)[0] if len(raw) >= 6 else None,
"rt_cur": struct.unpack_from("<f", raw, 21)[0] if len(raw) >= 25 else 0.0,
"motT": struct.unpack_from("<h", raw, 2)[0] * 0.01 if len(raw) >= 4 else None,
"b10": raw[10] if len(raw) > 10 else 0,
}
return d
def snap_motors() -> dict:
out = {}
st = post("/api/motor/state")
for m in st.get("motors") or []:
n = m.get("node")
if n not in NODES:
continue
dec = decode_status(bytes.fromhex(m.get("data") or ""))
if dec:
dec["online"] = bool(m.get("online"))
dec["seq"] = m.get("seq")
out[n] = dec
out["_cycles"] = st.get("cycles")
out["_period_ms"] = st.get("period_ms")
return out
def read_configs_from_rx() -> dict:
cfgs = {}
try:
rx = post("/api/motor/rx")
except Exception:
return cfgs
for f in (rx.get("frames") or rx.get("parsed") or []):
if not isinstance(f, dict):
continue
fid = f.get("id", 0)
try:
if isinstance(fid, str):
fid = int(fid, 0)
except Exception:
continue
if (fid & 0xFFFF) != 0x0101:
continue
n = (fid >> 16) & 0xFF
if n not in NODES:
continue
try:
raw = bytes.fromhex(f.get("data") or "")
except Exception:
continue
if len(raw) >= 21:
cfgs[n] = {
"mode": raw[0],
"limit_V": struct.unpack_from("<H", raw, 1)[0] * 0.001,
"cur": struct.unpack_from("<f", raw, 3)[0],
"vel": struct.unpack_from("<f", raw, 7)[0],
"ang": struct.unpack_from("<f", raw, 11)[0],
"pid": [struct.unpack_from("<h", raw, o)[0] for o in (15, 17, 19)],
"hex": raw[:24].hex(),
}
return cfgs
# ───────────────────────── 采集 ─────────────────────────
def poller():
while not _stop_flag.is_set():
try:
d = post("/api/motor/state")
except Exception:
time.sleep(0.008)
continue
cyc = d.get("cycles", 0) or 0
wall = time.time()
for m in d.get("motors") or []:
n = m.get("node")
if n not in NODES:
continue
sq = m.get("seq", 0) or 0
with _lock:
if _seen.get(n) == sq:
continue
_seen[n] = sq
dec = decode_status(bytes.fromhex(m.get("data") or ""))
if not dec:
continue
with _lock:
_rows.append({
"wall": wall, "cyc": cyc, "node": n, "seq": sq,
"ang": dec["ang"], "vel": dec["vel"],
"rt_cur": dec["rt_cur"], "motT": dec["motT"],
})
def start_poll(n=6):
del _rows[:]
_seen.clear()
_stop_flag.clear()
ths = [threading.Thread(target=poller, daemon=True) for _ in range(n)]
for t in ths:
t.start()
return ths
def stop_poll(ths):
_stop_flag.set()
for t in ths:
t.join(timeout=2)
def aligned_series():
"""返回 [(cyc,t,a2,v2,a3,v3,vsum,asum)] 以 N2 cycles 对齐。"""
by = defaultdict(list)
for r in _rows:
by[r["node"]].append(r)
for n in by:
by[n].sort(key=lambda x: x["cyc"])
s2, s3 = by.get(2, []), by.get(3, [])
if not s2 or not s3:
return []
def interp(n, cyc):
s = by[n]
if cyc <= s[0]["cyc"]:
return s[0]["ang"], s[0]["vel"]
if cyc >= s[-1]["cyc"]:
return s[-1]["ang"], s[-1]["vel"]
lo, hi = 0, len(s) - 1
while lo + 1 < hi:
mid = (lo + hi) // 2
if s[mid]["cyc"] <= cyc:
lo = mid
else:
hi = mid
c0, a0, v0 = s[lo]["cyc"], s[lo]["ang"], s[lo]["vel"]
c1, a1, v1 = s[hi]["cyc"], s[hi]["ang"], s[hi]["vel"]
if c1 == c0:
return a1, v1
f = (cyc - c0) / (c1 - c0)
return a0 + (a1 - a0) * f, v0 + (v1 - v0) * f
out = []
for r in s2:
a3, v3 = interp(3, r["cyc"])
out.append((r["cyc"], r["wall"], r["ang"], r["vel"], a3, v3,
r["vel"] + v3, r["ang"] + a3, r["rt_cur"]))
return out
def trapz_vel(series, period_ms, key_v2=3, key_v3=5):
"""series 对齐列表 → (I2, I3, Isum) 全程梯形积分。"""
I2 = I3 = 0.0
for i in range(1, len(series)):
dt = (series[i][0] - series[i - 1][0]) * period_ms / 1000.0
if dt < 0:
dt = 0
I2 += 0.5 * (series[i][key_v2] + series[i - 1][key_v2]) * dt
I3 += 0.5 * (series[i][key_v3] + series[i - 1][key_v3]) * dt
return I2, I3, I2 + I3
def write_series_csv(path, series, meta=None):
with open(path, "w", newline="", encoding="utf-8") as fp:
w = csv.writer(fp)
w.writerow(["cycles", "wall", "a2", "v2", "a3", "v3", "vsum", "asum", "rt_cur2"])
for row in series:
w.writerow(row)
if meta:
fp.write("# meta " + json.dumps(meta, ensure_ascii=False) + "\n")
def analyze_pair(series, period_ms, label=""):
if len(series) < 40:
return {"label": label, "error": "too_few_samples", "n": len(series)}
mov = [r for r in series if abs(r[3]) > 15 or abs(r[5]) > 15]
cr = [r for r in series if abs(r[3]) > 80 and abs(r[5]) > 80]
I2, I3, Isum = trapz_vel(series, period_ms)
out = {
"label": label,
"n": len(series),
"I2": I2, "I3": I3, "I_sum_v2_plus_v3": Isum,
"asum_start_end": [series[0][7], series[-1][7]],
"asum_delta": series[-1][7] - series[0][7],
"a2_start_end": [series[0][2], series[-1][2]],
"a3_start_end": [series[0][4], series[-1][4]],
}
if cr:
out["cruise_mean_abs_v2"] = sum(abs(r[3]) for r in cr) / len(cr)
out["cruise_mean_abs_v3"] = sum(abs(r[5]) for r in cr) / len(cr)
out["cruise_mean_vsum"] = sum(r[6] for r in cr) / len(cr)
out["cruise_rms_vsum"] = (sum(r[6] ** 2 for r in cr) / len(cr)) ** 0.5
if mov:
out["mov_mean_abs_v2"] = sum(abs(r[3]) for r in mov) / len(mov)
out["mov_mean_abs_v3"] = sum(abs(r[5]) for r in mov) / len(mov)
out["mov_rms_vsum"] = (sum(r[6] ** 2 for r in mov) / len(mov)) ** 0.5
out["mov_max_abs_vsum"] = max(abs(r[6]) for r in mov)
return out
# ───────────────────────── 连续发令 ─────────────────────────
def drive(items, duration, rate=25.0, mode=3, voltage=24.0, pid=(100, 100, 100),
allow_same_dir=False):
"""连续发令 + 看门狗。已对接:仅 mode3 + 镜像角度目标 + 低速优先。"""
if mode != 3:
raise RuntimeError("SAFETY: 已对接仅允许 mode3,禁止 mode=%s" % mode)
_check_pair(items, allow_same=allow_same_dir)
t_end = time.perf_counter() + duration
dt = 1.0 / max(rate, 1.0)
n_ok = 0
last_err = None
spin_t0 = None
while time.perf_counter() < t_end:
t0 = time.perf_counter()
try:
burst_cmds(items, mode=mode, voltage=voltage, pid=pid,
allow_same_dir=allow_same_dir)
n_ok += 1
except Exception as e:
last_err = str(e)
print(" send err:", e)
if "SAFETY" in str(e):
_watchdog_stop(str(e))
break
try:
a2, v2, a3, v3, _ = _read_pair()
if v2 is not None and v3 is not None:
s2, s3 = abs(v2) < STOP_VEL_EPS, abs(v3) < STOP_VEL_EPS
spin = abs(v2) > SPIN_VEL_WARN or abs(v3) > SPIN_VEL_WARN
if (s2 != s3) and spin:
if spin_t0 is None:
spin_t0 = time.perf_counter()
elif (time.perf_counter() - spin_t0) * 1000.0 >= ASYMMETRY_MS:
_watchdog_stop("一台已停另一台仍在转 v2=%.1f v3=%.1f" % (v2, v3))
break
else:
spin_t0 = None
if a2 is not None and a3 is not None and (
abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE):
_watchdog_stop("角度超限 a2=%.1f a3=%.1f" % (a2, a3))
break
except Exception:
pass
pad = dt - (time.perf_counter() - t0)
if pad > 0:
time.sleep(pad)
return n_ok, last_err
def hold_position(pid=(100, 100, 100), voltage=24.0, rate=20.0, seconds=0.4,
targets=None):
snap = snap_motors()
if targets is None:
targets = []
for n in NODES:
if n in snap:
targets.append((n, 0.0, snap[n]["ang"]))
else:
targets.append((n, 0.0, 0.0))
return drive(targets, seconds, rate=rate, mode=3, voltage=voltage, pid=pid)
# ───────────────────────── 子命令 ─────────────────────────
def cmd_status(_args):
period, st = ensure_ecat()
print("链路 wkc=%s period=%sms cycles=%s motors=%s"
% (st.get("wkc"), period, st.get("cycles"),
[(m.get("node"), m.get("online"), m.get("seq"))
for m in st.get("motors") or []]))
print("\n--- live STATUS ---")
snap = snap_motors()
for n in NODES:
d = snap.get(n)
if not d:
continue
print(" N%s ang=%.3f vel=%.2f pwr=%s motT=%s b10=%s online=%s"
% (n, d["ang"], d["vel"], d["pwr"], d["motT"], d["b10"], d["online"]))
print("\n--- CONFIG from /api/motor/rx ---")
cfgs = read_configs_from_rx()
if not cfgs:
print(" (rx 暂无 0x0101 CONFIG,可先发一帧或等电机上报)")
for n, c in sorted(cfgs.items()):
print(" N%s %s" % (n, c))
return 0
def cmd_align(args):
period, st = ensure_ecat()
print("align: limitV=%.2f PID=%s mode=%s" % (args.limit_v, args.pid, args.mode))
print("before CONFIG:", json.dumps(read_configs_from_rx(), ensure_ascii=False))
r = send_config({"mode": args.mode, "limit_v": args.limit_v,
"velocity": args.vel, "angle": 0.0})
print("config send:", r)
time.sleep(0.15)
# 用统一参数发 CMD(速度 0,保持)
snap = snap_motors()
items = []
for n in NODES:
ang = snap.get(n, {}).get("ang", 0.0)
items.append((n, 0.0, ang))
for _ in range(int(args.hold * 25)):
burst_cmds(items, mode=args.mode, voltage=args.limit_v + 2,
pid=tuple(args.pid))
time.sleep(0.04)
print("after CONFIG:", json.dumps(read_configs_from_rx(), ensure_ascii=False))
print("live:", json.dumps(
{str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)},
ensure_ascii=False)[:400])
return 0
def cmd_openloop(args):
"""已对接:禁止 mode2 开环。只做短时 mode3 镜像微动(±30°)测响应。"""
period, _ = ensure_ecat()
print("SAFETY: 对接后禁止 open-loop mode2。改为 mode3 镜像小行程 ±20° @ vel≤50")
speed = min(abs(args.vel), 50.0)
span = 20.0
a2, _, a3, _, _ = _read_pair()
if a2 is None or a3 is None:
print("无状态")
return 2
# 在当前位置附近做镜像微动:目标 = 当前 ± 小量,并强制 target2=-target3
# 仅当当前已接近对称时允许
if abs(a2 + a3) > MAX_ASUM_ERR:
print("拒绝:a2+a3=%.1f 错位过大,先人工回位" % (a2 + a3))
return 2
t2 = span if a2 < 0 else -span
t3 = -t2
# 若当前位置离目标太近/太远则改用以 0 为中心
t2, t3 = span, -span
ok, msg, items = safety_precheck_motion(t2, t3, speed)
print(msg)
if not ok:
return 2
ths = start_poll(args.threads)
t0 = time.time()
drive(items, args.cruise, args.rate, mode=3, voltage=args.voltage,
pid=tuple(args.pid))
# 回到对称位附近 0/0 或停在当前位置
ok2, msg2, items2 = safety_precheck_motion(0.0, 0.0, speed)
print(msg2)
if ok2:
drive(items2, args.settle, args.rate, mode=3, voltage=args.voltage,
pid=tuple(args.pid))
t1 = time.time()
stop_poll(ths)
series = aligned_series()
period = post("/api/motor/state").get("period_ms") or period
tag = "micro_mirror"
csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag)
meta = {"test": "micro_mirror_mode3", "span": span, "vel": speed,
"t0": t0, "t1": t1}
write_series_csv(csv_path, series, meta)
an = analyze_pair(series, period, label=tag)
an["csv"] = csv_path
an["command"] = meta
jpath = os.path.join(LAB, "pair_test_%s.json" % tag)
with open(jpath, "w", encoding="utf-8") as fp:
json.dump(an, fp, ensure_ascii=False, indent=2)
print(json.dumps(an, ensure_ascii=False, indent=2))
print("CSV", csv_path, "JSON", jpath)
return 0
def cmd_stop(args):
"""镜像 mode3:短行程后停在目标,禁止开环刹停测法。"""
period, _ = ensure_ecat()
speed = min(abs(args.vel), 80.0)
print("stop-test:对接机构仅允许 mode3 镜像行程后自然停靠(禁止 mode2)")
a2, _, a3, _, _ = _read_pair()
if a2 is None:
return 2
# 从对称位附近:先到 ±span,再回 0(行程用 stop.hold)
span = min(args.span if hasattr(args, "span") else 150.0, 200.0)
ok, msg, items = safety_precheck_motion(span, -span, speed)
print("leg1", msg)
if not ok:
return 2
ths = start_poll(args.threads)
t0 = time.time()
drive(items, args.cruise + 1.5, args.rate, mode=3,
voltage=args.voltage, pid=tuple(args.pid))
t_cmd_stop = time.time()
ok2, msg2, items2 = safety_precheck_motion(0.0, 0.0, speed)
print("leg2→0", msg2)
if ok2:
drive(items2, args.hold + 1.5, args.rate, mode=3,
voltage=args.voltage, pid=tuple(args.pid))
t1 = time.time()
stop_poll(ths)
series = aligned_series()
period = post("/api/motor/state").get("period_ms") or period
tag = "stop_mirror_mode3"
csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag)
meta = {"test": "stop_mirror_mode3", "span": span, "vel": speed,
"t0": t0, "t_mid": t_cmd_stop, "t1": t1}
write_series_csv(csv_path, series, meta)
an = analyze_pair(series, period, label=tag)
an["csv"] = csv_path
an["command"] = meta
an["live_end"] = {str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)}
jpath = os.path.join(LAB, "pair_test_%s.json" % tag)
with open(jpath, "w", encoding="utf-8") as fp:
json.dump(an, fp, ensure_ascii=False, indent=2)
print(json.dumps(an, ensure_ascii=False, indent=2))
print("CSV", csv_path, "JSON", jpath)
return 0
hold_position(seconds=0.35, pid=tuple(args.pid), voltage=args.voltage)
ths = start_poll(args.threads)
t0 = time.time()
print("cruise...")
n1, e1 = drive(cruise_items, args.cruise, args.rate, mode=2,
voltage=args.voltage, pid=tuple(args.pid))
t_cmd_stop = time.time()
print("STOP → 0")
n2, e2 = drive(stop_items, args.hold, args.rate, mode=2,
voltage=args.voltage, pid=tuple(args.pid))
t1 = time.time()
stop_poll(ths)
series = aligned_series()
period = post("/api/motor/state").get("period_ms") or period
csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag)
meta = {"test": "stop", "vel": vel, "mirror": args.mirror,
"t0": t0, "t_stop_cmd": t_cmd_stop, "t1": t1,
"sends_cruise": n1, "sends_stop": n2, "err": [e1, e2]}
write_series_csv(csv_path, series, meta)
# 停止沿分析:以发 STOP 时刻为界
if len(series) < 20:
print("samples too few")
return 2
# 找 wall 接近 t_cmd_stop 的索引
idx_stop = 0
for i, row in enumerate(series):
if row[1] >= t_cmd_stop:
idx_stop = max(0, i - 1)
break
pre = series[:idx_stop + 1]
post_s = series[idx_stop:]
def win_stats(seg):
if len(seg) < 5:
return None
v2 = [r[3] for r in seg]
v3 = [r[5] for r in seg]
vs = [r[6] for r in seg]
I2, I3, Isum = trapz_vel(seg, period)
return {
"n": len(seg),
"mean_v2": sum(v2) / len(v2),
"mean_v3": sum(v3) / len(v3),
"mean_abs_v2": sum(abs(x) for x in v2) / len(v2),
"mean_abs_v3": sum(abs(x) for x in v3) / len(v3),
"rms_vsum": (sum(x * x for x in vs) / len(vs)) ** 0.5,
"max_abs_vsum": max(abs(x) for x in vs),
"I2": I2, "I3": I3, "Isum": Isum,
}
# 各轴 |v| 穿越 20 的时刻
def cross(seg, node_i, thr=20.0, falling=True):
vi = 3 if node_i == 2 else 5
for i in range(1, len(seg)):
v0, v1 = abs(seg[i - 1][vi]), abs(seg[i][vi])
if falling and v0 >= thr > v1:
return seg[i - 1][0] + (v0 - thr) / (v0 - v1 + 1e-12) * (seg[i][0] - seg[i - 1][0])
if (not falling) and v0 < thr <= v1:
return seg[i - 1][0] + (thr - v0) / (v1 - v0 + 1e-12) * (seg[i][0] - seg[i - 1][0])
return None
c2 = cross(post_s, 2)
c3 = cross(post_s, 3)
off_delta_ms = None
if c2 is not None and c3 is not None:
off_delta_ms = (c3 - c2) * period
an = analyze_pair(series, period, label=tag)
an.update({
"csv": csv_path,
"command": meta,
"pre_stop": win_stats(pre),
"post_stop": win_stats(post_s),
"off_cross20_ms_n3_minus_n2": off_delta_ms,
"verdict_hint": {
"cruise": "比 pre_stop 的 mean_abs_v2/v3 谁大",
"stop": "比 post_stop 的 max_abs_vsum 与 off_delta_ms;Δ>0 表示 N3 晚停",
"integral": "post_stop.Isum 绝对值 = 停车段被顶开的积分",
},
})
jpath = os.path.join(LAB, "pair_test_%s.json" % tag)
with open(jpath, "w", encoding="utf-8") as fp:
json.dump(an, fp, ensure_ascii=False, indent=2)
print(json.dumps(an, ensure_ascii=False, indent=2))
print("CSV", csv_path, "JSON", jpath)
return 0
def cmd_trip(args):
"""mode3 镜像行程:n2=+span, n3=-span(已对接强制镜像)。"""
period, _ = ensure_ecat()
span = abs(args.span)
pos = [(2, span), (3, -span)]
zero = [(2, 0.0), (3, 0.0)]
tag = "trip_mirror"
print("trip %s span=±%.1f vel=%.1f hold=%.2f(强制镜像)"
% (tag, span, args.vel, args.hold))
snap = snap_motors()
a2 = snap.get(2, {}).get("ang", 0.0)
# 选择更远的端点作为第一目标
if abs(a2 - span) < abs(a2 - 0) and abs(a2) > span * 0.5:
first, second = zero, pos
elif abs(a2) < span * 0.5:
first, second = pos, zero
else:
first, second = zero, pos
ths = start_poll(args.threads)
t0 = time.time()
print("leg1 →", first)
snap = snap_motors()
t2t, t3t = first[0][1], first[1][1]
drive(_mirror_cmd_items(t2t, t3t, abs(args.vel), snap), args.hold,
args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid))
t_mid = time.time()
print("leg2 →", second)
snap = snap_motors()
t2t, t3t = second[0][1], second[1][1]
drive(_mirror_cmd_items(t2t, t3t, abs(args.vel), snap), args.hold,
args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid))
t1 = time.time()
stop_poll(ths)
series = aligned_series()
period = post("/api/motor/state").get("period_ms") or period
csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag)
meta = {"test": "trip_mirror", "span": span, "vel": args.vel,
"t0": t0, "t_mid": t_mid, "t1": t1, "coupled": True}
write_series_csv(csv_path, series, meta)
an = analyze_pair(series, period, label=tag)
an["csv"] = csv_path
an["command"] = meta
an["live_end"] = {str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)}
jpath = os.path.join(LAB, "pair_test_%s.json" % tag)
with open(jpath, "w", encoding="utf-8") as fp:
json.dump(an, fp, ensure_ascii=False, indent=2)
print(json.dumps(an, ensure_ascii=False, indent=2))
print("CSV", csv_path, "JSON", jpath)
return 0
def _mirror_cmd_items(target2, target3, speed, snap=None):
"""仅通过 safety_precheck 生成 CMD;禁止把速度强行改成相反符号。"""
ok, msg, items = safety_precheck_motion(target2, target3, abs(speed))
print(" precheck:", msg)
if not ok:
raise RuntimeError("SAFETY: " + msg)
return items
def cmd_lowspeed(args):
"""低速镜像试控:先只读检查,再极小行程。错位大则拒绝一切运动。"""
period, st = ensure_ecat()
a2, v2, a3, v3, snap = _read_pair()
asum = None
if a2 is not None and a3 is not None:
asum = a2 + a3
print("\n======== 低速试控前置检查 ========")
print("a2=%s a3=%s a2+a3=%s v2=%s v3=%s" % (a2, a3, asum, v2, v3))
print("硬限位: |ang|≤%.0f |a2+a3|≤%.0f 禁止 mode2 禁止同向" %
(MAX_ABS_ANGLE, MAX_ASUM_ERR))
if a2 is None or a3 is None:
print("结论: 状态缺失 → 不发运动指令")
return 2
if abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE:
print("结论: 编码器超限 → 不发运动指令。请人工把机构回到对称位。")
return 2
if asum is not None and abs(asum) > MAX_ASUM_ERR:
print("结论: 错位 a2+a3=%.1f° 过大 → 拒绝电控运动。" % asum)
print("请先人工盘车/松耦合,使 a2≈-a3(和接近 0),再跑 lows peed。")
return 2
speed = min(abs(args.vel), 20.0) # 低速硬顶 20°/s
span = min(abs(args.span), 15.0) # 小行程硬顶 15°
t2, t3 = span, -span
if abs(t2 - a2) > 3 * span:
# 当前离目标太远则以当前对称附近为中心:目标 = round 到对称
t2 = span if a2 < 0 else -span
t3 = -t2
ok, msg, items = safety_precheck_motion(t2, t3, speed)
print("试控目标 ±%.1f° @ %.1f°/s" % (span, speed))
print(msg)
if not ok:
print("结论: precheck 失败,不发令")
return 2
print("发令 items:", items)
ths = start_poll(args.threads)
t0 = time.time()
n_ok, err = drive(items, max(args.hold, 2.0), rate=10.0, mode=3,
voltage=args.voltage, pid=tuple(args.pid))
print("行程发送完成 n_ok=%s err=%s" % (n_ok, err))
# 回 0(若仍安全)
ok2, msg2, items2 = safety_precheck_motion(0.0, 0.0, speed)
print("回零:", msg2)
if ok2:
drive(items2, max(args.hold, 2.0), rate=10.0, mode=3,
voltage=args.voltage, pid=tuple(args.pid))
else:
# 保持当前位置
a2, _, a3, _, _ = _read_pair()
if a2 is not None and a3 is not None:
try:
drive([(2, 0.0, a2), (3, 0.0, a3)], 0.6, rate=10.0, mode=3,
voltage=args.voltage, pid=tuple(args.pid))
except Exception as e:
print("hold err", e)
t1 = time.time()
stop_poll(ths)
a2e, v2e, a3e, v3e, _ = _read_pair()
print("结束: a2=%s v2=%s a3=%s v3=%s asum=%s" %
(a2e, v2e, a3e, v3e, (None if a2e is None or a3e is None else a2e + a3e)))
series = aligned_series()
period = post("/api/motor/state").get("period_ms") or period
csv_path = os.path.join(LAB, "pair_test_lowspeed.csv")
meta = {"test": "lowspeed", "vel": speed, "span": span,
"t0": t0, "t1": t1, "items": items}
write_series_csv(csv_path, series, meta)
an = analyze_pair(series, period, label="lowspeed")
an["csv"] = csv_path
an["command"] = meta
an["end"] = {"a2": a2e, "v2": v2e, "a3": a3e, "v3": v3e,
"asum": None if a2e is None or a3e is None else a2e + a3e}
jpath = os.path.join(LAB, "pair_test_lowspeed.json")
with open(jpath, "w", encoding="utf-8") as fp:
json.dump(an, fp, ensure_ascii=False, indent=2)
print(json.dumps(an, ensure_ascii=False, indent=2)[:800])
print("CSV", csv_path, "JSON", jpath)
return 0
def cmd_fairtrip(args):
"""公平行程:先双机 mode3 停到 0(镜像速度),再同时去 +span/-span。"""
period, _ = ensure_ecat()
span = abs(args.span)
print("fairtrip: settle@0 → mirror ±%.0f @ vel=%.1f hold=%.2f(强制镜像)"
% (span, args.vel, args.hold))
ths = start_poll(args.threads)
t0 = time.time()
print("1) 同步到 0(速度按镜像指向 0)")
items0 = _mirror_cmd_items(0.0, 0.0, abs(args.vel))
print(" cmd", items0)
drive(items0, args.settle,
args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid))
snap = snap_motors()
print(" at0:", {n: (snap.get(n) or {}).get("ang") for n in NODES})
t_go = time.time()
print("2) 同时镜像 ±span")
items1 = _mirror_cmd_items(span, -span, abs(args.vel), snap)
print(" cmd", items1)
drive(items1, args.hold,
args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid))
t1 = time.time()
stop_poll(ths)
snap = snap_motors()
print(" end:", {n: (snap.get(n) or {}).get("ang") for n in NODES},
{n: (snap.get(n) or {}).get("vel") for n in NODES})
series = aligned_series()
period = post("/api/motor/state").get("period_ms") or period
csv_path = os.path.join(LAB, "pair_test_fairtrip.csv")
meta = {"test": "fairtrip", "span": span, "vel": args.vel,
"t0": t0, "t_go": t_go, "t1": t1}
write_series_csv(csv_path, series, meta)
# 只分析 t_go 之后的行程
seg = [r for r in series if r[1] >= t_go]
an = analyze_pair(seg, period, label="fairtrip")
an["csv"] = csv_path
an["command"] = meta
an["live_end"] = {str(n): snap.get(n) for n in NODES if snap.get(n)}
# 到达时刻:|ang - target| < 3 且 |v| < 10
def arrive(node, target):
vi_ang = 2 if node == 2 else 4
vi_vel = 3 if node == 5 else 5
vi_vel = 3 if node == 2 else 5
for r in seg:
if abs(r[vi_ang] - target) < 3.0 and abs(r[vi_vel]) < 10.0:
return r[1]
return None
t2 = arrive(2, span)
t3 = arrive(3, -span)
an["arrive_wall"] = {"n2": t2, "n3": t3}
if t2 and t3:
an["arrive_delta_ms_n3_minus_n2"] = (t3 - t2) * 1000.0
# 行程中段 20%-80% 的 vsum / 巡航
if len(seg) > 50:
w = seg[len(seg) // 5: max(len(seg) - len(seg) // 5, len(seg) // 5 + 2)]
I2, I3, Isum = trapz_vel(w, period)
an["mid_stroke"] = {
"n": len(w),
"mean_abs_v2": sum(abs(r[3]) for r in w) / len(w),
"mean_abs_v3": sum(abs(r[5]) for r in w) / len(w),
"mean_vsum": sum(r[6] for r in w) / len(w),
"rms_vsum": (sum(r[6] ** 2 for r in w) / len(w)) ** 0.5,
"max_abs_vsum": max(abs(r[6]) for r in w),
"I2": I2, "I3": I3, "Isum": Isum,
}
jpath = os.path.join(LAB, "pair_test_fairtrip.json")
with open(jpath, "w", encoding="utf-8") as fp:
json.dump(an, fp, ensure_ascii=False, indent=2)
print(json.dumps(an, ensure_ascii=False, indent=2))
print("CSV", csv_path, "JSON", jpath)
return 0
def cmd_stopnow(_args):
print("STOP NOW")
for _ in range(8):
try:
burst_cmds([(2, 0.0, 0.0), (3, 0.0, 0.0)], mode=2)
except Exception as e:
print(" ", e)
time.sleep(0.03)
print("live", json.dumps(
{str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)},
ensure_ascii=False)[:300])
return 0
def cmd_full(args):
"""对接安全套件:仅镜像 openloop / stop / fairtrip(禁止同向)。"""
results = {}
print("\n======== FULL-MIRROR: 1/4 align ========")
cmd_align(args)
print("\n======== FULL-MIRROR: 2/4 openloop mirror ========")
cmd_openloop(args)
p_ol = os.path.join(LAB, "pair_test_openloop_mirror_vel%s.json" % abs(args.vel))
if os.path.exists(p_ol):
results["openloop_mirror"] = json.loads(open(p_ol, encoding="utf-8").read())
print("\n======== FULL-MIRROR: 3/4 stop mirror ========")
cmd_stop(args)
p_st = os.path.join(LAB, "pair_test_stop_mirror.json")
if os.path.exists(p_st):
results["stop_mirror"] = json.loads(open(p_st, encoding="utf-8").read())
print("\n======== FULL-MIRROR: 4/4 fairtrip mirror ========")
cmd_fairtrip(args)
p_ft = os.path.join(LAB, "pair_test_fairtrip.json")
if os.path.exists(p_ft):
results["fairtrip"] = json.loads(open(p_ft, encoding="utf-8").read())
out = os.path.join(LAB, "pair_test_FULL_mirror_summary.json")
slim = {"coupled": True, "rule": "mirror_only_v2=-v3", "results": {}}
for k, v in results.items():
slim["results"][k] = {kk: v.get(kk) for kk in (
"label", "cruise_mean_abs_v2", "cruise_mean_abs_v3",
"cruise_mean_vsum", "mov_rms_vsum", "mov_max_abs_vsum",
"I_sum_v2_plus_v3", "asum_delta", "arrive_delta_ms_n3_minus_n2",
"off_cross20_ms_n3_minus_n2", "mid_stroke", "pre_stop", "post_stop",
) if kk in v}
with open(out, "w", encoding="utf-8") as fp:
json.dump(slim, fp, ensure_ascii=False, indent=2)
print(json.dumps(slim, ensure_ascii=False, indent=2))
print("summary ->", out)
return 0
def _who_slower(an):
v2 = an.get("cruise_mean_abs_v2")
v3 = an.get("cruise_mean_abs_v3")
if not v2 or not v3:
return None
if abs(v2 - v3) < 1.0:
return "almost_equal"
return "N2_slower" if v2 < v3 else "N3_slower"
# ───────────────────────── CLI ─────────────────────────
def build_parser():
p = argparse.ArgumentParser(
description="N2/N3 对接电机诊断发令(ECAT)— 请确认机构安全")
p.add_argument("--pid", type=int, nargs=3, default=[100, 100, 100],
help="PID current/vel/angle P,默认 100 100 100")
p.add_argument("--voltage", type=float, default=24.0)
p.add_argument("--rate", type=float, default=25.0, help="发令频率 Hz")
p.add_argument("--threads", type=int, default=6)
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("status", help="只读状态与 CONFIG")
slowp = sub.add_parser("lowspeed", help="低速镜像试控(错位大则拒绝)")
slowp.add_argument("--vel", type=float, default=15.0)
slowp.add_argument("--span", type=float, default=10.0)
slowp.add_argument("--hold", type=float, default=2.5)
sp = sub.add_parser("align", help="统一 CONFIG 并保持")
sp.add_argument("--limit-v", type=float, default=22.0)
sp.add_argument("--vel", type=float, default=100.0)
sp.add_argument("--mode", type=int, default=3)
sp.add_argument("--hold", type=float, default=0.8)
so = sub.add_parser("openloop", help="开环速度对比 mode2")
so.add_argument("--vel", type=float, default=80.0)
so.add_argument("--cruise", type=float, default=1.5)
so.add_argument("--settle", type=float, default=0.6)
ss = sub.add_parser("stop", help="巡航→同时刹停")
ss.add_argument("--vel", type=float, default=80.0)
ss.add_argument("--cruise", type=float, default=1.2)
ss.add_argument("--hold", type=float, default=0.8)
ss.add_argument("--mirror", action="store_true", help="v2=-v3 镜像巡航")
st = sub.add_parser("trip", help="mode3 位置行程")
st.add_argument("--span", type=float, default=200.0)
st.add_argument("--vel", type=float, default=80.0)
st.add_argument("--hold", type=float, default=2.5)
st.add_argument("--mirror", action="store_true")
sfair = sub.add_parser("fairtrip", help="先到0再同时±span,公平对比")
sfair.add_argument("--span", type=float, default=400.0)
sfair.add_argument("--vel", type=float, default=200.0)
sfair.add_argument("--hold", type=float, default=2.5)
sfair.add_argument("--settle", type=float, default=2.0)
sub.add_parser("stopnow", help="急停速度=0")
sf = sub.add_parser("full", help="align+openloop+stop+trip 全套")
sf.add_argument("--vel", type=float, default=80.0)
sf.add_argument("--cruise", type=float, default=1.2)
sf.add_argument("--hold", type=float, default=2.2)
sf.add_argument("--settle", type=float, default=0.6)
sf.add_argument("--span", type=float, default=200.0)
sf.add_argument("--limit-v", type=float, default=22.0)
sf.add_argument("--mode", type=int, default=3)
sf.add_argument("--mirror", action="store_true")
return p
def main(argv=None):
args = build_parser().parse_args(argv)
print("⚠ 请确认电机2/3机构处于安全状态;Ctrl+C 后请手动 stopnow")
print("target:", args.cmd, "pid", args.pid)
handlers = {
"status": cmd_status,
"lowspeed": cmd_lowspeed,
"align": cmd_align,
"openloop": cmd_openloop,
"stop": cmd_stop,
"trip": cmd_trip,
"fairtrip": cmd_fairtrip,
"stopnow": cmd_stopnow,
"full": cmd_full,
}
try:
return handlers[args.cmd](args)
except KeyboardInterrupt:
print("\n!! 中断 → 急停")
try:
cmd_stopnow(args)
except Exception:
pass
return 130
if __name__ == "__main__":
raise SystemExit(main())