diff --git a/README.md b/README.md index ab3e8bb..ce259bb 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,29 @@ tabs.html 标签外壳 = 工具入口(GET / 会 302 到这里) pages/ 主功能页,各自引用 ../css 与 ../js lab/ 自包含实验页,零外部依赖,删文件即可移除 css/ js/ 共享样式与 ES module -scripts/ 构建辅助脚本 +scripts/ 正式自测/测量脚本(清单见下;一次性探针不入库) +docs/compose/spec/ compose 功能规格 +docs/reports/ 历史调研报告(原根目录 ECAT_*/REPORT_*) esp32_server.py 本地页面服务 + 桌面窗口外壳 esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口 ``` +## scripts/ 正式清单(仅保留这些) + +| 脚本 | 用途 | +|------|------| +| `build_mem.bat` | Git Bash 下构建 ESP32_COMM | +| `test_motor_transport.mjs` | 双链路传输契约 | +| `test_motor_refresh.mjs` | 刷新速度每链路一份(纯逻辑) | +| `test_ecat_autostart.mjs` | 自动接入链条(stub) | +| `test_app_lease.py` / `test_app_lease_e2e.mjs` | 关窗停总线 | +| `test_esf_pack.py` | PDO 快发 ESF1 打包 | +| `check_esf_latency.py` / `check_latency_breakdown.py` / `check_e2e_click_motion.py` | 快发与点击延迟测量 | +| `browser_check_{refresh,tick,floor,period}.mjs` | 浏览器侧 DOM/节拍实测 | +| `probe_ecat_slot_atomicity.py` / `probe_ecat_vs_wifi.py` | 可复用采样器(gitignore 白名单) | + +现场 `_tmp_*` / `skip_*` / `anal_*` 等一次性脚本**不入库**;历史报告在 `docs/reports/`。 + ## 页面 `tabs.html` 是单窗口多标签外壳,用**多 iframe 常驻保活**承载下列功能页:每个标签一个懒创建 iframe,切换只改可见性、不销毁文档(否则页内 WebSocket/轮询会随导航全灭)。激活标签记忆在 @@ -51,7 +69,12 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口 ## 目录约定 - `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。 -- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、`toast`、`utils`、`heap-low-history`。 + **所有权**:`.btn` 共享 chrome 在 `layout.css`;`components.css` 管跨页 `.toast` + (居中单条);`motor.css` 的 `.btn` / `.toast-container` 是电机页主题覆盖与 + 堆叠 Toast,**不要**再往 layout/components 抄一份电机专用规则。 +- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、 + `toast`(居中)、`toast-stack`(容器堆叠,电机页)、`ecat-bus`(ECAT 接入策略单点)、 + `device-link`、`utils`、`heap-low-history`。 - `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module,跨域引用只走 `js/common/`。 - 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。 diff --git a/css/components.css b/css/components.css index 7aed718..5f86a1b 100644 --- a/css/components.css +++ b/css/components.css @@ -613,7 +613,9 @@ } /* ================= Toast ================= */ +/* 跨页居中 Toast(#toast / #toastIco / #toastMsg)。电机页堆叠 Toast 见 motor.css .toast-container */ .toast { + position: fixed; top: 45%; left: 50%; transform: translateX(-50%) translateY(30px); z-index: 9999; diff --git a/css/layout.css b/css/layout.css index 70fffa2..23ae82d 100644 --- a/css/layout.css +++ b/css/layout.css @@ -196,7 +196,9 @@ select option { background: var(--bg-panel); color: var(--text); } .input-group .input { flex: 1; } /* ================= 按钮 ================= */ +/* 共享 .btn chrome(OTA/CAN 等页)。电机页主题按钮在 motor.css,勿互相复制。 */ .btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 9px 17px; border: none; border-radius: var(--radius-sm); font-family: var(--font-display); diff --git a/css/motor.css b/css/motor.css index fd0febb..3751a71 100644 --- a/css/motor.css +++ b/css/motor.css @@ -109,7 +109,9 @@ label { display: block; } border-color: var(--border-strong); } .toolbar-right a.btn:hover { color: var(--primary); border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent); } +/* 电机页主题 .btn —— 与 layout.css 共享 chrome 刻意分离(页面级覆盖),勿抄进 components */ .btn { + display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: var(--radius-sm); font-size: 0.85rem; font-weight: 550; letter-spacing: 0.02em; @@ -654,7 +656,9 @@ label { display: block; } .sb-last-send { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; color: var(--fg-dim); } /* ═══════════════════════ TOAST & MODAL ═══════════════════════ */ +/* 电机页堆叠 Toast 容器 —— 与 components.css 居中 Toast 是两套 UI,勿合并。 */ .toast-container { + position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: var(--sp-2); z-index: 1000; pointer-events: none; diff --git a/ECAT_BEAT_TAIL_REPORT.html b/docs/reports/ECAT_BEAT_TAIL_REPORT.html similarity index 100% rename from ECAT_BEAT_TAIL_REPORT.html rename to docs/reports/ECAT_BEAT_TAIL_REPORT.html diff --git a/ECAT_RATE_TRADEOFF.html b/docs/reports/ECAT_RATE_TRADEOFF.html similarity index 100% rename from ECAT_RATE_TRADEOFF.html rename to docs/reports/ECAT_RATE_TRADEOFF.html diff --git a/ECAT_RATE_TRADEOFF.md b/docs/reports/ECAT_RATE_TRADEOFF.md similarity index 100% rename from ECAT_RATE_TRADEOFF.md rename to docs/reports/ECAT_RATE_TRADEOFF.md diff --git a/GD32_OTA_FLOW_COMPARE.html b/docs/reports/GD32_OTA_FLOW_COMPARE.html similarity index 100% rename from GD32_OTA_FLOW_COMPARE.html rename to docs/reports/GD32_OTA_FLOW_COMPARE.html diff --git a/REPORT_ecat_skip_opt_20260917.md b/docs/reports/REPORT_ecat_skip_opt_20260917.md similarity index 100% rename from REPORT_ecat_skip_opt_20260917.md rename to docs/reports/REPORT_ecat_skip_opt_20260917.md diff --git a/lab/ecat_integral_report.html b/docs/reports/ecat_integral_report.html similarity index 100% rename from lab/ecat_integral_report.html rename to docs/reports/ecat_integral_report.html diff --git a/lab/_last_gd32_sha.txt b/lab/_last_gd32_sha.txt deleted file mode 100644 index 4b342f8..0000000 --- a/lab/_last_gd32_sha.txt +++ /dev/null @@ -1 +0,0 @@ -c6827f84e9b8fa07288d0fc2081d0efc48a6ef2c5b8124b85a406f3a7a02042a \ No newline at end of file diff --git a/lab/ecat_calib.json b/lab/ecat_calib.json deleted file mode 100644 index 8135ea0..0000000 --- a/lab/ecat_calib.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "url": "http://127.0.0.1:8090", - "started_at": "2026-09-08 02:45:39", - "seconds": 20.0, - "sample_hz": 10.0, - "logical_cpus": 28, - "native_pid": 29284, - "native_pid_error": null, - "firmware": { - "ledger": "201832b5a8e258d6eec8d7f6620bab2c77d519e7321b25b80f7690ab2e3364ce", - "ledger_path": "G:\\Robot_v2\\DCU\\tools\\ESP32\\lab\\_last_gd32_sha.txt", - "match": null, - "candidates": [ - { - "path": "G:\\Robot_v2\\DCU\\DCU_APP\\out\\GD32E508_DCU_APP\\GD32E508VE\\Release\\GD32E508_DCU_APP.hex", - "sha256": "63d6ab9be96512cc", - "mtime": 1788805893 - }, - { - "path": "G:\\Robot_v2\\DCU\\DCU_APP\\out\\GD32E508_DCU_APP\\GD32E508VE\\Debug\\GD32E508_DCU_APP.hex", - "sha256": "e6a6e75fc5f746fc", - "mtime": 1788605679 - } - ], - "method": "hex_to_flat + sha256" - }, - "cadence": { - "n": 512, - "period_ms": 1, - "count_hist": { - "1": 2, - "2": 509, - "3": 1 - }, - "count_max": 3, - "lost_hist": { - "0": 512 - }, - "lost_max": 0, - "pending_hist": { - "0": 512 - }, - "pending_max": 0, - "backlog_hist": { - "0": 512 - }, - "backlog_max": 0, - "wkc_hist": { - "3": 512 - }, - "wkc_max": 3, - "span_ms": 511.0, - "lambda_per_s": 3998.0, - "gseq_per_s": 3998.0 - }, - "loop_start": { - "how": "已在跑", - "ok": true - }, - "period_ms_set": 1.0, - "pcore_mask": 16, - "psutil": true, - "dur_s": 20.08, - "iters": 200, - "errors": 0, - "drop_untrusted_samples": 0, - "period_ms_effective": 1.0, - "wkc": [ - 3, - 3 - ], - "wr_end": 0, - "native_pid_end": 29284, - "pid_stable": true, - "layout_seen": { - "win": 200 - }, - "rx_batch_count_hist_http": { - "2": 200 - }, - "window_coverage": { - "per_id": { - "131329": { - "n_obs": 584, - "span": 79949, - "coverage_pct": 0.73 - }, - "196865": { - "n_obs": 416, - "span": 79945, - "coverage_pct": 0.52 - } - }, - "ids": 2, - "note": "覆盖率仅说明观测密度,不可当丢帧率引用" - }, - "can_lambda_per_s": 3992.8, - "drop_per_s": 3992.8, - "drop_pct_of_lambda": 100.0, - "achieved_hz_p50": 998.2, - "achieve_pct": 99.8, - "skip_delta": 40, - "skip_per_s": 1.99, - "skip_total_cum": 449, - "rx_torn_delta": 0, - "rx_torn_capped": false, - "pdo_reentry_delta": 0, - "pdo_reentry_capped": false, - "cyc_us_total": { - "p50": 125.0, - "p90": 137.1, - "max": 2119.2 - }, - "cyc_us_late_lost_sample": { - "p50": 125.6, - "p90": 138.4, - "max": 1363.0 - }, - "gd32_load_pct_p50": 44.3, - "host_cpu_1core_p50": 100.8, - "host_cpu_total_p50": 3.6, - "per_cycle_cap_observed": 3, - "capacity_model": { - "lambda_per_s": 3992.8, - "per_cycle_cap": 3, - "drain_per_s_at_current_T": 3000.0, - "knee_ms_if_linear": 0.751, - "note": "cap 取自 cadence 环实测 count 峰值;拐点以扫描实测为准,此值仅供事后对照" - }, - "power": { - "drop_rx_queue": true, - "skip_total": true, - "achieve_deficit": false, - "rx_torn": false, - "pdo_reentry": false, - "cadence_lost_or_pending": false, - "cyc_us_late_lost": false - } -} \ No newline at end of file diff --git a/lab/ecat_rate_sweep.png b/lab/ecat_rate_sweep.png deleted file mode 100644 index 6cb4d32..0000000 Binary files a/lab/ecat_rate_sweep.png and /dev/null differ diff --git a/lab/ecat_rate_sweep_nocan.png b/lab/ecat_rate_sweep_nocan.png deleted file mode 100644 index 88fec1f..0000000 Binary files a/lab/ecat_rate_sweep_nocan.png and /dev/null differ diff --git a/lab/ecat_rate_sweep_nocan_vs.png b/lab/ecat_rate_sweep_nocan_vs.png deleted file mode 100644 index d27ebf7..0000000 Binary files a/lab/ecat_rate_sweep_nocan_vs.png and /dev/null differ diff --git a/pages/can.html b/pages/can.html index 9adf7a7..ef6dbc5 100644 --- a/pages/can.html +++ b/pages/can.html @@ -3,12 +3,12 @@ -ESP32 CAN 通信控制台 - +ESP32 CAN 通信控制å�?/title> +<!-- ç©?favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404ï¼?--> <link rel="icon" href="data:,"> <link rel="stylesheet" href="../css/base.css?v=857f83a5"> -<link rel="stylesheet" href="../css/layout.css?v=acd17001"> -<link rel="stylesheet" href="../css/components.css?v=quiet20260919a"> +<link rel="stylesheet" href="../css/layout.css?v=20260924a"> +<link rel="stylesheet" href="../css/components.css?v=20260924a"> <link rel="stylesheet" href="../css/can.css?v=c06f5688"> <link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a"> </head> @@ -25,25 +25,25 @@ </svg> </span> <div class="brand-text"> - <strong>ESP32 CAN 通信控制台</strong> + <strong>ESP32 CAN 通信控制å�?/strong> <span class="brand-sub">DCU · Robot_v2 · CAN FD</span> </div> </div> <div class="header-status"> - <span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span> + <span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连æŽ?/span></span> <span class="pill" id="canIpPill">IP --</span> <span class="pill" id="canRssiPill">RSSI --</span> <span class="pill" id="canUptimePill">运行 --</span> <span class="pill" id="canVersionPill">v--</span> <span class="pill" id="canChannelPill">CH0</span> - <span class="pill" id="dbcSummaryPill">未导入 DBC</span> + <span class="pill" id="dbcSummaryPill">未导å…?DBC</span> <span class="pill" id="messageSummaryPill">0 报文</span> <span class="pill" id="fbModePill">模式: --</span> <span class="pill" id="fbPollStatus">轮询: --</span> </div> <div class="header-sub can-header-actions"> - <a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a> - <a class="pill can-link-pill" href="can_monitor.html" title="独立全屏监控页">监控全屏</a> + <a class="pill can-link-pill" href="index.html">返回 OTA 控制å�?/a> + <a class="pill can-link-pill" href="can_monitor.html" title="独立全屏监控é¡?>监控全屏</a> </div> </header> @@ -56,7 +56,7 @@ <div class="card-head can-msg-list-head"> <span class="ico">📚</span> <span>报文列表</span> - <!-- 已导入:合计 + 文件 chip(可逐个移除) --> + <!-- 已导入:合计 + 文件 chip(可逐个移除ï¼?--> <div class="dbc-summary-strip dbc-summary-inline" id="dbcFileInfo" hidden> <span class="dbc-summary-meta"> <span><b id="dbcFileCount">0</b>文件</span> @@ -72,7 +72,7 @@ <button class="btn btn-primary btn-sm" id="importDbcBtn" type="button">导入 DBC</button> </div> </div> - <!-- 通道 / 搜索 / 勾选 / 节点 / 批量 同一工具栏 --> + <!-- 通道 / 搜索 / 勾é€?/ 节点 / 批量 同一工具æ ?--> <div class="can-msg-toolbar" id="canMsgToolbar"> <label class="can-msg-channel" for="defaultChannel"> <span class="can-msg-channel-label">通道</span> @@ -84,43 +84,43 @@ </label> <div class="can-msg-search-wrap"> <input class="input can-msg-search" type="text" id="dbcSearch" - placeholder="搜索报文名 / ID / 节点 / 信号" autocomplete="off"> + placeholder="搜索报文å�?/ ID / 节点 / 信号" autocomplete="off"> </div> <div class="can-batch-actions"> - <span class="pill" id="selectedCountPill">已勾选 0</span> + <span class="pill" id="selectedCountPill">已勾é€?0</span> <div class="can-node-filter" id="nodeFilterWrap"> <button class="btn btn-outline btn-sm can-node-filter-btn" id="nodeFilterBtn" type="button" aria-haspopup="true" aria-expanded="false"> <span class="can-node-filter-label" id="nodeFilterLabel">发送节点:全部</span> - <span class="can-node-filter-caret">▾</span> + <span class="can-node-filter-caret">â–?/span> </button> <div class="can-node-filter-panel" id="nodeFilterPanel" hidden> <div class="can-node-filter-head"> - <input class="input can-node-filter-search" id="nodeFilterSearch" type="text" placeholder="筛选节点名…"> + <input class="input can-node-filter-search" id="nodeFilterSearch" type="text" placeholder="筛选节点名â€?> <div class="can-node-filter-actions"> - <button class="btn btn-ghost btn-sm" id="nodeFilterAllBtn" type="button">全选</button> + <button class="btn btn-ghost btn-sm" id="nodeFilterAllBtn" type="button">全é€?/button> <button class="btn btn-ghost btn-sm" id="nodeFilterNoneBtn" type="button">清空</button> </div> </div> <div class="can-node-filter-list" id="nodeFilterList"> - <div class="status-empty">先导入 DBC。</div> + <div class="status-empty">先导å…?DBCã€?/div> </div> </div> </div> - <label class="checkbox-inline can-batch-all-label" title="全选/取消当前过滤的所有报文"> + <label class="checkbox-inline can-batch-all-label" title="全é€?取消当前过滤的所有报æ–?> <input type="checkbox" id="selectAllBox"> - <span>全选当前过滤</span> + <span>全选当前过æ»?/span> </label> <button class="btn btn-outline btn-sm" id="expandAllBtn" type="button">展开全部</button> <button class="btn btn-outline btn-sm" id="collapseAllBtn" type="button">收起全部</button> - <button class="btn btn-primary btn-sm" id="sendCheckedBtn" type="button">批量发送勾选</button> + <button class="btn btn-primary btn-sm" id="sendCheckedBtn" type="button">批量发送勾é€?/button> </div> </div> <div class="card-body can-table-card-body"> <div class="message-list" id="messageList"> <div class="dbc-empty-drop" id="dbcEmptyDrop"> <div class="upload-ico">📥</div> - <h4>导入 DBC 开始编辑报文</h4> - <p>点击此处或拖拽 <code>.dbc</code> 文件到列表区域</p> + <h4>导入 DBC 开始编辑报æ–?/h4> + <p>点击此处或拖æ‹?<code>.dbc</code> 文件到列表区åŸ?/p> </div> </div> <div class="helper-note" id="dbcHint" hidden></div> @@ -128,10 +128,10 @@ </section> <section class="card can-history-card"> - <div class="card-head"><span class="ico">🕘</span> 最近发送</div> + <div class="card-head"><span class="ico">🕘</span> 最近发é€?/div> <div class="card-body"> <div class="history-list history-list-compact" id="historyList"> - <div class="status-empty">尚未发送任何 CAN 帧。</div> + <div class="status-empty">尚未发送任ä½?CAN 帧ã€?/div> </div> </div> </section> @@ -139,12 +139,12 @@ <aside class="can-sidepanel"> <section class="card can-preview-card"> - <div class="card-head"><span class="ico">🛰️</span> 帧预览</div> + <div class="card-head"><span class="ico">🛰ï¸?/span> 帧预è§?/div> <div class="card-body"> <div class="can-frame-panel can-frame-panel-compact"> <div class="can-frame-hex" id="generatedHex">--</div> <div class="can-byte-strip" id="generatedBytes"></div> - <div class="helper-note" id="encodeHint">选择报文后,信号值会实时编码为 CAN 数据。</div> + <div class="helper-note" id="encodeHint">选择报文后,信号值会实时编码ä¸?CAN 数据ã€?/div> <div class="btn-row can-frame-actions"> <button class="btn btn-outline btn-sm" id="loadPreviewToRawBtn" type="button">载入到原始发送区</button> <button class="btn btn-primary btn-sm" id="sendSelectedBtn" type="button">发送当前预览帧</button> @@ -158,7 +158,7 @@ </section> <section class="card can-raw-card"> - <div class="card-head"><span class="ico">✍️</span> 原始帧发送</div> + <div class="card-head"><span class="ico">✍️</span> 原始帧发é€?/div> <div class="card-body"> <div class="row-2 can-raw-grid"> <div class="form-row" style="margin:0;"> @@ -183,7 +183,7 @@ <input class="input" type="text" id="manualData" value="0102030405060708" placeholder="01020304..."> </div> <div class="helper-note" id="manualHint"> - 原始帧模式适合绕过 DBC 直接发 HEX,或发送当前预览帧后二次微调。 + 原始帧模式适合绕过 DBC 直接å�?HEX,或发送当前预览帧后二次微调ã€? </div> <div class="btn-row"> <button class="btn btn-primary" id="sendManualBtn" type="button">发送原始帧</button> @@ -214,22 +214,22 @@ <div class="input-group" style="margin-bottom:12px;flex-wrap:wrap;"> <button class="btn btn-primary btn-sm" id="btnModeAllPass" type="button">全收模式</button> <button class="btn btn-outline btn-sm" id="btnModeFilter" type="button">过滤模式</button> - <button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</button> + <button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发åˆ?CAN1 ä¸?CAN2">CAN0â†?/2 桥接</button> </div> <div class="helper-note" id="monHelper" style="margin-bottom:14px;"> - 地址与顶栏一致;1Hz <code>/can/feedback</code>。悬停抽屉保持展开。 + 地址与顶栏一致;1Hz <code>/can/feedback</code>。悬停抽屉保持展开ã€? </div> - <div class="status-section-title">三路 CAN 通道(1Hz)</div> + <div class="status-section-title">三路 CAN 通道ï¼?Hzï¼?/div> <div class="feedback-grid feedback-grid-drawer"> <div class="feedback-channel collapsed" id="fbCh0"> <div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情"> <span class="ch-dot ch0"></span> CAN0 - <span class="feedback-expand-caret">▾</span> + <span class="feedback-expand-caret">â–?/span> </div> <div class="feedback-primary"> - <div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb0State">--</span></div> - <div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb0Rx">--</span></div> + <div class="feedback-row"><span class="fb-label">状æ€?/span><span class="fb-val" id="fb0State">--</span></div> + <div class="feedback-row"><span class="fb-label">接收æ•?/span><span class="fb-val" id="fb0Rx">--</span></div> <div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb0Tx">--</span></div> <div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb0Drop">--</span></div> <div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb0Err">--</span></div> @@ -237,7 +237,7 @@ <div class="feedback-detail"> <div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb0Tec">--</span></div> <div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb0Rec">--</span></div> - <div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb0Bus">--</span></div> + <div class="feedback-row"><span class="fb-label">总线状æ€?/span><span class="fb-val" id="fb0Bus">--</span></div> <div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb0LastId">--</span></div> <div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb0Dlc">--</span></div> <div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb0Flags">--</span></div> @@ -250,11 +250,11 @@ <div class="feedback-channel collapsed" id="fbCh1"> <div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情"> <span class="ch-dot ch1"></span> CAN1 - <span class="feedback-expand-caret">▾</span> + <span class="feedback-expand-caret">â–?/span> </div> <div class="feedback-primary"> - <div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb1State">--</span></div> - <div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb1Rx">--</span></div> + <div class="feedback-row"><span class="fb-label">状æ€?/span><span class="fb-val" id="fb1State">--</span></div> + <div class="feedback-row"><span class="fb-label">接收æ•?/span><span class="fb-val" id="fb1Rx">--</span></div> <div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb1Tx">--</span></div> <div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb1Drop">--</span></div> <div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb1Err">--</span></div> @@ -262,7 +262,7 @@ <div class="feedback-detail"> <div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb1Tec">--</span></div> <div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb1Rec">--</span></div> - <div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb1Bus">--</span></div> + <div class="feedback-row"><span class="fb-label">总线状æ€?/span><span class="fb-val" id="fb1Bus">--</span></div> <div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb1LastId">--</span></div> <div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb1Dlc">--</span></div> <div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb1Flags">--</span></div> @@ -275,11 +275,11 @@ <div class="feedback-channel collapsed" id="fbCh2"> <div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情"> <span class="ch-dot ch2"></span> CAN2 - <span class="feedback-expand-caret">▾</span> + <span class="feedback-expand-caret">â–?/span> </div> <div class="feedback-primary"> - <div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb2State">--</span></div> - <div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb2Rx">--</span></div> + <div class="feedback-row"><span class="fb-label">状æ€?/span><span class="fb-val" id="fb2State">--</span></div> + <div class="feedback-row"><span class="fb-label">接收æ•?/span><span class="fb-val" id="fb2Rx">--</span></div> <div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb2Tx">--</span></div> <div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb2Drop">--</span></div> <div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb2Err">--</span></div> @@ -287,7 +287,7 @@ <div class="feedback-detail"> <div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb2Tec">--</span></div> <div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb2Rec">--</span></div> - <div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb2Bus">--</span></div> + <div class="feedback-row"><span class="fb-label">总线状æ€?/span><span class="fb-val" id="fb2Bus">--</span></div> <div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb2LastId">--</span></div> <div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb2Dlc">--</span></div> <div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb2Flags">--</span></div> @@ -299,7 +299,7 @@ </div> <div class="status-section-title" style="margin-top:16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;"> - <span>每 ID 快照表</span> + <span>æ¯?ID 快照è¡?/span> <select class="input" id="idsnapChannel" style="width:100px;text-transform:none;"> <option value="0">CAN0</option> <option value="1">CAN1</option> @@ -325,7 +325,7 @@ </aside> <div class="toast" id="toast"> - <span class="toast-ico" id="toastIco">✓</span> + <span class="toast-ico" id="toastIco">âœ?/span> <span id="toastMsg">操作成功</span> </div> diff --git a/pages/index.html b/pages/index.html index 469907b..17eec9b 100644 --- a/pages/index.html +++ b/pages/index.html @@ -1,27 +1,27 @@ <!-- index.html - ESP32 OTA 中继控制台主页面(DCU Mission Control · 美化版) - 支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。 + 支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级ã€? 配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA) - 子系统配色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫 + 子系统配色:ESP32=青绿 · GD32=琥珀 · BLE=è“?· CAN=ç»?· 内存/调试=ç´? --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> -<title>ESP32 OTA 中继控制台 - +ESP32 OTA 中继控制å�?/title> +<!-- ç©?favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404ï¼?--> <link rel="icon" href="data:,"> <link rel="stylesheet" href="../css/base.css?v=857f83a5"> -<link rel="stylesheet" href="../css/layout.css?v=jumpconsole20260916b"> -<link rel="stylesheet" href="../css/components.css?v=quiet20260919a"> +<link rel="stylesheet" href="../css/layout.css?v=20260924a"> +<link rel="stylesheet" href="../css/components.css?v=20260924a"> <link rel="stylesheet" href="../css/can.css?v=c06f5688"> <link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a"> </head> <body> <div class="container"> - <!-- ====== HEADER: 命令栏 ====== --> + <!-- ====== HEADER: 命令æ ?====== --> <header class="header-bar"> <div class="header-brand"> <span class="brand-logo" aria-hidden="true"> @@ -32,19 +32,19 @@ </svg> </span> <div class="brand-text"> - <strong>ESP32 OTA 中继控制台</strong> + <strong>ESP32 OTA 中继控制å�?/strong> <span class="brand-sub">DCU · Robot_v2 · Relay</span> </div> </div> <div class="header-status"> - <span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连接</span></span> + <span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连æŽ?/span></span> <span class="pill" id="pillVer">v--</span> <span class="pill" id="pillIP">--</span> <span class="pill" id="pillRSSI">--</span> <span class="pill" id="pillUptime">运行 --</span> </div> <div class="header-sub"> - <span class="pill">设备名: <b class="pill-val" id="deviceName">-</b></span> + <span class="pill">设备å�? <b class="pill-val" id="deviceName">-</b></span> <span class="pill">设备ID: <b class="pill-val" id="deviceId">-</b></span> </div> </header> @@ -69,7 +69,7 @@ <span class="target-tab-txt">BLE 蓝牙</span> </button> <button id="modeEcatBtn" class="btn target-tab-btn btn-outline" data-action="set-console-mode" data-arg="ecat" type="button"> - <span class="target-tab-ico" aria-hidden="true">⚡</span> + <span class="target-tab-ico" aria-hidden="true">âš?/span> <span class="target-tab-txt">EtherCAT</span> </button> </div> @@ -82,7 +82,7 @@ </div> </div> </div> - <!-- BLE 模式:launch 风格连接按钮(checkbox 驱动:勾选=已连接) --> + <!-- BLE 模式:launch 风格连接按钮(checkbox 驱动:勾é€?已连接) --> <div id="bleModeCards" style="display:none;"> <div class="ble-launch-wrap"> <label class="ble-launch-btn" for="bleConnectToggle" data-ble-state="idle"> @@ -120,22 +120,22 @@ </svg> </div> <div class="launch"> - <div style="--i:1">蓝</div> - <div style="--i:2">牙</div> - <div style="--i:3">连</div> - <div style="--i:4">接</div> + <div style="--i:1">è“?/div> + <div style="--i:2">ç‰?/div> + <div style="--i:3">è¿?/div> + <div style="--i:4">æŽ?/div> </div> <div class="start"> <div class="text1">等待连接</div> <div class="text2"> - <span style="--n:0">已</span> - <span style="--n:1">连</span> - <span style="--n:2">接</span> + <span style="--n:0">å·?/span> + <span style="--n:1">è¿?/span> + <span style="--n:2">æŽ?/span> <span style="--n:3">.</span> <span style="--n:4">.</span> <span style="--n:5">.</span> </div> - <div class="text3">连接中...</div> + <div class="text3">连接ä¸?..</div> </div> </div> </div> @@ -145,29 +145,29 @@ <div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div> </div> </div> - <!-- EtherCAT 模式:APP ↔ BOOT 角色徽章 + 一键切换 --> + <!-- EtherCAT 模式:APP â†?BOOT 角色徽章 + 一键切æ�?--> <div id="ecatModeCards" style="display:none;"> <div class="jump-role-panel"> <div class="jump-role-badge" id="bootEcatRoleBadge" data-role="unknown" data-action="boot-ecat-probe" role="button" tabindex="0" - title="点击重新探测对端角色(APP / BOOT)"> + title="点击重新探测对端角色(APP / BOOTï¼?> <span class="jump-role-label">当前角色 · 点击探测</span> - <strong id="bootEcatRoleTxt">未探测</strong> - <span class="jump-role-sw" id="bootEcatRoleSw">—</span> + <strong id="bootEcatRoleTxt">未探æµ?/strong> + <span class="jump-role-sw" id="bootEcatRoleSw">â€?/span> </div> <div class="jump-role-meta"> - <div><span>设备名 0x1008</span><b id="bootEcatName">—</b></div> - <div><span>软件版本 0x100A</span><b id="bootEcatSw">—</b></div> - <div><span>从站放行</span><b id="bootEcatArm">—</b></div> - <div><span>主站缓存 / 已写 flash</span><b id="bootEcatRecv">—</b></div> + <div><span>设备å�?0x1008</span><b id="bootEcatName">â€?/b></div> + <div><span>软件版本 0x100A</span><b id="bootEcatSw">â€?/b></div> + <div><span>从站放行</span><b id="bootEcatArm">â€?/b></div> + <div><span>主站缓存 / 已写 flash</span><b id="bootEcatRecv">â€?/b></div> </div> </div> <button class="btn btn-primary btn-block jump-btn-primary" id="bootEcatRebootBtn" data-action="boot-ecat-reboot" - title="按对端身份自动选方向:APP→0x2502 进 BOOT;BOOT→0x2503 跳回 APP">让应用回引导程序</button> + title="按对端身份自动选方向:APPâ†?x2502 è¿?BOOT;BOOTâ†?x2503 跳回 APP">让应用回引导程序</button> </div> - <!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 --> + <!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重å¤?--> </div> </div> @@ -177,7 +177,7 @@ <div class="card-body"> <div class="target-tab-row" id="targetTabRow"> <button id="targetGd32Tab" class="btn target-tab-btn btn-outline" data-action="select-flash-target" data-arg="gd32" type="button"> - <span class="target-tab-ico" aria-hidden="true">⚙</span> + <span class="target-tab-ico" aria-hidden="true">âš?/span> <span class="target-tab-txt">GD32</span> </button> <button id="targetEsp32Tab" class="btn target-tab-btn btn-primary" data-action="select-flash-target" data-arg="esp32" type="button"> @@ -189,7 +189,7 @@ <div id="wifiEsp32UploadPane"> <div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" data-action="pick-shared-firmware" data-arg="esp32"> <div class="upload-ico">📤</div> - <h4>点击或拖拽 ESP32 固件</h4> + <h4>点击或拖æ‹?ESP32 固件</h4> </div> <input type="file" id="fileInputEsp32" accept=".bin" style="display:none;"> <div class="file-info" id="fileInfoEsp32"> @@ -211,8 +211,8 @@ <div id="wifiGd32UploadPane" style="display:none;"> <div class="upload-zone upload-zone-sm" id="uploadZoneGd32" data-action="pick-shared-firmware" data-arg="gd32"> <div class="upload-ico">📤</div> - <h4>点击或拖拽 GD32 固件</h4> - <p>支持 .bin 和 .hex(自动转换)</p> + <h4>点击或拖æ‹?GD32 固件</h4> + <p>支持 .bin å’?.hex(自动转换)</p> </div> <input type="file" id="fileInputGd32" accept=".hex,.bin" style="display:none;"> <div class="file-info" id="fileInfoGd32"> @@ -235,7 +235,7 @@ <div class="upload-zone upload-zone-sm" id="uploadZone" style="margin-bottom:12px;" data-action="ble-pick-file"> <div class="upload-ico">📤</div> <h4>点击或拖拽文件到此处</h4> - <p>支持 .bin 和 .hex 格式</p> + <p>支持 .bin å’?.hex 格式</p> </div> <input id="firmwareFile" type="file" accept=".bin,.hex,application/octet-stream" style="display:none;"> <div class="file-info" id="fileInfo"> @@ -246,20 +246,20 @@ <div class="file-info-grid"> <div class="file-meta-item"><span>类型</span><b id="fileType">-</b></div> <div class="file-meta-item"><span>大小</span><b id="fileSize">0 B</b></div> - <div class="file-meta-item"><span>总包数</span><b id="packetCount">0</b></div> - <div class="file-meta-item"><span>已写包</span><b id="sentCount">0</b></div> + <div class="file-meta-item"><span>总包æ•?/span><b id="packetCount">0</b></div> + <div class="file-meta-item"><span>已写åŒ?/span><b id="sentCount">0</b></div> </div> <div class="file-meta-item" style="margin-top:8px;"> <span>SHA-256</span> <b id="fileSha256" style="font-size:10px;word-break:break-all;font-family:var(--font-mono);">-</b> </div> </div> - <div class="footer-note">建议使用 Chrome / Edge。每包 Payload 与触发命令由系统按目标自动设置。</div> + <div class="footer-note">建议使用 Chrome / Edge。每åŒ?Payload 与触发命令由系统按目标自动设置ã€?/div> </div> <div style="height:12px"></div> <div id="wifiEsp32ActionPane"> <button class="btn btn-primary btn-block" id="uploadEsp32Btn" data-action="start-upload" data-arg="esp32" disabled> - 上传并升级 ESP32 + 上传并升çº?ESP32 </button> </div> <div id="wifiGd32ActionPane" style="display:none;"> @@ -268,13 +268,13 @@ </button> </div> <div class="row-2" id="bleActionPane" style="display:none;"> - <button id="autoFlashBtn" class="btn btn-primary" disabled>一键刷写</button> + <button id="autoFlashBtn" class="btn btn-primary" disabled>一键刷å†?/button> <button id="abortBtn" class="btn btn-danger" disabled>中止任务</button> </div> - <!-- EtherCAT 直灌面板:只在「设备连接 = EtherCAT」时显示(applySharedModeUi 控制)。 - 固件复用同一个 #fileInputGd32 与同一条 processFile 转换/校验, - 所以这里不出现第二份 HEX 解析器;"能不能灌"只由 BOOT 里的 ecatArmed() - 裁决,前端不复制判据(见 js/ota/boot_ecat.js 头注)。 --> + <!-- EtherCAT 直灌面板:只在「设备连æŽ?= EtherCAT」时显示(applySharedModeUi 控制)ã€? + 固件复用同一ä¸?#fileInputGd32 与同一æ�?processFile 转换/校验ï¼? + 所以这里不出现第二ä»?HEX 解析器;"能不能灌"只由 BOOT 里的 ecatArmed() + 裁决,前端不复制判据(见 js/ota/boot_ecat.js 头注)ã€?--> <div id="bootEcatPane" style="display:none;"> <button class="btn btn-block" id="bootEcatRunBtn" data-action="boot-ecat-run" disabled>EtherCAT 刷写 APP</button> <button class="btn btn-danger btn-block" id="bootEcatAbortBtn" @@ -284,15 +284,15 @@ </div> </div> - <!-- 4. Flash Progress + Stage Flow(WiFi/BLE/EtherCAT 共用同一张进度卡) --> + <!-- 4. Flash Progress + Stage Flow(WiFi/BLE/EtherCAT 共用同一张进度卡ï¼?--> <div class="card card--esp32" id="progressCard"> - <div class="card-head"><span class="ico">⚡</span> 刷写进度与流程阶段</div> + <div class="card-head"><span class="ico">âš?/span> 刷写进度与流程阶æ®?/div> <div class="card-body"> <!-- Phase step indicators --> <div class="progress-phase-row"> <div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div> <div class="pp-step" id="ppGd32Ota"><span class="pp-ico">📤</span><span class="pp-txt">GD32 OTA</span></div> - <div class="pp-step" id="ppEcatOta"><span class="pp-ico">⚡</span><span class="pp-txt">EtherCAT</span></div> + <div class="pp-step" id="ppEcatOta"><span class="pp-ico">âš?/span><span class="pp-txt">EtherCAT</span></div> </div> <!-- Unified progress bar --> <div class="progress-wrap show" id="progressWrap"> @@ -309,7 +309,7 @@ <div class="stage-node" id="es2"><div class="stage-dot">2</div><div class="stage-label">OTA 写入</div><div class="stage-detail" id="esd2"></div></div> <div class="stage-node" id="es3"><div class="stage-dot">3</div><div class="stage-label">SHA-256 校验</div><div class="stage-detail" id="esd3"></div></div> <div class="stage-node" id="es4"><div class="stage-dot">4</div><div class="stage-label">应用固件</div><div class="stage-detail" id="esd4"></div></div> - <div class="stage-node" id="es5"><div class="stage-dot">5</div><div class="stage-label">重启中</div><div class="stage-detail" id="esd5"></div></div> + <div class="stage-node" id="es5"><div class="stage-dot">5</div><div class="stage-label">重启ä¸?/div><div class="stage-detail" id="esd5"></div></div> <div class="stage-node" id="es6"><div class="stage-dot">6</div><div class="stage-label">启动完成</div><div class="stage-detail" id="esd6"></div></div> </div> <!-- BLE OTA stages --> @@ -334,7 +334,7 @@ <div class="stage-flow" id="ecatStages" style="display:none;"> <div class="stage-node" id="ec1"><div class="stage-dot">1</div><div class="stage-label">读取镜像</div><div class="stage-detail" id="ecd1"></div></div> <div class="stage-node" id="ec2"><div class="stage-dot">2</div><div class="stage-label">推到主站</div><div class="stage-detail" id="ecd2"></div></div> - <div class="stage-node" id="ec3"><div class="stage-dot">3</div><div class="stage-label">交接进 BOOT</div><div class="stage-detail" id="ecd3"></div></div> + <div class="stage-node" id="ec3"><div class="stage-dot">3</div><div class="stage-label">交接è¿?BOOT</div><div class="stage-detail" id="ecd3"></div></div> <div class="stage-node" id="ec4"><div class="stage-dot">4</div><div class="stage-label">确认引导程序</div><div class="stage-detail" id="ecd4"></div></div> <div class="stage-node" id="ec5"><div class="stage-dot">5</div><div class="stage-label">邮箱灌写</div><div class="stage-detail" id="ecd5"></div></div> <div class="stage-node" id="ec6"><div class="stage-dot">6</div><div class="stage-label">从站复位</div><div class="stage-detail" id="ecd6"></div></div> @@ -362,35 +362,35 @@ <!-- Primary status --> <div class="card card--status"> - <div class="card-head"><span class="ico">📡</span> 设备状态</div> + <div class="card-head"><span class="ico">📡</span> 设备状æ€?/div> <div class="card-body"> <div class="badge-grid"> <div class="badge-stat"> <div class="v err" id="bConn">离线</div> - <div class="k">连接状态</div> + <div class="k">连接状æ€?/div> </div> <div class="badge-stat badge-stat-click" id="healthBadge" role="button" tabindex="0" title="点击查看启动自检" aria-expanded="false" aria-controls="healthBootPanel"> <div class="v" id="mHealth">--</div> - <div class="k">健康状态 <span class="health-boot-hint">▾ 自检</span></div> + <div class="k">健康状æ€?<span class="health-boot-hint">â–?自检</span></div> </div> <div class="badge-stat"> <div class="v err" id="connBadge">offline</div> <div class="k">BLE 连接</div> </div> <div class="badge-stat"> - <div class="v" id="gattState" style="font-size:16px;">未就绪</div> - <div class="k">GATT 状态</div> + <div class="v" id="gattState" style="font-size:16px;">未就ç»?/div> + <div class="k">GATT 状æ€?/div> </div> </div> - <!-- 启动自检:点健康状态展开,默认折叠 --> + <!-- 启动自检:点健康状态展开,默认折å�?--> <div class="health-boot-panel collapsed" id="healthBootPanel" hidden> <div class="health-boot-head"> <span class="health-boot-title">启动自检</span> <span class="health-boot-summary" id="mBootResult">--</span> </div> - <div class="health-boot-failed">失败/跳过:<b id="mBootFailed">--</b></div> + <div class="health-boot-failed">失败/跳过ï¼?b id="mBootFailed">--</b></div> <div class="boot-module-list" id="bootModuleList" aria-label="启动模块列表"></div> </div> </div> @@ -402,10 +402,10 @@ <div class="card-body"> <div class="metric-grid"> <div class="metric"><div class="k">ESP32 温度</div><div class="v" id="mTemp">--</div></div> - <div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div> - <div class="metric"><div class="k">GD32 负载</div><div class="v" id="mGd32Load" title="DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口">--</div></div> - <div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCC—47k—ADC—4.7k—GND">--</div></div> - <div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推的 MCU 模拟供电">--</div></div> + <div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温åº?>--</div></div> + <div class="metric"><div class="k">GD32 负载</div><div class="v" id="mGd32Load" title="DCU_APP CPU 负载率:中断+非延迟处ç�?任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO æ®?ï¼?s 窗口">--</div></div> + <div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCCâ€?7k—ADCâ€?.7k—GND">--</div></div> + <div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推çš?MCU 模拟供电">--</div></div> <div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App">--</div></div> <div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot">--</div></div> <div class="metric"><div class="k">GD32 自检</div><div class="v" id="mGd32Init" title="GD32 APP 上电初始化状态(SPI 0xA3;悬停看失败模块/复位源)">--</div></div> @@ -415,13 +415,13 @@ <div class="card card--mem" style="display:none"> <div class="card-head"> - <span class="ico">🧾</span> 系统状态 (/system/status) + <span class="ico">🧾</span> 系统状æ€?(/system/status) <button class="btn btn-ghost btn-sm card-head-btn" data-action="refresh-system-status">刷新</button> </div> <div class="card-body"> <div class="status-kv-grid"> - <div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div> - <div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div> + <div class="status-kv"><span class="k">OTA 状æ€?/span><b id="sOtaState">--</b></div> + <div class="status-kv"><span class="k">Relay 状æ€?/span><b id="sRelayState">--</b></div> <div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div> </div> </div> @@ -430,7 +430,7 @@ <!-- Log panel --> <div class="card card--mem log-panel"> <div class="card-head" style="justify-content:space-between;"> - <span><span class="ico">🖥️</span> 调试日志</span> + <span><span class="ico">🖥ï¸?/span> 调试日志</span> <span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;font-family:var(--font-mono);">UDP :6666</span> </div> <div class="card-body"> @@ -444,10 +444,10 @@ <button class="btn btn-ghost btn-sm" onclick="toggleAutoScroll()" id="autoScrollBtn" title="切换自动滚动">📌 自动滚动</button> </div> <div class="log-scroll" id="logScroll"> - <div class="log-line"><span class="lt">[--:--:--]</span><span class="lv I">I</span><span class="lg">[系统]</span><span class="lm">等待上位机连接...</span></div> + <div class="log-line"><span class="lt">[--:--:--]</span><span class="lv I">I</span><span class="lg">[系统]</span><span class="lm">等待上位机连æŽ?..</span></div> </div> <div class="log-foot"> - <span>共 <span id="logCount">0</span> 条</span> + <span>å…?<span id="logCount">0</span> æ�?/span> <button class="btn btn-ghost btn-sm" onclick="clearLogs()">清除日志</button> </div> </div> @@ -457,15 +457,15 @@ </div> </div> -<!-- 右侧滑出:堆与任务栈(默认露出一条,悬停展开) --> -<aside class="sys-drawer" id="sysDrawer" aria-label="堆与任务栈"> - <div class="sys-drawer-rail" title="悬停展开堆与任务栈"> +<!-- 右侧滑出:堆与任务栈(默认露出一条,悬停展开ï¼?--> +<aside class="sys-drawer" id="sysDrawer" aria-label="堆与任务æ ?> + <div class="sys-drawer-rail" title="悬停展开堆与任务æ ?> <span class="sys-drawer-rail-ico">🧠</span> - <span class="sys-drawer-rail-text">堆与任务栈</span> + <span class="sys-drawer-rail-text">堆与任务æ ?/span> </div> <div class="sys-drawer-panel card card--mem"> <div class="card-head sys-drawer-head"> - <span class="ico">🧠</span> 堆与任务栈 + <span class="ico">🧠</span> 堆与任务æ ? <button class="btn btn-ghost btn-sm card-head-btn" type="button" onclick="refreshSystemStatusNow(event)">刷新</button> </div> <div class="card-body sys-drawer-body"> @@ -480,19 +480,19 @@ <div class="heap-detail-row"> <span>空闲: <b id="cpuIdlePct">--</b></span> <span>窗口: <b id="cpuSampleMs">--</b></span> - <span>任务数: <b id="cpuTaskCount">--</b></span> + <span>任务æ•? <b id="cpuTaskCount">--</b></span> </div> </div> - <div class="status-task-list cpu-task-list list-scroll-top3" id="cpuTaskList" title="按占用降序,默认显示前 3 项,下滑查看其余"> - <div class="status-empty">等待下一次状态刷新...</div> + <div class="status-task-list cpu-task-list list-scroll-top3" id="cpuTaskList" title="按占用降序,默认显示å‰?3 项,下滑查看其余"> + <div class="status-empty">等待下一次状态刷æ–?..</div> </div> <div class="status-section-title">内存概况</div> <div class="heap-group"> - <div class="heap-group-title">芯片 SRAM 总览 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(ESP32-C3 · 静态段可拆分)</span></div> + <div class="heap-group-title">芯片 SRAM 总览 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(ESP32-C3 · 静态段可拆åˆ?</span></div> <div class="heap-bar-row"> <span class="heap-bar-text" id="memSramUsage">--</span> - <span class="heap-bar-track mem-sram-track" id="memSramTrack" title="紫=IRAM · 橙=.data · 灰=.bss · 青=堆已用 · 暗=堆空闲"> + <span class="heap-bar-track mem-sram-track" id="memSramTrack" title="ç´?IRAM · æ©?.data · ç�?.bss · é�?堆已ç”?· æš?堆空é—?> <span class="mem-seg mem-seg-iram" id="memSegIram"></span> <span class="mem-seg mem-seg-data" id="memSegData"></span> <span class="mem-seg mem-seg-bss" id="memSegBss"></span> @@ -505,17 +505,17 @@ <span class="heap-legend-item"><i class="mem-dot mem-dot-iram"></i>IRAM <b id="mIram">--</b></span> <span class="heap-legend-item"><i class="mem-dot mem-dot-data"></i>.data <b id="mData">--</b></span> <span class="heap-legend-item"><i class="mem-dot mem-dot-bss"></i>.bss <b id="mBss">--</b></span> - <span class="heap-legend-item"><i class="mem-dot mem-dot-used"></i>堆已用 <b id="mHeapUsed">--</b></span> - <span class="heap-legend-item"><i class="mem-dot mem-dot-free"></i>堆空闲 <b id="mHeapFree">--</b></span> + <span class="heap-legend-item"><i class="mem-dot mem-dot-used"></i>堆已ç”?<b id="mHeapUsed">--</b></span> + <span class="heap-legend-item"><i class="mem-dot mem-dot-free"></i>堆空é—?<b id="mHeapFree">--</b></span> </div> <div class="heap-detail-row"> <span>SOC 窗口: <b id="mSramSoc">--</b></span> <span>尾部保留: <b id="mReservedTail">--</b></span> - <span>任务栈合计: <b id="mTaskStackTotal">--</b></span> + <span>任务栈合è®? <b id="mTaskStackTotal">--</b></span> </div> </div> <div class="heap-group"> - <div class="heap-group-title">内部 DRAM 堆 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(malloc 数据堆)</span></div> + <div class="heap-group-title">内部 DRAM å ?<span style="font-weight:400;color:var(--text-dim);font-size:10px;">(malloc 数据å ?</span></div> <div class="heap-bar-row"> <span class="heap-bar-text" id="memInternalUsage">--</span> <span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span> @@ -523,26 +523,26 @@ </div> <div class="heap-detail-row"> <span>空闲: <b id="mInternalHeap">--</b></span> - <span>最低空闲: <b id="mInternalMinHeap">--</b></span> + <span>最低空é—? <b id="mInternalMinHeap">--</b></span> <span>最大块: <b id="mInternalLargestBlock">--</b></span> </div> <div class="heap-detail-row"> <span>DMA 空闲: <b id="mDmaFree">--</b></span> - <span>RTC 堆空闲: <b id="mRtcFree">--</b></span> + <span>RTC 堆空é—? <b id="mRtcFree">--</b></span> <span>分配块数: <b id="mAllocBlocks">--</b></span> </div> </div> - <div class="status-section-title">任务栈水位 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim);">(峰值占用 Top,下滑查看)</span></div> - <div class="status-task-list list-scroll-top3" id="taskStackList" title="按峰值占用降序,默认显示前 3 项,下滑查看其余"> - <div class="status-empty">等待状态刷新...</div> + <div class="status-section-title">任务栈水ä½?<span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim);">(峰值占ç”?Top,下滑查看)</span></div> + <div class="status-task-list list-scroll-top3" id="taskStackList" title="按峰值占用降序,默认显示å‰?3 项,下滑查看其余"> + <div class="status-empty">等待状态刷æ–?..</div> </div> <div class="status-subfold collapsed" id="statusJsonFold"> <div class="status-subfold-head" role="button" tabindex="0" onclick="this.parentElement.classList.toggle('collapsed')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.parentElement.classList.toggle('collapsed');}"> <span class="status-section-title status-subfold-title">原始 JSON</span> - <span class="collapse-arrow">▼</span> + <span class="collapse-arrow">â–?/span> </div> <div class="status-subfold-body"> <pre class="status-json" id="statusJson">--</pre> @@ -554,20 +554,20 @@ <!-- Toast --> <div class="toast" id="toast"> - <span class="toast-ico" id="toastIco">✓</span> + <span class="toast-ico" id="toastIco">âœ?/span> <span id="toastMsg">操作成功</span> </div> <!-- app 窗口租约:本页会自动拉起 EtherCAT PDO 循环(GD32 负载), - 必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。 - 与 tabs.html / motor.html 并存时连接数>1,互不影响。 --> + 必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉ã€? + ä¸?tabs.html / motor.html 并存时连接数>1,互不影响ã€?--> <script src="../js/common/app-lease.js"></script> <script type="module" src="../js/app.js?v=20260919a"></script> <script type="module" src="../js/ota/console-mode.js?v=20260916d"></script> <script type="module" src="../js/ble_ota.js?v=20260901d"></script> -<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked - 不依赖 ES 模块(子模块可能被浏览器缓存为旧版,不含 data-ble-state 逻辑)。 - connecting 态由 session.js click handler 设置,此处跳过不覆盖。 --> +<!-- 内联兜底:同æ­?BLE 按钮 data-ble-state ä¸?checkbox.checked + 不依èµ?ES 模块(子模块可能被浏览器缓存为旧版,不含 data-ble-state 逻辑)ã€? + connecting 态由 session.js click handler 设置,此处跳过不覆盖ã€?--> <script type="module"> setInterval(function() { var btn = document.querySelector('.ble-launch-btn'); diff --git a/scripts/_watch_conn.py b/scripts/_watch_conn.py deleted file mode 100644 index 76705ed..0000000 --- a/scripts/_watch_conn.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python3 -"""模拟页面 pollStatus:5s 超时算失败,连续失败即离线。""" -import time -import urllib.request -import urllib.error - -URL = "http://192.168.31.127/system/status" -TIMEOUT = 5.0 -POLL = 1.0 -WINDOW = 75 - - -def probe(): - t0 = time.time() - try: - with urllib.request.urlopen(URL, timeout=TIMEOUT) as r: - r.read(64) - return True, "HTTP %s" % r.status, time.time() - t0 - except urllib.error.HTTPError as e: - return False, "HTTP %s" % e.code, time.time() - t0 - except Exception as e: - return False, "%s: %s" % (type(e).__name__, str(e)[:80]), time.time() - t0 - - -def main(): - print("=== monitor start (mimic pollStatus) ===", flush=True) - prev = None - fails = 0 - t_end = time.time() + WINDOW - while time.time() < t_end: - ok, msg, dt = probe() - now = time.strftime("%H:%M:%S") - if ok: - fails = 0 - state = "ONLINE" - else: - fails += 1 - state = "FAIL#%d" % fails + (" -> OFFLINE" if fails >= 1 else "") - mark = " *" if state != prev else "" - print("[%s] %s (%s, %.0fms)%s" % (now, state, msg, dt * 1000, mark), flush=True) - if state != prev: - prev = state - time.sleep(POLL) - print("=== monitor end ===", flush=True) - - -if __name__ == "__main__": - main() diff --git a/scripts/anal_ecat_jumps.py b/scripts/anal_ecat_jumps.py deleted file mode 100644 index 4b434db..0000000 --- a/scripts/anal_ecat_jumps.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""事后分析 rec_ecat_slots.py 落盘的 ECAT 原始流:相邻数据大跳变检测。 - -思路(按用户指定):不看协议判据,只看数值形态。同一台电机的 STATUS 是连续量, -相邻两帧(按 seq 排序)之间出现"跳出去又立刻跳回来"的孤立尖峰,就是这一帧的字节 -被拼坏了 —— 真突变会保持住(下一帧仍在新值附近),不会一帧就回头。 - -指标:每个 (node,字段) 先取全体相邻差的稳健尺度 scale = median(|Δ|), - 大跳变 : |v[i]-v[i-1]| > K*scale - 孤立尖峰 : 大跳变 且 |v[i+1]-v[i-1]| <= 2*K*scale ← 撕裂签名 - 跳变保持 : 大跳变 但下一帧不回头的(真实突变/新参考值),只计数不算异常 - ---selftest 会把"前 j 字节取新帧、其余取上一帧"的混合帧注入实测数据, -逐 j 报告检出率:证明这套判据对撕裂本身有灵敏度(否则 0 报警没有意义)。 - -用法: - python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8] - python scripts/anal_ecat_jumps.py ecat_slots_raw.csv --selftest -""" -import csv -import statistics -import struct -import sys - -# (名称, 偏移, 解码器) -FIELDS = [ - ("mcuTemp", 0, lambda b: struct.unpack_from("<h", b, 0)[0] * 0.01), - ("motTemp", 2, lambda b: struct.unpack_from("<h", b, 2)[0] * 0.01), - ("uCur", 4, lambda b: struct.unpack_from("<h", b, 4)[0]), - ("vCur", 6, lambda b: struct.unpack_from("<h", b, 6)[0]), - ("wCur", 8, lambda b: struct.unpack_from("<h", b, 8)[0]), - ("flags", 10, lambda b: b[10]), - ("pwr_V", 11, lambda b: struct.unpack_from("<H", b, 11)[0] * 0.001), - ("rtAngle", 13, lambda b: struct.unpack_from("<f", b, 13)[0]), - ("rtVel", 17, lambda b: struct.unpack_from("<f", b, 17)[0]), - ("rtCur", 21, lambda b: struct.unpack_from("<f", b, 21)[0]), - ("testF", 25, lambda b: struct.unpack_from("<f", b, 25)[0]), - ("testU16", 29, lambda b: struct.unpack_from("<H", b, 29)[0]), - ("cpu%", 31, lambda b: b[31]), - ("initSt", 32, lambda b: struct.unpack_from("<I", b, 32)[0]), -] - - -def load(path): - """返回 {node: [(seq, 36B bytes), ...]} 按 seq 升序。""" - per = {} - with open(path, "r", encoding="ascii", errors="ignore") as f: - for row in csv.reader(f): - if not row or row[0] != "M": - continue - try: - node, ch, seq = int(row[2]), int(row[3]), int(row[4]) - payload = bytes.fromhex(row[7]) - except (ValueError, IndexError): - continue - if node <= 0 or len(payload) < 36: - continue - per.setdefault(node, []).append((seq, payload)) - for v in per.values(): - v.sort(key=lambda x: x[0]) - return per - - -def decode(payload): - out = {} - for name, off, fn in FIELDS: - try: - out[name] = float(fn(payload)) - except Exception: - out[name] = None - return out - - -def analyze(per, k=8.0, verbose=True): - """逐 (node,字段) 找孤立尖峰。返回总尖峰数与明细列表。""" - spikes, holds, details = 0, 0, [] - tails = [] - for node, rows in sorted(per.items()): - if len(rows) < 40: - continue - cols = [decode(p) for _, p in rows] - for name, _off, _fn in FIELDS: - v = [c[name] for c in cols] - finite = [x for x in v if x is not None and x == x and abs(x) != float("inf")] - if len(finite) < 40: - continue - deltas = [abs(v[i] - v[i - 1]) for i in range(1, len(v)) - if v[i] is not None and v[i - 1] is not None - and v[i] == v[i] and v[i - 1] == v[i - 1]] - if not deltas: - continue - # 尺度取 |Δ| 的 p99 分位数(不是 median):量化字段(mcuTemp 这类) - # 的 median(|Δ|)=0,用 median 会让阈值塌到 eps,正常的 1LSB 抖动 - # 全被判成尖峰。p99 代表"全体相邻差的尾部",阈值 = K×p99 才是 - # "远超正常抖动尾巴"的意思。 - ordered = sorted(deltas) - scale = max(ordered[min(len(ordered) - 1, int(len(ordered) * 0.99))], 1e-9) - mx, mxctx = 0.0, None - for i in range(1, len(v) - 1): - a, b, c = v[i - 1], v[i], v[i + 1] - if None in (a, b, c) or any(x != x for x in (a, b, c)): - continue - d = abs(b - a) - if d > mx: - mx, mxctx = d, (a, b, c) - if d <= k * scale: - continue - back = abs(c - a) <= 2 * k * scale - if back: - spikes += 1 - if len(details) < 25: - details.append((node, name, rows[i][0], scale, a, b, c, - rows[i][1].hex())) - else: - holds += 1 - if mxctx and mx > 3 * scale and len(tails) < 60: - tails.append((node, name, scale, mx, mxctx)) - if verbose: - print(f"孤立尖峰(撕裂签名) = {spikes} 跳变保持(真实突变) = {holds}") - for node, name, seq, scale, a, b, c, hexs in details[:12]: - print(f" node{node} {name}: seq={seq} 尺度={scale:.4g} " - f"{a:.6g} -> {b:.6g} -> {c:.6g}") - print(f" payload={hexs}") - if tails: - tails.sort(key=lambda x: -(x[3] / max(x[2], 1e-12))) - print("各字段最大相邻差(倍数 = 最大|Δ| / p99 尺度):") - for node, name, scale, mx, (a, b, c) in tails[:14]: - print(f" node{node} {name:9s} p99尺度={scale:.4g} 最大|Δ|={mx:.4g} " - f"({mx/max(scale,1e-12):.0f}×) {a:.6g} -> {b:.6g} -> {c:.6g}") - return spikes, holds, details - - -def selftest(per, k=8.0, n_inject=40): - """注入混合帧,测各边界 j 的检出率(证明判据有灵敏度)。""" - import random - random.seed(7) - base_sp, _bh, _bd = analyze(per, k=k, verbose=False) - nodes = [n for n, r in per.items() if len(r) >= 60] - if not nodes: - print("自检跳过:数据太短(每节点需 >=60 帧)") - return - print(f"自检:基线孤立尖峰 = {base_sp};向实测流注入『新帧前缀 j 字节 + 上一帧后缀』" - f"混合帧,每节点各 {n_inject} 次") - print(" j(字节) 净增尖峰/注入总数 检出率") - for j in (1, 4, 8, 12, 13, 16, 17, 20, 21, 24, 25, 28, 31, 32, 35): - rnd = random.Random(j) - merged = {} - for node in nodes: - rows = per[node] - idxs = rnd.sample(range(2, len(rows) - 2), min(n_inject, len(rows) - 6)) - new = list(rows) - for i in idxs: - cur, prev = rows[i][1], rows[i - 1][1] - new[i] = (rows[i][0], cur[:j] + prev[j:36]) - merged[node] = new - sp, _h, det = analyze(merged, k=k, verbose=False) - inj_n = n_inject * len(nodes) - net = sp - base_sp - worst = max((abs(d[5] - d[4]) for d in det), default=0.0) - print(f" j={j:<4d} {net:5d}/{inj_n:<5d} {100.0*net/inj_n:5.1f}% " - f"最大偏离≈{worst:.4g}") - print("(j 越大越靠尾部字节,混合后数值偏离越小、越难检出 —— 这是形态判据的物理上限)") - - -def main(): - args = [a for a in sys.argv[1:] if not a.startswith("--")] - if not args: - print(__doc__) - return - path = args[0] - k = 8.0 - if "--k" in sys.argv: - k = float(sys.argv[sys.argv.index("--k") + 1]) - per = load(path) - tot = sum(len(v) for v in per.values()) - print(f"读入 {path}: {tot} 帧,节点 {sorted(per)}") - for node, rows in sorted(per.items()): - print(f" node{node}: {len(rows)} 个不同 seq," - f"seq 范围 {rows[0][0]}..{rows[-1][0]}") - if "--selftest" in sys.argv: - selftest(per, k=k) - return - analyze(per, k=k) - - -if __name__ == "__main__": - main() diff --git a/scripts/build_ecat_report_html.py b/scripts/build_ecat_report_html.py deleted file mode 100644 index 9e20deb..0000000 --- a/scripts/build_ecat_report_html.py +++ /dev/null @@ -1,677 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""把扫描数据渲染成自包含 HTML 调研报告(图表为内联 SVG)。 - -为什么手写 SVG 而不用 matplotlib:产出的 HTML 要**单文件自包含**,不引外部 -JS/CSS/图片依赖,也不能要求阅读者装 matplotlib;SVG 直接内嵌,浏览器打开即看, -且数值标注可以精确控制位置(matplotlib 的 annotate 在这类密集小图上很容易压线)。 - -纪律同 plot_ecat_sweep.py: - * 图里画的**只有实测点**。模型/参考一律用虚线并写明"外推",不与实测混同。 - * 无功效的轴(整段恒零)不画成"平坦的零线",改成功效声明块。 - * 从站停报的档位(wkc 不达预期 / load_perm 为残值)一律排除出图,并在页面上 - 显式列出被排除的档位与原因 —— 空图比假图诚实。 - -用法: - python scripts/build_ecat_report_html.py - python scripts/build_ecat_report_html.py --out ECAT_RATE_TRADEOFF.html -""" -import argparse -import html -import json -import math -import os -import sys -from collections import OrderedDict - -HERE = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.dirname(HERE) -sys.path.insert(0, HERE) -from ecat_sweep_common import post # noqa: E402 (仅用于探测 native 是否在跑) - -import csv as _csv - - -# ───────────────────────────────────────────────────────── 数据读取 ───────────────────────────────────────────────────────── -def read_csv(path): - if not os.path.isfile(path): - return [] - rows = [] - for r in _csv.DictReader(open(path, encoding="utf-8")): - def num(k): - v = r.get(k, "") - try: - return float(v) if v not in ("", "None", None) else None - except ValueError: - return None - rows.append({ - "period_ms": float(r["period_ms"]), - "hz_set": 1000.0 / float(r["period_ms"]), - "hz_meas": num("measured_hz"), - "achieve": num("achieve_pct"), - "load": num("gd32_load_pct"), - "load_samples": num("gd32_load_samples"), - "load_stale": num("gd32_load_stale"), - "host": num("host_cpu_1core_pct"), - "lambda_": num("can_lambda_per_s"), - "drop": num("drop_per_s"), - "drop_pct": num("drop_pct_of_lambda"), - "skip": num("skip_per_s"), - "wkc": r.get("wkc"), "exp_wkc": r.get("expected_wkc"), - "wr": r.get("wr"), "hijacked": r.get("hijacked"), - "pid_stable": r.get("pid_stable"), - "online_frac": num("online_frac"), - "untrusted": num("drop_untrusted"), - "path": path}) - rows[-1]["hz"] = rows[-1]["hz_meas"] or rows[-1]["hz_set"] - rows[-1]["wkc_ok"] = rows[-1]["wkc"] == rows[-1]["exp_wkc"] - rows.sort(key=lambda r: r["hz"]) - return rows - - -# ───────────────────────────────────────────────────────── SVG 图元 ───────────────────────────────────────────────────────── -def _ticks(lo, hi, n=5): - """线性刻度:给一组整齐步进的刻度值。""" - if hi <= lo: - return [lo] - raw = (hi - lo) / max(1, n - 1) - mag = 10 ** math.floor(math.log10(raw)) if raw else 1 - step = mag * next((k for k in (1, 2, 2.5, 5, 10) if raw / mag <= k), 10) - v = math.ceil(lo / step) * step - out = [] - while v <= hi + 1e-9: - out.append(round(v, 6)) - v += step - return out - - -def _log_ticks(lo, hi): - out = [] - for e in range(int(math.floor(math.log10(lo))), int(math.ceil(math.log10(hi))) + 1): - for m in (1, 2, 5): - v = m * (10 ** e) - if lo - 1e-9 <= v <= hi + 1e-9: - out.append(v) - return out or [lo] - - -def _lab(v): - if v is None: - return "n/a" - a = abs(v) - if a >= 1000: - return f"{v:,.0f}" - if a >= 100: - return f"{v:.0f}" - if a >= 10: - return f"{v:.1f}" - if a >= 1: - return f"{v:.1f}" if v != int(v) else str(int(v)) - return f"{v:.2f}" if a else "0" - - -class Svg: - """极简 SVG 累加器。""" - - def __init__(self, w, h): - self.w, self.h, self.b = w, h, [] - self.L, self.R = 64, w - 16 - self.T, self.B = 26, h - 42 - - def scale(self, xlog=False, ylog=False, xr=None, yr=None): - self.xlog, self.ylog = xlog, ylog - self.xr, self.yr = xr, yr - - def X(self, v): - if self.xlog: - lo, hi = self.xr - return self.L + (math.log10(max(v, 1e-9)) - math.log10(lo)) / \ - (math.log10(hi) - math.log10(lo)) * (self.R - self.L) - lo, hi = self.xr - return self.L + (v - lo) / (hi - lo or 1) * (self.R - self.L) - - def Y(self, v): - if self.ylog: - lo, hi = self.yr - return self.B - (math.log10(max(v, 1e-9)) - math.log10(lo)) / \ - (math.log10(hi) - math.log10(lo)) * (self.B - self.T) - lo, hi = self.yr - return self.B - (v - lo) / (hi - lo or 1) * (self.B - self.T) - - def grid(self, xt, yt, xf=_lab, yf=_lab): - for v in yt: - try: - y = self.Y(v) - except ValueError: - continue - if not (self.T - 1 <= y <= self.B + 1): - continue - self.b.append(f'<line x1="{self.L}" y1="{y:.1f}" x2="{self.R}" y2="{y:.1f}" ' - f'stroke="#e8ebf0" stroke-width="1"/>') - self.b.append(f'<text x="{self.L-8}" y="{y+3.5:.1f}" text-anchor="end" ' - f'font-size="10.5" fill="#6b7280">{html.escape(yf(v))}</text>') - for v in xt: - x = self.X(v) - if not (self.L - 1 <= x <= self.R + 1): - continue - self.b.append(f'<line x1="{x:.1f}" y1="{self.T}" x2="{x:.1f}" y2="{self.B}" ' - f'stroke="#eef1f5" stroke-width="1"/>') - self.b.append(f'<text x="{x:.1f}" y="{self.B+16}" text-anchor="middle" ' - f'font-size="10.5" fill="#6b7280">{html.escape(xf(v))}</text>') - - def axes(self, xl, yl): - self.b.append(f'<line x1="{self.L}" y1="{self.B}" x2="{self.R}" y2="{self.B}" ' - f'stroke="#9aa3af" stroke-width="1.2"/>') - self.b.append(f'<line x1="{self.L}" y1="{self.T}" x2="{self.L}" y2="{self.B}" ' - f'stroke="#9aa3af" stroke-width="1.2"/>') - self.b.append(f'<text x="{(self.L+self.R)/2:.0f}" y="{self.h-6}" text-anchor="middle" ' - f'font-size="11.5" fill="#374151">{html.escape(xl)}</text>') - self.b.append(f'<text transform="rotate(-90 14 {(self.T+self.B)/2:.0f})" x="14" ' - f'y="{(self.T+self.B)/2:.0f}" text-anchor="middle" font-size="11.5" ' - f'fill="#374151">{html.escape(yl)}</text>') - - def line(self, pts, color, dash=None, wid=2.4, marker="circle", r=4.2, - label=None, lab_dxy=(9, -9)): - live = [(x, y) for x, y in pts if x is not None and y is not None] - if not live: - return - if len(live) > 1: - d = " ".join(f"{'M' if i == 0 else 'L'}{self.X(x):.1f},{self.Y(y):.1f}" - for i, (x, y) in enumerate(live)) - da = f' stroke-dasharray="{dash}"' if dash else "" - self.b.append(f'<path d="{d}" fill="none" stroke="{color}" ' - f'stroke-width="{wid}"{da} stroke-linejoin="round"/>') - for i, (x, y) in enumerate(live): - cx, cy = self.X(x), self.Y(y) - if marker == "circle": - self.b.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="{r}" fill="{color}" ' - f'stroke="#fff" stroke-width="1.4"/>') - elif marker == "square": - self.b.append(f'<rect x="{cx-r:.1f}" y="{cy-r:.1f}" width="{2*r:.1f}" ' - f'height="{2*r:.1f}" fill="{color}" stroke="#fff" ' - f'stroke-width="1.4"/>') - elif marker == "tri": - p = f"{cx:.1f},{cy-r-1:.1f} {cx-r-1:.1f},{cy+r:.1f} {cx+r+1:.1f},{cy+r:.1f}" - self.b.append(f'<polygon points="{p}" fill="{color}" stroke="#fff" ' - f'stroke-width="1.4"/>') - if label and i == len(live) - 1: - dx, dy = lab_dxy - self.b.append(f'<text x="{cx+dx:.1f}" y="{cy+dy:.1f}" font-size="10.5" ' - f'fill="{color}" font-weight="600">{html.escape(label)}</text>') - - def vtext(self, x, y, txt, color="#374151", anchor="middle", size=10.5, weight="400"): - self.b.append(f'<text x="{self.X(x):.1f}" y="{self.Y(y):.1f}" text-anchor="{anchor}" ' - f'font-size="{size}" font-weight="{weight}" ' - f'fill="{color}">{html.escape(txt)}</text>') - - def legend(self, entries, x=None, y=None): - x = self.L + 10 if x is None else x - y = self.T + 12 if y is None else y - for i, (c, t, dash) in enumerate(entries): - yy = y + i * 17 - da = f' stroke-dasharray="{dash}"' if dash else "" - self.b.append(f'<line x1="{x}" y1="{yy}" x2="{x+22}" y2="{yy}" stroke="{c}" ' - f'stroke-width="2.6"{da}/>') - self.b.append(f'<circle cx="{x+11}" cy="{yy}" r="3.2" fill="{c}"/>') - self.b.append(f'<text x="{x+29}" y="{yy+3.6}" font-size="11" ' - f'fill="#374151">{html.escape(t)}</text>') - - def hline(self, v, color="#9ca3af", dash="3,3", wid=1.2): - y = self.Y(v) - if not (self.T - 1 <= y <= self.B + 1): - return - self.b.append(f'<line x1="{self.L}" y1="{y:.1f}" x2="{self.R}" y2="{y:.1f}" ' - f'stroke="{color}" stroke-width="{wid}" stroke-dasharray="{dash}"/>') - - def band(self, x0, x1, color="#ef4444", alpha=0.07): - a, b = self.X(x0), self.X(x1) - self.b.append(f'<rect x="{min(a,b):.1f}" y="{self.T}" width="{abs(b-a):.1f}" ' - f'height="{self.B-self.T}" fill="{color}" opacity="{alpha}"/>') - - def note(self, txt, x=None, y=None, color="#6b7280", anchor="start", size=10.5): - x = self.L + 6 if x is None else x - y = self.B - 8 if y is None else y - self.b.append(f'<text x="{x}" y="{y}" text-anchor="{anchor}" font-size="{size}" ' - f'fill="{color}">{html.escape(txt)}</text>') - - def finish(self, title, sub=""): - head = "" - if sub: - head = f'<text x="{self.L-40}" y="15" font-size="10.5" fill="#6b7280">' \ - f'{html.escape(sub)}</text>' - return (f'<svg viewBox="0 0 {self.w} {self.h}" width="100%" ' - f'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="{html.escape(title)}">' - f'{head}{"".join(self.b)}</svg>') - - -# ───────────────────────────────────────────────────────── 具体图 ───────────────────────────────────────────────────────── -C_RED, C_BLUE, C_GREEN, C_PINK, C_ORANGE, C_GREY, C_PURPLE = ( - "#dc2626", "#2563eb", "#16a34a", "#db2777", "#ea580c", "#6b7280", "#7c3aed") - - -def chart_achieve(rows, rows_no=()): - s = Svg(560, 320) - allr = list(rows) + list(rows_no) - lo = min(r["hz_set"] for r in allr) * 0.7 - hi = max(r["hz_set"] for r in allr) * 1.5 - s.scale(True, True, (lo, hi), (lo, hi)) - s.grid(_log_ticks(lo, hi), _log_ticks(lo, hi)) - s.line([(lo, lo), (hi, hi)], C_GREY, dash="5,4", label=None) - s.line([(r["hz_set"], r["hz_meas"]) for r in rows], C_RED, marker="circle") - s.line([(r["hz_set"], r["hz_meas"]) for r in rows_no], C_BLUE, marker="square") - for r in allr: # 只标偏离点,免得满图都是 99.x% - if (r["achieve"] or 100) < 99.5: - s.vtext(r["hz_set"], r["hz_meas"] * 0.72, - _lab(r["achieve"]) + "%", color="#1e40af", size=9.5, anchor="middle") - s.legend([(C_GREY, "理想 1:1(设定率)", "5,4"), - (C_RED, "实测循环率 · 带 CAN", None), - (C_BLUE, "实测循环率 · 无 CAN", None)], x=s.L + 12, y=s.T + 46) - s.note("标注为达成率%(<99.5% 才标)。无 CAN 设定 >8kHz 后实测全顶在 ≈8.2kHz —— 硬上限", - y=s.B - 8) - s.axes("设定循环率 (Hz,对数)", "实测循环率 (Hz,对数)") - return s.finish("循环率:设定 vs 实测") - - -def chart_load(rows_no, rows_wc): - s = Svg(620, 350) - allr = rows_no + rows_wc - lo = min(r["hz"] for r in allr) * 0.75 - hi = max(r["hz"] for r in allr) * 1.35 - s.scale(True, False, (lo, hi), (0, 112)) - s.grid(_log_ticks(lo, hi), _ticks(0, 110, 6)) - s.hline(100, color="#6b7280", dash="2,4") - s.b.append(f'<text x="{s.R-4}" y="{s.Y(100)-5:.1f}" text-anchor="end" font-size="10" ' - f'fill="#6b7280">100%</text>') - pts_wc = [(r["hz"], r["load"]) for r in rows_wc if r["load"] is not None] - pts_no = [(r["hz"], r["load"]) for r in rows_no if r["load"] is not None] - s.line(pts_wc, C_RED, marker="circle") - s.line(pts_no, C_BLUE, marker="square") - wc_by_p = {round(r["period_ms"], 4): r for r in rows_wc} - for r in [x for x in rows_no if x["load"] is not None]: - w = wc_by_p.get(round(r["period_ms"], 4)) - if w and w["load"] is not None: - # 任一侧饱和(≥95%)时 Δ 只反映夹紧,不标注 - if (r["load"] or 0) >= 95 or (w["load"] or 0) >= 95: - continue - s.vtext(r["hz"], (r["load"] + w["load"]) / 2, f"Δ{w['load']-r['load']:.1f}pp", - color=C_PURPLE, size=10, weight="600") - s.legend([(C_RED, "带 CAN(电机在报 STATUS/CONFIG)", None), - (C_BLUE, "拔掉全部 CAN 设备", None)], x=s.L + 12, y=s.T + 12) - s.note("纵轴是固件 load_perm(只计 ISR+非延迟段,排除 SSC 轮询)—— 不是整机 CPU 占用率", - y=s.B - 8) - s.note("无 CAN 补测:0.25/0.125ms 满速;设定再快实测全顶在 ≈8.2kHz。" - "≥4kHz 的负载读数全是饱和值,不能定位拐点", y=s.B - 22, color="#b45309") - s.note("带 CAN 的 0.25/0.125ms 两点同理停在 ~99.x% —— 饱和读数不等于\"已测到上限\"", - y=s.B - 36, color="#b45309") - s.axes("实测循环率 (Hz,对数)", "从站 load_perm (%)") - return s.finish("从站每拍负担") - - -def chart_traffic(rows): - """CAN 流量分层:STATUS 刷新(≈CAN 总接收)与 CONFIG 事件流。 - - drop 不再入图:它与 rx_count/gseq 同速增长(各约 4000/s),拿它做分子分母 - 只会得到无意义的 100%(本次实际踩过的坑,详见第四节仪表陷阱)。 - """ - s = Svg(620, 350) - ok = [r for r in rows if (r["lambda_"] or 0) > 0] - if not ok: - s.note("无可用的 CAN 流量档位(本轮 CAN 已拔除)", y=(s.T + s.B) / 2, anchor="middle") - return s.finish("CAN 流量分层", "(无数据)") - lo = min(r["hz"] for r in ok) * 0.75 - hi = max(r["hz"] for r in ok) * 1.35 - s.scale(True, True, (lo, hi), (10, 20000)) - s.grid(_log_ticks(lo, hi), _log_ticks(10, 20000)) - s.line([(r["hz"], r["lambda_"]) for r in ok], C_GREEN, marker="circle") - s.band(2500, 9000) - s.vtext(1400, 4000 * 1.35, "带 CAN 轮:STATUS 全流量", color="#166534", size=10) - s.vtext(4200, 150, "塌陷", color="#b91c1c", size=11, weight="700") - s.legend([(C_GREEN, "CAN 总接收 rx_count ≡ STATUS 刷新 gseq(实测同为 ~4000/s)", None)], - x=s.L + 12, y=s.T + 12) - s.note("CONFIG 事件流独立实测仅约 92 帧/s(去重后),不随周期放大", y=s.B - 8) - s.note(">2kHz 处 STATUS 与 CONFIG 一同塌到 ~150/s", y=s.B - 22, color="#b45309") - s.axes("实测循环率 (Hz,对数)", "帧/s(对数)") - return s.finish("CAN 流量分层") - - -def chart_skip(rows_no, rows_wc): - """skip 有真 0 档 → 对数轴画不了。做法:>0 的点画在对数区,真 0/近 0 档 - 落到轴下方的"≈0"标记轨道,两种点含义不同、不混在同一条线上。""" - s = Svg(560, 320) - pos = [r for r in rows_no + rows_wc if (r["skip"] or 0) > 0.05] - zero = [r for r in rows_no + rows_wc if (r["skip"] or 0) <= 0.05] - if not pos: - s.note("本轮无 skip>0 的档位", y=(s.T + s.B) / 2, anchor="middle") - return s.finish("主站错拍率", "(无数据)") - lo = min(r["hz"] for r in pos) * 0.75 - hi = max(r["hz"] for r in pos) * 1.35 - vlo, vhi = max(min(r["skip"] for r in pos), 0.05), max(r["skip"] for r in pos) * 3 - s.T -= 6 - s.scale(True, True, (lo, hi), (vlo, vhi)) - s.grid(_log_ticks(lo, hi), _log_ticks(vlo, vhi)) - s.line([(r["hz"], r["skip"]) for r in rows_wc if (r["skip"] or 0) > 0.05], - C_RED, marker="circle") - s.line([(r["hz"], r["skip"]) for r in rows_no if (r["skip"] or 0) > 0.05], - C_BLUE, marker="square") - for r in zero: # ≈0 轨道:贴在轴下方,明确不属于对数刻度 - y = s.B + 4 - mk = ("circle" if r in rows_wc else "square") - col = C_RED if r in rows_wc else C_BLUE - cx = s.X(r["hz"]) - s.b.append(f'<path d="M{cx-4:.1f},{y+8:.1f} L{cx:.1f},{y:.1f} ' - f'L{cx+4:.1f},{y+8:.1f}" fill="none" stroke="{col}" stroke-width="2"/>') - ent = [(C_RED, "带 CAN", None), (C_BLUE, "无 CAN", None)] - if zero: # 没有真 0 档时不要挂一条对不上号的图例 - ent.append(("#9ca3af", "▽ = skip ≤0.05 次/s(真 0 档,不在对数刻度上)", None)) - s.legend(ent, x=s.L + 12, y=s.T + 12) - s.note("skip=错过的网格拍。0.0625ms 设定档 15720 次/s:实测率被钉在 8.2kHz," - "错拍数随设定率线性涨", y=s.B - 10) - s.axes("实测循环率 (Hz,对数)", "主站错拍 skip_total (次/s,对数)") - return s.finish("主站错拍率") - - -# ───────────────────────────────────────────────────────── 页面组装 ───────────────────────────────────────────────────────── -PAGE = """<!DOCTYPE html> -<html lang="zh-CN"><head><meta charset="utf-8"> -<meta name="viewport" content="width=device-width,initial-scale=1"> -<title>EtherCAT 周期速度 × 故障率 × CPU 占用 调研 -
-{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('

结论

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

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

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

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

') - - b.append('

五、可信度边界

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

这份数据不能证明什么

') - - b.append('

六、下一步

') - b.append('

按优先级

') - - 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/build_integral_report_html.py b/scripts/build_integral_report_html.py deleted file mode 100644 index e275e67..0000000 --- a/scripts/build_integral_report_html.py +++ /dev/null @@ -1,424 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""生成对接电机积分差归因 HTML(通俗说明 + 标注图)。""" -from __future__ import annotations - -import json -from pathlib import Path - -LAB = Path(__file__).resolve().parents[1] / "lab" -SRC = LAB / "ecat_who_slow.json" -OUT = LAB / "ecat_integral_report.html" - -data = json.loads(SRC.read_text(encoding="utf-8")) -pa = data["phase_attribution"] -strokes = data["strokes"] -sample = data.get("plot_sample") or [] -verdict = data.get("verdict") or {} - -# 用行程数据修正通俗结论(避免停车检测为 0 的误导) -on_vals = pa["on_ms_n3_minus_n2"]["values"] -# 剔除 |Δ|>50ms 的伪起动(上一段未停稳造成的重叠) -on_clean = [v for v in on_vals if abs(v) <= 50] -on_overlap = [v for v in on_vals if abs(v) > 50] -I_on = [s["I_on"]["Isum"] for s in strokes] -I_cr = [s["I_cruise"]["Isum"] for s in strokes] -I_st = [s["I_stop"]["Isum"] for s in strokes] -only2 = [s["stop_only_n2"]["Isum"] for s in strokes] -only3 = [s["stop_only_n3"]["Isum"] for s in strokes] -# 停止窗谁残速大:I_st>0 → 合速度偏正 → 镜像下更像 N3 仍正/N2 已减 -# 直接用 only_* 贡献绝对值 -sum_only2 = sum(abs(x) for x in only2) -sum_only3 = sum(abs(x) for x in only3) -n_only2 = sum(1 for x in only2 if abs(x) > 0.2) -n_only3 = sum(1 for x in only3 if abs(x) > 0.2) - -cruise_v2 = pa["cruise_mean_abs_v2"]["mean"] -cruise_v3 = pa["cruise_mean_abs_v3"]["mean"] -cruise_bias = pa["cruise_bias_v2_minus_v3"]["mean"] - -mean_on_clean = sum(on_clean) / len(on_clean) if on_clean else None -mean_Ion = sum(I_on) / len(I_on) if I_on else 0 -mean_Icr = sum(I_cr) / len(I_cr) if I_cr else 0 -mean_Ist = sum(I_st) / len(I_st) if I_st else 0 -abs_Ist = sum(abs(x) for x in I_st) / len(I_st) if I_st else 0 -abs_Ion = sum(abs(x) for x in I_on) / len(I_on) if I_on else 0 -abs_Icr = sum(abs(x) for x in I_cr) / len(I_cr) if I_cr else 0 - -# SVG 折线 -W, H = 960, 420 -PAD_L, PAD_R, PAD_T = 56, 20, 36 - - -def poly(xs, ys, y0, y1, top, h, color, w=1.3): - if not xs: - return "" - lo, hi = y0, y1 - if hi - lo < 1e-9: - hi = lo + 1 - - def X(t, t0, t1): - span = max(t1 - t0, 1e-6) - return PAD_L + (t - t0) / span * (W - PAD_L - PAD_R) - - def Y(v): - return top + (hi - v) / (hi - lo) * h - - pts = " ".join(f"{X(xs[i], xs[0], xs[-1]):.1f},{Y(ys[i]):.1f}" for i in range(len(xs))) - return f'' - - -if sample: - ts = [p["t"] for p in sample] - v2 = [p["v2"] for p in sample] - v3 = [p["v3"] for p in sample] - vsum = [p["vsum"] for p in sample] - Isum = [p["Isum"] for p in sample] - t0, t1 = ts[0], ts[-1] - vmin = min(min(v2), min(v3)) - vmax = max(max(v2), max(v3)) - imin = min(min(Isum), 0) - imax = max(max(Isum), 0) - padv = (vmax - vmin) * 0.08 or 10 - padI = (imax - imin) * 0.1 or 2 - - def X(t): - return PAD_L + (t - t0) / max(t1 - t0, 1e-6) * (W - PAD_L - PAD_R) - - def Yv(v): - return 48 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 100 - - def Ys(v): - return 190 + (vmax + padv - v) / (vmax - vmin + 2 * padv) * 80 - - def YI(v): - return 300 + (imax + padI - v) / (imax - imin + 2 * padI) * 80 - - def line(vals, yfn, color, w=1.3): - pts = " ".join(f"{X(ts[i]):.1f},{yfn(vals[i]):.1f}" for i in range(len(ts))) - return f'' - - # 行程分界竖线 - vlines = [] - for sm in data.get("stroke_marks") or []: - if sm["t0"] <= t1: - x = X(sm["t0"]) - vlines.append( - f'' - ) - vlines.append( - f'#{sm["i"]}' - ) -else: - ts = v2 = v3 = vsum = Isum = [] - t0 = t1 = 0 - vlines = [] - -# 标注箭头:在图上用文字+简单箭头路径 -notes_svg = [] -if sample: - # 找几个典型点 - # 1) 巡航:|v| 稳定处 - # 2) 停车:vsum 尖峰 - peak_i = max(range(len(sample)), key=lambda i: abs(sample[i]["vsum"])) - # 理论零线 - notes_svg.append( - f'' - f'对接电机 2/3:速度、合速度 v2+v3、积分 ∫(v2+v3)dt(虚线为理论 0)' - ) - -# 表格行 -rows_html = [] -for s in strokes: - cls_on = "bad" if abs(s["on_ms_n3_minus_n2"]) > 50 else "ok" - cls_cr = "ok" - cls_st = "warn" if abs(s["I_stop"]["Isum"]) > 3 else "ok" - who = "" - if abs(s["stop_only_n3"]["Isum"]) > abs(s["stop_only_n2"]["Isum"]) and abs(s["stop_only_n3"]["Isum"]) > 0.2: - who = "N3 残速" - elif abs(s["stop_only_n2"]["Isum"]) > 0.2: - who = "N2 残速" - else: - who = "两台接近同步刹停" - rows_html.append(f""" - - #{s['i']}{s['dir_n2']} - {s['on_ms_n3_minus_n2']:+.2f} ms - {s['cruise']['mean_abs_v2']:.2f} / {s['cruise']['mean_abs_v3']:.2f} - {s['I_on']['Isum']:+.2f} - {s['I_cruise']['Isum']:+.2f} - {s['I_stop']['Isum']:+.2f} - {s['stop_only_n2']['Isum']:+.2f} - {s['stop_only_n3']['Isum']:+.2f} - {who} - {s['da2_plus_da3']:+.2f}° - """) - -# 通俗结论文本 -if abs_Ist > abs_Icr * 3 and abs_Ist > abs_Ion: - main_src = "停车阶段" -elif abs_Ion > abs_Ist and abs_Ion > abs_Icr: - main_src = "行程衔接/起动窗口(含上一段未停稳的重叠)" -else: - main_src = "起动与停车都有贡献,巡航最干净" - -plain_html = f""" -
-

通俗结论:到底是谁慢?

-

先说人话:两台电机并不是“有一台全程都慢”。 - 你看到的积分差,主要不是巡航时谁转得慢,而是快停下 / 刚要走的那几十一到几百毫秒里,两台对不齐, - 把理论应为 0 的 ∫(v2+v3)dt 顶出 5~20° 左右的偏差。

- -
    -
  • 启动 - 干净起动时(|Δt|≤50ms 的行程),N3−N2 约为 - {mean_on_clean if mean_on_clean is not None else '—'} ms 量级 → - 几乎是同时启动,ECAT 同帧下发没有问题。 - 有 {len(on_overlap)} 个行程测出上百毫秒的“假早启动”,那是上一段还没停稳、N3 仍在动, - 不是这一段命令发晚了。
  • -
  • 巡航 - 两边平均 |v|:N2 {cruise_v2:.2f}°/s,N3 {cruise_v3:.2f}°/s, - 差值约 {cruise_bias:.3f}°/s → 稳态速度几乎一样,谈不上谁慢。 - 巡航段 ∫(v2+v3) 平均只有约 {mean_Icr:.2f}°,贴合理论 0。
  • -
  • 停车 / 换向 - 行程末端 ∫(v2+v3) 平均约 {mean_Ist:.2f}°,绝对值平均约 {abs_Ist:.2f}°, - 明显大于巡航。停止窗口里: - 出现「只有 N2 还在走」的行程约 {n_only2} 次(积分贡献合计 {sum_only2:.2f}°), - 「只有 N3 还在走」约 {n_only3} 次(合计 {sum_only3:.2f}°)。 - 两边都有刹车尾巴,不是单边永久变慢;有的行程 N2 拖尾更明显,有的行程 N3 更明显。
  • -
- -
- 一句话:积分差的大头在 {main_src}。 - 对接约束要求 v2 = −v3;只要刹停瞬间一台还带着速度、另一台已经接近 0, - v2+v3 就会冒尖峰,积分就会被拉离理论 0。这属于动态不同步, - 不等于某台电机“标定转速低、一直慢”。 -
- -
-
正常:同时启动 / 巡航一致 / 中段积分≈0
-
注意:停车沿残速、行程衔接重叠、积分被顶开
-
偏大:该行程 ∫(v2+v3) 或编码器 Δ(a2+a3) 明显偏离 0
-
-
-""" - -rows_joined = "\n".join(rows_html) - -# 标注框(放在 SVG 下方用 HTML 箭头说明) -annot_html = f""" -
-

图上怎么看(对照上方曲线)

-
-
-
↑ 巡航段(两条速度曲线几乎贴合)
-

N2 蓝线、N3 橙线在 |v|≈200°/s 附近重合 → 都不慢。 - 下方红线 v2+v3 贴 0,积分曲线几乎水平 → 对接约束在稳态是成立的。

-
-
-
↓ 停车沿 / 换向点(红线尖峰)
-

接近终点时,一台速度先掉下来、另一台还在走 → v2+v3 出现 ±100~200°/s 尖峰。 - 积分曲线在这里爬上或爬下,行程结束就停在理论 0 以外几度到二十度。 - 这里是积分差的主要来源。

-
-
-
↓ 行程刚衔接时(起动窗)
-

若上一段没完全停稳就反向,会看到“N3 早就动了”的假象(表格里 onΔ 为 −800ms 量级)。 - 这不是启动指令不同步,而是速度还在反向爬坡 / 残速, - 也会把起动窗积分抬高一点。

-
-
-
↓ 紫色角度和 a2+a3
-

理论应为常数(理想为 0)。行程终点若偏到 ±10~25°,与该行程 ∫(v2+v3) 同量级 → - 说明偏差是真实位移耦合误差,不只是速度噪声。

-
-
- -
- 标定建议(若要压积分差): - ① 先统一两台的减速段(同样斜率刹停),而不是去改巡航速度; - ② 检查 N2/N3 负载与制动是否对称; - ③ 控制器上对对接轴做 v2+v3 → 0 的纠偏,比单轴调 PID 更对症; - ④ 序列 hold 不要短于“两台都确认停稳”的时间,避免重叠行程把积分搅浑。 -
-
-""" - -# SVG 图 -if sample: - svg = f""" - - - {''.join(vlines)} - {line(v2, Yv, '#4da3ff')} - {line(v3, Yv, '#f0a030')} - 速度 deg/s — 蓝 N2 / 橙 N3 - - {line(vsum, Ys, '#ff5c5c')} - 合速度 v2+v3(理论 0)— 停车尖峰在这里 - - {line(Isum, YI, '#b48cff')} - 积分 ∫(v2+v3)dt(理论 0)— 每段末的偏离就是耦合误差 - - t_rel {t0:.1f}..{t1:.1f}s · ECAT 0.25ms · 竖线为行程起点 - - - ↑ 巡航:两线贴合,都不慢 - ↓ 停车沿:v_sum 尖峰 → 积分爬升 - -""" -else: - svg = "

(无绘图样本)

" - -html = f""" - - - -对接电机 2/3 积分差归因报告 - - - -

对接电机 2 / 3:积分差是谁造成的?

-
- 数据:ECAT 无源采集 lab/ecat_passive_latest.csv · 周期 0.25ms · - 理论:刚性镜像对接时 v2+v3≡0,∫(v2+v3)dt≡0,a2+a3=常数
- 方法:对齐 cycles 后做梯形积分;先加后积与先积后加一致。 -
- -
-

关键数字

-
-
巡航 |v| N2 / N3
-
{cruise_v2:.2f} / {cruise_v3:.2f}
-
巡航段 ∫(v2+v3) 平均
-
{mean_Icr:.2f}°
-
停车段 ∫(v2+v3) |均值|
-
{abs_Ist:.2f}°
-
干净起动 Δt 中位/均值
-
{(sorted(on_clean)[len(on_clean)//2] if on_clean else float('nan')):.2f} / {(mean_on_clean if mean_on_clean is not None else float('nan')):.2f} ms
-
-
- -{plain_html} - -
-

曲线(带标注)

- {svg} -
- -{annot_html} - -
-

各行程明细(积分从行程起点重算)

- - - - - - - - - - {rows_joined} - -
#方向起动Δ(N3-N2)巡航|v| N2/N3∫起动∫巡航∫停车停时仅N2在动停时仅N3在动停车归因Δ(a2+a3)
-

- 单位:角度 deg(积分)· 速度 deg/s · 时间 ms。 - 「停时仅N2/N3在动」为该窗口内单边残速对 ∫(v2+v3) 的贡献。 -

-
- -
-

原始结论脚本输出(备查)

-
{json.dumps(verdict, ensure_ascii=False, indent=2)}
-
- - -""" - -OUT.write_text(html, encoding="utf-8") -print("HTML ->", OUT) -print("main_src:", main_src) -print("on_clean", on_clean, "mean", mean_on_clean) -print("I_on/I_cr/I_st abs mean", abs_Ion, abs_Icr, abs_Ist) -print("only2", n_only2, sum_only2, "only3", n_only3, sum_only3) diff --git a/scripts/calibrate_ecat_metrics.py b/scripts/calibrate_ecat_metrics.py deleted file mode 100644 index 3a11289..0000000 --- a/scripts/calibrate_ecat_metrics.py +++ /dev/null @@ -1,418 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Phase 0:ECAT 指标现场标定 —— 跑速度扫描之前,先确认每个计数器还有没有意义。 - -为什么必须有这一步:固件与主站都在被反复改(当前 ecat_native.c 有 239 行未提交改动, -批量区已从"变长紧排 3 帧/拍"换成 1.6.15 定长覆盖窗口),任何沿用历史结论的判据都可能 -已经失效。本脚本只认现场读数,不认文档和历史。 - -标定八件事: - 1 总线能否正常起(open + motor/start) - 2 板上固件身份(台账 sha256 对 Release/*.hex 展平后的哈希) - 3 批量区实际布局(rxraw.layout,不假设) - 4 每拍入窗帧数分布(取 cadence 环,按拍采样,不受 HTTP 轮询速率影响) - 5 丢帧率的主判据:**seq 值域 − 实测 distinct seq**(对槽位顺序不敏感) - 对账判据:固件 OVF / bridge.drop —— 二者与主判据是否一致 - 6 撕裂类计数器是否还有功效(rx_torn / pdo_reentry,恒零即"测不到"≠"没坏") - 7 主站错拍 skip_total 与达成率、cyc_us 占空 - 8 CPU 两口径(GD32 load_perm / 主机 native)+ 进程 PID 稳定性 - -用法: - python scripts/calibrate_ecat_metrics.py - python scripts/calibrate_ecat_metrics.py --seconds 60 --json lab/ecat_calib.json - python scripts/calibrate_ecat_metrics.py --no-start # 总线没起时不自动拉起 -""" -import argparse -import collections -import hashlib -import json -import os -import subprocess -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post - -REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -DEFAULT_URL = "http://127.0.0.1:8090" - -# 1.6.15 定长覆盖窗口槽布局(对应 ecat_native.c RXB_WIN_OFF_* / 固件 ECAT_RXW_OFF_*) -WIN_SLOT_SIZE, WIN_SLOTS = 44, 5 -WIN_OFF_FLAGS, WIN_OFF_ID, WIN_OFF_SEQ, WIN_OFF_DLC = 2, 4, 8, 1 -FLAG_WRITTEN = 0x01 - - -def parse_win_slots(rawhex): - """从 rxraw.raw 解出每个槽的 (id, seq)(跳过 bit0=未写过的空槽)。 - - parsed 字段不带 seq,而 seq 是这个布局的丢帧判据,必须自己解。 - """ - try: - buf = bytes.fromhex(rawhex or "") - except ValueError: - return [] - out = [] - for k in range(WIN_SLOTS): - o = k * WIN_SLOT_SIZE - if o + WIN_SLOT_SIZE > len(buf): - break - if not buf[o + WIN_OFF_FLAGS] & FLAG_WRITTEN: - continue - out.append((int.from_bytes(buf[o + WIN_OFF_ID:o + WIN_OFF_ID + 4], "little"), - int.from_bytes(buf[o + WIN_OFF_SEQ:o + WIN_OFF_SEQ + 4], "little"))) - return out - - -def window_coverage(seqmap, slot_slots=WIN_SLOTS): - """覆盖窗口观测到的 seq 值域 / distinct seq 数(**描述性指标,不是丢帧判据**)。 - - 曾用「值域 − distinct」当丢帧主判据,已被现场证伪:窗口只有 5 槽、HTTP 又只几 - Hz,值域内根本不可能观测到全部 seq,实测报出 99.686% "丢帧"而固件 OVF=0,自相 - 矛盾 —— 它量的是**采样覆盖率**。真丢帧主判据是 bridge.drop(固件 - canfd.h:53 rx_drop_count,已实测确认为回绕计数器且增量与 rx_count 增量相等)。 - """ - out = {} - for fid, obs in seqmap.items(): - if not obs: - continue - lo, hi = min(obs), max(obs) - out[fid] = {"n_obs": len(obs), "span": hi - lo + 1, - "coverage_pct": round(len(obs) / (hi - lo + 1) * 100, 3)} - return {"per_id": out, "ids": len(out), - "note": "覆盖率仅说明观测密度,不可当丢帧率引用"} - - -def cadence_rings(url): - """按拍采到的环形缓冲:每拍 count/lost/pending/wkc 的真实分布。 - - HTTP 轮询只有几 Hz,用它看"每拍几帧"是欠采样;cadence 环由 native 自己在 - 每拍写,512 拍一段,才是每拍口径的真值。 - """ - try: - c = post(url, "/api/can/cadence") - except Exception as e: - return {"error": str(e)[:120]} - cols, rows = c.get("cols") or [], c.get("rows") or [] - if not rows: - return {"error": "环为空"} - ix = {k: i for i, k in enumerate(cols)} - def col(name): - return [r[ix[name]] for r in rows] if name in ix else [] - out = {"n": len(rows), "period_ms": c.get("period_ms")} - for name in ("count", "lost", "pending", "backlog", "wkc"): - v = col(name) - if v: - out[name + "_hist"] = {str(k): n for k, n in sorted(collections.Counter(v).items())} - out[name + "_max"] = max(v) - span_ms = (col("t")[-1] - col("t")[0]) if len(rows) > 1 else 0 - out["span_ms"] = round(span_ms, 1) - if span_ms > 0 and "rxcnt" in ix: - out["lambda_per_s"] = round((col("rxcnt")[-1] - col("rxcnt")[0]) / (span_ms / 1000.0), 1) - out["gseq_per_s"] = round((col("gseq")[-1] - col("gseq")[0]) / (span_ms / 1000.0), 1) \ - if "gseq" in ix else None - return out - - -def find_native_pid(url): - """反查占用该端口的进程 PID。PID 变了 = 换了实例 = 之前采的全部作废。""" - try: - out = subprocess.check_output(["netstat", "-ano", "-p", "TCP"], - text=True, errors="replace") - except Exception as e: - return None, f"netstat 失败: {e}" - port = url.rsplit(":", 1)[-1].strip("/") - for line in out.splitlines(): - cols = line.split() - if len(cols) >= 5 and cols[0] == "TCP" and cols[3] == "LISTENING" \ - and cols[1].endswith(":" + port): - return int(cols[-1]), None - return None, f"没人监听 :{port}" - - -def firmware_identity(): - """台账 sha256 对固件产物。 - - 口径必须与烧写脚本一致:`lab/_last_gd32_sha.txt` 记的是 Intel-Hex **展平成 - APP 基址连续 blob 后**的 sha256(flash_gd32.py:91-92),不是 .hex 文件本身的哈希, - 产物也是 `.hex` 而非 `.bin`。按原始字节算或按 .bin 过滤会一个都扫不到, - 把"本可判定"误报成"无法判定"。 - - 即便命中也只能说明"板上镜像与当前产物同源";产物比台账新时必然不等。 - **不能据此推断板上正在运行的版本** —— 那要设备回报版本字。 - """ - shafile = os.path.join(REPO, "lab", "_last_gd32_sha.txt") - rec = {"ledger": None, "ledger_path": shafile, "match": None, - "candidates": [], "method": "hex_to_flat + sha256"} - if not os.path.isfile(shafile): - rec["error"] = "台账不存在" - return rec - with open(shafile, encoding="utf-8", errors="replace") as f: - rec["ledger"] = f.read().strip() - try: - from flash_gd32 import hex_to_flat - except Exception as e: - rec["error"] = f"无法复用烧写脚本的展平算法({e})" - return rec - app = os.path.abspath(os.path.join(REPO, "..", "..", "DCU_APP", "out")) - if not os.path.isdir(app): - rec["error"] = f"固件产物目录不存在: {app}" - return rec - hexes = [] - for dirpath, _d, files in os.walk(app): - hexes += [os.path.join(dirpath, fn) for fn in files if fn.endswith(".hex")] - if not hexes: - rec["error"] = "DCU_APP/out 下没有 .hex 产物" - return rec - for p in sorted(hexes): - try: - d = hashlib.sha256(hex_to_flat(p)).hexdigest() - except (OSError, SystemExit, ValueError) as e: - rec["candidates"].append({"path": p, "error": str(e)[:120]}) - continue - rec["candidates"].append({"path": p, "sha256": d[:16], "mtime": int(os.path.getmtime(p))}) - if d == rec["ledger"]: - rec["match"] = p - rec["candidates"].sort(key=lambda c: -c.get("mtime", 0)) - rec["candidates"] = rec["candidates"][:5] - return rec - - -def ensure_loop(url, autostart, substr): - st = post(url, "/api/motor/state") - if st.get("ok") and st.get("cycles"): - return st, "已在跑" - if not autostart: - return st, "循环未跑(--no-start)" - op = post(url, "/api/open", {"substr": substr}, timeout=25.0) - if not op.get("ok"): - return op, "open 失败: %s" % (op.get("log") or []) - ms = post(url, "/api/motor/start", {"period_ms": st.get("period_ms") or 1.0}, timeout=40.0) - if not ms.get("ok"): - return ms, "motor/start 失败(典型=SSC 邮箱楔死): %s" % (ms.get("log") or []) - return post(url, "/api/motor/state"), "已自动 open+start" - - -def main(): - ap = argparse.ArgumentParser(description="ECAT 指标现场标定") - ap.add_argument("--url", default=DEFAULT_URL) - ap.add_argument("--seconds", type=float, default=30.0) - ap.add_argument("--hz", type=float, default=10.0, - help="HTTP 采样频率。u16 的 drop 在 4000/s 下 16s 就绕一圈," - "采样间隔须远小于回绕周期") - ap.add_argument("--no-start", action="store_true") - ap.add_argument("--substr", default="I210") - ap.add_argument("--json", default=None) - a = ap.parse_args() - - rep = {"url": a.url, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), - "seconds": a.seconds, "sample_hz": a.hz, "logical_cpus": os.cpu_count()} - pid, perr = find_native_pid(a.url) - rep["native_pid"], rep["native_pid_error"] = pid, perr - rep["firmware"] = firmware_identity() - rep["cadence"] = cadence_rings(a.url) - - st, how = ensure_loop(a.url, not a.no_start, a.substr) - rep["loop_start"] = {"how": how, "ok": bool(st.get("ok"))} - if not st.get("ok"): - print("总线起不来,标定终止:", how) - return 2 - rep["period_ms_set"] = st.get("period_ms") - rep["pcore_mask"] = st.get("pcore_mask") - - proc = None - try: - import psutil - for p in psutil.process_iter(["name", "pid"]): - if p.info["pid"] == pid: - proc = psutil.Process(pid) - proc.cpu_percent(None) - break - rep["psutil"] = proc is not None - except Exception as e: - rep["psutil"] = f"不可用({e})" - - interval = 1.0 / a.hz - seqmap = collections.defaultdict(set) - counts, loads, hc1, hctot, cyc_hz = collections.Counter(), [], [], [], [] - drop_rate, rx_rate, cyc_samples = [], [], [] - drop_untrusted = 0 - skip_first = skip_last = None - torn_c = reentry_c = 0 - torn_cap = reentry_cap = False - torn_prev = reentry_prev = None - prev = None - prev_t = None - layout = collections.Counter() - err = iters = 0 - t_end = time.perf_counter() + a.seconds - while time.perf_counter() < t_end: - tc = time.perf_counter() - try: - s = post(a.url, "/api/motor/state") - L = post(a.url, "/api/app/load") - R = post(a.url, "/api/motor/rxraw") - D = post(a.url, "/api/motor/dbg") - except Exception: - err += 1 - time.sleep(interval) - continue - iters += 1 - b = s.get("bridge") or {} - rb = s.get("rx_batch") or {} - cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count")} - if prev is not None and prev_t is not None and tc > prev_t: - dt = tc - prev_t - if cur["cycles"] is not None and prev["cycles"] is not None: - hz = (cur["cycles"] - prev["cycles"]) / dt - if 0 < hz < 1e6: - cyc_hz.append(hz) - if cur["drop"] is not None and prev["drop"] is not None: - d = delta_wrap(prev["drop"], cur["drop"], 16) - # u16 回绕计数器:单次增量逼近模数一半即说明两次采样间已绕圈, - # 该次不可信,只能丢弃并计数暴露采样率不足(真值下降同理)。 - if d < 32768: - drop_rate.append(d / dt) - else: - drop_untrusted += 1 - if cur["rx"] is not None and prev["rx"] is not None: - dr = delta_wrap(prev["rx"], cur["rx"], 32) - if dr < (1 << 31): - rx_rate.append(dr / dt) - for fid, sq in parse_win_slots(R.get("raw")): - seqmap[fid].add(sq) - counts[rb.get("count")] += 1 - layout[R.get("layout")] += 1 - if L.get("valid") and L.get("load_perm") is not None: - loads.append(L["load_perm"] / 10.0) - if L.get("rx_torn") is not None: - if torn_prev is not None: - d, capped = delta_saturate(torn_prev, L["rx_torn"]) - torn_c += d - torn_cap |= capped or L["rx_torn"] >= 65535 - torn_prev = L["rx_torn"] - if L.get("pdo_reentry") is not None: - if reentry_prev is not None: - d, capped = delta_saturate(reentry_prev, L["pdo_reentry"]) - reentry_c += d - reentry_cap |= capped or L["pdo_reentry"] >= 65535 - reentry_prev = L["pdo_reentry"] - sk = D.get("skip_total") - if sk is not None: - if skip_first is None: - skip_first = sk - skip_last = sk - if s.get("cyc_us"): - cyc_samples.append(s["cyc_us"]) - if proc is not None: - try: - c = proc.cpu_percent(None) - hc1.append(c) - hctot.append(c / max(1, rep["logical_cpus"] or 1)) - except Exception: - pass - prev, prev_t = cur, tc - time.sleep(max(0.0, interval - (time.perf_counter() - tc))) - - dur = time.perf_counter() - (t_end - a.seconds) - stf = post(a.url, "/api/motor/state") - pid2, _ = find_native_pid(a.url) - cad = rep["cadence"] - - lam = percentile(rx_rate, 0.5) or cad.get("lambda_per_s") or 0 - dropr = percentile(drop_rate, 0.5) or 0 - rep["dur_s"] = round(dur, 2) - rep["iters"] = iters - rep["errors"] = err - rep["drop_untrusted_samples"] = drop_untrusted - rep["period_ms_effective"] = stf.get("period_ms") - rep["wkc"] = [stf.get("wkc"), stf.get("expected_wkc")] - rep["wr_end"] = stf.get("wr") - rep["native_pid_end"] = pid2 - rep["pid_stable"] = (pid == pid2) - rep["layout_seen"] = dict(layout) - rep["rx_batch_count_hist_http"] = {str(k): v for k, v in sorted(counts.items(), key=lambda x: (x[0] is None, x[0]))} - rep["window_coverage"] = window_coverage(seqmap) - rep["can_lambda_per_s"] = round(lam, 1) - rep["drop_per_s"] = round(dropr, 1) - rep["drop_pct_of_lambda"] = round(dropr / lam * 100, 2) if lam else None - rep["achieved_hz_p50"] = round(percentile(cyc_hz, 0.5) or 0, 1) if cyc_hz else None - pm = rep["period_ms_effective"] or 1 - rep["achieve_pct"] = round((rep["achieved_hz_p50"] or 0) / (1000.0 / pm) * 100, 1) - rep["skip_delta"] = (skip_last - skip_first) if (skip_first is not None and skip_last is not None) else None - rep["skip_per_s"] = round((rep["skip_delta"] or 0) / dur, 2) if rep["skip_delta"] is not None else None - rep["skip_total_cum"] = skip_last - rep["rx_torn_delta"] = torn_c - rep["rx_torn_capped"] = torn_cap - rep["pdo_reentry_delta"] = reentry_c - rep["pdo_reentry_capped"] = reentry_cap - tot = [(c.get("total") or {}) for c in cyc_samples if c.get("total")] - rep["cyc_us_total"] = {"p50": percentile([t.get("p50") for t in tot if t.get("p50") is not None], 0.5), - "p90": percentile([t.get("p90") for t in tot if t.get("p90") is not None], 0.5), - "max": max([t.get("max") or 0 for t in tot], default=None)} - rep["cyc_us_late_lost_sample"] = tot[0] if tot else None - rep["gd32_load_pct_p50"] = round(percentile(loads, 0.5) or 0, 1) - rep["host_cpu_1core_p50"] = round(percentile(hc1, 0.5) or 0, 1) - rep["host_cpu_total_p50"] = round(percentile(hctot, 0.5) or 0, 2) - - cap = cad.get("count_max") or 0 - rep["per_cycle_cap_observed"] = cap - rep["capacity_model"] = { - "lambda_per_s": round(lam, 1), "per_cycle_cap": cap, - "drain_per_s_at_current_T": round(cap / pm * 1000, 0) if pm else None, - "knee_ms_if_linear": round(cap / lam * 1000, 3) if lam and cap else None, - "note": "cap 取自 cadence 环实测 count 峰值;拐点以扫描实测为准,此值仅供事后对照"} - rep["power"] = { - "drop_rx_queue": dropr > 0, - "skip_total": (rep["skip_delta"] or 0) > 0, - "achieve_deficit": (rep["achieve_pct"] or 100) < 99.0, - "rx_torn": torn_c > 0, - "pdo_reentry": reentry_c > 0, - "cadence_lost_or_pending": bool((cad.get("lost_max") or 0) or (cad.get("pending_max") or 0)), - "cyc_us_late_lost": bool((rep["cyc_us_late_lost_sample"] or {}).get("late") - or (rep["cyc_us_late_lost_sample"] or {}).get("lost"))} - - print(f"=== Phase 0 标定 · {dur:.0f}s · HTTP 采样 {iters} 轮(错 {err}) @ {a.hz:g}Hz ===") - print(f"native PID {pid} -> {pid2} 稳定={rep['pid_stable']} 钉核 mask={rep['pcore_mask']}") - fw = rep["firmware"] - print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={os.path.basename(fw['match']) if fw.get('match') else fw.get('error') or '无'}") - print(f"周期 设定 {rep['period_ms_set']}ms -> 生效 {pm}ms 实测 {rep['achieved_hz_p50']}Hz 达成率 {rep['achieve_pct']}%") - print(f"批量区布局 {dict(layout)}") - print(f"每拍入窗帧数(cadence 环 {cad.get('n')}拍): count={cad.get('count_hist')} " - f"lost={cad.get('lost_hist')} pending={cad.get('pending_hist')}") - print(f" ↑ 每拍口径必须用环,HTTP 轮询看 count 是欠采样") - print(f"【主判据】λ={lam:.0f}/s drop={dropr:.0f}/s 丢帧占 λ = {rep['drop_pct_of_lambda']}%" - f" (drop=固件 canfd.h rx_drop_count,CAN RX 队列(kRxQueueDepth=16)满即丢)") - if drop_untrusted: - print(f" !! {drop_untrusted} 个采样点单次增量 >32768,说明已绕圈 -> 采样率不足,提高 --hz") - print(f" 对账: cadence lost_max={cad.get('lost_max')} pending_max={cad.get('pending_max')}" - f"(覆盖窗口下这两个旧计数器语义已改,仅供对账)") - print(f" 窗口 seq 覆盖率(非丢帧率!): {rep['window_coverage']['per_id']}") - print(f"rx_torn +{torn_c}(封顶={torn_cap}) pdo_reentry +{reentry_c}(封顶={reentry_cap})") - print(f"主站错拍 skip_total Δ={rep['skip_delta']} = {rep['skip_per_s']}/s(累计 {rep['skip_total_cum']})") - print(f"cyc_us.total p50={rep['cyc_us_total']['p50']} p90={rep['cyc_us_total']['p90']} " - f"max={rep['cyc_us_total']['max']} -> 占空 {round((rep['cyc_us_total']['p50'] or 0)/(pm*1000)*100,1)}%") - print(f"CPU: GD32 load_perm {rep['gd32_load_pct_p50']}% 主机 native 单核 {rep['host_cpu_1core_p50']}% " - f"/ 整机 {rep['host_cpu_total_p50']}%(逻辑核 {rep['logical_cpus']})") - print(f"wkc {rep['wkc']} wr={rep['wr_end']}") - km = rep["capacity_model"] - print(f"\n容量对照: cap={km['per_cycle_cap']} λ={km['lambda_per_s']:.0f} " - f"当前档排空 {km['drain_per_s_at_current_T']} 线性外推拐点 {km['knee_ms_if_linear']}ms" - f"(仅事后对照,拐点以扫描实测为准)") - dead = [k for k, v in rep["power"].items() if not v] - live = [k for k, v in rep["power"].items() if v] - print("有功效:", live or "无") - if dead: - print(f"无功效: {dead}") - print(" → 这些轴在报告里只能写「本次测不到」,**禁止**写成「无故障/已修好」") - - if a.json: - os.makedirs(os.path.dirname(os.path.abspath(a.json)), exist_ok=True) - with open(a.json, "w", encoding="utf-8") as f: - json.dump(rep, f, ensure_ascii=False, indent=2) - print(f"\n标定结果: {a.json}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/can_bridge_reconcile.py b/scripts/can_bridge_reconcile.py deleted file mode 100644 index 155ad1d..0000000 --- a/scripts/can_bridge_reconcile.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""逐层对账 CAN→ECAT:谁在丢帧、丢多少、哪一层丢的。 - -动机:面板上的 `drop`(段1/段3"最近一帧"单格被未取先覆的帧数)恒等于 λ − 实测Hz, -是个会自证的数,拿它说"丢帧"没有说服力。真丢帧要逐层各数一遍: - - L1 CAN 控制器 err/TEC/REC —— ESP32 /can/feedback - L2 CAN 软件队列 rx_drop_count(深度 16 满则丢) —— 只在 ESP32 侧有,过程数据里没有 - L3 ECAT 桥 bridge.rx_count(进桥帧数) - L4 段4 覆盖窗口 s_rxWinSeq 每入窗帧 +2 → Δmax_seq/2 是**精确**的入窗帧数 - L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(GD32 OVF) —— 单拍值, - 由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍) - -L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件 -ethercat_can_bridge.cpp:435),每节点 STATUS 率另用段2 槽 seq 步长反推 -(率 = λ/step,与轮询率无关)。 - -只读:全部走只读诊断路由,不发报、不改周期、不碰生命周期。 -用法: python scripts/can_bridge_reconcile.py [秒数=20] [esp32_ip=192.168.31.127] -""" -import json -import socket -import sys -import time -import urllib.request - -# 控制台默认 GBK:一个字符编不出来就整轮数据白采,所以先把输出流换成 UTF-8 -try: - sys.stdout.reconfigure(encoding="utf-8", errors="replace") -except Exception: - pass - -HOST = "127.0.0.1" -ECAT_PORTS = range(8090, 8096) -DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0 -ESP_IP = sys.argv[2] if len(sys.argv) > 2 else "192.168.31.127" - -# 段4 覆盖窗口(固件 1.6.15 定长布局):raw 从 PDO 偏移 364 起,5 槽 × 44B -WIN_SLOT_SIZE = 44 -WIN_SLOTS = 5 -W_DLC, W_FLAGS, W_ID, W_SEQ = 1, 2, 4, 8 - - -def post(path, port, timeout=6.0): - """裸 socket + 连接阶段不设 timeout:给 urllib 传 timeout 会撞上 Windows - 非阻塞 connect 的 ~10ms 调度粒度,把仪表自己变成扰动源。""" - body = b"{}" - req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{port}\r\n" - "Content-Type: application/json\r\n" - f"Content-Length: {len(body)}\r\n\r\n").encode("ascii") + body - s = socket.create_connection((HOST, port)) - try: - s.settimeout(timeout) - s.sendall(req) - buf = bytearray() - while True: - c = s.recv(65536) - if not c: - break - buf.extend(c) - finally: - s.close() - return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace")) - - -def find_port(): - for p in ECAT_PORTS: - try: - if post("/api/motor/state", p, 2.0).get("ok"): - return p - except Exception: - continue - return None - - -def wrap(a, b, bits): - return (b - a) % (1 << bits) - - -def le32(bs, off): - return int.from_bytes(bs[off:off + 4], "little") - - -def can_ch(ip): - """一次读全部 CAN 通道,返回 {通道号: 通道 dict};读不到返回 None(绝不填 0)。 - - 不能在这里挑"rx 最大的通道":ESP32 的 ID 快照是分页轮转的,开机初期某通道还没 - 轮到 valid 时 rx=0,两次采样各挑各的就会挑到不同通道,差值直接变成整个累计值 - (实测虚报成 3.0M 帧/s)。通道由调用方跨两次采样锁定。""" - try: - with urllib.request.urlopen(f"http://{ip}/can/feedback", timeout=3) as r: - d = json.loads(r.read().decode("utf-8")) - except Exception: - return None - chs = {c.get("ch"): c for c in (d.get("channels") or []) if c.get("valid")} - return chs or None - - -def can_pick(a, b): - """两次采样都 valid 且 rx 最大的那个通道 —— 保证差分是同一计数器的两端。""" - if not a or not b: - return None - common = [k for k in a if k in b] - if not common: - return None - return max(common, key=lambda k: max(a[k].get("rx") or 0, b[k].get("rx") or 0)) - - -def win_slots(raw_hex): - """原始字节 → [(seq, id, dlc)],只收 flags.bit0=1 且 seq 为偶数(整槽一致)的槽。""" - bs = bytes.fromhex(raw_hex) - out = [] - for i in range(WIN_SLOTS): - p = bs[i * WIN_SLOT_SIZE:(i + 1) * WIN_SLOT_SIZE] - if len(p) < W_ID + 4 or (p[W_FLAGS] & 1) == 0: - continue - sq = le32(p, W_SEQ) - if sq == 0 or sq & 1: - continue - out.append((sq, le32(p, W_ID), p[W_DLC])) - return out - - -def main(): - port = find_port() - if not port: - print("找不到在监听的 ECAT native(8090..8095)—— 主站没起,先连一次总线") - return 1 - print(f"native :{port} 窗口 {DUR_S:.0f}s ESP32 {ESP_IP}") - - seen_cycle = None - seq_first = seq_last = None - per_id = {} - lost_max = pend_max = cnt_max = online = 0 - n_state = n_raw = 0 - t0 = time.perf_counter() - c0 = can_ch(ESP_IP) - s0 = post("/api/motor/state", port) - while time.perf_counter() - t0 < DUR_S: - st = post("/api/motor/state", port) - n_state += 1 - rb = st.get("rx_batch") or {} - online = max(online, sum(1 for m in (st.get("motors") or []) if m.get("online"))) - # 每拍回填清零的单拍值,只能取抽样最大值当下界 - lost_max = max(lost_max, rb.get("lost") or 0) - pend_max = max(pend_max, rb.get("pending") or 0) - cnt_max = max(cnt_max, rb.get("count") or 0) - try: - rr = post("/api/motor/rxraw", port) - except Exception: - rr = None - if rr and rr.get("layout") != "win": - print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用") - return 2 - # 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节, - # 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""GD32 OVF"。只有第三个 - # 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。 - if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数 - seen_cycle = rr.get("cycle") - n_raw += 1 - for sq, _id, _dlc in win_slots(rr.get("raw") or ""): - seq_first = sq if seq_first is None else min(seq_first, sq) - seq_last = sq if seq_last is None else max(seq_last, sq) - per_id.setdefault(_id, set()).add(sq) - t1 = time.perf_counter() - time.sleep(0.05) # 约 20Hz:开环全速轮询会让仪表自己变成扰动源 - t1 = time.perf_counter() - c1 = can_ch(ESP_IP) - s1 = post("/api/motor/state", port) - dt = t1 - t0 - - hz = wrap(s0.get("cycles", 0), s1.get("cycles", 0), 64) / dt - lam = wrap((s0.get("head") or {}).get("global_seq", 0), - (s1.get("head") or {}).get("global_seq", 0), 32) / dt - bridge = wrap((s0.get("bridge") or {}).get("rx_count", 0), - (s1.get("bridge") or {}).get("rx_count", 0), 32) / dt - win_rate = ((seq_last - seq_first) / 2 / dt) if seq_last is not None else None - got = sum(len(v) for v in per_id.values()) / dt - drop_single = wrap((s0.get("bridge") or {}).get("drop", 0), - (s1.get("bridge") or {}).get("drop", 0), 16) / dt - - print(f"\n窗口 {dt:.2f}s 档位 {s1.get('period_ms')}ms PDO {hz:.0f}Hz " - f"轮询 state {n_state} 次 / rxraw 新拍 {n_raw} 次" - f"({n_raw / dt:.1f} 拍/s → 抽样比 {n_raw / max(1e-9, hz * dt):.1%})") - print("\n层 计数 帧/s 判读") - ck = can_pick(c0, c1) - can_rate = None - if ck is not None: - rx0 = c0[ck].get("rx") or 0 - dr0 = c0[ck].get("drop") or 0 - rx1 = c1[ck].get("rx") or 0 - dr1 = c1[ck].get("drop") or 0 - err, tec, rec, bus = (c1[ck].get(k) for k in ("err", "tec", "rec", "bus_state")) - can_rate = wrap(rx0, rx1, 32) / dt # rx / drop 都是 u32 - can_drop = wrap(dr0, dr1, 32) / dt - print(f"L1 CAN err/TEC/REC/bus —— {err}/{tec}/{rec}/{bus}" - f" {'干净' if not (err or tec or rec) else '有错'} (通道 {ck})") - print(f"L2 CAN 软件队列满丢 {can_drop:10.0f} " - f"{'0 = 队列(深度16)没满过' if not can_drop else '在丢帧,查从站取帧速率'}") - print(f"L3 CAN 控制器收帧 {can_rate:10.0f}") - else: - print("L1/L2/L3 ESP32 /can/feedback 不可读 —— 不填 0,这三层本轮无数据") - print(f"L3 ECAT桥 进桥 rx_count {bridge:10.0f}") - if can_rate is not None: - gap = can_rate - bridge - print(f" CAN - 桥 {gap:10.0f} " - f"{'≈0 两层同域' if abs(gap) < bridge * 0.01 else '差额:到了 CAN 但没进桥计数'}") - print(f"L4 段4窗 入窗帧 Δseq/2 {win_rate if win_rate else float('nan'):10.0f}" - f" 精确值(窗口 seq 是累计计数器,与抽样率无关)") - print(f" STATUS(=桥−入窗) {bridge - (win_rate or 0):10.0f} " - f"STATUS 在入窗前就 return,固件 ethercat_can_bridge.cpp:435") - print(f" 本脚本见到的唯一入窗帧 {got:10.0f} 下界:轮询只覆盖 {n_state / max(1e-9, hz * dt):.1%} 的拍") - print(f"L5 单拍值 主站空洞 lost 最大 {lost_max:10.0f} 每拍回填清零,只能当下界") - print(f" GD32 OVF pending 最大 {pend_max:10.0f} 同上") - print(f" 主站上报 count 最大 {cnt_max:10.0f} 单拍上报帧数(窗口 5 槽)") - print(f"-- 段1/段3 单格 drop {drop_single:10.0f} " - f"恒等于 λ−Hz = {lam - hz:.0f},不参与判丢帧") - - if online: - print(f"\n按节点拆(在线 {online} 个): STATUS ≈ {(bridge - (win_rate or 0)) / online:6.0f} 帧/s/节点," - f"入窗 ≈ {(win_rate or 0) / online:6.0f} 帧/s/节点") - if per_id: - print("\n入窗帧按 ID(唯一 seq 计数,下界):") - for _id in sorted(per_id): - n = len(per_id[_id]) - print(f" 0x{_id:08X}: {n / dt:8.0f} 帧/s 子码 0x{_id & 0xFFFF:04X}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/cpu_minimize_probe.ps1 b/scripts/cpu_minimize_probe.ps1 deleted file mode 100644 index 8147fdc..0000000 --- a/scripts/cpu_minimize_probe.ps1 +++ /dev/null @@ -1,59 +0,0 @@ -param([int]$Seconds = 8) -Add-Type @" -using System; -using System.Runtime.InteropServices; -public static class W { - [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); - [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); - [DllImport("user32.dll")] public static extern IntPtr FindWindow(string c, string t); -} -"@ -$SW_RESTORE = 9 -$SW_MINIMIZE = 6 -$win = $null -Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -match 'ESP32' } | ForEach-Object { - $win = $_; Write-Output ("found window: pid={0} title='{1}'" -f $_.Id, $_.MainWindowTitle) -} -if (-not $win) { Write-Output 'NO ESP32 WINDOW FOUND'; exit 1 } -$hwnd = $win.MainWindowHandle -Write-Output ("before: minimized=" + [W]::IsIconic($hwnd)) - -function Snap { - $h = @{} - foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match 'msedgewebview2|ecat_native|pythonw' })) { - try { $h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = [math]::Round($p.WorkingSet64/1MB,0) } } catch {} - } - return $h -} -function Measure-Phase($label, $dt) { - $a = Snap - Start-Sleep -Seconds $dt - $b = Snap - $rows = foreach ($k in $b.Keys) { - if ($a.ContainsKey($k)) { - $d = ($b[$k].cpu - $a[$k].cpu) - if ($d -gt 0) { - [pscustomobject]@{ Phase = $label; Pid = [int]$k; Name = $b[$k].name; Pct1Core = [math]::Round($d/$dt*100,1); WS_MB = $b[$k].ws } - } - } - } - return $rows -} - -$r1 = Measure-Phase 'visible' $Seconds -Write-Output '--- minimize ---' -[void][W]::ShowWindow($hwnd, $SW_MINIMIZE) -Start-Sleep -Seconds 2 -$r2 = Measure-Phase 'minimized' $Seconds -Write-Output '--- restore ---' -[void][W]::ShowWindow($hwnd, $SW_RESTORE) -Start-Sleep -Seconds 2 -$r3 = Measure-Phase 'restored' $Seconds - -$all = @() + $r1 + $r2 + $r3 -$out = $all | Sort-Object Phase, Pct1Core -Descending -$out | Format-Table -AutoSize | Out-String -Width 200 | Write-Output -foreach ($ph in @('visible','minimized','restored')) { - $s = ($out | Where-Object { $_.Phase -eq $ph } | Measure-Object -Property Pct1Core -Sum).Sum - Write-Output ("TOTAL {0,-10} = {1,7:N1} % of one core" -f $ph, $s) -} diff --git a/scripts/cpu_sample.ps1 b/scripts/cpu_sample.ps1 deleted file mode 100644 index 7bb8575..0000000 --- a/scripts/cpu_sample.ps1 +++ /dev/null @@ -1,31 +0,0 @@ -$ErrorActionPreference = 'Stop' -$cpus = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors -Write-Output "logical_cpus=$cpus" -$pat = 'msedgewebview2|ecat_native|pythonw|python' -function Snap { - $h = @{} - foreach ($p in (Get-Process | Where-Object { $_.ProcessName -match $pat })) { - $h[[string]$p.Id] = [pscustomobject]@{ name = $p.ProcessName; cpu = $p.CPU; ws = $p.WorkingSet64 } - } - return $h -} -$a = Snap -Start-Sleep -Seconds 8 -$b = Snap -$dt = 8 -$rows = foreach ($k in $b.Keys) { - if ($a.ContainsKey($k)) { - $d = ($b[$k].cpu - $a[$k].cpu) - if ($d -gt 0) { - [pscustomobject]@{ - pid = [int]$k - name = $b[$k].name - pct = [math]::Round($d / $dt * 100, 1) - wsMB = [math]::Round($b[$k].ws / 1MB, 0) - } - } - } -} -$rows | Where-Object { $_.pct -ge 0.3 } | Sort-Object pct -Descending | Format-Table -AutoSize | Out-String -Width 200 | Write-Output -$total = ($rows | Measure-Object -Property pct -Sum).Sum -Write-Output ("total_pct_of_one_core=" + [math]::Round($total,1)) diff --git a/scripts/ecat_sweep_common.py b/scripts/ecat_sweep_common.py deleted file mode 100644 index 960b921..0000000 --- a/scripts/ecat_sweep_common.py +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""ECAT native 主站的采样公共件:HTTP 调用 + 计数器差分。 - -固件 1.6.15 覆盖窗口布局下,各计数器的口径并不一致,混用会静默算错: - * bridge.rx_count / head.global_seq —— u32,**回绕** - * bridge.drop —— u16,**回绕**;段 1/段 3 单格"未取先覆"数,恒等于 λ − 实测Hz - * rx_batch.lost(主站按 seq 空洞算的单拍丢帧) / pending(从站窗口 OVF) —— - **每拍回填清零的单拍值**(ecat_native.c:461),不是累计计数:拿它做差得速率恒为 0, - 拿轮询见过的最大值当总丢帧也只是下界 - * app/load.rx_torn / pdo_reentry —— u16,**饱和**(到 65535 就停,不是回绕) -饱和量按回绕算会把"已封顶"误报成"发生了 6 万多次",故二者分开处理。 -""" -import json -import urllib.request - -SATURATE = 65535 # u16 饱和计数的顶 - - -def post(url, path, body=None, timeout=6.0): - req = urllib.request.Request( - url + path, data=json.dumps(body or {}).encode("utf-8"), - headers={"Content-Type": "application/json"}, method="POST") - with urllib.request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read().decode("utf-8")) - - -def delta_wrap(a, b, bits=16): - """回绕计数器的增量。真值下降(重启清零)会表现为一个巨大的正数, - 调用方须用 sanity 上界剔除 —— 这里只负责把模数算对。""" - m = 1 << bits - return (b - a) % m - - -def delta_saturate(a, b): - """饱和计数的增量。返回 (增量, 是否已封顶)。 - - 封顶后增量恒为 0 —— 读数 0 既可能是"真没发生"也可能是"早发生了但计不动", - 报告里必须写成下界而不是精确值。 - """ - if b >= SATURATE: - return 0, True - if b < a: - return 0, False # 计数器被回填清零:该窗口作废,不臆造增量 - return b - a, False - - -def percentile(values, q): - """小样本分位数(最近邻)。不引 numpy,采样窗口只有百来个点。""" - if not values: - return None - s = sorted(values) - i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1))))) - return s[i] diff --git a/scripts/flash_gd32.py b/scripts/flash_gd32.py deleted file mode 100644 index 9f45310..0000000 --- a/scripts/flash_gd32.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""GD32 固件经 ESP32 中继刷写(tools/flash_gd32.py 删除后的等价实现)。 - -流程(与 ESP32 侧 http_handlers.cpp 的 ota_upload_handler / -ota_relay_trigger_handler 契约一致): - 1. Release 的 Intel HEX 展平成从 0x08048000 起的连续 BIN(间隙填 0xFF); - 2. POST /ota/upload,请求体 = {"total_size":N} 紧跟 BIN 字节; - 3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做 - 包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒); - 4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是 - 期望版本;**并且必须拿到"GD32 真的在跑新代码"的证据**:版本号相对刷前变了, - 或 gd32_adc.uptime_sec 归零(复位过)。两者都没有 → 判"未生效",不写台账、 - 非零退出。(实测踩过:01:44 刷进 1.6.22 后板子又跑了 2 小时老镜像, - 布局/耗时全程不变,台账却已更新 —— 写入 ≠ 在跑。) - 5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。 - -用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0] -""" -import hashlib -import json -import os - -import struct -import sys -import time -import urllib.error -import urllib.request - -ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127") -APP_BASE = 0x08048000 -HERE = os.path.dirname(os.path.abspath(__file__)) -ROOT = os.path.abspath(os.path.join(HERE, "..", "..", "..")) -HEX = os.path.join(ROOT, "DCU_APP", "out", "GD32E508_DCU_APP", "GD32E508VE", - "Release", "GD32E508_DCU_APP.hex") -LEDGER = os.path.join(HERE, "..", "lab", "_last_gd32_sha.txt") - - -def hex_to_flat(path): - """Intel HEX → 从 APP_BASE 起的连续字节。间隙填 0xFF(擦除态)。""" - mem = {} - base = 0 - with open(path, "r", encoding="ascii", errors="ignore") as f: - for ln, line in enumerate(f, 1): - line = line.strip() - if not line.startswith(":"): - continue - raw = bytes.fromhex(line[1:]) - cnt, addr, rtype = raw[0], struct.unpack(">H", raw[1:3])[0], raw[3] - data = raw[4:4 + cnt] - if len(raw) != cnt + 5 or (sum(raw) & 0xFF) != 0: - raise SystemExit(f"{path}:{ln} 校验和不对") - if rtype == 0x00: - for i, b in enumerate(data): - mem[base + addr + i] = b - elif rtype == 0x02: - base = struct.unpack(">H", data[:2])[0] << 4 - elif rtype == 0x04: - base = struct.unpack(">H", data[:2])[0] << 16 - elif rtype == 0x01: - break - if not mem: - raise SystemExit("HEX 里没有数据记录") - lo, hi = min(mem), max(mem) - if lo > APP_BASE: - raise SystemExit(f"镜像起点 0x{lo:08X} 高于 APP 基址 0x{APP_BASE:08X},不是 APP 包") - if lo < APP_BASE: - raise SystemExit(f"镜像含 0x{lo:08X} 的地址,落在 APP 区之外(疑似 BOOT 包)") - out = bytearray(b"\xFF" * (hi - APP_BASE + 1)) - for a, b in mem.items(): - out[a - APP_BASE] = b - return bytes(out) - - -def post(path, body, timeout=60): - req = urllib.request.Request(f"http://{ESP}{path}", data=body, method="POST") - req.add_header("Content-Type", "application/octet-stream") - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - return r.status, r.read().decode("utf-8", "replace") - except urllib.error.HTTPError as e: - return e.code, e.read().decode("utf-8", "replace") - - -def get(path, timeout=10): - with urllib.request.urlopen(f"http://{ESP}{path}", timeout=timeout) as r: - return json.loads(r.read().decode("utf-8", "replace")) - - -def main(): - want = sys.argv[1] if len(sys.argv) > 1 else None - if not os.path.isfile(HEX): - raise SystemExit(f"找不到 {HEX}") - blob = hex_to_flat(HEX) - sha = hashlib.sha256(blob).hexdigest() - print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}") - - hdr = json.dumps({"total_size": len(blob)}).encode("ascii") - b_ver, b_up = "", None - try: - b = get("/system/status") - b_ver = str((b.get("ota") or {}).get("gd32_app") or "").lstrip("v") - b_up = (b.get("gd32_adc") or {}).get("uptime_sec") - except Exception as e: - print(f"刷前 /system/status 读失败({type(e).__name__}):生效判定只能靠 uptime") - print(f"刷前: gd32_app=v{b_ver or '?'} gd32 uptime={b_up}") - t0 = time.time() - st, body = post("/ota/upload", hdr + blob, timeout=120) - print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)") - if st != 200: - raise SystemExit("上传失败") - - t0 = time.time() - st, body = post("/ota/relay", json.dumps({"sha256": sha}).encode("ascii"), 60) - print(f"relay -> {st} {body[:300]} ({time.time()-t0:.1f}s)") - - deadline = time.time() + 90 - last = "" - while time.time() < deadline: - time.sleep(2) - try: - s = get("/system/status") - except Exception as e: - print(f" 等待中… status 读失败 {type(e).__name__}") - continue - o = s.get("ota", {}) - line = (f" sys_state={o.get('sys_state')} gd32_app={o.get('gd32_app')} " - f"alive={o.get('gd32_app_alive')} uptime={s.get('uptime_sec')}") - if line != last: - print(line) - last = line - if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"): - ver = str(o["gd32_app"]).lstrip("v") - a_up = (s.get("gd32_adc") or {}).get("uptime_sec") - if want and ver != want: - raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效") - changed = bool(b_ver) and ver != b_ver - rebooted = a_up is not None and (b_up is None or a_up < b_up) - if not changed and not rebooted: - raise SystemExit( - f"写入完成,但没有 GD32 换跑新代码的证据:板上仍是 v{ver}、" - f"uptime={a_up}(刷前 {b_up})—— 老镜像还在跑,台账不更新。" - "断电重启(或复位 APP)后重跑本脚本再确认") - with open(LEDGER, "w", encoding="ascii") as f: - f.write(sha) - print(f"完成: 板上 gd32_app=v{ver}(依据: " - f"{'版本 ' + b_ver + '→' + ver if changed else 'uptime 归零 ' + str(b_up) + '→' + str(a_up)}" - f"),台账已写 {os.path.normpath(LEDGER)}") - return - raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试") - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_ecat_nocan_vs_can.py b/scripts/plot_ecat_nocan_vs_can.py deleted file mode 100644 index 00b70fd..0000000 --- a/scripts/plot_ecat_nocan_vs_can.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""对比「带 CAN」与「拔掉 CAN」两轮扫描,分离每拍负担的组成。 - -用途:判断 ECAT 速度上限究竟由什么决定 —— 是每拍都要跑的过程数据交换 -(PDI_Isr / PDO 填充,与 CAN 无关),还是叠加在上面的 CAN 收发处理。 - -判读:两条 load 曲线的**垂直间距**就是 CAN 在每个周期档上贡献的负担。 -间距近似恒定 → CAN 负担与周期无关,上限由纯 ECAT 那条线单独决定; -间距随频率张开 → CAN 负担本身也按拍放大,提速会被 CAN 先拖垮。 - -用法: - python scripts/plot_ecat_nocan_vs_can.py \ - --nocan lab/ecat_rate_sweep_nocan.csv --withcan lab/ecat_rate_sweep_withcan.csv -""" -import argparse -import csv -import os - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib import rcParams - -rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"] -rcParams["axes.unicode_minus"] = False -rcParams["figure.dpi"] = 130 - - -def load(path): - out = {} - for r in csv.DictReader(open(path, encoding="utf-8")): - def num(k): - v = r.get(k, "") - try: - return float(v) if v not in ("", "None", None) else None - except ValueError: - return None - out[float(r["period_ms"])] = { - "load": num("gd32_load_pct"), "hz": num("measured_hz"), - "achieve": num("achieve_pct"), "skip": num("skip_per_s"), - "wkc_ok": r.get("wkc") == r.get("expected_wkc"), - "stale": num("gd32_load_stale")} - return out - - -def main(): - ap = argparse.ArgumentParser(description="带 CAN vs 无 CAN 每拍负担分离") - d = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ap.add_argument("--nocan", default=os.path.join(d, "lab", "ecat_rate_sweep_nocan.csv")) - ap.add_argument("--withcan", default=os.path.join(d, "lab", "ecat_rate_sweep_withcan.csv")) - ap.add_argument("--out", default=None) - a = ap.parse_args() - - no, wc = load(a.nocan), load(a.withcan) - common = sorted(set(no) & set(wc), reverse=True) - out = a.out or os.path.splitext(a.nocan)[0] + "_vs.png" - - fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13.0, 5.2)) - fig.suptitle("每拍负担分离:拔掉 CAN 之后,速度上限由谁决定", fontsize=13) - - # 只取两侧 wkc 都达预期的档位:从站停报后 load_perm 会保留**残值** - # (实测楔死后仍报 99.2%),留在图里会被当成真数据、把 Δ 趋势带歪。 - healthy = [t for t in common if no[t]["wkc_ok"] and wc[t]["wkc_ok"]] - dropped = [t for t in common if t not in healthy] - T = [t for t in healthy if no[t]["load"] is not None and wc[t]["load"] is not None] - if not T: - raise SystemExit("两文件没有可对比的公共档位(且均需有效负载读数)") - Hz = [1000.0 / t for t in T] - ax1.plot(Hz, [wc[t]["load"] for t in T], "o-", color="#d62728", lw=2, - label="带 CAN(电机在报 STATUS/CONFIG)") - ax1.plot(Hz, [no[t]["load"] for t in T], "s-", color="#1f77b4", lw=2, - label="拔掉全部 CAN 设备") - for t, h in zip(T, Hz): - ax1.annotate(f"Δ={wc[t]['load'] - no[t]['load']:.1f}pp", (h, no[t]["load"]), - textcoords="offset points", xytext=(0, -14), ha="center", fontsize=8) - ax1.axhline(100, ls=":", color="k", lw=1) - ax1.set_xscale("log"); ax1.set_ylim(0, 112) - ax1.set_xlabel("循环率 (Hz)"); ax1.set_ylabel("load_perm (%)") - ax1.set_title("从站每拍负担(load_perm 口径,非整机 CPU)") - ax1.legend(fontsize=8, loc="upper left"); ax1.grid(alpha=0.3) - - ax2.plot(Hz, [wc[t]["skip"] for t in T], "o-", color="#d62728", lw=2, label="带 CAN") - ax2.plot(Hz, [no[t]["skip"] for t in T], "s-", color="#1f77b4", lw=2, label="无 CAN") - ax2.set_xscale("log"); ax2.set_yscale("symlog", linthresh=1) - ax2.set_xlabel("循环率 (Hz)"); ax2.set_ylabel("skip_total (次/s)") - ax2.set_title("主站错拍率:CAN 有无对节拍精度的影响") - ax2.legend(fontsize=8, loc="upper left"); ax2.grid(alpha=0.3) - - fig.tight_layout(rect=(0, 0, 1, 0.94)) - fig.savefig(out, bbox_inches="tight") - print(f"对比图 -> {out}\n") - print(f"{'周期':>7} {'循环率':>8} {'带CAN':>8} {'无CAN':>8} {'Δpp':>7} {'带CAN skip':>11} {'无CAN skip':>11}") - for t in T: - print(f"{t:>6}ms {1000.0/t:>7.0f}Hz {wc[t]['load']:>7.1f}% {no[t]['load']:>7.1f}% " - f"{wc[t]['load']-no[t]['load']:>6.1f} {wc[t]['skip']:>10.2f} {no[t]['skip']:>10.2f}") - skipped = [t for t in common if t not in T] - if skipped: - print(f"\n排除的档位:{skipped}") - for t in skipped: - why = [] - if not no[t]["wkc_ok"]: - why.append("无CAN 侧 wkc 不达预期(从站已停报,load_perm 为残值)") - if not wc[t]["wkc_ok"]: - why.append("带CAN 侧 wkc 不达预期") - if no[t]["load"] is None or wc[t]["load"] is None: - why.append("缺有效负载读数") - print(f" {t}ms: " + "; ".join(why or ["未知"])) - - -if __name__ == "__main__": - main() diff --git a/scripts/plot_ecat_sweep.py b/scripts/plot_ecat_sweep.py deleted file mode 100644 index 2cb5e96..0000000 --- a/scripts/plot_ecat_sweep.py +++ /dev/null @@ -1,188 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""把 sweep_ecat_rate.py 的 CSV 画成「速度-故障率-CPU」四联图。 - -画法纪律(对应调研报告的判据要求): - * 先画实测点/折线,参考模型只在事后叠加,且线型与实测明显区分 —— 避免拿模型 - 冒充数据。容量参考线用的是**本次实测**的每拍入窗帧数,不是源码常量。 - * 无功效的轴(整段恒零)不画成"平坦的零线" —— 那会被读成"没问题"。改为在子图 - 里写明"本次测不到",并保留轴刻度供对比。 - * 横轴用频率(=1000/周期)而不是周期:物理上限在时间轴上是反比双曲线, - 在频率轴上是直线,读者不会看错方向。 -""" -import argparse -import csv -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib import rcParams - -rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"] -rcParams["axes.unicode_minus"] = False -rcParams["figure.dpi"] = 130 - -MX = MY = [] -DEFAULT_CSV = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "lab", "ecat_rate_sweep.csv") - - -def load(path): - rows = list(csv.DictReader(open(path, encoding="utf-8"))) - if not rows: - raise SystemExit(f"{path} 没有数据行") - for r in rows: - r["hz_set"] = 1000.0 / float(r["period_ms"]) - for k in ("measured_hz", "achieve_pct", "can_lambda_per_s", "drop_per_s", - "drop_pct_of_lambda", "skip_per_s", "torn_per_s", "reentry_per_s", - "gd32_load_pct", "host_cpu_1core_pct", "cyc_p50_us", "cyc_p90_us", - "cyc_duty_pct", "lambda_u16_crosscheck", "rxb_count_mode_per_cycle"): - v = r.get(k, "") # 旧 CSV 可能没有后加的列 - try: - r[k] = float(v) if v not in ("", "None", None) else None - except ValueError: - r[k] = None - # CPU / 丢帧曲线的真正自变量是**实测**循环率:快档未必设得到设定值, - # 拿设定周期当横轴会把点画到错的位置上。 - r["hz"] = r["measured_hz"] or r["hz_set"] - rows.sort(key=lambda r: r["hz"]) - return rows - - -def ys(rows, key): - """取该列的 y 值序列;x 一律用 xs()。col() 曾按 (y, x) 顺序返回,调用处按 - (x, y) 解包会把 y 当横轴画(实测把 CPU% 画成了 2–100Hz),故改名并各自独立。""" - return [r[key] for r in rows] - - -def xs(rows): - return [r["hz"] for r in rows] - - -def no_power(rows, key): - """该轴整段恒零 → 无检出功效,不能画成"平坦的零线"。""" - vals = [r[key] for r in rows if r[key] is not None] - return (not vals) or max(vals) == 0 - - -def annotate_flags(ax, rows): - """把采样作废/劫持/PID 易主这些不可信标记写在图边上,避免读者信了坏数据。""" - bad = [] - for r in rows: - if r.get("hijacked") not in ("no", None): - bad.append(f"{r['period_ms']}ms 周期被劫持") - if r.get("pid_stable") != "yes": - bad.append(f"{r['period_ms']}ms native PID 易主") - if int(r.get("drop_untrusted") or 0) > 0: - bad.append(f"{r['period_ms']}ms drop 有 {r['drop_untrusted']} 点绕圈作废") - if bad: - ax.set_xlabel(ax.get_xlabel() + " ⚠ " + "; ".join(bad[:3]), color="darkred") - - -def main(): - ap = argparse.ArgumentParser(description="画 ECAT 速度-故障率-CPU 曲线") - ap.add_argument("--csv", default=DEFAULT_CSV) - ap.add_argument("--out", default=None, help="输出 PNG(默认与 CSV 同名 .png)") - a = ap.parse_args() - - rows = load(a.csv) - out = a.out or os.path.splitext(a.csv)[0] + ".png" - # 电机槽 seq 前进速率:扫描后另用 8s/档定点实测(判据见报告"方法"节)。 - # 单独测是因为它要看的是 u32 槽 seq 的秒增量,与扫描的 60s 平均口径不同源。 - global MX, MY - MX = [1000.0, 1999.0, 3989.0, 7762.0] - MY = [4000.2, 4000.2, 87.9, 147.5] - fig, axes = plt.subplots(2, 2, figsize=(13.5, 9.2)) - fig.suptitle("EtherCAT 周期速度 × 故障率 × CPU 占用(实测)", fontsize=13, y=0.985) - - # ---- 1 速度达成:设定 vs 实测 ---- - ax = axes[0][0] - xset = [r["hz_set"] for r in rows] - ax.plot([min(xset), max(xset)], [min(xset), max(xset)], "k--", lw=1, - label="理想 1:1(设定率)") - ax.plot(xset, [r["measured_hz"] for r in rows], "o-", color="#1f77b4", label="实测达成") - ax.set_xscale("log"); ax.set_yscale("log") - ax.set_xlabel("设定循环率 (Hz)"); ax.set_ylabel("实测循环率 (Hz)") - ax.set_title("① 循环率:设定 vs 实测(主站侧)"); ax.legend(fontsize=8, loc="upper left") - ax.grid(alpha=0.3, which="both") - txt = "达成率 " + "\n".join( - f"{r['period_ms']}ms: {r['achieve_pct']:.1f}%" for r in rows if r["achieve_pct"]) - ax.annotate(txt, (0.98, 0.5), xycoords="axes fraction", fontsize=7.5, - ha="right", va="center") - - # ---- 2 CPU 占用(两路:从站负载 vs 主机自旋)---- - ax = axes[0][1] - X = xs(rows) - gy, hy = ys(rows, "gd32_load_pct"), ys(rows, "host_cpu_1core_pct") - ax.plot(X, gy, "s-", color="#d62728", lw=2, label="GD32 负载 load_perm") - ax.plot(X, hy, "^-", color="#7f7f7f", lw=1.4, label="主机 native(自旋,单核%)") - ax.axhline(95, ls=":", color="darkred", lw=1) - ax.annotate("95% 逼近饱和", (1.02, 95), fontsize=7.5, color="darkred", va="bottom") - ax.set_xscale("log"); ax.set_ylim(0, 112) - ax.set_xlabel("循环率 (Hz)"); ax.set_ylabel("CPU 占用 (%)") - ax.set_title("② CPU 占用:GD32 与主机 native 两路") - ax.legend(fontsize=8, loc="center left"); ax.grid(alpha=0.3) - sat = [r for r in rows if r["gd32_load_pct"] is not None and r["gd32_load_pct"] >= 95] - if sat: - ax.annotate(f"≥95% 的档位: {', '.join(str(r['period_ms'])+'ms' for r in sat)}", - (0.02, 0.04), xycoords="axes fraction", fontsize=7.5, color="darkred") - - # ---- 3 故障率主曲线:功能是否还活着(电机数据刷新率)vs 从站负载 ---- - # 横轴用对数;λ 与 drop 两条线**刻意画在一起**,用来展示"加速对丢帧无改善" - ax = axes[1][0] - ax.plot(X, ys(rows, "can_lambda_per_s"), "o-", color="#2ca02c", lw=2, - label="λ CAN 到帧(u32 gseq)") - ax.plot(X, ys(rows, "drop_per_s"), "v--", color="#e377c2", lw=2, - label="drop 队列满丢帧(与 λ 重合)") - ax.plot(MX, MY, "^-", color="#d62728", lw=2.2, - label="电机数据刷新率(真故障轴)") - ax.axvspan(2000, 4000, color="red", alpha=0.07) - ax.annotate("GD32 饱和 → 功能塌陷", (2550, 40), fontsize=8, color="darkred") - ax.set_xscale("log"); ax.set_yscale("log") # 帧/s 恒为正,用 symlog 会把轴拖到负值 - ax.set_ylim(10, 20000) - ax.set_xlabel("实测循环率 (Hz)"); ax.set_ylabel("帧/s") - ax.set_title("③ 故障率:加速对丢帧毫无改善,且 >2kHz 直接打断功能") - ax.grid(alpha=0.3, which="both") - ax.legend(fontsize=7.5, loc="lower left") - - # ---- 4 错拍率 + 撕裂/重入功效声明 ---- - ax = axes[1][1] - ax.plot(X, ys(rows, "skip_per_s"), "D-", color="#ff7f0e", lw=2, - label="主站错拍 skip_total(次/s)") - ax.set_xscale("log"); ax.set_yscale("symlog", linthresh=1) # skip 有真 0 档,需 symlog - ax.set_xlabel("循环率 (Hz)"); ax.set_ylabel("次/s") - ax.set_ylim(-10, None) - ax.set_title("④ 主站错拍率,及撕裂/重入轴功效声明"); ax.legend(fontsize=8, loc="upper left") - ax.grid(alpha=0.3) - dead = [n for n, k in (("rx_torn", "torn_per_s"), ("pdo_reentry", "reentry_per_s")) - if no_power(rows, k)] - if dead: - ax.annotate("恒零轴(无检出功效):" + ", ".join(dead) + - "\n→ 本次测不到 ≠ 无故障,不得据此判\"已修好\"\n" - " (且固件版本无法与在盘产物对账)", - (0.03, 0.06), xycoords="axes fraction", fontsize=8, - color="darkred", bbox=dict(fc="#fff3f3", ec="darkred", alpha=0.9)) - annotate_flags(ax, rows) - - fig.tight_layout(rect=(0, 0, 1, 0.975)) - fig.savefig(out, bbox_inches="tight") - print(f"曲线图 -> {out}") - for r in rows: - def fmt(v, w=7, p=0, unit=""): - return f"{v:>{w}.{p}f}{unit}" if v is not None else f"{'n/a':>{w}}" - ld = r["gd32_load_pct"] - extra = (f"{ld:.1f}%" if ld is not None else "n/a") - if r.get("gd32_load_stale") is not None: - extra += f"(样{r.get('gd32_load_samples')}/废{r['gd32_load_stale']})" - print(f" {r['period_ms']:>6}ms {fmt(r['hz']):>7}Hz 实测{fmt(r['measured_hz'])}Hz " - f"GD32={extra:<18} λ={fmt(r['can_lambda_per_s'])}/s " - f"drop={fmt(r['drop_per_s'])}/s skip={fmt(r['skip_per_s'], 6, 1)}/s " - f"在线={r.get('online_frac', 'n/a')}") - if dead: - print(f" 无功效轴: {dead} —— 报告按「测不到」写") - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/rec_ecat_slots.py b/scripts/rec_ecat_slots.py deleted file mode 100644 index 0d6fe03..0000000 --- a/scripts/rec_ecat_slots.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""把 ECAT 侧收到的每一拍信号全量落盘,供事后分析(不在线判优劣)。 - -去重规则只有一条:同一 (node,seq) 或同一 (id,data) 帧只在第一次出现时写一行 —— -重复采样不携带新信息,去掉后可以把时长拉长。其余一律保留。 - -行格式(CSV,第 1 列是相对起始的秒): - M,t,node,ch,seq,online,dlc,hex36 段2 电机槽(STATUS 前 36B,含 cpu%@31/init@32-35) - F,t,kind,ch,dlc,id,hex36 段3 xrx / 段4 rx_batch 的帧 - H,t,cycles,wkc,gseq,win_base,win_count,active,rx,tx,drop 每 20 拍一条头部与桥计数 - -用法: python scripts/rec_ecat_slots.py [秒数=120] [端口=8090] [输出文件] -""" -import csv -import json -import socket -import struct -import sys -import time - -HOST = "127.0.0.1" -DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 120.0 -PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8090 -OUT = sys.argv[3] if len(sys.argv) > 3 else "ecat_slots_raw.csv" - -REQ = (f"POST /api/motor/state HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n" - "Content-Type: application/json\r\nContent-Length: 2\r\n" - "Connection: close\r\n\r\n{}").encode("ascii") - - -def once(): - s = socket.create_connection((HOST, PORT)) - try: - s.sendall(REQ) - buf = bytearray() - while True: - c = s.recv(65536) - if not c: - break - buf.extend(c) - finally: - s.close() - _, _, d = bytes(buf).partition(b"\r\n\r\n") - return json.loads(d.decode("utf-8", "replace")) - - -def main(): - seen_m, seen_f = set(), set() - polls = rows = 0 - t0 = time.time() - with open(OUT, "w", newline="", encoding="ascii") as f: - w = csv.writer(f) - w.writerow(["#kind", "t", "a", "b", "c", "d", "e", "f"]) - while True: - dt = time.time() - t0 - if dt >= DUR_S: - break - try: - snap = once() - except Exception: - continue - polls += 1 - h = snap.get("head") or {} - b = snap.get("bridge") or {} - if polls % 20 == 1: - w.writerow(["H", f"{dt:.6f}", snap.get("cycles"), snap.get("wkc"), - h.get("global_seq"), h.get("win_base"), h.get("win_count"), - h.get("active"), b.get("rx_count"), b.get("tx_count"), - b.get("drop")]) - for m in snap.get("motors") or []: - if not m.get("data"): - continue - key = (m["node"], m["ch"], m["seq"]) - if key in seen_m: - continue - seen_m.add(key) - if len(seen_m) > 400000: # 去重集合本身的内存上限 - seen_m = set(list(seen_m)[-200000:]) - d = bytes.fromhex(m["data"])[:36] - w.writerow(["M", f"{dt:.6f}", m["node"], m["ch"], m["seq"], - int(bool(m.get("online"))), m.get("dlc"), d.hex()]) - rows += 1 - def emit_frames(fr, kind): - if not fr or not fr.get("data"): - return - d = bytes.fromhex(fr["data"])[:36] - key = (fr.get("id"), d.hex()) - if key in seen_f: - return - seen_f.add(key) - w.writerow([kind, f"{dt:.6f}", fr.get("ch"), fr.get("dlc"), - fr.get("id"), "", "", d.hex()]) - - emit_frames(snap.get("xrx"), "X") - for x in ((snap.get("rx_batch") or {}).get("frames") or []): - emit_frames(x, "B") - if len(seen_f) > 400000: - seen_f = set(list(seen_f)[-200000:]) - print(f"落盘 {OUT}: 轮询 {polls} 次 / {DUR_S:.0f}s,写入 {rows} 行 " - f"(电机槽 {len(seen_m)} 帧, 接收帧 {len(seen_f)})") - - -if __name__ == "__main__": - main() diff --git a/scripts/repro_cmdload.py b/scripts/repro_cmdload.py deleted file mode 100644 index dc96a64..0000000 --- a/scripts/repro_cmdload.py +++ /dev/null @@ -1,251 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""复现实验:高频回发 CMD(与电机当前目标一致的 no-op)+ 全拍监控。 - -用户反馈:数据错乱要"频繁发指令"才出现。本脚本把 TX 压力拉起来,同时 -段2(全拍)与段4(全拍)双路监控,命中即记录 dlc/完整48B/当时CONFIG/时刻, -用于判定残余拼接的方向与机制。 - -安全性:CMD 载荷 = 电机当前自报 CONFIG 的回声(mode/limitV/cur/vel/ang) -+ 页面默认 PID(100/100/100),不改变电机行为。 - -用法: python scripts/repro_cmdload.py [秒数=90] [发送Hz=500] -""" -import json -import math -import socket -import struct -import sys -import threading -import time - -HOST, PORT = "127.0.0.1", 8090 -DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 90.0 -SEND_HZ = float(sys.argv[2]) if len(sys.argv) > 2 else 500.0 - - -def post(path, body=b"{}"): - req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{PORT}\r\n" - f"Content-Type: application/json\r\nContent-Length: {len(body)}\r\n" - "Connection: close\r\n\r\n").encode("ascii") + body - s = socket.create_connection((HOST, PORT)) - try: - s.settimeout(5) - s.sendall(req) - buf = bytearray() - while True: - c = s.recv(65536) - if not c: - break - buf.extend(c) - finally: - s.close() - return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace")) - - -def build_cmd(cfg, ang, vel): - """CMD 帧:mode3 速度+位置,limitV 回声当前 CONFIG,角度=正弦轨迹。 - vel 按节点取 200/150 —— 给 CONFIG 回显的 B7-10 速度字段打节点指纹。""" - b = bytearray(32) - b[0] = 3 # mode: Velocity_Angle - b[1:3] = cfg[1:3] # limitV u16 回声 - b[3:7] = struct.pack(" 最近 4 个已接受 CONFIG hex(页面过滤器模拟) -cfg_susp = {} # node -> 连续疑似计数 -torn_base = [0] - - -def sender(): - """每拍把两电机当前 CONFIG 回声成 CMD 发出去。""" - cfgs = {} - interval = 1.0 / SEND_HZ - while not stop.is_set(): - t0 = time.time() - try: - rr = post("/api/motor/rxraw") - raw = bytes.fromhex(rr.get("raw") or "") - off = 0 - for _ in range(rr.get("count") or 0): - if off + 8 > len(raw): - break - dlc = raw[off + 1] - fid = struct.unpack_from("> 16) & 0xFF] = raw[off + 8: off + 8 + w] - off += 8 + w - except Exception: - time.sleep(0.05) - continue - if len(cfgs) >= 2 or time.time() - stats["t0"] > 3: - # 电机偶尔暂停 CONFIG 自报(如正弦轰炸后):拿不到回声就用页面默认 - # 限压 24.0V(u16 LE 0x5DC0 → bytes c0 5d)兜底,保证指令压力不断。 - fallback = bytes([0x03, 0xc0, 0x5d]) + bytes(21) - s = math.sin(2.0 * math.pi * (time.time() - stats["t0"]) / 10.0) - frames = [] - for n in (2, 3): - c = cfgs.get(n, fallback) - ang = 1000.0 + (200.0 * s if n == 2 else -200.0 * s) - vel = 200.0 if n == 2 else 150.0 - frames.append({"ch": 2, "id": hex((n << 16) | 0x0200), "dlc": 32, - "data": build_cmd(c, ang, vel)}) - try: - post("/api/motor/send", json.dumps({"frames": frames}).encode()) - with lock: - stats["sends"] += 1 - except Exception: - with lock: - stats["send_err"] += 1 - dt = interval - (time.time() - t0) - if dt > 0: - time.sleep(dt) - - -def is_cfg_head(d, cfg): - """段2槽头 8B 是否等于该节点当前 CONFIG 头(污染判据,比量程更准)。""" - return cfg and len(d) >= 8 and d[:8] == cfg[:8] and d[0] == 0x03 - - -def monitor(): - seen_s, seen_r = set(), set() - cfgs = {} - while not stop.is_set(): - try: - st = post("/api/motor/state") - except Exception: - time.sleep(0.01) - continue - cyc = st.get("cycles") - if cyc in seen_s: - continue - seen_s.add(cyc) - if len(seen_s) > 400000: - seen_s.clear() - for m in st.get("motors") or []: - if not m.get("online") or len(m.get("data") or "") < 96: - continue - d = bytes.fromhex(m["data"]) - mcu = struct.unpack_from(" 400000: - seen_r.clear() - raw = bytes.fromhex(rr.get("raw") or "") - off = 0 - for _ in range(rr.get("count") or 0): - if off + 8 > len(raw): - break - dlc = raw[off + 1] - fid = struct.unpack_from("> 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 - - -def main(): - try: - r = post("/api/app/load") - torn_base[0] = r.get("rx_torn") or 0 - except Exception: - pass - ts = threading.Thread(target=sender, daemon=True) - tm = threading.Thread(target=monitor, daemon=True) - ts.start(); tm.start() - time.sleep(DUR_S) - stop.set(); ts.join(3); tm.join(3) - try: - r = post("/api/app/load") - torn_now = r.get("rx_torn") or 0 - except Exception: - torn_now = -1 - with lock: - print(f"时长 {DUR_S:.0f}s 发送 {stats['sends']} 次(错{stats['send_err']}) " - f"≈{stats['sends']/DUR_S:.0f} CMD拍/s") - print(f"rx_torn: {torn_base[0]} -> {torn_now} (Δ={torn_now-torn_base[0]})") - print(f"段2 污染事件: {len(torn_events)}(前 8)") - for e in torn_events[:8]: - print(f" t={e[0]}s node={e[1]} dlc={e[2]}") - print(f" slot: {e[3]}") - print(f" cfg : {e[4]}") - print(f"段4 污染事件: {len(batch_events)}(前 8)") - for e in batch_events[:8]: - print(f" t={e[0]}s node={e[1]} data={e[2]}") - print(f"CONFIG↔CONFIG 指纹(预热后): 违规={fp_after[0]+fp_dropped[0]} " - f"过滤器正确丢弃={fp_dropped[0]} 过滤后残余={fp_after[0]} " - f"误杀真帧={fp_falsekill[0]}(样本 {len(fp_events)})") - print(f"监控消费的 CONFIG 帧总数: {cfg_total[0]}(活性自证:>0 才说明违规=0 是真结果)") - for e in fp_events[:8]: - print(f" t={e[0]}s node={e[1]} vel={e[2]:.1f} 应为{e[3]:.0f} data={e[4]}") - - -if __name__ == "__main__": - main() diff --git a/scripts/skip_bind_experiment.py b/scripts/skip_bind_experiment.py deleted file mode 100644 index 17298d7..0000000 --- a/scripts/skip_bind_experiment.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""在「以太网 2」(I210) 上关闭非 EtherCAT 绑定,测跳拍率;可 --restore 还原。""" -from __future__ import annotations - -import json -import os -import subprocess -import sys -import time - -ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -LAB = os.path.join(ROOT, "lab", "skip_meter") -NIC = "以太网 2" - - -def ps(cmd, timeout=60): - r = subprocess.run( - ["powershell", "-NoProfile", "-Command", cmd], - capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, - ) - return (r.stdout or "").strip(), (r.stderr or "").strip() - - -def snapshot(): - out, err = ps( - f"Get-NetAdapterBinding -Name '{NIC}' | " - "Select-Object DisplayName,ComponentID,Enabled | ConvertTo-Json -Compress" - ) - data = json.loads(out) - return data if isinstance(data, list) else [data] - - -def disable(component_ids): - logs = [] - for cid in component_ids: - out, err = ps( - f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; " - f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | " - "Select-Object ComponentID,Enabled | ConvertTo-Json -Compress" - ) - logs.append({"cid": cid, "out": out[:300], "err": err[:300]}) - return logs - - -def enable(component_ids): - logs = [] - for cid in component_ids: - out, err = ps( - f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false; " - f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | " - "Select-Object ComponentID,Enabled | ConvertTo-Json -Compress" - ) - logs.append({"cid": cid, "out": out[:300], "err": err[:300]}) - return logs - - -def set_prio(level="RealTime"): - p = int(subprocess.check_output( - ["powershell", "-NoProfile", "-Command", "(Get-Process ecat_native).Id"], - text=True).strip()) - subprocess.check_output( - ["powershell", "-NoProfile", "-Command", - f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], - text=True) - return p - - -def meter(tag, secs=18): - r = subprocess.run( - [sys.executable, os.path.join(ROOT, "scripts", "skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted(f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json")) - return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) - - -# Offloads that are useless/dangerous for raw EtherCAT on a dedicated NIC -OFFLOADS = [ - "vmware_bridge", # VMware Bridge — intercepts/injects, classic ECAT killer - "ms_pacer", # QoS packet scheduler - "ms_lldp", # LLDP - "ms_rspndr", # LLTD responder - "ms_lltdio", # LLTD mapper - "ms_server", # file/printer sharing - "ms_msclient", # Microsoft network client - # keep ms_tcpip / ms_tcpip6 for now in case anything uses IP on this NIC - # Npcap must stay: INSECURE_NPCAP -] - - -def main(): - os.makedirs(LAB, exist_ok=True) - snap = snapshot() - snap_path = os.path.join(LAB, f"nic_bind_{int(time.time())}.json") - json.dump(snap, open(snap_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - print("bind snapshot", snap_path) - - if "--restore" in sys.argv: - print(enable(OFFLOADS)) - return 0 - - print("disable", OFFLOADS) - print(json.dumps(disable(OFFLOADS), ensure_ascii=False, indent=2)) - print("set RealTime") - print("pid", set_prio("RealTime")) - # binding change usually doesn't reset link; brief settle - time.sleep(3) - data = meter("bind_off", 18) - row = { - "late_per_s": data["late_per_s"], - "skip_per_s": data["skip_per_s"], - "late_frac_pct": data["late_frac_pct"], - "achieved_hz": data["achieved_hz"], - "n_late": data["n_late"], - "send_stall_frac": data["send_stall_frac"], - "rx_stall_frac": data["rx_stall_frac"], - "work_p50": data["window_cyc_us"]["work"]["p50"], - "rx_p50": data["window_cyc_us"]["rx"]["p50"], - "send_p50": data["window_cyc_us"]["send"]["p50"], - "work_p90": data["window_cyc_us"]["work"]["p90"], - } - print("ROW", json.dumps(row, ensure_ascii=False, indent=2)) - out = os.path.join(LAB, f"summary_bind_off_{int(time.time())}.json") - json.dump({"row": row, "disabled": OFFLOADS, "snap": snap_path}, - open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2) - print("WROTE", out) - - -if __name__ == "__main__": - main() diff --git a/scripts/skip_bind_noip.py b/scripts/skip_bind_noip.py deleted file mode 100644 index 0d6cc51..0000000 --- a/scripts/skip_bind_noip.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python3 -import json, os, subprocess, sys, time -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") -NIC = "以太网 2" - -def ps(cmd): - r = subprocess.check_output(["powershell","-NoProfile","-Command",cmd], - text=True, encoding="utf-8", errors="replace") - return r.strip() - -def set_prio(level="RealTime"): - p = int(ps("(Get-Process ecat_native).Id")) - ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass") - return p - -def meter(tag, secs=18): - subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")) - return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) - -def main(): - restore = "--restore" in sys.argv - ids = ["ms_tcpip", "ms_tcpip6"] - for cid in ids: - if restore: - ps(f"Enable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false") - else: - ps(f"Disable-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' -Confirm:$false") - print(ps(f"Get-NetAdapterBinding -Name '{NIC}' -ComponentID '{cid}' | Select-Object ComponentID,Enabled | ConvertTo-Json -Compress")) - if restore: - return - print("pid", set_prio("RealTime")) - time.sleep(3) - data = meter("bind_noip", 18) - row = { - "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"], - "late_frac_pct": data["late_frac_pct"], "achieved_hz": data["achieved_hz"], - "n_late": data["n_late"], - "send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"], - "work_p50": data["window_cyc_us"]["work"]["p50"], - "rx_p50": data["window_cyc_us"]["rx"]["p50"], - "send_p50": data["window_cyc_us"]["send"]["p50"], - } - print(json.dumps(row, ensure_ascii=False, indent=2)) - out = os.path.join(LAB, f"summary_bind_noip_{int(time.time())}.json") - json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2) - print("WROTE", out) - -if __name__ == "__main__": - main() diff --git a/scripts/skip_fe_ab.py b/scripts/skip_fe_ab.py deleted file mode 100644 index 6150ac9..0000000 --- a/scripts/skip_fe_ab.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""0.25ms 下 A/B:静默 vs 模拟 spi_test 前端轮询。""" -import json, time, urllib.request, os - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") - -def post(p, b=None, timeout=5): - d = json.dumps(b or {}).encode() - r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"}) - with urllib.request.urlopen(r, timeout=timeout) as resp: - return json.loads(resp.read().decode()) - -def snap(): - st = post("/api/motor/state") - dbg = post("/api/motor/dbg") - return { - "cycles": st.get("cycles") or 0, - "skip_total": dbg.get("skip_total") or 0, - "gseq": (st.get("head") or {}).get("global_seq") or 0, - "period": st.get("period_ms"), - "wkc": st.get("wkc"), - "pcore": st.get("pcore_mask"), - "cyc_us": st.get("cyc_us"), - } - -def rate(a, b, dt): - dc = b["cycles"] - a["cycles"] - ds = (b["skip_total"] - a["skip_total"]) # may wrap? long long, no - return { - "dt": round(dt, 3), - "pdo_hz": round(dc / dt, 1) if dt else None, - "late_per_s": round(ds / dt, 2) if dt else None, - "skip_frac": round(100.0 * ds / dc, 2) if dc else None, - "gseq_per_s": round(((b["gseq"] - a["gseq"]) & 0xFFFFFFFF) / dt, 1) if dt else None, - "dcyc": dc, "dskip": ds, - } - -def arm_period(ms): - try: - post("/api/motor/stop") - except Exception: - pass - time.sleep(0.15) - post("/api/motor/period", {"period_ms": ms}) - time.sleep(0.15) - post("/api/motor/start") - time.sleep(0.5) - -print("ensure 0.25ms") -arm_period(0.25) -time.sleep(3) - -# --- A: fully quiet --- -a0 = snap() -t0 = time.time() -# no HTTP -time.sleep(10) -a1 = snap() -A = rate(a0, a1, time.time() - t0) -print("QUIET", json.dumps(A, ensure_ascii=False)) -print(" cyc_us", json.dumps(a1.get("cyc_us"), ensure_ascii=False)) - -# --- B: hammer like spi_test (state+load+dbg+rxraw) --- -time.sleep(1) -b0 = snap() -t0 = time.time() -n = 0 -while time.time() - t0 < 10: - try: - post("/api/motor/state") - post("/api/app/load") - post("/api/motor/dbg") - post("/api/motor/rxraw") - n += 1 - except Exception as e: - print("hammer err", e) - break - time.sleep(0.05) # ~20Hz full set, similar to busy panel -b1 = snap() -B = rate(b0, b1, time.time() - t0) -print("HAMMER", json.dumps(B, ensure_ascii=False), "polls", n) -print(" cyc_us", json.dumps(b1.get("cyc_us"), ensure_ascii=False)) - -# --- C: only motor/state at max (no dbg/rxraw) --- -time.sleep(1) -c0 = snap() -t0 = time.time() -n = 0 -while time.time() - t0 < 10: - try: - post("/api/motor/state") - n += 1 - except Exception: - break - time.sleep(0.05) -c1 = snap() -C = rate(c0, c1, time.time() - t0) -print("STATE_ONLY", json.dumps(C, ensure_ascii=False), "polls", n) - -out = {"quiet": A, "hammer": B, "state_only": C, - "quiet_cyc": a1.get("cyc_us"), "hammer_cyc": b1.get("cyc_us")} -path = os.path.join(LAB, f"summary_fe_ab_{int(time.time())}.json") -json.dump(out, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=2) -print("WROTE", path) diff --git a/scripts/skip_host_snapshot.py b/scripts/skip_host_snapshot.py deleted file mode 100644 index fa71e87..0000000 --- a/scripts/skip_host_snapshot.py +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""快照当前主机侧可回滚现场:I210 高级属性、中断亲和、ecat 进程。""" -from __future__ import annotations - -import json -import os -import subprocess -import sys -import time - -ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -LAB = os.path.join(ROOT, "lab", "skip_meter") - - -def ps(cmd: str) -> str: - r = subprocess.run( - ["powershell", "-NoProfile", "-Command", cmd], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - ) - return (r.stdout or "") + (("ERR:" + r.stderr) if r.stderr else "") - - -def main() -> int: - os.makedirs(LAB, exist_ok=True) - snap = {"ts": time.time(), "host": os.environ.get("COMPUTERNAME")} - - snap["adapters"] = ps( - "Get-NetAdapter | Select-Object Name,InterfaceDescription,LinkSpeed,Status," - "ifIndex,MacAddress | ConvertTo-Json -Compress" - ) - # I210 specifically - snap["i210"] = ps( - "Get-NetAdapter | Where-Object {$_.InterfaceDescription -match 'I210'} | " - "Select-Object Name,ifIndex,Status,LinkSpeed | ConvertTo-Json -Compress" - ) - - # advanced properties (restore-able) - snap["i210_adv"] = ps( - "Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | " - "Select-Object Name,DisplayName,DisplayValue,RegistryKeyword,RegistryValue | " - "ConvertTo-Json -Compress" - ) - - # interrupt moderation / RSS / power - for kw in ( - "*InterruptModeration", - "*ReceiveBuffers", - "*TransmitBuffers", - "*EEE", - "*EnergyEfficient", - "*FlowControl", - "*PriorityVLANTag", - "*TCPChecksumOffload*", - "*IPChecksumOffload*", - "*VMQ", - "*RSS", - "*InterruptModerationRate", - ): - snap[f"adv_{kw.strip('*').replace('*','_')}"] = ps( - f"Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | " - f"Where-Object {{$_.RegistryKeyword -like '{kw}'}} | " - f"Select-Object RegistryKeyword,DisplayValue,RegistryValue | ConvertTo-Json -Compress" - ) - - snap["power"] = ps( - "Get-NetAdapterPowerManagement -Name '以太网 2' -ErrorAction SilentlyContinue | " - "Select-Object Name,AllowComputerToTurnOffDevice,WakeOnMagicPacket," - "WakeOnPattern,DeviceSleepOnDisconnect | ConvertTo-Json -Compress" - ) - - snap["ecat_proc"] = ps( - "Get-Process ecat_native -ErrorAction SilentlyContinue | " - "Select-Object Id,Path,StartTime,CPU,WorkingSet | ConvertTo-Json -Compress" - ) - - snap["ecat_cmdline"] = ps( - "Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | " - "Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress" - ) - - # interrupt affinity via Get-NetAdapterInterrupt? not always available - snap["msi_affinity"] = ps( - "Get-NetAdapterAdvancedProperty -Name '以太网 2' -ErrorAction SilentlyContinue | " - "Where-Object {$_.DisplayName -match 'Interrupt|Affinity|RSS|Moderation|Buffer|Energy|Green|Flow|Offload'} | " - "Select-Object DisplayName,DisplayValue,RegistryKeyword | ConvertTo-Json -Compress" - ) - - # CPU topology quick - snap["cpu"] = ps( - "$env:NUMBER_OF_PROCESSORS; (Get-CimInstance Win32_Processor | " - "Select-Object Name,NumberOfCores,NumberOfLogicalProcessors | ConvertTo-Json -Compress)" - ) - - out = os.path.join(LAB, f"host_snap_{int(time.time())}.json") - with open(out, "w", encoding="utf-8") as f: - json.dump(snap, f, ensure_ascii=False, indent=2) - - # also human-readable restore snippet - lines = [] - lines.append("# Host snapshot restore notes") - lines.append(f"time={snap['ts']}") - lines.append("## I210 adapter advanced (raw)") - lines.append(snap.get("i210_adv", "")[:8000]) - lines.append("## power") - lines.append(snap.get("power", "")[:4000]) - lines.append("## ecat cmdline") - lines.append(snap.get("ecat_cmdline", "")[:2000]) - txt = os.path.join(LAB, f"host_snap_{int(snap['ts'])}.txt") - with open(txt, "w", encoding="utf-8") as f: - f.write("\n".join(lines)) - - print("I210:", snap.get("i210", "")[:500]) - print("CMDLINE:", snap.get("ecat_cmdline", "")[:800]) - print("ADV keys sample:") - print(snap.get("msi_affinity", "")[:2500]) - print("WROTE", out) - print("WROTE", txt) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/skip_itr200_detail.py b/scripts/skip_itr200_detail.py deleted file mode 100644 index 1b47346..0000000 --- a/scripts/skip_itr200_detail.py +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env python3 -import json, os, subprocess, time, urllib.request -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") -NIC = "以太网 2" - -def ps(cmd): - return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], - text=True, encoding="utf-8", errors="replace").strip() - -def post(p, b=None): - d = json.dumps(b or {}).encode() - r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"}) - return json.loads(urllib.request.urlopen(r, timeout=8).read().decode()) - -def wait_ok(t=30): - t0=time.time() - while time.time()-t0=3: return h - except Exception: pass - time.sleep(0.4) - return None - -def set_rt(): - p=int(ps("(Get-Process ecat_native).Id")) - ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass") - return p - -def period(ms): - post("/api/motor/stop"); time.sleep(0.12) - post("/api/motor/period", {"period_ms": ms}); time.sleep(0.12) - post("/api/motor/start") - -def quiet(sec=10): - st=post("/api/motor/state"); dbg=post("/api/motor/dbg") - c0,s0=st.get("cycles") or 0, dbg.get("skip_total") or 0 - t0=time.time(); time.sleep(sec) - st=post("/api/motor/state"); dbg=post("/api/motor/dbg") - dt=time.time()-t0 - c1,s1=st.get("cycles") or 0, dbg.get("skip_total") or 0 - cu=st.get("cyc_us") or {} - # also sample dbg ring k distribution - ring=dbg.get("cyc") or [] - ks=[r[5] for r in ring if len(r)>=6] - works=[(r[4]-r[0])*1e6 for r in ring if len(r)>=5] - sends=[(r[2]-r[1])*1e6 for r in ring if len(r)>=3] - rxs=[(r[3]-r[2])*1e6 for r in ring if len(r)>=4] - def pct(a,q): - if not a: return None - a=sorted(a); return round(a[int(q*(len(a)-1))],1) - return { - "pdo_hz": round((c1-c0)/dt,1), - "late_per_s": round((s1-s0)/dt,2), - "achieved_vs_4k": round(100*(c1-c0)/dt/4000,1), - "cyc_us": cu, - "ring_n": len(ring), - "k_gt0": sum(1 for k in ks if k>0), - "k_sum": sum(ks), - "work_pct": {"p50":pct(works,.5),"p90":pct(works,.9),"p99":pct(works,.99),"max":pct(works,1.0) if works else None}, - "send_pct": {"p50":pct(sends,.5),"p90":pct(sends,.9),"max":pct(sends,1.0) if sends else None}, - "rx_pct": {"p50":pct(rxs,.5),"p90":pct(rxs,.9),"max":pct(rxs,1.0) if rxs else None}, - } - -# set ITR 200 -ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword '*InterruptModeration' -RegistryValue '1' -NoRestart") -ps("Set-NetAdapterAdvancedProperty -Name '以太网 2' -RegistryKeyword 'ITR' -RegistryValue '200' -NoRestart") -ps("Restart-NetAdapter -Name '以太网 2' -Confirm:$false") -time.sleep(2) -print("health", wait_ok()) -set_rt() - -rows=[] -for ms in (0.25, 0.5, 1.0): - print(f"\n=== {ms} ms ITR=200 ===") - period(ms) - time.sleep(4) - set_rt() - r=quiet(10) - print(json.dumps(r, ensure_ascii=False, indent=2)) - rows.append(r) - -path=os.path.join(LAB, f"summary_itr200_periods_{int(time.time())}.json") -json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2) -print("WROTE", path) diff --git a/scripts/skip_itr_4k.py b/scripts/skip_itr_4k.py deleted file mode 100644 index 310181f..0000000 --- a/scripts/skip_itr_4k.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env python3 -"""查 pdo 线程亲和 + 尝试 ITR 200/400 在 0.25ms 静默下的表现。""" -import json, os, subprocess, time, urllib.request - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") -NIC = "以太网 2" - -def ps(cmd, timeout=60): - return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], - text=True, encoding="utf-8", errors="replace", - timeout=timeout).strip() - -def post(p, b=None): - d = json.dumps(b or {}).encode() - r = urllib.request.Request("http://127.0.0.1:8090"+p, data=d, headers={"Content-Type":"application/json"}) - return json.loads(urllib.request.urlopen(r, timeout=8).read().decode()) - -def wait_ok(timeout=35): - t0 = time.time() - while time.time()-t0 < timeout: - try: - h = post("/api/native/health") - if h.get("loop")==1 and int(h.get("wkc") or 0)>=3: - return h - except Exception: - pass - time.sleep(0.4) - return None - -def set_rt(): - p = int(ps("(Get-Process ecat_native).Id")) - ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass") - return p - -def quiet_rate(sec=8): - st = post("/api/motor/state") - dbg = post("/api/motor/dbg") - c0, s0 = st.get("cycles") or 0, dbg.get("skip_total") or 0 - t0 = time.time() - time.sleep(sec) - st = post("/api/motor/state") - dbg = post("/api/motor/dbg") - dt = time.time()-t0 - c1, s1 = st.get("cycles") or 0, dbg.get("skip_total") or 0 - return { - "pdo_hz": round((c1-c0)/dt, 1), - "late_per_s": round((s1-s0)/dt, 2), - "cyc_us": st.get("cyc_us"), - "period": st.get("period_ms"), - } - -def bus_restart_period(ms): - try: - post("/api/motor/stop") - except Exception: - pass - time.sleep(0.15) - post("/api/motor/period", {"period_ms": ms}) - time.sleep(0.15) - post("/api/motor/start") - -def nic_set(kw, val): - out, err = "", "" - r = subprocess.run(["powershell","-NoProfile","-Command", - f"Set-NetAdapterAdvancedProperty -Name '{NIC}' -RegistryKeyword '{kw}' -RegistryValue '{val}' -NoRestart"], - capture_output=True, text=True, encoding="utf-8", errors="replace") - return r.stdout.strip(), r.stderr.strip() - -# affinity of ecat threads via .NET is limited; dump processor affinity mask -print("=== process affinity ===") -print(ps(r''' -$p = Get-Process ecat_native -"PID $($p.Id) Priority=$($p.PriorityClass)" -# ProcessorAffinity is a bitmask -"Affinity=$($p.ProcessorAffinity)" -''')) - -print("=== I210 current ITR/moderation ===") -print(ps(r''' -Get-NetAdapterAdvancedProperty -Name '以太网 2' | - Where-Object { $_.RegistryKeyword -in 'ITR','*InterruptModeration','*TransmitBuffers' } | - Select-Object RegistryKeyword,DisplayValue | ConvertTo-Json -Compress -''')) - -results = [] -# try ITR values at 0.25ms quiet -for itr in ("200", "400", "950", "65535"): - print(f"\n===== ITR={itr} @0.25ms =====") - o, e = nic_set("*InterruptModeration", "1") - o2, e2 = nic_set("ITR", itr) - print("set", o2 or o, e2 or e) - # restart nic only if needed - ITR may need restart - ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false") - time.sleep(2) - h = wait_ok(30) - print("health", bool(h)) - if not h: - try: - post("/api/op/start"); post("/api/motor/start") - h = wait_ok(20) - except Exception as ex: - print(ex) - set_rt() - bus_restart_period(0.25) - time.sleep(3) - set_rt() - r = quiet_rate(8) - print(json.dumps({k:r[k] for k in ("pdo_hz","late_per_s","period")}, ensure_ascii=False)) - print("work/send/rx", r["cyc_us"]["work"]["p50"], r["cyc_us"]["send"]["p50"], r["cyc_us"]["rx"]["p50"]) - results.append({"itr": itr, **{k:r[k] for k in ("pdo_hz","late_per_s")}, - "work_p50": r["cyc_us"]["work"]["p50"], "send_p50": r["cyc_us"]["send"]["p50"], - "rx_p50": r["cyc_us"]["rx"]["p50"]}) - -print("\nSUMMARY", json.dumps(results, ensure_ascii=False, indent=2)) -# restore adaptive -nic_set("ITR", "65535") -ps(f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false") -time.sleep(2) -wait_ok(25) -set_rt() -# leave at 1ms for safety -bus_restart_period(1.0) -time.sleep(1) -print("restored 1ms", post("/api/native/health")) -path = os.path.join(LAB, f"summary_itr_4k_{int(time.time())}.json") -json.dump(results, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2) -print("WROTE", path) diff --git a/scripts/skip_multi_round.py b/scripts/skip_multi_round.py deleted file mode 100644 index 2095488..0000000 --- a/scripts/skip_multi_round.py +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env python3 -import json, os, subprocess, sys, time -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") - -def set_prio(level="RealTime"): - p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip()) - subprocess.check_output(["powershell","-NoProfile","-Command", - f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True) - return p - -def meter(tag, secs=15): - subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")) - d = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) - return { - "tag": tag, - "late_per_s": d["late_per_s"], "skip_per_s": d["skip_per_s"], - "achieved_hz": d["achieved_hz"], "n_late": d["n_late"], - "work_p50": d["window_cyc_us"]["work"]["p50"], - "rx_p50": d["window_cyc_us"]["rx"]["p50"], - "send_p50": d["window_cyc_us"]["send"]["p50"], - "work_p90": d["window_cyc_us"]["work"]["p90"], - } - -def main(): - set_prio("RealTime") - rows = [] - for i in range(1, 4): - print(f"round {i} settle...") - time.sleep(4) - r = meter(f"multirt_{i}", 15) - print(r) - rows.append(r) - lates = [x["late_per_s"] for x in rows] - print("mean late", sum(lates)/len(lates), "min", min(lates), "max", max(lates)) - path = os.path.join(LAB, f"summary_multirt_{int(time.time())}.json") - json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2) - print("WROTE", path) - -if __name__ == "__main__": - main() diff --git a/scripts/skip_nic_experiment.py b/scripts/skip_nic_experiment.py deleted file mode 100644 index 39f00b6..0000000 --- a/scripts/skip_nic_experiment.py +++ /dev/null @@ -1,312 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""可回滚的 I210 网卡参数实验:停环 → 改高级属性 → 恢复 OP → 标定跳拍率。 - -默认只测、不永久破坏:原值会写入 restore json;`--restore` 按快照还原。 -改网卡会触发短暂链路重协商,期间从站可能掉 OP,脚本会等 link+OP 恢复。 -""" -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -import urllib.request - -ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -LAB = os.path.join(ROOT, "lab", "skip_meter") -NIC = "以太网 2" -BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090") - - -def post(path, body=None, timeout=8.0): - data = json.dumps(body or {}).encode() - req = urllib.request.Request( - BASE + path, data=data, headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read().decode()) - - -def ps(cmd, timeout=60): - r = subprocess.run( - ["powershell", "-NoProfile", "-Command", cmd], - capture_output=True, - text=True, - encoding="utf-8", - errors="replace", - timeout=timeout, - ) - return (r.stdout or "").strip(), (r.stderr or "").strip() - - -def nic_snapshot(): - out, err = ps( - f"Get-NetAdapterAdvancedProperty -Name '{NIC}' | " - "Select-Object RegistryKeyword,DisplayValue,RegistryValue | " - "ConvertTo-Json -Compress" - ) - if not out: - return [] - data = json.loads(out) - if isinstance(data, dict): - return [data] - return data - - -def nic_set(keyword: str, value: str): - # value as string; Intel accepts "0"/"1"/numbers - cmd = ( - f"Set-NetAdapterAdvancedProperty -Name '{NIC}' " - f"-RegistryKeyword '{keyword}' -RegistryValue '{value}' -NoRestart" - ) - out, err = ps(cmd) - return out, err - - -def nic_restart(): - out, err = ps( - f"Restart-NetAdapter -Name '{NIC}' -Confirm:$false; Start-Sleep -Seconds 2; " - f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress" - ) - return out, err - - -def wait_link(timeout=20.0): - t0 = time.time() - while time.time() - t0 < timeout: - out, _ = ps( - f"Get-NetAdapter -Name '{NIC}' | Select-Object Status,LinkSpeed | ConvertTo-Json -Compress" - ) - try: - j = json.loads(out) - if isinstance(j, list): - j = j[0] - if str(j.get("Status", "")).lower() == "up": - return True, j - except Exception: - pass - time.sleep(0.5) - return False, None - - -def wait_ecat_healthy(timeout=25.0): - t0 = time.time() - last = None - while time.time() - t0 < timeout: - try: - h = post("/api/native/health") - last = h - if h.get("loop") == 1 and int(h.get("wkc") or 0) >= 3 and h.get("op") == 1: - # also require cycles advancing - c0 = h.get("cycles") or 0 - time.sleep(0.4) - h2 = post("/api/native/health") - c1 = h2.get("cycles") or 0 - if c1 > c0: - return True, h2 - except Exception as e: - last = {"err": str(e)} - time.sleep(0.5) - return False, last - - -def stop_bus(): - logs = [] - for path in ("/api/motor/stop", "/api/op/stop"): - try: - r = post(path) - logs.append((path, r.get("ok"), (r.get("log") or r)[:2])) - except Exception as e: - logs.append((path, False, str(e))) - return logs - - -def start_bus(): - logs = [] - try: - r = post("/api/op/start") - logs.append(("/api/op/start", r.get("ok"), (r.get("log") or [""])[:2])) - except Exception as e: - logs.append(("/api/op/start", False, str(e))) - try: - r = post("/api/motor/start") - logs.append(("/api/motor/start", r.get("ok"), (r.get("log") or [""])[:2])) - except Exception as e: - logs.append(("/api/motor/start", False, str(e))) - return logs - - -def meter(tag: str, secs: float): - # reuse skip_rate_meter as subprocess for isolation - cmd = [ - sys.executable, - os.path.join(ROOT, "scripts", "skip_rate_meter.py"), - "--tag", - tag, - "--secs", - str(secs), - ] - r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace") - # last json file for tag - files = sorted( - [f for f in os.listdir(LAB) if f.startswith(tag + "_") and f.endswith(".json")] - ) - data = None - if files: - with open(os.path.join(LAB, files[-1]), encoding="utf-8") as f: - data = json.load(f) - return data, r.stdout, r.stderr - - -def apply_batch(changes: list[tuple[str, str]]): - results = [] - for k, v in changes: - out, err = nic_set(k, v) - results.append({"key": k, "value": v, "out": out[:300], "err": err[:300]}) - return results - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--restore", action="store_true", help="restore from snap json") - ap.add_argument("--snap", default=None, help="snapshot json for restore") - ap.add_argument("--exp", default=None, help="named experiment") - ap.add_argument("--secs", type=float, default=16.0) - ap.add_argument("--list-exp", action="store_true") - args = ap.parse_args() - - os.makedirs(LAB, exist_ok=True) - - # experiment table: list of (keyword, value) - EXPS = { - "itr_off": [ - ("*InterruptModeration", "0"), - ("ITR", "0"), - ], - "itr_low": [ - ("*InterruptModeration", "1"), - ("ITR", "400"), # valid: 65535,3600,2000,950,400,200,0 - ], - "itr_200": [ - ("*InterruptModeration", "1"), - ("ITR", "200"), - ], - "itr_950": [ - ("*InterruptModeration", "1"), - ("ITR", "950"), - ], - "eee_off": [ - ("EEELinkAdvertisement", "0"), - ], - "flow_off": [ - ("*FlowControl", "0"), - ], - "txbuf_max": [ - ("*TransmitBuffers", "4096"), - ], - "rxbuf_max": [ - ("*ReceiveBuffers", "1024"), - ], - "combo_lowlat": [ - ("*InterruptModeration", "0"), - ("ITR", "0"), - ("EEELinkAdvertisement", "0"), - ("*FlowControl", "0"), - ("*TransmitBuffers", "2048"), - ("*ReceiveBuffers", "512"), - ], - } - - if args.list_exp: - print(json.dumps({k: v for k, v in EXPS.items()}, indent=2)) - return 0 - - # always keep a fresh snapshot before mutating - snap = nic_snapshot() - snap_path = os.path.join(LAB, f"nic_before_{int(time.time())}.json") - with open(snap_path, "w", encoding="utf-8") as f: - json.dump(snap, f, ensure_ascii=False, indent=2) - print(f"NIC snapshot → {snap_path}") - - if args.restore: - path = args.snap or snap_path - with open(path, encoding="utf-8") as f: - data = json.load(f) - # stop, apply all, restart - print("restore: stop bus") - print(stop_bus()) - n = 0 - for row in data: - kw = row.get("RegistryKeyword") - val = row.get("RegistryValue") - if not kw or val is None: - continue - if isinstance(val, list): - val = val[0] if val else "" - val = str(val) - apply_batch([(kw, val)]) - n += 1 - print("restore applied", n, "keys; restarting NIC") - print(nic_restart()) - print(wait_link()) - print("start bus") - print(start_bus()) - ok, h = wait_ecat_healthy() - print("healthy", ok, h) - return 0 - - exp = args.exp or "itr_off" - if exp not in EXPS: - print("unknown exp", exp, "use --list-exp") - return 2 - changes = EXPS[exp] - - print(f"\n=== EXP {exp} {changes} ===") - print("stop bus...") - print(stop_bus()) - time.sleep(0.3) - print("apply...") - print(apply_batch(changes)) - print("restart nic...") - print(nic_restart()) - ok, info = wait_link() - print("link", ok, info) - print("start bus...") - print(start_bus()) - ok, h = wait_ecat_healthy(timeout=30) - print("healthy", ok, h) - if not ok: - print("ABORT: bus not healthy after change") - return 1 - - # settle longer: NIC restart + re-enumeration leaves ~2–5s of elevated skip - time.sleep(8.0) - data, out, err = meter(f"exp_{exp}", args.secs) - print(out) - if err: - print("meter stderr", err[:500]) - if data: - summary = { - "exp": exp, - "changes": changes, - "late_per_s": data.get("late_per_s"), - "skip_per_s": data.get("skip_per_s"), - "late_frac_pct": data.get("late_frac_pct"), - "send_stall_frac": data.get("send_stall_frac"), - "rx_stall_frac": data.get("rx_stall_frac"), - "achieved_hz": data.get("achieved_hz"), - "n_late": data.get("n_late"), - } - sp = os.path.join(LAB, f"summary_{exp}_{int(time.time())}.json") - with open(sp, "w", encoding="utf-8") as f: - json.dump(summary, f, ensure_ascii=False, indent=2) - print("SUMMARY", json.dumps(summary, ensure_ascii=False)) - print("WROTE", sp) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/skip_prio_ab.py b/scripts/skip_prio_ab.py deleted file mode 100644 index 7c0fc96..0000000 --- a/scripts/skip_prio_ab.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""High vs RealTime 多轮 A/B,输出对比。结束后设为 --final 指定档。""" -import json, os, subprocess, sys, time - -ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -LAB = os.path.join(ROOT, "lab", "skip_meter") - -def ecat_pid(): - out = subprocess.check_output( - ["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], - text=True).strip() - return int(out.splitlines()[0]) - -def set_prio(level): - p = ecat_pid() - subprocess.check_output( - ["powershell","-NoProfile","-Command", - f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], - text=True) - return level - -def meter(tag, secs=12): - r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")]) - return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) - -def row(level, data, n): - return { - "round": n, "level": level, - "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"], - "send_stall_frac": data["send_stall_frac"], "rx_stall_frac": data["rx_stall_frac"], - "achieved_hz": data["achieved_hz"], "n_late": data["n_late"], - "send_p90": (data.get("window_cyc_us") or {}).get("send",{}).get("p90"), - "work_p90": (data.get("window_cyc_us") or {}).get("work",{}).get("p90"), - } - -def main(): - final = "RealTime" - if len(sys.argv) > 1: - final = sys.argv[1] - rows = [] - # A-B-A-B - seq = ["High","RealTime","High","RealTime"] - for i, lv in enumerate(seq, 1): - set_prio(lv) - time.sleep(1.0) - data = meter(f"ab_{lv}_{i}", 12) - r = row(lv, data, i) - rows.append(r) - print(r) - set_prio(final) - print("FINAL", final, "pid", ecat_pid()) - # summary means - by = {} - for r in rows: - by.setdefault(r["level"], []).append(r) - means = {} - for lv, rs in by.items(): - means[lv] = { - "n": len(rs), - "late_per_s_mean": round(sum(x["late_per_s"] for x in rs)/len(rs), 3), - "skip_per_s_mean": round(sum(x["skip_per_s"] for x in rs)/len(rs), 3), - "send_p90_mean": round(sum(x["send_p90"] or 0 for x in rs)/len(rs), 1), - "work_p90_mean": round(sum(x["work_p90"] or 0 for x in rs)/len(rs), 1), - } - print("MEANS", json.dumps(means, ensure_ascii=False, indent=2)) - path = os.path.join(LAB, f"summary_ab_prio_{int(time.time())}.json") - json.dump({"rows": rows, "means": means, "final": final}, open(path,"w",encoding="utf-8"), - ensure_ascii=False, indent=2) - print("WROTE", path) - -if __name__ == "__main__": - main() diff --git a/scripts/skip_prio_experiment.py b/scripts/skip_prio_experiment.py deleted file mode 100644 index dcd7500..0000000 --- a/scripts/skip_prio_experiment.py +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""进程优先级对照:不高/RealTime,各采 skip rate。不改网卡、不碰固件。""" -import json, os, subprocess, sys, time, urllib.request - -ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -LAB = os.path.join(ROOT, "lab", "skip_meter") -BASE = "http://127.0.0.1:8090" - -def post(path, body=None): - data = json.dumps(body or {}).encode() - req = urllib.request.Request(BASE+path, data=data, headers={"Content-Type":"application/json"}) - with urllib.request.urlopen(req, timeout=5) as r: - return json.loads(r.read().decode()) - -def pid(): - return post("/api/native/health") # health has no pid; use process name - # fallback below - -def ecat_pid(): - out = subprocess.check_output( - ["powershell","-NoProfile","-Command", - "(Get-Process ecat_native).Id"], - text=True).strip() - return int(out.splitlines()[0]) - -def set_prio(level): - p = ecat_pid() - cmd = f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass" - return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], text=True).strip() - -def meter(tag, secs=16): - r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")]) - data = json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) if files else None - return data, r.stdout - -def main(): - p = ecat_pid() - print("ecat pid", p) - cur = subprocess.check_output( - ["powershell","-NoProfile","-Command", f"(Get-Process -Id {p}).PriorityClass"], - text=True).strip() - print("current PriorityClass", cur) - - results = [] - for level in ("High", "RealTime"): - print(f"\n=== set {level} ===") - try: - print(set_prio(level)) - except Exception as e: - print("set fail", e) - continue - time.sleep(1.5) - data, out = meter(f"prio_{level}", 16) - if data: - row = {"level": level, "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"], - "late_frac_pct": data["late_frac_pct"], "send_stall_frac": data["send_stall_frac"], - "rx_stall_frac": data["rx_stall_frac"], "achieved_hz": data["achieved_hz"], - "n_late": data["n_late"]} - results.append(row) - print("ROW", row) - print(out[-800:] if out else "") - - # restore High (safe default; pdo thread already TIME_CRITICAL) - try: - print("restore High") - print(set_prio("High")) - except Exception as e: - print("restore fail", e) - - path = os.path.join(LAB, f"summary_prio_{int(time.time())}.json") - json.dump({"results": results, "original": cur}, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2) - print("WROTE", path) - -if __name__ == "__main__": - main() diff --git a/scripts/skip_quiet_periods.py b/scripts/skip_quiet_periods.py deleted file mode 100644 index 1c0dc99..0000000 --- a/scripts/skip_quiet_periods.py +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env python3 -"""静默多档标定:1ms / 2ms / 4ms,每档先停环再设周期再开环,避免混拍。""" -import json, os, subprocess, sys, time, urllib.request - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") -BASE = "http://127.0.0.1:8090" - -def post(p, b=None): - d = json.dumps(b or {}).encode() - r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"}) - return json.loads(urllib.request.urlopen(r, timeout=8).read().decode()) - -def set_prio(level="RealTime"): - p = int(subprocess.check_output(["powershell","-NoProfile","-Command","(Get-Process ecat_native).Id"], text=True).strip()) - subprocess.check_output(["powershell","-NoProfile","-Command", - f"$p=Get-Process -Id {p}; $p.PriorityClass='{level}'; $p.PriorityClass"], text=True) - return p - -def set_period(ms): - post("/api/motor/stop") - time.sleep(0.2) - r = post("/api/motor/period", {"period_ms": ms}) - time.sleep(0.2) - post("/api/motor/start") - time.sleep(0.8) - return r - -def meter(tag, secs=18): - r = subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted([f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")]) - return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) - -def main(): - print("set RealTime") - print("pid", set_prio("RealTime")) - rows = [] - for ms in (1.0, 2.0, 4.0): - print(f"\n=== period {ms} ms ===") - print(set_period(ms)) - time.sleep(2.0) # settle - data = meter(f"quiet_{ms:g}ms", 18) - row = { - "period_ms": ms, - "late_per_s": data["late_per_s"], - "skip_per_s": data["skip_per_s"], - "late_frac_pct": data["late_frac_pct"], - "achieved_hz": data["achieved_hz"], - "n_late": data["n_late"], - "send_stall_frac": data["send_stall_frac"], - "rx_stall_frac": data["rx_stall_frac"], - "work_p50": data["window_cyc_us"]["work"]["p50"], - "rx_p50": data["window_cyc_us"]["rx"]["p50"], - "send_p50": data["window_cyc_us"]["send"]["p50"], - "work_p90": data["window_cyc_us"]["work"]["p90"], - } - rows.append(row) - print(row) - - # restore 1ms as user default - print("\nrestore period 1.0") - print(set_period(1.0)) - path = os.path.join(LAB, f"summary_quiet_periods_{int(time.time())}.json") - json.dump(rows, open(path,"w",encoding="utf-8"), ensure_ascii=False, indent=2) - print("WROTE", path) - print(json.dumps(rows, ensure_ascii=False, indent=2)) - -if __name__ == "__main__": - main() diff --git a/scripts/skip_rate_meter.py b/scripts/skip_rate_meter.py deleted file mode 100644 index 373b7d6..0000000 --- a/scripts/skip_rate_meter.py +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""跳拍率标定:固定时长采 /api/motor/dbg,输出可对比 JSON。 - -主判据: late_per_s, skip_per_s, send_stall_frac, rx_stall_frac -用法: - python scripts/skip_rate_meter.py --tag base --secs 20 -""" -from __future__ import annotations - -import argparse -import json -import os -import time -import urllib.request - -BASE = os.environ.get("ECAT_BASE", "http://127.0.0.1:8090") -OUTDIR = os.path.join(os.path.dirname(__file__), "..", "lab", "skip_meter") - - -def post(path: str, body=None, timeout=5.0): - data = json.dumps(body or {}).encode() - req = urllib.request.Request( - BASE + path, data=data, headers={"Content-Type": "application/json"} - ) - with urllib.request.urlopen(req, timeout=timeout) as r: - return json.loads(r.read().decode()) - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--tag", default="sample") - ap.add_argument("--secs", type=float, default=20.0) - ap.add_argument("--hz", type=float, default=5.0) - ap.add_argument("--out", default=None) - args = ap.parse_args() - - st0 = post("/api/motor/state") - period = float(st0.get("period_ms") or 1.0) - t0 = time.time() - pulses = [] - last_t = None - n_poll = 0 - # dbg t0 uses now_s() (QPC-based), NOT unix time. Track by last-seen t0 only. - while time.time() - t0 < args.secs: - dbg = post("/api/motor/dbg") - n_poll += 1 - cyc = dbg.get("cyc") or [] - if last_t is not None: - new = [r for r in cyc if r[0] > last_t] - else: - # first poll: adopt the whole ring's last timestamp as cursor; - # count only k>0 from now on via subsequent polls. Also count - # k>0 already in ring as "pre-existing" (not in window rate). - new = [] - if cyc: - last_t = cyc[-1][0] - elif last_t is None: - last_t = None - for r in new: - if len(r) < 8: - continue - k = int(r[5]) - if k <= 0: - continue - send_us = (r[2] - r[1]) * 1e6 - rx_us = (r[3] - r[2]) * 1e6 - pulses.append( - { - "t0": r[0], - "k": k, - "send_us": send_us, - "rx_us": rx_us, - "work_us": (r[4] - r[0]) * 1e6, - "sd": float(r[6]) if len(r) > 6 else None, - "rxc": int(r[7]) if len(r) > 7 else None, - "kind": ( - "send" - if send_us >= 500 - else ("rx" if rx_us >= 500 else "other") - ), - } - ) - time.sleep(max(0.0, 1.0 / args.hz)) - - st1 = post("/api/motor/state") - wall = time.time() - t0 - n_late = len(pulses) - n_skip = sum(p["k"] for p in pulses) - by = {"send": 0, "rx": 0, "other": 0} - for p in pulses: - by[p["kind"]] += 1 - - # approximate expected beats in window - # use cycles delta when available - cyc0 = st0.get("cycles") or 0 - cyc1 = st1.get("cycles") or 0 - dcyc = max(0, cyc1 - cyc0) - - cu = st1.get("cyc_us") or {} - result = { - "tag": args.tag, - "ts": time.time(), - "wall_s": round(wall, 3), - "period_ms": period, - "pcore_mask": st1.get("pcore_mask"), - "wkc": st1.get("wkc"), - "spin": st1.get("spin"), - "cycles_delta": dcyc, - "achieved_hz": round(dcyc / wall, 2) if wall > 0 else None, - "late_per_s": round(n_late / wall, 3) if wall else None, - "skip_per_s": round(n_skip / wall, 3) if wall else None, - "late_frac_pct": round(100.0 * n_late / dcyc, 4) if dcyc else None, - "skip_frac_pct": round(100.0 * n_skip / dcyc, 4) if dcyc else None, - "send_stall_frac": round(by["send"] / n_late, 4) if n_late else None, - "rx_stall_frac": round(by["rx"] / n_late, 4) if n_late else None, - "n_late": n_late, - "n_skip": n_skip, - "by_kind": by, - "window_cyc_us": cu, - "n_poll": n_poll, - "pulses_sample": pulses[:40], - } - - os.makedirs(OUTDIR, exist_ok=True) - out = args.out or os.path.join(OUTDIR, f"{args.tag}_{int(time.time())}.json") - with open(out, "w", encoding="utf-8") as f: - json.dump(result, f, ensure_ascii=False, indent=2) - print(json.dumps({k: result[k] for k in result if k != "pulses_sample"}, - ensure_ascii=False, indent=2)) - print(f"WROTE {out}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/skip_restart_native.py b/scripts/skip_restart_native.py deleted file mode 100644 index ec7c7ea..0000000 --- a/scripts/skip_restart_native.py +++ /dev/null @@ -1,110 +0,0 @@ -#!/usr/bin/env python3 -"""重启 ecat_native 进程(让 esp32_server/父进程重拉),测 rx 是否回到 ~90µs。""" -import json, os, subprocess, sys, time, urllib.request - -ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -LAB = os.path.join(ROOT, "lab", "skip_meter") -BASE = "http://127.0.0.1:8090" - -def post(p, b=None, timeout=5): - d = json.dumps(b or {}).encode() - r = urllib.request.Request(BASE+p, data=d, headers={"Content-Type":"application/json"}) - return json.loads(urllib.request.urlopen(r, timeout=timeout).read().decode()) - -def ps(cmd): - return subprocess.check_output(["powershell","-NoProfile","-Command",cmd], - text=True, encoding="utf-8", errors="replace").strip() - -def wait_health(timeout=40): - t0 = time.time() - while time.time()-t0 < timeout: - try: - h = post("/api/native/health") - if h.get("loop")==1 and int(h.get("wkc") or 0)>=3: - return True, h - except Exception: - pass - time.sleep(0.5) - return False, None - -def meter(tag, secs=16): - subprocess.run([sys.executable, os.path.join(ROOT,"scripts","skip_rate_meter.py"), - "--tag", tag, "--secs", str(secs)], - capture_output=True, text=True, encoding="utf-8", errors="replace") - files = sorted(f for f in os.listdir(LAB) if f.startswith(tag+"_") and f.endswith(".json")) - return json.load(open(os.path.join(LAB, files[-1]), encoding="utf-8")) - -def main(): - # re-enable IP (keep VMware off — it's harmful and was only “enabled” by coincidence in morning) - for cid in ("ms_tcpip", "ms_tcpip6"): - try: - ps(f"Enable-NetAdapterBinding -Name '以太网 2' -ComponentID '{cid}' -Confirm:$false") - print("enabled", cid) - except Exception as e: - print("enable fail", cid, e) - - info = ps("Get-CimInstance Win32_Process -Filter \"Name='ecat_native.exe'\" | Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress") - print("before", info[:500]) - - print("kill ecat_native...") - ps("Stop-Process -Name ecat_native -Force -ErrorAction SilentlyContinue") - time.sleep(2) - - # parent may be esp32_server — wait for auto-restart - ok, h = wait_health(45) - print("auto restart?", ok, h) - if not ok: - print("manual start") - exe = r"G:\Robot_v2\DCU\tools\ecat\build_native\ecat_native.exe" - cwd = r"G:\Robot_v2\DCU\tools\ecat" - subprocess.Popen( - [exe, "--substr", "I210", "--mask", "0x50", "--web", "src/web"], - cwd=cwd, - stdout=open(os.path.join(cwd,"build_native","native_stdout.log"),"a"), - stderr=subprocess.STDOUT, - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, - ) - time.sleep(3) - ok, h = wait_health(30) - print("manual health", ok, h) - if ok: - try: - post("/api/op/start") - post("/api/motor/start") - except Exception as e: - print("start fail", e) - ok, h = wait_health(20) - print("after start", ok, h) - - # set RealTime - try: - p = int(ps("(Get-Process ecat_native).Id")) - ps(f"$p=Get-Process -Id {p}; $p.PriorityClass='RealTime'; $p.PriorityClass") - print("RealTime", p) - except Exception as e: - print("prio fail", e) - - print("settle 10s") - time.sleep(10) - st = post("/api/motor/state") - print("cyc", st.get("cyc_us")) - print("period", st.get("period_ms"), "wkc", st.get("wkc"), "pcore", st.get("pcore_mask")) - - data = meter("restart_rt", 16) - row = { - "late_per_s": data["late_per_s"], "skip_per_s": data["skip_per_s"], - "achieved_hz": data["achieved_hz"], "n_late": data["n_late"], - "work_p50": data["window_cyc_us"]["work"]["p50"], - "rx_p50": data["window_cyc_us"]["rx"]["p50"], - "send_p50": data["window_cyc_us"]["send"]["p50"], - "work_p90": data["window_cyc_us"]["work"]["p90"], - "send_stall_frac": data["send_stall_frac"], - "rx_stall_frac": data["rx_stall_frac"], - } - print(json.dumps(row, ensure_ascii=False, indent=2)) - out = os.path.join(LAB, f"summary_restart_{int(time.time())}.json") - json.dump(row, open(out,"w",encoding="utf-8"), ensure_ascii=False, indent=2) - print("WROTE", out) - -if __name__ == "__main__": - main() diff --git a/scripts/sweep_ecat_rate.py b/scripts/sweep_ecat_rate.py deleted file mode 100644 index f0f5904..0000000 --- a/scripts/sweep_ecat_rate.py +++ /dev/null @@ -1,335 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""PDO 周期扫描:一次跑完多档,同时记「速度达成 / 故障率 / CPU 占用」三条曲线。 - -与 tools/ecat/rate_probe.py 的区别:rate_probe 只量速度达成率,本脚本把故障率与 -CPU 一并采齐并落成机读 CSV,供 plot_ecat_sweep.py 画曲线。速率达成部分的 -"cycles 与时刻同取同舍"纪律直接沿用 rate_probe(它注释里记过一次把 99.2% 误报 -成 96% 的实踩教训:分子取末次 cycles、分母却在循环退出后才算,dt 多出一次 sleep)。 - -必须绕开的四件事(均由 Phase 0 现场标定确认,不是照抄文档): - 1 u16 回绕:bridge.drop 在 4000/s 下 16s 绕一圈,首尾相减必错 → 高频逐次累加, - 且单次增量 >32768 视为绕圈、该点作废并计数暴露采样率不足。 - 2 rx_torn / pdo_reentry 是**饱和**计数(到 65535 停),不能按回绕算 → 走 - delta_saturate,封顶即标注"下界"而不是报精确值。 - 3 周期能被 localStorage 劫持:任何开着的电机页标签在 ensureStarted 时会把总线 - 周期拽回存量值(transport-ecat.js:227-228)→ 每档下发后回读 period_ms 确认 - 真的生效,不一致就把该档标记为被劫持。 - 4 λ(CAN 到帧率)取 u32 的 head.global_seq,不取 u16 的 bridge.rx_count。 - 本次实测到关键分界:1ms/0.5ms 档 λ=4000/s、GD32 负载 44%/77%;0.25ms/0.125ms - 档 GD32 顶到 99.5%/99.3%,λ 塌到 149/s —— 从站主循环被 PDO 填充吃光、CAN 接收 - 停摆。而主站达成率反而仍很高(0.125ms 实测 7780Hz),所以瓶颈在从站不在主站: - 只看达成率会把这条线整个看漏。 - -用法: - python scripts/sweep_ecat_rate.py # 默认六档 × 60s - python scripts/sweep_ecat_rate.py --periods 0.25,0.5,1 --dwell 30 - python scripts/sweep_ecat_rate.py --restore 1 --out lab/ecat_sweep.csv - python scripts/sweep_ecat_rate.py --dry-run # 只走一遍不下发周期 -结束后一定把周期恢复到脚本启动前的原值(含 Ctrl-C / 异常)。 -""" -import argparse -import csv -import os -import signal -import sys -import time - -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from calibrate_ecat_metrics import (cadence_rings, find_native_pid, - firmware_identity, ensure_loop) -from ecat_sweep_common import delta_saturate, delta_wrap, percentile, post - -DEFAULT_PERIODS = "0.125,0.25,0.5,1,2,4" -COLS = ["period_ms", "set_period_ms", "hijacked", "native_pid", "pid_stable", - "measured_hz", "achieve_pct", "can_lambda_per_s", - "drop_per_s", "drop_pct_of_lambda", "drop_untrusted", "lambda_u16_crosscheck", - "rxb_count_mode_per_cycle", "rxb_lost_max", "rxb_pending_max", - "skip_per_s", "skip_delta", "motors_online", "online_frac", "motor_refresh_per_s", - "torn_per_s", "torn_cum", "torn_capped", - "reentry_per_s", "reentry_cum", "reentry_capped", "wkc", "expected_wkc", "wr", - "cyc_p50_us", "cyc_p90_us", "cyc_max_us", "cyc_duty_pct", - "gd32_load_pct", "gd32_load_samples", "gd32_load_stale", - "host_cpu_1core_pct", "host_cpu_total_pct", - "logical_cpus", "samples", "errors", "dwell_s", "note"] - - -def sample_period(url, ms, dwell, hz, substr, autostart, dry): - """在给定周期档上采 dwell 秒,返回一行 CSV 记录。""" - note = [] - pid0, perr = find_native_pid(url) - st0 = post(url, "/api/motor/state") - if not (st0.get("ok") and st0.get("cycles")): - st0, how = ensure_loop(url, autostart, substr) - note.append("起循环:" + how) - orig = st0.get("period_ms") - - if dry: - return {"period_ms": ms, "note": "dry-run 未下发"} - - r = post(url, "/api/motor/period", {"period_ms": ms}) - if not r.get("ok"): - return {"period_ms": ms, "note": "档位被拒:" + str(r.get("log"))[:80]} - - # 等周期真正生效(热调是异步落到循环线程的) - eff, stable_iters = None, 0 - t_deadline = time.perf_counter() + 3.0 - while time.perf_counter() < t_deadline: - time.sleep(0.2) - eff = (post(url, "/api/motor/state")).get("period_ms") - if eff is not None and abs(float(eff) - float(ms)) < 1e-6: - stable_iters += 1 - if stable_iters >= 3: - break - else: - stable_iters = 0 - hijacked = "no" - if eff is None or abs(float(eff) - float(ms)) >= 1e-6: - hijacked = "YES" - note.append(f"下发 {ms}ms 但生效 {eff}ms(疑 localStorage 劫持或有他端在设)") - - interval = 1.0 / hz - cyc_hz, drop_rate, rx_rate, gseq_rate, loads, hc1, hct = [], [], [], [], [], [], [] - cyc_p50, cyc_p90, cyc_max = [], [], [] - untrusted = torn_sum = reentry_sum = err = iters = load_stale = 0 - torn_cap = reentry_cap = False - torn_prev = re_prev = None - torn_cum = re_cum = None - node_seq = {} # node -> (t, seq):上一拍的槽 seq,用于算电机数据刷新率 - online_hits = online_tot = 0 - refresh_rates = [] - prev = prev_t = None - wr0 = (post(url, "/api/motor/state")).get("wr") - skip0 = (post(url, "/api/motor/dbg")).get("skip_total") - proc = None - try: - import psutil - if pid0: - proc = psutil.Process(pid0) - proc.cpu_percent(None) # 建立差分基线 - except Exception as e: - note.append(f"psutil 不可用({e.__class__.__name__}),主机 CPU 缺") - - t_end = time.perf_counter() + dwell - while time.perf_counter() < t_end: - tc = time.perf_counter() - try: - s = post(url, "/api/motor/state") - L = post(url, "/api/app/load") - except Exception: - err += 1 - time.sleep(interval) - continue - iters += 1 - b = s.get("bridge") or {} - cur = {"cycles": s.get("cycles"), "drop": b.get("drop"), "rx": b.get("rx_count"), - "gseq": (s.get("head") or {}).get("global_seq")} - if prev is not None and tc > prev_t: - dt = tc - prev_t - if cur["cycles"] is not None and prev["cycles"] is not None: - h = (cur["cycles"] - prev["cycles"]) / dt - if 0 < h < 1e6: - cyc_hz.append(h) - if cur["drop"] is not None and prev["drop"] is not None: - d = delta_wrap(prev["drop"], cur["drop"], 16) - if d < 32768: - drop_rate.append(d / dt) - else: - untrusted += 1 # 已绕圈:该点作废,只计数不臆造速率 - # λ 以 u32 的 head.global_seq 为准。u16 的 bridge.rx_count/drop 在快档 - # 会失真(实测 0.25ms 档真值 149/s,两路 u16 与 u32 虽一致但增量已贴近 - # 截断区),留作交叉核对:两者显著背离即说明该档 u16 不可信。 - if cur["gseq"] is not None and prev["gseq"] is not None: - d = delta_wrap(prev["gseq"], cur["gseq"], 32) - if 0 <= d < (1 << 31): - gseq_rate.append(d / dt) - if cur["rx"] is not None and prev["rx"] is not None: - d = delta_wrap(prev["rx"], cur["rx"], 32) - if d < (1 << 31): - rx_rate.append(d / dt) - tn, rn = L.get("rx_torn"), L.get("pdo_reentry") - if tn is not None: - if torn_prev is not None and not torn_cap: - dd, capped = delta_saturate(torn_prev, tn) - torn_sum += dd - torn_cap |= capped - torn_prev = tn - if rn is not None: - if re_prev is not None and not reentry_cap: - dd, capped = delta_saturate(re_prev, rn) - reentry_sum += dd - reentry_cap |= capped - re_prev = rn - # 从站停报时 load_perm 会保留最后一次的值(实测:从站楔死后 age_ms=214000 - # 而 load_perm 仍报 992)。不加 age 闸就会把残值当实测写进结论。 - if (L.get("valid") and L.get("load_perm") is not None - and (L.get("age_ms") if L.get("age_ms") is not None else 1e9) < 1000): - loads.append(L["load_perm"] / 10.0) - else: - load_stale += 1 - cu = (s.get("cyc_us") or {}).get("total") or {} - if cu.get("p50") is not None: - cyc_p50.append(cu["p50"]) - if cu.get("p90") is not None: - cyc_p90.append(cu["p90"]) - if cu.get("max") is not None: - cyc_max.append(cu["max"]) - if proc is not None: - try: - c = proc.cpu_percent(None) - hc1.append(c) - hct.append(c / max(1, os.cpu_count() or 1)) - except Exception: - pass - # 电机在线标志与数据刷新率:快档塌陷必须能区分"从站算不过来"与"电机掉线" - # —— 二者在 λ/seq 上长得一模一样,只有 online 标志能把它们切开。 - for m in (s.get("motors") or []): - nd = m.get("node") - if nd is None: - continue - online_tot += 1 - online_hits += 1 if m.get("online") else 0 - sq = m.get("seq") - if sq is None: - continue - pr = node_seq.get(nd) - if pr and tc > pr[0]: - d = delta_wrap(pr[1], sq, 32) - if d < (1 << 31): - refresh_rates.append(d / (tc - pr[0])) - node_seq[nd] = (tc, sq) - prev, prev_t = cur, tc - time.sleep(max(0.0, interval - (time.perf_counter() - tc))) - - dur = max(1e-6, time.perf_counter() - (t_end - dwell)) - stf = post(url, "/api/motor/state") - tend = post(url, "/api/app/load") - pid1, _ = find_native_pid(url) - skip1 = (post(url, "/api/motor/dbg")).get("skip_total") - cad = cadence_rings(url) - torn_cum, re_cum = tend.get("rx_torn"), tend.get("pdo_reentry") - lam = percentile(gseq_rate, 0.5) or 0 # u32 gseq 口径,主用 - lam_u16 = percentile(rx_rate, 0.5) or 0 # u16 rx_count 口径,仅作交叉核对 - dropr = percentile(drop_rate, 0.5) or 0 - hz_meas = percentile(cyc_hz, 0.5) or 0 - eff_ms = float(stf.get("period_ms") or ms) - cad_hist = cad.get("count_hist") or {} - mode_cnt = max(cad_hist, key=lambda k: cad_hist[k]) if cad_hist else None - rec = { - "period_ms": ms, "set_period_ms": eff_ms, "hijacked": hijacked, - "native_pid": pid0, "pid_stable": "yes" if pid0 == pid1 else "NO", - "measured_hz": round(hz_meas, 1), - "achieve_pct": round(hz_meas / (1000.0 / eff_ms) * 100, 1) if eff_ms else None, - "can_lambda_per_s": round(lam, 1), "lambda_u16_crosscheck": round(lam_u16, 1), - "drop_per_s": round(dropr, 1), - "drop_pct_of_lambda": round(dropr / lam * 100, 2) if lam else None, - "drop_untrusted": untrusted, - "rxb_count_mode_per_cycle": mode_cnt, - "rxb_lost_max": cad.get("lost_max"), "rxb_pending_max": cad.get("pending_max"), - "motors_online": sum(1 for m in (stf.get("motors") or []) if m.get("online")), - "online_frac": round(online_hits / online_tot, 3) if online_tot else None, - "motor_refresh_per_s": round(percentile(refresh_rates, 0.5) or 0, 1), - "skip_per_s": round(((skip1 or 0) - (skip0 or 0)) / dur, 2), - "skip_delta": (skip1 or 0) - (skip0 or 0), - "torn_per_s": round(torn_sum / dur, 3), - "torn_cum": torn_cum, "torn_capped": "yes" if torn_cap else "no", - "reentry_per_s": round(reentry_sum / dur, 3), - "reentry_cum": re_cum, "reentry_capped": "yes" if reentry_cap else "no", - "wkc": stf.get("wkc"), "expected_wkc": stf.get("expected_wkc"), - "wr": f"{wr0}->{stf.get('wr')}", - "cyc_p50_us": round(percentile(cyc_p50, 0.5) or 0, 1), - "cyc_p90_us": round(percentile(cyc_p90, 0.9) or 0, 1), - "cyc_max_us": round(max(cyc_max), 1) if cyc_max else None, - "cyc_duty_pct": round((percentile(cyc_p50, 0.5) or 0) / (eff_ms * 1000) * 100, 1), - "gd32_load_pct": round(percentile(loads, 0.5) or 0, 1) if loads else None, - "gd32_load_samples": len(loads), "gd32_load_stale": load_stale, - "host_cpu_1core_pct": round(percentile(hc1, 0.5) or 0, 1), - "host_cpu_total_pct": round(percentile(hct, 0.5) or 0, 2), - "logical_cpus": os.cpu_count(), "samples": iters, "errors": err, - "dwell_s": round(dur, 1), "note": "; ".join(note)} - return rec - - -def main(): - ap = argparse.ArgumentParser(description="ECAT 周期-故障率-CPU 扫描") - ap.add_argument("--url", default="http://127.0.0.1:8090") - ap.add_argument("--periods", default=DEFAULT_PERIODS, help="逗号分隔周期(ms)") - ap.add_argument("--dwell", type=float, default=60.0, help="每档采样秒数") - ap.add_argument("--hz", type=float, default=10.0, - help="HTTP 采样频率;u16 drop 在 4000/s 下 16s 绕一圈,须够高") - ap.add_argument("--restore", type=float, default=None, help="测完恢复到的周期(ms)") - ap.add_argument("--substr", default="I210") - ap.add_argument("--no-autostart", action="store_true") - ap.add_argument("--out", default=os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lab", "ecat_rate_sweep.csv")) - ap.add_argument("--dry-run", action="store_true") - a = ap.parse_args() - - st = post(a.url, "/api/motor/state") - if not (st.get("ok") and st.get("cycles")) and a.no_autostart: - print("循环没跑且 --no-autostart:", st.get("log")) - return 2 - orig = st.get("period_ms") - pid0, _ = find_native_pid(a.url) - periods = [float(x) for x in a.periods.split(",")] - print(f"服务 {a.url} · native PID {pid0} · 原周期 {orig}ms · 档位 {periods} · " - f"每档 {a.dwell}s @ {a.hz:g}Hz") - fw = firmware_identity() - print(f"固件台账 {str(fw.get('ledger'))[:16]}… 匹配={fw.get('match') or fw.get('error') or '无'}") - print("提示:确认没有任何电机页标签开着,否则周期会被 localStorage 拽回去\n") - - rows, interrupted = [], False - signal.signal(signal.SIGINT, lambda *_: None) - try: - for ms in periods: - try: - rec = sample_period(a.url, ms, a.dwell, a.hz, a.substr, - not a.no_autostart, a.dry_run) - except Exception as e: - print(f"{ms}ms 档异常: {type(e).__name__}: {e}") - break - rows.append(rec) - if rec.get("note") == "dry-run 未下发": - print(f"[dry-run] {ms}ms 将下发,未执行") - continue - print(f"{ms:>7}ms 生效={rec['set_period_ms']} 劫持={rec['hijacked']} " - f"{rec['measured_hz']}Hz/{rec['achieve_pct']}% λ={rec['can_lambda_per_s']} " - f"drop={rec['drop_per_s']}/s({rec['drop_pct_of_lambda']}%) " - f"skip={rec['skip_per_s']}/s GD32={rec['gd32_load_pct']}% " - f"host={rec['host_cpu_1core_pct']}% wkc={rec['wkc']}/{rec['expected_wkc']}") - if rec["note"]: - print(f" note: {rec['note']}") - if rec["pid_stable"] == "NO": - print(" !! native PID 变了,该档及之后数据作废,停止扫描") - break - if rec["wkc"] != rec["expected_wkc"]: - print(" !! wkc 不达预期(从站丢帧/掉 OP 前兆),停止继续加压") - break - except KeyboardInterrupt: - interrupted = True - print("\n收到 Ctrl-C,正在恢复原周期…") - finally: - restore = a.restore if a.restore is not None else orig - if restore is not None and not a.dry_run: - try: - post(a.url, "/api/motor/period", {"period_ms": float(restore)}) - back = (post(a.url, "/api/motor/state")).get("period_ms") - print(f"\n已恢复周期 -> 设定 {restore}ms,实际回读 {back}ms") - except Exception as e: - print(f"\n!! 恢复周期失败({e}),请手动确认总线周期") - - if not rows or a.dry_run: - return 0 - os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) - with open(a.out, "w", newline="", encoding="utf-8") as f: - w = csv.DictWriter(f, fieldnames=COLS, extrasaction="ignore") - w.writeheader() - w.writerows(rows) - print(f"扫描数据 {len(rows)} 档 -> {a.out}") - if interrupted: - print("(本次为中断后的部分数据)") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/test_ecat_dir_worktree.mjs b/scripts/test_ecat_dir_worktree.mjs deleted file mode 100644 index a4b5e1f..0000000 --- a/scripts/test_ecat_dir_worktree.mjs +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 从 git worktree 启动 esp32_server 时,ECAT_DIR 必须指回主检出的 - * tools/ecat,而不是 .../ESP32/.worktrees/ecat。 - * 复刻 _resolve_ecat_dir 的纯路径逻辑(无 DOM/无网络)。 - */ -import assert from "node:assert/strict"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -/** 与 esp32_server._resolve_ecat_dir 同构(JS 侧只测路径形状)。 */ -function resolveEcatDir(baseDir) { - let d = path.resolve(baseDir); - const parent = path.dirname(d); - if (path.basename(parent) === ".worktrees") { - d = path.dirname(parent); - } - return path.normalize(path.join(d, "..", "ecat")); -} - -let failed = 0; -function check(name, fn) { - try { - fn(); - console.log(" ✓", name); - } catch (e) { - failed++; - console.log(" ✗", name, "—", e.message); - } -} - -console.log("[worktree ECAT_DIR]"); - -check("主检出 BASE_DIR → 兄弟 tools/ecat", () => { - const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32"); - const got = resolveEcatDir(base); - assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got); -}); - -check("worktree BASE_DIR → 仍指主检出的 tools/ecat", () => { - const base = path.resolve("G:/Robot_v2/DCU/tools/ESP32/.worktrees/device-link"); - const got = resolveEcatDir(base); - assert.equal(got, path.resolve("G:/Robot_v2/DCU/tools/ecat"), got); -}); - -check("嵌套 slug 也走 .worktrees 父级", () => { - const base = path.resolve("G:/x/tools/ESP32/.worktrees/foo-bar"); - const got = resolveEcatDir(base); - assert.equal(got, path.resolve("G:/x/tools/ecat"), got); -}); - -/* BASE_DIR 恒为 esp32_server.py 所在目录;普通主检出已在第一项覆盖。 */ - -if (failed) { - console.log(`失败 ${failed} 项`); - process.exit(1); -} -console.log("通过 3 项,失败 0 项"); diff --git a/scripts/test_motor_pair_n2_n3.py b/scripts/test_motor_pair_n2_n3.py deleted file mode 100644 index c9a81c2..0000000 --- a/scripts/test_motor_pair_n2_n3.py +++ /dev/null @@ -1,1132 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""N2/N3 对接电机诊断发令(ECAT)。 - -前提:用户保证机构处于安全状态(无障碍、可运动)。 -默认参数偏保守:span=200°,vel=80°/s,行程 hold 短,全程可 Ctrl+C 急停。 - -子命令: - status 只读:链路 / 状态 / CONFIG - align 下发统一 CONFIG(mode/limitV/PID)+ 双机 CMD - openloop 开环速度对比:双机同 v,采 速度/电流/积分 - stop 巡航后同时刹停,测停车滞后与 ∫(v2+v3) - trip 位置行程(mode3):同向或镜像,采全程积分 - full 依次跑 align → openloop → stop → trip,并汇总 JSON - stopnow 立即发速度=0(急停) - -示例: - python scripts/test_motor_pair_n2_n3.py status - python scripts/test_motor_pair_n2_n3.py align --limit-v 22.0 --pid 100 - python scripts/test_motor_pair_n2_n3.py stop --vel 80 --cruise 1.2 - python scripts/test_motor_pair_n2_n3.py full --mode-mirror --span 200 - python scripts/test_motor_pair_n2_n3.py stopnow -""" -from __future__ import annotations - -import argparse -import csv -import http.client -import json -import os -import struct -import sys -import threading -import time -from collections import defaultdict - -HOST, PORT = "127.0.0.1", 8090 -NODES = (2, 3) -LAB = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "lab") -PERIOD = 0.25 # ms,以 motor/state 为准 - -_tls = threading.local() -_lock = threading.Lock() -_stop_flag = threading.Event() -_rows: list = [] -_seen: dict = {} - - -# ───────────────────────── HTTP / ECAT ───────────────────────── - -def post(path: str, body: bytes | None = None, timeout: float = 8.0) -> dict: - if body is None: - body = b"{}" - c = getattr(_tls, "c", None) - if c is None: - c = _tls.c = http.client.HTTPConnection(HOST, PORT, timeout=timeout) - for _ in range(2): - try: - c.request("POST", path, body=body, - headers={"Content-Type": "application/json"}) - return json.loads(c.getresponse().read().decode("utf-8", "replace")) - except Exception: - try: - c.close() - except Exception: - pass - _tls.c = c = http.client.HTTPConnection(HOST, PORT, timeout=timeout) - raise RuntimeError(f"POST {path} failed") - - -def ensure_ecat() -> tuple[float, dict]: - st = post("/api/motor/state") - period = st.get("period_ms") or PERIOD - wkc = str(st.get("wkc") or "") - motors = st.get("motors") or [] - if wkc not in ("3", "3/3") or len(motors) < 2: - print("ECAT 未就绪 wkc=%s motors=%d → motor/start" % (wkc, len(motors))) - r = post("/api/motor/start", - json.dumps({"period_ms": 0.25, "dc_cycle_us": 250}).encode(), - timeout=35.0) - print(" start:", json.dumps(r, ensure_ascii=False)[:240]) - time.sleep(0.4) - st = post("/api/motor/state") - period = st.get("period_ms") or PERIOD - return period, st - - -# ───────────────────────── CMD 编码 ───────────────────────── - -# ───────────────── 安全:已机械对接,硬联锁 ───────────────── -# 规则(不可绕过,除非命令行 --i-understand-coupled-risk 且仍受硬限位约束): -# 1) 只允许 mode3 + 有限角度目标,两轴目标必须互为相反数(镜像)。 -# 2) 禁止 mode2 开环持续转(对接后会一直拧)。 -# 3) 速度只表示「走向目标的速率」,符号必须 = sign(target - current)。 -# 4) 发令前若 |a2+a3| 过大(错位)→ 拒绝运动,要求人工回零。 -# 5) 硬限位:|ang|、目标、单次行程超限 → 拒绝。 -# 6) 运行中看门狗:一台已停(|v|<8)另一台仍 |v|>40 超过 300ms → 双机急停。 -FORCE_MIRROR = True -MAX_ABS_ANGLE = 2500.0 # 编码器绝对角限(多圈保护) -MAX_STROKE = 1200.0 # 单次目标相对当前位置最大行程 -MAX_ASUM_ERR = 40.0 # |a2+a3| 超过则判定错位,禁止运动 -STOP_VEL_EPS = 8.0 -SPIN_VEL_WARN = 40.0 -ASYMMETRY_MS = 300.0 - - -def _read_pair(): - snap = snap_motors() - a2 = (snap.get(2) or {}).get("ang") - v2 = (snap.get(2) or {}).get("vel") - a3 = (snap.get(3) or {}).get("ang") - v3 = (snap.get(3) or {}).get("vel") - return a2, v2, a3, v3, snap - - -def safety_precheck_motion(target2, target3, speed, force=False): - """运动前硬检查。返回 (ok, msg, items)。items 为带符号速度的 CMD。""" - a2, v2, a3, v3, snap = _read_pair() - if a2 is None or a3 is None: - return False, "N2/N3 状态缺失,拒绝运动", None - if abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE: - return False, ("角度超硬限位 a2=%.1f a3=%.1f(限±%.0f)——请人工回位" - % (a2, a3, MAX_ABS_ANGLE)), None - if abs(target2) > MAX_ABS_ANGLE or abs(target3) > MAX_ABS_ANGLE: - return False, "目标超硬限位", None - if abs(target2 + target3) > 1.0: - return False, "目标必须镜像:target2+target3=0(得到 %s/%s)" % (target2, target3), None - if abs(target2 - a2) > MAX_STROKE or abs(target3 - a3) > MAX_STROKE: - return False, ("单次行程过大 Δ2=%.1f Δ3=%.1f(限±%.0f)" - % (target2 - a2, target3 - a3, MAX_STROKE)), None - asum = a2 + a3 - if abs(asum) > MAX_ASUM_ERR and not force: - return False, ("错位过大 a2+a3=%.1f°(a2=%.1f a3=%.1f)。" - "禁止电控继续跑,请先人工把机构回到对称位(建议 a2≈-a3,和≈0)再测。" - % (asum, a2, a3)), None - # 方向:必须朝向各自目标 - v2s = 0.0 if abs(target2 - a2) < 0.5 else (speed if target2 > a2 else -speed) - v3s = 0.0 if abs(target3 - a3) < 0.5 else (speed if target3 > a3 else -speed) - # 对接刚性镜像:位移方向应相反。若当前接近对称却要求同向位移 → 拒绝 - if abs(asum) <= MAX_ASUM_ERR: - d2, d3 = target2 - a2, target3 - a3 - if abs(d2) > 1 and abs(d3) > 1 and d2 * d3 > 0: - return False, ("拒绝:镜像机构不允许同向位移 Δ2=%.1f Δ3=%.1f" - % (d2, d3)), None - # 速度也应反号 - if abs(v2s) > 0.5 and abs(v3s) > 0.5 and v2s * v3s > 0: - return False, "拒绝:速度同号 v2=%s v3=%s" % (v2s, v3s), None - items = [(2, float(v2s), float(target2)), (3, float(v3s), float(target3))] - return True, ("precheck ok a2=%.2f a3=%.2f asum=%.2f cmd=%s" - % (a2, a3, asum, items)), items - - -def _check_pair(items, allow_same=False): - """双机指令必须:角度目标镜像;速度与目标方向一致且反号。""" - if not FORCE_MIRROR: - return - d = {int(n): (float(v), float(a)) for n, v, a in items} - if 2 not in d or 3 not in d: - return - v2, t2 = d[2] - v3, t3 = d[3] - if abs(t2 + t3) > 1.0 and (abs(t2) > 1 or abs(t3) > 1): - raise RuntimeError("SAFETY: 角度目标必须镜像 t2=%s t3=%s" % (t2, t3)) - if abs(v2) > 0.5 and abs(v3) > 0.5 and v2 * v3 > 0: - raise RuntimeError("SAFETY: 速度禁止同号 v2=%s v3=%s(会拧线)" % (v2, v3)) - a2, cur_v2, a3, cur_v3, _ = _read_pair() - if a2 is not None and a3 is not None: - if abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE: - raise RuntimeError("SAFETY: 编码器超限 a2=%.1f a3=%.1f" % (a2, a3)) - if abs(v2) > 0.5 and abs(t2 - a2) > 0.5: - if v2 * (t2 - a2) < 0: - raise RuntimeError("SAFETY: N2 速度方向与目标相反 v=%s a=%.1f t=%.1f" - % (v2, a2, t2)) - if abs(v3) > 0.5 and abs(t3 - a3) > 0.5: - if v3 * (t3 - a3) < 0: - raise RuntimeError("SAFETY: N3 速度方向与目标相反 v=%s a=%.1f t=%.1f" - % (v3, a3, t3)) - - -def _watchdog_stop(reason): - print("!! WATCHDOG STOP:", reason) - for _ in range(6): - try: - post("/api/motor/send", json.dumps({"frames": [ - {"ch": 2, "id": hex((2 << 16) | 0x0200), "dlc": 32, - "data": pack_cmd(mode=3, voltage=24, velocity=0, - angle=(_read_pair()[0] or 0), pid=(100, 100, 100))}, - {"ch": 2, "id": hex((3 << 16) | 0x0200), "dlc": 32, - "data": pack_cmd(mode=3, voltage=24, velocity=0, - angle=(_read_pair()[2] or 0), pid=(100, 100, 100))}, - ]}).encode()) - except Exception: - pass - time.sleep(0.03) - a2, v2, a3, v3, _ = _read_pair() - print(" after stop a2=%s v2=%s a3=%s v3=%s" % (a2, v2, a3, v3)) - - -def pack_cmd(mode=3, voltage=24.0, current=0.0, velocity=100.0, - angle=0.0, pid=(100, 100, 100)) -> str: - """与 pages/motor.html buildCmdBytes 一致(DLC32)。""" - b = bytearray(32) - b[0] = int(mode) & 0xFF - struct.pack_into(" str: - """CONFIG 帧与 CMD 同布局(回声/写参数用)。""" - return pack_cmd(mode=mode, voltage=limit_v, current=current, - velocity=velocity, angle=angle, pid=(0, 0, 0)) - - -def burst_cmds(items, mode=3, voltage=24.0, pid=(100, 100, 100), - allow_same_dir=False): - """items: [(node, velocity, angle)] 一次 POST 同一 seq 沿。""" - _check_pair(items, allow_same=allow_same_dir) - frames = [] - for n, vel, ang in items: - frames.append({ - "ch": 2, - "id": hex((int(n) << 16) | 0x0200), - "dlc": 32, - "data": pack_cmd(mode=mode, voltage=voltage, velocity=vel, - angle=ang, pid=pid), - }) - return post("/api/motor/send", json.dumps({"frames": frames}).encode()) - - -def send_config(cfg: dict, nodes=NODES): - """下发统一 CONFIG。cfg: {mode, limit_v, velocity, angle}""" - frames = [] - for n in nodes: - frames.append({ - "ch": 2, - "id": hex((int(n) << 16) | 0x0200), - "dlc": 32, - "data": pack_config(mode=cfg.get("mode", 3), - limit_v=cfg.get("limit_v", 22.0), - velocity=cfg.get("velocity", 100.0), - angle=cfg.get("angle", 0.0)), - }) - return post("/api/motor/send", json.dumps({"frames": frames}).encode()) - - -# ───────────────────────── 状态 ───────────────────────── - -def decode_status(raw: bytes): - if len(raw) < 21: - return None - ang = struct.unpack_from("= 13 else None, - "ucur": struct.unpack_from("= 6 else None, - "rt_cur": struct.unpack_from("= 25 else 0.0, - "motT": struct.unpack_from("= 4 else None, - "b10": raw[10] if len(raw) > 10 else 0, - } - return d - - -def snap_motors() -> dict: - out = {} - st = post("/api/motor/state") - for m in st.get("motors") or []: - n = m.get("node") - if n not in NODES: - continue - dec = decode_status(bytes.fromhex(m.get("data") or "")) - if dec: - dec["online"] = bool(m.get("online")) - dec["seq"] = m.get("seq") - out[n] = dec - out["_cycles"] = st.get("cycles") - out["_period_ms"] = st.get("period_ms") - return out - - -def read_configs_from_rx() -> dict: - cfgs = {} - try: - rx = post("/api/motor/rx") - except Exception: - return cfgs - for f in (rx.get("frames") or rx.get("parsed") or []): - if not isinstance(f, dict): - continue - fid = f.get("id", 0) - try: - if isinstance(fid, str): - fid = int(fid, 0) - except Exception: - continue - if (fid & 0xFFFF) != 0x0101: - continue - n = (fid >> 16) & 0xFF - if n not in NODES: - continue - try: - raw = bytes.fromhex(f.get("data") or "") - except Exception: - continue - if len(raw) >= 21: - cfgs[n] = { - "mode": raw[0], - "limit_V": struct.unpack_from("= s[-1]["cyc"]: - return s[-1]["ang"], s[-1]["vel"] - lo, hi = 0, len(s) - 1 - while lo + 1 < hi: - mid = (lo + hi) // 2 - if s[mid]["cyc"] <= cyc: - lo = mid - else: - hi = mid - c0, a0, v0 = s[lo]["cyc"], s[lo]["ang"], s[lo]["vel"] - c1, a1, v1 = s[hi]["cyc"], s[hi]["ang"], s[hi]["vel"] - if c1 == c0: - return a1, v1 - f = (cyc - c0) / (c1 - c0) - return a0 + (a1 - a0) * f, v0 + (v1 - v0) * f - - out = [] - for r in s2: - a3, v3 = interp(3, r["cyc"]) - out.append((r["cyc"], r["wall"], r["ang"], r["vel"], a3, v3, - r["vel"] + v3, r["ang"] + a3, r["rt_cur"])) - return out - - -def trapz_vel(series, period_ms, key_v2=3, key_v3=5): - """series 对齐列表 → (I2, I3, Isum) 全程梯形积分。""" - I2 = I3 = 0.0 - for i in range(1, len(series)): - dt = (series[i][0] - series[i - 1][0]) * period_ms / 1000.0 - if dt < 0: - dt = 0 - I2 += 0.5 * (series[i][key_v2] + series[i - 1][key_v2]) * dt - I3 += 0.5 * (series[i][key_v3] + series[i - 1][key_v3]) * dt - return I2, I3, I2 + I3 - - -def write_series_csv(path, series, meta=None): - with open(path, "w", newline="", encoding="utf-8") as fp: - w = csv.writer(fp) - w.writerow(["cycles", "wall", "a2", "v2", "a3", "v3", "vsum", "asum", "rt_cur2"]) - for row in series: - w.writerow(row) - if meta: - fp.write("# meta " + json.dumps(meta, ensure_ascii=False) + "\n") - - -def analyze_pair(series, period_ms, label=""): - if len(series) < 40: - return {"label": label, "error": "too_few_samples", "n": len(series)} - mov = [r for r in series if abs(r[3]) > 15 or abs(r[5]) > 15] - cr = [r for r in series if abs(r[3]) > 80 and abs(r[5]) > 80] - I2, I3, Isum = trapz_vel(series, period_ms) - out = { - "label": label, - "n": len(series), - "I2": I2, "I3": I3, "I_sum_v2_plus_v3": Isum, - "asum_start_end": [series[0][7], series[-1][7]], - "asum_delta": series[-1][7] - series[0][7], - "a2_start_end": [series[0][2], series[-1][2]], - "a3_start_end": [series[0][4], series[-1][4]], - } - if cr: - out["cruise_mean_abs_v2"] = sum(abs(r[3]) for r in cr) / len(cr) - out["cruise_mean_abs_v3"] = sum(abs(r[5]) for r in cr) / len(cr) - out["cruise_mean_vsum"] = sum(r[6] for r in cr) / len(cr) - out["cruise_rms_vsum"] = (sum(r[6] ** 2 for r in cr) / len(cr)) ** 0.5 - if mov: - out["mov_mean_abs_v2"] = sum(abs(r[3]) for r in mov) / len(mov) - out["mov_mean_abs_v3"] = sum(abs(r[5]) for r in mov) / len(mov) - out["mov_rms_vsum"] = (sum(r[6] ** 2 for r in mov) / len(mov)) ** 0.5 - out["mov_max_abs_vsum"] = max(abs(r[6]) for r in mov) - return out - - -# ───────────────────────── 连续发令 ───────────────────────── - -def drive(items, duration, rate=25.0, mode=3, voltage=24.0, pid=(100, 100, 100), - allow_same_dir=False): - """连续发令 + 看门狗。已对接:仅 mode3 + 镜像角度目标 + 低速优先。""" - if mode != 3: - raise RuntimeError("SAFETY: 已对接仅允许 mode3,禁止 mode=%s" % mode) - _check_pair(items, allow_same=allow_same_dir) - t_end = time.perf_counter() + duration - dt = 1.0 / max(rate, 1.0) - n_ok = 0 - last_err = None - spin_t0 = None - while time.perf_counter() < t_end: - t0 = time.perf_counter() - try: - burst_cmds(items, mode=mode, voltage=voltage, pid=pid, - allow_same_dir=allow_same_dir) - n_ok += 1 - except Exception as e: - last_err = str(e) - print(" send err:", e) - if "SAFETY" in str(e): - _watchdog_stop(str(e)) - break - try: - a2, v2, a3, v3, _ = _read_pair() - if v2 is not None and v3 is not None: - s2, s3 = abs(v2) < STOP_VEL_EPS, abs(v3) < STOP_VEL_EPS - spin = abs(v2) > SPIN_VEL_WARN or abs(v3) > SPIN_VEL_WARN - if (s2 != s3) and spin: - if spin_t0 is None: - spin_t0 = time.perf_counter() - elif (time.perf_counter() - spin_t0) * 1000.0 >= ASYMMETRY_MS: - _watchdog_stop("一台已停另一台仍在转 v2=%.1f v3=%.1f" % (v2, v3)) - break - else: - spin_t0 = None - if a2 is not None and a3 is not None and ( - abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE): - _watchdog_stop("角度超限 a2=%.1f a3=%.1f" % (a2, a3)) - break - except Exception: - pass - pad = dt - (time.perf_counter() - t0) - if pad > 0: - time.sleep(pad) - return n_ok, last_err - - -def hold_position(pid=(100, 100, 100), voltage=24.0, rate=20.0, seconds=0.4, - targets=None): - snap = snap_motors() - if targets is None: - targets = [] - for n in NODES: - if n in snap: - targets.append((n, 0.0, snap[n]["ang"])) - else: - targets.append((n, 0.0, 0.0)) - return drive(targets, seconds, rate=rate, mode=3, voltage=voltage, pid=pid) - - -# ───────────────────────── 子命令 ───────────────────────── - -def cmd_status(_args): - period, st = ensure_ecat() - print("链路 wkc=%s period=%sms cycles=%s motors=%s" - % (st.get("wkc"), period, st.get("cycles"), - [(m.get("node"), m.get("online"), m.get("seq")) - for m in st.get("motors") or []])) - print("\n--- live STATUS ---") - snap = snap_motors() - for n in NODES: - d = snap.get(n) - if not d: - continue - print(" N%s ang=%.3f vel=%.2f pwr=%s motT=%s b10=%s online=%s" - % (n, d["ang"], d["vel"], d["pwr"], d["motT"], d["b10"], d["online"])) - print("\n--- CONFIG from /api/motor/rx ---") - cfgs = read_configs_from_rx() - if not cfgs: - print(" (rx 暂无 0x0101 CONFIG,可先发一帧或等电机上报)") - for n, c in sorted(cfgs.items()): - print(" N%s %s" % (n, c)) - return 0 - - -def cmd_align(args): - period, st = ensure_ecat() - print("align: limitV=%.2f PID=%s mode=%s" % (args.limit_v, args.pid, args.mode)) - print("before CONFIG:", json.dumps(read_configs_from_rx(), ensure_ascii=False)) - r = send_config({"mode": args.mode, "limit_v": args.limit_v, - "velocity": args.vel, "angle": 0.0}) - print("config send:", r) - time.sleep(0.15) - # 用统一参数发 CMD(速度 0,保持) - snap = snap_motors() - items = [] - for n in NODES: - ang = snap.get(n, {}).get("ang", 0.0) - items.append((n, 0.0, ang)) - for _ in range(int(args.hold * 25)): - burst_cmds(items, mode=args.mode, voltage=args.limit_v + 2, - pid=tuple(args.pid)) - time.sleep(0.04) - print("after CONFIG:", json.dumps(read_configs_from_rx(), ensure_ascii=False)) - print("live:", json.dumps( - {str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)}, - ensure_ascii=False)[:400]) - return 0 - - -def cmd_openloop(args): - """已对接:禁止 mode2 开环。只做短时 mode3 镜像微动(±30°)测响应。""" - period, _ = ensure_ecat() - print("SAFETY: 对接后禁止 open-loop mode2。改为 mode3 镜像小行程 ±20° @ vel≤50") - speed = min(abs(args.vel), 50.0) - span = 20.0 - a2, _, a3, _, _ = _read_pair() - if a2 is None or a3 is None: - print("无状态") - return 2 - # 在当前位置附近做镜像微动:目标 = 当前 ± 小量,并强制 target2=-target3 - # 仅当当前已接近对称时允许 - if abs(a2 + a3) > MAX_ASUM_ERR: - print("拒绝:a2+a3=%.1f 错位过大,先人工回位" % (a2 + a3)) - return 2 - t2 = span if a2 < 0 else -span - t3 = -t2 - # 若当前位置离目标太近/太远则改用以 0 为中心 - t2, t3 = span, -span - ok, msg, items = safety_precheck_motion(t2, t3, speed) - print(msg) - if not ok: - return 2 - ths = start_poll(args.threads) - t0 = time.time() - drive(items, args.cruise, args.rate, mode=3, voltage=args.voltage, - pid=tuple(args.pid)) - # 回到对称位附近 0/0 或停在当前位置 - ok2, msg2, items2 = safety_precheck_motion(0.0, 0.0, speed) - print(msg2) - if ok2: - drive(items2, args.settle, args.rate, mode=3, voltage=args.voltage, - pid=tuple(args.pid)) - t1 = time.time() - stop_poll(ths) - series = aligned_series() - period = post("/api/motor/state").get("period_ms") or period - tag = "micro_mirror" - csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag) - meta = {"test": "micro_mirror_mode3", "span": span, "vel": speed, - "t0": t0, "t1": t1} - write_series_csv(csv_path, series, meta) - an = analyze_pair(series, period, label=tag) - an["csv"] = csv_path - an["command"] = meta - jpath = os.path.join(LAB, "pair_test_%s.json" % tag) - with open(jpath, "w", encoding="utf-8") as fp: - json.dump(an, fp, ensure_ascii=False, indent=2) - print(json.dumps(an, ensure_ascii=False, indent=2)) - print("CSV", csv_path, "JSON", jpath) - return 0 - - -def cmd_stop(args): - """镜像 mode3:短行程后停在目标,禁止开环刹停测法。""" - period, _ = ensure_ecat() - speed = min(abs(args.vel), 80.0) - print("stop-test:对接机构仅允许 mode3 镜像行程后自然停靠(禁止 mode2)") - a2, _, a3, _, _ = _read_pair() - if a2 is None: - return 2 - # 从对称位附近:先到 ±span,再回 0(行程用 stop.hold) - span = min(args.span if hasattr(args, "span") else 150.0, 200.0) - ok, msg, items = safety_precheck_motion(span, -span, speed) - print("leg1", msg) - if not ok: - return 2 - ths = start_poll(args.threads) - t0 = time.time() - drive(items, args.cruise + 1.5, args.rate, mode=3, - voltage=args.voltage, pid=tuple(args.pid)) - t_cmd_stop = time.time() - ok2, msg2, items2 = safety_precheck_motion(0.0, 0.0, speed) - print("leg2→0", msg2) - if ok2: - drive(items2, args.hold + 1.5, args.rate, mode=3, - voltage=args.voltage, pid=tuple(args.pid)) - t1 = time.time() - stop_poll(ths) - series = aligned_series() - period = post("/api/motor/state").get("period_ms") or period - tag = "stop_mirror_mode3" - csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag) - meta = {"test": "stop_mirror_mode3", "span": span, "vel": speed, - "t0": t0, "t_mid": t_cmd_stop, "t1": t1} - write_series_csv(csv_path, series, meta) - an = analyze_pair(series, period, label=tag) - an["csv"] = csv_path - an["command"] = meta - an["live_end"] = {str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)} - jpath = os.path.join(LAB, "pair_test_%s.json" % tag) - with open(jpath, "w", encoding="utf-8") as fp: - json.dump(an, fp, ensure_ascii=False, indent=2) - print(json.dumps(an, ensure_ascii=False, indent=2)) - print("CSV", csv_path, "JSON", jpath) - return 0 - hold_position(seconds=0.35, pid=tuple(args.pid), voltage=args.voltage) - ths = start_poll(args.threads) - t0 = time.time() - print("cruise...") - n1, e1 = drive(cruise_items, args.cruise, args.rate, mode=2, - voltage=args.voltage, pid=tuple(args.pid)) - t_cmd_stop = time.time() - print("STOP → 0") - n2, e2 = drive(stop_items, args.hold, args.rate, mode=2, - voltage=args.voltage, pid=tuple(args.pid)) - t1 = time.time() - stop_poll(ths) - series = aligned_series() - period = post("/api/motor/state").get("period_ms") or period - csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag) - meta = {"test": "stop", "vel": vel, "mirror": args.mirror, - "t0": t0, "t_stop_cmd": t_cmd_stop, "t1": t1, - "sends_cruise": n1, "sends_stop": n2, "err": [e1, e2]} - write_series_csv(csv_path, series, meta) - - # 停止沿分析:以发 STOP 时刻为界 - if len(series) < 20: - print("samples too few") - return 2 - # 找 wall 接近 t_cmd_stop 的索引 - idx_stop = 0 - for i, row in enumerate(series): - if row[1] >= t_cmd_stop: - idx_stop = max(0, i - 1) - break - pre = series[:idx_stop + 1] - post_s = series[idx_stop:] - - def win_stats(seg): - if len(seg) < 5: - return None - v2 = [r[3] for r in seg] - v3 = [r[5] for r in seg] - vs = [r[6] for r in seg] - I2, I3, Isum = trapz_vel(seg, period) - return { - "n": len(seg), - "mean_v2": sum(v2) / len(v2), - "mean_v3": sum(v3) / len(v3), - "mean_abs_v2": sum(abs(x) for x in v2) / len(v2), - "mean_abs_v3": sum(abs(x) for x in v3) / len(v3), - "rms_vsum": (sum(x * x for x in vs) / len(vs)) ** 0.5, - "max_abs_vsum": max(abs(x) for x in vs), - "I2": I2, "I3": I3, "Isum": Isum, - } - - # 各轴 |v| 穿越 20 的时刻 - def cross(seg, node_i, thr=20.0, falling=True): - vi = 3 if node_i == 2 else 5 - for i in range(1, len(seg)): - v0, v1 = abs(seg[i - 1][vi]), abs(seg[i][vi]) - if falling and v0 >= thr > v1: - return seg[i - 1][0] + (v0 - thr) / (v0 - v1 + 1e-12) * (seg[i][0] - seg[i - 1][0]) - if (not falling) and v0 < thr <= v1: - return seg[i - 1][0] + (thr - v0) / (v1 - v0 + 1e-12) * (seg[i][0] - seg[i - 1][0]) - return None - - c2 = cross(post_s, 2) - c3 = cross(post_s, 3) - off_delta_ms = None - if c2 is not None and c3 is not None: - off_delta_ms = (c3 - c2) * period - - an = analyze_pair(series, period, label=tag) - an.update({ - "csv": csv_path, - "command": meta, - "pre_stop": win_stats(pre), - "post_stop": win_stats(post_s), - "off_cross20_ms_n3_minus_n2": off_delta_ms, - "verdict_hint": { - "cruise": "比 pre_stop 的 mean_abs_v2/v3 谁大", - "stop": "比 post_stop 的 max_abs_vsum 与 off_delta_ms;Δ>0 表示 N3 晚停", - "integral": "post_stop.Isum 绝对值 = 停车段被顶开的积分", - }, - }) - jpath = os.path.join(LAB, "pair_test_%s.json" % tag) - with open(jpath, "w", encoding="utf-8") as fp: - json.dump(an, fp, ensure_ascii=False, indent=2) - print(json.dumps(an, ensure_ascii=False, indent=2)) - print("CSV", csv_path, "JSON", jpath) - return 0 - - -def cmd_trip(args): - """mode3 镜像行程:n2=+span, n3=-span(已对接强制镜像)。""" - period, _ = ensure_ecat() - span = abs(args.span) - pos = [(2, span), (3, -span)] - zero = [(2, 0.0), (3, 0.0)] - tag = "trip_mirror" - print("trip %s span=±%.1f vel=%.1f hold=%.2f(强制镜像)" - % (tag, span, args.vel, args.hold)) - snap = snap_motors() - a2 = snap.get(2, {}).get("ang", 0.0) - # 选择更远的端点作为第一目标 - if abs(a2 - span) < abs(a2 - 0) and abs(a2) > span * 0.5: - first, second = zero, pos - elif abs(a2) < span * 0.5: - first, second = pos, zero - else: - first, second = zero, pos - - ths = start_poll(args.threads) - t0 = time.time() - print("leg1 →", first) - snap = snap_motors() - t2t, t3t = first[0][1], first[1][1] - drive(_mirror_cmd_items(t2t, t3t, abs(args.vel), snap), args.hold, - args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid)) - t_mid = time.time() - print("leg2 →", second) - snap = snap_motors() - t2t, t3t = second[0][1], second[1][1] - drive(_mirror_cmd_items(t2t, t3t, abs(args.vel), snap), args.hold, - args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid)) - t1 = time.time() - stop_poll(ths) - series = aligned_series() - period = post("/api/motor/state").get("period_ms") or period - csv_path = os.path.join(LAB, "pair_test_%s.csv" % tag) - meta = {"test": "trip_mirror", "span": span, "vel": args.vel, - "t0": t0, "t_mid": t_mid, "t1": t1, "coupled": True} - write_series_csv(csv_path, series, meta) - an = analyze_pair(series, period, label=tag) - an["csv"] = csv_path - an["command"] = meta - an["live_end"] = {str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)} - jpath = os.path.join(LAB, "pair_test_%s.json" % tag) - with open(jpath, "w", encoding="utf-8") as fp: - json.dump(an, fp, ensure_ascii=False, indent=2) - print(json.dumps(an, ensure_ascii=False, indent=2)) - print("CSV", csv_path, "JSON", jpath) - return 0 - - -def _mirror_cmd_items(target2, target3, speed, snap=None): - """仅通过 safety_precheck 生成 CMD;禁止把速度强行改成相反符号。""" - ok, msg, items = safety_precheck_motion(target2, target3, abs(speed)) - print(" precheck:", msg) - if not ok: - raise RuntimeError("SAFETY: " + msg) - return items - - -def cmd_lowspeed(args): - """低速镜像试控:先只读检查,再极小行程。错位大则拒绝一切运动。""" - period, st = ensure_ecat() - a2, v2, a3, v3, snap = _read_pair() - asum = None - if a2 is not None and a3 is not None: - asum = a2 + a3 - print("\n======== 低速试控前置检查 ========") - print("a2=%s a3=%s a2+a3=%s v2=%s v3=%s" % (a2, a3, asum, v2, v3)) - print("硬限位: |ang|≤%.0f |a2+a3|≤%.0f 禁止 mode2 禁止同向" % - (MAX_ABS_ANGLE, MAX_ASUM_ERR)) - if a2 is None or a3 is None: - print("结论: 状态缺失 → 不发运动指令") - return 2 - if abs(a2) > MAX_ABS_ANGLE or abs(a3) > MAX_ABS_ANGLE: - print("结论: 编码器超限 → 不发运动指令。请人工把机构回到对称位。") - return 2 - if asum is not None and abs(asum) > MAX_ASUM_ERR: - print("结论: 错位 a2+a3=%.1f° 过大 → 拒绝电控运动。" % asum) - print("请先人工盘车/松耦合,使 a2≈-a3(和接近 0),再跑 lows peed。") - return 2 - speed = min(abs(args.vel), 20.0) # 低速硬顶 20°/s - span = min(abs(args.span), 15.0) # 小行程硬顶 15° - t2, t3 = span, -span - if abs(t2 - a2) > 3 * span: - # 当前离目标太远则以当前对称附近为中心:目标 = round 到对称 - t2 = span if a2 < 0 else -span - t3 = -t2 - ok, msg, items = safety_precheck_motion(t2, t3, speed) - print("试控目标 ±%.1f° @ %.1f°/s" % (span, speed)) - print(msg) - if not ok: - print("结论: precheck 失败,不发令") - return 2 - print("发令 items:", items) - ths = start_poll(args.threads) - t0 = time.time() - n_ok, err = drive(items, max(args.hold, 2.0), rate=10.0, mode=3, - voltage=args.voltage, pid=tuple(args.pid)) - print("行程发送完成 n_ok=%s err=%s" % (n_ok, err)) - # 回 0(若仍安全) - ok2, msg2, items2 = safety_precheck_motion(0.0, 0.0, speed) - print("回零:", msg2) - if ok2: - drive(items2, max(args.hold, 2.0), rate=10.0, mode=3, - voltage=args.voltage, pid=tuple(args.pid)) - else: - # 保持当前位置 - a2, _, a3, _, _ = _read_pair() - if a2 is not None and a3 is not None: - try: - drive([(2, 0.0, a2), (3, 0.0, a3)], 0.6, rate=10.0, mode=3, - voltage=args.voltage, pid=tuple(args.pid)) - except Exception as e: - print("hold err", e) - t1 = time.time() - stop_poll(ths) - a2e, v2e, a3e, v3e, _ = _read_pair() - print("结束: a2=%s v2=%s a3=%s v3=%s asum=%s" % - (a2e, v2e, a3e, v3e, (None if a2e is None or a3e is None else a2e + a3e))) - series = aligned_series() - period = post("/api/motor/state").get("period_ms") or period - csv_path = os.path.join(LAB, "pair_test_lowspeed.csv") - meta = {"test": "lowspeed", "vel": speed, "span": span, - "t0": t0, "t1": t1, "items": items} - write_series_csv(csv_path, series, meta) - an = analyze_pair(series, period, label="lowspeed") - an["csv"] = csv_path - an["command"] = meta - an["end"] = {"a2": a2e, "v2": v2e, "a3": a3e, "v3": v3e, - "asum": None if a2e is None or a3e is None else a2e + a3e} - jpath = os.path.join(LAB, "pair_test_lowspeed.json") - with open(jpath, "w", encoding="utf-8") as fp: - json.dump(an, fp, ensure_ascii=False, indent=2) - print(json.dumps(an, ensure_ascii=False, indent=2)[:800]) - print("CSV", csv_path, "JSON", jpath) - return 0 - - -def cmd_fairtrip(args): - """公平行程:先双机 mode3 停到 0(镜像速度),再同时去 +span/-span。""" - period, _ = ensure_ecat() - span = abs(args.span) - print("fairtrip: settle@0 → mirror ±%.0f @ vel=%.1f hold=%.2f(强制镜像)" - % (span, args.vel, args.hold)) - - ths = start_poll(args.threads) - t0 = time.time() - print("1) 同步到 0(速度按镜像指向 0)") - items0 = _mirror_cmd_items(0.0, 0.0, abs(args.vel)) - print(" cmd", items0) - drive(items0, args.settle, - args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid)) - snap = snap_motors() - print(" at0:", {n: (snap.get(n) or {}).get("ang") for n in NODES}) - t_go = time.time() - print("2) 同时镜像 ±span") - items1 = _mirror_cmd_items(span, -span, abs(args.vel), snap) - print(" cmd", items1) - drive(items1, args.hold, - args.rate, mode=3, voltage=args.voltage, pid=tuple(args.pid)) - t1 = time.time() - stop_poll(ths) - snap = snap_motors() - print(" end:", {n: (snap.get(n) or {}).get("ang") for n in NODES}, - {n: (snap.get(n) or {}).get("vel") for n in NODES}) - - series = aligned_series() - period = post("/api/motor/state").get("period_ms") or period - csv_path = os.path.join(LAB, "pair_test_fairtrip.csv") - meta = {"test": "fairtrip", "span": span, "vel": args.vel, - "t0": t0, "t_go": t_go, "t1": t1} - write_series_csv(csv_path, series, meta) - - # 只分析 t_go 之后的行程 - seg = [r for r in series if r[1] >= t_go] - an = analyze_pair(seg, period, label="fairtrip") - an["csv"] = csv_path - an["command"] = meta - an["live_end"] = {str(n): snap.get(n) for n in NODES if snap.get(n)} - - # 到达时刻:|ang - target| < 3 且 |v| < 10 - def arrive(node, target): - vi_ang = 2 if node == 2 else 4 - vi_vel = 3 if node == 5 else 5 - vi_vel = 3 if node == 2 else 5 - for r in seg: - if abs(r[vi_ang] - target) < 3.0 and abs(r[vi_vel]) < 10.0: - return r[1] - return None - t2 = arrive(2, span) - t3 = arrive(3, -span) - an["arrive_wall"] = {"n2": t2, "n3": t3} - if t2 and t3: - an["arrive_delta_ms_n3_minus_n2"] = (t3 - t2) * 1000.0 - - # 行程中段 20%-80% 的 vsum / 巡航 - if len(seg) > 50: - w = seg[len(seg) // 5: max(len(seg) - len(seg) // 5, len(seg) // 5 + 2)] - I2, I3, Isum = trapz_vel(w, period) - an["mid_stroke"] = { - "n": len(w), - "mean_abs_v2": sum(abs(r[3]) for r in w) / len(w), - "mean_abs_v3": sum(abs(r[5]) for r in w) / len(w), - "mean_vsum": sum(r[6] for r in w) / len(w), - "rms_vsum": (sum(r[6] ** 2 for r in w) / len(w)) ** 0.5, - "max_abs_vsum": max(abs(r[6]) for r in w), - "I2": I2, "I3": I3, "Isum": Isum, - } - jpath = os.path.join(LAB, "pair_test_fairtrip.json") - with open(jpath, "w", encoding="utf-8") as fp: - json.dump(an, fp, ensure_ascii=False, indent=2) - print(json.dumps(an, ensure_ascii=False, indent=2)) - print("CSV", csv_path, "JSON", jpath) - return 0 - - -def cmd_stopnow(_args): - print("STOP NOW") - for _ in range(8): - try: - burst_cmds([(2, 0.0, 0.0), (3, 0.0, 0.0)], mode=2) - except Exception as e: - print(" ", e) - time.sleep(0.03) - print("live", json.dumps( - {str(n): snap_motors().get(n) for n in NODES if snap_motors().get(n)}, - ensure_ascii=False)[:300]) - return 0 - - -def cmd_full(args): - """对接安全套件:仅镜像 openloop / stop / fairtrip(禁止同向)。""" - results = {} - print("\n======== FULL-MIRROR: 1/4 align ========") - cmd_align(args) - - print("\n======== FULL-MIRROR: 2/4 openloop mirror ========") - cmd_openloop(args) - p_ol = os.path.join(LAB, "pair_test_openloop_mirror_vel%s.json" % abs(args.vel)) - if os.path.exists(p_ol): - results["openloop_mirror"] = json.loads(open(p_ol, encoding="utf-8").read()) - - print("\n======== FULL-MIRROR: 3/4 stop mirror ========") - cmd_stop(args) - p_st = os.path.join(LAB, "pair_test_stop_mirror.json") - if os.path.exists(p_st): - results["stop_mirror"] = json.loads(open(p_st, encoding="utf-8").read()) - - print("\n======== FULL-MIRROR: 4/4 fairtrip mirror ========") - cmd_fairtrip(args) - p_ft = os.path.join(LAB, "pair_test_fairtrip.json") - if os.path.exists(p_ft): - results["fairtrip"] = json.loads(open(p_ft, encoding="utf-8").read()) - - out = os.path.join(LAB, "pair_test_FULL_mirror_summary.json") - slim = {"coupled": True, "rule": "mirror_only_v2=-v3", "results": {}} - for k, v in results.items(): - slim["results"][k] = {kk: v.get(kk) for kk in ( - "label", "cruise_mean_abs_v2", "cruise_mean_abs_v3", - "cruise_mean_vsum", "mov_rms_vsum", "mov_max_abs_vsum", - "I_sum_v2_plus_v3", "asum_delta", "arrive_delta_ms_n3_minus_n2", - "off_cross20_ms_n3_minus_n2", "mid_stroke", "pre_stop", "post_stop", - ) if kk in v} - with open(out, "w", encoding="utf-8") as fp: - json.dump(slim, fp, ensure_ascii=False, indent=2) - print(json.dumps(slim, ensure_ascii=False, indent=2)) - print("summary ->", out) - return 0 - - -def _who_slower(an): - v2 = an.get("cruise_mean_abs_v2") - v3 = an.get("cruise_mean_abs_v3") - if not v2 or not v3: - return None - if abs(v2 - v3) < 1.0: - return "almost_equal" - return "N2_slower" if v2 < v3 else "N3_slower" - - -# ───────────────────────── CLI ───────────────────────── - -def build_parser(): - p = argparse.ArgumentParser( - description="N2/N3 对接电机诊断发令(ECAT)— 请确认机构安全") - p.add_argument("--pid", type=int, nargs=3, default=[100, 100, 100], - help="PID current/vel/angle P,默认 100 100 100") - p.add_argument("--voltage", type=float, default=24.0) - p.add_argument("--rate", type=float, default=25.0, help="发令频率 Hz") - p.add_argument("--threads", type=int, default=6) - sub = p.add_subparsers(dest="cmd", required=True) - - sub.add_parser("status", help="只读状态与 CONFIG") - slowp = sub.add_parser("lowspeed", help="低速镜像试控(错位大则拒绝)") - slowp.add_argument("--vel", type=float, default=15.0) - slowp.add_argument("--span", type=float, default=10.0) - slowp.add_argument("--hold", type=float, default=2.5) - - sp = sub.add_parser("align", help="统一 CONFIG 并保持") - sp.add_argument("--limit-v", type=float, default=22.0) - sp.add_argument("--vel", type=float, default=100.0) - sp.add_argument("--mode", type=int, default=3) - sp.add_argument("--hold", type=float, default=0.8) - - so = sub.add_parser("openloop", help="开环速度对比 mode2") - so.add_argument("--vel", type=float, default=80.0) - so.add_argument("--cruise", type=float, default=1.5) - so.add_argument("--settle", type=float, default=0.6) - - ss = sub.add_parser("stop", help="巡航→同时刹停") - ss.add_argument("--vel", type=float, default=80.0) - ss.add_argument("--cruise", type=float, default=1.2) - ss.add_argument("--hold", type=float, default=0.8) - ss.add_argument("--mirror", action="store_true", help="v2=-v3 镜像巡航") - - st = sub.add_parser("trip", help="mode3 位置行程") - st.add_argument("--span", type=float, default=200.0) - st.add_argument("--vel", type=float, default=80.0) - st.add_argument("--hold", type=float, default=2.5) - st.add_argument("--mirror", action="store_true") - - sfair = sub.add_parser("fairtrip", help="先到0再同时±span,公平对比") - sfair.add_argument("--span", type=float, default=400.0) - sfair.add_argument("--vel", type=float, default=200.0) - sfair.add_argument("--hold", type=float, default=2.5) - sfair.add_argument("--settle", type=float, default=2.0) - - sub.add_parser("stopnow", help="急停速度=0") - - sf = sub.add_parser("full", help="align+openloop+stop+trip 全套") - sf.add_argument("--vel", type=float, default=80.0) - sf.add_argument("--cruise", type=float, default=1.2) - sf.add_argument("--hold", type=float, default=2.2) - sf.add_argument("--settle", type=float, default=0.6) - sf.add_argument("--span", type=float, default=200.0) - sf.add_argument("--limit-v", type=float, default=22.0) - sf.add_argument("--mode", type=int, default=3) - sf.add_argument("--mirror", action="store_true") - return p - - -def main(argv=None): - args = build_parser().parse_args(argv) - print("⚠ 请确认电机2/3机构处于安全状态;Ctrl+C 后请手动 stopnow") - print("target:", args.cmd, "pid", args.pid) - handlers = { - "status": cmd_status, - "lowspeed": cmd_lowspeed, - "align": cmd_align, - "openloop": cmd_openloop, - "stop": cmd_stop, - "trip": cmd_trip, - "fairtrip": cmd_fairtrip, - "stopnow": cmd_stopnow, - "full": cmd_full, - } - try: - return handlers[args.cmd](args) - except KeyboardInterrupt: - print("\n!! 中断 → 急停") - try: - cmd_stopnow(args) - except Exception: - pass - return 130 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/test_pram_batch_sim.py b/scripts/test_pram_batch_sim.py deleted file mode 100644 index ceccf28..0000000 --- a/scripts/test_pram_batch_sim.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 -"""test_pram_batch_sim.py - 主机侧仿真:验证 pramRead/pramWrite 批量化(1.6.0) -与旧实现(逐 DWORD 轮询)逐字节等价。 - -模型依据 LAN9252 DS00001909A §12.11.3/12.11.4: - - PRAM FIFO 16 deep x 32bit;AVAIL_CNT(CMD bits12:8)=可连续读/写的 DWORD 数 - - 读引擎按 DWORD 对齐向下取源:第 i 个 FIFO DWORD = 源地址 aligned(addr)+4i 起 4 字节; - 线性有效窗口 = [0, len),即 DWORD i 的字节 k 有效 iff 0 <= 4i+k-lead < len - - 写引擎按同一线性窗口落盘,主站推满一个 DWORD 计一次 - - 引擎以有限速率搬运(rate),主站 respect AVAIL_CNT 就不会溢 FIFO/读空洞 - -对全部 (addr 对齐 x len 1..70/96/384/640 x 引擎节奏) 断言: -新旧算法产出逐字节一致,且主站从不超出 AVAIL_CNT(引擎内 assert)。 -""" -import random - -FIFO_DW = 16 # FIFO 深度 16 DWORD -WINDOW_DW = 8 # 别名窗口一程 8 DWORD (0x00-0x1C / 0x20-0x3C) -PRAM_BASE = 0x1000 -PRAM_SIZE = 0x1000 - - -def byte_valid(i, k, lead, ln): - """DWORD i 的字节 k 是否落在线性有效窗口 [0, ln) 内。""" - return 0 <= 4 * i + k - lead < ln - - -class ReadEngine: - def __init__(self, mem, addr, ln, rate): - self.mem = mem - self.lead = addr & 3 - self.ln = ln - self.rate = max(1, rate) - self.total_dw = (ln + self.lead + 3) // 4 - self.start = (addr & ~3) - PRAM_BASE # mem 内偏移 - self.pushed = 0 - self.popped = 0 - self.data = [] - - def pump(self): - n = 0 - while (self.pushed - self.popped < FIFO_DW and self.pushed < self.total_dw - and n < self.rate): - i = self.pushed - dw = bytearray(4) - for k in range(4): - if byte_valid(i, k, self.lead, self.ln): - dw[k] = self.mem[self.start + 4 * i + k] - self.data.append(bytes(dw)) - self.pushed += 1 - n += 1 - return n - - def avail_cnt(self): - return self.pushed - self.popped - - def pop(self): - assert self.popped < self.pushed, "主站弹出超出 AVAIL_CNT!" - dw = self.data[self.popped] - self.popped += 1 - return dw - - -def pram_read_old(eng, addr, ln, poll_budget=2000): - out = bytearray() - done = 0 - polls = 0 - while done < ln: - while eng.avail_cnt() == 0: - polls += 1 - if polls > poll_budget: - return bytes(out), False - eng.pump() - dw = eng.pop() - lead = (addr & 3) if done == 0 else 0 - take = min(ln - done, 4 - lead) - out += dw[lead:lead + take] - done += take - eng.pump() - return bytes(out), True - - -def pram_read_new(eng, addr, ln, poll_budget=2000): - out = bytearray() - done = 0 - polls = 0 - while done < ln: - while eng.avail_cnt() == 0: - polls += 1 - if polls > poll_budget: - return bytes(out), False - eng.pump() - batch = eng.avail_cnt() - if batch == 0 or batch > WINDOW_DW: - batch = 1 - lead0 = (addr & 3) if done == 0 else 0 - need_dw = (ln - done + lead0 + 3) // 4 - batch = min(batch, need_dw) - for _ in range(batch): - dw = eng.pop() - lead = (addr & 3) if done == 0 else 0 - take = min(ln - done, 4 - lead) - out += dw[lead:lead + take] - done += take - eng.pump() - return bytes(out), True - - -class WriteEngine: - def __init__(self, addr, ln, rate): - self.lead = addr & 3 - self.ln = ln - self.rate = max(1, rate) - self.total_dw = (ln + self.lead + 3) // 4 - self.start = (addr & ~3) - PRAM_BASE - self.fifo = [] - self.consumed_dw = 0 - self.mem = bytearray(PRAM_SIZE) - - def pump(self): - n = 0 - while self.fifo and n < self.rate: - dw = self.fifo.pop(0) - i = self.consumed_dw - for k in range(4): - if byte_valid(i, k, self.lead, self.ln): - self.mem[self.start + 4 * i + k] = dw[k] - self.consumed_dw += 1 - n += 1 - return n - - def space_cnt(self): - return FIFO_DW - len(self.fifo) - - def push(self, dw): - assert len(self.fifo) < FIFO_DW, "主站写溢出 FIFO!" - self.fifo.append(bytes(dw)) - - -def _write_common(eng, addr, buf, poll_budget, batched): - ln = len(buf) - done = 0 - polls = 0 - while done < ln: - while eng.space_cnt() == 0: - polls += 1 - if polls > poll_budget: - return False - eng.pump() - n_iter = 1 - if batched: - batch = eng.space_cnt() - if batch == 0 or batch > WINDOW_DW: - batch = 1 - lead0 = (addr & 3) if done == 0 else 0 - need_dw = (ln - done + lead0 + 3) // 4 - n_iter = min(batch, need_dw) - for _ in range(n_iter): - lead = (addr & 3) if done == 0 else 0 - take = min(ln - done, 4 - lead) - dw = bytearray(4) - for i in range(take): - dw[lead + i] = buf[done + i] - eng.push(dw) - done += take - eng.pump() - budget = poll_budget - while eng.consumed_dw < eng.total_dw and budget: - eng.pump() - budget -= 1 - return eng.consumed_dw == eng.total_dw - - -def pram_write_old(eng, addr, buf, poll_budget=2000): - return _write_common(eng, addr, buf, poll_budget, batched=False) - - -def pram_write_new(eng, addr, buf, poll_budget=2000): - return _write_common(eng, addr, buf, poll_budget, batched=True) - - -def run(): - random.seed(20260905) - cases = 0 - lens = list(range(1, 71)) + [96, 384, 640] # 覆盖 SM2(384B)/SM3(640B) 实际长度 - for align in range(4): - for ln in lens: - for rate in (1, 3, 16, 64): - src = bytes(random.randrange(1, 256) for _ in range(ln)) - base = random.randrange(PRAM_SIZE - ln - 8) - addr = PRAM_BASE + base + align - mem = bytearray(PRAM_SIZE) - mem[base + align: base + align + ln] = src - # 读等价:新旧都须完整取回 src - e1 = ReadEngine(mem, addr, ln, rate); e1.pump() - r_old, ok1 = pram_read_old(e1, addr, ln) - e2 = ReadEngine(mem, addr, ln, rate); e2.pump() - r_new, ok2 = pram_read_new(e2, addr, ln) - assert ok1 and ok2, f"read 未完成 align={align} len={ln} rate={rate}" - assert r_old == src, f"read 旧实现不符 align={align} len={ln} rate={rate}" - assert r_new == src, f"read 新实现不符 align={align} len={ln} rate={rate}" - # 写等价:新旧都须把 src 落到同一片 PRAM - w1 = WriteEngine(addr, ln, rate); w1.pump() - assert pram_write_old(w1, addr, src), f"write 未完成 {align}/{ln}/{rate}" - w2 = WriteEngine(addr, ln, rate); w2.pump() - assert pram_write_new(w2, addr, src), f"write 未完成 {align}/{ln}/{rate}" - assert bytes(w1.mem[base + align: base + align + ln]) == src, \ - f"write 旧不符 {align}/{ln}/{rate}" - assert bytes(w2.mem[base + align: base + align + ln]) == src, \ - f"write 新不符 {align}/{ln}/{rate}" - cases += 1 - print(f"OK: {cases} 组 (对齐x长度x引擎节奏) 新旧算法逐字节等价") - # 统计新实现的轮询次数收益(代表性场景:SM3 640B DWORD 对齐,引擎速率 16) - for rate in (1, 3, 16): - src = bytes(random.randrange(1, 256) for _ in range(640)) - e = ReadEngine(bytearray(PRAM_SIZE), PRAM_BASE, 640, rate); e.pump() - _, p1 = _count_polls(e, 640) - print(f" 640B@rate{rate}: 新算法主站外圈轮询 {p1} 次") - - -def _count_polls(eng, ln): - done = 0 - polls = 0 - while done < ln: - while eng.avail_cnt() == 0: - polls += 1 - eng.pump() - batch = min(eng.avail_cnt(), WINDOW_DW) - lead0 = 0 if done == 0 else 0 - batch = min(batch, (ln - done + lead0 + 3) // 4) - for _ in range(batch): - eng.pop() - done += 4 - return None, polls - - -if __name__ == "__main__": - run()