chore(hygiene): P3 报告归档 docs/reports + scripts 收敛 + lab 清理 + CSS/README 约定

正式 scripts 仅保留 README 自测清单;根目录报告迁入 docs/reports;
lab 采样 dump 出库;CSS 注明 btn/toast 所有权并 bump ?v=。
This commit is contained in:
cbb
2026-09-21 02:26:42 +08:00
parent 261caf50b7
commit beeae8a382
48 changed files with 170 additions and 6438 deletions
-48
View File
@@ -1,48 +0,0 @@
#!/usr/bin/env python3
"""模拟页面 pollStatus:5s 超时算失败,连续失败即离线。"""
import time
import urllib.request
import urllib.error
URL = "http://192.168.31.127/system/status"
TIMEOUT = 5.0
POLL = 1.0
WINDOW = 75
def probe():
t0 = time.time()
try:
with urllib.request.urlopen(URL, timeout=TIMEOUT) as r:
r.read(64)
return True, "HTTP %s" % r.status, time.time() - t0
except urllib.error.HTTPError as e:
return False, "HTTP %s" % e.code, time.time() - t0
except Exception as e:
return False, "%s: %s" % (type(e).__name__, str(e)[:80]), time.time() - t0
def main():
print("=== monitor start (mimic pollStatus) ===", flush=True)
prev = None
fails = 0
t_end = time.time() + WINDOW
while time.time() < t_end:
ok, msg, dt = probe()
now = time.strftime("%H:%M:%S")
if ok:
fails = 0
state = "ONLINE"
else:
fails += 1
state = "FAIL#%d" % fails + (" -> OFFLINE" if fails >= 1 else "")
mark = " *" if state != prev else ""
print("[%s] %s (%s, %.0fms)%s" % (now, state, msg, dt * 1000, mark), flush=True)
if state != prev:
prev = state
time.sleep(POLL)
print("=== monitor end ===", flush=True)
if __name__ == "__main__":
main()
-188
View File
@@ -1,188 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""事后分析 rec_ecat_slots.py 落盘的 ECAT 原始流:相邻数据大跳变检测。
思路(按用户指定):不看协议判据,只看数值形态。同一台电机的 STATUS 是连续量,
相邻两帧(按 seq 排序)之间出现"跳出去又立刻跳回来"的孤立尖峰,就是这一帧的字节
被拼坏了 —— 真突变会保持住(下一帧仍在新值附近),不会一帧就回头。
指标:每个 (node,字段) 先取全体相邻差的稳健尺度 scale = median(|Δ|),
大跳变 : |v[i]-v[i-1]| > K*scale
孤立尖峰 : 大跳变 且 |v[i+1]-v[i-1]| <= 2*K*scale ← 撕裂签名
跳变保持 : 大跳变 但下一帧不回头的(真实突变/新参考值),只计数不算异常
--selftest 会把"前 j 字节取新帧、其余取上一帧"的混合帧注入实测数据,
逐 j 报告检出率:证明这套判据对撕裂本身有灵敏度(否则 0 报警没有意义)。
用法:
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8]
python scripts/anal_ecat_jumps.py ecat_slots_raw.csv --selftest
"""
import csv
import statistics
import struct
import sys
# (名称, 偏移, 解码器)
FIELDS = [
("mcuTemp", 0, lambda b: struct.unpack_from("<h", b, 0)[0] * 0.01),
("motTemp", 2, lambda b: struct.unpack_from("<h", b, 2)[0] * 0.01),
("uCur", 4, lambda b: struct.unpack_from("<h", b, 4)[0]),
("vCur", 6, lambda b: struct.unpack_from("<h", b, 6)[0]),
("wCur", 8, lambda b: struct.unpack_from("<h", b, 8)[0]),
("flags", 10, lambda b: b[10]),
("pwr_V", 11, lambda b: struct.unpack_from("<H", b, 11)[0] * 0.001),
("rtAngle", 13, lambda b: struct.unpack_from("<f", b, 13)[0]),
("rtVel", 17, lambda b: struct.unpack_from("<f", b, 17)[0]),
("rtCur", 21, lambda b: struct.unpack_from("<f", b, 21)[0]),
("testF", 25, lambda b: struct.unpack_from("<f", b, 25)[0]),
("testU16", 29, lambda b: struct.unpack_from("<H", b, 29)[0]),
("cpu%", 31, lambda b: b[31]),
("initSt", 32, lambda b: struct.unpack_from("<I", b, 32)[0]),
]
def load(path):
"""返回 {node: [(seq, 36B bytes), ...]} 按 seq 升序。"""
per = {}
with open(path, "r", encoding="ascii", errors="ignore") as f:
for row in csv.reader(f):
if not row or row[0] != "M":
continue
try:
node, ch, seq = int(row[2]), int(row[3]), int(row[4])
payload = bytes.fromhex(row[7])
except (ValueError, IndexError):
continue
if node <= 0 or len(payload) < 36:
continue
per.setdefault(node, []).append((seq, payload))
for v in per.values():
v.sort(key=lambda x: x[0])
return per
def decode(payload):
out = {}
for name, off, fn in FIELDS:
try:
out[name] = float(fn(payload))
except Exception:
out[name] = None
return out
def analyze(per, k=8.0, verbose=True):
"""逐 (node,字段) 找孤立尖峰。返回总尖峰数与明细列表。"""
spikes, holds, details = 0, 0, []
tails = []
for node, rows in sorted(per.items()):
if len(rows) < 40:
continue
cols = [decode(p) for _, p in rows]
for name, _off, _fn in FIELDS:
v = [c[name] for c in cols]
finite = [x for x in v if x is not None and x == x and abs(x) != float("inf")]
if len(finite) < 40:
continue
deltas = [abs(v[i] - v[i - 1]) for i in range(1, len(v))
if v[i] is not None and v[i - 1] is not None
and v[i] == v[i] and v[i - 1] == v[i - 1]]
if not deltas:
continue
# 尺度取 |Δ| 的 p99 分位数(不是 median):量化字段(mcuTemp 这类)
# 的 median(|Δ|)=0,用 median 会让阈值塌到 eps,正常的 1LSB 抖动
# 全被判成尖峰。p99 代表"全体相邻差的尾部",阈值 = K×p99 才是
# "远超正常抖动尾巴"的意思。
ordered = sorted(deltas)
scale = max(ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))], 1e-9)
mx, mxctx = 0.0, None
for i in range(1, len(v) - 1):
a, b, c = v[i - 1], v[i], v[i + 1]
if None in (a, b, c) or any(x != x for x in (a, b, c)):
continue
d = abs(b - a)
if d > mx:
mx, mxctx = d, (a, b, c)
if d <= k * scale:
continue
back = abs(c - a) <= 2 * k * scale
if back:
spikes += 1
if len(details) < 25:
details.append((node, name, rows[i][0], scale, a, b, c,
rows[i][1].hex()))
else:
holds += 1
if mxctx and mx > 3 * scale and len(tails) < 60:
tails.append((node, name, scale, mx, mxctx))
if verbose:
print(f"孤立尖峰(撕裂签名) = {spikes} 跳变保持(真实突变) = {holds}")
for node, name, seq, scale, a, b, c, hexs in details[:12]:
print(f" node{node} {name}: seq={seq} 尺度={scale:.4g} "
f"{a:.6g} -> {b:.6g} -> {c:.6g}")
print(f" payload={hexs}")
if tails:
tails.sort(key=lambda x: -(x[3] / max(x[2], 1e-12)))
print("各字段最大相邻差(倍数 = 最大|Δ| / p99 尺度):")
for node, name, scale, mx, (a, b, c) in tails[:14]:
print(f" node{node} {name:9s} p99尺度={scale:.4g} 最大|Δ|={mx:.4g} "
f"({mx/max(scale,1e-12):.0f}×) {a:.6g} -> {b:.6g} -> {c:.6g}")
return spikes, holds, details
def selftest(per, k=8.0, n_inject=40):
"""注入混合帧,测各边界 j 的检出率(证明判据有灵敏度)。"""
import random
random.seed(7)
base_sp, _bh, _bd = analyze(per, k=k, verbose=False)
nodes = [n for n, r in per.items() if len(r) >= 60]
if not nodes:
print("自检跳过:数据太短(每节点需 >=60 帧)")
return
print(f"自检:基线孤立尖峰 = {base_sp};向实测流注入『新帧前缀 j 字节 + 上一帧后缀』"
f"混合帧,每节点各 {n_inject} 次")
print(" j(字节) 净增尖峰/注入总数 检出率")
for j in (1, 4, 8, 12, 13, 16, 17, 20, 21, 24, 25, 28, 31, 32, 35):
rnd = random.Random(j)
merged = {}
for node in nodes:
rows = per[node]
idxs = rnd.sample(range(2, len(rows) - 2), min(n_inject, len(rows) - 6))
new = list(rows)
for i in idxs:
cur, prev = rows[i][1], rows[i - 1][1]
new[i] = (rows[i][0], cur[:j] + prev[j:36])
merged[node] = new
sp, _h, det = analyze(merged, k=k, verbose=False)
inj_n = n_inject * len(nodes)
net = sp - base_sp
worst = max((abs(d[5] - d[4]) for d in det), default=0.0)
print(f" j={j:<4d} {net:5d}/{inj_n:<5d} {100.0*net/inj_n:5.1f}% "
f"最大偏离≈{worst:.4g}")
print("(j 越大越靠尾部字节,混合后数值偏离越小、越难检出 —— 这是形态判据的物理上限)")
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if not args:
print(__doc__)
return
path = args[0]
k = 8.0
if "--k" in sys.argv:
k = float(sys.argv[sys.argv.index("--k") + 1])
per = load(path)
tot = sum(len(v) for v in per.values())
print(f"读入 {path}: {tot} 帧,节点 {sorted(per)}")
for node, rows in sorted(per.items()):
print(f" node{node}: {len(rows)} 个不同 seq,"
f"seq 范围 {rows[0][0]}..{rows[-1][0]}")
if "--selftest" in sys.argv:
selftest(per, k=k)
return
analyze(per, k=k)
if __name__ == "__main__":
main()
-677
View File
@@ -1,677 +0,0 @@
#!/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()
-424
View File
@@ -1,424 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成对接电机积分差归因 HTML(通俗说明 + 标注图)。"""
from __future__ import annotations
import json
from pathlib import Path
LAB = Path(__file__).resolve().parents[1] / "lab"
SRC = LAB / "ecat_who_slow.json"
OUT = LAB / "ecat_integral_report.html"
data = json.loads(SRC.read_text(encoding="utf-8"))
pa = data["phase_attribution"]
strokes = data["strokes"]
sample = data.get("plot_sample") or []
verdict = data.get("verdict") or {}
# 用行程数据修正通俗结论(避免停车检测为 0 的误导)
on_vals = pa["on_ms_n3_minus_n2"]["values"]
# 剔除 |Δ|>50ms 的伪起动(上一段未停稳造成的重叠)
on_clean = [v for v in on_vals if abs(v) <= 50]
on_overlap = [v for v in on_vals if abs(v) > 50]
I_on = [s["I_on"]["Isum"] for s in strokes]
I_cr = [s["I_cruise"]["Isum"] for s in strokes]
I_st = [s["I_stop"]["Isum"] for s in strokes]
only2 = [s["stop_only_n2"]["Isum"] for s in strokes]
only3 = [s["stop_only_n3"]["Isum"] for s in strokes]
# 停止窗谁残速大:I_st>0 → 合速度偏正 → 镜像下更像 N3 仍正/N2 已减
# 直接用 only_* 贡献绝对值
sum_only2 = sum(abs(x) for x in only2)
sum_only3 = sum(abs(x) for x in only3)
n_only2 = sum(1 for x in only2 if abs(x) > 0.2)
n_only3 = sum(1 for x in only3 if abs(x) > 0.2)
cruise_v2 = pa["cruise_mean_abs_v2"]["mean"]
cruise_v3 = pa["cruise_mean_abs_v3"]["mean"]
cruise_bias = pa["cruise_bias_v2_minus_v3"]["mean"]
mean_on_clean = sum(on_clean) / len(on_clean) if on_clean else None
mean_Ion = sum(I_on) / len(I_on) if I_on else 0
mean_Icr = sum(I_cr) / len(I_cr) if I_cr else 0
mean_Ist = sum(I_st) / len(I_st) if I_st else 0
abs_Ist = sum(abs(x) for x in I_st) / len(I_st) if I_st else 0
abs_Ion = sum(abs(x) for x in I_on) / len(I_on) if I_on else 0
abs_Icr = sum(abs(x) for x in I_cr) / len(I_cr) if I_cr else 0
# SVG 折线
W, H = 960, 420
PAD_L, PAD_R, PAD_T = 56, 20, 36
def poly(xs, ys, y0, y1, top, h, color, w=1.3):
if not xs:
return ""
lo, hi = y0, y1
if hi - lo < 1e-9:
hi = lo + 1
def X(t, t0, t1):
span = max(t1 - t0, 1e-6)
return PAD_L + (t - t0) / span * (W - PAD_L - PAD_R)
def Y(v):
return top + (hi - v) / (hi - lo) * h
pts = " ".join(f"{X(xs[i], xs[0], xs[-1]):.1f},{Y(ys[i]):.1f}" for i in range(len(xs)))
return f'<polyline fill="none" stroke="{color}" stroke-width="{w}" points="{pts}"/>'
if sample:
ts = [p["t"] for p in sample]
v2 = [p["v2"] for p in sample]
v3 = [p["v3"] for p in sample]
vsum = [p["vsum"] for p in sample]
Isum = [p["Isum"] for p in sample]
t0, t1 = ts[0], ts[-1]
vmin = min(min(v2), min(v3))
vmax = max(max(v2), max(v3))
imin = min(min(Isum), 0)
imax = max(max(Isum), 0)
padv = (vmax - vmin) * 0.08 or 10
padI = (imax - imin) * 0.1 or 2
def X(t):
return PAD_L + (t - t0) / max(t1 - t0, 1e-6) * (W - PAD_L - PAD_R)
def Yv(v):
return 48 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 100
def Ys(v):
return 190 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 80
def YI(v):
return 300 + (imax + padI - v) / (imax - imin + 2 * padI) * 80
def line(vals, yfn, color, w=1.3):
pts = " ".join(f"{X(ts[i]):.1f},{yfn(vals[i]):.1f}" for i in range(len(ts)))
return f'<polyline fill="none" stroke="{color}" stroke-width="{w}" points="{pts}"/>'
# 行程分界竖线
vlines = []
for sm in data.get("stroke_marks") or []:
if sm["t0"] <= t1:
x = X(sm["t0"])
vlines.append(
f'<line x1="{x:.1f}" x2="{x:.1f}" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/>'
)
vlines.append(
f'<text x="{x+3:.1f}" y="54" fill="#5c6b7e" font-size="9" '
f'font-family="Segoe UI,sans-serif">#{sm["i"]}</text>'
)
else:
ts = v2 = v3 = vsum = Isum = []
t0 = t1 = 0
vlines = []
# 标注箭头:在图上用文字+简单箭头路径
notes_svg = []
if sample:
# 找几个典型点
# 1) 巡航:|v| 稳定处
# 2) 停车:vsum 尖峰
peak_i = max(range(len(sample)), key=lambda i: abs(sample[i]["vsum"]))
# 理论零线
notes_svg.append(
f'<text x="{PAD_L}" y="28" fill="#e8eef7" font-size="14" '
f'font-family="Segoe UI,Microsoft YaHei,sans-serif">'
f'对接电机 2/3:速度、合速度 v2+v3、积分 ∫(v2+v3)dt(虚线为理论 0)</text>'
)
# 表格行
rows_html = []
for s in strokes:
cls_on = "bad" if abs(s["on_ms_n3_minus_n2"]) > 50 else "ok"
cls_cr = "ok"
cls_st = "warn" if abs(s["I_stop"]["Isum"]) > 3 else "ok"
who = ""
if abs(s["stop_only_n3"]["Isum"]) > abs(s["stop_only_n2"]["Isum"]) and abs(s["stop_only_n3"]["Isum"]) > 0.2:
who = "N3 残速"
elif abs(s["stop_only_n2"]["Isum"]) > 0.2:
who = "N2 残速"
else:
who = "两台接近同步刹停"
rows_html.append(f"""
<tr>
<td>#{s['i']}</td><td>{s['dir_n2']}</td>
<td class="{cls_on}">{s['on_ms_n3_minus_n2']:+.2f} ms</td>
<td class="{cls_cr}">{s['cruise']['mean_abs_v2']:.2f} / {s['cruise']['mean_abs_v3']:.2f}</td>
<td>{s['I_on']['Isum']:+.2f}</td>
<td class="ok">{s['I_cruise']['Isum']:+.2f}</td>
<td class="{cls_st}">{s['I_stop']['Isum']:+.2f}</td>
<td>{s['stop_only_n2']['Isum']:+.2f}</td>
<td>{s['stop_only_n3']['Isum']:+.2f}</td>
<td class="{cls_st}">{who}</td>
<td>{s['da2_plus_da3']:+.2f}°</td>
</tr>""")
# 通俗结论文本
if abs_Ist > abs_Icr * 3 and abs_Ist > abs_Ion:
main_src = "停车阶段"
elif abs_Ion > abs_Ist and abs_Ion > abs_Icr:
main_src = "行程衔接/起动窗口(含上一段未停稳的重叠)"
else:
main_src = "起动与停车都有贡献,巡航最干净"
plain_html = f"""
<section class="card">
<h2>通俗结论:到底是谁慢?</h2>
<p class="lead">先说人话:<strong>两台电机并不是“有一台全程都慢”</strong>。
你看到的积分差,主要不是巡航时谁转得慢,而是<strong>快停下 / 刚要走的那几十一到几百毫秒里,两台对不齐</strong>,
把理论应为 0 的 <code>∫(v2+v3)dt</code> 顶出 5~20° 左右的偏差。</p>
<ul class="verdict">
<li><span class="tag ok">启动</span>
干净起动时(|Δt|≤50ms 的行程),N3−N2 约为
<strong>{mean_on_clean if mean_on_clean is not None else '—'} ms</strong> 量级 →
<strong>几乎是同时启动</strong>,ECAT 同帧下发没有问题。
有 {len(on_overlap)} 个行程测出上百毫秒的“假早启动”,那是<strong>上一段还没停稳、N3 仍在动</strong>,
不是这一段命令发晚了。</li>
<li><span class="tag ok">巡航</span>
两边平均 |v|:N2 <strong>{cruise_v2:.2f}</strong>°/s,N3 <strong>{cruise_v3:.2f}</strong>°/s,
差值约 <strong>{cruise_bias:.3f}</strong>°/s → <strong>稳态速度几乎一样,谈不上谁慢</strong>。
巡航段 ∫(v2+v3) 平均只有约 <strong>{mean_Icr:.2f}°</strong>,贴合理论 0。</li>
<li><span class="tag warn">停车 / 换向</span>
行程末端 ∫(v2+v3) 平均约 <strong>{mean_Ist:.2f}°</strong>,绝对值平均约 <strong>{abs_Ist:.2f}°</strong>,
明显大于巡航。停止窗口里:
出现「只有 N2 还在走」的行程约 <strong>{n_only2}</strong> 次(积分贡献合计 {sum_only2:.2f}°),
「只有 N3 还在走」约 <strong>{n_only3}</strong> 次(合计 {sum_only3:.2f}°)。
<strong>两边都有刹车尾巴,不是单边永久变慢</strong>;有的行程 N2 拖尾更明显,有的行程 N3 更明显。</li>
</ul>
<div class="callout">
<strong>一句话:</strong>积分差的大头在 <strong>{main_src}</strong>。
对接约束要求 <code>v2 = −v3</code>;只要刹停瞬间一台还带着速度、另一台已经接近 0,
<code>v2+v3</code> 就会冒尖峰,积分就会被拉离理论 0。这属于<strong>动态不同步</strong>,
不等于某台电机“标定转速低、一直慢”。
</div>
<div class="arrow-legend">
<div><span class="arrow ok-arrow"></span> 正常:同时启动 / 巡航一致 / 中段积分≈0</div>
<div><span class="arrow warn-arrow"></span> 注意:停车沿残速、行程衔接重叠、积分被顶开</div>
<div><span class="arrow bad-arrow"></span> 偏大:该行程 ∫(v2+v3) 或编码器 Δ(a2+a3) 明显偏离 0</div>
</div>
</section>
"""
rows_joined = "\n".join(rows_html)
# 标注框(放在 SVG 下方用 HTML 箭头说明)
annot_html = f"""
<section class="card">
<h2>图上怎么看(对照上方曲线)</h2>
<div class="annot-grid">
<div class="annot ok">
<div class="ann-title">↑ 巡航段(两条速度曲线几乎贴合)</div>
<p>N2 蓝线、N3 橙线在 |v|≈200°/s 附近重合 → <strong>都不慢</strong>。
下方红线 <code>v2+v3</code> 贴 0,积分曲线几乎水平 → 对接约束在稳态是成立的。</p>
</div>
<div class="annot warn">
<div class="ann-title">↓ 停车沿 / 换向点(红线尖峰)</div>
<p>接近终点时,一台速度先掉下来、另一台还在走 → <code>v2+v3</code> 出现 ±100~200°/s 尖峰。
积分曲线在这里<strong>爬上或爬下</strong>,行程结束就停在理论 0 以外几度到二十度。
<strong>这里是积分差的主要来源。</strong></p>
</div>
<div class="annot warn">
<div class="ann-title">↓ 行程刚衔接时(起动窗)</div>
<p>若上一段没完全停稳就反向,会看到“N3 早就动了”的假象(表格里 onΔ 为 −800ms 量级)。
这<strong>不是启动指令不同步</strong>,而是<strong>速度还在反向爬坡 / 残速</strong>,
也会把起动窗积分抬高一点。</p>
</div>
<div class="annot bad">
<div class="ann-title">↓ 紫色角度和 a2+a3</div>
<p>理论应为常数(理想为 0)。行程终点若偏到 ±10~25°,与该行程 ∫(v2+v3) 同量级 →
说明偏差是<strong>真实位移耦合误差</strong>,不只是速度噪声。</p>
</div>
</div>
<div class="callout small">
<strong>标定建议(若要压积分差):</strong>
① 先统一两台的减速段(同样斜率刹停),而不是去改巡航速度;
② 检查 N2/N3 负载与制动是否对称;
③ 控制器上对对接轴做 <code>v2+v3 → 0</code> 的纠偏,比单轴调 PID 更对症;
④ 序列 hold 不要短于“两台都确认停稳”的时间,避免重叠行程把积分搅浑。
</div>
</section>
"""
# SVG 图
if sample:
svg = f"""
<svg viewBox="0 0 {W} {H}" width="100%" xmlns="http://www.w3.org/2000/svg">
<rect width="{W}" height="{H}" fill="#0f1419"/>
{''.join(vlines)}
{line(v2, Yv, '#4da3ff')}
{line(v3, Yv, '#f0a030')}
<text x="{PAD_L}" y="44" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">速度 deg/s — 蓝 N2 / 橙 N3</text>
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{Yv(0):.1f}" y2="{Yv(0):.1f}" stroke="#2a3340" stroke-dasharray="4 3"/>
{line(vsum, Ys, '#ff5c5c')}
<text x="{PAD_L}" y="184" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">合速度 v2+v3(理论 0)— 停车尖峰在这里</text>
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{Ys(0):.1f}" y2="{Ys(0):.1f}" stroke="#3a4656" stroke-dasharray="4 3"/>
{line(Isum, YI, '#b48cff')}
<text x="{PAD_L}" y="294" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">积分 ∫(v2+v3)dt(理论 0)— 每段末的偏离就是耦合误差</text>
<line x1="{PAD_L}" x2="{W-PAD_R}" y1="{YI(0):.1f}" y2="{YI(0):.1f}" stroke="#3a4656" stroke-dasharray="4 3"/>
<text x="{PAD_L}" y="{H-12}" fill="#6b7a8d" font-size="11" font-family="Segoe UI,sans-serif">t_rel {t0:.1f}..{t1:.1f}s · ECAT 0.25ms · 竖线为行程起点</text>
<!-- 标注箭头 -->
<g font-family="Segoe UI,Microsoft YaHei,sans-serif" font-size="11">
<text x="{W-210}" y="70" fill="#3d8f5b">↑ 巡航:两线贴合,都不慢</text>
<text x="{W-230}" y="210" fill="#d4a017">↓ 停车沿:v_sum 尖峰 → 积分爬升</text>
</g>
</svg>"""
else:
svg = "<p>(无绘图样本)</p>"
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>对接电机 2/3 积分差归因报告</title>
<style>
:root {{
--bg:#0f1419; --card:#161c24; --ink:#e8eef7; --mut:#8a9bb0;
--ok:#3d8f5b; --warn:#d4a017; --bad:#e25555; --line:#2a3340;
}}
* {{ box-sizing:border-box; }}
body {{
margin:0; background:var(--bg); color:var(--ink);
font-family:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
line-height:1.55; padding:24px;
}}
h1 {{ font-size:22px; font-weight:600; margin:0 0 6px; }}
h2 {{ font-size:16px; font-weight:600; margin:0 0 12px; }}
.sub {{ color:var(--mut); font-size:13px; margin-bottom:18px; }}
.card {{
background:var(--card); border:1px solid var(--line); border-radius:12px;
padding:18px 20px; margin-bottom:16px;
}}
.lead {{ margin:0 0 12px; color:var(--ink); }}
code {{
background:#0c1014; padding:1px 6px; border-radius:4px;
font-family:Consolas,monospace; font-size:13px; color:#c5d4e8;
}}
ul.verdict {{ list-style:none; padding:0; margin:0 0 14px; }}
ul.verdict li {{
margin:0 0 10px; padding:10px 12px; background:#121820;
border-left:3px solid var(--line); border-radius:0 8px 8px 0;
}}
.tag {{
display:inline-block; font-size:12px; font-weight:600; padding:2px 8px;
border-radius:999px; margin-right:8px; vertical-align:middle;
}}
.tag.ok {{ background:rgba(61,143,91,.2); color:#6dca8f; }}
.tag.warn {{ background:rgba(212,160,23,.2); color:#f0c35a; }}
.callout {{
background:#121a14; border:1px solid #2a4a34; border-radius:8px;
padding:12px 14px; margin-top:8px;
}}
.callout.small {{ font-size:13px; background:#1a1520; border-color:#3a2a4a; }}
table {{
width:100%; border-collapse:collapse; font-size:12.5px; margin-top:8px;
}}
th, td {{
border-bottom:1px solid var(--line); padding:6px 8px; text-align:left;
}}
th {{ color:var(--mut); font-weight:500; }}
td.ok {{ color:#6dca8f; }}
td.warn {{ color:#f0c35a; }}
td.bad {{ color:#ff8a8a; }}
.annot-grid {{
display:grid; grid-template-columns:1fr 1fr; gap:12px;
}}
@media (max-width:800px) {{ .annot-grid {{ grid-template-columns:1fr; }} }}
.annot {{
background:#121820; border-radius:8px; padding:12px; border-left:3px solid var(--line);
}}
.annot.ok {{ border-left-color:var(--ok); }}
.annot.warn {{ border-left-color:var(--warn); }}
.annot.bad {{ border-left-color:var(--bad); }}
.ann-title {{ font-weight:600; margin-bottom:6px; font-size:13px; }}
.annot p {{ margin:0; font-size:13px; color:#c5d0dc; }}
.arrow-legend {{ margin-top:12px; font-size:13px; color:var(--mut); }}
.arrow-legend div {{ margin:4px 0; }}
.arrow {{ display:inline-block; width:18px; height:3px; margin-right:8px; vertical-align:middle; }}
.ok-arrow {{ background:var(--ok); }}
.warn-arrow {{ background:var(--warn); }}
.bad-arrow {{ background:var(--bad); }}
.metrics {{
display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:10px;
}}
@media (max-width:800px) {{ .metrics {{ grid-template-columns:1fr 1fr; }} }}
.metric {{
background:#121820; border:1px solid var(--line); border-radius:8px; padding:10px 12px;
}}
.metric .k {{ color:var(--mut); font-size:12px; }}
.metric .v {{ font-size:20px; font-weight:600; margin-top:2px; }}
.metric .v.ok {{ color:#6dca8f; }}
.metric .v.warn {{ color:#f0c35a; }}
</style>
</head>
<body>
<h1>对接电机 2 / 3:积分差是谁造成的?</h1>
<div class="sub">
数据:ECAT 无源采集 <code>lab/ecat_passive_latest.csv</code> · 周期 0.25ms ·
理论:刚性镜像对接时 <code>v2+v3≡0</code>,<code>∫(v2+v3)dt≡0</code>,<code>a2+a3=常数</code><br/>
方法:对齐 cycles 后做梯形积分;先加后积与先积后加一致。
</div>
<div class="card">
<h2>关键数字</h2>
<div class="metrics">
<div class="metric"><div class="k">巡航 |v| N2 / N3</div>
<div class="v ok">{cruise_v2:.2f} / {cruise_v3:.2f}</div></div>
<div class="metric"><div class="k">巡航段 ∫(v2+v3) 平均</div>
<div class="v ok">{mean_Icr:.2f}°</div></div>
<div class="metric"><div class="k">停车段 ∫(v2+v3) |均值|</div>
<div class="v warn">{abs_Ist:.2f}°</div></div>
<div class="metric"><div class="k">干净起动 Δt 中位/均值</div>
<div class="v ok">{(sorted(on_clean)[len(on_clean)//2] if on_clean else float('nan')):.2f} / {(mean_on_clean if mean_on_clean is not None else float('nan')):.2f} ms</div></div>
</div>
</div>
{plain_html}
<section class="card">
<h2>曲线(带标注)</h2>
{svg}
</section>
{annot_html}
<section class="card">
<h2>各行程明细(积分从行程起点重算)</h2>
<table>
<thead>
<tr>
<th>#</th><th>方向</th><th>起动Δ(N3-N2)</th><th>巡航|v| N2/N3</th>
<th>∫起动</th><th>∫巡航</th><th>∫停车</th>
<th>停时仅N2在动</th><th>停时仅N3在动</th><th>停车归因</th><th>Δ(a2+a3)</th>
</tr>
</thead>
<tbody>
{rows_joined}
</tbody>
</table>
<p class="sub" style="margin-top:10px">
单位:角度 deg(积分)· 速度 deg/s · 时间 ms。
「停时仅N2/N3在动」为该窗口内单边残速对 ∫(v2+v3) 的贡献。
</p>
</section>
<section class="card">
<h2>原始结论脚本输出(备查)</h2>
<pre style="white-space:pre-wrap;font-size:12px;color:#a8b6c8;margin:0">{json.dumps(verdict, ensure_ascii=False, indent=2)}</pre>
</section>
</body>
</html>
"""
OUT.write_text(html, encoding="utf-8")
print("HTML ->", OUT)
print("main_src:", main_src)
print("on_clean", on_clean, "mean", mean_on_clean)
print("I_on/I_cr/I_st abs mean", abs_Ion, abs_Icr, abs_Ist)
print("only2", n_only2, sum_only2, "only3", n_only3, sum_only3)
-418
View File
@@ -1,418 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Phase 0:ECAT 指标现场标定 —— 跑速度扫描之前,先确认每个计数器还有没有意义。
为什么必须有这一步:固件与主站都在被反复改(当前 ecat_native.c 有 239 行未提交改动,
批量区已从"变长紧排 3 帧/拍"换成 1.6.15 定长覆盖窗口),任何沿用历史结论的判据都可能
已经失效。本脚本只认现场读数,不认文档和历史。
标定八件事:
1 总线能否正常起(open + motor/start)
2 板上固件身份(台账 sha256 对 Release/*.hex 展平后的哈希)
3 批量区实际布局(rxraw.layout,不假设)
4 每拍入窗帧数分布(取 cadence 环,按拍采样,不受 HTTP 轮询速率影响)
5 丢帧率的主判据:**seq 值域 − 实测 distinct seq**(对槽位顺序不敏感)
对账判据:固件 OVF / bridge.drop —— 二者与主判据是否一致
6 撕裂类计数器是否还有功效(rx_torn / pdo_reentry,恒零即"测不到"≠"没坏")
7 主站错拍 skip_total 与达成率、cyc_us 占空
8 CPU 两口径(GD32 load_perm / 主机 native)+ 进程 PID 稳定性
用法:
python scripts/calibrate_ecat_metrics.py
python scripts/calibrate_ecat_metrics.py --seconds 60 --json lab/ecat_calib.json
python scripts/calibrate_ecat_metrics.py --no-start # 总线没起时不自动拉起
"""
import argparse
import collections
import hashlib
import json
import os
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_URL = "http://127.0.0.1:8090"
# 1.6.15 定长覆盖窗口槽布局(对应 ecat_native.c RXB_WIN_OFF_* / 固件 ECAT_RXW_OFF_*)
WIN_SLOT_SIZE, WIN_SLOTS = 44, 5
WIN_OFF_FLAGS, WIN_OFF_ID, WIN_OFF_SEQ, WIN_OFF_DLC = 2, 4, 8, 1
FLAG_WRITTEN = 0x01
def parse_win_slots(rawhex):
"""从 rxraw.raw 解出每个槽的 (id, seq)(跳过 bit0=未写过的空槽)。
parsed 字段不带 seq,而 seq 是这个布局的丢帧判据,必须自己解。
"""
try:
buf = bytes.fromhex(rawhex or "")
except ValueError:
return []
out = []
for k in range(WIN_SLOTS):
o = k * WIN_SLOT_SIZE
if o + WIN_SLOT_SIZE > len(buf):
break
if not buf[o + WIN_OFF_FLAGS] & FLAG_WRITTEN:
continue
out.append((int.from_bytes(buf[o + WIN_OFF_ID:o + WIN_OFF_ID + 4], "little"),
int.from_bytes(buf[o + WIN_OFF_SEQ:o + WIN_OFF_SEQ + 4], "little")))
return out
def window_coverage(seqmap, slot_slots=WIN_SLOTS):
"""覆盖窗口观测到的 seq 值域 / distinct seq 数(**描述性指标,不是丢帧判据**)。
曾用「值域 − distinct」当丢帧主判据,已被现场证伪:窗口只有 5 槽、HTTP 又只几
Hz,值域内根本不可能观测到全部 seq,实测报出 99.686% "丢帧"而固件 OVF=0,自相
矛盾 —— 它量的是**采样覆盖率**。真丢帧主判据是 bridge.drop(固件
canfd.h:53 rx_drop_count,已实测确认为回绕计数器且增量与 rx_count 增量相等)。
"""
out = {}
for fid, obs in seqmap.items():
if not obs:
continue
lo, hi = min(obs), max(obs)
out[fid] = {"n_obs": len(obs), "span": hi - lo + 1,
"coverage_pct": round(len(obs) / (hi - lo + 1) * 100, 3)}
return {"per_id": out, "ids": len(out),
"note": "覆盖率仅说明观测密度,不可当丢帧率引用"}
def cadence_rings(url):
"""按拍采到的环形缓冲:每拍 count/lost/pending/wkc 的真实分布。
HTTP 轮询只有几 Hz,用它看"每拍几帧"是欠采样;cadence 环由 native 自己在
每拍写,512 拍一段,才是每拍口径的真值。
"""
try:
c = post(url, "/api/can/cadence")
except Exception as e:
return {"error": str(e)[:120]}
cols, rows = c.get("cols") or [], c.get("rows") or []
if not rows:
return {"error": "环为空"}
ix = {k: i for i, k in enumerate(cols)}
def col(name):
return [r[ix[name]] for r in rows] if name in ix else []
out = {"n": len(rows), "period_ms": c.get("period_ms")}
for name in ("count", "lost", "pending", "backlog", "wkc"):
v = col(name)
if v:
out[name + "_hist"] = {str(k): n for k, n in sorted(collections.Counter(v).items())}
out[name + "_max"] = max(v)
span_ms = (col("t")[-1] - col("t")[0]) if len(rows) > 1 else 0
out["span_ms"] = round(span_ms, 1)
if span_ms > 0 and "rxcnt" in ix:
out["lambda_per_s"] = round((col("rxcnt")[-1] - col("rxcnt")[0]) / (span_ms / 1000.0), 1)
out["gseq_per_s"] = round((col("gseq")[-1] - col("gseq")[0]) / (span_ms / 1000.0), 1) \
if "gseq" in ix else None
return out
def find_native_pid(url):
"""反查占用该端口的进程 PID。PID 变了 = 换了实例 = 之前采的全部作废。"""
try:
out = subprocess.check_output(["netstat", "-ano", "-p", "TCP"],
text=True, errors="replace")
except Exception as e:
return None, f"netstat 失败: {e}"
port = url.rsplit(":", 1)[-1].strip("/")
for line in out.splitlines():
cols = line.split()
if len(cols) >= 5 and cols[0] == "TCP" and cols[3] == "LISTENING" \
and cols[1].endswith(":" + port):
return int(cols[-1]), None
return None, f"没人监听 :{port}"
def firmware_identity():
"""台账 sha256 对固件产物。
口径必须与烧写脚本一致:`lab/_last_gd32_sha.txt` 记的是 Intel-Hex **展平成
APP 基址连续 blob 后**的 sha256(flash_gd32.py:91-92),不是 .hex 文件本身的哈希,
产物也是 `.hex` 而非 `.bin`。按原始字节算或按 .bin 过滤会一个都扫不到,
把"本可判定"误报成"无法判定"。
即便命中也只能说明"板上镜像与当前产物同源";产物比台账新时必然不等。
**不能据此推断板上正在运行的版本** —— 那要设备回报版本字。
"""
shafile = os.path.join(REPO, "lab", "_last_gd32_sha.txt")
rec = {"ledger": None, "ledger_path": shafile, "match": None,
"candidates": [], "method": "hex_to_flat + sha256"}
if not os.path.isfile(shafile):
rec["error"] = "台账不存在"
return rec
with open(shafile, encoding="utf-8", errors="replace") as f:
rec["ledger"] = f.read().strip()
try:
from flash_gd32 import hex_to_flat
except Exception as e:
rec["error"] = f"无法复用烧写脚本的展平算法({e})"
return rec
app = os.path.abspath(os.path.join(REPO, "..", "..", "DCU_APP", "out"))
if not os.path.isdir(app):
rec["error"] = f"固件产物目录不存在: {app}"
return rec
hexes = []
for dirpath, _d, files in os.walk(app):
hexes += [os.path.join(dirpath, fn) for fn in files if fn.endswith(".hex")]
if not hexes:
rec["error"] = "DCU_APP/out 下没有 .hex 产物"
return rec
for p in sorted(hexes):
try:
d = hashlib.sha256(hex_to_flat(p)).hexdigest()
except (OSError, SystemExit, ValueError) as e:
rec["candidates"].append({"path": p, "error": str(e)[:120]})
continue
rec["candidates"].append({"path": p, "sha256": d[:16], "mtime": int(os.path.getmtime(p))})
if d == rec["ledger"]:
rec["match"] = p
rec["candidates"].sort(key=lambda c: -c.get("mtime", 0))
rec["candidates"] = rec["candidates"][:5]
return rec
def ensure_loop(url, autostart, substr):
st = post(url, "/api/motor/state")
if st.get("ok") and st.get("cycles"):
return st, "已在跑"
if not autostart:
return st, "循环未跑(--no-start)"
op = post(url, "/api/open", {"substr": substr}, timeout=25.0)
if not op.get("ok"):
return op, "open 失败: %s" % (op.get("log") or [])
ms = post(url, "/api/motor/start", {"period_ms": st.get("period_ms") or 1.0}, timeout=40.0)
if not ms.get("ok"):
return ms, "motor/start 失败(典型=SSC 邮箱楔死): %s" % (ms.get("log") or [])
return post(url, "/api/motor/state"), "已自动 open+start"
def main():
ap = argparse.ArgumentParser(description="ECAT 指标现场标定")
ap.add_argument("--url", default=DEFAULT_URL)
ap.add_argument("--seconds", type=float, default=30.0)
ap.add_argument("--hz", type=float, default=10.0,
help="HTTP 采样频率。u16 的 drop 在 4000/s 下 16s 就绕一圈,"
"采样间隔须远小于回绕周期")
ap.add_argument("--no-start", action="store_true")
ap.add_argument("--substr", default="I210")
ap.add_argument("--json", default=None)
a = ap.parse_args()
rep = {"url": a.url, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"seconds": a.seconds, "sample_hz": a.hz, "logical_cpus": os.cpu_count()}
pid, perr = find_native_pid(a.url)
rep["native_pid"], rep["native_pid_error"] = pid, perr
rep["firmware"] = firmware_identity()
rep["cadence"] = cadence_rings(a.url)
st, how = ensure_loop(a.url, not a.no_start, a.substr)
rep["loop_start"] = {"how": how, "ok": bool(st.get("ok"))}
if not st.get("ok"):
print("总线起不来,标定终止:", how)
return 2
rep["period_ms_set"] = st.get("period_ms")
rep["pcore_mask"] = st.get("pcore_mask")
proc = None
try:
import psutil
for p in psutil.process_iter(["name", "pid"]):
if p.info["pid"] == pid:
proc = psutil.Process(pid)
proc.cpu_percent(None)
break
rep["psutil"] = proc is not None
except Exception as e:
rep["psutil"] = f"不可用({e})"
interval = 1.0 / a.hz
seqmap = collections.defaultdict(set)
counts, loads, hc1, hctot, cyc_hz = collections.Counter(), [], [], [], []
drop_rate, rx_rate, cyc_samples = [], [], []
drop_untrusted = 0
skip_first = skip_last = None
torn_c = reentry_c = 0
torn_cap = reentry_cap = False
torn_prev = reentry_prev = None
prev = None
prev_t = None
layout = collections.Counter()
err = iters = 0
t_end = time.perf_counter() + a.seconds
while time.perf_counter() < t_end:
tc = time.perf_counter()
try:
s = post(a.url, "/api/motor/state")
L = post(a.url, "/api/app/load")
R = post(a.url, "/api/motor/rxraw")
D = post(a.url, "/api/motor/dbg")
except Exception:
err += 1
time.sleep(interval)
continue
iters += 1
b = s.get("bridge") or {}
rb = s.get("rx_batch") or {}
cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count")}
if prev is not None and prev_t is not None and tc > prev_t:
dt = tc - prev_t
if cur["cycles"] is not None and prev["cycles"] is not None:
hz = (cur["cycles"] - prev["cycles"]) / dt
if 0 < hz < 1e6:
cyc_hz.append(hz)
if cur["drop"] is not None and prev["drop"] is not None:
d = delta_wrap(prev["drop"], cur["drop"], 16)
# u16 回绕计数器:单次增量逼近模数一半即说明两次采样间已绕圈,
# 该次不可信,只能丢弃并计数暴露采样率不足(真值下降同理)。
if d < 32768:
drop_rate.append(d / dt)
else:
drop_untrusted += 1
if cur["rx"] is not None and prev["rx"] is not None:
dr = delta_wrap(prev["rx"], cur["rx"], 32)
if dr < (1 << 31):
rx_rate.append(dr / dt)
for fid, sq in parse_win_slots(R.get("raw")):
seqmap[fid].add(sq)
counts[rb.get("count")] += 1
layout[R.get("layout")] += 1
if L.get("valid") and L.get("load_perm") is not None:
loads.append(L["load_perm"] / 10.0)
if L.get("rx_torn") is not None:
if torn_prev is not None:
d, capped = delta_saturate(torn_prev, L["rx_torn"])
torn_c += d
torn_cap |= capped or L["rx_torn"] >= 65535
torn_prev = L["rx_torn"]
if L.get("pdo_reentry") is not None:
if reentry_prev is not None:
d, capped = delta_saturate(reentry_prev, L["pdo_reentry"])
reentry_c += d
reentry_cap |= capped or L["pdo_reentry"] >= 65535
reentry_prev = L["pdo_reentry"]
sk = D.get("skip_total")
if sk is not None:
if skip_first is None:
skip_first = sk
skip_last = sk
if s.get("cyc_us"):
cyc_samples.append(s["cyc_us"])
if proc is not None:
try:
c = proc.cpu_percent(None)
hc1.append(c)
hctot.append(c / max(1, rep["logical_cpus"] or 1))
except Exception:
pass
prev, prev_t = cur, tc
time.sleep(max(0.0, interval - (time.perf_counter() - tc)))
dur = time.perf_counter() - (t_end - a.seconds)
stf = post(a.url, "/api/motor/state")
pid2, _ = find_native_pid(a.url)
cad = rep["cadence"]
lam = percentile(rx_rate, 0.5) or cad.get("lambda_per_s") or 0
dropr = percentile(drop_rate, 0.5) or 0
rep["dur_s"] = round(dur, 2)
rep["iters"] = iters
rep["errors"] = err
rep["drop_untrusted_samples"] = drop_untrusted
rep["period_ms_effective"] = stf.get("period_ms")
rep["wkc"] = [stf.get("wkc"), stf.get("expected_wkc")]
rep["wr_end"] = stf.get("wr")
rep["native_pid_end"] = pid2
rep["pid_stable"] = (pid == pid2)
rep["layout_seen"] = dict(layout)
rep["rx_batch_count_hist_http"] = {str(k): v for k, v in sorted(counts.items(), key=lambda x: (x[0] is None, x[0]))}
rep["window_coverage"] = window_coverage(seqmap)
rep["can_lambda_per_s"] = round(lam, 1)
rep["drop_per_s"] = round(dropr, 1)
rep["drop_pct_of_lambda"] = round(dropr / lam * 100, 2) if lam else None
rep["achieved_hz_p50"] = round(percentile(cyc_hz, 0.5) or 0, 1) if cyc_hz else None
pm = rep["period_ms_effective"] or 1
rep["achieve_pct"] = round((rep["achieved_hz_p50"] or 0) / (1000.0 / pm) * 100, 1)
rep["skip_delta"] = (skip_last - skip_first) if (skip_first is not None and skip_last is not None) else None
rep["skip_per_s"] = round((rep["skip_delta"] or 0) / dur, 2) if rep["skip_delta"] is not None else None
rep["skip_total_cum"] = skip_last
rep["rx_torn_delta"] = torn_c
rep["rx_torn_capped"] = torn_cap
rep["pdo_reentry_delta"] = reentry_c
rep["pdo_reentry_capped"] = reentry_cap
tot = [(c.get("total") or {}) for c in cyc_samples if c.get("total")]
rep["cyc_us_total"] = {"p50": percentile([t.get("p50") for t in tot if t.get("p50") is not None], 0.5),
"p90": percentile([t.get("p90") for t in tot if t.get("p90") is not None], 0.5),
"max": max([t.get("max") or 0 for t in tot], default=None)}
rep["cyc_us_late_lost_sample"] = tot[0] if tot else None
rep["gd32_load_pct_p50"] = round(percentile(loads, 0.5) or 0, 1)
rep["host_cpu_1core_p50"] = round(percentile(hc1, 0.5) or 0, 1)
rep["host_cpu_total_p50"] = round(percentile(hctot, 0.5) or 0, 2)
cap = cad.get("count_max") or 0
rep["per_cycle_cap_observed"] = cap
rep["capacity_model"] = {
"lambda_per_s": round(lam, 1), "per_cycle_cap": cap,
"drain_per_s_at_current_T": round(cap / pm * 1000, 0) if pm else None,
"knee_ms_if_linear": round(cap / lam * 1000, 3) if lam and cap else None,
"note": "cap 取自 cadence 环实测 count 峰值;拐点以扫描实测为准,此值仅供事后对照"}
rep["power"] = {
"drop_rx_queue": dropr > 0,
"skip_total": (rep["skip_delta"] or 0) > 0,
"achieve_deficit": (rep["achieve_pct"] or 100) < 99.0,
"rx_torn": torn_c > 0,
"pdo_reentry": reentry_c > 0,
"cadence_lost_or_pending": bool((cad.get("lost_max") or 0) or (cad.get("pending_max") or 0)),
"cyc_us_late_lost": bool((rep["cyc_us_late_lost_sample"] or {}).get("late")
or (rep["cyc_us_late_lost_sample"] or {}).get("lost"))}
print(f"=== Phase 0 标定 · {dur:.0f}s · HTTP 采样 {iters} 轮(错 {err}) @ {a.hz:g}Hz ===")
print(f"native PID {pid} -> {pid2} 稳定={rep['pid_stable']} 钉核 mask={rep['pcore_mask']}")
fw = rep["firmware"]
print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={os.path.basename(fw['match']) if fw.get('match') else fw.get('error') or '无'}")
print(f"周期 设定 {rep['period_ms_set']}ms -> 生效 {pm}ms 实测 {rep['achieved_hz_p50']}Hz 达成率 {rep['achieve_pct']}%")
print(f"批量区布局 {dict(layout)}")
print(f"每拍入窗帧数(cadence 环 {cad.get('n')}拍): count={cad.get('count_hist')} "
f"lost={cad.get('lost_hist')} pending={cad.get('pending_hist')}")
print(f" ↑ 每拍口径必须用环,HTTP 轮询看 count 是欠采样")
print(f"【主判据】λ={lam:.0f}/s drop={dropr:.0f}/s 丢帧占 λ = {rep['drop_pct_of_lambda']}%"
f" (drop=固件 canfd.h rx_drop_count,CAN RX 队列(kRxQueueDepth=16)满即丢)")
if drop_untrusted:
print(f" !! {drop_untrusted} 个采样点单次增量 >32768,说明已绕圈 -> 采样率不足,提高 --hz")
print(f" 对账: cadence lost_max={cad.get('lost_max')} pending_max={cad.get('pending_max')}"
f"(覆盖窗口下这两个旧计数器语义已改,仅供对账)")
print(f" 窗口 seq 覆盖率(非丢帧率!): {rep['window_coverage']['per_id']}")
print(f"rx_torn +{torn_c}(封顶={torn_cap}) pdo_reentry +{reentry_c}(封顶={reentry_cap})")
print(f"主站错拍 skip_total Δ={rep['skip_delta']} = {rep['skip_per_s']}/s(累计 {rep['skip_total_cum']})")
print(f"cyc_us.total p50={rep['cyc_us_total']['p50']} p90={rep['cyc_us_total']['p90']} "
f"max={rep['cyc_us_total']['max']} -> 占空 {round((rep['cyc_us_total']['p50'] or 0)/(pm*1000)*100,1)}%")
print(f"CPU: GD32 load_perm {rep['gd32_load_pct_p50']}% 主机 native 单核 {rep['host_cpu_1core_p50']}% "
f"/ 整机 {rep['host_cpu_total_p50']}%(逻辑核 {rep['logical_cpus']})")
print(f"wkc {rep['wkc']} wr={rep['wr_end']}")
km = rep["capacity_model"]
print(f"\n容量对照: cap={km['per_cycle_cap']} λ={km['lambda_per_s']:.0f} "
f"当前档排空 {km['drain_per_s_at_current_T']} 线性外推拐点 {km['knee_ms_if_linear']}ms"
f"(仅事后对照,拐点以扫描实测为准)")
dead = [k for k, v in rep["power"].items() if not v]
live = [k for k, v in rep["power"].items() if v]
print("有功效:", live or "无")
if dead:
print(f"无功效: {dead}")
print(" → 这些轴在报告里只能写「本次测不到」,**禁止**写成「无故障/已修好」")
if a.json:
os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True)
with open(a.json, "w", encoding="utf-8") as f:
json.dump(rep, f, ensure_ascii=False, indent=2)
print(f"\n标定结果: {a.json}")
return 0
if __name__ == "__main__":
sys.exit(main())
-232
View File
@@ -1,232 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""逐层对账 CAN→ECAT:谁在丢帧、丢多少、哪一层丢的。
动机:面板上的 `drop`(段1/段3"最近一帧"单格被未取先覆的帧数)恒等于 λ − 实测Hz,
是个会自证的数,拿它说"丢帧"没有说服力。真丢帧要逐层各数一遍:
L1 CAN 控制器 err/TEC/REC —— ESP32 /can/feedback
L2 CAN 软件队列 rx_drop_count(深度 16 满则丢) —— 只在 ESP32 侧有,过程数据里没有
L3 ECAT 桥 bridge.rx_count(进桥帧数)
L4 段4 覆盖窗口 s_rxWinSeq 每入窗帧 +2 → Δmax_seq/2 是**精确**的入窗帧数
L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(GD32 OVF) —— 单拍值,
由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍)
L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件
ethercat_can_bridge.cpp:435),每节点 STATUS 率另用段2 槽 seq 步长反推
(率 = λ/step,与轮询率无关)。
只读:全部走只读诊断路由,不发报、不改周期、不碰生命周期。
用法: python scripts/can_bridge_reconcile.py [秒数=20] [esp32_ip=192.168.31.127]
"""
import json
import socket
import sys
import time
import urllib.request
# 控制台默认 GBK:一个字符编不出来就整轮数据白采,所以先把输出流换成 UTF-8
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HOST = "127.0.0.1"
ECAT_PORTS = range(8090, 8096)
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0
ESP_IP = sys.argv[2] if len(sys.argv) > 2 else "192.168.31.127"
# 段4 覆盖窗口(固件 1.6.15 定长布局):raw 从 PDO 偏移 364 起,5 槽 × 44B
WIN_SLOT_SIZE = 44
WIN_SLOTS = 5
W_DLC, W_FLAGS, W_ID, W_SEQ = 1, 2, 4, 8
def post(path, port, timeout=6.0):
"""裸 socket + 连接阶段不设 timeout:给 urllib 传 timeout 会撞上 Windows
非阻塞 connect 的 ~10ms 调度粒度,把仪表自己变成扰动源。"""
body = b"{}"
req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{port}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n\r\n").encode("ascii") + body
s = socket.create_connection((HOST, port))
try:
s.settimeout(timeout)
s.sendall(req)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace"))
def find_port():
for p in ECAT_PORTS:
try:
if post("/api/motor/state", p, 2.0).get("ok"):
return p
except Exception:
continue
return None
def wrap(a, b, bits):
return (b - a) % (1 << bits)
def le32(bs, off):
return int.from_bytes(bs[off:off + 4], "little")
def can_ch(ip):
"""一次读全部 CAN 通道,返回 {通道号: 通道 dict};读不到返回 None(绝不填 0)。
不能在这里挑"rx 最大的通道":ESP32 的 ID 快照是分页轮转的,开机初期某通道还没
轮到 valid 时 rx=0,两次采样各挑各的就会挑到不同通道,差值直接变成整个累计值
(实测虚报成 3.0M 帧/s)。通道由调用方跨两次采样锁定。"""
try:
with urllib.request.urlopen(f"http://{ip}/can/feedback", timeout=3) as r:
d = json.loads(r.read().decode("utf-8"))
except Exception:
return None
chs = {c.get("ch"): c for c in (d.get("channels") or []) if c.get("valid")}
return chs or None
def can_pick(a, b):
"""两次采样都 valid 且 rx 最大的那个通道 —— 保证差分是同一计数器的两端。"""
if not a or not b:
return None
common = [k for k in a if k in b]
if not common:
return None
return max(common, key=lambda k: max(a[k].get("rx") or 0, b[k].get("rx") or 0))
def win_slots(raw_hex):
"""原始字节 → [(seq, id, dlc)],只收 flags.bit0=1 且 seq 为偶数(整槽一致)的槽。"""
bs = bytes.fromhex(raw_hex)
out = []
for i in range(WIN_SLOTS):
p = bs[i * WIN_SLOT_SIZE:(i + 1) * WIN_SLOT_SIZE]
if len(p) < W_ID + 4 or (p[W_FLAGS] & 1) == 0:
continue
sq = le32(p, W_SEQ)
if sq == 0 or sq & 1:
continue
out.append((sq, le32(p, W_ID), p[W_DLC]))
return out
def main():
port = find_port()
if not port:
print("找不到在监听的 ECAT native(8090..8095)—— 主站没起,先连一次总线")
return 1
print(f"native :{port} 窗口 {DUR_S:.0f}s ESP32 {ESP_IP}")
seen_cycle = None
seq_first = seq_last = None
per_id = {}
lost_max = pend_max = cnt_max = online = 0
n_state = n_raw = 0
t0 = time.perf_counter()
c0 = can_ch(ESP_IP)
s0 = post("/api/motor/state", port)
while time.perf_counter() - t0 < DUR_S:
st = post("/api/motor/state", port)
n_state += 1
rb = st.get("rx_batch") or {}
online = max(online, sum(1 for m in (st.get("motors") or []) if m.get("online")))
# 每拍回填清零的单拍值,只能取抽样最大值当下界
lost_max = max(lost_max, rb.get("lost") or 0)
pend_max = max(pend_max, rb.get("pending") or 0)
cnt_max = max(cnt_max, rb.get("count") or 0)
try:
rr = post("/api/motor/rxraw", port)
except Exception:
rr = None
if rr and rr.get("layout") != "win":
print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用")
return 2
# 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节,
# 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""GD32 OVF"。只有第三个
# 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。
if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数
seen_cycle = rr.get("cycle")
n_raw += 1
for sq, _id, _dlc in win_slots(rr.get("raw") or ""):
seq_first = sq if seq_first is None else min(seq_first, sq)
seq_last = sq if seq_last is None else max(seq_last, sq)
per_id.setdefault(_id, set()).add(sq)
t1 = time.perf_counter()
time.sleep(0.05) # 约 20Hz:开环全速轮询会让仪表自己变成扰动源
t1 = time.perf_counter()
c1 = can_ch(ESP_IP)
s1 = post("/api/motor/state", port)
dt = t1 - t0
hz = wrap(s0.get("cycles", 0), s1.get("cycles", 0), 64) / dt
lam = wrap((s0.get("head") or {}).get("global_seq", 0),
(s1.get("head") or {}).get("global_seq", 0), 32) / dt
bridge = wrap((s0.get("bridge") or {}).get("rx_count", 0),
(s1.get("bridge") or {}).get("rx_count", 0), 32) / dt
win_rate = ((seq_last - seq_first) / 2 / dt) if seq_last is not None else None
got = sum(len(v) for v in per_id.values()) / dt
drop_single = wrap((s0.get("bridge") or {}).get("drop", 0),
(s1.get("bridge") or {}).get("drop", 0), 16) / dt
print(f"\n窗口 {dt:.2f}s 档位 {s1.get('period_ms')}ms PDO {hz:.0f}Hz "
f"轮询 state {n_state} 次 / rxraw 新拍 {n_raw} 次"
f"({n_raw / dt:.1f} 拍/s → 抽样比 {n_raw / max(1e-9, hz * dt):.1%})")
print("\n层 计数 帧/s 判读")
ck = can_pick(c0, c1)
can_rate = None
if ck is not None:
rx0 = c0[ck].get("rx") or 0
dr0 = c0[ck].get("drop") or 0
rx1 = c1[ck].get("rx") or 0
dr1 = c1[ck].get("drop") or 0
err, tec, rec, bus = (c1[ck].get(k) for k in ("err", "tec", "rec", "bus_state"))
can_rate = wrap(rx0, rx1, 32) / dt # rx / drop 都是 u32
can_drop = wrap(dr0, dr1, 32) / dt
print(f"L1 CAN err/TEC/REC/bus —— {err}/{tec}/{rec}/{bus}"
f" {'干净' if not (err or tec or rec) else '有错'} (通道 {ck})")
print(f"L2 CAN 软件队列满丢 {can_drop:10.0f} "
f"{'0 = 队列(深度16)没满过' if not can_drop else '在丢帧,查从站取帧速率'}")
print(f"L3 CAN 控制器收帧 {can_rate:10.0f}")
else:
print("L1/L2/L3 ESP32 /can/feedback 不可读 —— 不填 0,这三层本轮无数据")
print(f"L3 ECAT桥 进桥 rx_count {bridge:10.0f}")
if can_rate is not None:
gap = can_rate - bridge
print(f" CAN - 桥 {gap:10.0f} "
f"{'≈0 两层同域' if abs(gap) < bridge * 0.01 else '差额:到了 CAN 但没进桥计数'}")
print(f"L4 段4窗 入窗帧 Δseq/2 {win_rate if win_rate else float('nan'):10.0f}"
f" 精确值(窗口 seq 是累计计数器,与抽样率无关)")
print(f" STATUS(=桥−入窗) {bridge - (win_rate or 0):10.0f} "
f"STATUS 在入窗前就 return,固件 ethercat_can_bridge.cpp:435")
print(f" 本脚本见到的唯一入窗帧 {got:10.0f} 下界:轮询只覆盖 {n_state / max(1e-9, hz * dt):.1%} 的拍")
print(f"L5 单拍值 主站空洞 lost 最大 {lost_max:10.0f} 每拍回填清零,只能当下界")
print(f" GD32 OVF pending 最大 {pend_max:10.0f} 同上")
print(f" 主站上报 count 最大 {cnt_max:10.0f} 单拍上报帧数(窗口 5 槽)")
print(f"-- 段1/段3 单格 drop {drop_single:10.0f} "
f"恒等于 λ−Hz = {lam - hz:.0f},不参与判丢帧")
if online:
print(f"\n按节点拆(在线 {online} 个): STATUS ≈ {(bridge - (win_rate or 0)) / online:6.0f} 帧/s/节点,"
f"入窗 ≈ {(win_rate or 0) / online:6.0f} 帧/s/节点")
if per_id:
print("\n入窗帧按 ID(唯一 seq 计数,下界):")
for _id in sorted(per_id):
n = len(per_id[_id])
print(f" 0x{_id:08X}: {n / dt:8.0f} 帧/s 子码 0x{_id & 0xFFFF:04X}")
return 0
if __name__ == "__main__":
sys.exit(main())
-59
View File
@@ -1,59 +0,0 @@
param([int]$Seconds = 8)
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class W {
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n);
[DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string c, string t);
}
"@
$SW_RESTORE = 9
$SW_MINIMIZE = 6
$win = $null
Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -match 'ESP32' } | ForEach-Object {
$win = $_; Write-Output ("found window: pid={0} title='{1}'" -f $_.Id, $_.MainWindowTitle)
}
if (-not $win) { Write-Output 'NO ESP32 WINDOW FOUND'; exit 1 }
$hwnd = $win.MainWindowHandle
Write-Output ("before: minimized=" + [W]::IsIconic($hwnd))
function Snap {
$h = @{}
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match 'msedgewebview2|ecat_native|pythonw' })) {
try { $h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = [math]::Round($p.WorkingSet64/1MB,0) } } catch {}
}
return $h
}
function Measure-Phase($label, $dt) {
$a = Snap
Start-Sleep -Seconds $dt
$b = Snap
$rows = foreach ($k in $b.Keys) {
if ($a.ContainsKey($k)) {
$d = ($b[$k].cpu - $a[$k].cpu)
if ($d -gt 0) {
[pscustomobject]@{ Phase = $label; Pid = [int]$k; Name = $b[$k].name; Pct1Core = [math]::Round($d/$dt*100,1); WS_MB = $b[$k].ws }
}
}
}
return $rows
}
$r1 = Measure-Phase 'visible' $Seconds
Write-Output '--- minimize ---'
[void][W]::ShowWindow($hwnd, $SW_MINIMIZE)
Start-Sleep -Seconds 2
$r2 = Measure-Phase 'minimized' $Seconds
Write-Output '--- restore ---'
[void][W]::ShowWindow($hwnd, $SW_RESTORE)
Start-Sleep -Seconds 2
$r3 = Measure-Phase 'restored' $Seconds
$all = @() + $r1 + $r2 + $r3
$out = $all | Sort-Object Phase, Pct1Core -Descending
$out | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
foreach ($ph in @('visible','minimized','restored')) {
$s = ($out | Where-Object { $_.Phase -eq $ph } | Measure-Object -Property Pct1Core -Sum).Sum
Write-Output ("TOTAL {0,-10} = {1,7:N1} % of one core" -f $ph, $s)
}
-31
View File
@@ -1,31 +0,0 @@
$ErrorActionPreference = 'Stop'
$cpus = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors
Write-Output "logical_cpus=$cpus"
$pat = 'msedgewebview2|ecat_native|pythonw|python'
function Snap {
$h = @{}
foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match $pat })) {
$h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = $p.WorkingSet64 }
}
return $h
}
$a = Snap
Start-Sleep -Seconds 8
$b = Snap
$dt = 8
$rows = foreach ($k in $b.Keys) {
if ($a.ContainsKey($k)) {
$d = ($b[$k].cpu - $a[$k].cpu)
if ($d -gt 0) {
[pscustomobject]@{
pid = [int]$k
name = $b[$k].name
pct = [math]::Round($d / $dt * 100, 1)
wsMB = [math]::Round($b[$k].ws / 1MB, 0)
}
}
}
}
$rows | Where-Object { $_.pct -ge 0.3 } | Sort-Object pct -Descending | Format-Table -AutoSize | Out-String -Width 200 | Write-Output
$total = ($rows | Measure-Object -Property pct -Sum).Sum
Write-Output ("total_pct_of_one_core=" + [math]::Round($total,1))
-54
View File
@@ -1,54 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ECAT native 主站的采样公共件:HTTP 调用 + 计数器差分。
固件 1.6.15 覆盖窗口布局下,各计数器的口径并不一致,混用会静默算错:
* bridge.rx_count / head.global_seq —— u32,**回绕**
* bridge.drop —— u16,**回绕**;段 1/段 3 单格"未取先覆"数,恒等于 λ − 实测Hz
* rx_batch.lost(主站按 seq 空洞算的单拍丢帧) / pending(从站窗口 OVF) ——
**每拍回填清零的单拍值**(ecat_native.c:461),不是累计计数:拿它做差得速率恒为 0,
拿轮询见过的最大值当总丢帧也只是下界
* app/load.rx_torn / pdo_reentry —— u16,**饱和**(到 65535 就停,不是回绕)
饱和量按回绕算会把"已封顶"误报成"发生了 6 万多次",故二者分开处理。
"""
import json
import urllib.request
SATURATE = 65535 # u16 饱和计数的顶
def post(url, path, body=None, timeout=6.0):
req = urllib.request.Request(
url + path, data=json.dumps(body or {}).encode("utf-8"),
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def delta_wrap(a, b, bits=16):
"""回绕计数器的增量。真值下降(重启清零)会表现为一个巨大的正数,
调用方须用 sanity 上界剔除 —— 这里只负责把模数算对。"""
m = 1 << bits
return (b - a) % m
def delta_saturate(a, b):
"""饱和计数的增量。返回 (增量, 是否已封顶)。
封顶后增量恒为 0 —— 读数 0 既可能是"真没发生"也可能是"早发生了但计不动",
报告里必须写成下界而不是精确值。
"""
if b >= SATURATE:
return 0, True
if b < a:
return 0, False # 计数器被回填清零:该窗口作废,不臆造增量
return b - a, False
def percentile(values, q):
"""小样本分位数(最近邻)。不引 numpy,采样窗口只有百来个点。"""
if not values:
return None
s = sorted(values)
i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))
return s[i]
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""GD32 固件经 ESP32 中继刷写(tools/flash_gd32.py 删除后的等价实现)。
流程(与 ESP32 侧 http_handlers.cpp 的 ota_upload_handler /
ota_relay_trigger_handler 契约一致):
1. Release 的 Intel HEX 展平成从 0x08048000 起的连续 BIN(间隙填 0xFF);
2. POST /ota/upload,请求体 = {"total_size":N} 紧跟 BIN 字节;
3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做
包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒);
4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是
期望版本;**并且必须拿到"GD32 真的在跑新代码"的证据**:版本号相对刷前变了,
或 gd32_adc.uptime_sec 归零(复位过)。两者都没有 → 判"未生效",不写台账、
非零退出。(实测踩过:01:44 刷进 1.6.22 后板子又跑了 2 小时老镜像,
布局/耗时全程不变,台账却已更新 —— 写入 ≠ 在跑。)
5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。
用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0]
"""
import hashlib
import json
import os
import struct
import sys
import time
import urllib.error
import urllib.request
ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127")
APP_BASE = 0x08048000
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
HEX = os.path.join(ROOT, "DCU_APP", "out", "GD32E508_DCU_APP", "GD32E508VE",
"Release", "GD32E508_DCU_APP.hex")
LEDGER = os.path.join(HERE, "..", "lab", "_last_gd32_sha.txt")
def hex_to_flat(path):
"""Intel HEX → 从 APP_BASE 起的连续字节。间隙填 0xFF(擦除态)。"""
mem = {}
base = 0
with open(path, "r", encoding="ascii", errors="ignore") as f:
for ln, line in enumerate(f, 1):
line = line.strip()
if not line.startswith(":"):
continue
raw = bytes.fromhex(line[1:])
cnt, addr, rtype = raw[0], struct.unpack(">H", raw[1:3])[0], raw[3]
data = raw[4:4 + cnt]
if len(raw) != cnt + 5 or (sum(raw) & 0xFF) != 0:
raise SystemExit(f"{path}:{ln} 校验和不对")
if rtype == 0x00:
for i, b in enumerate(data):
mem[base + addr + i] = b
elif rtype == 0x02:
base = struct.unpack(">H", data[:2])[0] << 4
elif rtype == 0x04:
base = struct.unpack(">H", data[:2])[0] << 16
elif rtype == 0x01:
break
if not mem:
raise SystemExit("HEX 里没有数据记录")
lo, hi = min(mem), max(mem)
if lo > APP_BASE:
raise SystemExit(f"镜像起点 0x{lo:08X} 高于 APP 基址 0x{APP_BASE:08X},不是 APP 包")
if lo < APP_BASE:
raise SystemExit(f"镜像含 0x{lo:08X} 的地址,落在 APP 区之外(疑似 BOOT 包)")
out = bytearray(b"\xFF" * (hi - APP_BASE + 1))
for a, b in mem.items():
out[a - APP_BASE] = b
return bytes(out)
def post(path, body, timeout=60):
req = urllib.request.Request(f"http://{ESP}{path}", data=body, method="POST")
req.add_header("Content-Type", "application/octet-stream")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def get(path, timeout=10):
with urllib.request.urlopen(f"http://{ESP}{path}", timeout=timeout) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def main():
want = sys.argv[1] if len(sys.argv) > 1 else None
if not os.path.isfile(HEX):
raise SystemExit(f"找不到 {HEX}")
blob = hex_to_flat(HEX)
sha = hashlib.sha256(blob).hexdigest()
print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}")
hdr = json.dumps({"total_size": len(blob)}).encode("ascii")
b_ver, b_up = "", None
try:
b = get("/system/status")
b_ver = str((b.get("ota") or {}).get("gd32_app") or "").lstrip("v")
b_up = (b.get("gd32_adc") or {}).get("uptime_sec")
except Exception as e:
print(f"刷前 /system/status 读失败({type(e).__name__}):生效判定只能靠 uptime")
print(f"刷前: gd32_app=v{b_ver or '?'} gd32 uptime={b_up}")
t0 = time.time()
st, body = post("/ota/upload", hdr + blob, timeout=120)
print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)")
if st != 200:
raise SystemExit("上传失败")
t0 = time.time()
st, body = post("/ota/relay", json.dumps({"sha256": sha}).encode("ascii"), 60)
print(f"relay -> {st} {body[:300]} ({time.time()-t0:.1f}s)")
deadline = time.time() + 90
last = ""
while time.time() < deadline:
time.sleep(2)
try:
s = get("/system/status")
except Exception as e:
print(f" 等待中… status 读失败 {type(e).__name__}")
continue
o = s.get("ota", {})
line = (f" sys_state={o.get('sys_state')} gd32_app={o.get('gd32_app')} "
f"alive={o.get('gd32_app_alive')} uptime={s.get('uptime_sec')}")
if line != last:
print(line)
last = line
if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"):
ver = str(o["gd32_app"]).lstrip("v")
a_up = (s.get("gd32_adc") or {}).get("uptime_sec")
if want and ver != want:
raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效")
changed = bool(b_ver) and ver != b_ver
rebooted = a_up is not None and (b_up is None or a_up < b_up)
if not changed and not rebooted:
raise SystemExit(
f"写入完成,但没有 GD32 换跑新代码的证据:板上仍是 v{ver}、"
f"uptime={a_up}(刷前 {b_up})—— 老镜像还在跑,台账不更新。"
"断电重启(或复位 APP)后重跑本脚本再确认")
with open(LEDGER, "w", encoding="ascii") as f:
f.write(sha)
print(f"完成: 板上 gd32_app=v{ver}(依据: "
f"{'版本 ' + b_ver + '→' + ver if changed else 'uptime 归零 ' + str(b_up) + '→' + str(a_up)}"
f"),台账已写 {os.path.normpath(LEDGER)}")
return
raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试")
if __name__ == "__main__":
main()
-112
View File
@@ -1,112 +0,0 @@
#!/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()
-188
View File
@@ -1,188 +0,0 @@
#!/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())
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""把 ECAT 侧收到的每一拍信号全量落盘,供事后分析(不在线判优劣)。
去重规则只有一条:同一 (node,seq) 或同一 (id,data) 帧只在第一次出现时写一行 ——
重复采样不携带新信息,去掉后可以把时长拉长。其余一律保留。
行格式(CSV,第 1 列是相对起始的秒):
M,t,node,ch,seq,online,dlc,hex36 段2 电机槽(STATUS 前 36B,含 cpu%@31/init@32-35)
F,t,kind,ch,dlc,id,hex36 段3 xrx / 段4 rx_batch 的帧
H,t,cycles,wkc,gseq,win_base,win_count,active,rx,tx,drop 每 20 拍一条头部与桥计数
用法: python scripts/rec_ecat_slots.py [秒数=120] [端口=8090] [输出文件]
"""
import csv
import json
import socket
import struct
import sys
import time
HOST = "127.0.0.1"
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 120.0
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8090
OUT = sys.argv[3] if len(sys.argv) > 3 else "ecat_slots_raw.csv"
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
"Content-Type: application/json\r\nContent-Length: 2\r\n"
"Connection: close\r\n\r\n{}").encode("ascii")
def once():
s = socket.create_connection((HOST, PORT))
try:
s.sendall(REQ)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
_, _, d = bytes(buf).partition(b"\r\n\r\n")
return json.loads(d.decode("utf-8", "replace"))
def main():
seen_m, seen_f = set(), set()
polls = rows = 0
t0 = time.time()
with open(OUT, "w", newline="", encoding="ascii") as f:
w = csv.writer(f)
w.writerow(["#kind", "t", "a", "b", "c", "d", "e", "f"])
while True:
dt = time.time() - t0
if dt >= DUR_S:
break
try:
snap = once()
except Exception:
continue
polls += 1
h = snap.get("head") or {}
b = snap.get("bridge") or {}
if polls % 20 == 1:
w.writerow(["H", f"{dt:.6f}", snap.get("cycles"), snap.get("wkc"),
h.get("global_seq"), h.get("win_base"), h.get("win_count"),
h.get("active"), b.get("rx_count"), b.get("tx_count"),
b.get("drop")])
for m in snap.get("motors") or []:
if not m.get("data"):
continue
key = (m["node"], m["ch"], m["seq"])
if key in seen_m:
continue
seen_m.add(key)
if len(seen_m) > 400000: # 去重集合本身的内存上限
seen_m = set(list(seen_m)[-200000:])
d = bytes.fromhex(m["data"])[:36]
w.writerow(["M", f"{dt:.6f}", m["node"], m["ch"], m["seq"],
int(bool(m.get("online"))), m.get("dlc"), d.hex()])
rows += 1
def emit_frames(fr, kind):
if not fr or not fr.get("data"):
return
d = bytes.fromhex(fr["data"])[:36]
key = (fr.get("id"), d.hex())
if key in seen_f:
return
seen_f.add(key)
w.writerow([kind, f"{dt:.6f}", fr.get("ch"), fr.get("dlc"),
fr.get("id"), "", "", d.hex()])
emit_frames(snap.get("xrx"), "X")
for x in ((snap.get("rx_batch") or {}).get("frames") or []):
emit_frames(x, "B")
if len(seen_f) > 400000:
seen_f = set(list(seen_f)[-200000:])
print(f"落盘 {OUT}: 轮询 {polls} 次 / {DUR_S:.0f}s,写入 {rows} 行 "
f"(电机槽 {len(seen_m)} 帧, 接收帧 {len(seen_f)})")
if __name__ == "__main__":
main()
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""复现实验:高频回发 CMD(与电机当前目标一致的 no-op)+ 全拍监控。
用户反馈:数据错乱要"频繁发指令"才出现。本脚本把 TX 压力拉起来,同时
段2(全拍)与段4(全拍)双路监控,命中即记录 dlc/完整48B/当时CONFIG/时刻,
用于判定残余拼接的方向与机制。
安全性:CMD 载荷 = 电机当前自报 CONFIG 的回声(mode/limitV/cur/vel/ang)
+ 页面默认 PID(100/100/100),不改变电机行为。
用法: python scripts/repro_cmdload.py [秒数=90] [发送Hz=500]
"""
import json
import math
import socket
import struct
import sys
import threading
import time
HOST, PORT = "127.0.0.1", 8090
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 90.0
SEND_HZ = float(sys.argv[2]) if len(sys.argv) > 2 else 500.0
def post(path, body=b"{}"):
req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n"
"Connection: close\r\n\r\n").encode("ascii") + body
s = socket.create_connection((HOST, PORT))
try:
s.settimeout(5)
s.sendall(req)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace"))
def build_cmd(cfg, ang, vel):
"""CMD 帧:mode3 速度+位置,limitV 回声当前 CONFIG,角度=正弦轨迹。
vel 按节点取 200/150 —— 给 CONFIG 回显的 B7-10 速度字段打节点指纹。"""
b = bytearray(32)
b[0] = 3 # mode: Velocity_Angle
b[1:3] = cfg[1:3] # limitV u16 回声
b[3:7] = struct.pack("<f", 0.0) # target cur 0
b[7:11] = struct.pack("<f", vel) # vel limit = 节点指纹
b[11:15] = struct.pack("<f", ang) # target angle = sine
for off in (15, 17, 19):
struct.pack_into("<h", b, off, 100)
return bytes(b).hex()
stop = threading.Event()
lock = threading.Lock()
stats = {"sends": 0, "send_err": 0, "t0": time.time()}
torn_events = [] # (t, node, dlc, data48hex, cfg_hex)
batch_events = []
fp_events = [] # 过滤后仍残余的指纹违规
fp_total = [0] # (弃用,保留兼容)
cfg_total = [0] # 监控实际消费的 CONFIG 帧总数(活性自证,非 0 才有资格谈违规=0)
fp_after = [0] # 过滤器放行掉的违规(应为 0)
fp_dropped = [0] # 过滤器正确丢弃的违规
fp_falsekill = [0] # 过滤器误杀的真帧(应为 0)
cfg_seen = {} # node -> 最近 4 个已接受 CONFIG hex(页面过滤器模拟)
cfg_susp = {} # node -> 连续疑似计数
torn_base = [0]
def sender():
"""每拍把两电机当前 CONFIG 回声成 CMD 发出去。"""
cfgs = {}
interval = 1.0 / SEND_HZ
while not stop.is_set():
t0 = time.time()
try:
rr = post("/api/motor/rxraw")
raw = bytes.fromhex(rr.get("raw") or "")
off = 0
for _ in range(rr.get("count") or 0):
if off + 8 > len(raw):
break
dlc = raw[off + 1]
fid = struct.unpack_from("<I", raw, off + 4)[0]
w = min(dlc, 32)
cfgs[(fid >> 16) & 0xFF] = raw[off + 8: off + 8 + w]
off += 8 + w
except Exception:
time.sleep(0.05)
continue
if len(cfgs) >= 2 or time.time() - stats["t0"] > 3:
# 电机偶尔暂停 CONFIG 自报(如正弦轰炸后):拿不到回声就用页面默认
# 限压 24.0V(u16 LE 0x5DC0 → bytes c0 5d)兜底,保证指令压力不断。
fallback = bytes([0x03, 0xc0, 0x5d]) + bytes(21)
s = math.sin(2.0 * math.pi * (time.time() - stats["t0"]) / 10.0)
frames = []
for n in (2, 3):
c = cfgs.get(n, fallback)
ang = 1000.0 + (200.0 * s if n == 2 else -200.0 * s)
vel = 200.0 if n == 2 else 150.0
frames.append({"ch": 2, "id": hex((n << 16) | 0x0200), "dlc": 32,
"data": build_cmd(c, ang, vel)})
try:
post("/api/motor/send", json.dumps({"frames": frames}).encode())
with lock:
stats["sends"] += 1
except Exception:
with lock:
stats["send_err"] += 1
dt = interval - (time.time() - t0)
if dt > 0:
time.sleep(dt)
def is_cfg_head(d, cfg):
"""段2槽头 8B 是否等于该节点当前 CONFIG 头(污染判据,比量程更准)。"""
return cfg and len(d) >= 8 and d[:8] == cfg[:8] and d[0] == 0x03
def monitor():
seen_s, seen_r = set(), set()
cfgs = {}
while not stop.is_set():
try:
st = post("/api/motor/state")
except Exception:
time.sleep(0.01)
continue
cyc = st.get("cycles")
if cyc in seen_s:
continue
seen_s.add(cyc)
if len(seen_s) > 400000:
seen_s.clear()
for m in st.get("motors") or []:
if not m.get("online") or len(m.get("data") or "") < 96:
continue
d = bytes.fromhex(m["data"])
mcu = struct.unpack_from("<h", d, 0)[0] * 0.01
cfg = cfgs.get(m["node"])
if not (-40.0 <= mcu <= 125.0) or is_cfg_head(d, cfg):
with lock:
if len(torn_events) < 40:
torn_events.append((round(time.time() - stats["t0"], 2),
m["node"], m.get("dlc"), d.hex(),
(cfg or b"").hex()))
try:
rr = post("/api/motor/rxraw")
except Exception:
continue
rc = rr.get("cycle")
if rc in seen_r:
continue
seen_r.add(rc)
if len(seen_r) > 400000:
seen_r.clear()
raw = bytes.fromhex(rr.get("raw") or "")
off = 0
for _ in range(rr.get("count") or 0):
if off + 8 > len(raw):
break
dlc = raw[off + 1]
fid = struct.unpack_from("<I", raw, off + 4)[0]
w = min(dlc, 32)
d = raw[off + 8: off + 8 + w]
node = (fid >> 16) & 0xFF
if (fid & 0xFFFF) == 0x0101:
cfgs[node] = d
cfg_total[0] += 1 # 活性自证:本拍监控实际看到的 CONFIG 帧数
if any(d[15:24]):
with lock:
if len(batch_events) < 40:
batch_events.append((round(time.time() - stats["t0"], 2),
node, d.hex()))
# CONFIG↔CONFIG 拼接:结构校验抓不到,用速度指纹暴露。
# 预热 3s:sender 尚未生效时电机回显的是页面/上一轮的 vel,
# 不是拼接,不能算违规。
if len(d) >= 15 and time.time() - stats["t0"] > 3.0:
vel = struct.unpack_from("<f", d, 7)[0]
exp_vel = 200.0 if node == 2 else 150.0
viol = abs(vel - exp_vel) > 1.0
with lock:
# 模拟页面 CONFIG 跨节点拼接过滤器(transport-ecat.js
# dispatchFrame 同逻辑),统计过滤后残余违规与误杀。
hx = d.hex()
seen = cfg_seen.setdefault(node, [])
own = hx in seen
foreign = (not own) and any(
hx in s2 for n2, s2 in cfg_seen.items() if n2 != node)
if own or not foreign or cfg_susp.get(node, 0) >= 5:
cfg_susp[node] = 0
seen.insert(0, hx)
del seen[4:]
if viol:
fp_after[0] += 1
if len(fp_events) < 40:
fp_events.append((round(time.time() - stats["t0"], 2),
node, vel, exp_vel, hx))
else:
cfg_susp[node] = cfg_susp.get(node, 0) + 1
if viol:
fp_dropped[0] += 1
else:
fp_falsekill[0] += 1
off += 8 + w
def main():
try:
r = post("/api/app/load")
torn_base[0] = r.get("rx_torn") or 0
except Exception:
pass
ts = threading.Thread(target=sender, daemon=True)
tm = threading.Thread(target=monitor, daemon=True)
ts.start(); tm.start()
time.sleep(DUR_S)
stop.set(); ts.join(3); tm.join(3)
try:
r = post("/api/app/load")
torn_now = r.get("rx_torn") or 0
except Exception:
torn_now = -1
with lock:
print(f"时长 {DUR_S:.0f}s 发送 {stats['sends']} 次(错{stats['send_err']}) "
f"≈{stats['sends']/DUR_S:.0f} CMD拍/s")
print(f"rx_torn: {torn_base[0]} -> {torn_now} (Δ={torn_now-torn_base[0]})")
print(f"段2 污染事件: {len(torn_events)}(前 8)")
for e in torn_events[:8]:
print(f" t={e[0]}s node={e[1]} dlc={e[2]}")
print(f" slot: {e[3]}")
print(f" cfg : {e[4]}")
print(f"段4 污染事件: {len(batch_events)}(前 8)")
for e in batch_events[:8]:
print(f" t={e[0]}s node={e[1]} data={e[2]}")
print(f"CONFIG↔CONFIG 指纹(预热后): 违规={fp_after[0]+fp_dropped[0]} "
f"过滤器正确丢弃={fp_dropped[0]} 过滤后残余={fp_after[0]} "
f"误杀真帧={fp_falsekill[0]}(样本 {len(fp_events)})")
print(f"监控消费的 CONFIG 帧总数: {cfg_total[0]}(活性自证:>0 才说明违规=0 是真结果)")
for e in fp_events[:8]:
print(f" t={e[0]}s node={e[1]} vel={e[2]:.1f} 应为{e[3]:.0f} data={e[4]}")
if __name__ == "__main__":
main()
-130
View File
@@ -1,130 +0,0 @@
#!/usr/bin/env python3
"""在「以太网 2」(I210) 上关闭非 EtherCAT 绑定,测跳拍率;可 --restore 还原。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd, timeout=60):
r = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
)
return (r.stdout or "").strip(), (r.stderr or "").strip()
def snapshot():
out, err = ps(
f"Get-NetAdapterBinding -Name '{NIC}' | "
"Select-Object DisplayName,ComponentID,Enabled | ConvertTo-Json -Compress"
)
data = json.loads(out)
return data if isinstance(data, list) else [data]
def disable(component_ids):
logs = []
for cid in component_ids:
out, err = ps(
f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
)
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
return logs
def enable(component_ids):
logs = []
for cid in component_ids:
out, err = ps(
f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; "
f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | "
"Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"
)
logs.append({"cid": cid, "out": out[:300], "err": err[:300]})
return logs
def set_prio(level="RealTime"):
p = int(subprocess.check_output(
["powershell", "-NoProfile", "-Command", "(Get-Process ecat_native).Id"],
text=True).strip())
subprocess.check_output(
["powershell", "-NoProfile", "-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
text=True)
return p
def meter(tag, secs=18):
r = subprocess.run(
[sys.executable, os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json"))
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
# Offloads that are useless/dangerous for raw EtherCAT on a dedicated NIC
OFFLOADS = [
"vmware_bridge", # VMware Bridge — intercepts/injects, classic ECAT killer
"ms_pacer", # QoS packet scheduler
"ms_lldp", # LLDP
"ms_rspndr", # LLTD responder
"ms_lltdio", # LLTD mapper
"ms_server", # file/printer sharing
"ms_msclient", # Microsoft network client
# keep ms_tcpip / ms_tcpip6 for now in case anything uses IP on this NIC
# Npcap must stay: INSECURE_NPCAP
]
def main():
os.makedirs(LAB, exist_ok=True)
snap = snapshot()
snap_path = os.path.join(LAB, f"nic_bind_{int(time.time())}.json")
json.dump(snap, open(snap_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("bind snapshot", snap_path)
if "--restore" in sys.argv:
print(enable(OFFLOADS))
return 0
print("disable", OFFLOADS)
print(json.dumps(disable(OFFLOADS), ensure_ascii=False, indent=2))
print("set RealTime")
print("pid", set_prio("RealTime"))
# binding change usually doesn't reset link; brief settle
time.sleep(3)
data = meter("bind_off", 18)
row = {
"late_per_s": data["late_per_s"],
"skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"],
"achieved_hz": data["achieved_hz"],
"n_late": data["n_late"],
"send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
"work_p90": data["window_cyc_us"]["work"]["p90"],
}
print("ROW", json.dumps(row, ensure_ascii=False, indent=2))
out = os.path.join(LAB, f"summary_bind_off_{int(time.time())}.json")
json.dump({"row": row, "disabled": OFFLOADS, "snap": snap_path},
open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", out)
if __name__ == "__main__":
main()
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd):
r = subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace")
return r.strip()
def set_prio(level="RealTime"):
p = int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass")
return p
def meter(tag, secs=18):
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def main():
restore = "--restore" in sys.argv
ids = ["ms_tcpip", "ms_tcpip6"]
for cid in ids:
if restore:
ps(f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
else:
ps(f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false")
print(ps(f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | Select-Object ComponentID,Enabled | ConvertTo-Json -Compress"))
if restore:
return
print("pid", set_prio("RealTime"))
time.sleep(3)
data = meter("bind_noip", 18)
row = {
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"], "achieved_hz": data["achieved_hz"],
"n_late": data["n_late"],
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
}
print(json.dumps(row, ensure_ascii=False, indent=2))
out = os.path.join(LAB, f"summary_bind_noip_{int(time.time())}.json")
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", out)
if __name__ == "__main__":
main()
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env python3
"""0.25ms 下 A/B:静默 vs 模拟 spi_test 前端轮询。"""
import json, time, urllib.request, os
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def post(p, b=None, timeout=5):
d = json.dumps(b or {}).encode()
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
with urllib.request.urlopen(r, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def snap():
st = post("/api/motor/state")
dbg = post("/api/motor/dbg")
return {
"cycles": st.get("cycles") or 0,
"skip_total": dbg.get("skip_total") or 0,
"gseq": (st.get("head") or {}).get("global_seq") or 0,
"period": st.get("period_ms"),
"wkc": st.get("wkc"),
"pcore": st.get("pcore_mask"),
"cyc_us": st.get("cyc_us"),
}
def rate(a, b, dt):
dc = b["cycles"] - a["cycles"]
ds = (b["skip_total"] - a["skip_total"]) # may wrap? long long, no
return {
"dt": round(dt, 3),
"pdo_hz": round(dc / dt, 1) if dt else None,
"late_per_s": round(ds / dt, 2) if dt else None,
"skip_frac": round(100.0 * ds / dc, 2) if dc else None,
"gseq_per_s": round(((b["gseq"] - a["gseq"]) & 0xFFFFFFFF) / dt, 1) if dt else None,
"dcyc": dc, "dskip": ds,
}
def arm_period(ms):
try:
post("/api/motor/stop")
except Exception:
pass
time.sleep(0.15)
post("/api/motor/period", {"period_ms": ms})
time.sleep(0.15)
post("/api/motor/start")
time.sleep(0.5)
print("ensure 0.25ms")
arm_period(0.25)
time.sleep(3)
# --- A: fully quiet ---
a0 = snap()
t0 = time.time()
# no HTTP
time.sleep(10)
a1 = snap()
A = rate(a0, a1, time.time() - t0)
print("QUIET", json.dumps(A, ensure_ascii=False))
print(" cyc_us", json.dumps(a1.get("cyc_us"), ensure_ascii=False))
# --- B: hammer like spi_test (state+load+dbg+rxraw) ---
time.sleep(1)
b0 = snap()
t0 = time.time()
n = 0
while time.time() - t0 < 10:
try:
post("/api/motor/state")
post("/api/app/load")
post("/api/motor/dbg")
post("/api/motor/rxraw")
n += 1
except Exception as e:
print("hammer err", e)
break
time.sleep(0.05) # ~20Hz full set, similar to busy panel
b1 = snap()
B = rate(b0, b1, time.time() - t0)
print("HAMMER", json.dumps(B, ensure_ascii=False), "polls", n)
print(" cyc_us", json.dumps(b1.get("cyc_us"), ensure_ascii=False))
# --- C: only motor/state at max (no dbg/rxraw) ---
time.sleep(1)
c0 = snap()
t0 = time.time()
n = 0
while time.time() - t0 < 10:
try:
post("/api/motor/state")
n += 1
except Exception:
break
time.sleep(0.05)
c1 = snap()
C = rate(c0, c1, time.time() - t0)
print("STATE_ONLY", json.dumps(C, ensure_ascii=False), "polls", n)
out = {"quiet": A, "hammer": B, "state_only": C,
"quiet_cyc": a1.get("cyc_us"), "hammer_cyc": b1.get("cyc_us")}
path = os.path.join(LAB, f"summary_fe_ab_{int(time.time())}.json")
json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
-126
View File
@@ -1,126 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""快照当前主机侧可回滚现场:I210 高级属性、中断亲和、ecat 进程。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def ps(cmd: str) -> str:
r = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
return (r.stdout or "") + (("ERR:" + r.stderr) if r.stderr else "")
def main() -> int:
os.makedirs(LAB, exist_ok=True)
snap = {"ts": time.time(), "host": os.environ.get("COMPUTERNAME")}
snap["adapters"] = ps(
"Get-NetAdapter | Select-Object Name,InterfaceDescription,LinkSpeed,Status,"
"ifIndex,MacAddress | ConvertTo-Json -Compress"
)
# I210 specifically
snap["i210"] = ps(
"Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'I210'} | "
"Select-Object Name,ifIndex,Status,LinkSpeed | ConvertTo-Json -Compress"
)
# advanced properties (restore-able)
snap["i210_adv"] = ps(
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
"Select-Object Name,DisplayName,DisplayValue,RegistryKeyword,RegistryValue | "
"ConvertTo-Json -Compress"
)
# interrupt moderation / RSS / power
for kw in (
"*InterruptModeration",
"*ReceiveBuffers",
"*TransmitBuffers",
"*EEE",
"*EnergyEfficient",
"*FlowControl",
"*PriorityVLANTag",
"*TCPChecksumOffload*",
"*IPChecksumOffload*",
"*VMQ",
"*RSS",
"*InterruptModerationRate",
):
snap[f"adv_{kw.strip('*').replace('*','_')}"] = ps(
f"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
f"Where-Object {{$_.RegistryKeyword -like '{kw}'}} | "
f"Select-Object RegistryKeyword,DisplayValue,RegistryValue | ConvertTo-Json -Compress"
)
snap["power"] = ps(
"Get-NetAdapterPowerManagement -Name '以太网 2' -ErrorAction SilentlyContinue | "
"Select-Object Name,AllowComputerToTurnOffDevice,WakeOnMagicPacket,"
"WakeOnPattern,DeviceSleepOnDisconnect | ConvertTo-Json -Compress"
)
snap["ecat_proc"] = ps(
"Get-Process ecat_native -ErrorAction SilentlyContinue | "
"Select-Object Id,Path,StartTime,CPU,WorkingSet | ConvertTo-Json -Compress"
)
snap["ecat_cmdline"] = ps(
"Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | "
"Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"
)
# interrupt affinity via Get-NetAdapterInterrupt? not always available
snap["msi_affinity"] = ps(
"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | "
"Where-Object {$_.DisplayName -match 'Interrupt|Affinity|RSS|Moderation|Buffer|Energy|Green|Flow|Offload'} | "
"Select-Object DisplayName,DisplayValue,RegistryKeyword | ConvertTo-Json -Compress"
)
# CPU topology quick
snap["cpu"] = ps(
"$env:NUMBER_OF_PROCESSORS; (Get-CimInstance Win32_Processor | "
"Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json -Compress)"
)
out = os.path.join(LAB, f"host_snap_{int(time.time())}.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False, indent=2)
# also human-readable restore snippet
lines = []
lines.append("# Host snapshot restore notes")
lines.append(f"time={snap['ts']}")
lines.append("## I210 adapter advanced (raw)")
lines.append(snap.get("i210_adv", "")[:8000])
lines.append("## power")
lines.append(snap.get("power", "")[:4000])
lines.append("## ecat cmdline")
lines.append(snap.get("ecat_cmdline", "")[:2000])
txt = os.path.join(LAB, f"host_snap_{int(snap['ts'])}.txt")
with open(txt, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print("I210:", snap.get("i210", "")[:500])
print("CMDLINE:", snap.get("ecat_cmdline", "")[:800])
print("ADV keys sample:")
print(snap.get("msi_affinity", "")[:2500])
print("WROTE", out)
print("WROTE", txt)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-86
View File
@@ -1,86 +0,0 @@
#!/usr/bin/env python3
import json, os, subprocess, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd):
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace").strip()
def post(p, b=None):
d = json.dumps(b or {}).encode()
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
def wait_ok(t=30):
t0=time.time()
while time.time()-t0<t:
try:
h=post("/api/native/health")
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3: return h
except Exception: pass
time.sleep(0.4)
return None
def set_rt():
p=int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
return p
def period(ms):
post("/api/motor/stop"); time.sleep(0.12)
post("/api/motor/period", {"period_ms": ms}); time.sleep(0.12)
post("/api/motor/start")
def quiet(sec=10):
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
c0,s0=st.get("cycles") or 0, dbg.get("skip_total") or 0
t0=time.time(); time.sleep(sec)
st=post("/api/motor/state"); dbg=post("/api/motor/dbg")
dt=time.time()-t0
c1,s1=st.get("cycles") or 0, dbg.get("skip_total") or 0
cu=st.get("cyc_us") or {}
# also sample dbg ring k distribution
ring=dbg.get("cyc") or []
ks=[r[5] for r in ring if len(r)>=6]
works=[(r[4]-r[0])*1e6 for r in ring if len(r)>=5]
sends=[(r[2]-r[1])*1e6 for r in ring if len(r)>=3]
rxs=[(r[3]-r[2])*1e6 for r in ring if len(r)>=4]
def pct(a,q):
if not a: return None
a=sorted(a); return round(a[int(q*(len(a)-1))],1)
return {
"pdo_hz": round((c1-c0)/dt,1),
"late_per_s": round((s1-s0)/dt,2),
"achieved_vs_4k": round(100*(c1-c0)/dt/4000,1),
"cyc_us": cu,
"ring_n": len(ring),
"k_gt0": sum(1 for k in ks if k>0),
"k_sum": sum(ks),
"work_pct": {"p50":pct(works,.5),"p90":pct(works,.9),"p99":pct(works,.99),"max":pct(works,1.0) if works else None},
"send_pct": {"p50":pct(sends,.5),"p90":pct(sends,.9),"max":pct(sends,1.0) if sends else None},
"rx_pct": {"p50":pct(rxs,.5),"p90":pct(rxs,.9),"max":pct(rxs,1.0) if rxs else None},
}
# set ITR 200
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword '*InterruptModeration' -RegistryValue '1' -NoRestart")
ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword 'ITR' -RegistryValue '200' -NoRestart")
ps("Restart-NetAdapter -Name '以太网 2' -Confirm:$false")
time.sleep(2)
print("health", wait_ok())
set_rt()
rows=[]
for ms in (0.25, 0.5, 1.0):
print(f"\n=== {ms} ms ITR=200 ===")
period(ms)
time.sleep(4)
set_rt()
r=quiet(10)
print(json.dumps(r, ensure_ascii=False, indent=2))
rows.append(r)
path=os.path.join(LAB, f"summary_itr200_periods_{int(time.time())}.json")
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""查 pdo 线程亲和 + 尝试 ITR 200/400 在 0.25ms 静默下的表现。"""
import json, os, subprocess, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
def ps(cmd, timeout=60):
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace",
timeout=timeout).strip()
def post(p, b=None):
d = json.dumps(b or {}).encode()
r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
def wait_ok(timeout=35):
t0 = time.time()
while time.time()-t0 < timeout:
try:
h = post("/api/native/health")
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
return h
except Exception:
pass
time.sleep(0.4)
return None
def set_rt():
p = int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
return p
def quiet_rate(sec=8):
st = post("/api/motor/state")
dbg = post("/api/motor/dbg")
c0, s0 = st.get("cycles") or 0, dbg.get("skip_total") or 0
t0 = time.time()
time.sleep(sec)
st = post("/api/motor/state")
dbg = post("/api/motor/dbg")
dt = time.time()-t0
c1, s1 = st.get("cycles") or 0, dbg.get("skip_total") or 0
return {
"pdo_hz": round((c1-c0)/dt, 1),
"late_per_s": round((s1-s0)/dt, 2),
"cyc_us": st.get("cyc_us"),
"period": st.get("period_ms"),
}
def bus_restart_period(ms):
try:
post("/api/motor/stop")
except Exception:
pass
time.sleep(0.15)
post("/api/motor/period", {"period_ms": ms})
time.sleep(0.15)
post("/api/motor/start")
def nic_set(kw, val):
out, err = "", ""
r = subprocess.run(["powershell","-NoProfile","-Command",
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' -RegistryKeyword '{kw}' -RegistryValue '{val}' -NoRestart"],
capture_output=True, text=True, encoding="utf-8", errors="replace")
return r.stdout.strip(), r.stderr.strip()
# affinity of ecat threads via .NET is limited; dump processor affinity mask
print("=== process affinity ===")
print(ps(r'''
$p = Get-Process ecat_native
"PID $($p.Id) Priority=$($p.PriorityClass)"
# ProcessorAffinity is a bitmask
"Affinity=$($p.ProcessorAffinity)"
'''))
print("=== I210 current ITR/moderation ===")
print(ps(r'''
Get-NetAdapterAdvancedProperty -Name '以太网 2' |
Where-Object { $_.RegistryKeyword -in 'ITR','*InterruptModeration','*TransmitBuffers' } |
Select-Object RegistryKeyword,DisplayValue | ConvertTo-Json -Compress
'''))
results = []
# try ITR values at 0.25ms quiet
for itr in ("200", "400", "950", "65535"):
print(f"\n===== ITR={itr} @0.25ms =====")
o, e = nic_set("*InterruptModeration", "1")
o2, e2 = nic_set("ITR", itr)
print("set", o2 or o, e2 or e)
# restart nic only if needed - ITR may need restart
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
time.sleep(2)
h = wait_ok(30)
print("health", bool(h))
if not h:
try:
post("/api/op/start"); post("/api/motor/start")
h = wait_ok(20)
except Exception as ex:
print(ex)
set_rt()
bus_restart_period(0.25)
time.sleep(3)
set_rt()
r = quiet_rate(8)
print(json.dumps({k:r[k] for k in ("pdo_hz","late_per_s","period")}, ensure_ascii=False))
print("work/send/rx", r["cyc_us"]["work"]["p50"], r["cyc_us"]["send"]["p50"], r["cyc_us"]["rx"]["p50"])
results.append({"itr": itr, **{k:r[k] for k in ("pdo_hz","late_per_s")},
"work_p50": r["cyc_us"]["work"]["p50"], "send_p50": r["cyc_us"]["send"]["p50"],
"rx_p50": r["cyc_us"]["rx"]["p50"]})
print("\nSUMMARY", json.dumps(results, ensure_ascii=False, indent=2))
# restore adaptive
nic_set("ITR", "65535")
ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false")
time.sleep(2)
wait_ok(25)
set_rt()
# leave at 1ms for safety
bus_restart_period(1.0)
time.sleep(1)
print("restored 1ms", post("/api/native/health"))
path = os.path.join(LAB, f"summary_itr_4k_{int(time.time())}.json")
json.dump(results, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
-44
View File
@@ -1,44 +0,0 @@
#!/usr/bin/env python3
import json, os, subprocess, sys, time
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def set_prio(level="RealTime"):
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
subprocess.check_output(["powershell","-NoProfile","-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
return p
def meter(tag, secs=15):
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
d = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
return {
"tag": tag,
"late_per_s": d["late_per_s"], "skip_per_s": d["skip_per_s"],
"achieved_hz": d["achieved_hz"], "n_late": d["n_late"],
"work_p50": d["window_cyc_us"]["work"]["p50"],
"rx_p50": d["window_cyc_us"]["rx"]["p50"],
"send_p50": d["window_cyc_us"]["send"]["p50"],
"work_p90": d["window_cyc_us"]["work"]["p90"],
}
def main():
set_prio("RealTime")
rows = []
for i in range(1, 4):
print(f"round {i} settle...")
time.sleep(4)
r = meter(f"multirt_{i}", 15)
print(r)
rows.append(r)
lates = [x["late_per_s"] for x in rows]
print("mean late", sum(lates)/len(lates), "min", min(lates), "max", max(lates))
path = os.path.join(LAB, f"summary_multirt_{int(time.time())}.json")
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
if __name__ == "__main__":
main()
-312
View File
@@ -1,312 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""可回滚的 I210 网卡参数实验:停环 → 改高级属性 → 恢复 OP → 标定跳拍率。
默认只测、不永久破坏:原值会写入 restore json;`--restore` 按快照还原。
改网卡会触发短暂链路重协商,期间从站可能掉 OP,脚本会等 link+OP 恢复。
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
import urllib.request
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
NIC = "以太网 2"
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
def post(path, body=None, timeout=8.0):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path, data=data, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
def ps(cmd, timeout=60):
r = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
return (r.stdout or "").strip(), (r.stderr or "").strip()
def nic_snapshot():
out, err = ps(
f"Get-NetAdapterAdvancedProperty -Name '{NIC}' | "
"Select-Object RegistryKeyword,DisplayValue,RegistryValue | "
"ConvertTo-Json -Compress"
)
if not out:
return []
data = json.loads(out)
if isinstance(data, dict):
return [data]
return data
def nic_set(keyword: str, value: str):
# value as string; Intel accepts "0"/"1"/numbers
cmd = (
f"Set-NetAdapterAdvancedProperty -Name '{NIC}' "
f"-RegistryKeyword '{keyword}' -RegistryValue '{value}' -NoRestart"
)
out, err = ps(cmd)
return out, err
def nic_restart():
out, err = ps(
f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false; Start-Sleep -Seconds 2; "
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
)
return out, err
def wait_link(timeout=20.0):
t0 = time.time()
while time.time() - t0 < timeout:
out, _ = ps(
f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress"
)
try:
j = json.loads(out)
if isinstance(j, list):
j = j[0]
if str(j.get("Status", "")).lower() == "up":
return True, j
except Exception:
pass
time.sleep(0.5)
return False, None
def wait_ecat_healthy(timeout=25.0):
t0 = time.time()
last = None
while time.time() - t0 < timeout:
try:
h = post("/api/native/health")
last = h
if h.get("loop") == 1 and int(h.get("wkc") or 0) >= 3 and h.get("op") == 1:
# also require cycles advancing
c0 = h.get("cycles") or 0
time.sleep(0.4)
h2 = post("/api/native/health")
c1 = h2.get("cycles") or 0
if c1 > c0:
return True, h2
except Exception as e:
last = {"err": str(e)}
time.sleep(0.5)
return False, last
def stop_bus():
logs = []
for path in ("/api/motor/stop", "/api/op/stop"):
try:
r = post(path)
logs.append((path, r.get("ok"), (r.get("log") or r)[:2]))
except Exception as e:
logs.append((path, False, str(e)))
return logs
def start_bus():
logs = []
try:
r = post("/api/op/start")
logs.append(("/api/op/start", r.get("ok"), (r.get("log") or [""])[:2]))
except Exception as e:
logs.append(("/api/op/start", False, str(e)))
try:
r = post("/api/motor/start")
logs.append(("/api/motor/start", r.get("ok"), (r.get("log") or [""])[:2]))
except Exception as e:
logs.append(("/api/motor/start", False, str(e)))
return logs
def meter(tag: str, secs: float):
# reuse skip_rate_meter as subprocess for isolation
cmd = [
sys.executable,
os.path.join(ROOT, "scripts", "skip_rate_meter.py"),
"--tag",
tag,
"--secs",
str(secs),
]
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
# last json file for tag
files = sorted(
[f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json")]
)
data = None
if files:
with open(os.path.join(LAB, files[-1]), encoding="utf-8") as f:
data = json.load(f)
return data, r.stdout, r.stderr
def apply_batch(changes: list[tuple[str, str]]):
results = []
for k, v in changes:
out, err = nic_set(k, v)
results.append({"key": k, "value": v, "out": out[:300], "err": err[:300]})
return results
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--restore", action="store_true", help="restore from snap json")
ap.add_argument("--snap", default=None, help="snapshot json for restore")
ap.add_argument("--exp", default=None, help="named experiment")
ap.add_argument("--secs", type=float, default=16.0)
ap.add_argument("--list-exp", action="store_true")
args = ap.parse_args()
os.makedirs(LAB, exist_ok=True)
# experiment table: list of (keyword, value)
EXPS = {
"itr_off": [
("*InterruptModeration", "0"),
("ITR", "0"),
],
"itr_low": [
("*InterruptModeration", "1"),
("ITR", "400"), # valid: 65535,3600,2000,950,400,200,0
],
"itr_200": [
("*InterruptModeration", "1"),
("ITR", "200"),
],
"itr_950": [
("*InterruptModeration", "1"),
("ITR", "950"),
],
"eee_off": [
("EEELinkAdvertisement", "0"),
],
"flow_off": [
("*FlowControl", "0"),
],
"txbuf_max": [
("*TransmitBuffers", "4096"),
],
"rxbuf_max": [
("*ReceiveBuffers", "1024"),
],
"combo_lowlat": [
("*InterruptModeration", "0"),
("ITR", "0"),
("EEELinkAdvertisement", "0"),
("*FlowControl", "0"),
("*TransmitBuffers", "2048"),
("*ReceiveBuffers", "512"),
],
}
if args.list_exp:
print(json.dumps({k: v for k, v in EXPS.items()}, indent=2))
return 0
# always keep a fresh snapshot before mutating
snap = nic_snapshot()
snap_path = os.path.join(LAB, f"nic_before_{int(time.time())}.json")
with open(snap_path, "w", encoding="utf-8") as f:
json.dump(snap, f, ensure_ascii=False, indent=2)
print(f"NIC snapshot → {snap_path}")
if args.restore:
path = args.snap or snap_path
with open(path, encoding="utf-8") as f:
data = json.load(f)
# stop, apply all, restart
print("restore: stop bus")
print(stop_bus())
n = 0
for row in data:
kw = row.get("RegistryKeyword")
val = row.get("RegistryValue")
if not kw or val is None:
continue
if isinstance(val, list):
val = val[0] if val else ""
val = str(val)
apply_batch([(kw, val)])
n += 1
print("restore applied", n, "keys; restarting NIC")
print(nic_restart())
print(wait_link())
print("start bus")
print(start_bus())
ok, h = wait_ecat_healthy()
print("healthy", ok, h)
return 0
exp = args.exp or "itr_off"
if exp not in EXPS:
print("unknown exp", exp, "use --list-exp")
return 2
changes = EXPS[exp]
print(f"\n=== EXP {exp} {changes} ===")
print("stop bus...")
print(stop_bus())
time.sleep(0.3)
print("apply...")
print(apply_batch(changes))
print("restart nic...")
print(nic_restart())
ok, info = wait_link()
print("link", ok, info)
print("start bus...")
print(start_bus())
ok, h = wait_ecat_healthy(timeout=30)
print("healthy", ok, h)
if not ok:
print("ABORT: bus not healthy after change")
return 1
# settle longer: NIC restart + re-enumeration leaves ~2–5s of elevated skip
time.sleep(8.0)
data, out, err = meter(f"exp_{exp}", args.secs)
print(out)
if err:
print("meter stderr", err[:500])
if data:
summary = {
"exp": exp,
"changes": changes,
"late_per_s": data.get("late_per_s"),
"skip_per_s": data.get("skip_per_s"),
"late_frac_pct": data.get("late_frac_pct"),
"send_stall_frac": data.get("send_stall_frac"),
"rx_stall_frac": data.get("rx_stall_frac"),
"achieved_hz": data.get("achieved_hz"),
"n_late": data.get("n_late"),
}
sp = os.path.join(LAB, f"summary_{exp}_{int(time.time())}.json")
with open(sp, "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2)
print("SUMMARY", json.dumps(summary, ensure_ascii=False))
print("WROTE", sp)
return 0
if __name__ == "__main__":
raise SystemExit(main())
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""High vs RealTime 多轮 A/B,输出对比。结束后设为 --final 指定档。"""
import json, os, subprocess, sys, time
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
def ecat_pid():
out = subprocess.check_output(
["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"],
text=True).strip()
return int(out.splitlines()[0])
def set_prio(level):
p = ecat_pid()
subprocess.check_output(
["powershell","-NoProfile","-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"],
text=True)
return level
def meter(tag, secs=12):
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def row(level, data, n):
return {
"round": n, "level": level,
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"],
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
"send_p90": (data.get("window_cyc_us") or {}).get("send",{}).get("p90"),
"work_p90": (data.get("window_cyc_us") or {}).get("work",{}).get("p90"),
}
def main():
final = "RealTime"
if len(sys.argv) > 1:
final = sys.argv[1]
rows = []
# A-B-A-B
seq = ["High","RealTime","High","RealTime"]
for i, lv in enumerate(seq, 1):
set_prio(lv)
time.sleep(1.0)
data = meter(f"ab_{lv}_{i}", 12)
r = row(lv, data, i)
rows.append(r)
print(r)
set_prio(final)
print("FINAL", final, "pid", ecat_pid())
# summary means
by = {}
for r in rows:
by.setdefault(r["level"], []).append(r)
means = {}
for lv, rs in by.items():
means[lv] = {
"n": len(rs),
"late_per_s_mean": round(sum(x["late_per_s"] for x in rs)/len(rs), 3),
"skip_per_s_mean": round(sum(x["skip_per_s"] for x in rs)/len(rs), 3),
"send_p90_mean": round(sum(x["send_p90"] or 0 for x in rs)/len(rs), 1),
"work_p90_mean": round(sum(x["work_p90"] or 0 for x in rs)/len(rs), 1),
}
print("MEANS", json.dumps(means, ensure_ascii=False, indent=2))
path = os.path.join(LAB, f"summary_ab_prio_{int(time.time())}.json")
json.dump({"rows": rows, "means": means, "final": final}, open(path,"w",encoding="utf-8"),
ensure_ascii=False, indent=2)
print("WROTE", path)
if __name__ == "__main__":
main()
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""进程优先级对照:不高/RealTime,各采 skip rate。不改网卡、不碰固件。"""
import json, os, subprocess, sys, time, urllib.request
ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
LAB = os.path.join(ROOT, "lab", "skip_meter")
BASE = "http://127.0.0.1:8090"
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(BASE+path, data=data, headers={"Content-Type":"application/json"})
with urllib.request.urlopen(req, timeout=5) as r:
return json.loads(r.read().decode())
def pid():
return post("/api/native/health") # health has no pid; use process name
# fallback below
def ecat_pid():
out = subprocess.check_output(
["powershell","-NoProfile","-Command",
"(Get-Process ecat_native).Id"],
text=True).strip()
return int(out.splitlines()[0])
def set_prio(level):
p = ecat_pid()
cmd = f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], text=True).strip()
def meter(tag, secs=16):
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
data = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) if files else None
return data, r.stdout
def main():
p = ecat_pid()
print("ecat pid", p)
cur = subprocess.check_output(
["powershell","-NoProfile","-Command", f"(Get-Process -Id {p}).PriorityClass"],
text=True).strip()
print("current PriorityClass", cur)
results = []
for level in ("High", "RealTime"):
print(f"\n=== set {level} ===")
try:
print(set_prio(level))
except Exception as e:
print("set fail", e)
continue
time.sleep(1.5)
data, out = meter(f"prio_{level}", 16)
if data:
row = {"level": level, "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"], "send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"], "achieved_hz": data["achieved_hz"],
"n_late": data["n_late"]}
results.append(row)
print("ROW", row)
print(out[-800:] if out else "")
# restore High (safe default; pdo thread already TIME_CRITICAL)
try:
print("restore High")
print(set_prio("High"))
except Exception as e:
print("restore fail", e)
path = os.path.join(LAB, f"summary_prio_{int(time.time())}.json")
json.dump({"results": results, "original": cur}, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
if __name__ == "__main__":
main()
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env python3
"""静默多档标定:1ms / 2ms / 4ms,每档先停环再设周期再开环,避免混拍。"""
import json, os, subprocess, sys, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
BASE = "http://127.0.0.1:8090"
def post(p, b=None):
d = json.dumps(b or {}).encode()
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=8).read().decode())
def set_prio(level="RealTime"):
p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip())
subprocess.check_output(["powershell","-NoProfile","-Command",
f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True)
return p
def set_period(ms):
post("/api/motor/stop")
time.sleep(0.2)
r = post("/api/motor/period", {"period_ms": ms})
time.sleep(0.2)
post("/api/motor/start")
time.sleep(0.8)
return r
def meter(tag, secs=18):
r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")])
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def main():
print("set RealTime")
print("pid", set_prio("RealTime"))
rows = []
for ms in (1.0, 2.0, 4.0):
print(f"\n=== period {ms} ms ===")
print(set_period(ms))
time.sleep(2.0) # settle
data = meter(f"quiet_{ms:g}ms", 18)
row = {
"period_ms": ms,
"late_per_s": data["late_per_s"],
"skip_per_s": data["skip_per_s"],
"late_frac_pct": data["late_frac_pct"],
"achieved_hz": data["achieved_hz"],
"n_late": data["n_late"],
"send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
"work_p90": data["window_cyc_us"]["work"]["p90"],
}
rows.append(row)
print(row)
# restore 1ms as user default
print("\nrestore period 1.0")
print(set_period(1.0))
path = os.path.join(LAB, f"summary_quiet_periods_{int(time.time())}.json")
json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", path)
print(json.dumps(rows, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
-136
View File
@@ -1,136 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""跳拍率标定:固定时长采 /api/motor/dbg,输出可对比 JSON。
主判据: late_per_s, skip_per_s, send_stall_frac, rx_stall_frac
用法:
python scripts/skip_rate_meter.py --tag base --secs 20
"""
from __future__ import annotations
import argparse
import json
import os
import time
import urllib.request
BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090")
OUTDIR = os.path.join(os.path.dirname(__file__), "..", "lab", "skip_meter")
def post(path: str, body=None, timeout=5.0):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(
BASE + path, data=data, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--tag", default="sample")
ap.add_argument("--secs", type=float, default=20.0)
ap.add_argument("--hz", type=float, default=5.0)
ap.add_argument("--out", default=None)
args = ap.parse_args()
st0 = post("/api/motor/state")
period = float(st0.get("period_ms") or 1.0)
t0 = time.time()
pulses = []
last_t = None
n_poll = 0
# dbg t0 uses now_s() (QPC-based), NOT unix time. Track by last-seen t0 only.
while time.time() - t0 < args.secs:
dbg = post("/api/motor/dbg")
n_poll += 1
cyc = dbg.get("cyc") or []
if last_t is not None:
new = [r for r in cyc if r[0] > last_t]
else:
# first poll: adopt the whole ring's last timestamp as cursor;
# count only k>0 from now on via subsequent polls. Also count
# k>0 already in ring as "pre-existing" (not in window rate).
new = []
if cyc:
last_t = cyc[-1][0]
elif last_t is None:
last_t = None
for r in new:
if len(r) < 8:
continue
k = int(r[5])
if k <= 0:
continue
send_us = (r[2] - r[1]) * 1e6
rx_us = (r[3] - r[2]) * 1e6
pulses.append(
{
"t0": r[0],
"k": k,
"send_us": send_us,
"rx_us": rx_us,
"work_us": (r[4] - r[0]) * 1e6,
"sd": float(r[6]) if len(r) > 6 else None,
"rxc": int(r[7]) if len(r) > 7 else None,
"kind": (
"send"
if send_us >= 500
else ("rx" if rx_us >= 500 else "other")
),
}
)
time.sleep(max(0.0, 1.0 / args.hz))
st1 = post("/api/motor/state")
wall = time.time() - t0
n_late = len(pulses)
n_skip = sum(p["k"] for p in pulses)
by = {"send": 0, "rx": 0, "other": 0}
for p in pulses:
by[p["kind"]] += 1
# approximate expected beats in window
# use cycles delta when available
cyc0 = st0.get("cycles") or 0
cyc1 = st1.get("cycles") or 0
dcyc = max(0, cyc1 - cyc0)
cu = st1.get("cyc_us") or {}
result = {
"tag": args.tag,
"ts": time.time(),
"wall_s": round(wall, 3),
"period_ms": period,
"pcore_mask": st1.get("pcore_mask"),
"wkc": st1.get("wkc"),
"spin": st1.get("spin"),
"cycles_delta": dcyc,
"achieved_hz": round(dcyc / wall, 2) if wall > 0 else None,
"late_per_s": round(n_late / wall, 3) if wall else None,
"skip_per_s": round(n_skip / wall, 3) if wall else None,
"late_frac_pct": round(100.0 * n_late / dcyc, 4) if dcyc else None,
"skip_frac_pct": round(100.0 * n_skip / dcyc, 4) if dcyc else None,
"send_stall_frac": round(by["send"] / n_late, 4) if n_late else None,
"rx_stall_frac": round(by["rx"] / n_late, 4) if n_late else None,
"n_late": n_late,
"n_skip": n_skip,
"by_kind": by,
"window_cyc_us": cu,
"n_poll": n_poll,
"pulses_sample": pulses[:40],
}
os.makedirs(OUTDIR, exist_ok=True)
out = args.out or os.path.join(OUTDIR, f"{args.tag}_{int(time.time())}.json")
with open(out, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
print(json.dumps({k: result[k] for k in result if k != "pulses_sample"},
ensure_ascii=False, indent=2))
print(f"WROTE {out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-110
View File
@@ -1,110 +0,0 @@
#!/usr/bin/env python3
"""重启 ecat_native 进程(让 esp32_server/父进程重拉),测 rx 是否回到 ~90µs。"""
import json, os, subprocess, sys, time, urllib.request
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LAB = os.path.join(ROOT, "lab", "skip_meter")
BASE = "http://127.0.0.1:8090"
def post(p, b=None, timeout=5):
d = json.dumps(b or {}).encode()
r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"})
return json.loads(urllib.request.urlopen(r, timeout=timeout).read().decode())
def ps(cmd):
return subprocess.check_output(["powershell","-NoProfile","-Command",cmd],
text=True, encoding="utf-8", errors="replace").strip()
def wait_health(timeout=40):
t0 = time.time()
while time.time()-t0 < timeout:
try:
h = post("/api/native/health")
if h.get("loop")==1 and int(h.get("wkc") or 0)>=3:
return True, h
except Exception:
pass
time.sleep(0.5)
return False, None
def meter(tag, secs=16):
subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"),
"--tag", tag, "--secs", str(secs)],
capture_output=True, text=True, encoding="utf-8", errors="replace")
files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json"))
return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8"))
def main():
# re-enable IP (keep VMware off — it's harmful and was only “enabled” by coincidence in morning)
for cid in ("ms_tcpip", "ms_tcpip6"):
try:
ps(f"Enable-NetAdapterBinding -Name '以太网 2' -ComponentID '{cid}' -Confirm:$false")
print("enabled", cid)
except Exception as e:
print("enable fail", cid, e)
info = ps("Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
print("before", info[:500])
print("kill ecat_native...")
ps("Stop-Process -Name ecat_native -Force -ErrorAction SilentlyContinue")
time.sleep(2)
# parent may be esp32_server — wait for auto-restart
ok, h = wait_health(45)
print("auto restart?", ok, h)
if not ok:
print("manual start")
exe = r"G:\Robot_v2\DCU\tools\ecat\build_native\ecat_native.exe"
cwd = r"G:\Robot_v2\DCU\tools\ecat"
subprocess.Popen(
[exe, "--substr", "I210", "--mask", "0x50", "--web", "src/web"],
cwd=cwd,
stdout=open(os.path.join(cwd,"build_native","native_stdout.log"),"a"),
stderr=subprocess.STDOUT,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
)
time.sleep(3)
ok, h = wait_health(30)
print("manual health", ok, h)
if ok:
try:
post("/api/op/start")
post("/api/motor/start")
except Exception as e:
print("start fail", e)
ok, h = wait_health(20)
print("after start", ok, h)
# set RealTime
try:
p = int(ps("(Get-Process ecat_native).Id"))
ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass")
print("RealTime", p)
except Exception as e:
print("prio fail", e)
print("settle 10s")
time.sleep(10)
st = post("/api/motor/state")
print("cyc", st.get("cyc_us"))
print("period", st.get("period_ms"), "wkc", st.get("wkc"), "pcore", st.get("pcore_mask"))
data = meter("restart_rt", 16)
row = {
"late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"],
"achieved_hz": data["achieved_hz"], "n_late": data["n_late"],
"work_p50": data["window_cyc_us"]["work"]["p50"],
"rx_p50": data["window_cyc_us"]["rx"]["p50"],
"send_p50": data["window_cyc_us"]["send"]["p50"],
"work_p90": data["window_cyc_us"]["work"]["p90"],
"send_stall_frac": data["send_stall_frac"],
"rx_stall_frac": data["rx_stall_frac"],
}
print(json.dumps(row, ensure_ascii=False, indent=2))
out = os.path.join(LAB, f"summary_restart_{int(time.time())}.json")
json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2)
print("WROTE", out)
if __name__ == "__main__":
main()
-335
View File
@@ -1,335 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""PDO 周期扫描:一次跑完多档,同时记「速度达成 / 故障率 / CPU 占用」三条曲线。
与 tools/ecat/rate_probe.py 的区别:rate_probe 只量速度达成率,本脚本把故障率与
CPU 一并采齐并落成机读 CSV,供 plot_ecat_sweep.py 画曲线。速率达成部分的
"cycles 与时刻同取同舍"纪律直接沿用 rate_probe(它注释里记过一次把 99.2% 误报
成 96% 的实踩教训:分子取末次 cycles、分母却在循环退出后才算,dt 多出一次 sleep)。
必须绕开的四件事(均由 Phase 0 现场标定确认,不是照抄文档):
1 u16 回绕:bridge.drop 在 4000/s 下 16s 绕一圈,首尾相减必错 → 高频逐次累加,
且单次增量 >32768 视为绕圈、该点作废并计数暴露采样率不足。
2 rx_torn / pdo_reentry 是**饱和**计数(到 65535 停),不能按回绕算 → 走
delta_saturate,封顶即标注"下界"而不是报精确值。
3 周期能被 localStorage 劫持:任何开着的电机页标签在 ensureStarted 时会把总线
周期拽回存量值(transport-ecat.js:227-228)→ 每档下发后回读 period_ms 确认
真的生效,不一致就把该档标记为被劫持。
4 λ(CAN 到帧率)取 u32 的 head.global_seq,不取 u16 的 bridge.rx_count。
本次实测到关键分界:1ms/0.5ms 档 λ=4000/s、GD32 负载 44%/77%;0.25ms/0.125ms
档 GD32 顶到 99.5%/99.3%,λ 塌到 149/s —— 从站主循环被 PDO 填充吃光、CAN 接收
停摆。而主站达成率反而仍很高(0.125ms 实测 7780Hz),所以瓶颈在从站不在主站:
只看达成率会把这条线整个看漏。
用法:
python scripts/sweep_ecat_rate.py # 默认六档 × 60s
python scripts/sweep_ecat_rate.py --periods 0.25,0.5,1 --dwell 30
python scripts/sweep_ecat_rate.py --restore 1 --out lab/ecat_sweep.csv
python scripts/sweep_ecat_rate.py --dry-run # 只走一遍不下发周期
结束后一定把周期恢复到脚本启动前的原值(含 Ctrl-C / 异常)。
"""
import argparse
import csv
import os
import signal
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from calibrate_ecat_metrics import (cadence_rings, find_native_pid,
firmware_identity, ensure_loop)
from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post
DEFAULT_PERIODS = "0.125,0.25,0.5,1,2,4"
COLS = ["period_ms", "set_period_ms", "hijacked", "native_pid", "pid_stable",
"measured_hz", "achieve_pct", "can_lambda_per_s",
"drop_per_s", "drop_pct_of_lambda", "drop_untrusted", "lambda_u16_crosscheck",
"rxb_count_mode_per_cycle", "rxb_lost_max", "rxb_pending_max",
"skip_per_s", "skip_delta", "motors_online", "online_frac", "motor_refresh_per_s",
"torn_per_s", "torn_cum", "torn_capped",
"reentry_per_s", "reentry_cum", "reentry_capped", "wkc", "expected_wkc", "wr",
"cyc_p50_us", "cyc_p90_us", "cyc_max_us", "cyc_duty_pct",
"gd32_load_pct", "gd32_load_samples", "gd32_load_stale",
"host_cpu_1core_pct", "host_cpu_total_pct",
"logical_cpus", "samples", "errors", "dwell_s", "note"]
def sample_period(url, ms, dwell, hz, substr, autostart, dry):
"""在给定周期档上采 dwell 秒,返回一行 CSV 记录。"""
note = []
pid0, perr = find_native_pid(url)
st0 = post(url, "/api/motor/state")
if not (st0.get("ok") and st0.get("cycles")):
st0, how = ensure_loop(url, autostart, substr)
note.append("起循环:" + how)
orig = st0.get("period_ms")
if dry:
return {"period_ms": ms, "note": "dry-run 未下发"}
r = post(url, "/api/motor/period", {"period_ms": ms})
if not r.get("ok"):
return {"period_ms": ms, "note": "档位被拒:" + str(r.get("log"))[:80]}
# 等周期真正生效(热调是异步落到循环线程的)
eff, stable_iters = None, 0
t_deadline = time.perf_counter() + 3.0
while time.perf_counter() < t_deadline:
time.sleep(0.2)
eff = (post(url, "/api/motor/state")).get("period_ms")
if eff is not None and abs(float(eff) - float(ms)) < 1e-6:
stable_iters += 1
if stable_iters >= 3:
break
else:
stable_iters = 0
hijacked = "no"
if eff is None or abs(float(eff) - float(ms)) >= 1e-6:
hijacked = "YES"
note.append(f"下发 {ms}ms 但生效 {eff}ms(疑 localStorage 劫持或有他端在设)")
interval = 1.0 / hz
cyc_hz, drop_rate, rx_rate, gseq_rate, loads, hc1, hct = [], [], [], [], [], [], []
cyc_p50, cyc_p90, cyc_max = [], [], []
untrusted = torn_sum = reentry_sum = err = iters = load_stale = 0
torn_cap = reentry_cap = False
torn_prev = re_prev = None
torn_cum = re_cum = None
node_seq = {} # node -> (t, seq):上一拍的槽 seq,用于算电机数据刷新率
online_hits = online_tot = 0
refresh_rates = []
prev = prev_t = None
wr0 = (post(url, "/api/motor/state")).get("wr")
skip0 = (post(url, "/api/motor/dbg")).get("skip_total")
proc = None
try:
import psutil
if pid0:
proc = psutil.Process(pid0)
proc.cpu_percent(None) # 建立差分基线
except Exception as e:
note.append(f"psutil 不可用({e.__class__.__name__}),主机 CPU 缺")
t_end = time.perf_counter() + dwell
while time.perf_counter() < t_end:
tc = time.perf_counter()
try:
s = post(url, "/api/motor/state")
L = post(url, "/api/app/load")
except Exception:
err += 1
time.sleep(interval)
continue
iters += 1
b = s.get("bridge") or {}
cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count"),
"gseq": (s.get("head") or {}).get("global_seq")}
if prev is not None and tc > prev_t:
dt = tc - prev_t
if cur["cycles"] is not None and prev["cycles"] is not None:
h = (cur["cycles"] - prev["cycles"]) / dt
if 0 < h < 1e6:
cyc_hz.append(h)
if cur["drop"] is not None and prev["drop"] is not None:
d = delta_wrap(prev["drop"], cur["drop"], 16)
if d < 32768:
drop_rate.append(d / dt)
else:
untrusted += 1 # 已绕圈:该点作废,只计数不臆造速率
# λ 以 u32 的 head.global_seq 为准。u16 的 bridge.rx_count/drop 在快档
# 会失真(实测 0.25ms 档真值 149/s,两路 u16 与 u32 虽一致但增量已贴近
# 截断区),留作交叉核对:两者显著背离即说明该档 u16 不可信。
if cur["gseq"] is not None and prev["gseq"] is not None:
d = delta_wrap(prev["gseq"], cur["gseq"], 32)
if 0 <= d < (1 << 31):
gseq_rate.append(d / dt)
if cur["rx"] is not None and prev["rx"] is not None:
d = delta_wrap(prev["rx"], cur["rx"], 32)
if d < (1 << 31):
rx_rate.append(d / dt)
tn, rn = L.get("rx_torn"), L.get("pdo_reentry")
if tn is not None:
if torn_prev is not None and not torn_cap:
dd, capped = delta_saturate(torn_prev, tn)
torn_sum += dd
torn_cap |= capped
torn_prev = tn
if rn is not None:
if re_prev is not None and not reentry_cap:
dd, capped = delta_saturate(re_prev, rn)
reentry_sum += dd
reentry_cap |= capped
re_prev = rn
# 从站停报时 load_perm 会保留最后一次的值(实测:从站楔死后 age_ms=214000
# 而 load_perm 仍报 992)。不加 age 闸就会把残值当实测写进结论。
if (L.get("valid") and L.get("load_perm") is not None
and (L.get("age_ms") if L.get("age_ms") is not None else 1e9) < 1000):
loads.append(L["load_perm"] / 10.0)
else:
load_stale += 1
cu = (s.get("cyc_us") or {}).get("total") or {}
if cu.get("p50") is not None:
cyc_p50.append(cu["p50"])
if cu.get("p90") is not None:
cyc_p90.append(cu["p90"])
if cu.get("max") is not None:
cyc_max.append(cu["max"])
if proc is not None:
try:
c = proc.cpu_percent(None)
hc1.append(c)
hct.append(c / max(1, os.cpu_count() or 1))
except Exception:
pass
# 电机在线标志与数据刷新率:快档塌陷必须能区分"从站算不过来"与"电机掉线"
# —— 二者在 λ/seq 上长得一模一样,只有 online 标志能把它们切开。
for m in (s.get("motors") or []):
nd = m.get("node")
if nd is None:
continue
online_tot += 1
online_hits += 1 if m.get("online") else 0
sq = m.get("seq")
if sq is None:
continue
pr = node_seq.get(nd)
if pr and tc > pr[0]:
d = delta_wrap(pr[1], sq, 32)
if d < (1 << 31):
refresh_rates.append(d / (tc - pr[0]))
node_seq[nd] = (tc, sq)
prev, prev_t = cur, tc
time.sleep(max(0.0, interval - (time.perf_counter() - tc)))
dur = max(1e-6, time.perf_counter() - (t_end - dwell))
stf = post(url, "/api/motor/state")
tend = post(url, "/api/app/load")
pid1, _ = find_native_pid(url)
skip1 = (post(url, "/api/motor/dbg")).get("skip_total")
cad = cadence_rings(url)
torn_cum, re_cum = tend.get("rx_torn"), tend.get("pdo_reentry")
lam = percentile(gseq_rate, 0.5) or 0 # u32 gseq 口径,主用
lam_u16 = percentile(rx_rate, 0.5) or 0 # u16 rx_count 口径,仅作交叉核对
dropr = percentile(drop_rate, 0.5) or 0
hz_meas = percentile(cyc_hz, 0.5) or 0
eff_ms = float(stf.get("period_ms") or ms)
cad_hist = cad.get("count_hist") or {}
mode_cnt = max(cad_hist, key=lambda k: cad_hist[k]) if cad_hist else None
rec = {
"period_ms": ms, "set_period_ms": eff_ms, "hijacked": hijacked,
"native_pid": pid0, "pid_stable": "yes" if pid0 == pid1 else "NO",
"measured_hz": round(hz_meas, 1),
"achieve_pct": round(hz_meas / (1000.0 / eff_ms) * 100, 1) if eff_ms else None,
"can_lambda_per_s": round(lam, 1), "lambda_u16_crosscheck": round(lam_u16, 1),
"drop_per_s": round(dropr, 1),
"drop_pct_of_lambda": round(dropr / lam * 100, 2) if lam else None,
"drop_untrusted": untrusted,
"rxb_count_mode_per_cycle": mode_cnt,
"rxb_lost_max": cad.get("lost_max"), "rxb_pending_max": cad.get("pending_max"),
"motors_online": sum(1 for m in (stf.get("motors") or []) if m.get("online")),
"online_frac": round(online_hits / online_tot, 3) if online_tot else None,
"motor_refresh_per_s": round(percentile(refresh_rates, 0.5) or 0, 1),
"skip_per_s": round(((skip1 or 0) - (skip0 or 0)) / dur, 2),
"skip_delta": (skip1 or 0) - (skip0 or 0),
"torn_per_s": round(torn_sum / dur, 3),
"torn_cum": torn_cum, "torn_capped": "yes" if torn_cap else "no",
"reentry_per_s": round(reentry_sum / dur, 3),
"reentry_cum": re_cum, "reentry_capped": "yes" if reentry_cap else "no",
"wkc": stf.get("wkc"), "expected_wkc": stf.get("expected_wkc"),
"wr": f"{wr0}->{stf.get('wr')}",
"cyc_p50_us": round(percentile(cyc_p50, 0.5) or 0, 1),
"cyc_p90_us": round(percentile(cyc_p90, 0.9) or 0, 1),
"cyc_max_us": round(max(cyc_max), 1) if cyc_max else None,
"cyc_duty_pct": round((percentile(cyc_p50, 0.5) or 0) / (eff_ms * 1000) * 100, 1),
"gd32_load_pct": round(percentile(loads, 0.5) or 0, 1) if loads else None,
"gd32_load_samples": len(loads), "gd32_load_stale": load_stale,
"host_cpu_1core_pct": round(percentile(hc1, 0.5) or 0, 1),
"host_cpu_total_pct": round(percentile(hct, 0.5) or 0, 2),
"logical_cpus": os.cpu_count(), "samples": iters, "errors": err,
"dwell_s": round(dur, 1), "note": "; ".join(note)}
return rec
def main():
ap = argparse.ArgumentParser(description="ECAT 周期-故障率-CPU 扫描")
ap.add_argument("--url", default="http://127.0.0.1:8090")
ap.add_argument("--periods", default=DEFAULT_PERIODS, help="逗号分隔周期(ms)")
ap.add_argument("--dwell", type=float, default=60.0, help="每档采样秒数")
ap.add_argument("--hz", type=float, default=10.0,
help="HTTP 采样频率;u16 drop 在 4000/s 下 16s 绕一圈,须够高")
ap.add_argument("--restore", type=float, default=None, help="测完恢复到的周期(ms)")
ap.add_argument("--substr", default="I210")
ap.add_argument("--no-autostart", action="store_true")
ap.add_argument("--out", default=os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lab", "ecat_rate_sweep.csv"))
ap.add_argument("--dry-run", action="store_true")
a = ap.parse_args()
st = post(a.url, "/api/motor/state")
if not (st.get("ok") and st.get("cycles")) and a.no_autostart:
print("循环没跑且 --no-autostart:", st.get("log"))
return 2
orig = st.get("period_ms")
pid0, _ = find_native_pid(a.url)
periods = [float(x) for x in a.periods.split(",")]
print(f"服务 {a.url} · native PID {pid0} · 原周期 {orig}ms · 档位 {periods} · "
f"每档 {a.dwell}s @ {a.hz:g}Hz")
fw = firmware_identity()
print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={fw.get('match') or fw.get('error') or '无'}")
print("提示:确认没有任何电机页标签开着,否则周期会被 localStorage 拽回去\n")
rows, interrupted = [], False
signal.signal(signal.SIGINT, lambda *_: None)
try:
for ms in periods:
try:
rec = sample_period(a.url, ms, a.dwell, a.hz, a.substr,
not a.no_autostart, a.dry_run)
except Exception as e:
print(f"{ms}ms 档异常: {type(e).__name__}: {e}")
break
rows.append(rec)
if rec.get("note") == "dry-run 未下发":
print(f"[dry-run] {ms}ms 将下发,未执行")
continue
print(f"{ms:>7}ms 生效={rec['set_period_ms']} 劫持={rec['hijacked']} "
f"{rec['measured_hz']}Hz/{rec['achieve_pct']}% λ={rec['can_lambda_per_s']} "
f"drop={rec['drop_per_s']}/s({rec['drop_pct_of_lambda']}%) "
f"skip={rec['skip_per_s']}/s GD32={rec['gd32_load_pct']}% "
f"host={rec['host_cpu_1core_pct']}% wkc={rec['wkc']}/{rec['expected_wkc']}")
if rec["note"]:
print(f" note: {rec['note']}")
if rec["pid_stable"] == "NO":
print(" !! native PID 变了,该档及之后数据作废,停止扫描")
break
if rec["wkc"] != rec["expected_wkc"]:
print(" !! wkc 不达预期(从站丢帧/掉 OP 前兆),停止继续加压")
break
except KeyboardInterrupt:
interrupted = True
print("\n收到 Ctrl-C,正在恢复原周期…")
finally:
restore = a.restore if a.restore is not None else orig
if restore is not None and not a.dry_run:
try:
post(a.url, "/api/motor/period", {"period_ms": float(restore)})
back = (post(a.url, "/api/motor/state")).get("period_ms")
print(f"\n已恢复周期 -> 设定 {restore}ms,实际回读 {back}ms")
except Exception as e:
print(f"\n!! 恢复周期失败({e}),请手动确认总线周期")
if not rows or a.dry_run:
return 0
os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True)
with open(a.out, "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=COLS, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
print(f"扫描数据 {len(rows)} 档 -> {a.out}")
if interrupted:
print("(本次为中断后的部分数据)")
return 0
if __name__ == "__main__":
sys.exit(main())
-57
View File
@@ -1,57 +0,0 @@
/**
* 从 git worktree 启动 esp32_server 时,ECAT_DIR 必须指回主检出的
* tools/ecat,而不是 .../ESP32/.worktrees/ecat。
* 复刻 _resolve_ecat_dir 的纯路径逻辑(无 DOM/无网络)。
*/
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
/** 与 esp32_server._resolve_ecat_dir 同构(JS 侧只测路径形状)。 */
function resolveEcatDir(baseDir) {
let d = path.resolve(baseDir);
const parent = path.dirname(d);
if (path.basename(parent) === ".worktrees") {
d = path.dirname(parent);
}
return path.normalize(path.join(d, "..", "ecat"));
}
let failed = 0;
function check(name, fn) {
try {
fn();
console.log(" ✓", name);
} catch (e) {
failed++;
console.log(" ✗", name, "—", e.message);
}
}
console.log("[worktree ECAT_DIR]");
check("主检出 BASE_DIR → 兄弟 tools/ecat", () => {
const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32");
const got = resolveEcatDir(base);
assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got);
});
check("worktree BASE_DIR → 仍指主检出的 tools/ecat", () => {
const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32/.worktrees/device-link");
const got = resolveEcatDir(base);
assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got);
});
check("嵌套 slug 也走 .worktrees 父级", () => {
const base = path.resolve("G:/x/tools/ESP32/.worktrees/foo-bar");
const got = resolveEcatDir(base);
assert.equal(got, path.resolve("G:/x/tools/ecat"), got);
});
/* BASE_DIR 恒为 esp32_server.py 所在目录;普通主检出已在第一项覆盖。 */
if (failed) {
console.log(`失败 ${failed} 项`);
process.exit(1);
}
console.log("通过 3 项,失败 0 项");
File diff suppressed because it is too large Load Diff
-238
View File
@@ -1,238 +0,0 @@
#!/usr/bin/env python3
"""test_pram_batch_sim.py - 主机侧仿真:验证 pramRead/pramWrite 批量化(1.6.0)
与旧实现(逐 DWORD 轮询)逐字节等价。
模型依据 LAN9252 DS00001909A §12.11.3/12.11.4:
- PRAM FIFO 16 deep x 32bit;AVAIL_CNT(CMD bits12:8)=可连续读/写的 DWORD 数
- 读引擎按 DWORD 对齐向下取源:第 i 个 FIFO DWORD = 源地址 aligned(addr)+4i 起 4 字节;
线性有效窗口 = [0, len),即 DWORD i 的字节 k 有效 iff 0 <= 4i+k-lead < len
- 写引擎按同一线性窗口落盘,主站推满一个 DWORD 计一次
- 引擎以有限速率搬运(rate),主站 respect AVAIL_CNT 就不会溢 FIFO/读空洞
对全部 (addr 对齐 x len 1..70/96/384/640 x 引擎节奏) 断言:
新旧算法产出逐字节一致,且主站从不超出 AVAIL_CNT(引擎内 assert)。
"""
import random
FIFO_DW = 16 # FIFO 深度 16 DWORD
WINDOW_DW = 8 # 别名窗口一程 8 DWORD (0x00-0x1C / 0x20-0x3C)
PRAM_BASE = 0x1000
PRAM_SIZE = 0x1000
def byte_valid(i, k, lead, ln):
"""DWORD i 的字节 k 是否落在线性有效窗口 [0, ln) 内。"""
return 0 <= 4 * i + k - lead < ln
class ReadEngine:
def __init__(self, mem, addr, ln, rate):
self.mem = mem
self.lead = addr & 3
self.ln = ln
self.rate = max(1, rate)
self.total_dw = (ln + self.lead + 3) // 4
self.start = (addr & ~3) - PRAM_BASE # mem 内偏移
self.pushed = 0
self.popped = 0
self.data = []
def pump(self):
n = 0
while (self.pushed - self.popped < FIFO_DW and self.pushed < self.total_dw
and n < self.rate):
i = self.pushed
dw = bytearray(4)
for k in range(4):
if byte_valid(i, k, self.lead, self.ln):
dw[k] = self.mem[self.start + 4 * i + k]
self.data.append(bytes(dw))
self.pushed += 1
n += 1
return n
def avail_cnt(self):
return self.pushed - self.popped
def pop(self):
assert self.popped < self.pushed, "主站弹出超出 AVAIL_CNT!"
dw = self.data[self.popped]
self.popped += 1
return dw
def pram_read_old(eng, addr, ln, poll_budget=2000):
out = bytearray()
done = 0
polls = 0
while done < ln:
while eng.avail_cnt() == 0:
polls += 1
if polls > poll_budget:
return bytes(out), False
eng.pump()
dw = eng.pop()
lead = (addr & 3) if done == 0 else 0
take = min(ln - done, 4 - lead)
out += dw[lead:lead + take]
done += take
eng.pump()
return bytes(out), True
def pram_read_new(eng, addr, ln, poll_budget=2000):
out = bytearray()
done = 0
polls = 0
while done < ln:
while eng.avail_cnt() == 0:
polls += 1
if polls > poll_budget:
return bytes(out), False
eng.pump()
batch = eng.avail_cnt()
if batch == 0 or batch > WINDOW_DW:
batch = 1
lead0 = (addr & 3) if done == 0 else 0
need_dw = (ln - done + lead0 + 3) // 4
batch = min(batch, need_dw)
for _ in range(batch):
dw = eng.pop()
lead = (addr & 3) if done == 0 else 0
take = min(ln - done, 4 - lead)
out += dw[lead:lead + take]
done += take
eng.pump()
return bytes(out), True
class WriteEngine:
def __init__(self, addr, ln, rate):
self.lead = addr & 3
self.ln = ln
self.rate = max(1, rate)
self.total_dw = (ln + self.lead + 3) // 4
self.start = (addr & ~3) - PRAM_BASE
self.fifo = []
self.consumed_dw = 0
self.mem = bytearray(PRAM_SIZE)
def pump(self):
n = 0
while self.fifo and n < self.rate:
dw = self.fifo.pop(0)
i = self.consumed_dw
for k in range(4):
if byte_valid(i, k, self.lead, self.ln):
self.mem[self.start + 4 * i + k] = dw[k]
self.consumed_dw += 1
n += 1
return n
def space_cnt(self):
return FIFO_DW - len(self.fifo)
def push(self, dw):
assert len(self.fifo) < FIFO_DW, "主站写溢出 FIFO!"
self.fifo.append(bytes(dw))
def _write_common(eng, addr, buf, poll_budget, batched):
ln = len(buf)
done = 0
polls = 0
while done < ln:
while eng.space_cnt() == 0:
polls += 1
if polls > poll_budget:
return False
eng.pump()
n_iter = 1
if batched:
batch = eng.space_cnt()
if batch == 0 or batch > WINDOW_DW:
batch = 1
lead0 = (addr & 3) if done == 0 else 0
need_dw = (ln - done + lead0 + 3) // 4
n_iter = min(batch, need_dw)
for _ in range(n_iter):
lead = (addr & 3) if done == 0 else 0
take = min(ln - done, 4 - lead)
dw = bytearray(4)
for i in range(take):
dw[lead + i] = buf[done + i]
eng.push(dw)
done += take
eng.pump()
budget = poll_budget
while eng.consumed_dw < eng.total_dw and budget:
eng.pump()
budget -= 1
return eng.consumed_dw == eng.total_dw
def pram_write_old(eng, addr, buf, poll_budget=2000):
return _write_common(eng, addr, buf, poll_budget, batched=False)
def pram_write_new(eng, addr, buf, poll_budget=2000):
return _write_common(eng, addr, buf, poll_budget, batched=True)
def run():
random.seed(20260905)
cases = 0
lens = list(range(1, 71)) + [96, 384, 640] # 覆盖 SM2(384B)/SM3(640B) 实际长度
for align in range(4):
for ln in lens:
for rate in (1, 3, 16, 64):
src = bytes(random.randrange(1, 256) for _ in range(ln))
base = random.randrange(PRAM_SIZE - ln - 8)
addr = PRAM_BASE + base + align
mem = bytearray(PRAM_SIZE)
mem[base + align: base + align + ln] = src
# 读等价:新旧都须完整取回 src
e1 = ReadEngine(mem, addr, ln, rate); e1.pump()
r_old, ok1 = pram_read_old(e1, addr, ln)
e2 = ReadEngine(mem, addr, ln, rate); e2.pump()
r_new, ok2 = pram_read_new(e2, addr, ln)
assert ok1 and ok2, f"read 未完成 align={align} len={ln} rate={rate}"
assert r_old == src, f"read 旧实现不符 align={align} len={ln} rate={rate}"
assert r_new == src, f"read 新实现不符 align={align} len={ln} rate={rate}"
# 写等价:新旧都须把 src 落到同一片 PRAM
w1 = WriteEngine(addr, ln, rate); w1.pump()
assert pram_write_old(w1, addr, src), f"write 未完成 {align}/{ln}/{rate}"
w2 = WriteEngine(addr, ln, rate); w2.pump()
assert pram_write_new(w2, addr, src), f"write 未完成 {align}/{ln}/{rate}"
assert bytes(w1.mem[base + align: base + align + ln]) == src, \
f"write 旧不符 {align}/{ln}/{rate}"
assert bytes(w2.mem[base + align: base + align + ln]) == src, \
f"write 新不符 {align}/{ln}/{rate}"
cases += 1
print(f"OK: {cases} 组 (对齐x长度x引擎节奏) 新旧算法逐字节等价")
# 统计新实现的轮询次数收益(代表性场景:SM3 640B DWORD 对齐,引擎速率 16)
for rate in (1, 3, 16):
src = bytes(random.randrange(1, 256) for _ in range(640))
e = ReadEngine(bytearray(PRAM_SIZE), PRAM_BASE, 640, rate); e.pump()
_, p1 = _count_polls(e, 640)
print(f" 640B@rate{rate}: 新算法主站外圈轮询 {p1} 次")
def _count_polls(eng, ln):
done = 0
polls = 0
while done < ln:
while eng.avail_cnt() == 0:
polls += 1
eng.pump()
batch = min(eng.avail_cnt(), WINDOW_DW)
lead0 = 0 if done == 0 else 0
batch = min(batch, (ln - done + lead0 + 3) // 4)
for _ in range(batch):
eng.pop()
done += 4
return None, polls
if __name__ == "__main__":
run()