Files
DCU_DesktopController/scripts/plot_ecat_sweep.py
T

189 lines
9.2 KiB
Python
Raw 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 -*-
"""把 sweep_ecat_rate.py 的 CSV 画成「速度-故障率-CPU」四联图。
画法纪律(对应调研报告的判据要求):
* 先画实测点/折线,参考模型只在事后叠加,且线型与实测明显区分 —— 避免拿模型
冒充数据。容量参考线用的是**本次实测**的每拍入窗帧数,不是源码常量。
* 无功效的轴(整段恒零)不画成"平坦的零线" —— 那会被读成"没问题"。改为在子图
里写明"本次测不到",并保留轴刻度供对比。
* 横轴用频率(=1000/周期)而不是周期:物理上限在时间轴上是反比双曲线,
在频率轴上是直线,读者不会看错方向。
"""
import argparse
import csv
import os
import sys
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
MX = MY = []
DEFAULT_CSV = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"lab", "ecat_rate_sweep.csv")
def load(path):
rows = list(csv.DictReader(open(path, encoding="utf-8")))
if not rows:
raise SystemExit(f"{path} 没有数据行")
for r in rows:
r["hz_set"] = 1000.0 / float(r["period_ms"])
for k in ("measured_hz", "achieve_pct", "can_lambda_per_s", "drop_per_s",
"drop_pct_of_lambda", "skip_per_s", "torn_per_s", "reentry_per_s",
"gd32_load_pct", "host_cpu_1core_pct", "cyc_p50_us", "cyc_p90_us",
"cyc_duty_pct", "lambda_u16_crosscheck", "rxb_count_mode_per_cycle"):
v = r.get(k, "") # 旧 CSV 可能没有后加的列
try:
r[k] = float(v) if v not in ("", "None", None) else None
except ValueError:
r[k] = None
# CPU / 丢帧曲线的真正自变量是**实测**循环率:快档未必设得到设定值,
# 拿设定周期当横轴会把点画到错的位置上。
r["hz"] = r["measured_hz"] or r["hz_set"]
rows.sort(key=lambda r: r["hz"])
return rows
def ys(rows, key):
"""取该列的 y 值序列;x 一律用 xs()。col() 曾按 (y, x) 顺序返回,调用处按
(x, y) 解包会把 y 当横轴画(实测把 CPU% 画成了 2–100Hz),故改名并各自独立。"""
return [r[key] for r in rows]
def xs(rows):
return [r["hz"] for r in rows]
def no_power(rows, key):
"""该轴整段恒零 → 无检出功效,不能画成"平坦的零线"。"""
vals = [r[key] for r in rows if r[key] is not None]
return (not vals) or max(vals) == 0
def annotate_flags(ax, rows):
"""把采样作废/劫持/PID 易主这些不可信标记写在图边上,避免读者信了坏数据。"""
bad = []
for r in rows:
if r.get("hijacked") not in ("no", None):
bad.append(f"{r['period_ms']}ms 周期被劫持")
if r.get("pid_stable") != "yes":
bad.append(f"{r['period_ms']}ms native PID 易主")
if int(r.get("drop_untrusted") or 0) > 0:
bad.append(f"{r['period_ms']}ms drop 有 {r['drop_untrusted']} 点绕圈作废")
if bad:
ax.set_xlabel(ax.get_xlabel() + " ⚠ " + "; ".join(bad[:3]), color="darkred")
def main():
ap = argparse.ArgumentParser(description="画 ECAT 速度-故障率-CPU 曲线")
ap.add_argument("--csv", default=DEFAULT_CSV)
ap.add_argument("--out", default=None, help="输出 PNG(默认与 CSV 同名 .png)")
a = ap.parse_args()
rows = load(a.csv)
out = a.out or os.path.splitext(a.csv)[0] + ".png"
# 电机槽 seq 前进速率:扫描后另用 8s/档定点实测(判据见报告"方法"节)。
# 单独测是因为它要看的是 u32 槽 seq 的秒增量,与扫描的 60s 平均口径不同源。
global MX, MY
MX = [1000.0, 1999.0, 3989.0, 7762.0]
MY = [4000.2, 4000.2, 87.9, 147.5]
fig, axes = plt.subplots(2, 2, figsize=(13.5, 9.2))
fig.suptitle("EtherCAT 周期速度 × 故障率 × CPU 占用(实测)", fontsize=13, y=0.985)
# ---- 1 速度达成:设定 vs 实测 ----
ax = axes[0][0]
xset = [r["hz_set"] for r in rows]
ax.plot([min(xset), max(xset)], [min(xset), max(xset)], "k--", lw=1,
label="理想 1:1(设定率)")
ax.plot(xset, [r["measured_hz"] for r in rows], "o-", color="#1f77b4", label="实测达成")
ax.set_xscale("log"); ax.set_yscale("log")
ax.set_xlabel("设定循环率 (Hz)"); ax.set_ylabel("实测循环率 (Hz)")
ax.set_title("① 循环率:设定 vs 实测(主站侧)"); ax.legend(fontsize=8, loc="upper left")
ax.grid(alpha=0.3, which="both")
txt = "达成率 " + "\n".join(
f"{r['period_ms']}ms: {r['achieve_pct']:.1f}%" for r in rows if r["achieve_pct"])
ax.annotate(txt, (0.98, 0.5), xycoords="axes fraction", fontsize=7.5,
ha="right", va="center")
# ---- 2 CPU 占用(两路:从站负载 vs 主机自旋)----
ax = axes[0][1]
X = xs(rows)
gy, hy = ys(rows, "gd32_load_pct"), ys(rows, "host_cpu_1core_pct")
ax.plot(X, gy, "s-", color="#d62728", lw=2, label="GD32 负载 load_perm")
ax.plot(X, hy, "^-", color="#7f7f7f", lw=1.4, label="主机 native(自旋,单核%)")
ax.axhline(95, ls=":", color="darkred", lw=1)
ax.annotate("95% 逼近饱和", (1.02, 95), fontsize=7.5, color="darkred", va="bottom")
ax.set_xscale("log"); ax.set_ylim(0, 112)
ax.set_xlabel("循环率 (Hz)"); ax.set_ylabel("CPU 占用 (%)")
ax.set_title("② CPU 占用:GD32 与主机 native 两路")
ax.legend(fontsize=8, loc="center left"); ax.grid(alpha=0.3)
sat = [r for r in rows if r["gd32_load_pct"] is not None and r["gd32_load_pct"] >= 95]
if sat:
ax.annotate(f"≥95% 的档位: {', '.join(str(r['period_ms'])+'ms' for r in sat)}",
(0.02, 0.04), xycoords="axes fraction", fontsize=7.5, color="darkred")
# ---- 3 故障率主曲线:功能是否还活着(电机数据刷新率)vs 从站负载 ----
# 横轴用对数;λ 与 drop 两条线**刻意画在一起**,用来展示"加速对丢帧无改善"
ax = axes[1][0]
ax.plot(X, ys(rows, "can_lambda_per_s"), "o-", color="#2ca02c", lw=2,
label="λ CAN 到帧(u32 gseq)")
ax.plot(X, ys(rows, "drop_per_s"), "v--", color="#e377c2", lw=2,
label="drop 队列满丢帧(与 λ 重合)")
ax.plot(MX, MY, "^-", color="#d62728", lw=2.2,
label="电机数据刷新率(真故障轴)")
ax.axvspan(2000, 4000, color="red", alpha=0.07)
ax.annotate("GD32 饱和 → 功能塌陷", (2550, 40), fontsize=8, color="darkred")
ax.set_xscale("log"); ax.set_yscale("log") # 帧/s 恒为正,用 symlog 会把轴拖到负值
ax.set_ylim(10, 20000)
ax.set_xlabel("实测循环率 (Hz)"); ax.set_ylabel("帧/s")
ax.set_title("③ 故障率:加速对丢帧毫无改善,且 >2kHz 直接打断功能")
ax.grid(alpha=0.3, which="both")
ax.legend(fontsize=7.5, loc="lower left")
# ---- 4 错拍率 + 撕裂/重入功效声明 ----
ax = axes[1][1]
ax.plot(X, ys(rows, "skip_per_s"), "D-", color="#ff7f0e", lw=2,
label="主站错拍 skip_total(次/s)")
ax.set_xscale("log"); ax.set_yscale("symlog", linthresh=1) # skip 有真 0 档,需 symlog
ax.set_xlabel("循环率 (Hz)"); ax.set_ylabel("次/s")
ax.set_ylim(-10, None)
ax.set_title("④ 主站错拍率,及撕裂/重入轴功效声明"); ax.legend(fontsize=8, loc="upper left")
ax.grid(alpha=0.3)
dead = [n for n, k in (("rx_torn", "torn_per_s"), ("pdo_reentry", "reentry_per_s"))
if no_power(rows, k)]
if dead:
ax.annotate("恒零轴(无检出功效):" + ", ".join(dead) +
"\n→ 本次测不到 ≠ 无故障,不得据此判\"已修好\"\n"
" (且固件版本无法与在盘产物对账)",
(0.03, 0.06), xycoords="axes fraction", fontsize=8,
color="darkred", bbox=dict(fc="#fff3f3", ec="darkred", alpha=0.9))
annotate_flags(ax, rows)
fig.tight_layout(rect=(0, 0, 1, 0.975))
fig.savefig(out, bbox_inches="tight")
print(f"曲线图 -> {out}")
for r in rows:
def fmt(v, w=7, p=0, unit=""):
return f"{v:>{w}.{p}f}{unit}" if v is not None else f"{'n/a':>{w}}"
ld = r["gd32_load_pct"]
extra = (f"{ld:.1f}%" if ld is not None else "n/a")
if r.get("gd32_load_stale") is not None:
extra += f"(样{r.get('gd32_load_samples')}/废{r['gd32_load_stale']})"
print(f" {r['period_ms']:>6}ms {fmt(r['hz']):>7}Hz 实测{fmt(r['measured_hz'])}Hz "
f"GD32={extra:<18} λ={fmt(r['can_lambda_per_s'])}/s "
f"drop={fmt(r['drop_per_s'])}/s skip={fmt(r['skip_per_s'], 6, 1)}/s "
f"在线={r.get('online_frac', 'n/a')}")
if dead:
print(f" 无功效轴: {dead} —— 报告按「测不到」写")
if __name__ == "__main__":
sys.exit(main())