#!/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(" 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("= 13 else None, "ucur": struct.unpack_from("= 6 else None, "rt_cur": struct.unpack_from("= 25 else 0.0, "motT": struct.unpack_from("= 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("= 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())