feat(motor): 新增 EtherCAT 周期速率扫描分析工具链,支持 sweep 脚本、指标校准、绘图与 HTML 报告生成

This commit is contained in:
cbb
2026-09-10 03:02:14 +08:00
parent 4e018af168
commit 81096d0425
20 changed files with 4544 additions and 219 deletions
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""对比「带 CAN」与「拔掉 CAN」两轮扫描,分离每拍负担的组成。
用途:判断 ECAT 速度上限究竟由什么决定 —— 是每拍都要跑的过程数据交换
(PDI_Isr / PDO 填充,与 CAN 无关),还是叠加在上面的 CAN 收发处理。
判读:两条 load 曲线的**垂直间距**就是 CAN 在每个周期档上贡献的负担。
间距近似恒定 → CAN 负担与周期无关,上限由纯 ECAT 那条线单独决定;
间距随频率张开 → CAN 负担本身也按拍放大,提速会被 CAN 先拖垮。
用法:
python scripts/plot_ecat_nocan_vs_can.py \
--nocan lab/ecat_rate_sweep_nocan.csv --withcan lab/ecat_rate_sweep_withcan.csv
"""
import argparse
import csv
import os
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib import rcParams
rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
rcParams["axes.unicode_minus"] = False
rcParams["figure.dpi"] = 130
def load(path):
out = {}
for r in csv.DictReader(open(path, encoding="utf-8")):
def num(k):
v = r.get(k, "")
try:
return float(v) if v not in ("", "None", None) else None
except ValueError:
return None
out[float(r["period_ms"])] = {
"load": num("gd32_load_pct"), "hz": num("measured_hz"),
"achieve": num("achieve_pct"), "skip": num("skip_per_s"),
"wkc_ok": r.get("wkc") == r.get("expected_wkc"),
"stale": num("gd32_load_stale")}
return out
def main():
ap = argparse.ArgumentParser(description="带 CAN vs 无 CAN 每拍负担分离")
d = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ap.add_argument("--nocan", default=os.path.join(d, "lab", "ecat_rate_sweep_nocan.csv"))
ap.add_argument("--withcan", default=os.path.join(d, "lab", "ecat_rate_sweep_withcan.csv"))
ap.add_argument("--out", default=None)
a = ap.parse_args()
no, wc = load(a.nocan), load(a.withcan)
common = sorted(set(no) & set(wc), reverse=True)
out = a.out or os.path.splitext(a.nocan)[0] + "_vs.png"
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.0, 5.2))
fig.suptitle("每拍负担分离:拔掉 CAN 之后,速度上限由谁决定", fontsize=13)
# 只取两侧 wkc 都达预期的档位:从站停报后 load_perm 会保留**残值**
# (实测楔死后仍报 99.2%),留在图里会被当成真数据、把 Δ 趋势带歪。
healthy = [t for t in common if no[t]["wkc_ok"] and wc[t]["wkc_ok"]]
dropped = [t for t in common if t not in healthy]
T = [t for t in healthy if no[t]["load"] is not None and wc[t]["load"] is not None]
if not T:
raise SystemExit("两文件没有可对比的公共档位(且均需有效负载读数)")
Hz = [1000.0 / t for t in T]
ax1.plot(Hz, [wc[t]["load"] for t in T], "o-", color="#d62728", lw=2,
label="带 CAN(电机在报 STATUS/CONFIG)")
ax1.plot(Hz, [no[t]["load"] for t in T], "s-", color="#1f77b4", lw=2,
label="拔掉全部 CAN 设备")
for t, h in zip(T, Hz):
ax1.annotate(f"Δ={wc[t]['load'] - no[t]['load']:.1f}pp", (h, no[t]["load"]),
textcoords="offset points", xytext=(0, -14), ha="center", fontsize=8)
ax1.axhline(100, ls=":", color="k", lw=1)
ax1.set_xscale("log"); ax1.set_ylim(0, 112)
ax1.set_xlabel("循环率 (Hz)"); ax1.set_ylabel("load_perm (%)")
ax1.set_title("从站每拍负担(load_perm 口径,非整机 CPU)")
ax1.legend(fontsize=8, loc="upper left"); ax1.grid(alpha=0.3)
ax2.plot(Hz, [wc[t]["skip"] for t in T], "o-", color="#d62728", lw=2, label="带 CAN")
ax2.plot(Hz, [no[t]["skip"] for t in T], "s-", color="#1f77b4", lw=2, label="无 CAN")
ax2.set_xscale("log"); ax2.set_yscale("symlog", linthresh=1)
ax2.set_xlabel("循环率 (Hz)"); ax2.set_ylabel("skip_total (次/s)")
ax2.set_title("主站错拍率:CAN 有无对节拍精度的影响")
ax2.legend(fontsize=8, loc="upper left"); ax2.grid(alpha=0.3)
fig.tight_layout(rect=(0, 0, 1, 0.94))
fig.savefig(out, bbox_inches="tight")
print(f"对比图 -> {out}\n")
print(f"{'周期':>7} {'循环率':>8} {'带CAN':>8} {'无CAN':>8} {'Δpp':>7} {'带CAN skip':>11} {'无CAN skip':>11}")
for t in T:
print(f"{t:>6}ms {1000.0/t:>7.0f}Hz {wc[t]['load']:>7.1f}% {no[t]['load']:>7.1f}% "
f"{wc[t]['load']-no[t]['load']:>6.1f} {wc[t]['skip']:>10.2f} {no[t]['skip']:>10.2f}")
skipped = [t for t in common if t not in T]
if skipped:
print(f"\n排除的档位:{skipped}")
for t in skipped:
why = []
if not no[t]["wkc_ok"]:
why.append("无CAN 侧 wkc 不达预期(从站已停报,load_perm 为残值)")
if not wc[t]["wkc_ok"]:
why.append("带CAN 侧 wkc 不达预期")
if no[t]["load"] is None or wc[t]["load"] is None:
why.append("缺有效负载读数")
print(f" {t}ms: " + "; ".join(why or ["未知"]))
if __name__ == "__main__":
main()