diff --git a/ECAT_RATE_TRADEOFF.html b/ECAT_RATE_TRADEOFF.html new file mode 100644 index 0000000..71bd810 --- /dev/null +++ b/ECAT_RATE_TRADEOFF.html @@ -0,0 +1,69 @@ + + + +EtherCAT 周期速度 × 故障率 × CPU 占用 调研 +
+

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

+
调研日期 2026-09-08 · 本机 I210 + 1 个 GD32E508 从站 · 两轮扫描:带 CAN / 拔掉全部 CAN · 数据 lab/ecat_rate_sweep_*.csv · 生成器 scripts/build_ecat_report_html.py
+
0.5ms实测可用上限(2kHz,带 CAN 工况)
≈9.0ppCAN 每拍恒定负担(3 档配对,饱和档除外)
4000 帧/s电机 STATUS 实测刷新(1/0.5ms)
≈8.2kHz纯 ECAT 实测硬上限(设 10/12.5/16kHz 实测均不涨)
+

结论

+

一、曲线

+
+
① 循环率:设定 vs 实测(主站侧)
2005001,0002,0005,00010,00020,0002005001,0002,0005,00010,00020,00097.2%98.0%51.2%82.3%65.9%理想 1:1(设定率)实测循环率 · 带 CAN实测循环率 · 无 CAN标注为达成率%(<99.5% 才标)。无 CAN 设定 >8kHz 后实测全顶在 ≈8.2kHz —— 硬上限设定循环率 (Hz,对数)实测循环率 (Hz,对数)
+
② 从站每拍负担:带 CAN vs 无 CAN
025.050.075.01002005001,0002,0005,00010,000100%Δ9.3ppΔ9.1ppΔ8.7pp带 CAN(电机在报 STATUS/CONFIG)拔掉全部 CAN 设备纵轴是固件 load_perm(只计 ISR+非延迟段,排除 SSC 轮询)—— 不是整机 CPU 占用率无 CAN 补测:0.25/0.125ms 满速;设定再快实测全顶在 ≈8.2kHz。≥4kHz 的负载读数全是饱和值,不能定位拐点带 CAN 的 0.25/0.125ms 两点同理停在 ~99.x% —— 饱和读数不等于"已测到上限"实测循环率 (Hz,对数)从站 load_perm (%)
+
③ CAN 流量分层:STATUS 刷新与 CONFIG 事件流
10.020.050.01002005001,0002,0005,00010,00020,0002005001,0002,0005,00010,000带 CAN 轮:STATUS 全流量塌陷CAN 总接收 rx_count ≡ STATUS 刷新 gseq(实测同为 ~4000/s)CONFIG 事件流独立实测仅约 92 帧/s(去重后),不随周期放大>2kHz 处 STATUS 与 CONFIG 一同塌到 ~150/s实测循环率 (Hz,对数)帧/s(对数)
+
④ 主站错拍率
0.100.200.5012510.020.050.01002005001,0002,0005,00010,00020,0005001,0002,0005,00010,000带 CAN无 CAN▽ = skip ≤0.05 次/s(真 0 档,不在对数刻度上)skip=错过的网格拍。0.0625ms 设定档 15720 次/s:实测率被钉在 8.2kHz,错拍数随设定率线性涨实测循环率 (Hz,对数)主站错拍 skip_total (次/s,对数)
+
+

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

+

二、数据全表

+

带 CAN(主结论轮)

周期实测Hz达成率从站loadCAN接收/sskip/swkc在线
4ms24999.7%20.2%3,9870.023/3未记录*
2ms49999.7%28.3%3,9900.083/3未记录*
1ms99899.8%44.5%3,9921.103/3未记录*
0.5ms1,999100.0%76.7%3,99511.863/3未记录*
0.25ms3,98999.7%99.7%15998.613/3未记录*
0.125ms7,78097.2%99.3%1905,106.623/3未记录*
+

拔掉全部 CAN 设备(补测轮)

周期实测Hz达成率从站loadCAN接收/sskip/swkc在线
2ms49999.7%19.0%00.083/30.00
1ms99899.8%35.4%00.583/30.00
0.5ms2,000100.0%68.0%017.083/30.00
0.25ms3,98899.7%99.4%039.723/30.00
0.125ms7,83998.0%98.8%04,672.073/30.00
0.0625ms8,19651.2%99.0%015,720.463/30.00
0.1ms8,23282.3%98.9%010,039.193/30.00
0.08ms8,23865.9%98.9%012,282.873/30.00
+

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

+

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

周期刷新率 帧/s判定
1ms4,000.2正常
0.5ms4,000.2正常
0.25ms87.9塌陷
0.125ms147.5塌陷

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

+

三、机理与上限推算

+

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

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

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

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

+

四、判据口径与踩过的坑

+

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

+

五、可信度边界

+

这份数据不能证明什么

+

六、下一步

+

按优先级

+
本报告由 scripts/build_ecat_report_html.py 从 lab/*.csv 与 lab/ecat_calib.json 现场渲染,图中每个数值均从原始数据计算,未手抄。native 实时:op=1 loop=1 wkc=3 period=1.0ms。Markdown 版(含同源结论):ECAT_RATE_TRADEOFF.md。
+
diff --git a/ECAT_RATE_TRADEOFF.md b/ECAT_RATE_TRADEOFF.md new file mode 100644 index 0000000..b6c2c28 --- /dev/null +++ b/ECAT_RATE_TRADEOFF.md @@ -0,0 +1,291 @@ +# EtherCAT 周期速度 × 故障率 × CPU 占用 调研 + +> **⚠ 本 Markdown 版含一条已作废的结论,权威版本是 `ECAT_RATE_TRADEOFF.html`。** +> 作废内容:第一节「`drop` ≡ λ → 丢帧率 100%、与周期无关」。**错因**:`drop`、 +> `bridge.rx_count`、`head.global_seq` **各自都以约 4000/s 增长**,两两相除必然得到 100%, +> 那只是同速量之比、不是丢帧。**反证**(后续实测):两台电机 STATUS 以 4001 帧/s 正常刷新, +> 帧确实收到了;而 ECAT 批量窗口去重后的 CONFIG 事件流只有约 92 帧/s,与 `drop` 的 4000/s +> 完全不成比例。`drop` 的口径至今未查明,**不得用它做任何丢帧判断**。 +> 仍然成立:速度上限、从站 CPU 负担、拔 CAN 对照、>2kHz 时 STATUS/CONFIG 一同塌陷。 + +调研日期:2026-09-08 · 台子:本机 I210 + 1 个 GD32E508 从站(双电机在线) +数据与脚本:`scripts/sweep_ecat_rate.py`(扫描)、`scripts/calibrate_ecat_metrics.py`(现场标定)、 +`scripts/plot_ecat_sweep.py`(出图)、`scripts/plot_ecat_nocan_vs_can.py`(带/无 CAN 对比); +曲线 `lab/ecat_rate_sweep.png`、`lab/ecat_rate_sweep_nocan_vs.png`;原始数据 +`lab/ecat_rate_sweep_withcan.csv`、`lab/ecat_rate_sweep_nocan.csv`、`lab/ecat_calib.json` +(`*.csv`/`*.jsonl` 按 `.gitignore` 约定不入库)。 +本轮共两轮扫描:**带 CAN**(第四~五节主结论)与**拔掉 CAN 的补测**(第四 b 节)。 + +--- + +## 一、结论 + +**速度余量约 2×,且瓶颈在从站 GD32,不在主站。** 实测可用到 **0.5ms(2kHz)**; +0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s(带 CAN 那轮 8s/档定点实测,当场确认 +`online=True`,故为真失效)。上限的**具体位置没测到** —— 只夹在 (0.25ms, 0.5ms] 之间。 + +**「加快 ECAT 周期能减少 CAN 丢帧」这个前提被本次数据推翻。** 在 4ms→0.5ms 整个区间, +桥接层丢帧 `bridge.drop` 与 CAN 到帧率 λ **完全相等**(丢帧率恒 100.0%),与周期无关。 +该字段属 GD32 段1 桥接层的接收计数,其消费者是 GD32 主循环而非 ECAT PDO 填充路径, +所以 ECAT 周期快慢改不了它(字段的精确含义尚有一处未闭合矛盾,见第五节)。 + +**提速的实际收益与代价**: + +| | 4ms | 2ms | 1ms(现行) | **0.5ms** | 0.25ms | 0.125ms | +|---|---|---|---|---|---|---| +| 实测循环率 | 249Hz | 499Hz | 998Hz | **1999Hz** | 3989Hz | 7780Hz | +| 达成率 | 99.7% | 99.7% | 99.8% | **100.0%** | 99.7% | 97.2% | +| GD32 负载 | 20.2% | 28.3% | 44.5% | **76.7%** | 99.7% | 99.3% | +| 主站错拍 skip/s | 0.02 | 0.08 | 1.1 | **11.9** | 98.6 | 5106.6 | +| 电机数据刷新 /s ¹ | 4000† | 4000† | 4000.2 | **4000.2** | 87.9 | 147.5 | +| CAN 到帧 λ /s | 3987 | 3990 | 3992 | **3995** | 159 | 190 | +| 桥接层丢帧 drop /s | 3987 | 3990 | 3992 | **3995** | 159 | 190 | +| 丢帧率 | 100.0% | 100.0% | 100.0% | **99.99%** | 100.0% | 100.0% | +| 主机 native CPU | 100.3% | 100.3% | 100.3% | **100.3%** | 100.3% | 87.5% | + +¹ 电机刷新率只在 **1 / 0.5 / 0.25 / 0.125ms** 四档定点实测(8s/档)。标 † 的 4ms、2ms 两列 +是按"λ 不随周期变化"推的,**未实测**。 + +建议:**要吃到这份余量就停在 0.5ms**(功能与 1ms 完全同速、达成率满分、CPU 76.7% 仍有余量, +代价是错拍从 1.1/s 升到 11.9/s)。0.25ms 不是"更快",是**功能损坏**。 + +**补测(拔掉全部 CAN)后新增两点**: +- CAN 的每拍负担是**常量 ≈9 个百分点**(2/1/0.5ms 三档 Δ=9.3/9.1/8.7pp),不随频率张开 + → 带 CAN 那轮的快档塌陷不是 CAN 挤出来的,上限由纯 ECAT 的过程数据交换决定。 +- 纯 ECAT 到 2kHz 只需 68.0%,模型外推上限约 **3.5kHz**(带 CAN 约 2.7kHz)。 + 但 **0.25ms 那档没测到数**:加压时从站停止应答(`wkc=-1`、`load_perm` 成残值), + 所以"3.5kHz"目前是外推而非实测,**不要据此设定期望值**。详见第四 b 节。 + +--- + +## 二、工况与数据身份(先声明,因为对账不上) + +| 项 | 值 | 能否确认 | +|---|---|---| +| GD32 烧写台账 | `lab/_last_gd32_sha.txt` = `201832b5a8e258d6…`(未提交状态) | 台账本身可读 | +| 在盘固件产物 | 展平后哈希 `63d6ab9b…`(09-08 02:31)、`e6a6e75f…`(09-05) | **均不等于台账** | +| 主站构建 | `ecat_native.c` 改于 02:39、`ecat_native.exe` 改于 02:40,均有未提交改动 | 运行中 PID 29284 起于 02:32 | +| 板载固件版本 | — | **无法确认**(设备不回报版本字) | + +三点后果,均已在结论中按保守口径处理: + +1. 台账哈希与任何在盘产物都不匹配,且产物时间戳(02:31)**晚于**台账记录 —— 无法证明板上跑的 + 就是当前源码编出的那一版。**本报告不复用任何历史固件结论**(含"1.6.5 已修 seqlock 撕裂")。 +2. 测量期间主站构建又更新过(02:39/02:40),无法证明 02:32 启动的进程内存里就是盘上那份 exe。 +3. `pdo_reentry` 字段要固件 1.6.7+ 才有、`rx_torn` 要 1.6.6+。两者本次恒零,**既可能是"没发生", + 也可能是"该字段根本没实现、恒读 0"** —— 因版本未知,二者无法区分。 + +--- + +## 三、判据口径(含三个当场失效/需修正的测法) + +先做 Phase 0 标定(`scripts/calibrate_ecat_metrics.py`)再扫描,因为固件改过很多版, +不能假设任何历史判据仍然有效。标定过程中我自己有三个判据当场失效或被我误判,记录如下以免重犯: + +| 测法 | 结果 | 处置 | +|---|---|---| +| 拿「seq 值域 − 观测到的 distinct seq」当丢帧率 | 报出 **99.686%** 丢帧 | **方向与 `drop`≈λ 一致,不是假信号**;但分母是全局 seq 值域(含 STATUS 全流量),口径不干净 → 不单独引用,只作 `drop` 的旁证。曾据 `OVF=0` 判它"被证伪",该否定**收回**(见下节 OVF 说明) | +| 用 HTTP 轮询看 `rx_batch.count` 当"每拍几帧" | 读出 cap=2,与源码 3 帧/拍不符 | 轮询欠采样。每拍口径必须取 native 的按拍环 `/api/can/cadence`(实测 count 众数=2,与轮询一致,但与 1.6.15 的 5 槽上限不是一回事) | +| 用 `rxraw` 的环快照列 `rxb_count_mode_per_cycle` / `lost` / `pending` | 快档读出 5、4、2 | 该环只存最近 512 拍,0.25ms 档 = 128ms、0.125ms 档 = 64ms,环没装满 —— 读到的是**行数**不是帧数。**这几列不入结论** | + +最终采用的口径: + +- **速度达成**:`cycles`(u32)增量 / 墙钟,**cycles 与时刻同取同舍**(沿用 `tools/ecat/rate_probe.py` 的纪律,其注释记过一次因分子分母不同源把 99.2% 误报成 96% 的实踩)。 +- **CAN 到帧 λ**:`head.global_seq`(u32,固件 `ECAT_MOTW_OFF_GLOBAL_SEQ`「状态表全局刷新序号」)。 + 与电机槽 `seq` 同速(实测均 4000/s)—— 两条独立字段互证,λ 口径可信。 +- **桥接层丢帧**:`bridge.drop`,固件出处 `ECAT_CAN_RX_OFF_DROP = 24`,注释明确为 + 「**投递时上一帧还没被取走**」的丢帧数(u16)—— 即窗口被覆盖而未送达主站的帧, + 语义正对本题。自增另有 `canfd.h:53 rx_drop_count`(CAN RX 软件队列满,`canfd.cpp:1615` + `queue.count >= kRxQueueDepth`,队列深 `kRxQueueDepth=16`);两者都指向"消费不及"。 + u16 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 + `(b-a) mod 65536`,单次增量 >32768 记为 + 绕圈作废。**本次作废点数 = 0**,即采样率足够。 +- **不能当"无丢帧"证据的字段**:`rx_batch.lost` / cadence 的 `lost`/`pending`。它们对应固件 + `OVF`,而 `fillRxWindow` 里 `in[...] = s_rxWinOvf; s_rxWinOvf = 0;` —— **每拍清零取增量**, + 实测恒 0 只说明单拍增量小于分辨率,不代表没有丢帧。曾据它判 `drop` 为死字段,此推断**收回**。 +- **功能存活(真故障轴)**:电机槽 `seq`(u32)前进速率。这是唯一直接回答"用户还能不能用"的指标。 +- **CPU(从站)**:`load_perm`(千分比,1s 滚动窗,`ECAT_DIAG_OFF_LOAD`)。**它不是整机 CPU 占用率** + —— 固件 `sys/cpu_load.h:9-27` 定义收窄为「中断 + 非延迟处理段」占墙钟之比,并**刻意排除** + SSC MainLoop 轮询与 `AppSpiCommandBus.poll()`;且当前是 free-run(`native/health.dc=0`), + 注释写明"free-run 调试模式下 PDO 应用跑在主循环轮询路径,**不计入**"。 + 因此:① 不能把"load→100%"读成"CPU 耗尽";② 但它仍随循环率强增(20.2%→76.7%), + 这份增量只能来自**被计入的部分**(PDI/EXTI 等挂钩 ISR 每拍触发一次), + 所以作为"每拍中断负担"的代理量仍然成立 —— 第五节据此建模,并把结论限定在这个口径内。 +- **CPU(主机)**:`psutil` 按 PID 差分,同时报单核% 与整机%(`scripts/cpu_sample.ps1` 只报前者, + 28 核机器上会把 3.6% 误读成 100%)。 +- **撕裂/重入**:`rx_torn` / `pdo_reentry` 为 u16 **饱和**计数(到 65535 停),按 + `delta_saturate` 处理,封顶即报"下界"而非精确值。 + +--- + +## 四、曲线与解读 + +![ECAT 速度-故障率-CPU 实测曲线](lab/ecat_rate_sweep.png) + +**① 主站循环率** 在 4ms→0.25ms 全程贴着 1:1(≥99.7%),0.125ms 才降到 97.2%。 +主站单拍耗时 `cyc_us.total.p50` 各档恒约 120–134µs、与设定周期无关(自旋钉核所致), +所以**主站侧根本不是限制项**。附带修正一条历史记录:`rate_probe.py` 写着"真机一送一回 +≈165µs 物理墙",本次实测 0.125ms 档跑到 7780Hz,那道墙在当前 5 槽布局下已经不是限制。 + +**② CPU 占用** 是唯一陡峭的分界线。GD32 负载 20.2 → 28.3 → 44.5 → **76.7 → 99.7**%, +在 0.5ms 与 0.25ms 之间越顶。主机 native 全程 100.3%(自旋线程恒占一个逻辑核, +`/api/motor/spin` 已是空操作,CPU 降不下来)—— 有意思的是 0.125ms 档反而**降到 87.5%**, +因为从站已经不回数据、总线事务提前完成:**主机 CPU 掉下来的那一刻,恰恰是从站死掉的那一刻**。 +这条曲线说明"提速免费"的判断只在一半意义上成立:不花主机成本,但花从站成本,而从站才是预算。 + +**③ 故障率** 是本次的核心图。λ 与 `drop` 两条线**重合到肉眼分不开**,横跨 4ms→0.5ms 全程, +然后一起从 4000/s 断崖式塌到 ~150/s。读法: +- 重合 = 到多少丢多少,**丢帧率 100% 且与周期无关** → 提速不改善它; +- 一起塌 = 0.25ms 起连 CAN 收帧本身都停了 → 提速会**打断**它。 +电机数据刷新率(红)与 λ 同步塌陷,确认这不是测量假象而是功能失效。 + +**④ 主站错拍 `skip_total`** 是次级上限:0.02 → 11.9(0.5ms)→ 98.6(0.25ms)→ 5107/s(0.125ms)。 +它在 GD32 越顶之前就已经指数上升,若只看主站视角,0.5ms 的 11.9 次/s 是可接受代价, +但 0.25ms 的 98.6 次/s 已说明主站也在勉强。 + +--- + +## 四b、补测:拔掉全部 CAN 设备后的纯 EtherCAT 速度 + +**动机**:带 CAN 的那轮里,快档同时出现「λ 塌陷」与「电机掉线」两种征象,二者在 λ/seq 上 +长得一模一样,无法区分是**每拍过程数据交换太重**还是**CAN 流量挤掉了主循环**。 +拔 CAN 是解这个混淆的判别性实验。 + +**结果**(同脚本同参数,`lab/ecat_rate_sweep_nocan.csv`,对比图 `lab/ecat_rate_sweep_nocan_vs.png`): + +| 周期 | 循环率 | 带 CAN | 无 CAN | Δ | +|---|---|---|---|---| +| 2ms | 500Hz | 28.3% | **19.0%** | 9.3pp | +| 1ms | 1000Hz | 44.5% | **35.4%** | 9.1pp | +| 0.5ms | 2000Hz | 76.7% | **68.0%** | 8.7pp | +| 0.25ms | 4000Hz | 99.7% | **不可引用** | — | + +**三条读数**: + +1. **CAN 的负担是常量、不是变量**:三档 Δ 稳定在 8.7–9.3pp。所以 CAN 既不是上限的成因, + 也不随频率张开 —— 带 CAN 那轮的快档塌陷**不是 CAN 挤出来的**。 +2. **纯 ECAT 到 2kHz 毫无问题**(68.0%,达成率 100.0%,错拍 17.1 次/s)。 +3. **上限仍没测到**:加压到 0.25ms 时**从站停止用有效数据应答** —— + `wkc=-1`(cadence 环里 512/512 拍全为 65535)、`bridge.valid=false`、`head.active=0`、 + `app/load` 的 `age_ms=214338`,而主站 `cycles` 仍以 1004Hz 前进、`op=1`、`al_status=0`。 + 即总线在空转、从站不应答。**0.25ms 档的 99.2% 是从站停报后的 load_perm 残值, + 本报告不引用。** + +**用无 CAN 数据重拟合第五节的两项模型**:F=(0.680−0.190)/(2.0−0.25)≈**272µs**、 +R≈4.0%,硬上限周期 F/(1−R)≈**284µs(3.5kHz)**。 +对比带 CAN 的 F≈323µs → 2.7kHz。**拔 CAN 大约把上限从 2.7kHz 抬到 3.5kHz, +但 0.25ms=4kHz 按此模型仍不可行** —— 与实测"0.25ms 出事"方向一致,只是这次 +出事的表征是从站不应答而非负载封顶,机理未定。 + +**扫描工具的两个缺陷由这轮暴露并已修**: +- 原脚本会把从站停报后的 `load_perm` 残值当实测写入结论 → 已加 `age_ms<1000` 闸, + 并新增 `gd32_load_samples` / `gd32_load_stale` 两列,使"这个数有没有被测到"可见。 +- 原脚本未记电机在线标志 → 已加 `motors_online` / `online_frac` / `motor_refresh_per_s`。 + +**台子现状**:从站仍停在 `wkc=-1`,恢复到 1ms 也不能自愈(native 的楔死看门狗未触发,`wr=0`)。 +继续测快档前需要先断电重启板子。 + + +--- + +## 五、机理:为什么是 2 倍而不是更多 + +从站每拍要写满输入 PDO。1.6.15 布局下输入 600B,`fillRxWindow` 在 +PDI_Isr/Sync0_Isr(preempt=0)里跑,**每拍都要跑一次**,与 CAN 是否有新帧无关。 + +先否定一个想当然的模型:把「负载 × 周期」当作每拍固定开销,反推得到 +808 / 566 / 445 / 384µs(4ms→0.5ms),**单调下降**——说明它量的是每拍总工作量, +里面还含一项与周期成正比的后台摊薄,不是常数。改成两项模型: + +``` +load(T) = F / T + R F = 每拍固定开销,R = 与周期无关的本底 +``` + +用两端点(4ms/20.2% 与 0.5ms/76.7%)辨识: + +``` +F = (0.767 − 0.202) / (1/0.5 − 1/4) = 0.565 / 1.750 ≈ 323µs +R = 0.202 − 323/4000 ≈ 11.7% +``` + +用**未参与拟合**的中间两点检验: + +| T | 模型 F/T+R | 实测 | 残差 | +|---|---|---|---| +| 2ms | 17.8% | 28.3% | +10.5 | +| 1ms | 44.0% | 44.5% | +0.5 | +| 0.25ms | **140.9%** | 99.7%(封顶) | 算术上 >100% → 不可行 | +| 0.125ms | **269.9%** | 99.3%(封顶) | 同上 | + +模型在 1ms 处几乎精确、2ms 处偏低(低速档还有别的开销项),但关键预测成立: +**存在硬上限周期 F/(1−R) ≈ 367µs ≈ 2.7kHz**,实测分界正好落在它两侧 +(0.5ms=2kHz 可跑、0.25ms=4kHz 不可跑)。这条是可证伪的:若下一次扫描在 0.4ms +也能满速运行且负载 <100%,本模型即被推翻。 + +用第四 b 节的**无 CAN** 数据独立重拟合,得 F≈272µs、R≈4.0%、上限 ≈284µs(3.5kHz)—— +同一模型形式、两组独立数据、F 相差 16%(正是 CAN 那份负担),说明 F/T+R 这个结构站得住。 +但**两组都没能直接测到上限本身**:带 CAN 时在 0.25–0.5ms 之间靠"功能塌陷"间接定位, +无 CAN 时 0.25ms 直接从站停报。所以上限目前是被**夹在** (0.25ms, 0.5ms] 区间内, +不是被测出来的。 + +结论:从站侧的极限周期由**每拍固定开销 F**决定,要突破只能减小每拍要搬的字节数 +(当前 600B / 5 槽 / 56B 电机槽),**不是调主站周期能解决的**。主机侧完全有余量 +(native 恒约 100% 单核,与周期无关),所以预算全在从站。 + +丢帧是另一条独立链路,与 ECAT 周期无关:`drop` 字段位于**段1(CAN RX 桥,偏移 24)**, +属 GD32 桥接层自己的接收/投递计数,其消费者是主循环(`sys/task_runner.cpp:248`)而非 +ECAT PDO 填充路径 —— 所以 ECAT 周期快慢改不了它,实测四个档位恒等于 λ 也印证了这点。 + +但这里有一处**尚未闭合的矛盾**,不能糊过去:固件 `config.h:125` 的 1.6.15 设计说明写着 +「STATUS 自 1.6.9 起不入窗,滤掉后入窗流量降到 CONFIG 量级」「5 槽=5000 帧/s 对 4000 帧/s +留 25% 余量」—— 按此设计意图,丢帧应当接近 0。实测却是 `drop` 以 3992/s 持续增长、 +且恰好等于 λ(含 STATUS 的全流量)。两种解释都还活着: +(a) 该计数统计的是入窗**之前**的 CAN RX 队列/桥接层丢弃,STATUS 在入队环节就被丢,与窗口 + 容量无关(则 `drop`≈λ 完全合理,设计意图也没被违背); +(b) 字段在当前固件里已改义或读错偏移,`drop`≈λ 是巧合。 +本次数据不足以区分二者,**列为待验证第 1 项**。无论哪种,都不影响主结论: +`drop` 与 ECAT 周期无关 → 提速不改善它。 + +--- + +## 六、局限 + +1. 单台单次,未做重复性与温漂验证。 +2. λ≈3993/s 是本次台子的 CAN 工况。λ 变则分界变,本报告给的是**相对关系**, + 0.5ms 这个具体数字不可直接搬到别的 CAN 流量下。 +3. 板载固件版本无法确认(见第二节),历史固件结论一律未采信;`rx_torn` / `pdo_reentry` + 恒零属**无检出功效**,只能表述为"本次测不到",**不得**读成"无撕裂/已修好"。 +4. `cyc_us.late` / `cyc_us.lost` 恒为 0(`ecat_native.c` 里是 Python 时代遗留占位),未接线,未使用。 +5. 扫描用 HTTP 以 10Hz 打 native,对被测对象有扰动;但主机 CPU 已恒 100% 单核, + 且 native 另有 HTTP 线程,扰动量级判断为小 —— **未单独做无负载对照实验**。 +6. `rxb_count_mode_per_cycle` / `rxb_lost_max` / `rxb_pending_max` 三列因环缓冲快照问题不可引用 + (见第三节),CSV 中保留原值但结论未使用。 +6b. **快档塌陷这条结论的证据来源要分清**:电机刷新率与 λ 的塌陷,取自扫描后另做的 8s/档 + 定点实测,那四次都当场确认 `online=True` 且 seq 在动,所以是**真功能失效**,不是电机 + 掉线造成的假象。但扫描 CSV 本身**没记在线标志**,事后无法复核。补该列前不要引用 + CSV 的快档数字。 + (2026-09-08 复测时电机已处于 `online=False`、`global_seq` 冻结状态 —— 总线本身正常, + cycles 仍以 1000Hz 前进、CAN 侧仍以 ~3700 帧/s 收帧,停的是 STATUS 上报。 + 该状态下的 0.4/0.35ms 快档读数一律不采信,也**未**写入本报告。) +7. 每拍固定开销 F≈323µs 是从 4 个负载点**反推**的,不是直接计时测得;两项模型在 + 2ms 档残差 +10.5 个百分点,说明低速档还有未建模的开销项。F 只能当量级用。 + +## 七、待验证(下一步该做的判据) + +1. **`drop` 语义二选一**(第五节的 (a)/(b)):从 ESP32/WiFi 侧读固件那份 u32 + `rx_drop_count`(`app_spi_cmd_can_feedback.cpp:99`,CAN 通道队列满丢弃的权威口径), + 与 ECAT 侧 u16 `bridge.drop` 对速率。若两侧同为 ~4000/s → (a) 成立,丢帧发生在入窗之前, + 与 ECAT 无关;若 WiFi 侧接近 0 而 ECAT 侧 4000/s → (b) 成立,字段读错偏移, + 本报告的故障率轴需要整体重做。**这是唯一还悬着的承重结论。** +2. **从站侧健康度目前不可读**:`OVF` 在 `fillRxWindow` 里每拍清零,只能取到单拍增量, + 而单拍增量落在 u8 分辨率以下即恒 0 —— 等于从站没有可用的累计丢帧仪表。 + 要判窗口是否真的健康,需要固件改成累计值或主站侧累加(属改动,需另行批准)。 +3. **每拍固定开销 F 直接计时**:给 `fillRxWindow` 加计时(属改动,需另行批准)。 + 目前 F≈323µs 是从 4 个负载点反推的;直接量到 F 就能把「上限 2.7kHz」从拟合 + 变成算式,也才能判断减字节能否真的换到速度。 +4. **模型已被证伪(保守方向)**:F/T+R 预测 0.4ms 约 92% 可行,但 0.4ms/0.35ms 的复测因 + 电机掉线作废,**上限究竟在 0.4 还是 0.5ms 仍未定**。恢复电机后需重跑这两档; + 在此之前,建议值只写"0.5ms 已验证可跑",不要外推到 0.4ms。 +5. **重复性**:同一档跑 3 次看 λ / GD32 负载 / 电机刷新率 / **online 标志** 的散布, + 确认 0.5ms 与 0.25ms 的分界不是单次偶然,也不是电机掉线的副产物。 +6. **0.5ms 档实跑功能验证**:在 0.5ms 下开电机页跑一段完整命令序列,确认前端 + `drainRx()` 与波形在 2kHz 下不积压(本调研只测了计数器,没测界面实际可用性)。 diff --git a/css/motor.css b/css/motor.css index 389f110..fd0febb 100644 --- a/css/motor.css +++ b/css/motor.css @@ -762,6 +762,38 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); } .seq-ops button.seq-send-one:hover:not(:disabled) { background: var(--ok-dim); color: var(--ok); } .seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); } .seq-ops button:disabled { opacity: .25; cursor: default; } +/* 拖动把手:按住上下换位(取代旧的上移/下移按钮),聚焦后 ↑↓ 方向键同样可移 */ +.seq-grip { + flex-shrink: 0; width: 18px; height: 26px; margin-left: -4px; + display: inline-flex; align-items: center; justify-content: center; + color: var(--fg-dim); border-radius: var(--radius-sm); + cursor: grab; user-select: none; -webkit-user-select: none; touch-action: none; + transition: background .15s, color .15s; +} +.seq-grip:hover { color: var(--primary); background: var(--primary-dim); } +.seq-grip:active { cursor: grabbing; } +.seq-grip:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; } +.seq-grip svg { pointer-events: none; } +/* 折叠:默认摘要不显示;折叠行收起动作组、只留头部 + 一行功能摘要 */ +.seq-fold svg { transition: transform .15s; } +.seq-step:not(.collapsed) .seq-fold svg { transform: rotate(90deg); } +.seq-summary { + display: none; font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.seq-sum-sep { color: var(--border-strong); margin: 0 var(--sp-2); } +.seq-sum-more { color: var(--fg-dim); margin-left: var(--sp-2); } +.seq-step.collapsed { padding: 6px var(--sp-3); } +.seq-step.collapsed .seq-step-head { margin-bottom: 0; } +.seq-step.collapsed .seq-actions { display: none; } +.seq-step.collapsed .seq-summary { display: block; margin-top: 2px; } +/* 拖拽中:整表(含被拖行)临时收成单行,腾出空间便于看清插入位置 */ +.seq-list.dragging { gap: var(--sp-2); user-select: none; } +.seq-list.dragging .seq-step { padding: 6px var(--sp-3); } +.seq-list.dragging .seq-step-head { margin-bottom: 0; } +.seq-list.dragging .seq-actions { display: none; } +.seq-list.dragging .seq-summary { display: block; margin-top: 2px; } +.seq-step.drag-src { opacity: .45; border-style: dashed; border-color: var(--primary); } .seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); } .seq-step-fields .field-label { font-size: 0.62rem; } .seq-step-fields .seq-num { width: 100%; } diff --git a/esp32_server.py b/esp32_server.py index afde5cb..ab0505c 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -142,11 +142,12 @@ g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 809 # 换成原生守护进程 build_native\ # ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python # 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约): -# 1) **必须先探测再拉起**。native 独立启动(无 --parent-pid)会清场: 对 +# 1) **必须先探测再拉起**。native 启动时**无条件清场**(后起的独占): 对 # 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。 -# 所以已有实例时只复用, 绝不重复拉起。 -# 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在 -# 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。 +# 已有实例时仍只复用不重复拉起 —— 清场是给"并发拉起撞上"兜底的, +# 不该被用来每次踢掉用户手开的实例。 +# 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断, +# 不留孤儿进程占着 Npcap 适配器和端口。(与清场无关, 清场总是执行) # 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。 ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat")) ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe") @@ -226,11 +227,14 @@ def _ecat_autostart(): g_ecat_child = None if g_ecat_child is None: try: - # --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后 - # 自我了断(不留孤儿占 Npcap/端口)。 - # --mask 0x10: PDO 自旋线程钉 LP4(i7-14700K P核2), 避开 DPC 集中的 - # 核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。不钉的话 - # 自旋线程随调度器漂移, 1ms 档周期抖动明显放大。 + # --parent-pid: 让 native 在本服务退出后自我了断(不留孤儿占 + # Npcap/端口)。native 侧的清场与它无关, 启动时无条件执行。 + # --mask 0x10: PDO 自旋线程钉单个 LP4(i7-14700K P核2), 避开 DPC + # 集中的核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。 + # 掩码**只能给单比特**: 多比特(如 0x50=LP4|LP6)不是"两个核一起 + # 自旋", 而是授权调度器在这一个线程上来回迁移, 迁移期间的迟到拍 + # 由 skip_total 计入、直接压低周期达成率。不钉的话随调度器漂移, + # 1ms 档周期抖动明显放大。 # cwd=tools/ecat: --web 的相对路径(src/web)按此解析。 g_ecat_child = subprocess.Popen( [ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x10", diff --git a/js/motor/main.js b/js/motor/main.js index 048a632..515ac34 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -97,6 +97,7 @@ const state = { selectedNode: null, // 当前选中节点号 motors: [], // 最近一次 /motor/state 的 motors 数组 decoded: new Map(), // node -> 解码后的 STATUS 对象 + decodedConfig: new Map(),// node -> 解码后的 CONFIG 对象(电机实时回报的目标模式/目标值) versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化) /* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。 * 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。 @@ -1456,6 +1457,17 @@ function refreshChannelViews() { $("#nodeSummary").textContent = `${onlineCount}/9 在线 · CH${state.ch}`; } +/** 节点卡片模式标签:电机 CONFIG 帧 B0(= FOC.control_mode)每周期实时回报,是唯一权威来源; + * 仅在这条链路还没回过 CONFIG 时,退到前端记录的最近发送 CMD。 + * 都不到就用 "--",不要写"待机"—— 电机没有这个状态,写了就是假仪表。 */ +function modeLabelOf(m) { + const cfg = state.decodedConfig.get(motorKey(m)); + const mode = cfg ? cfg.targetMode : state.lastSentMode.get(m.node); + if (mode == null) return "--"; + const def = MODES[mode]; + return def ? def.zh : ("模式" + mode); +} + function renderNodeList() { const motors = getDisplayMotors(); @@ -1513,11 +1525,9 @@ function renderNodeList() { const ledFull = "led " + ledCls; if (ref.led.className !== ledFull) ref.led.className = ledFull; - const sentMode = state.lastSentMode.get(m.node); - const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null; - /* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机。 + /* motStatus 是报错位(1=故障),不是运行标志;模式取电机 CONFIG 帧实时回报。 * otherCh:节点在其他通道在线,标注实际通道供用户切换。 */ - const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机")); + const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m)); if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel; const modeCls = m.otherCh != null ? "nc-mode mode-other-ch" : (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode")); @@ -1556,9 +1566,7 @@ function buildNodeCardHtml(m) { faultCount > 0 ? "fault" : "", !m.online ? "offline" : "", ].join(" ").trim(); - const sentMode = state.lastSentMode.get(m.node); - const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null; - const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机")); + const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m)); const modeCls = m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : "")); const stTxt = !m.online ? "--" : (faultLabel || "OK"); const cardTitle = faultLabel ? ("故障: " + faultLabel) @@ -2015,6 +2023,36 @@ function seqNodeOptions(selected) { return FIXED_MOTOR_NODES.map((n) => ``).join(""); } +/* 步骤序号中文读法:1 → 一、11 → 十一、20 → 二十;超出 99 退回阿拉伯数字避免读错 */ +const CN_DIGITS = "零一二三四五六七八九"; +function cnNum(n) { + if (n < 10) return CN_DIGITS[n]; + if (n < 20) return "十" + (n % 10 ? CN_DIGITS[n % 10] : ""); + return CN_DIGITS[Math.floor(n / 10)] + "十" + (n % 10 ? CN_DIGITS[n % 10] : ""); +} +function seqStepName(i) { + const n = i + 1; + return n <= 99 ? "步骤" + cnNum(n) : "步骤 " + n; +} +/* 折叠态摘要:一个 action 压成 "CH0·N3 速度+位置 速度100°/s 角度0°" */ +const SEQ_TARGET_ZH = { voltage: "电压", current: "电流", velocity: "速度", angle: "角度" }; +const SEQ_TARGET_UNIT = { voltage: "V", current: "mA", velocity: "°/s", angle: "°" }; +const SEQ_SUMMARY_MAX = 3; /* 一行最多列 3 台电机,其余折叠成 "+N 台" */ +function seqActionBrief(a) { + const m = MODES[parseInt(a.mode, 10)] || null; + const vals = (m ? m.targets : []).map((t) => + `${SEQ_TARGET_ZH[t] || t}${a[t] != null ? a[t] : "0"}${SEQ_TARGET_UNIT[t] || ""}`).join(" "); + return `CH${a.ch}·N${a.node} ${(m && m.zh) || "模式" + a.mode}${vals ? " " + vals : ""}`; +} +function seqStepSummaryHtml(s) { + const acts = s.actions || []; + if (!acts.length) return "无动作"; + const html = acts.slice(0, SEQ_SUMMARY_MAX).map((a) => escapeHtml(seqActionBrief(a))) + .join('|'); + return acts.length > SEQ_SUMMARY_MAX + ? `${html}+${acts.length - SEQ_SUMMARY_MAX} 台` + : html; +} /* 归一单个 action:补齐 ch/node/各参数默认值(ch/node 缺失时回退当前通道/选中节点) */ function actionFromAny(a) { a = a || {}; @@ -2031,14 +2069,16 @@ function actionFromAny(a) { pidAngP: a.pidAngP != null ? a.pidAngP : "100", }; } -/* 归一步骤:新结构 {holdMs, actions[]};旧扁平步骤(含 mode 等顶层字段)→ 单动作组 */ +/* 归一步骤:新结构 {holdMs, actions[], collapsed?};旧扁平步骤(含 mode 等顶层字段)→ 单动作组。 + * collapsed 是 UI 态但挂在步骤对象上:拖拽换位时要跟着这一步走,按索引记会错位。 */ function normStep(s) { s = s || {}; const holdMs = clampNum(parseInt(s.holdMs, 10) || 1000, 0, 600000); + const collapsed = !!s.collapsed; if (Array.isArray(s.actions) && s.actions.length) { - return { holdMs: holdMs, actions: s.actions.map(actionFromAny) }; + return { holdMs: holdMs, collapsed: collapsed, actions: s.actions.map(actionFromAny) }; } - return { holdMs: holdMs, actions: [actionFromAny(s)] }; + return { holdMs: holdMs, collapsed: collapsed, actions: [actionFromAny(s)] }; } function cloneAction(a) { return Object.assign({}, actionFromAny(a)); } /* 读取当前 CMD 表单已提交值 + 当前通道/选中节点,作为一个新 action(与 buildFormCmdBytes 同源) */ @@ -2062,12 +2102,15 @@ function renderSeqList() { seqListEl.innerHTML = '
尚未添加步骤 — 点击「+ 添加步骤」把当前 CMD 表单(含当前通道/节点)存为一步;每步可再「+ 电机」加入多个动作,同一步内的动作近同时下发
'; } else { seqListEl.innerHTML = seq.steps.map((s, i) => ` -
+
- 步骤 ${i + 1} + + + + ${seqStepName(i)} ${s.actions.length} 电机 保持 - + ms
- -
+
${seqStepSummaryHtml(s)}
${s.actions.map((a, j) => `
电机 ${j + 1} - - - + + +
- - -
-
- 满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。
- 判读:失败必须为 0;「最快」是链路物理极限周期;「平均-最快」差值是软件调度开销。 -
-
- -
-
等待测试…
-
- -
- - - -
时间帧数载荷失败平均最快吞吐
-
本页历史仅存内存,刷新即清空——方便改参数前后对比。
-
- - - - + + + + + + +链路测速台 · SPI / EtherCAT + + + +
+
+
+

链路测速台 · DCU 总线吞吐取证

+
临时诊断工具 [SPEEDTEST] — SPI 探针 0xA1 APP PING · EtherCAT 只读观测 ecat_native 过程数据
+
+ +
+ + + + + + +
+ + + diff --git a/pages/motor.html b/pages/motor.html index c5b7db0..e202f9d 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -6,7 +6,7 @@ 电机控制台 — Motor CAN FD Console - + @@ -282,6 +282,7 @@ 发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。 每步的绿色「▶」按钮为单发:只把该步全部动作下发一次,不循环、不推进步骤、不等待保持时间。 循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。 + 按住每步左侧「⠿」把手即可上下拖动排序(点选把手后用 ↑/↓ 同样可移);点「▸」折叠本步,只留一行功能摘要(通道·节点 / 模式 / 目标值);拖动过程中全部步骤自动折叠,方便看清插入位置。

@@ -401,7 +402,7 @@
- + diff --git a/scripts/build_ecat_report_html.py b/scripts/build_ecat_report_html.py new file mode 100644 index 0000000..301e4b0 --- /dev/null +++ b/scripts/build_ecat_report_html.py @@ -0,0 +1,677 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""把扫描数据渲染成自包含 HTML 调研报告(图表为内联 SVG)。 + +为什么手写 SVG 而不用 matplotlib:产出的 HTML 要**单文件自包含**,不引外部 +JS/CSS/图片依赖,也不能要求阅读者装 matplotlib;SVG 直接内嵌,浏览器打开即看, +且数值标注可以精确控制位置(matplotlib 的 annotate 在这类密集小图上很容易压线)。 + +纪律同 plot_ecat_sweep.py: + * 图里画的**只有实测点**。模型/参考一律用虚线并写明"外推",不与实测混同。 + * 无功效的轴(整段恒零)不画成"平坦的零线",改成功效声明块。 + * 从站停报的档位(wkc 不达预期 / load_perm 为残值)一律排除出图,并在页面上 + 显式列出被排除的档位与原因 —— 空图比假图诚实。 + +用法: + python scripts/build_ecat_report_html.py + python scripts/build_ecat_report_html.py --out ECAT_RATE_TRADEOFF.html +""" +import argparse +import html +import json +import math +import os +import sys +from collections import OrderedDict + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +sys.path.insert(0, HERE) +from ecat_sweep_common import post # noqa: E402 (仅用于探测 native 是否在跑) + +import csv as _csv + + +# ───────────────────────────────────────────────────────── 数据读取 ───────────────────────────────────────────────────────── +def read_csv(path): + if not os.path.isfile(path): + return [] + rows = [] + for r in _csv.DictReader(open(path, encoding="utf-8")): + def num(k): + v = r.get(k, "") + try: + return float(v) if v not in ("", "None", None) else None + except ValueError: + return None + rows.append({ + "period_ms": float(r["period_ms"]), + "hz_set": 1000.0 / float(r["period_ms"]), + "hz_meas": num("measured_hz"), + "achieve": num("achieve_pct"), + "load": num("gd32_load_pct"), + "load_samples": num("gd32_load_samples"), + "load_stale": num("gd32_load_stale"), + "host": num("host_cpu_1core_pct"), + "lambda_": num("can_lambda_per_s"), + "drop": num("drop_per_s"), + "drop_pct": num("drop_pct_of_lambda"), + "skip": num("skip_per_s"), + "wkc": r.get("wkc"), "exp_wkc": r.get("expected_wkc"), + "wr": r.get("wr"), "hijacked": r.get("hijacked"), + "pid_stable": r.get("pid_stable"), + "online_frac": num("online_frac"), + "untrusted": num("drop_untrusted"), + "path": path}) + rows[-1]["hz"] = rows[-1]["hz_meas"] or rows[-1]["hz_set"] + rows[-1]["wkc_ok"] = rows[-1]["wkc"] == rows[-1]["exp_wkc"] + rows.sort(key=lambda r: r["hz"]) + return rows + + +# ───────────────────────────────────────────────────────── SVG 图元 ───────────────────────────────────────────────────────── +def _ticks(lo, hi, n=5): + """线性刻度:给一组整齐步进的刻度值。""" + if hi <= lo: + return [lo] + raw = (hi - lo) / max(1, n - 1) + mag = 10 ** math.floor(math.log10(raw)) if raw else 1 + step = mag * next((k for k in (1, 2, 2.5, 5, 10) if raw / mag <= k), 10) + v = math.ceil(lo / step) * step + out = [] + while v <= hi + 1e-9: + out.append(round(v, 6)) + v += step + return out + + +def _log_ticks(lo, hi): + out = [] + for e in range(int(math.floor(math.log10(lo))), int(math.ceil(math.log10(hi))) + 1): + for m in (1, 2, 5): + v = m * (10 ** e) + if lo - 1e-9 <= v <= hi + 1e-9: + out.append(v) + return out or [lo] + + +def _lab(v): + if v is None: + return "n/a" + a = abs(v) + if a >= 1000: + return f"{v:,.0f}" + if a >= 100: + return f"{v:.0f}" + if a >= 10: + return f"{v:.1f}" + if a >= 1: + return f"{v:.1f}" if v != int(v) else str(int(v)) + return f"{v:.2f}" if a else "0" + + +class Svg: + """极简 SVG 累加器。""" + + def __init__(self, w, h): + self.w, self.h, self.b = w, h, [] + self.L, self.R = 64, w - 16 + self.T, self.B = 26, h - 42 + + def scale(self, xlog=False, ylog=False, xr=None, yr=None): + self.xlog, self.ylog = xlog, ylog + self.xr, self.yr = xr, yr + + def X(self, v): + if self.xlog: + lo, hi = self.xr + return self.L + (math.log10(max(v, 1e-9)) - math.log10(lo)) / \ + (math.log10(hi) - math.log10(lo)) * (self.R - self.L) + lo, hi = self.xr + return self.L + (v - lo) / (hi - lo or 1) * (self.R - self.L) + + def Y(self, v): + if self.ylog: + lo, hi = self.yr + return self.B - (math.log10(max(v, 1e-9)) - math.log10(lo)) / \ + (math.log10(hi) - math.log10(lo)) * (self.B - self.T) + lo, hi = self.yr + return self.B - (v - lo) / (hi - lo or 1) * (self.B - self.T) + + def grid(self, xt, yt, xf=_lab, yf=_lab): + for v in yt: + try: + y = self.Y(v) + except ValueError: + continue + if not (self.T - 1 <= y <= self.B + 1): + continue + self.b.append(f'') + self.b.append(f'{html.escape(yf(v))}') + for v in xt: + x = self.X(v) + if not (self.L - 1 <= x <= self.R + 1): + continue + self.b.append(f'') + self.b.append(f'{html.escape(xf(v))}') + + def axes(self, xl, yl): + self.b.append(f'') + self.b.append(f'') + self.b.append(f'{html.escape(xl)}') + self.b.append(f'{html.escape(yl)}') + + def line(self, pts, color, dash=None, wid=2.4, marker="circle", r=4.2, + label=None, lab_dxy=(9, -9)): + live = [(x, y) for x, y in pts if x is not None and y is not None] + if not live: + return + if len(live) > 1: + d = " ".join(f"{'M' if i == 0 else 'L'}{self.X(x):.1f},{self.Y(y):.1f}" + for i, (x, y) in enumerate(live)) + da = f' stroke-dasharray="{dash}"' if dash else "" + self.b.append(f'') + for i, (x, y) in enumerate(live): + cx, cy = self.X(x), self.Y(y) + if marker == "circle": + self.b.append(f'') + elif marker == "square": + self.b.append(f'') + elif marker == "tri": + p = f"{cx:.1f},{cy-r-1:.1f} {cx-r-1:.1f},{cy+r:.1f} {cx+r+1:.1f},{cy+r:.1f}" + self.b.append(f'') + if label and i == len(live) - 1: + dx, dy = lab_dxy + self.b.append(f'{html.escape(label)}') + + def vtext(self, x, y, txt, color="#374151", anchor="middle", size=10.5, weight="400"): + self.b.append(f'{html.escape(txt)}') + + def legend(self, entries, x=None, y=None): + x = self.L + 10 if x is None else x + y = self.T + 12 if y is None else y + for i, (c, t, dash) in enumerate(entries): + yy = y + i * 17 + da = f' stroke-dasharray="{dash}"' if dash else "" + self.b.append(f'') + self.b.append(f'') + self.b.append(f'{html.escape(t)}') + + def hline(self, v, color="#9ca3af", dash="3,3", wid=1.2): + y = self.Y(v) + if not (self.T - 1 <= y <= self.B + 1): + return + self.b.append(f'') + + def band(self, x0, x1, color="#ef4444", alpha=0.07): + a, b = self.X(x0), self.X(x1) + self.b.append(f'') + + def note(self, txt, x=None, y=None, color="#6b7280", anchor="start", size=10.5): + x = self.L + 6 if x is None else x + y = self.B - 8 if y is None else y + self.b.append(f'{html.escape(txt)}') + + def finish(self, title, sub=""): + head = "" + if sub: + head = f'' \ + f'{html.escape(sub)}' + return (f'' + f'{head}{"".join(self.b)}') + + +# ───────────────────────────────────────────────────────── 具体图 ───────────────────────────────────────────────────────── +C_RED, C_BLUE, C_GREEN, C_PINK, C_ORANGE, C_GREY, C_PURPLE = ( + "#dc2626", "#2563eb", "#16a34a", "#db2777", "#ea580c", "#6b7280", "#7c3aed") + + +def chart_achieve(rows, rows_no=()): + s = Svg(560, 320) + allr = list(rows) + list(rows_no) + lo = min(r["hz_set"] for r in allr) * 0.7 + hi = max(r["hz_set"] for r in allr) * 1.5 + s.scale(True, True, (lo, hi), (lo, hi)) + s.grid(_log_ticks(lo, hi), _log_ticks(lo, hi)) + s.line([(lo, lo), (hi, hi)], C_GREY, dash="5,4", label=None) + s.line([(r["hz_set"], r["hz_meas"]) for r in rows], C_RED, marker="circle") + s.line([(r["hz_set"], r["hz_meas"]) for r in rows_no], C_BLUE, marker="square") + for r in allr: # 只标偏离点,免得满图都是 99.x% + if (r["achieve"] or 100) < 99.5: + s.vtext(r["hz_set"], r["hz_meas"] * 0.72, + _lab(r["achieve"]) + "%", color="#1e40af", size=9.5, anchor="middle") + s.legend([(C_GREY, "理想 1:1(设定率)", "5,4"), + (C_RED, "实测循环率 · 带 CAN", None), + (C_BLUE, "实测循环率 · 无 CAN", None)], x=s.L + 12, y=s.T + 46) + s.note("标注为达成率%(<99.5% 才标)。无 CAN 设定 >8kHz 后实测全顶在 ≈8.2kHz —— 硬上限", + y=s.B - 8) + s.axes("设定循环率 (Hz,对数)", "实测循环率 (Hz,对数)") + return s.finish("循环率:设定 vs 实测") + + +def chart_load(rows_no, rows_wc): + s = Svg(620, 350) + allr = rows_no + rows_wc + lo = min(r["hz"] for r in allr) * 0.75 + hi = max(r["hz"] for r in allr) * 1.35 + s.scale(True, False, (lo, hi), (0, 112)) + s.grid(_log_ticks(lo, hi), _ticks(0, 110, 6)) + s.hline(100, color="#6b7280", dash="2,4") + s.b.append(f'100%') + pts_wc = [(r["hz"], r["load"]) for r in rows_wc if r["load"] is not None] + pts_no = [(r["hz"], r["load"]) for r in rows_no if r["load"] is not None] + s.line(pts_wc, C_RED, marker="circle") + s.line(pts_no, C_BLUE, marker="square") + wc_by_p = {round(r["period_ms"], 4): r for r in rows_wc} + for r in [x for x in rows_no if x["load"] is not None]: + w = wc_by_p.get(round(r["period_ms"], 4)) + if w and w["load"] is not None: + # 任一侧饱和(≥95%)时 Δ 只反映夹紧,不标注 + if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95: + continue + s.vtext(r["hz"], (r["load"] + w["load"]) / 2, f"Δ{w['load']-r['load']:.1f}pp", + color=C_PURPLE, size=10, weight="600") + s.legend([(C_RED, "带 CAN(电机在报 STATUS/CONFIG)", None), + (C_BLUE, "拔掉全部 CAN 设备", None)], x=s.L + 12, y=s.T + 12) + s.note("纵轴是固件 load_perm(只计 ISR+非延迟段,排除 SSC 轮询)—— 不是整机 CPU 占用率", + y=s.B - 8) + s.note("无 CAN 补测:0.25/0.125ms 满速;设定再快实测全顶在 ≈8.2kHz。" + "≥4kHz 的负载读数全是饱和值,不能定位拐点", y=s.B - 22, color="#b45309") + s.note("带 CAN 的 0.25/0.125ms 两点同理停在 ~99.x% —— 饱和读数不等于\"已测到上限\"", + y=s.B - 36, color="#b45309") + s.axes("实测循环率 (Hz,对数)", "从站 load_perm (%)") + return s.finish("从站每拍负担") + + +def chart_traffic(rows): + """CAN 流量分层:STATUS 刷新(≈CAN 总接收)与 CONFIG 事件流。 + + drop 不再入图:它与 rx_count/gseq 同速增长(各约 4000/s),拿它做分子分母 + 只会得到无意义的 100%(本次实际踩过的坑,详见第四节仪表陷阱)。 + """ + s = Svg(620, 350) + ok = [r for r in rows if (r["lambda_"] or 0) > 0] + if not ok: + s.note("无可用的 CAN 流量档位(本轮 CAN 已拔除)", y=(s.T + s.B) / 2, anchor="middle") + return s.finish("CAN 流量分层", "(无数据)") + lo = min(r["hz"] for r in ok) * 0.75 + hi = max(r["hz"] for r in ok) * 1.35 + s.scale(True, True, (lo, hi), (10, 20000)) + s.grid(_log_ticks(lo, hi), _log_ticks(10, 20000)) + s.line([(r["hz"], r["lambda_"]) for r in ok], C_GREEN, marker="circle") + s.band(2500, 9000) + s.vtext(1400, 4000 * 1.35, "带 CAN 轮:STATUS 全流量", color="#166534", size=10) + s.vtext(4200, 150, "塌陷", color="#b91c1c", size=11, weight="700") + s.legend([(C_GREEN, "CAN 总接收 rx_count ≡ STATUS 刷新 gseq(实测同为 ~4000/s)", None)], + x=s.L + 12, y=s.T + 12) + s.note("CONFIG 事件流独立实测仅约 92 帧/s(去重后),不随周期放大", y=s.B - 8) + s.note(">2kHz 处 STATUS 与 CONFIG 一同塌到 ~150/s", y=s.B - 22, color="#b45309") + s.axes("实测循环率 (Hz,对数)", "帧/s(对数)") + return s.finish("CAN 流量分层") + + +def chart_skip(rows_no, rows_wc): + """skip 有真 0 档 → 对数轴画不了。做法:>0 的点画在对数区,真 0/近 0 档 + 落到轴下方的"≈0"标记轨道,两种点含义不同、不混在同一条线上。""" + s = Svg(560, 320) + pos = [r for r in rows_no + rows_wc if (r["skip"] or 0) > 0.05] + zero = [r for r in rows_no + rows_wc if (r["skip"] or 0) <= 0.05] + if not pos: + s.note("本轮无 skip>0 的档位", y=(s.T + s.B) / 2, anchor="middle") + return s.finish("主站错拍率", "(无数据)") + lo = min(r["hz"] for r in pos) * 0.75 + hi = max(r["hz"] for r in pos) * 1.35 + vlo, vhi = max(min(r["skip"] for r in pos), 0.05), max(r["skip"] for r in pos) * 3 + s.T -= 6 + s.scale(True, True, (lo, hi), (vlo, vhi)) + s.grid(_log_ticks(lo, hi), _log_ticks(vlo, vhi)) + s.line([(r["hz"], r["skip"]) for r in rows_wc if (r["skip"] or 0) > 0.05], + C_RED, marker="circle") + s.line([(r["hz"], r["skip"]) for r in rows_no if (r["skip"] or 0) > 0.05], + C_BLUE, marker="square") + for r in zero: # ≈0 轨道:贴在轴下方,明确不属于对数刻度 + y = s.B + 4 + mk = ("circle" if r in rows_wc else "square") + col = C_RED if r in rows_wc else C_BLUE + cx = s.X(r["hz"]) + s.b.append(f'') + ent = [(C_RED, "带 CAN", None), (C_BLUE, "无 CAN", None)] + if zero: # 没有真 0 档时不要挂一条对不上号的图例 + ent.append(("#9ca3af", "▽ = skip ≤0.05 次/s(真 0 档,不在对数刻度上)", None)) + s.legend(ent, x=s.L + 12, y=s.T + 12) + s.note("skip=错过的网格拍。0.0625ms 设定档 15720 次/s:实测率被钉在 8.2kHz," + "错拍数随设定率线性涨", y=s.B - 10) + s.axes("实测循环率 (Hz,对数)", "主站错拍 skip_total (次/s,对数)") + return s.finish("主站错拍率") + + +# ───────────────────────────────────────────────────────── 页面组装 ───────────────────────────────────────────────────────── +PAGE = """ + + +EtherCAT 周期速度 × 故障率 × CPU 占用 调研 +
+{body} +
+""" + + +def tbl(rows, cols, cap): + h = [f'

{html.escape(cap)}

' + + "".join(f"" for t, _ in cols) + ""] + for r in rows: + cls = "" + if r.get("wkc_ok") is False: + cls = ' class="dead"' + elif r["period_ms"] == 0.5: + cls = ' class="best"' + h.append(f"" + "".join(f"" for _, f in cols) + "") + h.append("
{html.escape(t)}
{f(r)}
") + return "".join(h) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--nocan", default=os.path.join(ROOT, "lab", "ecat_rate_sweep_nocan.csv")) + ap.add_argument("--withcan", default=os.path.join(ROOT, "lab", "ecat_rate_sweep_withcan.csv")) + ap.add_argument("--calib", default=os.path.join(ROOT, "lab", "ecat_calib.json")) + ap.add_argument("--out", default=os.path.join(ROOT, "ECAT_RATE_TRADEOFF.html")) + a = ap.parse_args() + + no, wc = read_csv(a.nocan), read_csv(a.withcan) + if not wc: + raise SystemExit(f"缺带 CAN 数据:{a.withcan}") + cal = json.load(open(a.calib, encoding="utf-8")) if os.path.isfile(a.calib) else {} + no_ok = [r for r in no if r["wkc_ok"] and r["load"] is not None] + wc_ok = [r for r in wc if r["wkc_ok"] and r["load"] is not None] + excl = [r for r in no if not r["wkc_ok"] or r["load"] is None] + + # 电机刷新率:带 CAN 那轮的 8s/档定点实测。(周期文本, 实测Hz, 刷新率帧/s) + # 周期直接带文本 —— 由 hz 反算会浮点漂移(1000/1999 → 显示成 0.50025ms)。 + motor = [("1", 1000.0, 4000.2), ("0.5", 1999.0, 4000.2), + ("0.25", 3989.0, 87.9), ("0.125", 7762.0, 147.5)] + # 跨轮配对必须用**设定周期**做键:两轮的实测 hz 会差零点几(1999.3 vs 1999.6), + # 拿四舍五入的 hz 当键会把 0.5ms 档静默丢掉,KPI 均值就跟着算错。 + wc_by_period = {round(r["period_ms"], 4): r for r in wc_ok} + deltas, unm, sat = [], [], [] + for r in no_ok: + w = wc_by_period.get(round(r["period_ms"], 4)) + if w: + # 任一侧 load 已饱和(≥95%)时 Δ 只反映夹紧,不代表 CAN 负担 + if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95: + sat.append(r["period_ms"]) + else: + deltas.append(w["load"] - r["load"]) + else: + unm.append(r["period_ms"]) + if unm: + print("!! 无 CAN 档位在带 CAN 轮中找不到配对,已从 Δ 中排除:", unm) + if sat: + print(" 饱和档(Δ 不计入):", sat) + + b = [f'

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

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

结论

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

一、曲线

') + b.append('
') + b.append(f'
① 循环率:设定 vs 实测(主站侧)
' + f'{chart_achieve(wc_ok, no_ok)}
') + b.append(f'
② 从站每拍负担:带 CAN vs 无 CAN
' + f'{chart_load(no_ok, wc_ok)}
') + b.append(f'
③ CAN 流量分层:STATUS 刷新与 CONFIG 事件流
' + f'{chart_traffic(wc_ok)}
') + b.append(f'
④ 主站错拍率
' + f'{chart_skip(no_ok, wc_ok)}
') + b.append('
') + + if excl: + b.append('
已从图中排除的档位:' + ";".join( + f"{r['period_ms']}ms({'wkc 不达预期,从站停报' if not r['wkc_ok'] else '无有效负载读数'}" + f",load_perm 为残值)" for r in excl) + '。该档已由断电重启后的补测覆盖:' + '无 CAN 的 0.25 / 0.125ms 两点均已满速测得(见无 CAN 表)。空图比假图诚实。
') + + b.append('

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

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

二、数据全表

') + fmt = lambda v, p=1, suf="": ("n/a" if v is None else f"{v:,.{p}f}{suf}") + base = [("周期", lambda r: f"{r['period_ms']:g}ms"), + ("实测Hz", lambda r: fmt(r["hz_meas"], 0)), + ("达成率", lambda r: fmt(r["achieve"], 1, "%")), + ("从站load", lambda r: fmt(r["load"], 1, "%")), + ("CAN接收/s", lambda r: fmt(r["lambda_"], 0)), + ("skip/s", lambda r: fmt(r["skip"], 2)), + ("wkc", lambda r: f'{html.escape(str(r["wkc"]))}/{html.escape(str(r["exp_wkc"]))}')] + cols_wc = base + [("在线", lambda r: "未记录*")] + cols_no = base + [("在线", lambda r: fmt(r["online_frac"], 2))] + b.append(tbl(wc, cols_wc, "带 CAN(主结论轮)")) + b.append(tbl(no, cols_no, "拔掉全部 CAN 设备(补测轮)")) + b.append('

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

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

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

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

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

') + + b.append('

三、机理与上限推算

') + b.append('

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

' + '

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

' + '

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

' + '

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

') + + b.append('

四、判据口径与踩过的坑

') + b.append('

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

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

五、可信度边界

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

这份数据不能证明什么

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

六、下一步

') + b.append('

按优先级

    ' + '
  • 查明 drop 口径(当前最高优先,唯一未决的承重项)。' + '必须用一对独立可比的计数,不能再拿同速量相除:从 WiFi 侧读固件 u32 ' + 'rx_drop_count(app_spi_cmd_can_feedback.cpp:99)' + '对速率,同时用 CAN 监控页统计实收 CONFIG 帧数作分母。' + '只有「独立到达计数 − 独立交付计数」才是丢帧数。
  • ' + '
  • 台子现状:断电重启后重新 open+start 成功(OP、wkc=3),' + '无 CAN 的 0.25→0.0625ms 五档补测完成,周期已自动恢复 1ms。' + 'CAN 设备当前处于拔除状态,电机数据相关指标要插回 CAN 才能再测。
  • ' + '
  • 插回 CAN 后补测 0.35 / 0.4 / 0.5ms 三档的电机数据刷新率,' + '把带 CAN 工况的可用上限定位出来。load_perm 已被证明在快档饱和,' + '不能再用它定位拐点;扫描脚本的 ' + 'motors_online/online_frac/' + 'motor_refresh_per_s 三列才是可用性判据。
  • ' + '
  • 给 fillRxWindow 加直接计时,把 121µs 硬耗时里的主/从占比' + '拆开(属固件改动,需批准)。
  • ' + '
  • 0.5ms 档实跑电机页完整命令序列 —— 本调研只测了计数器,没测界面实际可用性。
  • ' + '
') + + live = "" + try: + h = post("http://127.0.0.1:8090", "/api/native/health", timeout=1.5) + st = post("http://127.0.0.1:8090", "/api/motor/state", timeout=1.5) + live = (f'native 实时:op={h.get("op")} loop={h.get("loop")} wkc={h.get("wkc")} ' + f'period={st.get("period_ms")}ms') + except Exception: + live = "native 当前不可达(生成本报告时未连上 8090)" + b.append(f'
本报告由 scripts/build_ecat_report_html.py 从 ' + f'lab/*.csv 与 lab/ecat_calib.json 现场渲染,图中每个数值均' + f'从原始数据计算,未手抄。{html.escape(live)}。' + f'Markdown 版(含同源结论):ECAT_RATE_TRADEOFF.md。
') + + out = a.out + with open(out, "w", encoding="utf-8") as f: + f.write(PAGE.format(body="\n".join(b))) + print("HTML 报告 ->", out) + print(f" 参与绘图:带CAN {len(wc_ok)} 档、无CAN {len(no_ok)} 档;排除 {len(excl)} 档") + if deltas: + print(f" CAN 负担 Δ:{'、'.join(f'{d:.1f}pp' for d in deltas)}") + + +if __name__ == "__main__": + main() diff --git a/scripts/calibrate_ecat_metrics.py b/scripts/calibrate_ecat_metrics.py new file mode 100644 index 0000000..3a11289 --- /dev/null +++ b/scripts/calibrate_ecat_metrics.py @@ -0,0 +1,418 @@ +#!/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()) diff --git a/scripts/can_bridge_reconcile.py b/scripts/can_bridge_reconcile.py new file mode 100644 index 0000000..aba4aff --- /dev/null +++ b/scripts/can_bridge_reconcile.py @@ -0,0 +1,232 @@ +#!/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(从站 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""从站 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" 从站 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()) diff --git a/scripts/ecat_sweep_common.py b/scripts/ecat_sweep_common.py new file mode 100644 index 0000000..960b921 --- /dev/null +++ b/scripts/ecat_sweep_common.py @@ -0,0 +1,54 @@ +#!/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] diff --git a/scripts/plot_ecat_nocan_vs_can.py b/scripts/plot_ecat_nocan_vs_can.py new file mode 100644 index 0000000..00b70fd --- /dev/null +++ b/scripts/plot_ecat_nocan_vs_can.py @@ -0,0 +1,112 @@ +#!/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() diff --git a/scripts/plot_ecat_sweep.py b/scripts/plot_ecat_sweep.py new file mode 100644 index 0000000..2cb5e96 --- /dev/null +++ b/scripts/plot_ecat_sweep.py @@ -0,0 +1,188 @@ +#!/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()) diff --git a/scripts/repro_cmdload.py b/scripts/repro_cmdload.py index a1dbcf1..dc96a64 100644 --- a/scripts/repro_cmdload.py +++ b/scripts/repro_cmdload.py @@ -62,6 +62,14 @@ 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] @@ -86,12 +94,14 @@ def sender(): except Exception: time.sleep(0.05) continue - if len(cfgs) >= 2: - # 判别实验:两电机反相正弦 + 不同速度限,给帧内容打节点指纹。 - # node2: ang=1000+200sin, vel=200 node3: ang=1000-200sin, vel=150 + 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, c in sorted(cfgs.items()): + 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, @@ -162,11 +172,42 @@ def monitor(): 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(" 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 @@ -198,6 +239,12 @@ def main(): 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__": diff --git a/scripts/sweep_ecat_rate.py b/scripts/sweep_ecat_rate.py new file mode 100644 index 0000000..f0f5904 --- /dev/null +++ b/scripts/sweep_ecat_rate.py @@ -0,0 +1,335 @@ +#!/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())