#!/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()