之前 e2e_web_send_latency.py / recover_ecat_after_reset.py / measure_click_to_motion.py 全在 .gitignore 里(e2e_/measure_ 通配),换台机器 clone 就跑不了验收;且 recover 脚本零失败路径(--skip-e2e 无条件退 0)。现在统一到已跟踪的 check_ 前缀, .gitignore 一行例外都不用加,并把"跑过了"和"恢复了"绑成一件事。 - check_recover_after_reset.py:force_reopen 后改为轮询到 dc=1/250us/loop=1/op=1/ wkc>=3(上限 15s,替掉原来单点采样那次假 loop=0),并断言守卫心跳在涨、 发一帧 3s 内出口必须跟上;退出码 0/1/2(前置不满足=BLOCKED=2)。 - check_web_send_latency.py:新增第 6 段读 health.guard(旧 exe 无 guard 块 / alive_ticks 不涨 都算 FAIL),第 5 段改调已入库的 check_click_to_motion.py。 它每次发送只等 2s,结构上看不到 8s 判据,所以只报"守卫当时是死是活", 不拿它冒充触发验证。 - check_guard_idle_silence.py:健康总线的"另一极"——90s 静默 + 25s 1Hz 发送, 要求心跳在涨、armed 恒 0、fires/wr 零增量、每次发送都被消费。这是防 "把守卫修成重连风暴"的真机版(对应离线 T1b/T4/T5/T6)。 - check_guard_fires_hw.py:需人在场按 GD32 RESET 制造冻结,证明 armed 秒起、 tx_stale_s 爬过 8s、fires +1、日志有 [GUARD] 全量重连、松手后 20s 内出口 重新前进,再验证 60s 冷却内不第二次触发;--with-pm-hold 另证"静音"时心跳仍在涨。 只认 guard.fires,不认 wr(wr 汇总所有恢复路径,会以错误的理由通过)。 - guard_probe.py:两个真机脚本共用同一份前置判据与通路选择(先 :2625 页面代理, 再直连 native 8090..8095)。前置没满足一律 BLOCKED——loop=0 或 pm_hold=1 时判据 按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
170 lines
6.3 KiB
Python
170 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""测量「点击发送」到「电机 STATUS 显示开始运动」的端到端延迟。
|
||
|
||
口径说明(务必读):
|
||
t_click = 脚本在发起 motor/send 之前的时间戳
|
||
≈ 网页点击处理完、即将 fetch 的时刻(未含浏览器主线程/桥调用开销)
|
||
t_http = motor/send HTTP 返回(控制面入队完成)
|
||
t_can = GD32 bridge.tx_count/ack 变化(EtherCAT 出口,命令已进 CAN)
|
||
t_motion = 电机 STATUS 中 rtAngle/rtVelocity 相对基线发生可测变化
|
||
(软件可见的「开始动」代理,不是编码器轴端绝对时刻)
|
||
|
||
用法:
|
||
python scripts/measure_click_to_motion.py
|
||
python scripts/measure_click_to_motion.py --node 3 --ch 2 --vel 40 --timeout 3
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import struct
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
|
||
PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
|
||
ECAT = PAGE + "/ecat/api"
|
||
|
||
|
||
def post(url, obj, timeout=8.0):
|
||
data = json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST")
|
||
t0 = time.perf_counter()
|
||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||
body = r.read()
|
||
return (time.perf_counter() - t0) * 1000.0, json.loads(body.decode("utf-8", "replace") or "{}")
|
||
|
||
|
||
def decode_status(data_hex: str):
|
||
if not data_hex or len(data_hex) < 36:
|
||
return None
|
||
raw = bytes.fromhex(data_hex[:72] if len(data_hex) >= 72 else data_hex)
|
||
if len(raw) < 21:
|
||
return None
|
||
|
||
def f32(off):
|
||
if off + 4 > len(raw):
|
||
return 0.0
|
||
v = struct.unpack_from("<f", raw, off)[0]
|
||
return v if v == v else 0.0 # NaN → 0
|
||
|
||
return {
|
||
"rtAngle": f32(13),
|
||
"rtVelocity": f32(17),
|
||
"rtCurrent": f32(21),
|
||
}
|
||
|
||
|
||
def motor_sample(node: int, ch: int):
|
||
_, snap = post(ECAT + "/motor/state", {}, 5)
|
||
if not snap.get("ok"):
|
||
return None, None, None
|
||
br = snap.get("bridge") or {}
|
||
outlet = (br.get("tx_count"), br.get("ack"))
|
||
seq = snap.get("sent_seq")
|
||
for m in snap.get("motors") or []:
|
||
if m.get("node") == node and m.get("ch") == ch:
|
||
return decode_status(m.get("data") or ""), outlet, seq
|
||
return None, outlet, seq
|
||
|
||
|
||
def build_cmd(mode, voltage, current, velocity, angle, pid=100):
|
||
buf = bytearray(32)
|
||
buf[0] = mode & 0xFF
|
||
struct.pack_into("<H", buf, 1, int(round(voltage * 1000)) & 0xFFFF)
|
||
struct.pack_into("<f", buf, 3, float(current))
|
||
struct.pack_into("<f", buf, 7, float(velocity))
|
||
struct.pack_into("<f", buf, 11, float(angle))
|
||
struct.pack_into("<h", buf, 15, pid)
|
||
struct.pack_into("<h", buf, 17, pid)
|
||
struct.pack_into("<h", buf, 19, pid)
|
||
return buf.hex()
|
||
|
||
|
||
def send_cmd(ch, node, data_hex):
|
||
return post(ECAT + "/motor/send", {
|
||
"frames": [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": data_hex}]
|
||
}, 6)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--node", type=int, default=3)
|
||
ap.add_argument("--ch", type=int, default=2)
|
||
ap.add_argument("--vel", type=float, default=40.0)
|
||
ap.add_argument("--timeout", type=float, default=3.0)
|
||
args = ap.parse_args()
|
||
node, ch = args.node, args.ch
|
||
|
||
post(ECAT + "/motor/start",
|
||
{"period_ms": 0.25, "dc_cycle_us": 250, "dc_shift_us": 0, "force_reopen": True}, 30)
|
||
time.sleep(0.3)
|
||
|
||
base, outlet0, _ = motor_sample(node, ch)
|
||
print(f"基线 STATUS node={node} ch={ch}: {base}")
|
||
print(f"基线 GD32 outlet tx/ack: {outlet0}")
|
||
if not base:
|
||
print("FAIL: 该节点无 STATUS(请确认 EtherCAT 在线且节点有回报)")
|
||
return 1
|
||
|
||
ang0, vel0 = base["rtAngle"], base["rtVelocity"]
|
||
data = build_cmd(mode=2, voltage=24.0, current=0.0, velocity=args.vel, angle=0.0)
|
||
print(f"将发送 CMD mode=Velocity v={args.vel} → 稍后自动 Disable")
|
||
|
||
t_click = time.perf_counter()
|
||
http_ms, resp = send_cmd(ch, node, data)
|
||
t_http = time.perf_counter()
|
||
print(f"motor/send HTTP {http_ms:.1f}ms ok={resp.get('ok')} queued={resp.get('queued')}")
|
||
|
||
t_can = None
|
||
t_motion = None
|
||
motion_detail = None
|
||
can_detail = None
|
||
t0 = t_click
|
||
while time.perf_counter() - t0 < args.timeout:
|
||
st, outlet, _ = motor_sample(node, ch)
|
||
now = time.perf_counter()
|
||
if t_can is None and outlet0 and outlet != outlet0 and outlet[0] is not None:
|
||
t_can = now
|
||
can_detail = f"outlet {outlet0} → {outlet}"
|
||
if st:
|
||
d_ang = abs(st["rtAngle"] - ang0)
|
||
d_vel = abs(st["rtVelocity"] - vel0)
|
||
# 开始运动:角度变化 >0.3° 或速度偏离基线 >5
|
||
if t_motion is None and (d_ang > 0.3 or d_vel > 5.0):
|
||
t_motion = now
|
||
motion_detail = (
|
||
f"Δangle={d_ang:.3f}° Δvel={d_vel:.2f} "
|
||
f"angle {ang0:.2f}→{st['rtAngle']:.2f} vel {vel0:.2f}→{st['rtVelocity']:.2f}"
|
||
)
|
||
if t_motion is not None and t_can is not None:
|
||
break
|
||
time.sleep(0.002)
|
||
|
||
stop = build_cmd(mode=0, voltage=24.0, current=0, velocity=0, angle=0)
|
||
send_cmd(ch, node, stop)
|
||
|
||
def ms(dt):
|
||
return None if dt is None else dt * 1000.0
|
||
|
||
print("\n=== 端到端(脚本 t_click ≈ 点击后即将发请求)===")
|
||
print(f" t_click → HTTP 返回 : {ms(t_http - t_click):.1f} ms")
|
||
print(f" t_click → GD32 CAN 出口 : {ms(t_can - t_click) if t_can else '未观测到'} ms {can_detail or ''}")
|
||
print(f" t_click → STATUS 开始动 : {ms(t_motion - t_click) if t_motion else '超时未观测到'} ms {motion_detail or ''}")
|
||
if t_can:
|
||
print(f" HTTP 返回 → CAN 出口 : {ms(t_can - t_http):.1f} ms")
|
||
if t_motion and t_can:
|
||
print(f" CAN 出口 → STATUS 动 : {ms(t_motion - t_can):.1f} ms ← 固件/回报段")
|
||
if t_motion:
|
||
print(f"\n软件口径「点击→STATUS 显示开始动」≈ {ms(t_motion - t_click):.1f} ms")
|
||
print("(不含真实鼠标事件前的浏览器调度;机械轴端时刻会更晚)")
|
||
return 0
|
||
print("\nFAIL: 超时未在 STATUS 中看到运动(电机未响应或回报未更新)")
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|