docs: 新增 .qoder Repowiki 知识库文档
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
#!/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()
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* 浏览器实测:ECAT 刷新速度 1ms(不限速档)。
|
||||
* 浏览器实测:ECAT 刷新速度下限档 6ms(不限速档)。
|
||||
* 单测只验了纯逻辑;这里验真机行为:
|
||||
* - 输入框 min/step 随链路变(否则浏览器把合法的 1ms 标成 :invalid)
|
||||
* - 1ms 真的比 50ms 快,且拿到实测频率而不是理论 1000Hz
|
||||
* - 输入框 min/step 随链路变(否则浏览器把合法的 6ms 标成 :invalid)
|
||||
* - 6ms 真的比 50ms 快,且拿到实测频率而不是理论 166Hz
|
||||
* - 存量缓存里的旧 1ms 会被抬到 6ms(绘制上限之上没有消费者)
|
||||
* - 不限速档**不**亮卡顿黄标(每拍撞上在途请求是预期行为)
|
||||
* - 切回 WiFi 时 1ms 不会被带过去(wifi 下限 50ms)
|
||||
* - 切回 WiFi 时 6ms 不会被带过去(wifi 下限 50ms)
|
||||
*/
|
||||
const CDP = "http://127.0.0.1:9333";
|
||||
const list = await (await fetch(CDP + "/json/list")).json();
|
||||
@@ -23,8 +24,8 @@ let pass = 0, fail = 0; const out = [];
|
||||
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点,直接进 ECAT */
|
||||
@@ -49,28 +50,35 @@ const pre = JSON.parse(await ev(`JSON.stringify({tp: motorDbg.state.tpId, conn:
|
||||
out.push("\n[前置] " + JSON.stringify(pre));
|
||||
check("已在 ecat 链路且已连接", pre.tp === "ecat" && pre.conn === true, JSON.stringify(pre));
|
||||
|
||||
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 1");
|
||||
out.push("\n[A] ECAT 下输入框的 min/step 应放开到 6/1");
|
||||
let dom = JSON.parse(await ev(`JSON.stringify({min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
|
||||
check("min=1(否则浏览器校验会把 1ms 判成非法)", dom.min === "1", JSON.stringify(dom));
|
||||
check("step=1(微调箭头能落到 1ms 档)", dom.step === "1", JSON.stringify(dom));
|
||||
check("min=6(否则浏览器校验会把 6ms 判成非法)", dom.min === "6", JSON.stringify(dom));
|
||||
check("step=1(下限档要能逐 ms 微调)", dom.step === "1", JSON.stringify(dom));
|
||||
|
||||
out.push("\n[B] 基线:ECAT 默认 50ms");
|
||||
const hz50 = await measure(3);
|
||||
out.push(` 实测 ${hz50.toFixed(1)} Hz`);
|
||||
check("50ms 档约 20Hz", hz50 > 12 && hz50 < 30, `实测 ${hz50.toFixed(1)}`);
|
||||
|
||||
out.push("\n[C] 设成 1ms —— 应被接受且明显更快");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
out.push("\n[C] 设成 6ms —— 应被接受且明显更快");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="6"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(800);
|
||||
let st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, valid: document.querySelector("#refreshRate").checkValidity(), map: motorDbg.state.refreshByTp})`));
|
||||
check("1ms 被接受(没被钳回 50)", st.eff === 1, JSON.stringify(st));
|
||||
check("输入框显示 1", st.val === "1", JSON.stringify(st));
|
||||
check("6ms 被接受(没被钳回 50)", st.eff === 6, JSON.stringify(st));
|
||||
check("输入框显示 6", st.val === "6", JSON.stringify(st));
|
||||
check("通过浏览器原生校验(min 已同步)", st.valid === true, JSON.stringify(st));
|
||||
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 1 && st.map.wifi === 250, JSON.stringify(st));
|
||||
check("只写进 ecat 那一份,wifi 仍 250", st.map.ecat === 6 && st.map.wifi === 250, JSON.stringify(st));
|
||||
const hz1 = await measure(4);
|
||||
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,实测约 125Hz 量级)`);
|
||||
check("1ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `1ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
|
||||
check("但达不到 1000Hz(受每拍 HTTP 往返限制,符合预期)", hz1 < 400, `实测 ${hz1.toFixed(1)}`);
|
||||
out.push(` 实测 ${hz1.toFixed(1)} Hz(每拍一次 HTTP 往返,6ms 档约 160Hz 量级)`);
|
||||
check("6ms 明显快于 50ms 档", hz1 > hz50 * 1.5, `6ms=${hz1.toFixed(1)} vs 50ms=${hz50.toFixed(1)}`);
|
||||
check("但达不到理论 166Hz 以上(受每拍 HTTP 往返限制)", hz1 < 200, `实测 ${hz1.toFixed(1)}`);
|
||||
|
||||
out.push("\n[C2] 硬填旧的 1ms —— 应被抬到下限 6ms");
|
||||
await ev(`(() => { const i=document.querySelector("#refreshRate"); i.value="1"; i.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
|
||||
check("旧 1ms 被抬到 6ms", st.eff === 6, JSON.stringify(st));
|
||||
check("输入框回填 6", st.val === "6", JSON.stringify(st));
|
||||
|
||||
out.push("\n[D] 不限速档不应亮卡顿黄标");
|
||||
st = JSON.parse(await ev(`JSON.stringify({stuck: motorDbg.state.tickStuck, cls: document.querySelector("#chSelect").classList.contains("stuck"), title: document.querySelector("#chSelect").title})`));
|
||||
@@ -78,23 +86,23 @@ check("tickStuck 保持 0(每拍撞上在途请求是预期行为,不算卡
|
||||
check("通道下拉框没有 .stuck 黄标", st.cls === false, JSON.stringify(st));
|
||||
check("没有误导性的卡顿提示文案", !st.title, JSON.stringify(st));
|
||||
|
||||
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 1000Hz");
|
||||
out.push("\n[E] STATUS 面板的 Hz 应是实测值,不是理论 166Hz");
|
||||
const hzText = await ev(`document.querySelector("#statusIdLabel").textContent`);
|
||||
out.push(` 面板显示: ${hzText}`);
|
||||
const m = String(hzText).match(/([\d.]+)\s*Hz/);
|
||||
check("面板有 Hz 读数", !!m || /\u2026/.test(String(hzText)), hzText);
|
||||
if (m) {
|
||||
const shown = parseFloat(m[1]);
|
||||
check("显示的不是谎报的 1000Hz", shown < 400, `显示 ${shown}Hz`);
|
||||
check("显示的不是谎报的理论值", shown < 200, `显示 ${shown}Hz`);
|
||||
check("显示值与实测频率同量级(±60%)",
|
||||
Math.abs(shown - hz1) / Math.max(hz1, 1) < 0.6, `显示 ${shown} vs 实测 ${hz1.toFixed(1)}`);
|
||||
}
|
||||
|
||||
out.push("\n[F] 切到 WiFi:1ms 不能带过去(wifi 下限 50ms)");
|
||||
out.push("\n[F] 切到 WiFi:6ms 不能带过去(wifi 下限 50ms)");
|
||||
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="wifi"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(1500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min, step: document.querySelector("#refreshRate").step})`));
|
||||
check("WiFi 生效值 250(不是 ECAT 的 1)", st.eff === 250, JSON.stringify(st));
|
||||
check("WiFi 生效值 250(不是 ECAT 的 6)", st.eff === 250, JSON.stringify(st));
|
||||
check("WiFi 下 min 收回 50", st.min === "50", JSON.stringify(st));
|
||||
check("WiFi 下 step 收回 50", st.step === "50", JSON.stringify(st));
|
||||
/* 在 WiFi 下硬填 1ms,应被钳到 50 */
|
||||
@@ -103,16 +111,16 @@ await sleep(500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value})`));
|
||||
check("WiFi 下填 1ms 被钳到 50(无线往返撑不住)", st.eff === 50, JSON.stringify(st));
|
||||
|
||||
out.push("\n[G] 切回 ECAT:仍记着自己的 1ms");
|
||||
out.push("\n[G] 切回 ECAT:仍记着自己的 6ms");
|
||||
await ev(`(() => { const s=document.querySelector("#transportSelect"); s.value="ecat"; s.dispatchEvent(new Event("change")); return "ok"; })()`);
|
||||
await sleep(2500);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, min: document.querySelector("#refreshRate").min})`));
|
||||
check("ECAT 生效值回到 1ms", st.eff === 1, JSON.stringify(st));
|
||||
check("min 重新放开到 1", st.min === "1", JSON.stringify(st));
|
||||
/* 刷新页面后 1ms 应持久化 */
|
||||
check("ECAT 生效值回到 6ms", st.eff === 6, JSON.stringify(st));
|
||||
check("min 重新放开到 6", st.min === "6", JSON.stringify(st));
|
||||
/* 刷新页面后 6ms 应持久化 */
|
||||
await send("Page.navigate", { url: PAGE }); await sleep(3000);
|
||||
st = JSON.parse(await ev(`JSON.stringify({eff: motorDbg.state.refreshIntervalMs, val: document.querySelector("#refreshRate").value, map: motorDbg.state.refreshByTp})`));
|
||||
check("刷新页面后 ECAT 仍是 1ms(持久化 + 加载时不被钳高)", st.eff === 1, JSON.stringify(st));
|
||||
check("刷新页面后 ECAT 仍是 6ms(持久化 + 加载时不被钳高)", st.eff === 6, JSON.stringify(st));
|
||||
check("刷新后 wifi 那一份仍是 50(上面手动改过)", st.map.wifi === 50, JSON.stringify(st));
|
||||
|
||||
console.log(out.join("\n"));
|
||||
@@ -24,7 +24,7 @@ let pass = 0, fail = 0; const out = [];
|
||||
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点:ECAT 链路,清掉周期/节拍残留偏好(后端保持当前值不动) */
|
||||
|
||||
@@ -44,8 +44,8 @@ function check(name, cond, detail) {
|
||||
await send("Runtime.enable");
|
||||
await send("Page.enable");
|
||||
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
async function goto(u) {
|
||||
|
||||
@@ -20,8 +20,8 @@ let pass = 0, fail = 0; const out = [];
|
||||
const check = (n, c, d) => { if (c) { pass++; out.push(" \u2713 " + n); } else { fail++; out.push(" \u2717 " + n + (d ? " \u2014 " + d : "")); } };
|
||||
|
||||
await send("Runtime.enable"); await send("Page.enable");
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 8080) */
|
||||
const PORT = Number(process.argv[2] || 8080);
|
||||
/* 页面服务端口可传参:node scripts/xxx.mjs [port](默认 2625) */
|
||||
const PORT = Number(process.argv[2] || 2625);
|
||||
const PAGE = `http://127.0.0.1:${PORT}/pages/motor.html`;
|
||||
|
||||
/* 干净起点,直接进 ECAT 链路(本机有 ecat 服务,能真连上跑起轮询) */
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
$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))
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/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_adc.uptime_sec 已归零;
|
||||
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")
|
||||
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")
|
||||
if want and ver != want:
|
||||
raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效")
|
||||
with open(LEDGER, "w", encoding="ascii") as f:
|
||||
f.write(sha)
|
||||
print(f"完成: 板上 gd32_app={o['gd32_app']},台账已写 {os.path.normpath(LEDGER)}")
|
||||
return
|
||||
raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ECAT 段2 撕裂 A/B 判据(修复前后的唯一对照指标)。
|
||||
|
||||
主判据(零假阳性,直接来自写侧契约):
|
||||
固件 MotorSlot 的 seq 与 data 属同一次更新,seq 先自增、data 后 memcpy。
|
||||
⇒ 同一 seq 在总线上只能对应唯一一份 48B payload。
|
||||
若同一 seq 先后被读到两份不同 payload,只可能是消费者(PDI_Isr 的 buildInput)
|
||||
在生产者 memcpy 中途取走了半成品 —— 即撕裂(含"seq 新、data 整体旧"的错拍型)。
|
||||
1.6.5 的 seqlock 把整个更新段(seq 自增 + data 拷贝)都盖进奇数区间,
|
||||
故修复后该不变量必须零例外。
|
||||
|
||||
功效条件:只有当该节点的 seq 在相邻两拍之间不前进时,同一 seq 才可能被读两次。
|
||||
所以脚本同时报"同 seq 重复读"的覆盖率——重复率低则本次判定功效不足,需换工况重跑。
|
||||
|
||||
旁判据(与速率无关):字段值的"跳一下又回来"(median 偏离 >8× 局部抖动带且下一帧
|
||||
回到带内)——真撕裂必然表现为这种形态。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import statistics
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
|
||||
DUR_S = float(sys.argv[2]) if len(sys.argv) > 2 else 150.0
|
||||
LOG = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ecat_torn_hits.jsonl")
|
||||
|
||||
FLOATS = (("rtAngle", 13), ("rtVelocity", 17), ("rtCurrent", 21), ("testFloat", 25))
|
||||
I16 = (("mcuTemp", 0), ("motTemp", 2), ("uCur", 4), ("vCur", 6), ("wCur", 8))
|
||||
|
||||
|
||||
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"))
|
||||
|
||||
|
||||
REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n"
|
||||
f"Content-Type: application/json\r\nContent-Length: 2\r\n"
|
||||
"Connection: close\r\n\r\n{{}}").encode("ascii")
|
||||
|
||||
|
||||
def main():
|
||||
# per node: 当前 seq 的首见/末见 payload、上一 seq 的确定 payload、字段历史
|
||||
st = {}
|
||||
viol, spikes, polls, err = [], [], 0, 0
|
||||
cov = 0
|
||||
t0 = time.time()
|
||||
lf = open(LOG, "w", encoding="utf-8")
|
||||
while time.time() - t0 < DUR_S:
|
||||
try:
|
||||
snap = once()
|
||||
except Exception:
|
||||
err += 1
|
||||
continue
|
||||
polls += 1
|
||||
for m in snap.get("motors") or []:
|
||||
if not m.get("online") or not m.get("data"):
|
||||
continue
|
||||
nd, seq = m["node"], m["seq"]
|
||||
d = bytes.fromhex(m["data"])
|
||||
e = st.setdefault(nd, {"cur": None, "first": None, "last": None,
|
||||
"done": None, "hist": {}, "seen": 0})
|
||||
if e["cur"] == seq:
|
||||
cov += 1 # 一次"同一 seq 被读第二遍"的判定机会
|
||||
if e["first"] != d:
|
||||
rec = {"t": round(time.time() - t0, 2), "node": nd, "seq": seq,
|
||||
"first": e["first"].hex(), "later": d.hex(),
|
||||
"prev_seq_data": (e["done"].hex() if e["done"] else None)}
|
||||
# 分类:整体等于上一帧 = 错拍型;前缀/后缀混合 = 真混合
|
||||
a, b, p = e["first"], d, e["done"]
|
||||
rec["cls"] = ("STALE(错拍)" if (p is not None and a == p) else
|
||||
"MATCH" if a == b else "VIOLATION")
|
||||
if p is not None and a != p:
|
||||
n = min(len(a), len(b), len(p))
|
||||
rec["mix_j"] = next((j for j in range(n + 1)
|
||||
if a[:j] == b[:j] and a[j:n] == p[j:n]), None)
|
||||
viol.append(rec)
|
||||
lf.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
||||
lf.flush()
|
||||
e["last"] = d
|
||||
else:
|
||||
# seq 前进:上一 seq 的末见即其确定内容
|
||||
if e["cur"] is not None and e["last"] is not None:
|
||||
e["done"] = e["last"]
|
||||
if len(e["last"]) >= 29: # 覆盖到 testFloat 末字节才做形态判据
|
||||
for name, off in FLOATS:
|
||||
e["hist"].setdefault(name, []).append(
|
||||
struct.unpack_from("<f", e["last"], off)[0])
|
||||
if len(e["hist"][name]) > 400:
|
||||
e["hist"][name].pop(0)
|
||||
# 形态判据:本帧各字段是否"跳出带又回来"需要下一帧,留到下次比较
|
||||
for name, off in FLOATS:
|
||||
h = e["hist"].get(name) or []
|
||||
if len(h) >= 4:
|
||||
a, b2 = h[-3], h[-2] # 上上帧 / 上一帧(刚确定)
|
||||
# 检查上一帧相对其邻居是否孤立尖峰(其前其后由 hist 提供)
|
||||
if len(h) >= 4:
|
||||
pre, post = h[-4], h[-1]
|
||||
band = max(abs(b2 - pre), abs(post - b2), 1e-6)
|
||||
if abs(b2 - (pre + post) / 2) > 20 * band:
|
||||
spikes.append({"t": round(time.time() - t0, 2),
|
||||
"node": nd, "field": name,
|
||||
"pre": pre, "spike": b2, "post": post})
|
||||
e["cur"], e["first"], e["last"], e["seen"] = seq, d, d, 0
|
||||
lf.close()
|
||||
|
||||
print(f"轮询 {polls} 次 / {DUR_S:.0f}s({polls/DUR_S:.0f} 次/s,连接错误 {err})")
|
||||
print(f"判定功效:同一 seq 被读第二遍的机会 = {cov} 次")
|
||||
print(f"主判据违规(同一 seq 出现两份不同 48B)= {len(viol)}")
|
||||
for r in viol[:6]:
|
||||
print(" ", r["cls"], f"t={r['t']}s node={r['node']} seq={r['seq']} mix_j={r.get('mix_j')}")
|
||||
print(f" first={r['first'][:64]}")
|
||||
print(f" later={r['later'][:64]}")
|
||||
print(f"旁判据(孤立尖峰,形态学,可能有真突变混入)= {len(spikes)}")
|
||||
for s in spikes[:4]:
|
||||
print(f" t={s['t']}s node={s['node']} {s['field']}: {s['pre']:.6g} -> {s['spike']:.6g} -> {s['post']:.6g}")
|
||||
print(f"明细: {LOG}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ECAT 与 WiFi 两条链路对同一 seq 的 48B STATUS 做逐字节对照。
|
||||
|
||||
两条链路读的是 GD32 里同一张 MotorState 表,但读取上下文不同:
|
||||
* ECAT 段2 由 buildInput 取表,跑在 PDI_Isr —— 会抢占主循环的写侧(本次修复点);
|
||||
* WiFi 0xC5/buildPayload 与写侧同在主循环 —— 结构上不可能读到中间态。
|
||||
所以 WiFi 侧可当参照:同一 seq 在两边必须逐字节相同(seq 由 global_seq 唯一确定,
|
||||
一次更新只产生一个 seq)。不等 ⇒ ECAT 侧那一份是没写完的半成品。
|
||||
|
||||
用法: python scripts/probe_ecat_vs_wifi.py [秒数]
|
||||
"""
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
HOST, PORT = "127.0.0.1", 8090
|
||||
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 60.0
|
||||
ESP = "192.168.31.127"
|
||||
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 ecat_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 wifi_once():
|
||||
"""返回 {node: (seq, 48B)}。协议 ver=2:entry 92B,data 在 +12。"""
|
||||
b = urllib.request.urlopen(f"http://{ESP}/motor/state?bin=1", timeout=3).read()
|
||||
if len(b) < 12 or struct.unpack_from("<H", b, 0)[0] != 0x4D53 or b[2] != 2:
|
||||
return {}
|
||||
out, off = {}, 12
|
||||
for _ in range(b[9]):
|
||||
if off + 92 > len(b):
|
||||
break
|
||||
out[b[off]] = (struct.unpack_from("<I", b, off + 8)[0],
|
||||
b[off + 12:off + 60])
|
||||
off += 92
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cache = {} # node -> {seq: payload}(ECAT 侧见过的每一帧)
|
||||
matched = mism = polls = wpolls = 0
|
||||
bad = []
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < DUR_S:
|
||||
try:
|
||||
snap = ecat_once()
|
||||
polls += 1
|
||||
for m in snap.get("motors") or []:
|
||||
if m.get("online") and m.get("data"):
|
||||
cache.setdefault(m["node"], {})[m["seq"]] = bytes.fromhex(m["data"])
|
||||
if len(cache[m["node"]]) > 4000:
|
||||
for k in sorted(cache[m["node"]])[:2000]:
|
||||
del cache[m["node"]][k]
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
w = wifi_once()
|
||||
wpolls += 1
|
||||
except Exception:
|
||||
continue
|
||||
for node, (seq, payload) in w.items():
|
||||
d = cache.get(node, {}).get(seq)
|
||||
if d is None:
|
||||
continue # 该 seq 未被 ECAT 侧采到,跳过
|
||||
matched += 1
|
||||
if d != payload:
|
||||
mism += 1
|
||||
bad.append((round(time.time() - t0, 2), node, seq, d.hex(), payload.hex()))
|
||||
print(f"轮询 ECAT {polls} 次 / WiFi {wpolls} 次 / {DUR_S:.0f}s")
|
||||
print(f"两侧同 seq 可比对 = {matched} 逐字节不一致 = {mism}")
|
||||
for t, node, seq, e, w in bad[:6]:
|
||||
j = next((i for i in range(48) if e[i] != w[i]), 48)
|
||||
print(f" t={t}s node={node} seq={seq} 首字节差={j}")
|
||||
print(f" ECAT={e}")
|
||||
print(f" WiFi={w}")
|
||||
if not matched:
|
||||
print("功效不足:ECAT 帧率远高于 WiFi 采样,同 seq 交集为 0,本次判定不作数。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/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()
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* test_ecat_autostart.mjs — EtherCAT 自动接入链条自测(Node,无浏览器)
|
||||
*
|
||||
* 跑法:node scripts/test_ecat_autostart.mjs [--port 8080]
|
||||
* 跑法:node scripts/test_ecat_autostart.mjs [--port 2625]
|
||||
*
|
||||
* 测什么:**真的 import 生产代码** transport-ecat.js,验证"自动接入"这条
|
||||
* 四级链条的每一级都被正确驱动,且失败原因可读。
|
||||
@@ -108,7 +108,7 @@ section("[1] probe() 自动开网卡 + 自动起循环(四级链条的 2/3/4
|
||||
un(); srv.close();
|
||||
check("先探 status 发现未连接", st.calls[0] === "/ecat/api/status", st.calls[0]);
|
||||
check("自动调用 /api/open(代替用户去点连接按钮)", st.calls.includes("/ecat/api/open"));
|
||||
check("open 用默认网卡子串 I226", st.openSubstr === "I226", String(st.openSubstr));
|
||||
check("open 用默认网卡子串 I210", st.openSubstr === "I210", String(st.openSubstr));
|
||||
check("随后自动调用 /api/motor/start(进 OP + 起后台循环)", st.started === true);
|
||||
check("open 在 start 之前", st.calls.indexOf("/ecat/api/open") < st.calls.indexOf("/ecat/api/motor/start"));
|
||||
check("periodMs 由后端回报写入", tp.debug().periodMs === 4, String(tp.debug().periodMs));
|
||||
@@ -133,7 +133,7 @@ section("[2] 已连接则不重复 open(幂等)");
|
||||
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
|
||||
section("[3] 打不开网卡时的错误语义");
|
||||
{
|
||||
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I226' 的适配器" });
|
||||
const { srv, port } = await makeStub({ failOpen: "没有找到描述包含 'I210' 的适配器" });
|
||||
const un = bindFetch(port);
|
||||
const tp = createEcatTransport(CTX);
|
||||
let err = null;
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
*
|
||||
* 覆盖:
|
||||
* 1 默认值:wifi 250 / ecat 50,且 defaultRefreshMap 每次给新对象
|
||||
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 1)
|
||||
* 2 归一 + 钳制:脏值/越界/字符串,**下限按链路**(wifi 50 / ecat 6)
|
||||
* 3 旧数据迁移:单个 refreshRate → 只进 wifi,ecat 保持默认
|
||||
* 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值
|
||||
* 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义)
|
||||
* 6 不限速谓词:1ms 档要关掉卡顿黄标与理论 Hz 显示(1ms ≠ 1000Hz)
|
||||
* 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz)
|
||||
*/
|
||||
import {
|
||||
REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP,
|
||||
@@ -45,11 +45,11 @@ check("defaultRefreshMap 不污染 REFRESH_DEFAULTS 常量", REFRESH_DEFAULTS.wi
|
||||
|
||||
/* ── 1b. 下限也按链路 ── */
|
||||
section("[1b] 每链路最小间隔");
|
||||
check("ecat 下限 1ms(读本机 PDO 快照,无总线代价)", REFRESH_MIN_BY_TP.ecat === 1,
|
||||
`实际 ${REFRESH_MIN_BY_TP.ecat}`);
|
||||
check("ecat 下限 6ms(≈160Hz,与本机 160Hz 屏的绘制上限对齐)",
|
||||
REFRESH_MIN_BY_TP.ecat === 6, `实际 ${REFRESH_MIN_BY_TP.ecat}`);
|
||||
check("wifi 下限仍 50ms(每拍是一次无线 HTTP 往返,再快只会积压)",
|
||||
REFRESH_MIN_BY_TP.wifi === 50, `实际 ${REFRESH_MIN_BY_TP.wifi}`);
|
||||
check("minRefreshFor('ecat') = 1", minRefreshFor("ecat") === 1);
|
||||
check("minRefreshFor('ecat') = 6", minRefreshFor("ecat") === 6);
|
||||
check("minRefreshFor('wifi') = 50", minRefreshFor("wifi") === 50);
|
||||
check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope") === REFRESH_MIN_MS);
|
||||
|
||||
@@ -57,10 +57,14 @@ check("未知链路回落到兜底下限 50(防御)", minRefreshFor("nope")
|
||||
section("[2] 归一与钳制(下限按链路)");
|
||||
const norm = normalizeRefreshMap({ wifi: 400, ecat: 20 });
|
||||
check("正常值原样保留", norm.wifi === 400);
|
||||
check("ecat 的 20ms 合法(下限 1ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
|
||||
check("ecat 可以设到 1ms", normalizeRefreshMap({ ecat: 1 }).ecat === 1);
|
||||
check("ecat 的 0/负数被抬到 1ms", normalizeRefreshMap({ ecat: 0 }).ecat === 1);
|
||||
check("ecat 的 -5 被抬到 1ms", normalizeRefreshMap({ ecat: -5 }).ecat === 1);
|
||||
check("ecat 的 20ms 合法(下限 6ms,不再被抬到 50)", norm.ecat === 20, `实际 ${norm.ecat}`);
|
||||
check("ecat 可以设到 6ms", normalizeRefreshMap({ ecat: 6 }).ecat === 6);
|
||||
/* 存量缓存里还留着旧的 1ms 档:读到就按新下限抬起来,不能原样放行 */
|
||||
check("存量 1ms 被抬到 6ms(绘制上限之上没有消费者)",
|
||||
normalizeRefreshMap({ ecat: 1 }).ecat === 6,
|
||||
`实际 ${normalizeRefreshMap({ ecat: 1 }).ecat}`);
|
||||
check("ecat 的 0/负数被抬到 6ms", normalizeRefreshMap({ ecat: 0 }).ecat === 6);
|
||||
check("ecat 的 -5 被抬到 6ms", normalizeRefreshMap({ ecat: -5 }).ecat === 6);
|
||||
check("wifi 的 1ms 仍被抬到 50(无线往返撑不住)",
|
||||
normalizeRefreshMap({ wifi: 1 }).wifi === 50, `实际 ${normalizeRefreshMap({ wifi: 1 }).wifi}`);
|
||||
check("wifi 的 20ms 也被抬到 50", normalizeRefreshMap({ wifi: 20 }).wifi === 50);
|
||||
@@ -68,7 +72,7 @@ check("高于上限被压到 5000", normalizeRefreshMap({ wifi: 99999 }).wifi ==
|
||||
check("ecat 高于上限同样压到 5000", normalizeRefreshMap({ ecat: 99999 }).ecat === REFRESH_MAX_MS);
|
||||
check("字符串数字可用(localStorage 里存的是字符串)",
|
||||
normalizeRefreshMap({ wifi: "300" }).wifi === 300);
|
||||
check("ecat 字符串 \"1\" 可用", normalizeRefreshMap({ ecat: "1" }).ecat === 1);
|
||||
check("ecat 字符串 \"8\" 可用", normalizeRefreshMap({ ecat: "8" }).ecat === 8);
|
||||
const dirty = normalizeRefreshMap({ wifi: "abc", ecat: null });
|
||||
check("脏值回落该链路默认(wifi)", dirty.wifi === 250, `实际 ${dirty.wifi}`);
|
||||
check("脏值回落该链路默认(ecat)", dirty.ecat === 50, `实际 ${dirty.ecat}`);
|
||||
@@ -97,7 +101,8 @@ check("正常取值", pickRefresh({ wifi: 300, ecat: 60 }, "ecat") === 60);
|
||||
check("缺该链路 → 回落该链路默认(不是另一条的值)",
|
||||
pickRefresh({ wifi: 300 }, "ecat") === 50);
|
||||
check("map 为 null 不炸", pickRefresh(null, "wifi") === 250);
|
||||
check("ecat 取出 1ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 1 }, "ecat") === 1);
|
||||
check("ecat 取出 8ms 原样保留(不再被抬到 50)", pickRefresh({ ecat: 8 }, "ecat") === 8);
|
||||
check("ecat 取出旧的 1ms 抬到下限 6ms", pickRefresh({ ecat: 1 }, "ecat") === 6);
|
||||
check("wifi 取出 1ms 被抬到 50", pickRefresh({ wifi: 1 }, "wifi") === 50);
|
||||
check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
|
||||
pickRefresh({}, "somethingnew") === 250);
|
||||
@@ -105,24 +110,24 @@ check("未知链路 id 回落到 wifi 默认(防御,不应出现)",
|
||||
/* ── 5. 隔离:本次改动的核心 ── */
|
||||
section("[5] 两条链路互不污染");
|
||||
const map = defaultRefreshMap();
|
||||
/* 模拟:用户在 ECAT 下把刷新速度拉到 1ms(applyRefreshRate 只写当前链路那一份) */
|
||||
map.ecat = 1;
|
||||
check("改 ecat 到 1ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
|
||||
/* 模拟:用户在 ECAT 下把刷新速度拉到下限 6ms(applyRefreshRate 只写当前链路那一份) */
|
||||
map.ecat = 6;
|
||||
check("改 ecat 到 6ms 后 wifi 仍是 250(不会把 WiFi 链路拖垮)", map.wifi === 250);
|
||||
/* 模拟:切回 WiFi,装载的应是 wifi 那一份 */
|
||||
check("切回 wifi 装载 250 而非 1", pickRefresh(map, "wifi") === 250);
|
||||
check("切回 wifi 装载 250 而非 6", pickRefresh(map, "wifi") === 250);
|
||||
/* 模拟:在 WiFi 下改成 1000ms,再切去 ECAT */
|
||||
map.wifi = 1000;
|
||||
check("改 wifi 后 ecat 仍是 1ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 1);
|
||||
check("切到 ecat 装载 1 而非 1000", pickRefresh(map, "ecat") === 1);
|
||||
check("改 wifi 后 ecat 仍是 6ms(WiFi 的慢值不会浪费 ECAT 的低延迟)", map.ecat === 6);
|
||||
check("切到 ecat 装载 6 而非 1000", pickRefresh(map, "ecat") === 6);
|
||||
/* 存盘 → 读回一圈后仍各自独立(saveUiState/loadUiState 的往返) */
|
||||
const roundTrip = normalizeRefreshMap(JSON.parse(JSON.stringify(map)));
|
||||
check("存盘再读回:wifi 保留 1000", roundTrip.wifi === 1000);
|
||||
check("存盘再读回:ecat 保留 1ms(不被 wifi 的下限 50 顺手抬高)",
|
||||
roundTrip.ecat === 1, `实际 ${roundTrip.ecat}`);
|
||||
check("存盘再读回:ecat 保留 6ms(不被 wifi 的下限 50 顺手抬高)",
|
||||
roundTrip.ecat === 6, `实际 ${roundTrip.ecat}`);
|
||||
|
||||
/* ── 6. 不限速谓词 ──
|
||||
* 1ms 档下节拍源必然比请求快,每拍都撞上"上一拍还在飞"。那是预期行为,
|
||||
* 所以要据此关掉卡顿黄标;同时 1ms 硬算成 1000Hz 是谎报(浏览器实测约 125Hz)。 */
|
||||
* 下限档(6ms)贴着往返耗时,节拍源仍会比请求完成得快,撞上"上一拍还在飞"是
|
||||
* 预期行为,所以要据此关掉卡顿黄标;6ms 硬算成 166Hz 也偏乐观(实测 ≈160Hz)。 */
|
||||
section("[6] 不限速谓词(关黄标 + 不谎报 Hz)");
|
||||
check("阈值 20ms(50Hz):比浏览器实测每拍耗时(~8ms)宽裕,又远小于常用档",
|
||||
UNTHROTTLED_MAX_MS === 20, `实际 ${UNTHROTTLED_MAX_MS}`);
|
||||
@@ -135,7 +140,7 @@ check("ECAT 默认 50ms 是正常限速档(黄标与 Hz 显示都保留)",
|
||||
check("WiFi 默认 250ms 是正常限速档", isUnthrottled(REFRESH_DEFAULTS.wifi) === false);
|
||||
check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)",
|
||||
isUnthrottled(REFRESH_MIN_BY_TP.wifi) === false);
|
||||
check("ECAT 下限 1ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
|
||||
check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
|
||||
check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false);
|
||||
|
||||
console.log(out.join("\n"));
|
||||
|
||||
@@ -150,7 +150,8 @@ if (liveOk) {
|
||||
typeof m.rx_count === "number" && typeof m.seq === "number" &&
|
||||
typeof m.data === "string" && typeof m.config_data === "string";
|
||||
check("motors[] 每项字段名/类型与 WiFi 一致", shapeOk, JSON.stringify(m).slice(0, 150));
|
||||
check("data 是 64 字符 hex(32B STATUS)", m.data.length === 64, `len=${m.data.length}`);
|
||||
check("data 是 96 字符 hex(48B STATUS,v0.74 含 InitStatus)", m.data.length === 96,
|
||||
`len=${m.data.length}`);
|
||||
results.push(` \u2139 在线电机: ` + st.motors
|
||||
.map((x) => `N${x.node}@CH${x.ch}${x.online ? "" : "(离线)"}`).join(" "));
|
||||
} else {
|
||||
@@ -172,8 +173,8 @@ if (liveOk) {
|
||||
typeof ecat.takeNodeIdReport === "function" && ecat.takeNodeIdReport(7) === null);
|
||||
const dbg = ecat.debug();
|
||||
check("debug() 暴露 PDO 周期与槽位统计",
|
||||
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 7 &&
|
||||
dbg.slotCount === 27 && dbg.scanCycles === 4, JSON.stringify(dbg && {
|
||||
dbg && typeof dbg.slots === "number" && dbg.slotsPerPdo === 5 &&
|
||||
dbg.slotCount === 27 && dbg.scanCycles === 6, JSON.stringify(dbg && {
|
||||
periodMs: dbg.periodMs, slots: dbg.slots }));
|
||||
}
|
||||
|
||||
@@ -226,15 +227,15 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
sent_seq: null, sent_frames: 0, queue_depth: 0,
|
||||
bridge: { valid: true, ack: 0, rx_count: 1000, tx_count: 5, drop: 2, tx_result: 0 },
|
||||
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
|
||||
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 7, global_seq: 42 },
|
||||
head: { active: motors.length, boot_active: false, win_base: 0, win_count: 5, global_seq: 42 },
|
||||
motors,
|
||||
}, extra || {});
|
||||
|
||||
const STATUS_HEX = "AB".repeat(32); /* 32B STATUS */
|
||||
const STATUS_HEX = "AB".repeat(48); /* 48B STATUS(v0.74) */
|
||||
const CONFIG_HEX = "5A".repeat(24); /* 24B CONFIG(0x0101) */
|
||||
|
||||
/* (a) 首次:seq 在动 → online=true,rx_count 从 1 起累 */
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 500, data: STATUS_HEX }]);
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 500, data: STATUS_HEX }]);
|
||||
let st = await tp.fetchMotorState();
|
||||
let m = st.motors[0];
|
||||
check("(a) 从站 online + seq 在动 → online=true", m.online === true);
|
||||
@@ -242,7 +243,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
check("(a) active_count 取自 head.active", st.active_count === 1);
|
||||
|
||||
/* (b) seq 递增 → rx_count 继续累加 */
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 501, data: STATUS_HEX }]);
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 501, data: STATUS_HEX }]);
|
||||
st = await tp.fetchMotorState();
|
||||
check("(b) seq 递增 → rx_count 累加到 2", st.motors[0].rx_count === 2,
|
||||
`rx=${st.motors[0].rx_count}`);
|
||||
@@ -250,7 +251,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
/* (c) CONFIG 从批量接收区累积 → 回填 motors[].config_data。
|
||||
* 这是 WiFi 有、EtherCAT 没有的字段,回填不了就等于 CONFIG 面板永久空白。 */
|
||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x0101, data: CONFIG_HEX }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 502, data: STATUS_HEX }],
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 502, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
st = await tp.fetchMotorState();
|
||||
check("(c) CONFIG(0x0101) 回填到 config_data(48 hex)",
|
||||
@@ -260,13 +261,13 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
/* (d) 版本(0x00FF):累积且 seq 递增 —— 上层靠 seq 增长判"这是本次查询的新应答" */
|
||||
const VER_HEX = "1A00" + "0200" + "0300" + "0400" + "00".repeat(16);
|
||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 503, data: STATUS_HEX }],
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 503, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
await tp.fetchMotorState();
|
||||
const v1 = await tp.fetchVersionNode(3);
|
||||
check("(d) 版本应答被累积", v1 && v1.node === 3 && v1.version === VER_HEX);
|
||||
rxFrames = [{ ch: 1, dlc: 24, valid: true, id: (3 << 16) | 0x00FF, data: VER_HEX }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 504, data: STATUS_HEX }],
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 504, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
await tp.fetchMotorState();
|
||||
const v2 = await tp.fetchVersionNode(3);
|
||||
@@ -275,7 +276,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
|
||||
/* (e) 节点 ID 应答:取走即清 */
|
||||
rxFrames = [{ ch: 1, dlc: 2, valid: true, id: (3 << 16) | 0x0401, data: "0700" }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 505, data: STATUS_HEX }],
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 505, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
await tp.fetchMotorState();
|
||||
check("(e) 节点 ID 应答可取出", tp.takeNodeIdReport(3) === "0700");
|
||||
@@ -283,7 +284,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
|
||||
/* (f) STATUS 已由状态窗口聚合,批量区里的 0x0100 必须跳过(否则重复解一遍) */
|
||||
rxFrames = [{ ch: 1, dlc: 32, valid: true, id: (3 << 16) | 0x0100, data: "FF".repeat(32) }];
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX }],
|
||||
snap = mkSnap([{ slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 506, data: STATUS_HEX }],
|
||||
{ rx_batch: { count: 1, lost: 0, pending: 0, frames: [] } });
|
||||
st = await tp.fetchMotorState();
|
||||
check("(f) 批量区的 STATUS(0x0100) 不污染 config_data",
|
||||
@@ -292,7 +293,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
|
||||
/* (g) xrx 长帧快照也要分派:CONFIG/版本是 DLC24,短快照会截断 */
|
||||
const CFG2 = "77".repeat(24);
|
||||
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 32, seq: 10, data: STATUS_HEX }],
|
||||
snap = mkSnap([{ slot: 1, node: 4, ch: 1, online: true, dlc: 48, seq: 10, data: STATUS_HEX }],
|
||||
{ xrx: { ch: 1, dlc: 24, id: (4 << 16) | 0x0101, data: CFG2 } });
|
||||
st = await tp.fetchMotorState();
|
||||
check("(g) xrx 长帧快照的 CONFIG 也回填",
|
||||
@@ -301,7 +302,7 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
/* (h) 关键:seq 停止不动超过阈值 → online 转 false。
|
||||
* 后端的快照**永不删除**见过的槽(电机拔了也还在),
|
||||
* 只认从站的 online 标志会让界面上永远挂着一台不存在的电机。 */
|
||||
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 32, seq: 506, data: STATUS_HEX };
|
||||
const stale = { slot: 0, node: 3, ch: 1, online: true, dlc: 48, seq: 506, data: STATUS_HEX };
|
||||
snap = mkSnap([stale]);
|
||||
st = await tp.fetchMotorState();
|
||||
check("(h) seq 未变但仍在阈值内 → 暂时仍算在线",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user