189 lines
9.2 KiB
Python
189 lines
9.2 KiB
Python
#!/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())
|