Files
DCU_DesktopController/scripts/build_ecat_report_html.py
T

678 lines
40 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把扫描数据渲染成自包含 HTML 调研报告(图表为内联 SVG)。
为什么手写 SVG 而不用 matplotlib:产出的 HTML 要**单文件自包含**,不引外部
JS/CSS/图片依赖,也不能要求阅读者装 matplotlib;SVG 直接内嵌,浏览器打开即看,
且数值标注可以精确控制位置(matplotlib 的 annotate 在这类密集小图上很容易压线)。
纪律同 plot_ecat_sweep.py:
* 图里画的**只有实测点**。模型/参考一律用虚线并写明"外推",不与实测混同。
* 无功效的轴(整段恒零)不画成"平坦的零线",改成功效声明块。
* 从站停报的档位(wkc 不达预期 / load_perm 为残值)一律排除出图,并在页面上
显式列出被排除的档位与原因 —— 空图比假图诚实。
用法:
python scripts/build_ecat_report_html.py
python scripts/build_ecat_report_html.py --out ECAT_RATE_TRADEOFF.html
"""
import argparse
import html
import json
import math
import os
import sys
from collections import OrderedDict
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
from ecat_sweep_common import post # noqa: E402 (仅用于探测 native 是否在跑)
import csv as _csv
# ───────────────────────────────────────────────────────── 数据读取 ─────────────────────────────────────────────────────────
def read_csv(path):
if not os.path.isfile(path):
return []
rows = []
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
rows.append({
"period_ms": float(r["period_ms"]),
"hz_set": 1000.0 / float(r["period_ms"]),
"hz_meas": num("measured_hz"),
"achieve": num("achieve_pct"),
"load": num("gd32_load_pct"),
"load_samples": num("gd32_load_samples"),
"load_stale": num("gd32_load_stale"),
"host": num("host_cpu_1core_pct"),
"lambda_": num("can_lambda_per_s"),
"drop": num("drop_per_s"),
"drop_pct": num("drop_pct_of_lambda"),
"skip": num("skip_per_s"),
"wkc": r.get("wkc"), "exp_wkc": r.get("expected_wkc"),
"wr": r.get("wr"), "hijacked": r.get("hijacked"),
"pid_stable": r.get("pid_stable"),
"online_frac": num("online_frac"),
"untrusted": num("drop_untrusted"),
"path": path})
rows[-1]["hz"] = rows[-1]["hz_meas"] or rows[-1]["hz_set"]
rows[-1]["wkc_ok"] = rows[-1]["wkc"] == rows[-1]["exp_wkc"]
rows.sort(key=lambda r: r["hz"])
return rows
# ───────────────────────────────────────────────────────── SVG 图元 ─────────────────────────────────────────────────────────
def _ticks(lo, hi, n=5):
"""线性刻度:给一组整齐步进的刻度值。"""
if hi <= lo:
return [lo]
raw = (hi - lo) / max(1, n - 1)
mag = 10 ** math.floor(math.log10(raw)) if raw else 1
step = mag * next((k for k in (1, 2, 2.5, 5, 10) if raw / mag <= k), 10)
v = math.ceil(lo / step) * step
out = []
while v <= hi + 1e-9:
out.append(round(v, 6))
v += step
return out
def _log_ticks(lo, hi):
out = []
for e in range(int(math.floor(math.log10(lo))), int(math.ceil(math.log10(hi))) + 1):
for m in (1, 2, 5):
v = m * (10 ** e)
if lo - 1e-9 <= v <= hi + 1e-9:
out.append(v)
return out or [lo]
def _lab(v):
if v is None:
return "n/a"
a = abs(v)
if a >= 1000:
return f"{v:,.0f}"
if a >= 100:
return f"{v:.0f}"
if a >= 10:
return f"{v:.1f}"
if a >= 1:
return f"{v:.1f}" if v != int(v) else str(int(v))
return f"{v:.2f}" if a else "0"
class Svg:
"""极简 SVG 累加器。"""
def __init__(self, w, h):
self.w, self.h, self.b = w, h, []
self.L, self.R = 64, w - 16
self.T, self.B = 26, h - 42
def scale(self, xlog=False, ylog=False, xr=None, yr=None):
self.xlog, self.ylog = xlog, ylog
self.xr, self.yr = xr, yr
def X(self, v):
if self.xlog:
lo, hi = self.xr
return self.L + (math.log10(max(v, 1e-9)) - math.log10(lo)) / \
(math.log10(hi) - math.log10(lo)) * (self.R - self.L)
lo, hi = self.xr
return self.L + (v - lo) / (hi - lo or 1) * (self.R - self.L)
def Y(self, v):
if self.ylog:
lo, hi = self.yr
return self.B - (math.log10(max(v, 1e-9)) - math.log10(lo)) / \
(math.log10(hi) - math.log10(lo)) * (self.B - self.T)
lo, hi = self.yr
return self.B - (v - lo) / (hi - lo or 1) * (self.B - self.T)
def grid(self, xt, yt, xf=_lab, yf=_lab):
for v in yt:
try:
y = self.Y(v)
except ValueError:
continue
if not (self.T - 1 <= y <= self.B + 1):
continue
self.b.append(f'<line x1="{self.L}" y1="{y:.1f}" x2="{self.R}" y2="{y:.1f}" '
f'stroke="#e8ebf0" stroke-width="1"/>')
self.b.append(f'<text x="{self.L-8}" y="{y+3.5:.1f}" text-anchor="end" '
f'font-size="10.5" fill="#6b7280">{html.escape(yf(v))}</text>')
for v in xt:
x = self.X(v)
if not (self.L - 1 <= x <= self.R + 1):
continue
self.b.append(f'<line x1="{x:.1f}" y1="{self.T}" x2="{x:.1f}" y2="{self.B}" '
f'stroke="#eef1f5" stroke-width="1"/>')
self.b.append(f'<text x="{x:.1f}" y="{self.B+16}" text-anchor="middle" '
f'font-size="10.5" fill="#6b7280">{html.escape(xf(v))}</text>')
def axes(self, xl, yl):
self.b.append(f'<line x1="{self.L}" y1="{self.B}" x2="{self.R}" y2="{self.B}" '
f'stroke="#9aa3af" stroke-width="1.2"/>')
self.b.append(f'<line x1="{self.L}" y1="{self.T}" x2="{self.L}" y2="{self.B}" '
f'stroke="#9aa3af" stroke-width="1.2"/>')
self.b.append(f'<text x="{(self.L+self.R)/2:.0f}" y="{self.h-6}" text-anchor="middle" '
f'font-size="11.5" fill="#374151">{html.escape(xl)}</text>')
self.b.append(f'<text transform="rotate(-90 14 {(self.T+self.B)/2:.0f})" x="14" '
f'y="{(self.T+self.B)/2:.0f}" text-anchor="middle" font-size="11.5" '
f'fill="#374151">{html.escape(yl)}</text>')
def line(self, pts, color, dash=None, wid=2.4, marker="circle", r=4.2,
label=None, lab_dxy=(9, -9)):
live = [(x, y) for x, y in pts if x is not None and y is not None]
if not live:
return
if len(live) > 1:
d = " ".join(f"{'M' if i == 0 else 'L'}{self.X(x):.1f},{self.Y(y):.1f}"
for i, (x, y) in enumerate(live))
da = f' stroke-dasharray="{dash}"' if dash else ""
self.b.append(f'<path d="{d}" fill="none" stroke="{color}" '
f'stroke-width="{wid}"{da} stroke-linejoin="round"/>')
for i, (x, y) in enumerate(live):
cx, cy = self.X(x), self.Y(y)
if marker == "circle":
self.b.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r}" fill="{color}" '
f'stroke="#fff" stroke-width="1.4"/>')
elif marker == "square":
self.b.append(f'<rect x="{cx-r:.1f}" y="{cy-r:.1f}" width="{2*r:.1f}" '
f'height="{2*r:.1f}" fill="{color}" stroke="#fff" '
f'stroke-width="1.4"/>')
elif marker == "tri":
p = f"{cx:.1f},{cy-r-1:.1f} {cx-r-1:.1f},{cy+r:.1f} {cx+r+1:.1f},{cy+r:.1f}"
self.b.append(f'<polygon points="{p}" fill="{color}" stroke="#fff" '
f'stroke-width="1.4"/>')
if label and i == len(live) - 1:
dx, dy = lab_dxy
self.b.append(f'<text x="{cx+dx:.1f}" y="{cy+dy:.1f}" font-size="10.5" '
f'fill="{color}" font-weight="600">{html.escape(label)}</text>')
def vtext(self, x, y, txt, color="#374151", anchor="middle", size=10.5, weight="400"):
self.b.append(f'<text x="{self.X(x):.1f}" y="{self.Y(y):.1f}" text-anchor="{anchor}" '
f'font-size="{size}" font-weight="{weight}" '
f'fill="{color}">{html.escape(txt)}</text>')
def legend(self, entries, x=None, y=None):
x = self.L + 10 if x is None else x
y = self.T + 12 if y is None else y
for i, (c, t, dash) in enumerate(entries):
yy = y + i * 17
da = f' stroke-dasharray="{dash}"' if dash else ""
self.b.append(f'<line x1="{x}" y1="{yy}" x2="{x+22}" y2="{yy}" stroke="{c}" '
f'stroke-width="2.6"{da}/>')
self.b.append(f'<circle cx="{x+11}" cy="{yy}" r="3.2" fill="{c}"/>')
self.b.append(f'<text x="{x+29}" y="{yy+3.6}" font-size="11" '
f'fill="#374151">{html.escape(t)}</text>')
def hline(self, v, color="#9ca3af", dash="3,3", wid=1.2):
y = self.Y(v)
if not (self.T - 1 <= y <= self.B + 1):
return
self.b.append(f'<line x1="{self.L}" y1="{y:.1f}" x2="{self.R}" y2="{y:.1f}" '
f'stroke="{color}" stroke-width="{wid}" stroke-dasharray="{dash}"/>')
def band(self, x0, x1, color="#ef4444", alpha=0.07):
a, b = self.X(x0), self.X(x1)
self.b.append(f'<rect x="{min(a,b):.1f}" y="{self.T}" width="{abs(b-a):.1f}" '
f'height="{self.B-self.T}" fill="{color}" opacity="{alpha}"/>')
def note(self, txt, x=None, y=None, color="#6b7280", anchor="start", size=10.5):
x = self.L + 6 if x is None else x
y = self.B - 8 if y is None else y
self.b.append(f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="{size}" '
f'fill="{color}">{html.escape(txt)}</text>')
def finish(self, title, sub=""):
head = ""
if sub:
head = f'<text x="{self.L-40}" y="15" font-size="10.5" fill="#6b7280">' \
f'{html.escape(sub)}</text>'
return (f'<svg viewBox="0 0 {self.w} {self.h}" width="100%" '
f'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="{html.escape(title)}">'
f'{head}{"".join(self.b)}</svg>')
# ───────────────────────────────────────────────────────── 具体图 ─────────────────────────────────────────────────────────
C_RED, C_BLUE, C_GREEN, C_PINK, C_ORANGE, C_GREY, C_PURPLE = (
"#dc2626", "#2563eb", "#16a34a", "#db2777", "#ea580c", "#6b7280", "#7c3aed")
def chart_achieve(rows, rows_no=()):
s = Svg(560, 320)
allr = list(rows) + list(rows_no)
lo = min(r["hz_set"] for r in allr) * 0.7
hi = max(r["hz_set"] for r in allr) * 1.5
s.scale(True, True, (lo, hi), (lo, hi))
s.grid(_log_ticks(lo, hi), _log_ticks(lo, hi))
s.line([(lo, lo), (hi, hi)], C_GREY, dash="5,4", label=None)
s.line([(r["hz_set"], r["hz_meas"]) for r in rows], C_RED, marker="circle")
s.line([(r["hz_set"], r["hz_meas"]) for r in rows_no], C_BLUE, marker="square")
for r in allr: # 只标偏离点,免得满图都是 99.x%
if (r["achieve"] or 100) < 99.5:
s.vtext(r["hz_set"], r["hz_meas"] * 0.72,
_lab(r["achieve"]) + "%", color="#1e40af", size=9.5, anchor="middle")
s.legend([(C_GREY, "理想 1:1(设定率)", "5,4"),
(C_RED, "实测循环率 · 带 CAN", None),
(C_BLUE, "实测循环率 · 无 CAN", None)], x=s.L + 12, y=s.T + 46)
s.note("标注为达成率%(<99.5% 才标)。无 CAN 设定 >8kHz 后实测全顶在 ≈8.2kHz —— 硬上限",
y=s.B - 8)
s.axes("设定循环率 (Hz,对数)", "实测循环率 (Hz,对数)")
return s.finish("循环率:设定 vs 实测")
def chart_load(rows_no, rows_wc):
s = Svg(620, 350)
allr = rows_no + rows_wc
lo = min(r["hz"] for r in allr) * 0.75
hi = max(r["hz"] for r in allr) * 1.35
s.scale(True, False, (lo, hi), (0, 112))
s.grid(_log_ticks(lo, hi), _ticks(0, 110, 6))
s.hline(100, color="#6b7280", dash="2,4")
s.b.append(f'<text x="{s.R-4}" y="{s.Y(100)-5:.1f}" text-anchor="end" font-size="10" '
f'fill="#6b7280">100%</text>')
pts_wc = [(r["hz"], r["load"]) for r in rows_wc if r["load"] is not None]
pts_no = [(r["hz"], r["load"]) for r in rows_no if r["load"] is not None]
s.line(pts_wc, C_RED, marker="circle")
s.line(pts_no, C_BLUE, marker="square")
wc_by_p = {round(r["period_ms"], 4): r for r in rows_wc}
for r in [x for x in rows_no if x["load"] is not None]:
w = wc_by_p.get(round(r["period_ms"], 4))
if w and w["load"] is not None:
# 任一侧饱和(≥95%)时 Δ 只反映夹紧,不标注
if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95:
continue
s.vtext(r["hz"], (r["load"] + w["load"]) / 2, f"Δ{w['load']-r['load']:.1f}pp",
color=C_PURPLE, size=10, weight="600")
s.legend([(C_RED, "带 CAN(电机在报 STATUS/CONFIG)", None),
(C_BLUE, "拔掉全部 CAN 设备", None)], x=s.L + 12, y=s.T + 12)
s.note("纵轴是固件 load_perm(只计 ISR+非延迟段,排除 SSC 轮询)—— 不是整机 CPU 占用率",
y=s.B - 8)
s.note("无 CAN 补测:0.25/0.125ms 满速;设定再快实测全顶在 ≈8.2kHz。"
"≥4kHz 的负载读数全是饱和值,不能定位拐点", y=s.B - 22, color="#b45309")
s.note("带 CAN 的 0.25/0.125ms 两点同理停在 ~99.x% —— 饱和读数不等于\"已测到上限\"",
y=s.B - 36, color="#b45309")
s.axes("实测循环率 (Hz,对数)", "从站 load_perm (%)")
return s.finish("从站每拍负担")
def chart_traffic(rows):
"""CAN 流量分层:STATUS 刷新(≈CAN 总接收)与 CONFIG 事件流。
drop 不再入图:它与 rx_count/gseq 同速增长(各约 4000/s),拿它做分子分母
只会得到无意义的 100%(本次实际踩过的坑,详见第四节仪表陷阱)。
"""
s = Svg(620, 350)
ok = [r for r in rows if (r["lambda_"] or 0) > 0]
if not ok:
s.note("无可用的 CAN 流量档位(本轮 CAN 已拔除)", y=(s.T + s.B) / 2, anchor="middle")
return s.finish("CAN 流量分层", "(无数据)")
lo = min(r["hz"] for r in ok) * 0.75
hi = max(r["hz"] for r in ok) * 1.35
s.scale(True, True, (lo, hi), (10, 20000))
s.grid(_log_ticks(lo, hi), _log_ticks(10, 20000))
s.line([(r["hz"], r["lambda_"]) for r in ok], C_GREEN, marker="circle")
s.band(2500, 9000)
s.vtext(1400, 4000 * 1.35, "带 CAN 轮:STATUS 全流量", color="#166534", size=10)
s.vtext(4200, 150, "塌陷", color="#b91c1c", size=11, weight="700")
s.legend([(C_GREEN, "CAN 总接收 rx_count ≡ STATUS 刷新 gseq(实测同为 ~4000/s)", None)],
x=s.L + 12, y=s.T + 12)
s.note("CONFIG 事件流独立实测仅约 92 帧/s(去重后),不随周期放大", y=s.B - 8)
s.note(">2kHz 处 STATUS 与 CONFIG 一同塌到 ~150/s", y=s.B - 22, color="#b45309")
s.axes("实测循环率 (Hz,对数)", "帧/s(对数)")
return s.finish("CAN 流量分层")
def chart_skip(rows_no, rows_wc):
"""skip 有真 0 档 → 对数轴画不了。做法:>0 的点画在对数区,真 0/近 0 档
落到轴下方的"≈0"标记轨道,两种点含义不同、不混在同一条线上。"""
s = Svg(560, 320)
pos = [r for r in rows_no + rows_wc if (r["skip"] or 0) > 0.05]
zero = [r for r in rows_no + rows_wc if (r["skip"] or 0) <= 0.05]
if not pos:
s.note("本轮无 skip>0 的档位", y=(s.T + s.B) / 2, anchor="middle")
return s.finish("主站错拍率", "(无数据)")
lo = min(r["hz"] for r in pos) * 0.75
hi = max(r["hz"] for r in pos) * 1.35
vlo, vhi = max(min(r["skip"] for r in pos), 0.05), max(r["skip"] for r in pos) * 3
s.T -= 6
s.scale(True, True, (lo, hi), (vlo, vhi))
s.grid(_log_ticks(lo, hi), _log_ticks(vlo, vhi))
s.line([(r["hz"], r["skip"]) for r in rows_wc if (r["skip"] or 0) > 0.05],
C_RED, marker="circle")
s.line([(r["hz"], r["skip"]) for r in rows_no if (r["skip"] or 0) > 0.05],
C_BLUE, marker="square")
for r in zero: # ≈0 轨道:贴在轴下方,明确不属于对数刻度
y = s.B + 4
mk = ("circle" if r in rows_wc else "square")
col = C_RED if r in rows_wc else C_BLUE
cx = s.X(r["hz"])
s.b.append(f'<path d="M{cx-4:.1f},{y+8:.1f} L{cx:.1f},{y:.1f} '
f'L{cx+4:.1f},{y+8:.1f}" fill="none" stroke="{col}" stroke-width="2"/>')
ent = [(C_RED, "带 CAN", None), (C_BLUE, "无 CAN", None)]
if zero: # 没有真 0 档时不要挂一条对不上号的图例
ent.append(("#9ca3af", "▽ = skip ≤0.05 次/s(真 0 档,不在对数刻度上)", None))
s.legend(ent, x=s.L + 12, y=s.T + 12)
s.note("skip=错过的网格拍。0.0625ms 设定档 15720 次/s:实测率被钉在 8.2kHz,"
"错拍数随设定率线性涨", y=s.B - 10)
s.axes("实测循环率 (Hz,对数)", "主站错拍 skip_total (次/s,对数)")
return s.finish("主站错拍率")
# ───────────────────────────────────────────────────────── 页面组装 ─────────────────────────────────────────────────────────
PAGE = """<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>EtherCAT 周期速度 × 故障率 × CPU 占用 调研</title>
<style>
:root{{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c}}
*{{box-sizing:border-box}}
body{{margin:0;background:#fff;color:var(--fg);
font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}}
main{{max-width:1120px;margin:0 auto;padding:40px 28px 90px}}
h1{{font-size:29px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}}
h2{{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:2px solid var(--bd)}}
h3{{font-size:16px;margin:26px 0 8px;color:#374151}}
p,li{{margin:8px 0}}
.meta{{color:var(--mut);font-size:13.5px;margin-bottom:26px}}
.card{{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);
border-radius:8px;padding:16px 20px;margin:18px 0}}
.card.warn{{border-left-color:var(--warn);background:#fffbeb}}
.card.bad{{border-left-color:var(--bad);background:#fef2f2}}
.card h3{{margin:0 0 8px;font-size:15px;color:inherit}}
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:18px;margin:18px 0}}
.fig{{border:1px solid var(--bd);border-radius:8px;padding:12px 10px 8px;background:#fff}}
.fig > .cap{{font-size:12.5px;font-weight:600;color:#374151;margin:0 0 4px 8px}}
.fig > .sub{{font-size:11.5px;color:var(--mut);margin:0 0 4px 8px}}
table{{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}}
th,td{{border:1px solid var(--bd);padding:6px 9px;text-align:right}}
th{{background:#f1f5f9;font-weight:600;text-align:center}}
td:first-child,th:first-child{{text-align:left}}
tr.best td{{background:#ecfdf5;font-weight:600}}
tr.dead td{{background:#fef2f2;color:#7f1d1d}}
code{{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;
font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}}
.kpi{{display:flex;gap:14px;flex-wrap:wrap;margin:22px 0 8px}}
.kpi div{{flex:1 1 168px;border:1px solid var(--bd);border-radius:8px;padding:13px 15px;background:#fff}}
.kpi b{{display:block;font-size:23px;line-height:1.25;color:var(--acc)}}
.kpi span{{font-size:12px;color:var(--mut)}}
ul{{padding-left:22px}}
.excl{{font-size:12.5px;color:var(--bad);background:#fef2f2;border:1px dashed #fca5a5;
border-radius:6px;padding:9px 13px;margin:12px 0}}
.foot{{margin-top:44px;padding-top:16px;border-top:1px solid var(--bd);
font-size:12.5px;color:var(--mut)}}
</style></head><body><main>
{body}
</main></body></html>
"""
def tbl(rows, cols, cap):
h = [f'<h3>{html.escape(cap)}</h3><table><tr>' +
"".join(f"<th>{html.escape(t)}</th>" for t, _ in cols) + "</tr>"]
for r in rows:
cls = ""
if r.get("wkc_ok") is False:
cls = ' class="dead"'
elif r["period_ms"] == 0.5:
cls = ' class="best"'
h.append(f"<tr{cls}>" + "".join(f"<td>{f(r)}</td>" for _, f in cols) + "</tr>")
h.append("</table>")
return "".join(h)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--nocan", default=os.path.join(ROOT, "lab", "ecat_rate_sweep_nocan.csv"))
ap.add_argument("--withcan", default=os.path.join(ROOT, "lab", "ecat_rate_sweep_withcan.csv"))
ap.add_argument("--calib", default=os.path.join(ROOT, "lab", "ecat_calib.json"))
ap.add_argument("--out", default=os.path.join(ROOT, "ECAT_RATE_TRADEOFF.html"))
a = ap.parse_args()
no, wc = read_csv(a.nocan), read_csv(a.withcan)
if not wc:
raise SystemExit(f"缺带 CAN 数据:{a.withcan}")
cal = json.load(open(a.calib, encoding="utf-8")) if os.path.isfile(a.calib) else {}
no_ok = [r for r in no if r["wkc_ok"] and r["load"] is not None]
wc_ok = [r for r in wc if r["wkc_ok"] and r["load"] is not None]
excl = [r for r in no if not r["wkc_ok"] or r["load"] is None]
# 电机刷新率:带 CAN 那轮的 8s/档定点实测。(周期文本, 实测Hz, 刷新率帧/s)
# 周期直接带文本 —— 由 hz 反算会浮点漂移(1000/1999 → 显示成 0.50025ms)。
motor = [("1", 1000.0, 4000.2), ("0.5", 1999.0, 4000.2),
("0.25", 3989.0, 87.9), ("0.125", 7762.0, 147.5)]
# 跨轮配对必须用**设定周期**做键:两轮的实测 hz 会差零点几(1999.3 vs 1999.6),
# 拿四舍五入的 hz 当键会把 0.5ms 档静默丢掉,KPI 均值就跟着算错。
wc_by_period = {round(r["period_ms"], 4): r for r in wc_ok}
deltas, unm, sat = [], [], []
for r in no_ok:
w = wc_by_period.get(round(r["period_ms"], 4))
if w:
# 任一侧 load 已饱和(≥95%)时 Δ 只反映夹紧,不代表 CAN 负担
if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95:
sat.append(r["period_ms"])
else:
deltas.append(w["load"] - r["load"])
else:
unm.append(r["period_ms"])
if unm:
print("!! 无 CAN 档位在带 CAN 轮中找不到配对,已从 Δ 中排除:", unm)
if sat:
print(" 饱和档(Δ 不计入):", sat)
b = [f'<h1>EtherCAT 周期速度 × 故障率 × CPU 占用 调研</h1>',
f'<div class="meta">调研日期 {html.escape(str(cal.get("started_at","2026-09-08"))[:10])}'
f' · 本机 I210 + 1 个 GD32E508 从站 · 两轮扫描:带 CAN / 拔掉全部 CAN'
f' · 数据 <code>lab/ecat_rate_sweep_*.csv</code> · 生成器 '
f'<code>scripts/build_ecat_report_html.py</code></div>']
b.append('<div class="kpi">'
'<div><b>0.5ms</b><span>实测可用上限(2kHz,带 CAN 工况)</span></div>'
f'<div><b>≈{(sum(deltas)/len(deltas) if deltas else 0):.1f}pp</b>'
f'<span>CAN 每拍恒定负担({len(deltas)} 档配对,饱和档除外)</span></div>'
'<div><b>4000 帧/s</b><span>电机 STATUS 实测刷新(1/0.5ms)</span></div>'
'<div><b>≈8.2kHz</b><span>纯 ECAT 实测硬上限(设 10/12.5/16kHz 实测均不涨)</span></div></div>')
b.append('<div class="card"><h3>结论</h3><ul>'
'<li><b>带 CAN 工况的可用上限在 0.5ms,瓶颈在从站 GD32 不在主站。</b>'
'实测可用到 0.5ms;0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s'
'(带 CAN 那轮当场确认 <code>online=True</code>,是真失效)。'
'可用上限只夹在 (0.25ms, 0.5ms] 内,<b>具体位置没测到</b>。</li>'
'<li><b>纯 ECAT 硬上限 ≈8.2kHz,这次测到了墙本身。</b>断电重启后无 CAN 补测:'
'0.25/0.125ms 满速通过(99.7% / 98.0%);再往下设 0.1 / 0.08 / 0.0625ms,'
'实测循环率全部顶在 8196–8238Hz(达成 82.3% / 65.9% / 51.2%),'
'wkc 全程 3/3、从站始终应答。墙的来历:主站单拍硬耗时 <code>cyc p50</code> '
'各档恒 117–126µs,1/121µs ≈ 8.2kHz —— 是"每拍必须花的往返+处理时间",'
'不是主机 CPU 不够(顶墙时主机 CPU 反而降到 ~85%)。原「3.5kHz 外推」'
'<b>被证伪</b>;首轮无 CAN 0.25ms 停报为残状态偶发,不复现。'
'注意"跑得快"≠"能用":带 CAN 的可用上限仍由电机数据刷新决定(下一条)。</li>'
'<li><b>拔 CAN 对照解开了混淆</b>:未饱和三档 Δ 稳定 8.7–9.3pp,'
'不随频率张开 → 快档塌陷不是 CAN 挤出来的;饱和两档 Δ 收窄到 ≤0.5pp,'
'正是读数被夹紧的特征。</li>'
'<li><b>主机 native CPU 恒约 100% 单核、与周期无关</b>(自旋恒转,'
'<code>/api/motor/spin</code> 已是空操作)。唯一的例外是带 CAN 轮的 '
'0.125ms 档降到 87.5% —— 因为从站已停答。无 CAN 补测里从站活着,'
'同档就回到 99.7%。<b>主机 CPU 掉下来那一刻,正是从站死掉那一刻。</b></li>'
'<li>建议:<b>要吃到余量就停在 0.5ms</b>。0.25ms 不是"更快",是功能损坏。</li>'
'</ul></div>')
b.append('<h2>一、曲线</h2>')
b.append('<div class="grid">')
b.append(f'<div class="fig"><div class="cap">① 循环率:设定 vs 实测(主站侧)</div>'
f'{chart_achieve(wc_ok, no_ok)}</div>')
b.append(f'<div class="fig"><div class="cap">② 从站每拍负担:带 CAN vs 无 CAN</div>'
f'{chart_load(no_ok, wc_ok)}</div>')
b.append(f'<div class="fig"><div class="cap">③ CAN 流量分层:STATUS 刷新与 CONFIG 事件流</div>'
f'{chart_traffic(wc_ok)}</div>')
b.append(f'<div class="fig"><div class="cap">④ 主站错拍率</div>'
f'{chart_skip(no_ok, wc_ok)}</div>')
b.append('</div>')
if excl:
b.append('<div class="excl">已从图中排除的档位:' + ";".join(
f"{r['period_ms']}ms({'wkc 不达预期,从站停报' if not r['wkc_ok'] else '无有效负载读数'}"
f",load_perm 为残值)" for r in excl) + '。该档已由断电重启后的补测覆盖:'
'无 CAN 的 0.25 / 0.125ms 两点均已满速测得(见无 CAN 表)。空图比假图诚实。</div>')
b.append('<div class="card"><h3>名词解释(表里这几个计数器到底是什么)</h3><ul>'
'<li><b>WKC(working counter,工作计数器)</b>:EtherCAT 报文自带一个计数器,'
'每有一个从站硬件处理了该报文就 +1。主站知道自己发出了几个报文,'
'这个应得值就是 <code>expected_wkc</code>(本台子 1 个从站、每拍 3 个报文 '
'→ 期望 3)。所以 <code>wkc 3/3</code> = 从站每拍都完整处理了;'
'<code>wkc -1</code> = 整帧没回来(从站停答/掉 OP);'
'<code>wr(watchdog reset)0→1</code> = 看门狗把总线重拉过一次。</li>'
'<li><b>主站错拍 skip</b>:主站按周期给自己排了一个"节拍网格"'
'(<code>ecat_native.c:776-791</code>)。每拍做完 EtherCAT 收发后,'
'若发现已经错过了下一个节拍点(甚至错过多个),就把错过的个数累进 '
'<code>skip_total</code>。<b>它量的是"主机软件迟到",不是从站故障</b> —— '
'1ms 档 1.1 次/s 只是偶发 OS 调度抖动;0.125ms 档 5107 次/s 说明'
'单拍耗时已超过两个周期,主机也在勉强。它不区分"从站慢"和"主机慢"。</li>'
'<li><b>在线</b>:电机槽位的 <code>online</code> 标志(槽位数据在超时窗内'
'持续刷新才为 true)。带 CAN 轮显示"未记录"是因为当时脚本还没有这列,'
'<b>不是电机掉线</b>;该轮的电机在线状况另有 8s/档定点实测佐证。</li>'
'</ul></div>')
b.append('<h2>二、数据全表</h2>')
fmt = lambda v, p=1, suf="": ("n/a" if v is None else f"{v:,.{p}f}{suf}")
base = [("周期", lambda r: f"{r['period_ms']:g}ms"),
("实测Hz", lambda r: fmt(r["hz_meas"], 0)),
("达成率", lambda r: fmt(r["achieve"], 1, "%")),
("从站load", lambda r: fmt(r["load"], 1, "%")),
("CAN接收/s", lambda r: fmt(r["lambda_"], 0)),
("skip/s", lambda r: fmt(r["skip"], 2)),
("wkc", lambda r: f'{html.escape(str(r["wkc"]))}/{html.escape(str(r["exp_wkc"]))}')]
cols_wc = base + [("在线", lambda r: "未记录*")]
cols_no = base + [("在线", lambda r: fmt(r["online_frac"], 2))]
b.append(tbl(wc, cols_wc, "带 CAN(主结论轮)"))
b.append(tbl(no, cols_no, "拔掉全部 CAN 设备(补测轮)"))
b.append('<p class="meta"><b>*</b> 「在线」列是扫描脚本后来才加的('
'<code>motors_online</code>/<code>online_frac</code>),带 CAN 那轮跑在加列'
'<b>之前</b>,逐档数据没采 —— <b>不是全部掉线</b>。该轮电机在线另有'
' 8s/档定点实测(「电机数据刷新率」表)佐证。</p>')
mrows = "".join(
f"<tr><td>{lab}ms</td><td>{v:,.1f}</td>"
f"<td>{'正常' if v > 3000 else '塌陷'}</td></tr>"
for lab, _hz, v in motor)
b.append('<h3>电机数据刷新率(带 CAN 轮,8s/档定点实测)</h3><table>'
'<tr><th>周期</th><th>刷新率 帧/s</th><th>判定</th></tr>' + mrows + '</table>'
'<p class="meta">这是唯一直接回答"用户还能不能用"的指标 —— λ 与 drop 都是中间量。'
'塌陷两档当场确认 <code>online=True</code>,故为真失效而非掉线。</p>')
b.append('<h2>三、机理与上限推算</h2>')
b.append('<div class="card"><h3>上限已实测:≈8.2kHz,墙是单拍 ≈121µs 的硬耗时</h3>'
'<p><b>证据链</b>:无 CAN 设定 0.25 → 0.0625ms 五档,实测循环率在 ≤0.125ms '
'(8kHz)前跟随设定,再快就被钉死在 8196–8238Hz;与此同时主站单拍耗时 '
'<code>cyc p50</code> 在<b>所有档位</b>(含 2ms 慢档)恒为 117–126µs,'
'1/121µs ≈ 8.2kHz,与顶墙实测严丝合缝。即:每拍有一段不可压缩的'
'「发送→从站处理→回读+主站处理」往返时间,周期设定小于它时就堵在这里。</p>'
'<p><b>排除项</b>:不是主机 CPU 不够 —— 顶墙档主机 CPU 只有 ~85%'
'(自旋等待占比变小);不是从站拒答 —— wkc 全程 3/3。'
'<code>load_perm</code> 在 ≥4kHz 全饱和 99.x%,既定位不了拐点也喂不准模型,'
'早先「两项模型 load=F/T+R → 2.7/3.5kHz」的外推已被满速补测证伪,仅留档。</p>'
'<p><b>未闭合的一处</b>:121µs 里主站协议栈与从站 ESC/固件各占多少,'
'本轮数据分不开 —— 需给固件 <code>fillRxWindow</code> 加直接计时'
'(固件改动,需批准)或抓总线帧时序才能归因。</p></div>')
b.append('<h2>四、判据口径与踩过的坑</h2>')
b.append('<div class="card warn"><h3>仪表陷阱(每条都实际踩过)</h3><ul>'
'<li><b>【本次真正翻车处】把两个同速计数器相除当成比率</b> —— '
'<code>drop</code>/<code>rx_count</code> 都约 4000/s,相除恒得 100%,'
'看着像"全丢",其实只是<b>同速量之比</b>。判丢帧必须有<b>独立</b>的分子与分母:'
'本次真正的独立证据是「STATUS 实测 4001 帧/s 正常刷新」+「CONFIG 事件流去重后'
'仅约 92 帧/s」,两者一起否掉了 100% 丢帧。<b>任何"A/B 比率"指标,先问 A 与 B '
'是否独立计数。</b></li>'
'<li><b>从站停报后 <code>load_perm</code> 保留残值</b> —— 实测 '
'<code>age_ms=214338</code> 时仍报 99.2%。任何取 load_perm 的脚本必须先过 '
'<code>age_ms</code> 闸。<b>扫描脚本已加该闸</b>并新增 '
'<code>gd32_load_samples</code>/<code>gd32_load_stale</code> 两列。</li>'
'<li><b><code>load_perm</code> 不是整机 CPU 占用率</b> —— 固件 '
'<code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",'
'<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO '
'应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li>'
'<li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— GD32 侧'
'没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据'
'(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li>'
'<li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 '
'=128ms、0.125ms=64ms,环没装满时取到的 <code>count</code>/<code>lost</code> '
'是<b>行数</b>不是每拍帧数。对应 CSV 列已从结论排除。</li>'
'<li><b>u16 回绕</b>:<code>drop</code> 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 '
'+ 取模,单次增量&gt;32768 记为作废。<b>本次作废点数=0</b>。</li>'
'<li><b>「seq 值域 − distinct」不是丢帧率</b>:报 99.686%,方向与 drop≈λ 一致'
'但分母含 STATUS 全流量,口径不干净 → 只作旁证。</li>'
'<li><b>周期可被 localStorage 劫持</b>(<code>transport-ecat.js:227-228</code>)'
'→ 每档下发后回读校验。本次六档 <code>hijacked=no</code>、native PID 全程 29284 未变。</li>'
'<li><b><code>cyc_us.late</code>/<code>lost</code> 恒 0</b>(遗留占位,未接线),未使用。</li>'
'</ul></div>')
b.append('<h2>五、可信度边界</h2>')
fw = cal.get("firmware", {})
b.append('<div class="card bad"><h3>这份数据不能证明什么</h3><ul>'
f'<li><b>板载固件版本无法确认</b>:烧写台账 '
f'<code>{html.escape(str(fw.get("ledger",""))[:16])}…</code> 与在盘 .hex 展平哈希'
f'({html.escape("、".join(str(c.get("sha256",""))[:8]+"…" for c in fw.get("candidates",[])))})'
f'<b>均不匹配</b>,且产物时间戳晚于台账。→ 本报告不复用任何历史固件结论。</li>'
'<li>主站在测量期间又重编过(源码 02:39 / exe 02:40,PID 起于 02:32),'
'无法证明运行中的进程就是盘上那份。</li>'
'<li><b><code>rx_torn</code> 与 <code>pdo_reentry</code> 整段恒零 = 无检出功效</b>,'
'不是"无撕裂"。二者分别需固件 1.6.6/1.6.7+,版本未知 → 无法区分"没发生"与'
'"字段根本没实现、恒读 0"。</li>'
'<li>λ≈3993/s 是本次 CAN 工况。<b>λ 变则负担变</b>,0.5ms 这个数不可外搬到别流量。</li>'
'<li>单台单次,未做重复性与温漂验证。</li>'
'</ul></div>')
b.append('<h2>六、下一步</h2>')
b.append('<div class="card"><h3>按优先级</h3><ul>'
'<li><b>查明 <code>drop</code> 口径(当前最高优先,唯一未决的承重项)</b>。'
'必须用<b>一对独立可比</b>的计数,不能再拿同速量相除:从 WiFi 侧读固件 u32 '
'<code>rx_drop_count</code>(<code>app_spi_cmd_can_feedback.cpp:99</code>)'
'对速率,同时用 CAN 监控页统计<b>实收</b> CONFIG 帧数作分母。'
'只有「独立到达计数 − 独立交付计数」才是丢帧数。</li>'
'<li><b>台子现状</b>:断电重启后重新 open+start 成功(OP、wkc=3),'
'无 CAN 的 0.25→0.0625ms 五档补测完成,周期已自动恢复 1ms。'
'<b>CAN 设备当前处于拔除状态</b>,电机数据相关指标要插回 CAN 才能再测。</li>'
'<li>插回 CAN 后补测 <b>0.35 / 0.4 / 0.5ms</b> 三档的<b>电机数据刷新率</b>,'
'把带 CAN 工况的可用上限定位出来。load_perm 已被证明在快档饱和,'
'<b>不能再用它定位拐点</b>;扫描脚本的 '
'<code>motors_online</code>/<code>online_frac</code>/'
'<code>motor_refresh_per_s</code> 三列才是可用性判据。</li>'
'<li>给 <code>fillRxWindow</code> 加直接计时,把 121µs 硬耗时里的主/从占比'
'拆开(属固件改动,需批准)。</li>'
'<li>0.5ms 档实跑电机页完整命令序列 —— 本调研只测了计数器,没测界面实际可用性。</li>'
'</ul></div>')
live = ""
try:
h = post("http://127.0.0.1:8090", "/api/native/health", timeout=1.5)
st = post("http://127.0.0.1:8090", "/api/motor/state", timeout=1.5)
live = (f'native 实时:op={h.get("op")} loop={h.get("loop")} wkc={h.get("wkc")} '
f'period={st.get("period_ms")}ms')
except Exception:
live = "native 当前不可达(生成本报告时未连上 8090)"
b.append(f'<div class="foot">本报告由 <code>scripts/build_ecat_report_html.py</code> 从 '
f'<code>lab/*.csv</code> 与 <code>lab/ecat_calib.json</code> 现场渲染,图中每个数值均'
f'从原始数据计算,未手抄。{html.escape(live)}。'
f'Markdown 版(含同源结论):<code>ECAT_RATE_TRADEOFF.md</code>。</div>')
out = a.out
with open(out, "w", encoding="utf-8") as f:
f.write(PAGE.format(body="\n".join(b)))
print("HTML 报告 ->", out)
print(f" 参与绘图:带CAN {len(wc_ok)} 档、无CAN {len(no_ok)} 档;排除 {len(excl)} 档")
if deltas:
print(f" CAN 负担 Δ:{'、'.join(f'{d:.1f}pp' for d in deltas)}")
if __name__ == "__main__":
main()