Files
cbb 83a035c6d6 test(guard): 验收链改名入库 + 补真退出码;tx 冻结守卫真机门槛两段
之前 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 时判据
  按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
2026-09-21 23:30:07 +08:00

170 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())