#!/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'') self.b.append(f'{html.escape(yf(v))}') for v in xt: x = self.X(v) if not (self.L - 1 <= x <= self.R + 1): continue self.b.append(f'') self.b.append(f'{html.escape(xf(v))}') def axes(self, xl, yl): self.b.append(f'') self.b.append(f'') self.b.append(f'{html.escape(xl)}') self.b.append(f'{html.escape(yl)}') 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'') for i, (x, y) in enumerate(live): cx, cy = self.X(x), self.Y(y) if marker == "circle": self.b.append(f'') elif marker == "square": self.b.append(f'') 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'') if label and i == len(live) - 1: dx, dy = lab_dxy self.b.append(f'{html.escape(label)}') def vtext(self, x, y, txt, color="#374151", anchor="middle", size=10.5, weight="400"): self.b.append(f'{html.escape(txt)}') 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'') self.b.append(f'') self.b.append(f'{html.escape(t)}') 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'') def band(self, x0, x1, color="#ef4444", alpha=0.07): a, b = self.X(x0), self.X(x1) self.b.append(f'') 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'{html.escape(txt)}') def finish(self, title, sub=""): head = "" if sub: head = f'' \ f'{html.escape(sub)}' return (f'' f'{head}{"".join(self.b)}') # ───────────────────────────────────────────────────────── 具体图 ───────────────────────────────────────────────────────── 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'100%') 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'') 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 = """ EtherCAT 周期速度 × 故障率 × CPU 占用 调研
{body}
""" def tbl(rows, cols, cap): h = [f'

{html.escape(cap)}

' + "".join(f"" for t, _ in cols) + ""] 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"" + "".join(f"" for _, f in cols) + "") h.append("
{html.escape(t)}
{f(r)}
") 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'

EtherCAT 周期速度 × 故障率 × CPU 占用 调研

', f'
调研日期 {html.escape(str(cal.get("started_at","2026-09-08"))[:10])}' f' · 本机 I210 + 1 个 GD32E508 从站 · 两轮扫描:带 CAN / 拔掉全部 CAN' f' · 数据 lab/ecat_rate_sweep_*.csv · 生成器 ' f'scripts/build_ecat_report_html.py
'] b.append('
' '
0.5ms实测可用上限(2kHz,带 CAN 工况)
' f'
≈{(sum(deltas)/len(deltas) if deltas else 0):.1f}pp' f'CAN 每拍恒定负担({len(deltas)} 档配对,饱和档除外)
' '
4000 帧/s电机 STATUS 实测刷新(1/0.5ms)
' '
≈8.2kHz纯 ECAT 实测硬上限(设 10/12.5/16kHz 实测均不涨)
') b.append('

结论

    ' '
  • 带 CAN 工况的可用上限在 0.5ms,瓶颈在从站 GD32 不在主站。' '实测可用到 0.5ms;0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s' '(带 CAN 那轮当场确认 online=True,是真失效)。' '可用上限只夹在 (0.25ms, 0.5ms] 内,具体位置没测到。
  • ' '
  • 纯 ECAT 硬上限 ≈8.2kHz,这次测到了墙本身。断电重启后无 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、从站始终应答。墙的来历:主站单拍硬耗时 cyc p50 ' '各档恒 117–126µs,1/121µs ≈ 8.2kHz —— 是"每拍必须花的往返+处理时间",' '不是主机 CPU 不够(顶墙时主机 CPU 反而降到 ~85%)。原「3.5kHz 外推」' '被证伪;首轮无 CAN 0.25ms 停报为残状态偶发,不复现。' '注意"跑得快"≠"能用":带 CAN 的可用上限仍由电机数据刷新决定(下一条)。
  • ' '
  • 拔 CAN 对照解开了混淆:未饱和三档 Δ 稳定 8.7–9.3pp,' '不随频率张开 → 快档塌陷不是 CAN 挤出来的;饱和两档 Δ 收窄到 ≤0.5pp,' '正是读数被夹紧的特征。
  • ' '
  • 主机 native CPU 恒约 100% 单核、与周期无关(自旋恒转,' '/api/motor/spin 已是空操作)。唯一的例外是带 CAN 轮的 ' '0.125ms 档降到 87.5% —— 因为从站已停答。无 CAN 补测里从站活着,' '同档就回到 99.7%。主机 CPU 掉下来那一刻,正是从站死掉那一刻。
  • ' '
  • 建议:要吃到余量就停在 0.5ms。0.25ms 不是"更快",是功能损坏。
  • ' '
') b.append('

一、曲线

') b.append('
') b.append(f'
① 循环率:设定 vs 实测(主站侧)
' f'{chart_achieve(wc_ok, no_ok)}
') b.append(f'
② 从站每拍负担:带 CAN vs 无 CAN
' f'{chart_load(no_ok, wc_ok)}
') b.append(f'
③ CAN 流量分层:STATUS 刷新与 CONFIG 事件流
' f'{chart_traffic(wc_ok)}
') b.append(f'
④ 主站错拍率
' f'{chart_skip(no_ok, wc_ok)}
') b.append('
') if excl: b.append('
已从图中排除的档位:' + ";".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 表)。空图比假图诚实。
') b.append('

名词解释(表里这几个计数器到底是什么)

    ' '
  • WKC(working counter,工作计数器):EtherCAT 报文自带一个计数器,' '每有一个从站硬件处理了该报文就 +1。主站知道自己发出了几个报文,' '这个应得值就是 expected_wkc(本台子 1 个从站、每拍 3 个报文 ' '→ 期望 3)。所以 wkc 3/3 = 从站每拍都完整处理了;' 'wkc -1 = 整帧没回来(从站停答/掉 OP);' 'wr(watchdog reset)0→1 = 看门狗把总线重拉过一次。
  • ' '
  • 主站错拍 skip:主站按周期给自己排了一个"节拍网格"' '(ecat_native.c:776-791)。每拍做完 EtherCAT 收发后,' '若发现已经错过了下一个节拍点(甚至错过多个),就把错过的个数累进 ' 'skip_total。它量的是"主机软件迟到",不是从站故障 —— ' '1ms 档 1.1 次/s 只是偶发 OS 调度抖动;0.125ms 档 5107 次/s 说明' '单拍耗时已超过两个周期,主机也在勉强。它不区分"从站慢"和"主机慢"。
  • ' '
  • 在线:电机槽位的 online 标志(槽位数据在超时窗内' '持续刷新才为 true)。带 CAN 轮显示"未记录"是因为当时脚本还没有这列,' '不是电机掉线;该轮的电机在线状况另有 8s/档定点实测佐证。
  • ' '
') b.append('

二、数据全表

') 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('

* 「在线」列是扫描脚本后来才加的(' 'motors_online/online_frac),带 CAN 那轮跑在加列' '之前,逐档数据没采 —— 不是全部掉线。该轮电机在线另有' ' 8s/档定点实测(「电机数据刷新率」表)佐证。

') mrows = "".join( f"{lab}ms{v:,.1f}" f"{'正常' if v > 3000 else '塌陷'}" for lab, _hz, v in motor) b.append('

电机数据刷新率(带 CAN 轮,8s/档定点实测)

' '' + mrows + '
周期刷新率 帧/s判定
' '

这是唯一直接回答"用户还能不能用"的指标 —— λ 与 drop 都是中间量。' '塌陷两档当场确认 online=True,故为真失效而非掉线。

') b.append('

三、机理与上限推算

') b.append('

上限已实测:≈8.2kHz,墙是单拍 ≈121µs 的硬耗时

' '

证据链:无 CAN 设定 0.25 → 0.0625ms 五档,实测循环率在 ≤0.125ms ' '(8kHz)前跟随设定,再快就被钉死在 8196–8238Hz;与此同时主站单拍耗时 ' 'cyc p50 在所有档位(含 2ms 慢档)恒为 117–126µs,' '1/121µs ≈ 8.2kHz,与顶墙实测严丝合缝。即:每拍有一段不可压缩的' '「发送→从站处理→回读+主站处理」往返时间,周期设定小于它时就堵在这里。

' '

排除项:不是主机 CPU 不够 —— 顶墙档主机 CPU 只有 ~85%' '(自旋等待占比变小);不是从站拒答 —— wkc 全程 3/3。' 'load_perm 在 ≥4kHz 全饱和 99.x%,既定位不了拐点也喂不准模型,' '早先「两项模型 load=F/T+R → 2.7/3.5kHz」的外推已被满速补测证伪,仅留档。

' '

未闭合的一处:121µs 里主站协议栈与从站 ESC/固件各占多少,' '本轮数据分不开 —— 需给固件 fillRxWindow 加直接计时' '(固件改动,需批准)或抓总线帧时序才能归因。

') b.append('

四、判据口径与踩过的坑

') b.append('

仪表陷阱(每条都实际踩过)

    ' '
  • 【本次真正翻车处】把两个同速计数器相除当成比率 —— ' 'drop/rx_count 都约 4000/s,相除恒得 100%,' '看着像"全丢",其实只是同速量之比。判丢帧必须有独立的分子与分母:' '本次真正的独立证据是「STATUS 实测 4001 帧/s 正常刷新」+「CONFIG 事件流去重后' '仅约 92 帧/s」,两者一起否掉了 100% 丢帧。任何"A/B 比率"指标,先问 A 与 B ' '是否独立计数。
  • ' '
  • 从站停报后 load_perm 保留残值 —— 实测 ' 'age_ms=214338 时仍报 99.2%。任何取 load_perm 的脚本必须先过 ' 'age_ms 闸。扫描脚本已加该闸并新增 ' 'gd32_load_samples/gd32_load_stale 两列。
  • ' '
  • load_perm 不是整机 CPU 占用率 —— 固件 ' 'sys/cpu_load.h 定义收窄为"中断+非延迟处理段",' '排除 SSC MainLoop 轮询;当前 free-run(dc=0)下 PDO ' '应用走主循环轮询路径、不计入。所以不能把"load→100%"读成"CPU 耗尽"。
  • ' '
  • OVF 每拍被 s_rxWinOvf=0 清零 —— GD32 侧' '没有可用的累计丢帧计数。读它恒 0 不能当"无丢帧"证据' '(我据此误判 bridge.drop 是死字段,结论已收回)。
  • ' '
  • /api/can/cadence 只存最近 512 拍 —— 0.25ms 档 ' '=128ms、0.125ms=64ms,环没装满时取到的 count/lost ' '是行数不是每拍帧数。对应 CSV 列已从结论排除。
  • ' '
  • u16 回绕:drop 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 ' '+ 取模,单次增量>32768 记为作废。本次作废点数=0。
  • ' '
  • 「seq 值域 − distinct」不是丢帧率:报 99.686%,方向与 drop≈λ 一致' '但分母含 STATUS 全流量,口径不干净 → 只作旁证。
  • ' '
  • 周期可被 localStorage 劫持(transport-ecat.js:227-228)' '→ 每档下发后回读校验。本次六档 hijacked=no、native PID 全程 29284 未变。
  • ' '
  • cyc_us.late/lost 恒 0(遗留占位,未接线),未使用。
  • ' '
') b.append('

五、可信度边界

') fw = cal.get("firmware", {}) b.append('

这份数据不能证明什么

    ' f'
  • 板载固件版本无法确认:烧写台账 ' f'{html.escape(str(fw.get("ledger",""))[:16])}… 与在盘 .hex 展平哈希' f'({html.escape("、".join(str(c.get("sha256",""))[:8]+"…" for c in fw.get("candidates",[])))})' f'均不匹配,且产物时间戳晚于台账。→ 本报告不复用任何历史固件结论。
  • ' '
  • 主站在测量期间又重编过(源码 02:39 / exe 02:40,PID 起于 02:32),' '无法证明运行中的进程就是盘上那份。
  • ' '
  • rx_torn 与 pdo_reentry 整段恒零 = 无检出功效,' '不是"无撕裂"。二者分别需固件 1.6.6/1.6.7+,版本未知 → 无法区分"没发生"与' '"字段根本没实现、恒读 0"。
  • ' '
  • λ≈3993/s 是本次 CAN 工况。λ 变则负担变,0.5ms 这个数不可外搬到别流量。
  • ' '
  • 单台单次,未做重复性与温漂验证。
  • ' '
') b.append('

六、下一步

') b.append('

按优先级

    ' '
  • 查明 drop 口径(当前最高优先,唯一未决的承重项)。' '必须用一对独立可比的计数,不能再拿同速量相除:从 WiFi 侧读固件 u32 ' 'rx_drop_count(app_spi_cmd_can_feedback.cpp:99)' '对速率,同时用 CAN 监控页统计实收 CONFIG 帧数作分母。' '只有「独立到达计数 − 独立交付计数」才是丢帧数。
  • ' '
  • 台子现状:断电重启后重新 open+start 成功(OP、wkc=3),' '无 CAN 的 0.25→0.0625ms 五档补测完成,周期已自动恢复 1ms。' 'CAN 设备当前处于拔除状态,电机数据相关指标要插回 CAN 才能再测。
  • ' '
  • 插回 CAN 后补测 0.35 / 0.4 / 0.5ms 三档的电机数据刷新率,' '把带 CAN 工况的可用上限定位出来。load_perm 已被证明在快档饱和,' '不能再用它定位拐点;扫描脚本的 ' 'motors_online/online_frac/' 'motor_refresh_per_s 三列才是可用性判据。
  • ' '
  • 给 fillRxWindow 加直接计时,把 121µs 硬耗时里的主/从占比' '拆开(属固件改动,需批准)。
  • ' '
  • 0.5ms 档实跑电机页完整命令序列 —— 本调研只测了计数器,没测界面实际可用性。
  • ' '
') 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'
本报告由 scripts/build_ecat_report_html.py 从 ' f'lab/*.csv 与 lab/ecat_calib.json 现场渲染,图中每个数值均' f'从原始数据计算,未手抄。{html.escape(live)}。' f'Markdown 版(含同源结论):ECAT_RATE_TRADEOFF.md。
') 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()