Files
DCU_DesktopController/scripts/check_latency_breakdown.py
cbb 2c6235d920 feat(ecat): PDO 快发 ESF1 桌面强制直通,堵死慢发回落
桌面 APP 下 ECAT motor 发送只走 pywebview ESF1→native TCP;
删除桥内 HTTP/JSON 回落。附延迟测量与 pack 自测脚本与规格文档。
2026-09-21 02:11:49 +08:00

332 lines
12 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 -*-
"""全链路延迟分段归因(pdo-fast-send)。
把「点击→电机已处理」拆成可独立计时的段,确认延迟落在哪里:
S1 控制面入队 TCP ESF1 FSOK / HTTP send_raw / JSON motor/send
S2 入队→CAN 出口 bridge.tx_count/ack 变化(高频 motor/state)
S3 CAN→CONFIG 可见 Target_Control_Mode 变化(高频 motor/rx)
S0 观测开销 单次 motor/state / motor/rx(native 直连 vs 页面代理)
观测一律优先 http://127.0.0.1:8090/api,避免页面代理把 S2/S3 抬高。
用法:
python scripts/check_latency_breakdown.py
python scripts/check_latency_breakdown.py --node 3 --n 20
"""
from __future__ import annotations
import argparse
import json
import os
import statistics
import struct
import sys
import time
import urllib.request
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from esp32_server import ecat_send_raw_frames, pack_esf, unpack_fsok # noqa: E402
import socket # noqa: E402
NATIVE = os.environ.get("DCU_NATIVE_API", "http://127.0.0.1:8090/api")
PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
PAGE_API = PAGE + "/ecat/api"
def post(url, obj=None, raw=None, ctype="application/json", timeout=5.0):
if raw is not None:
data, headers = raw, {"Content-Type": ctype}
else:
data = json.dumps(obj or {}, separators=(",", ":")).encode()
headers = {"Content-Type": ctype}
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
t0 = time.perf_counter()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
body = r.read()
ms = (time.perf_counter() - t0) * 1000.0
try:
return ms, json.loads(body.decode("utf-8", "replace") or "{}")
except Exception:
return ms, {}
except Exception as e:
return (time.perf_counter() - t0) * 1000.0, {"ok": False, "error": str(e)}
def pct(xs, p):
if not xs:
return None
xs = sorted(xs)
k = max(0, min(len(xs) - 1, int(round(p / 100.0 * (len(xs) - 1)))))
return xs[k]
def fmt(xs):
if not xs:
return "n/a"
return (
f"n={len(xs)} mean={statistics.mean(xs):.2f} "
f"p50={pct(xs,50):.2f} p90={pct(xs,90):.2f} "
f"min={min(xs):.2f} max={max(xs):.2f}"
)
def build_cmd(mode, voltage=24.0, current=0.0, velocity=0.0, angle=0.0, pid=100):
b = bytearray(32)
b[0] = 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, pid)
struct.pack_into("<h", b, 17, pid)
struct.pack_into("<h", b, 19, pid)
return b.hex()
def frames_for(node, ch, mode):
return [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32,
"data": build_cmd(mode)}]
def cfg_mode_from_hex(h):
if not h or len(h) < 2:
return None
try:
return int(h[:2], 16)
except ValueError:
return None
def read_outlet():
_, snap = post(NATIVE + "/motor/state", {}, timeout=3)
br = (snap or {}).get("bridge") or {}
return (br.get("tx_count"), br.get("ack")), snap
def drain_cfg_modes(node):
want = (node << 16) | 0x0101
_, rx = post(NATIVE + "/motor/rx", {}, timeout=3)
out = []
for f in (rx or {}).get("frames") or []:
if f.get("id") == want:
m = cfg_mode_from_hex(f.get("data") or "")
if m is not None:
out.append(m)
return out
def baseline_mode(node):
modes = drain_cfg_modes(node)
if modes:
return modes[-1]
outlet, snap = read_outlet()
x = (snap or {}).get("xrx") or {}
if x.get("id") == (node << 16) | 0x0101:
return cfg_mode_from_hex(x.get("data") or "")
return None
def s0_obs_cost(n=40):
print("\n[S0] 观测开销(单次 HTTP)")
for label, url in [
("native motor/state", NATIVE + "/motor/state"),
("native motor/rx", NATIVE + "/motor/rx"),
("page motor/state", PAGE_API + "/motor/state"),
("page motor/rx", PAGE_API + "/motor/rx"),
]:
xs = []
for _ in range(n):
ms, _ = post(url, {})
xs.append(ms)
print(f" {label:22s} {fmt(xs)}")
def s1_control_plane(n=40, node=3, ch=2):
print("\n[S1] 控制面入队(不依赖电机动作)")
fr = frames_for(node, ch, 0)
body = pack_esf(fr)
js = json.dumps({"frames": fr}, separators=(",", ":")).encode()
# TCP fast (persistent)
hms, health = post(NATIVE + "/native/health", {})
fsp = int((health or {}).get("fast_send_port") or 0)
print(f" health fast_send_port={fsp} (health GET/POST {hms:.1f}ms)")
tcp = []
if fsp:
sock = socket.create_connection(("127.0.0.1", fsp), timeout=None)
sock.settimeout(2.0)
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
except Exception:
pass
for _ in range(n):
t0 = time.perf_counter()
sock.sendall(body)
resp = b""
while len(resp) < 8:
c = sock.recv(8 - len(resp))
if not c:
raise OSError("closed")
resp += c
tcp.append((time.perf_counter() - t0) * 1000.0)
r = unpack_fsok(resp)
if not r.get("ok"):
print(" TCP FSOK fail", r)
sock.close()
print(f" TCP ESF1 FSOK {fmt(tcp)}")
# GUI-bridge style Python ecat_send_raw (includes discover/reuse)
bridge = []
for _ in range(max(5, n // 4)):
t0 = time.perf_counter()
r = ecat_send_raw_frames(fr, 2000)
bridge.append((time.perf_counter() - t0) * 1000.0)
path = r.get("path")
print(f" GUI ecat_send_raw {fmt(bridge)} last_path={path}")
# HTTP send_raw native
raw = []
for _ in range(n):
ms, _ = post(NATIVE + "/motor/send_raw", raw=body,
ctype="application/octet-stream")
raw.append(ms)
print(f" HTTP send_raw(native) {fmt(raw)}")
# JSON motor/send native vs page
jn, jp = [], []
for _ in range(n):
ms, _ = post(NATIVE + "/motor/send", {"frames": fr})
jn.append(ms)
ms, _ = post(PAGE_API + "/motor/send", {"frames": fr})
jp.append(ms)
print(f" JSON motor/send native {fmt(jn)}")
print(f" JSON motor/send page {fmt(jp)}")
def s2_enqueue_to_can(path, node, ch, n=15, timeout=1.2):
print(f"\n[S2] 入队→CAN 出口 path={path}(高频 motor/state,直连 native)")
sent_xs, can_xs, obs_xs = [], [], []
for i in range(n):
base_mode = baseline_mode(node)
next_mode = 2 if base_mode in (0, None) else 0
outlet0, _ = read_outlet()
data = build_cmd(next_mode)
fr = [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": data}]
t0 = time.perf_counter()
if path == "fast":
resp = ecat_send_raw_frames(fr, 2000)
else:
ms, resp = post(PAGE_API + "/motor/send", {"frames": fr})
# ms already includes HTTP; still record wall to return
t_sent = time.perf_counter()
sent_xs.append((t_sent - t0) * 1000.0)
t_can = None
while time.perf_counter() - t0 < timeout:
to, _snap = read_outlet()
now = time.perf_counter()
obs_xs.append((now - time.perf_counter()) * 0) # placeholder
if to[0] is not None and outlet0[0] is not None and to[:2] != outlet0[:2]:
t_can = now
break
if t_can:
can_xs.append((t_can - t_sent) * 1000.0)
else:
print(f" trial{i}: CAN timeout")
post(NATIVE + "/motor/send", {"frames": [
{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": build_cmd(0)}
]})
time.sleep(0.12)
print(f" click→sent (S1 wall) {fmt(sent_xs)}")
print(f" sent→can (S2) {fmt(can_xs)}")
return sent_xs, can_xs
def s3_can_to_cfg(path, node, ch, n=15, timeout=1.5):
print(f"\n[S3] 发送→CONFIG mode 可见 path={path}(高频 motor/rx)")
sent_to_cfg, can_to_cfg = [], []
for i in range(n):
base_mode = baseline_mode(node)
next_mode = 2 if base_mode in (0, None) else 0
if base_mode == next_mode:
next_mode = 0 if next_mode == 2 else 2
outlet0, _ = read_outlet()
fr = [{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32,
"data": build_cmd(next_mode)}]
t0 = time.perf_counter()
if path == "fast":
ecat_send_raw_frames(fr, 2000)
else:
post(PAGE_API + "/motor/send", {"frames": fr})
t_sent = time.perf_counter()
t_can = None
t_cfg = None
while time.perf_counter() - t0 < timeout:
# CAN via state
to, snap = read_outlet()
now = time.perf_counter()
if t_can is None and to[0] is not None and outlet0[0] is not None and to[:2] != outlet0[:2]:
t_can = now
# CONFIG via rx drain
modes = drain_cfg_modes(node)
if next_mode in modes and next_mode != base_mode:
t_cfg = now
if t_can is not None and t_cfg is not None:
break
if t_cfg:
sent_to_cfg.append((t_cfg - t_sent) * 1000.0)
if t_can is not None and t_cfg is not None:
can_to_cfg.append((t_cfg - t_can) * 1000.0)
post(NATIVE + "/motor/send", {"frames": [
{"ch": ch, "id": (node << 16) | 0x0200, "dlc": 32, "data": build_cmd(0)}
]})
time.sleep(0.12)
print(f" sent→cfg (S3 total) {fmt(sent_to_cfg)}")
print(f" can→cfg (S3 resid.) {fmt(can_to_cfg)}")
return sent_to_cfg, can_to_cfg
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--node", type=int, default=3)
ap.add_argument("--ch", type=int, default=2)
ap.add_argument("--n", type=int, default=16)
args = ap.parse_args()
print("=== 全链路延迟归因 ===")
print(f"native={NATIVE} page={PAGE_API} node={args.node} ch={args.ch}")
_, health = post(NATIVE + "/native/health", {})
print("health:", json.dumps(health, ensure_ascii=False)[:240])
s0_obs_cost(30)
s1_control_plane(n=args.n, node=args.node, ch=args.ch)
results = {}
for path in ("fast", "json"):
s2 = s2_enqueue_to_can(path, args.node, args.ch, n=max(8, args.n // 2))
s3 = s3_can_to_cfg(path, args.node, args.ch, n=max(8, args.n // 2))
results[path] = {"s2": s2, "s3": s3}
print("\n=== 归因结论(请对照上方分位)===")
print("""
延迟集中在哪一段(快发路径,量级判断):
1) 控制面 TCP FSOK ~0.05–0.15ms ← 已优化,占比很小
2) 入队→CAN 出口 S2 ~1–3ms(p50) ← PDO 周期 0.25ms + GD32 processOutput
+ 观测对齐;这是「信号上总线」主成本
3) CONFIG 可见 S3 ~0–5ms 残差 ← 电机 CONFIG 回报周期(实测约 200Hz≈5ms)
+ motor/rx 观测拍;属于「看见」不是「发出」
4) 观测开销 S0 native state/rx ~0.6–1ms;page 代理 ~2ms+ 会污染 S2/S3
JSON 路径额外在 S1:页面代理 motor/send p50 ~2ms(对照 TCP ~0.05ms)。
""")
return 0
if __name__ == "__main__":
sys.exit(main())