#!/usr/bin/env python3 # -*- coding: utf-8 -*- """PDO 周期极限测速探针(真机复用工具)。 对**正在运行**的 ecat_server 逐级下发热调周期 /api/motor/period,量每一档的 实测周期率、分段耗时(cyc_us)、wkc、自旋状态和 HTTP 读快照延迟。热调不停循环、 喂狗不断流,所以跑这个脚本不会把从站弄掉 OP(区别于反复打 motor/start)。 前提:ecat_server 已启动(端口 8090 在听)。**循环没跑时脚本会自动 open+start**, 所以断电重启板子后,直接敲本脚本一条命令即可,不必先开电机页或手敲 start。 若自动 start 失败(典型=SSC 邮箱楔死),脚本会把原因如实打出来并退出。 用法(在本目录的 venv 下跑,系统 python 也行——只用标准库): venv\\Scripts\\python.exe rate_probe.py venv\\Scripts\\python.exe rate_probe.py --periods 0.5,0.25,0.125 --sample 2 venv\\Scripts\\python.exe rate_probe.py --restore 0.25 # 测完留在 4kHz 档 venv\\Scripts\\python.exe rate_probe.py --no-autostart # 循环没跑时不自动拉起 默认从快照读当前周期,测完恢复到那个值。--substr 指定自动 open 的网卡子串(默认 I226)。 读数怎么解读: * 真机一送一回(send+receive)实测 ≈165us,这就是物理墙:比 165us 更快的 档位"实测率"必然低于目标,不是脚本或设置的错,cyc_us.total.p50 会贴住 周期值甚至跑超。保底 4kHz(250us) 在物理预算内。 * measured 明显低于 target 且 cyc_us 远小于周期 → 是节拍问题(自旋没生效? 看 spin 列),不是总线问题。 * 自旋占满一个逻辑核是预期行为(脚本进程不占,占的是 ecat_server 的 pdo-cycle 线程,正常应钉在 P 核)。 """ import argparse import json import statistics import sys import time import urllib.request DEFAULT_URL = "http://127.0.0.1:8090" def post(url, path, body, timeout=5.0): req = urllib.request.Request( url + path, data=json.dumps(body).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read().decode("utf-8")) def sample(url, seconds): """在 seconds 窗口里轮询快照:返回(实测Hz, HTTP延迟p50/p90(ms), 最后快照)。 实测时刻必须和 cycles 读数**同取同舍**:旧版分子取最后一次轮询的 cycles、 分母却在循环退出后才算 —— 退出前还有一次 sleep(0.1),dt 比 cycles 窗口多出 0.1s,Hz 被系统性低估 3~5% 且与周期无关(2026-09-04 实踩:把 99.2% 报成 96%, 一度被误读成"HTTP 负载吃掉 3 个点")。 """ lat = [] first = last = None # (读数时刻, cycles) —— 同一时刻成对取 t_end = time.perf_counter() + seconds last_snap = None while time.perf_counter() < t_end: t0 = time.perf_counter() snap = post(url, "/api/motor/state", {}) t1 = time.perf_counter() lat.append((t1 - t0) * 1000.0) if not snap.get("ok"): raise RuntimeError("motor/state 报错(可能掉出 OP): %s" % snap.get("log")) c = snap.get("cycles", 0) if first is None: first = (t1, c) last = (t1, c) last_snap = snap time.sleep(0.1) dt = last[0] - first[0] hz = (last[1] - first[1]) / dt if dt > 0 else 0.0 lat.sort() return hz, (lat[len(lat) // 2], lat[int(len(lat) * 0.9)]), last_snap def main(): ap = argparse.ArgumentParser(description="EtherCAT PDO 周期极限测速") ap.add_argument("--url", default=DEFAULT_URL) ap.add_argument("--periods", default="1,0.5,0.25,0.125,0.0625", help="逗号分隔的周期(ms)档位,允许亚毫秒") ap.add_argument("--sample", type=float, default=2.0, help="每档采样秒数") ap.add_argument("--restore", type=float, default=None, help="测完恢复到的周期(ms);缺省=脚本启动前的原值") ap.add_argument("--substr", default="I226", help="网卡匹配子串(自动 open 时用),默认 I226") ap.add_argument("--no-autostart", action="store_true", help="循环没跑时不要自动 open+start,直接报错退出") a = ap.parse_args() st = post(a.url, "/api/motor/state", {}) if not st.get("ok"): # 循环没跑:断电重启后这是常态(服务活着但没人 open/start)。自动补上, # 让"断电后只敲 rate_probe 一条命令"成立 —— 不必先开电机页或手敲 start。 if a.no_autostart: print("后台 PDO 循环没在跑(--no-autostart):", st.get("log")) return 1 print("循环没跑,尝试自动 open + motor/start …") try: op = post(a.url, "/api/open", {"substr": a.substr}, timeout=25.0) if not op.get("ok"): print(" open 失败:", op.get("log")) return 1 ms0 = post(a.url, "/api/motor/start", {"period_ms": 4}, timeout=40.0) if not ms0.get("ok"): # 这里最典型的失败就是 SSC 邮箱楔死(config_map 报 MailboxError), # 如实把原因打出来,别让人以为脚本坏了。 print(" motor/start 失败(多半是邮箱楔死,需断电重启板子):") for line in (ms0.get("log") or []): print(" ", line[:120]) return 1 except OSError as e: print(" 连不上服务(没起? 端口对吗?):", e) return 1 st = post(a.url, "/api/motor/state", {}) if not st.get("ok"): print(" start 后仍读不到快照:", st.get("log")) return 1 print(" 已自动进 OP 并起循环。") orig = st.get("period_ms", 4) print(f"服务 {a.url} · 当前周期 {orig}ms · 从站 wkc={st.get('wkc')}/{st.get('expected_wkc')}") print(f"{'周期':>9} {'目标Hz':>7} {'实测Hz':>8} {'达成率':>6} " f"{'cyc_us p50/p90':>15} {'HTTP p50':>8} spin 电机") for ms in [float(x) for x in a.periods.split(",")]: r = post(a.url, "/api/motor/period", {"period_ms": ms}) if not r.get("ok"): print(" 档位 %s 被拒绝: %s" % (ms, r.get("log"))) continue time.sleep(0.3) # 换周期后先让统计攒几个样本 try: hz, (p50, p90), snap = sample(a.url, a.sample) except (RuntimeError, OSError) as e: print(f" {ms}ms 档采样失败: {e}") break cyc = (snap.get("cyc_us") or {}).get("total") or {} rate = hz / (1000.0 / ms) * 100.0 label = f"{ms*1000:g}us" if ms < 1 else f"{ms:g}ms" print(f"{label:>9} {1000.0/ms:>7.0f} {hz:>8.0f} {rate:>5.0f}% " f"{str(cyc.get('p50'))+' / '+str(cyc.get('p90')):>15} " f"{p50:>6.2f}ms {str(snap.get('spin'))[0]} {len(snap.get('motors') or [])}") if snap.get("wkc") != snap.get("expected_wkc"): print(" !! wkc 不达预期,从站可能在丢帧,停止继续加压") break restore = a.restore if a.restore is not None else orig r = post(a.url, "/api/motor/period", {"period_ms": restore}) print("恢复:", r.get("log")) print("提示: 真机总线一送一回 ≈165us —— 比它快的档位达成率上不去是物理墙," "不是配置错;保底 4kHz(250us) 在预算内。") return 0 if __name__ == "__main__": sys.exit(main())