Files
cbb 79633a2b0f feat(motor): 自适应 PDO 窗口与通道活动指示,新增原生 SOEM 时序诊断
后端 PDO 循环改为按在线槽收缩状态窗口,稀疏总线下单槽每周期都能刷新,
并补上尾部槽的发现扫描游标;新增自旋节拍源(阈值/显式覆盖)、P 核识别
与线程级钉核,自旋期间释放 GIL 以免 HTTP 线程被饿死;启动时先关停旧实例,
避免两个进程争用 Npcap 适配器。

记录每拍完整时间线、GC stop-the-world 事件与网格跳拍数,经 /api/motor/dbg
导出用于归因固定频率的超时;配
2026-09-05 01:20:14 +08:00

157 lines
7.5 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 周期极限测速探针(真机复用工具)。
对**正在运行**的 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())