| @@ -1,119 +0,0 @@ | |||
| # PLSR Bug 报告(2026-08-13,逻辑分析仪实测) | |||
| > 发送对象:Codex 开发 | |||
| > 全部现象来自实板逻辑分析仪波形(6.25MS/s),运动计数均正确(1000/1000 脉冲),问题集中在**短轮廓(脉冲数不足以完成完整加减速)路径**和**段间延迟**。 | |||
| --- | |||
| ## Bug 1:短轮廓减速提前切换,尾部以停止速度慢爬(bug1.bin) | |||
| **配置**:段数 1|基准 100000Hz|启动 100Hz|停止 100Hz|加速 100ms|减速 100ms|段频率 100000Hz|脉冲数 **1000** | |||
| **现象**:总时长 **461ms**(理论 ~64ms),尾部 ~38 个脉冲以 **100Hz** 输出(每个 10ms,共 400ms)。 | |||
| 波形结构: | |||
| ``` | |||
| 100Hz →(450脉冲)→ 33kHz 峰值 →(485脉冲 减速到~8kHz)→ 跳变 100Hz×38个 → 停 | |||
| ``` | |||
| **实测 vs 理论**: | |||
| | 项 | 理论 | 实测 | | |||
| |---|---|---| | |||
| | 峰值 | ~31,623Hz((f²−10⁴)/1e6=1000 → f=√1e9) | 33,069Hz(+4.5%) | | |||
| | 加速段 | ~500 脉冲 | ~450 脉冲 | | |||
| | 减速段 | 平滑减到 100Hz,正好用完 | **减到 ~8kHz 直接跳 100Hz** | | |||
| | 总时长 | ~64ms | **461ms** | | |||
| **根因**:剩余脉冲不足以完成完整减速 ramp 时,固件直接 `ApplyFrequency(停止速度)` 把剩余脉冲发完,而不是按剩余脉冲**裁剪/重算减速斜坡**(截断三角)。 | |||
| **修复方向**:减速进入判定 `剩余脉冲 ≤ 减速所需` 时,按剩余脉冲重算减速斜率(保持三角形面积守恒),不要跳变到停止速度。 | |||
| --- | |||
| ## Bug 2:加速 ramp 消失 + 减速提前切换(bug2.bin) | |||
| **配置**:段数 1|基准 100000Hz|启动 100Hz|停止 100Hz|加速 **10ms**|减速 **10ms**|段频率 100000Hz|脉冲数 **1000** | |||
| **现象**:总时长 **524ms**(理论 ~20ms)。加速段整个丢失(**3 个脉冲**就从 100Hz 冲到 100kHz),然后 100kHz 恒速 945 个,尾部 **51 个 100Hz**(510ms)。 | |||
| 波形结构: | |||
| ``` | |||
| 100Hz →(1脉冲 10ms)→ 200Hz →(66kHz)→ 100kHz × 945个 → 100Hz × 51个 → 停 | |||
| ``` | |||
| **根因 A(加速消失)——时序错位**: | |||
| ``` | |||
| 启动速度 100Hz → 第 1 个脉冲要等 1/100Hz = 10ms 才发出 | |||
| 加速 ramp → (100000−100)×10ms/100000 = 9.99ms 就走完了 | |||
| → ramp 在第一个脉冲之前完成,加速段物理不可见 | |||
| → 首脉冲出来时频率已是 100kHz | |||
| ``` | |||
| **根因 B(减速提前切换)**:同 Bug 1。 | |||
| **修复方向**:ramp 计时起点改为**第一个脉冲发出时刻**(或段启动先应用启动速度、等首脉冲后再启动 ramp),而不是从段启动瞬间计时。 | |||
| --- | |||
| ## Bug 3:整段以启动/停止速度输出(启动速度极低场景,无波形,配置可复现) | |||
| **配置**:段数 1|基准 100000Hz|启动 **10Hz**|停止 **10Hz**|加速 **10ms**|减速 **100ms**|段频率 100000Hz|脉冲数 **1000** | |||
| **现象**:**输出 10Hz 脉冲 1000 个**(约 100 秒)。 | |||
| **数学推演**: | |||
| ``` | |||
| 启动速度 10Hz → 第 1 个脉冲要等 1/10Hz = 100ms | |||
| 完整梯形需要 → 加速500 + 减速5000 = 5500 脉冲(1000 不够 → 短轮廓) | |||
| 短轮廓总时长 → 10Hz↗42,640Hz↘10Hz ≈ 47ms | |||
| 47ms 曲线 < 100ms 首脉冲等待 → ramp 全程跑在第一个脉冲之前,消耗脉冲 = 0 | |||
| 固件曲线走完后的处理:频率回到停止速度 10Hz,剩余 1000 脉冲全部 10Hz 输出 | |||
| ``` | |||
| **根因**:Bug 2 根因 A 的极端版(ramp 时长 < 首脉冲等待时间),叠加"曲线走完但 0 脉冲消耗"的判定缺陷。 | |||
| **修复方向**:同 Bug 2 根因 A;并检查短轮廓"脉冲消耗=0"时的处理(应按首脉冲后的剩余脉冲重新规划)。 | |||
| **验证方法**:把启动速度改成 1000Hz(首脉冲 1ms < ramp 47ms),同一配置应恢复短轮廓三角——可快速验证根因。 | |||
| --- | |||
| ## Bug 4:段间 2.5ms 低电平间隙(TC-PD-011,有限路径 10 段连续执行) | |||
| **配置**:段数 10|完成模式|相对位置|恒速 1000Hz(启动=停止=段频率)|段脉冲 100~109|跳转 0 | |||
| **现象**:每个段之间固定 ~2.5ms 低电平(段 100ms 内占 2.5%)。 | |||
| **根因**:恒速小段走**有限脉冲路径**,段完成与下一段启动被拆成**两个主循环周期**(每周期 ~1ms): | |||
| ``` | |||
| 段 N 最后脉冲 → CC1 中断置 FiniteCompletionPending(IRQ 内,零延迟) | |||
| 周期 N:PlsrPoll1ms → PlsrServiceFiniteCompletion() 消费完成事件 | |||
| (诊断结算 + PlsrBoundaryPending=1)→ return ← plsr.c:4502 提前返回 | |||
| 周期 N+1:PlsrPoll1ms → PlsrHandleBoundary() → 启动段 N+1 | |||
| 延迟 = 段结束→周期N随机等待(0~1ms) + 2 周期 ≈ 2~3ms,平均 2.5ms | |||
| ``` | |||
| **对比**:非有限路径(有加减速)段完成在 IRQ 内直接置 `PlsrBoundaryPending`,段间仅 0~1ms——无此问题。 | |||
| **修复方向**:`PlsrServiceFiniteCompletion` 消费完成后**不 return**,同周期继续处理 `PlsrBoundaryPending`(去掉 plsr.c:4502 的提前返回),段间延迟降到 ~1ms。 | |||
| --- | |||
| ## 汇总与优先级 | |||
| | # | 问题 | 严重度 | 涉及路径 | 实测文件 | | |||
| |---|---|---|---|---| | |||
| | 1 | 减速预算不足时跳停止速度发完剩余 | 高 | 短轮廓/减速切换(`PlsrMaybePlanBoundaryRamp`、短轮廓边界规划) | bug1.bin、bug2.bin | | |||
| | 2 | ramp 计时从段启动起算,ramp 时长 < 首脉冲等待时加速丢失 | 高 | 段启动/ramp(`PlsrBeginSegmentOutput`→`PlsrRampStart`) | bug2.bin、Bug 3 | | |||
| | 3 | 短轮廓"0 脉冲消耗"判定 + 峰值规划偏差(31623 vs 实测 33069,加速 500 vs 450) | 中 | 短轮廓峰值/预算计算(`PlsrShortProfilePeak` 等) | bug1.bin | | |||
| | 4 | 有限路径段间 2 周期延迟(~2.5ms) | 低 | `PlsrServiceFiniteCompletion` → `PlsrPoll1ms` | TC-PD-011 | | |||
| **建议修复顺序**:Bug 1/2 根因(同一短轮廓路径)→ Bug 3(同根因极端版,改完 1/2 后复测确认)→ Bug 4(一行 return 改动)→ 峰值规划偏差(精度优化)。 | |||
| **回归验证**: | |||
| - bug1 配置预期:三角平滑到 100Hz 恰好 1000 脉冲,总时长 ~64ms,无 100Hz 尾巴 | |||
| - bug2 配置预期:10Hz→100kHz→10Hz(或短轮廓峰值),总时长 ~20ms | |||
| - Bug 3 配置预期:首脉冲等待 100ms 后短轮廓三角 ~47ms,总时长 ~147ms | |||
| - TC-PD-011 预期:段间间隙 <1ms | |||
| @@ -1,706 +0,0 @@ | |||
| # PLSR PULSE/DIR 测试用例与验收标准 | |||
| > 文档日期:2026-08-13 | |||
| > 适用分支:`codex/plsr-2026-minimal` | |||
| > 当前范围:单相 PULSE/DIR 基础功能、内部硬件计数、输出诊断 | |||
| > 暂不验收:AB 正交输出。AB 仍需单独完成硬件计数架构重构,不得用本文件的结论替代 AB 验收。 | |||
| **下述1就是“是”,0就是“否”** | |||
| ## 1. 测试目标和判定口径 | |||
| 本文件同时检查三条链路: | |||
| 1. 上位机运行状态:位置、频率、段号、状态码和错误码。 | |||
| 2. MCU 内部诊断:TIM9/TIM12 通过内部定时器连接得到的硬件周期计数,以及实际生效的 PSC/ARR/CCR 和曲线更新顺序。 | |||
| 3. 端子波形:逻辑分析仪直接检查 Y0~Y3 的实际输出脉冲和 Y12~Y15 的方向电平。 | |||
| 内部诊断不是外部端到端检测。它能发现定时器周期数、频率配置和曲线更新错误,但不能发现输出驱动、电气端子或外部接线故障。因此 P0 波形用例必须同时满足内部诊断和逻辑分析仪验收值。 | |||
| 优先级定义: | |||
| - `P0`:交付前必须通过,任一失败即不允许验收。 | |||
| - `P1`:完整功能回归必须通过。 | |||
| - `P2`:工程质量、异常和长时间稳定性测试。 | |||
| ## 2. 测试环境 | |||
| ### 2.1 软件和通信 | |||
| - 固件:本分支最新 IAR 构建并烧录的 `EWARM/Modbus/Exe/Modbus.hex`。 | |||
| - 上位机:`HostComputer/plsr_control_panel.py`。 | |||
| - 串口:`COM5`,`9600 baud`,`8E1`,Modbus RTU 从站地址 `1`。 | |||
| - 逻辑分析仪:采样率不低于被测最高频率的 20 倍;100 kHz 测试推荐 `10 MHz` 或更高。 | |||
| 启动上位机: | |||
| ```powershell | |||
| py -B HostComputer\plsr_control_panel.py | |||
| ``` | |||
| ### 2.2 接线 | |||
| - 逻辑分析仪地接 PLC 输出公共参考端。 | |||
| - CH0 接本次选择的脉冲端子 Y0、Y1、Y2 或 Y3。 | |||
| - CH1 接本次选择的方向端子 Y12、Y13、Y14 或 Y15。 | |||
| - 涉及 WAIT/EXT 的用例,再用可控 PLC 输出分别驱动 X4、X5;不执行输入用例时无需保留该接线。 | |||
| - 测试前确认输出负载和公共端连接符合板卡电气要求。 | |||
| ### 2.3 逻辑分析仪统一设置 | |||
| - 数字阈值按实际输出电平设置。 | |||
| - 普通脉冲测试使用 CH0 上升沿触发。 | |||
| - 方向切换测试采集窗口必须覆盖方向变化、方向延时和第一个脉冲。 | |||
| - 曲线和 100,000 脉冲测试应使用足够长的采集深度,禁止因缓冲区不足丢掉首尾波形。 | |||
| ### 2.4 每个运动用例的统一操作 | |||
| 除用例另有说明外,均按以下顺序执行: | |||
| 1. 在“公共参数”页选择 `PULSE/DIR (0)` 并填写公共参数。 | |||
| 2. 点击“写入公共参数”,等待写入并回读完成。 | |||
| 3. 在“10 段参数”页填写有效段;未使用段的脉冲数设为 `0`。 | |||
| 4. 点击“写入 10 段参数”,等待写入并回读完成。 | |||
| 5. 点击“清零”,再到“输出诊断”页点击“清除诊断”。 | |||
| 6. 逻辑分析仪进入等待触发状态。 | |||
| 7. 点击“启动”,等待状态进入“完成 (7)”“停止 (8)”或“错误 (9)”。 | |||
| 8. 保存上位机最终状态、诊断页数据和逻辑分析仪统计/截图。 | |||
| ## 3. 通用验收指标 | |||
| 除用例另有规定外,运动正常完成时必须同时满足: | |||
| | 项目 | 验收指标 | | |||
| |---|---| | |||
| | 最终状态 | `完成 (7)` | | |||
| | 错误码 | `0` | | |||
| | 位置 | 等于各段有符号位移之和;绝对模式按目标位置判定 | | |||
| | 内部期望脉冲 | 等于当前诊断段的脉冲绝对值 | | |||
| | 内部实测脉冲 | 与内部期望脉冲完全相等 | | |||
| | 内部计数误差 | `0` | | |||
| | 诊断 | 计数、频率、曲线均显示“通过”;首个故障为“无故障 (0)” | | |||
| | 外部脉冲个数 | 逻辑分析仪上升沿个数与设定脉冲数完全相等,不允许 ±1 | | |||
| | 稳态频率 | 相对“期望定时器频率”误差不超过 `±0.1%` 或 `±1 Hz`,取较大者 | | |||
| | 稳态占空比 | 排除首尾过渡周期后,每周期高、低电平各占 `35%~65%` | | |||
| | 窄脉冲 | 不允许出现小于稳态周期 `20%` 的异常高/低脉冲 | | |||
| | 方向信号 | 正反向电平符合逻辑设置;发波期间不得抖动 | | |||
| | 非选中输出 | Y0~Y3 中未选中的脉冲输出不得出现脉冲 | | |||
| 说明:上位机“请求频率”可能因定时器整数分频产生量化误差。外部频率应与诊断页“期望定时器频率”比较,而不是强制与请求值逐 Hz 相等。 | |||
| ## 4. P0 必测用例 | |||
| ### TC-PD-001 通信、参数写入和回读 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 | |||
| - 配置:连接 COM5 后读取全部参数;将输出模式改为 PULSE/DIR,脉冲输出 Y0,方向输出 Y12,段数 1,段 1 为 1,000 Hz、100 脉冲,然后分别写入公共参数和 10 段参数,再次读取全部参数。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | COM5 连接 | `9600 8E1`、从站 1,连接成功并持续稳定 | 是 | | | | |||
| | 公共参数回读 | 与写入值逐项一致 | 是 | | | | |||
| | 10 段参数回读 | 与写入值逐项一致 | 是 | | | | |||
| | 输出模式回读 | `PULSE/DIR (0)` | 是 | | | | |||
| | 保留地址 | 保持规定值,未被界面误写 | 是 | | | | |||
| | 通信质量 | 无超时、CRC 错误或非预期 Modbus 异常 | 是 | | | | |||
| ### TC-PD-002 Y0 正向 100 kHz 定频输出 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 公共参数:Y0、Y12、相对模式、完成模式、正逻辑、直线曲线、默认/起始/停止速度均 100,000 Hz、加减速均 0 ms、段数 1。 | |||
| - 段 1:100,000 Hz,`+100000` 脉冲,跳转 0。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 最终状态/错误码 | 完成 `7` / 错误码 `0` | 1 | | | | |||
| | 最终位置 | `100000` | 1 | | | | |||
| | 内部诊断计数 | 期望 `100000`,实测 `100000`,误差 `0` | 1 | | | | |||
| | 内部频率/曲线诊断 | 均已检查且通过,首个故障 `0` | 1 | | | | |||
| | 外部上升沿 | Y0 恰好 `100000` 个 | 1 | | | | |||
| | 稳态频率 | 相对期望定时器频率误差不超过 `±0.1%` 或 `±1 Hz` | 1 | | | | |||
| | 周期/占空比 | 周期约 `10 us`;高、低各占 `35%~65%` | 1 | | | | |||
| | 波形完整性 | 无窄脉冲、额外边沿和异常停顿 | 1 | | | | |||
| | 方向/非选中输出 | Y12 为正向电平且稳定;Y1~Y3 无脉冲 | 1 | | | | |||
| 备注:**稳定出现上述两个问题,脉冲输出口改成Y1Y2Y3一样会有这两种问题,只是窄脉冲有0.0几us区别** | |||
| ### TC-PD-003 Y0 反向 100 kHz 定频输出 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:与 TC-PD-002 相同,仅段 1 脉冲数改为 `-100000`。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 最终状态/错误码 | 完成 `7` / 错误码 `0` | 1 | | | | |||
| | 最终位置 | `-100000` | 1 | | | | |||
| | 内部诊断计数 | 期望 `100000`,实测 `100000`,误差 `0` | 1 | | | | |||
| | 外部上升沿 | Y0 恰好 `100000` 个 | 1 | | | | |||
| | 稳态频率/占空比 | 频率满足通用误差;高、低各占 `35%~65%` | 1 | | | | |||
| | 方向电平 | Y12 与 TC-PD-002 相反且发波期间稳定 | 1 | | | | |||
| | 波形完整性 | 无窄脉冲、额外边沿和异常停顿 | 1 | | | | |||
| 备注:同TC-PD-002 | |||
| ### TC-PD-004 四路脉冲输出选择 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:依次选择 Y0、Y1、Y2、Y3;对应方向端子可统一选择 Y12;每次均为 100,000 Hz、10,000 脉冲、零加减速。 | |||
| 验收记录(每行分别执行一次): | |||
| | 输出 | 状态 `7`/错误 `0` | 位置 `10000` | 内部计数 `10000`/误差 `0` | 外部上升沿 `10000` | 频率/波形通过 | 非选中路无脉冲 | 是 | 否 | 备注 | | |||
| |---|---|---|---|---|---|---|:---:|:---:|---| | |||
| | Y0 | | | | | | | | | | | |||
| | Y1 | | | | | | | | | | | |||
| | Y2 | | | | | | | | | | | |||
| | Y3 | | | | | | | | | | | |||
| 备注:全部通过 | |||
| ### TC-PD-005 四路方向端子和方向极性 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:脉冲端固定 Y0、1,000 Hz、100 脉冲、方向延时 20 ms;方向端依次选择 Y12~Y15。每一路分别执行正/负脉冲,并分别选择正逻辑/负逻辑,共 16 个组合。 | |||
| 验收记录(每个组合均要求:状态 7、错误 0、内外计数 100、误差 0、方向延时 `20 ms ± 2 ms`、发波期间方向稳定): | |||
| | 方向端 | 脉冲符号 | 逻辑 | 方向电平正确 | 延时正确 | 内外计数正确 | 位置正确 | 是 | 否 | 备注/实测延时 | | |||
| |---|---:|---|:---:|:---:|:---:|:---:|:---:|:---:|---| | |||
| | Y12 | + | 正逻辑 | | | | | | | | | |||
| | Y12 | - | 正逻辑 | | | | | | | | | |||
| | Y12 | + | 负逻辑 | | | | | | | | | |||
| | Y12 | - | 负逻辑 | | | | | | | | | |||
| | Y13 | + | 正逻辑 | | | | | | | | | |||
| | Y13 | - | 正逻辑 | | | | | | | | | |||
| | Y13 | + | 负逻辑 | | | | | | | | | |||
| | Y13 | - | 负逻辑 | | | | | | | | | |||
| | Y14 | + | 正逻辑 | | | | | | | | | |||
| | Y14 | - | 正逻辑 | | | | | | | | | |||
| | Y14 | + | 负逻辑 | | | | | | | | | |||
| | Y14 | - | 负逻辑 | | | | | | | | | |||
| | Y15 | + | 正逻辑 | | | | | | | | | |||
| | Y15 | - | 正逻辑 | | | | | | | | | |||
| | Y15 | + | 负逻辑 | | | | | | | | | |||
| | Y15 | - | 负逻辑 | | | | | | | | | |||
| 备注:暂时未测 | |||
| ### TC-PD-006 计数边界 | |||
| - 优先级:`P0` | |||
| - 方法:上位机;100,000 脉冲子项加逻辑分析仪 | |||
| - 配置:100,000 Hz、零加减速,分别运行 `1`、`2`、`65535`、`65536`、`65537`、`100000` 脉冲。 | |||
| 验收记录: | |||
| | 设定脉冲 | 状态 `7`/错误 `0` | 位置等于设定 | 内部期望=实测=设定 | 误差 `0` | 外部计数(100000 项) | 是 | 否 | 备注 | | |||
| |---:|:---:|:---:|:---:|:---:|---|:---:|:---:|---| | |||
| | 1 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 2 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 65535 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 65536 | | | | | 1 | | | | | |||
| | 65537 | | | | | | | | | | |||
| | 100000 | | | | | 必须为 100000 | | | | | |||
| 备注:全部通过 | |||
| ### TC-PD-007 三种长加减速曲线 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 每种曲线分别执行一次:直线、S 曲线、正弦曲线。 | |||
| - 公共参数:Y0/Y12,相对模式,起始 10,000 Hz,默认 50,000 Hz,停止 10,000 Hz,加速 100 ms,减速 100 ms。 | |||
| - 段 1:50,000 Hz、20,000 脉冲。 | |||
| 验收记录(每种曲线分别填写): | |||
| | 曲线 | 状态 `7`/错误 `0` | 位置/内外计数 `20000` | 10k→50k 单调加速 | 50k→10k 单调减速 | 无窄脉冲/空洞 | 样本>0且不匹配=0 | 最大误差=0/首错=FFFFFFFF | 是 | 否 | 备注 | | |||
| |---|---|---|---|---|---|---|---|:---:|:---:|---| | |||
| | 直线 | | | | | | | | | | | | |||
| | S 曲线 | | | | | | | | | | | | |||
| | 正弦曲线 | | | | | | | | | | | | |||
| 备注:验收全部通过 | |||
| ### TC-PD-008 短段曲线和单脉冲 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:默认 100,000 Hz、起始 100,000 Hz、停止 100 Hz、减速 1,000 ms;直线/S/正弦三种模式分别运行 1、2、10 脉冲。 | |||
| 验收记录(每个组合均要求:状态 7、错误 0、位置及内外计数等于设定、内部误差 0、尾周期完整): | |||
| | 曲线 | 设定脉冲 | 状态/错误正确 | 位置正确 | 内部计数/误差正确 | 外部计数正确 | 无截断/窄脉冲 | 是 | 否 | 备注/实测值 | | |||
| |---|---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| | |||
| | 直线 | 1 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 直线 | 2 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 直线 | 10 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | S 曲线 | 1 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | S 曲线 | 2 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | S 曲线 | 10 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 正弦曲线 | 1 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 正弦曲线 | 2 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| | 正弦曲线 | 10 | 1 | 1 | 1 | 1 | 1 | | | | | |||
| ### TC-PD-009 运行中修改当前段频率 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:Y0/Y12,初始 1,000 Hz,段 1 为 10,000 脉冲;运行中把段 1 频率依次改为 5,000 Hz、20,000 Hz、1,000 Hz,每次点击“写入 10 段参数”。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 初始频率 | 稳定在可实现的 1,000 Hz | 1 | | | | |||
| | 第一次更新 | 后续合法边界稳定到可实现的 5,000 Hz | 1 | | | | |||
| | 第二次更新 | 后续合法边界稳定到可实现的 20,000 Hz | 1 | | | | |||
| | 第三次更新 | 后续合法边界稳定到可实现的 1,000 Hz | 1 | | | | |||
| | 更新波形 | 无窄脉冲、额外边沿或异常长间隔 | 1 | | | | |||
| | 最终状态/位置 | 状态 7、错误 0、位置 10,000 | 1 | | | | |||
| | 内部/外部计数 | 均为 10,000,内部误差 0 | 1 | | | | |||
| ### TC-PD-010 STOP 减速停止和重复 STOP | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:起始/默认速度 100,000 Hz,停止速度 10,000 Hz,减速 300 ms,目标 1,000,000 脉冲。 | |||
| - 步骤:位置大于 10,000 后连续点击两次“停止”。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | STOP 接受 | 两次 STOP 均不导致通信或系统异常 | | | | | |||
| | 最终状态/错误码 | 停止 `8` / 错误码 `0` | | | | | |||
| | 提前停止 | `10000 < abs(位置) < 1000000` | | | | | |||
| | 减速过程 | 频率总体下降并到达停止边界 | | | | | |||
| | 尾脉冲 | 最后高、低电平完整,无截断窄脉冲 | | | | | |||
| | 停止后输出 | 不再出现脉冲,不自动重启 | | | | | |||
| | 内部计数一致性 | 内部累计增量等于最终位置绝对增量 | | | | | |||
| ### TC-PD-011 10 段连续执行 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 公共参数:段数 10、起始段 1、完成模式、相对位置、速度固定 1,000 Hz。 | |||
| - 段参数:第 1~10 段脉冲数依次为 100~109,频率均 1,000 Hz,跳转均为 0。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 段顺序 | 依次经过 1、2、3、4、5、6、7、8、9、10 | 1 | | | | |||
| | 段完整性 | 无漏段、重复段或提前完成 | 1 | | | | |||
| | 最终状态/错误码 | 完成 7 / 错误 0 | 1 | | | | |||
| | 最终位置 | `1045` | 1 | | | | |||
| | 外部总上升沿 | `1045` | 1 | | | | |||
| | 末段内部诊断 | 期望 109、实测 109、误差 0 | 1 | | | | |||
| ### TC-PD-012 起始段为第 10 段 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:段数 10、起始段 10;第 1~9 段各设非零脉冲,第 10 段设 200 脉冲。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 首个活动段 | 直接为第 10 段 | 1 | | | | |||
| | 第 1~9 段 | 均未执行 | 1 | | | | |||
| | 最终状态/错误码 | 完成 7 / 错误 0 | 1 | | | | |||
| | 位置/外部计数 | 均为 200 | 1 | | | | |||
| | 内部诊断 | 期望 200、实测 200、误差 0 | 1 | | | | |||
| ### TC-PD-013 完成模式与后续模式 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:两段均为 10,000 Hz、各 2,000 脉冲,先选择完成模式,再选择后续模式各执行一次。 | |||
| 验收记录: | |||
| | 模式 | 段顺序 1→2 | 状态 7/错误 0 | 位置 4000 | 外部计数 4000 | 末段内部计数 2000/误差0 | 边界无窄脉冲/额外脉冲 | 是 | 否 | 备注 | | |||
| |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| | |||
| | 完成模式 | | | | | | | | | | | |||
| | 后续模式 | | | | | | | | | | | |||
| ### TC-PD-014 累计位置、反向累计和清零 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 | |||
| - 步骤:相对模式依次运行 `+1000`、`-300`、`+20`,中间不清零;最后点击“清零”。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | `+1000` 后位置 | 1,000 | | | | | |||
| | `-300` 后位置 | 700 | | | | | |||
| | `+20` 后位置 | 720 | | | | | |||
| | 各次诊断 | 内部实测等于当次绝对脉冲数、误差 0 | | | | | |||
| | 清零后状态 | 位置 0、空闲、段号 0、错误码 0 | | | | | |||
| | 清零输出 | 清零动作不产生脉冲 | | | | | |||
| ### TC-PD-015 绝对位置及相等不动作 | |||
| - 优先级:`P0` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 步骤:先清零;绝对模式依次设置目标 `+1000`、`+300`、`+300`。 | |||
| 验收记录: | |||
| | 子项 | 方向/输出脉冲 | 最终位置 | 状态 7/错误 0 | 内外计数正确 | 是 | 否 | 备注 | | |||
| |---|---|---:|:---:|:---:|:---:|:---:|---| | |||
| | 目标 +1000 | 正向 1,000 | 1000 | | | | | | | |||
| | 目标 +300 | 反向 700 | 300 | | | | | | | |||
| | 再次目标 +300 | 不输出,外部 0 脉冲 | 300 | | N/A | | | | | |||
| ## 5. P1 完整功能用例 | |||
| ### TC-PD-101 非顺序跳转 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 | |||
| - 配置:有效段数 4;段 1 跳转 3,段 3 跳转 4,段 4 跳转 0;各段脉冲数分别为 10、20、30、40。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 执行顺序 | 1 → 3 → 4 | | | | | |||
| | 第 2 段 | 未执行 | | | | | |||
| | 最终状态/错误码 | 完成 7 / 错误 0 | | | | | |||
| | 最终位置 | 80 | | | | | |||
| | 末段内部诊断 | 期望 40、实测 40、误差 0 | | | | | |||
| ### TC-PD-102 WAIT 时间 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 秒表/日志 | |||
| - 配置:段 1 为 1,000 Hz、100 脉冲,等待类型 WAIT 时间,WAIT=500 ms。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 进入等待 | 100 脉冲后状态为等待 5 | | | | | |||
| | 等待时长 | `500 ms ± 30 ms` | | | | | |||
| | 等待期间位置 | 始终为 100 | | | | | |||
| | 等待期间输出 | 无新增脉冲 | | | | | |||
| | 最终状态/错误码 | 完成 7 / 错误 0 | | | | | |||
| | 内部诊断 | 期望 100、实测 100、误差 0 | | | | | |||
| ### TC-PD-103 ACT 时间切断 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:段 1 为 10,000 Hz、100,000 脉冲,等待类型 ACT 时间,ACT=100 ms。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | ACT 动作时间 | 启动后 `100 ms ± 30 ms` 触发切断 | | | | | |||
| | 切断有效 | `0 < 最终位置 < 100000` | | | | | |||
| | 最终状态/错误码 | 完成或进入规定下一段 / 错误 0 | | | | | |||
| | 停止后输出 | 无残余脉冲 | | | | | |||
| | 尾脉冲 | 最后一个周期完整,无窄脉冲 | | | | | |||
| ### TC-PD-104 WAIT 信号 X4/X5 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 可控输入接线 | |||
| - 步骤:X4 和 X5 分别测试。保持输入 OFF,运行 1,000 Hz、100 脉冲、WAIT 信号;进入等待并保持至少 100 ms 后将输入置 ON。 | |||
| 验收记录: | |||
| | 输入 | OFF 时等待且位置 100 | OFF 100 ms 不误触发 | ON 后完成 | 内部计数 100/误差0 | 另一输入无串扰 | 是 | 否 | 备注 | | |||
| |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| | |||
| | X4 | | | | | | | | | | |||
| | X5 | | | | | | | | | | |||
| ### TC-PD-105 EXT 信号 X4/X5 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 可控输入接线 + 逻辑分析仪 | |||
| - 步骤:X4 和 X5 分别测试。运行 10,000 Hz、100,000 脉冲、EXT 信号;发波后将所选输入置 ON。 | |||
| 验收记录: | |||
| | 输入 | ON 后切断 | `0 < 位置 < 100000` | 状态正确/错误0 | 停止后无脉冲 | 未选输入不触发 | 尾周期完整 | 是 | 否 | 备注 | | |||
| |---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|---| | |||
| | X4 | | | | | | | | | | | |||
| | X5 | | | | | | | | | | | |||
| ### TC-PD-106 EXT 信号或发送完成 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 可控输入接线 | |||
| - 子项 A:输入始终 OFF,运行 100 脉冲。 | |||
| - 子项 B:运行 100,000 脉冲,过程中将输入置 ON。 | |||
| 验收记录: | |||
| | 子项 | 最终状态/错误码 | 最终位置 | 内部诊断/切断 | 停止后无脉冲 | 是 | 否 | 备注 | | |||
| |---|---|---|---|:---:|:---:|:---:|---| | |||
| | A:输入 OFF | 完成 7 / 错误 0 | 100 | 期望=实测=100,误差0 | | | | | | |||
| | B:输入 ON | 按 EXT 分支完成 / 错误 0 | `0 < 位置 < 100000` | 输入触发提前切断 | | | | | | |||
| ### TC-PD-107 运行中修改未来段频率(完成模式) | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置:两段各 2,000 脉冲;段 1 为 1,000 Hz,段 2 初始为 2,000 Hz。 | |||
| - 步骤:段 1 运行时把段 2 改为 5,000 Hz并写入段参数。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 段 1 频率 | 写未来段后仍为可实现的 1,000 Hz | | | | | |||
| | 段 2 频率 | 到达段 2 后为可实现的 5,000 Hz | | | | | |||
| | 段切换波形 | 无额外边沿、漏脉冲和窄脉冲 | | | | | |||
| | 最终状态/位置 | 完成 7、错误 0、位置 4,000 | | | | | |||
| | 段 2 计数 | 内外均为 2,000,内部误差 0 | | | | | |||
| ### TC-PD-108 运行中修改未来段频率(后续模式) | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 配置和步骤:与 TC-PD-107 相同,但发送模式选择后续模式。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 段 1 频率 | 写未来段后仍为可实现的 1,000 Hz | | | | | |||
| | 未来预装重建 | 到达段 2 后使用可实现的 5,000 Hz | | | | | |||
| | 无缝边界 | 无多脉冲、漏脉冲、窄脉冲或异常空洞 | | | | | |||
| | 最终状态/位置 | 完成 7、错误 0、位置 4,000 | | | | | |||
| | 段 2 计数 | 内外均为 2,000,内部误差 0 | | | | | |||
| ### TC-PD-109 频率上下限 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 逻辑分析仪 | |||
| - 子项:分别运行 1 Hz、2 Hz、99,999 Hz、100,000 Hz,每次 10 脉冲。 | |||
| 验收记录: | |||
| | 请求频率 | 合法值接受/非法值拒绝 | 状态7/错误0 | 内外计数10 | 外部频率满足量化误差 | 不发波(非法值) | 是 | 否 | 备注/实测Hz | | |||
| |---:|---|:---:|:---:|:---:|:---:|:---:|:---:|---| | |||
| | 0 Hz | 必须拒绝 | N/A | N/A | N/A | | | | | | |||
| | 1 Hz | 必须接受 | | | | N/A | | | | | |||
| | 2 Hz | 必须接受 | | | | N/A | | | | | |||
| | 99,999 Hz | 必须接受 | | | | N/A | | | | | |||
| | 100,000 Hz | 必须接受 | | | | N/A | | | | | |||
| | 100,001 Hz | 必须拒绝 | N/A | N/A | N/A | | | | | | |||
| ### TC-PD-110 忙状态写保护 | |||
| - 优先级:`P1` | |||
| - 方法:上位机或产品测试脚本 | |||
| - 步骤:启动长运动后尝试修改脉冲输出、方向输出、输出模式、段数等结构参数,同时允许修改当前/未来段频率。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/异常码 | | |||
| |---|---|:---:|:---:|---| | |||
| | 修改脉冲输出 | 返回设备忙,活动配置不变 | | | | | |||
| | 修改方向输出 | 返回设备忙,活动配置不变 | | | | | |||
| | 修改输出模式 | 返回设备忙,活动配置不变 | | | | | |||
| | 修改段数 | 返回设备忙,活动配置不变 | | | | | |||
| | 修改合法当前段频率 | 接受并在合法边界生效 | | | | | |||
| | 修改合法未来段频率 | 接受并在进入该段时生效 | | | | | |||
| | 运动连续性 | 被拒绝写入不导致停波、错误或重启 | | | | | |||
| ### TC-PD-111 诊断清除和故障锁存 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 | |||
| - 步骤:完成一个正常用例,确认诊断通过;点击“清除诊断”;运动中再次尝试清除诊断。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 正常完成诊断 | 计数、频率、曲线均通过 | | | | | |||
| | 空闲清除 | 锁存原因和统计复位 | | | | | |||
| | 运行中清除 | 返回设备忙 | | | | | |||
| | 运行影响 | 清除请求不影响正在执行的波形 | | | | | |||
| | 再次完成诊断 | 重新生成完整通过结果 | | | | | |||
| ### TC-PD-112 配置与位置掉电保持 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 断电/复位 | |||
| - 步骤:写入一组容易识别的公共参数和段参数,运行到非零位置并等待保存完成;断电重上电后读取全部参数和位置。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 公共参数恢复 | 重启后与断电前逐项一致 | | | | | |||
| | 段参数恢复 | 重启后与断电前逐项一致 | | | | | |||
| | 位置恢复 | 恢复断电前已保存的非零位置 | | | | | |||
| | 上电安全 | 上电不自行发波 | | | | | |||
| | 通信恢复 | COM5 可重新连接并正常读写 | | | | | |||
| | 清零持久化 | 清零、保存、再重启后位置为 0 | | | | | |||
| ### TC-PD-113 运行中异常复位的位置有效性 | |||
| - 优先级:`P1` | |||
| - 方法:上位机 + 硬复位 | |||
| - 步骤:启动长运动,在运行中执行硬复位;重新连接后读取状态,再尝试绝对位置启动。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 复位后输出 | 不自行恢复发波 | | | | | |||
| | 忙状态恢复识别 | 能识别上次运动未正常结束 | | | | | |||
| | 位置有效性 | 不把中断位置当作可信绝对位置 | | | | | |||
| | 绝对模式保护 | 清零前启动被拒绝或明确要求清零 | | | | | |||
| | 恢复操作 | 相对模式和清零仍可正常使用 | | | | | |||
| ## 6. P2 工程和异常用例 | |||
| ### TC-PD-201 Modbus 地址、功能码和异常码 | |||
| - 优先级:`P2` | |||
| - 方法:自动产品测试脚本 | |||
| - 执行: | |||
| ```powershell | |||
| py -B HostComputer\plsr_modbus_product_test.py --run --phase smoke --port COM5 | |||
| ``` | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/输出摘要 | | |||
| |---|---|:---:|:---:|---| | |||
| | 固定地址映射 | 参数、状态、诊断、控制地址与地址表一致 | | | | | |||
| | 跨区/越界访问 | 返回非法地址 | | | | | |||
| | 只读区写入 | 返回非法地址或规定异常 | | | | | |||
| | 非法参数值 | 返回非法数据值 | | | | | |||
| | 不完整 32 位访问 | 按协议拒绝 | | | | | |||
| | 脚本结果 | 全部用例 PASS、零失败 | | | | | |||
| ### TC-PD-202 Host 回归、上位机和脚本自检 | |||
| - 优先级:`P2` | |||
| - 方法:PC 自动测试 | |||
| - 执行:使用仓库现有 Host 测试构建命令,并运行: | |||
| ```powershell | |||
| py -B HostComputer\plsr_modbus_product_test.py --self-test | |||
| py -B HostComputer\plsr_control_panel.py --self-test | |||
| ``` | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/输出摘要 | | |||
| |---|---|:---:|:---:|---| | |||
| | C Host 回归 | 全部测试和断言通过、0 failures | | | | | |||
| | 产品脚本自检 | PASS | | | | | |||
| | 控制面板自检 | PASS | | | | | |||
| | 自检副作用 | 不开串口、不启动 GUI、不改板上配置 | | | | | |||
| ### TC-PD-203 IAR 构建和固件产物 | |||
| - 优先级:`P2` | |||
| - 方法:IAR 命令行构建 | |||
| - 执行: | |||
| ```powershell | |||
| & 'F:\IAR Systems\Embedded Workbench 8.3\common\bin\IarBuild.exe' ` | |||
| 'EWARM\Modbus.ewp' -build Modbus -log errors | |||
| ``` | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | IAR 构建错误 | `0 errors` | | | | | |||
| | IAR warning | 数量和原因已记录,无新增代码 warning | | | | | |||
| | HEX 生成 | 文件存在且非空 | | | | | |||
| | HEX SHA256 | 已计算并记录 | | | | | |||
| | 烧录校验 | Download verify 成功 | | | | | |||
| ### TC-PD-204 内部硬件计数性能 | |||
| - 优先级:`P2` | |||
| - 方法:DWT CYCCNT + ST-LINK 读取调试统计 | |||
| - 配置 A:100 kHz、100,000 脉冲定频。 | |||
| - 配置 B:1 kHz -> 100 kHz -> 1 kHz、长加减速曲线。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/次数/平均/最大 cycles | | |||
| |---|---|:---:|:---:|---| | |||
| | 逐脉冲业务 ISR | 不存在,ISR 次数不随每个输出脉冲增加 | | | | | |||
| | 65,536 块中断 | 最大值 `< 1680 cycles` | | | | | |||
| | 最终停止中断 | 最大值 `< 1680 cycles` | | | | | |||
| | 短曲线生产项 | 平均值和最大值均 `< 1680 cycles` | | | | | |||
| | 长硬件计数曲线 | 逐脉冲生产活动计数为 `0` | | | | | |||
| | 性能统计完整性 | 各路径次数、平均值、最大值均已记录 | | | | | |||
| ### TC-PD-205 8 小时稳定性 | |||
| - 优先级:`P2` | |||
| - 方法:上位机/脚本循环 + 定期诊断读取 | |||
| - 配置:四路输出轮换,正反向轮换,1 kHz/10 kHz/100 kHz 轮换,每轮 100,000 脉冲;至少每分钟读取一次状态和诊断。 | |||
| 验收记录: | |||
| | 测试指标 | 验收标准 | 是 | 否 | 备注/实测值 | | |||
| |---|---|:---:|:---:|---| | |||
| | 连续运行时长 | 不少于 8 小时 | | | | | |||
| | 通信 | 无失联、连续超时或异常帧 | | | | | |||
| | 系统状态 | 无错误状态、看门狗复位、卡死 | | | | | |||
| | 每轮计数 | 位置增量=内部期望=内部实测=100,000 | | | | | |||
| | 累计位置 | 正反向累计无漂移 | | | | | |||
| | 四路/三频率覆盖 | Y0~Y3 和 1k/10k/100k 均实际执行 | | | | | |||
| | 结束恢复 | STOP、CLEAR、再次 START 均正常 | | | | | |||
| ## 7. 推荐执行顺序 | |||
| 1. TC-PD-001、002、003:先确认通信和一组正反向基本波形。 | |||
| 2. TC-PD-004~010:确认四路、方向、边界、曲线、动态更新和停止。 | |||
| 3. TC-PD-011~015:确认多段、发送模式、累计和绝对位置。 | |||
| 4. TC-PD-101~113:完成跳转、输入、动态段参数、诊断和掉电保持。 | |||
| 5. TC-PD-201~205:完成异常、构建、性能和稳定性验收。 | |||
| 任何 P0 用例失败时,应停止扩大测试范围,保留当前固件 SHA256、上位机参数、诊断寄存器、逻辑分析仪原始采集文件和复现步骤,修复后从该用例开始并重跑全部 P0。 | |||
| ## 8. 填写规则 | |||
| - 每个用例直接在其下方验收表填写。“是”和“否”只能选择一项;未执行时两项均留空,并在备注写明原因。 | |||
| - 多子项用例必须逐行填写,禁止只给整个用例一个总结果。 | |||
| - 备注至少填写关键实测值;逻辑分析仪用例还应填写原始采集文件名。 | |||
| - 每次执行前在本文件顶部或测试报告中记录固件 SHA256、日期和测试人员。 | |||
| - 每次重烧固件后不得沿用上一版波形或诊断结果。 | |||
| - 任一必测指标选择“否”,该用例结论即为 FAIL,并在备注填写问题编号和复现条件。 | |||
| @@ -1,119 +0,0 @@ | |||
| # PLSR 信捷对标追踪矩阵 | |||
| > 矩阵版本:V1.0 Rev.B | |||
| > 基准资料:[信捷XD/XL系列PLC定位控制手册](./信捷XD_XL系列PLC定位控制手册_PD02_20260510_V1.3.pdf) | |||
| > 对标原则:信捷PLSR已有功能全部纳入,本项目增强功能允许保留;Y0~Y3的PLSR固定软元件 | |||
| > 地址按[PLSR地址映射](./PLSR地址映射.md)一比一对应。 | |||
| 状态说明: | |||
| - `已纳入`:已写入V1.0需求。 | |||
| - `等效实现`:功能和外部行为一致,但使用本项目数据结构或Modbus接口。 | |||
| - `待实机确认`:手册语义存在歧义,需要信捷实机或厂家资料关闭。 | |||
| - `增强`:信捷手册没有强制规定,本项目额外提供。 | |||
| ## 1. 指令和参数模型 | |||
| | 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | | |||
| |---|---:|---|---| | |||
| | `PLSR S0 S1 S2 D` | 49 | 保留S0/S1/S2/D逻辑模型 | 已纳入 | | |||
| | 上升沿启动且启动条件断开后继续 | 49 | START接受后锁存运行 | 等效实现 | | |||
| | STOP中断运动 | 49 | 支持减速停止和立即停止 | 增强 | | |||
| | S0为可选择首地址的脉冲数据块 | 6~17 | 运行时指定未占用保持寄存器首地址,保持信捷固定相对布局 | 一比一实现 | | |||
| | S1为可选择首地址的用户参数块 | 17~18 | 在动态数据池中指定首地址,仅含相对/绝对和起始段 | 已纳入 | | |||
| | S2选择K0~K4 | 18~24、49 | 每轴提供0~4套参数 | 已纳入 | | |||
| | D选择脉冲输出端口 | 49 | D选择Y0~Y3;PULSE/DIR一一对应Q0~Q3,AB/CW/CCW仅允许Y0或Y2并占用固定点对 | 等效实现 | | |||
| ## 2. 脉冲和定位 | |||
| | 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | | |||
| |---|---:|---|---| | |||
| | 1 Hz~100 kHz | 49 | V1.0保证1 Hz~100 kHz | 已纳入 | | |||
| | 100~200 kHz非保证输出说明 | 50 | 板测后开放非保证扩展区并告警 | 已纳入 | | |||
| | 有符号32位脉冲数 | 49 | 支持完整INT32范围 | 已纳入 | | |||
| | 负数表示反向 | 49 | 有符号脉冲数量决定方向 | 已纳入 | | |||
| | 频率增减与方向无关 | 49 | 速度规划与方向符号分离 | 已纳入 | | |||
| | 段频率0使用默认速度 | 56 | 使用当前S2默认速度 | 已纳入 | | |||
| | 段脉冲数0的行为 | 56 | 项目冻结为跳过当前段且不输出脉冲;最后一段则完成任务 | 已冻结差异 | | |||
| | 相对定位 | 17、69等 | 支持相对模式 | 已纳入 | | |||
| | 绝对定位 | 17、69等 | 支持绝对模式 | 已纳入 | | |||
| | 绝对目标等于累计位置时不输出 | 50 | 零脉冲直接完成 | 已纳入 | | |||
| | 当前累计脉冲掉电保持 | 48、50 | Backup SRAM检查点和独立有效标志 | 增强 | | |||
| | 运行中修改当前段频率立即生效 | 50、56 | 在S2配置的0.1/1 ms内生效 | 已纳入 | | |||
| | 其它段参数下次启动生效 | 50 | 影子参数提交,下次任务生效 | 增强 | | |||
| ## 3. S0多段、等待和跳转 | |||
| | 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | | |||
| |---|---:|---|---| | |||
| | 1~100段 | 6、51 | 每轴最多100段 | 已纳入 | | |||
| | 每段频率和有符号脉冲数 | 6 | 等效字段 | 已纳入 | | |||
| | 脉冲完成后跳段 | 7~8 | H00等效条件 | 已纳入 | | |||
| | Wait时间 | 8~9 | 完成后计时,0按1 ms,最大INT32_MAX ms | 已纳入 | | |||
| | Wait信号 | 9~11 | 完成后判断,提前有效则立即通过 | 已纳入 | | |||
| | ACT时间 | 11~13 | 从段开始计时,可中途放弃剩余脉冲 | 已纳入 | | |||
| | EXT信号 | 13~15 | 上升沿立即跳段,优先外部中断输入 | 已纳入 | | |||
| | EXT或脉冲完成 | 15~16 | 先到条件决定跳段 | 已纳入 | | |||
| | 等待参数支持常数和多种变量 | 16 | 常数、输入、内部位、普通/保持变量 | 等效实现 | | |||
| | 跳转目标支持常数或变量 | 16~17 | 常数或运行时变量,范围0~100 | 等效实现 | | |||
| | 跳转0为顺序,最后一段0为结束 | 17 | 相同语义 | 已纳入 | | |||
| | 跳转自身为无限循环 | 17 | 相同语义并提供诊断 | 增强 | | |||
| | 指定起始段,0/1均从第一段开始 | 18 | 相同语义 | 已纳入 | | |||
| | 最后一段非完成条件保持等待 | 7 | WAIT状态保持至条件或STOP | 等效实现 | | |||
| ## 4. 速度曲线和段衔接 | |||
| | 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | | |||
| |---|---:|---|---| | |||
| | 完成方式 | 29~30、55 | 段完成后才向下一速度变化 | 已纳入 | | |||
| | 后续方式 | 29~30、55 | 段边界前达到下一段速度 | 已纳入 | | |||
| | 直线加减速 | 42 | 支持 | 已纳入 | | |||
| | S形加减速 | 42 | 支持 | 已纳入 | | |||
| | 正弦曲线加减速 | 42 | 支持 | 已纳入 | | |||
| | 短段自动形成三角曲线 | 55 | 自动压缩短距离曲线 | 已纳入 | | |||
| | 默认速度与加减速时间定义斜率 | 40、50 | 使用相同斜率模型 | 已纳入 | | |||
| | 起始速度和终止速度 | 43 | S2参数 | 已纳入 | | |||
| | 最高速度限制 | 22~24、49 | 高于S2最高速度时按最高速度限幅运行,并增加SPEED_CLAMPED告警 | 已纳入 | | |||
| | 最后一段动态变频后重算曲线 | 56 | 动态修改后重算剩余曲线 | 已纳入 | | |||
| ## 5. 公共参数和输出方式 | |||
| | 信捷功能 | 手册位置(印刷页) | 本项目要求 | 状态 | | |||
| |---|---:|---|---| | |||
| | 方向逻辑 | 25、60等 | 正/负逻辑可配置 | 已纳入 | | |||
| | 方向输出点 | 31、60等 | 从有效未占用Q点配置 | 等效实现 | | |||
| | 方向延时 | 31、41 | 默认10 ms,单位ms可配置 | 已纳入 | | |||
| | PULSE/DIR | 29、49 | 最多四轴 | 已纳入 | | |||
| | AB相输出 | 29、70等 | D取Y0或Y2,使用Q0/Q1或Q2/Q3固定点对;一个完整正交周期计1个指令脉冲 | 增强 | | |||
| | 脉冲单位 | 28~29 | 支持脉冲模式 | 已纳入 | | |||
| | 1 μm、0.01/0.1/1 mm当量 | 28 | 内核直接换算并保存余数 | 增强 | | |||
| | 每转脉冲数和每转移动量 | 28、60等 | 公共参数 | 已纳入 | | |||
| | 软限位和正负限位值 | 25~26 | 减速停止、禁越界方向、允许反向离开 | 已纳入 | | |||
| | 硬限位输入和常开/常闭 | 31、61等 | 输入点及极性可配置 | 已纳入 | | |||
| | 正反向齿隙补偿 | 31、41~43 | V1.0实现 | 已纳入 | | |||
| | 补偿加减速时间 | 41 | S2参数 | 已纳入 | | |||
| | FOLLOW性能参数 | 23~24、44 | 范围1~100 | 已纳入 | | |||
| | FOLLOW前馈补偿 | 23~24、44 | 范围0~100% | 已纳入 | | |||
| | 0.1 ms/1 ms刷新 | 22~24、44 | S2配置,独立硬件控制周期 | 已纳入 | | |||
| ## 6. 监控、事件和本项目增强 | |||
| | 功能 | 本项目要求 | 状态 | | |||
| |---|---|---| | |||
| | 每段对应脉冲中断 | 提供带轴号、段号、原因的段事件 | 等效实现 | | |||
| | 正在发脉冲标志 | 每轴独立运行标志 | 等效实现 | | |||
| | 当前段脉冲数 | 每轴当前段已发/剩余计数 | 增强 | | |||
| | Modbus状态监控 | 原子状态快照 | 增强 | | |||
| | 参数原子提交 | 影子参数加COMMIT | 增强 | | |||
| | 命令防重 | 命令序号保证只执行一次 | 增强 | | |||
| | 四轴资源检查 | PULSE/DIR轴数 + 2×AB轴数 + 2×CW/CCW轴数不超过4 | 增强 | | |||
| | 自测和错误数据块号 | 参数、状态机、计数、跳转、定时器和冲突诊断 | 增强 | | |||
| ## 7. 关闭准则 | |||
| 严格功能对标完成必须同时满足: | |||
| 1. 本矩阵不存在未处理的信捷PLSR功能。 | |||
| 2. `待实机确认`项目已通过信捷实机或厂家书面资料关闭。 | |||
| 3. 所有`已纳入`和`等效实现`项目具有测试用例和结果。 | |||
| 4. Y0~Y3的PLSR固定软元件地址不得作为差异项;差异仅限编程界面或明确记录的增强行为。 | |||
| @@ -104,7 +104,20 @@ AB 模式的频率单位是完整正交周期/秒,一个 `00` 起止的四状 | |||
| 诊断使用定时器分频值、完整周期计数和频率变化次序核对输出配置。它能发现内部计数、频率和曲线不一致,但不能代替逻辑分析仪对端子占空比、窄脉冲或电气波形的验收。 | |||
| ## 5 控制区 | |||
| ## 5 profile 队列诊断区 `0x2200..0x2208`(只读) | |||
| | 地址 | 类型 | 名称 | 说明 | | |||
| |---|---|---|---| | |||
| | `0x2200` | U16 | 队列诊断标志 | bit0=到达低水位,bit1=发生欠载,bit2=规划曲线被裁剪 | | |||
| | `0x2201` | U16 | 最小队列深度 | 本次运行期间观测到的最小 profile 项数;尚无样本时为 `0xFFFF` | | |||
| | `0x2202` | U16 | 当前队列深度 | 读取时尚未消费的 profile 项数 | | |||
| | `0x2203..0x2204` | U32 | 低水位事件数 | 队列跨入低水位的累计次数 | | |||
| | `0x2205..0x2206` | U32 | 队列欠载数 | IRQ 需要新项但生产器未完成且队列为空的次数 | | |||
| | `0x2207..0x2208` | U32 | 规划裁剪数 | 已发布但无法达到请求峰值的规划次数 | | |||
| `0x3100=1` 同时清除本区的锁存标志和计数。原有 `0x2100..0x2119` 诊断区的边界保持不变。 | |||
| ## 6 控制区 | |||
| | 地址 | 类型 | 名称 | 写入值 | | |||
| |---|---|---|---| | |||
| @@ -114,8 +127,8 @@ AB 模式的频率单位是完整正交周期/秒,一个 `00` 起止的四状 | |||
| 控制寄存器读取恒为 0。除上述单一值外的位组合均返回非法数据值。 | |||
| 运行期间写 `0x3100=1` 返回设备忙,避免清除正在采集的诊断上下文。 | |||
| ## 6 地址边界 | |||
| ## 7 地址边界 | |||
| - 原固定区 `0x1000..0x1197`、`0x2000..0x2006`、`0x3000` 保持不变。 | |||
| - 扩展区仅增加 `0x1200`、`0x2100..0x2119`、`0x3100`。 | |||
| - 扩展区增加 `0x1200`、`0x2100..0x2119`、`0x2200..0x2208`、`0x3100`。 | |||
| - 完全落在 PLSR 地址之外的请求返回“不处理”,跨越 PLSR 地址与空洞的请求返回“非法地址”。 | |||
| @@ -1,250 +0,0 @@ | |||
| # PLSR 波形验收报告:完成模式 10 段阶梯频率 | |||
| > 测试日期:2026-08-14 | |||
| > 测试方式:上位机 `plsr_control_panel.py` + 逻辑分析仪(6.25 MS/s,Y0 单通道数字采样) | |||
| > 波形文件:`Document\PLSR_document\波形\10段.bin`(28,565,856 采样 ≈ 4.571 s) | |||
| > 截图:`上位机图片\Snipaste_2026-08-14_13-19-30.png`(公共参数)、`Snipaste_2026-08-14_13-19-44.png`(10 段参数) | |||
| > 结论:**验收不通过**(脉冲总数正确,但段 2~8 恒速频率严重错误,且存在首脉冲偏长、段尾慢爬等已知问题复现) | |||
| --- | |||
| ## 1. 测试配置(与截图一致) | |||
| ### 1.1 公共参数 | |||
| | 字段 | 值 | | |||
| |---|---| | |||
| | 脉冲输出 | Y0 (0) | | |||
| | 方向输出 | Y12 (0) | | |||
| | 方向延时 ms | 0 | | |||
| | WAIT 输入 | X4 (0) | | |||
| | EXT 输入 | X4 (0) | | |||
| | 发送模式 | 脉冲发送完毕 (0)(完成模式) | | |||
| | 方向逻辑 | 正逻辑 (0) | | |||
| | 曲线模式 | 直线 (0) | | |||
| | 位置模式 | 相对位置 (0) | | |||
| | 段数 | 10 | | |||
| | 起始段 | 1 | | |||
| | 默认速度 Hz | 10000 | | |||
| | 启动速度 Hz | 100 | | |||
| | 停止速度 Hz | 100 | | |||
| | 加速时间 ms | 10 | | |||
| | 减速时间 ms | 10 | | |||
| | 输出模式 0x1200 | PULSE/DIR (0) | | |||
| ### 1.2 段参数(每段 100 脉冲,等待类型 外部或完成 4,WAIT/ACT/跳转均为 0) | |||
| | 段 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | | |||
| |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| | |||
| | 配置频率 Hz | 1000 | 2000 | 3000 | 4000 | 5000 | 6000 | 7000 | 8000 | 9000 | 10000 | | |||
| --- | |||
| ## 2. 波形总览 | |||
| | 项目 | 实测 | 判定 | | |||
| |---|---|---| | |||
| | 总采样 | 28,565,856(@6.25MS/s = 4.571 s) | — | | |||
| | 上升沿(脉冲总数) | **1000** = 10 段 × 100 脉冲 | ✅ 正确 | | |||
| | 波形起始 | 采集开始后 1725.9 ms 低电平,随后才有脉冲 | ℹ️ 采集窗口早于启动,不计故障 | | |||
| | 段间间隙 | 每段之间低电平约 **5.4 ~ 6.0 ms** | ⚠️ 异常(Bug 4 报告为 ~2.5 ms,此处更大) | | |||
| | 每段内部结构 | 首脉冲(5 ms 长高)+ ~96 个恒速脉冲 + 3 个 100 Hz 慢爬 | ⚠️ 异常 | | |||
| --- | |||
| ## 3. 逐段频率实测(核心结论) | |||
| 每段恒速段频率取排除首脉冲与尾慢爬后的中位数: | |||
| | 段 | 配置频率 Hz | 实测恒速频率 Hz | 偏差 | 判定 | | |||
| |---|---:|---:|---:|:---:| | |||
| | 1 | 1000 | **1000** | 0 % | ✅ | | |||
| | 2 | 2000 | **1049** | −48 % | ❌ | | |||
| | 3 | 3000 | **1066** | −64 % | ❌ | | |||
| | 4 | 4000 | **1074** | −73 % | ❌ | | |||
| | 5 | 5000 | **1080** | −78 % | ❌ | | |||
| | 6 | 6000 | **1083** | −82 % | ❌ | | |||
| | 7 | 7000 | **1085** | −84 % | ❌ | | |||
| | 8 | 8000 | **1087** | −86 % | ❌ | | |||
| | 9 | 9000 | **9006**(含完整 ramp) | +0.07 % | ✅ | | |||
| | 10 | 10000 | **9905**(峰值,含完整 ramp) | −0.95 % | ✅ | | |||
| ### 3.1 逐段频率形态 | |||
| - **段 1**:首脉冲 182 Hz → 恒速 1000 Hz ×94 → 尾部 3 个 100 Hz 慢爬。恒速正确。 | |||
| - **段 2~8**:首脉冲 ~183 Hz → 恒速 **~1049~1087 Hz**(每段仅比上一段高 15~25 Hz)→ 尾部 3 个 100 Hz 慢爬。**恒速频率几乎都落在 ~1 kHz 附近,与配置 2~8 kHz 严重不符**。 | |||
| - **段 9**:首脉冲 100 Hz → ramp 196→6463→9006 Hz → 恒速 9006 Hz → 平滑减速(9006→8480→8013→…→183 Hz)→ 尾部慢爬。**加减速 ramp 完整且频率正确**。 | |||
| - **段 10**:首脉冲 1050 Hz → ramp 1050→1928→…→9905 Hz(对称三角)→ 尾部 1050 Hz。**ramp 完整,峰值接近 10000 Hz**。 | |||
| 结论:加减速曲线路径(`PlsrRampAdvance`/短轮廓/Profile 队列)工作正常;**问题集中在完成模式下段 2~8 的恒速频率设定**,表现为"段启动后频率被限制在 ~1 kHz 附近缓慢爬升"。 | |||
| --- | |||
| ## 4. 发现的问题清单 | |||
| | # | 严重度 | 现象 | 疑似原因 | 关联已知问题 | | |||
| |---|---|---|---|---| | |||
| | 1 | **严重** | 段 2~8 恒速段输出 ~1049~1087 Hz,与配置 2000~8000 Hz 严重不符;段 9/10 正常 | 完成模式下多段恒速频率设定/段间预装交接(Handoff 双缓冲预装、频率队列写定时器 PSC/ARR)路径 | 未见对应历史记录,疑似新问题 | | |||
| | 2 | 中 | 每段第 1 个脉冲为约 5 ms 长高电平(~182 Hz),全 10 段复现 | 段启动首脉冲相位/定时器预载 | TC-PD-008 遗留"首脉冲偏长" | | |||
| | 3 | 中 | 每段尾部 3 个脉冲以 100 Hz(高 5 ms 低 5 ms,恰为停止速度)慢爬后停 | 减速预算不足时提前跳停止速度发完剩余 | 8-13 Bug 报告 Bug 1 复现 | | |||
| | 4 | 低 | 段间低电平间隙 5.4~6.0 ms | 有限路径段完成与下一段启动跨主循环周期 | 8-13 Bug 报告 Bug 4(原 ~2.5 ms,此处更大) | | |||
| | 5 | 信息 | 采集起始 1725.9 ms 无输出 | 逻辑分析仪先于启动命令开始采集 | 不计故障 | | |||
| --- | |||
| ## 5. 验收判定 | |||
| | 验收项 | 标准 | 实测 | 结果 | | |||
| |---|---|---|---| | |||
| | 脉冲总数 | 1000 | 1000 | ✅ | | |||
| | 段顺序 | 1→2→…→10 | 顺序正确 | ✅ | | |||
| | 每段恒速频率 | 与配置一致(±1 Hz 量化差) | 段 1/9/10 正确;**段 2~8 错误** | ❌ | | |||
| | 段 9/10 加减速 ramp | 平滑、峰值正确 | 196→9006 Hz、1050→9905 Hz | ✅ | | |||
| | 首脉冲宽度 | 该段频率对应半周期 | 实测恒为 5 ms | ❌ | | |||
| | 段尾无慢爬 | 无 100 Hz 尾段 | 每段 3 个 100 Hz 慢爬 | ❌ | | |||
| | 段间间隙 | ≤ 2 ms | 5.4~6.0 ms | ❌ | | |||
| **总体结论:不通过。** 阻塞项为问题 #1(段 2~8 恒速频率错误)与 #2/#3(首脉冲偏长、段尾慢爬)。 | |||
| --- | |||
| ## 6. 建议 | |||
| 1. **优先排查 #1**:在 `tests\plsr_host` 宿主测试中构造同配置(10 段、1000~10000 Hz 阶梯、每段 100 脉冲、完成模式),用 `PlsrTestOutputFrequency` 断言每段恒速频率,复现段 2~8 被限制在 ~1 kHz 的问题;重点审查完成模式段间交接(`PlsrBuildHandoffPlan`、`PlsrPreparedHandoffPlans` 双缓冲、`PlsrResolvedSegmentFrequency`、频率队列写 PSC/ARR 路径)。 | |||
| 2. **修复后回归**:`tests\plsr_host\run_tests.ps1` 全量通过(基线 94 tests / 515,670 assertions / 0 failures),并新增"完成模式多段阶梯频率逐段正确输出"用例。 | |||
| 3. **实板复测**:同配置重采波形,段 2~8 恒速频率与配置一致(±1 Hz),首脉冲宽度正常,段尾无 100 Hz 慢爬,段间间隙 ≤ 2 ms。 | |||
| ## 7. 分析脚本(复现用) | |||
| `tmp\analyze_wave_bin.py`(总览/频率统计)、`tmp\wave_final.py`(按段边界切段统计)、`tmp\wave_pulse_seq.py`(逐脉冲频率序列)、`tmp\wave_runs.py`(原始电平段明细)。 | |||
| --- | |||
| ## 8. 13:58 复测与慢尾修复状态 | |||
| > 当前 `Document\PLSR_document\波形\10段.bin` 已在 13:58 被新波形覆盖。前文第 2~5 节保留首次测试记录,本节以当前文件为准。 | |||
| ### 8.1 当前波形复测结果 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | 文件时间 | 2026-08-14 13:58:17 | | |||
| | 采样 | 17,729,200 点,6.25 MS/s,2.836672 s | | |||
| | 上升沿 | 1000,计数正确 | | |||
| | 段 1~9 峰值/恒速 | 1000、2000、3000.5、4001.3、5000、6003.8、7006.7、8002.6、9005.8 Hz | | |||
| | 段 10 峰值 | 9936.4 Hz,短轮廓达到目标的 99.36% | | |||
| 原阻塞问题“段 2~8 卡在约 1.05 kHz”已修复。当前文件仍能看到段尾提前到达 100 Hz,段 1~9 约残留 3~10 个低速脉冲;其低半周期将段间低电平拉长到约 5~6 ms。因此,当前 `10段.bin` 是**频率交接修复通过、慢尾修复前**的证据。 | |||
| ### 8.2 慢尾修复 | |||
| 根因是边界减速预算重复叠加了按频率和频差计算的余量,短斜坡被过早启动;同时首个减速频点要等下一次 1 ms 轮询才提交,长斜坡又依赖过大的提前量才能到达停止速度。 | |||
| 修复内容: | |||
| 1. 删除重复的频差余量,按曲线积分估算所需脉冲。 | |||
| 2. 边界条件命中后立即提交首个减速频点,消除额外 1 ms 启动延迟。 | |||
| 3. 仅当一个轮询周期的脉冲数不超过斜坡面积 5% 时保留轮询余量,避免短斜坡出现停止速度慢尾。 | |||
| 4. 新增 10 段逐脉冲慢尾检查和 10 ms 边界斜坡专项回归。 | |||
| ### 8.3 自动验证结果 | |||
| | 验证 | 结果 | | |||
| |---|---| | |||
| | Host 全量 | 100 tests / 525,879 assertions / 0 failures | | |||
| | 10 段 Host 慢尾 | 段 1~9 最后连续 `<=200 Hz` 脉冲均 `<=2` | | |||
| | 10 ms 边界专项 | 峰值 `>=99%`,最后连续 `<=200 Hz` 脉冲 `<=2` | | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 warning | | |||
| | HEX | 194,945 B,SHA256 `12930062D965B1473B8AA3DEFFC950AF71E2056785BBCCF2DD0BE3F4A8360169` | | |||
| | 持久化区保护 | 烧录前后 SHA256 均为 `64946F391E62A1D9250E2E7F5D5E56DC55EC29F9CE6D269BE6808FD6205462C8` | | |||
| | COM5 Pulse/Direction 矩阵 | 12/12 通过 | | |||
| | COM5 延长 10 段 | 逐段捕获 1000~10000 Hz,总位置 10000,count error 0,mismatch 0 | | |||
| | 四路有限计数最终 IRQ | Y0~Y3 最大 344 / 400 / 340 / 404 cycles,均 `<1680` | | |||
| | 10 脉冲短轮廓性能 | IRQ 最大 1657 cycles,生产者最大 1380 cycles,均 `<1680` | | |||
| | 65,535 脉冲长 ramp 性能 | IRQ 最大 159 cycles;另测 ramp 更新最大 1320 cycles,均 `<1680` | | |||
| 仓库 timing 脚本已改为在旧 `PlsrIrqCount` 无样本时读取有限硬件计数的 block/final IRQ 统计,消除新架构下的误报。完整 timing 阶段的 Pulse/Direction 部分全部通过;阶段最后的 AB Y2/Y3 负向诊断仍报 `flags=0x00FA`,属于已暂缓的 AB 路径,不计入本次 Pulse/Direction 结论。 | |||
| ### 8.4 最终波形验收状态 | |||
| 新固件已烧录,板上已恢复为 10 段、每段 100 脉冲、1~10 kHz、位置 0、IDLE。还需用逻辑分析仪覆盖采集一次 `10段.bin`,确认: | |||
| - 总上升沿为 1000; | |||
| - 段 1~9 无连续 100 Hz 慢尾(允许最多 2 个边界量化脉冲); | |||
| - 段间低电平不再被 100 Hz 半周期拉长到 5~6 ms; | |||
| - 各段峰值/恒速频率仍满足目标。 | |||
| 在新波形完成上述检查前,最终物理波形结论为:**待复测**。 | |||
| ### 8.5 14:34 第二次复测 | |||
| `10段.bin` 于 14:34:09 再次覆盖,SHA256 为 `9C3D00869726986E0391C4D1D11E0E6A0A3601ACFE9D2BE01FAC24C0F94127B1`。 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | 采样 | 11,009,376 点,6.25 MS/s,1.761500 s | | |||
| | 上升沿 | 1000,计数正确 | | |||
| | 段 1~10 峰值 | 1000、2000、3000.5、4001.3、5000、6003.8、7006.7、8002.6、9005.8、9952.2 Hz | | |||
| | 段 1~10 连续 `<=200 Hz` 尾脉冲 | 2、2、3、1、0、2、4、3、4、0 | | |||
| | 段 1~8 边界低电平 | 5.057920~5.989120 ms | | |||
| | 段 9 边界低电平 | 1.129280 ms | | |||
| 结论:频率与计数通过,但段 3、7、8、9 的慢尾仍超过 2 个,最终波形验收不通过。实测表明 Host 模型比硬件少覆盖约 2 个定时器预装边界脉冲;边界低电平还包含下一段以 100 Hz 启动时的低半周期,需与慢尾分别判断。 | |||
| 随后已删除估算函数中固定追加的 2 个预装脉冲余量。新版本 Host 仍为 `100 tests / 525,879 assertions / 0 failures`,IAR 为 `0 errors / 1 warning`,新 HEX SHA256 为 `4E4ED664CE05F364B7443711DC85E719960B52BCBCD5FFC347D76F0CCFE5D863`,已烧录并通过 COM5 延长 10 段计数/频率测试。当前板上已恢复 10 段 x 100 脉冲、位置 0、IDLE,等待第三次逻辑分析仪采样。 | |||
| ## 9. 完成模式连续轨迹语义修复 | |||
| 14:34 波形显示每个中间段都重新从 100 Hz 启动。根据最终确认的语义,10 个“脉冲发送完成”段组成一条连续轨迹:启动速度只作用于第 1 段,停止速度只作用于第 10 段,同向中间边界按相邻段目标频率衔接。 | |||
| 该语义已写入代码并完成以下验证: | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | Host 全量 | 102 tests / 526,542 assertions / 0 failures | | |||
| | 三段顺序/跳转专项 | 中间段不回到启动速度,跳转使用目标段频率 | | |||
| | 方向反转 | 不带速穿越,保留停机、方向建立和安全重启 | | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 warning | | |||
| | HEX SHA256 | `7C6534FA05FB61BBA8CAF7838F36BAF908612CBDE525A17F23354E8C702FB5A2` | | |||
| | ST-LINK | `Verification...OK`;持久化 Sector 10~13 前后哈希一致 | | |||
| | COM5 三段板测 | 1000/2000/3000 Hz,位置 3000,错误 0,计数误差 0,曲线失配 0 | | |||
| 板上已恢复本报告第 1 节的 10 段配置并处于 IDLE。现有 `10段.bin` 来自旧语义固件;必须覆盖采集新波形后,才能验收启动/停止速度作用范围和段间低电平。当前最终物理波形状态仍为:**待复测**。 | |||
| ## 10. 15:24 第三次复测与末段慢尾修复 | |||
| `10段.bin` 于 15:24:39 覆盖,SHA256 为 `49BB40FE59A70DF41A82122AC6EAE253C48661F64E2B74C27F3EE01B3E03860B`。 | |||
| | 项目 | 实测 | 判定 | | |||
| |---|---:|:---:| | |||
| | 采样 | 10,011,664 点,6.25 MS/s,1.601866 s | — | | |||
| | 上升沿/下降沿 | 1000 / 1000 | 通过 | | |||
| | 段 1~10 恒速/峰值 | 1~10 kHz 均达到目标 | 通过 | | |||
| | 中间段启动速度 | 第 2~10 段均从对应目标频率开始,未回到 100 Hz | 通过 | | |||
| | 段间低电平 | 0.250080~0.949760 ms | 通过 | | |||
| | 第 10 段减速 | 10 kHz 平滑下降,停止速度只作用于第 10 段 | 通过 | | |||
| | 第 10 段连续 `<=200 Hz` 周期 | 4 个,末脉冲高电平 5.000160 ms | 不通过 | | |||
| 本次波形确认连续轨迹语义和段间低电平已修复,但发现最终段仍提前约 5 脉冲到达停止速度。根因是连续梯形面积与实际 1 ms 右端点离散更新面积不一致。 | |||
| 离散面积修复后的验证结果:Host `102 tests / 526,543 assertions / 0 failures`,IAR `0 errors / 1 warning`,HEX SHA256 `5851B7714454770DD8EF8A0ED8B01379791F4EF71BDF1B795B36639B18F258AB`,ST-LINK 校验及持久化区保护通过。COM5 同配置 10×100 脉冲计数通过,末段最终板内诊断频率为 3071 Hz,不再提前进入 100 Hz 慢尾。 | |||
| 板子已恢复同一配置并处于 IDLE,等待第四次覆盖采样。当前最终物理波形状态:**待复测**。 | |||
| ## 11. 非零加减速段间停波根因修复 | |||
| 用户确认当前 `10段.bin` 未被覆盖,因此不再重复分析该文件。另通过对比测试确认:加减速时间为 0 ms 时段间连续,非 0 ms 时出现约 250~900 us 间隔。 | |||
| 根因是 0 ms 配置使用硬件多段有限序列,而非 0 ms 配置此前退回每段独立有限计数,在主循环处理边界期间发生 stop/start。现已改为非零加减速同样保持一条硬件多段序列,首段加速、中间变速、末段减速都在序列持续运行期间完成。 | |||
| 修复后自动验证:Host `102 tests / 526,561 assertions / 0 failures`,9 个非末段边界均断言硬件序列和脉冲输出保持 active;IAR `0 errors / 1 warning`;HEX SHA256 为 `8778C24D3FDA364DC4B1D6FAD92E69D9090C5B8EC57D1C65DFCAC89EA0B5AC45`;ST-LINK 和持久化区保护通过;COM5 10×100 脉冲累计位置、逐段边界事件和末段诊断均通过。 | |||
| 当前板上为同一 10 段配置、位置 0、IDLE。物理零间隔结论等待新波形确认。 | |||
| ## 12. `bbb.bin` 单段短斜坡复核 | |||
| `bbb.bin` 对应单段 1000 Hz x 100 脉冲,默认速度 10000 Hz,启动/停止 100 Hz,加减速 10 ms。直接解析原始边沿得到 100 个上升沿、100 个下降沿;首个上升沿至最后一个下降沿为 104.001120 ms,而不是理论/实测图片标注的约 526 ms。 | |||
| 图片中的长下降线是查看器缺陷:第 100 个脉冲后的采集空闲低电平被错误计入最后一个周期。查看器现已改为相邻上升沿计算周期,同一文件正确报告 100 个脉冲和 99 个可测完整周期。 | |||
| 原固件仍存在真实差异:首周期为 5.500160 ms(181.8129 Hz),末脉冲高电平仍为 0.500000 ms。根因是 1 ms 主循环无法及时提交持续时间仅约 1 ms 的启停斜坡。新固件已让不超过 1000 脉冲的单段 1 ms 短斜坡使用逐脉冲预计算,理论输出为首末约 550 Hz、中间 98 个 1000 Hz。Host `103 tests / 526,778 assertions / 0 failures`、IAR `0 errors / 1 warning`、烧录校验和持久化保护均已通过。COM5 板测位置 100、错误 0、expected/actual 100/100、count error 0、curve mismatch 0,末个诊断频率 550 Hz;等待覆盖采集新 `bbb.bin` 做最终物理波形验收。 | |||
| @@ -1,784 +0,0 @@ | |||
| # PLSR 2026 重写测试报告 | |||
| > 测试日期:2026-08-12 | |||
| > | |||
| > 测试分支:`codex/plsr-2026-minimal` | |||
| > | |||
| > 测试代码基线:`bf314a2343002a3df78bce5049badb29d11eae2e` | |||
| > | |||
| > PLSR 实现提交:`c7b851659ea26f9af8563e146bf5332c3c8d6dfd` | |||
| > | |||
| > 分支起点:`e35595aae18690a46a03445865bd6218a166bd89` | |||
| > | |||
| > 结论口径:只记录本次 2026 PLSR 最小重写实际执行过的测试 | |||
| ## 1. 总结 | |||
| 本次重写的 Host 回归、Python 离线自检、IAR 全量构建、固件烧录、真实 Modbus RTU 板测、Y0~Y3 逐路选择、X4/X5 实体回环、硬复位持久化和最终默认状态复核均通过。 | |||
| | 类别 | 最终结果 | 数量或关键结果 | | |||
| |---|---|---| | |||
| | C Host 回归 | PASS | 44 个测试组,1,694,007 次断言,0 失败 | | |||
| | Python 产品测试脚本离线自检 | PASS | 未打开串口 | | |||
| | Python 控制面板离线自检 | PASS | 未打开 GUI 或串口 | | |||
| | IAR EWARM 全量构建 | PASS | 0 errors,0 warnings | | |||
| | 固件烧录和校验 | PASS | `Verification...OK`,SHA256 与板测固件一致 | | |||
| | Modbus RTU/运动板测 | PASS | 9 组 | | |||
| | X4/X5 实体回环板测 | PASS | 9 个用例 | | |||
| | 硬复位持久化 | PASS | 配置和位置恢复,运动不自动续跑 | | |||
| | 最终默认状态复核 | PASS | 位置、配置、输入和夹具输出均恢复 | | |||
| | 逻辑分析仪测试 | 未执行 | 不在本轮验收范围内 | | |||
| 这里的“Y0~Y3 验证”是**同一个逻辑 PLSR 依次选择 Y0、Y1、Y2、Y3 作为脉冲输出**,不是四轴同时运动或四轴联动。 | |||
| `1,694,007 assertions` 是逐脉冲检查、边界检查和随机算术比对产生的断言总数,不代表 169 万个独立测试场景。 | |||
| ## 2. 测试范围和口径 | |||
| ### 2.1 纳入本报告 | |||
| - 本次重写新增的固定产品寄存器区:`0x1000~0x1197`、`0x2000~0x2006`、`0x3000`。 | |||
| - 单个逻辑 PLSR 的配置、状态、控制、运动、短段曲线、动态调频和停止。 | |||
| - 单个逻辑 PLSR 对 Y0~Y3 的逐路选择。 | |||
| - X4/X5 的 WAIT、EXT 和 EXT_OR_COMPLETE 实体输入回环。 | |||
| - 产品配置与逻辑位置的保存、硬复位恢复和默认值恢复。 | |||
| - 最小 Modbus RTU 的真实串口访问和异常响应。 | |||
| - 当前代码基线的 Host、Python、IAR 和固件产物复核。 | |||
| ### 2.2 不纳入本报告 | |||
| 旧工程曾做过的四轴并发、双 AB、软限位、长稳、USB CDC 并发和逻辑分析仪波形测试属于另一套实现,不能作为本次重写的通过证据,因此没有合并到本报告。 | |||
| ## 3. 环境与产物 | |||
| | 项目 | 实际环境或结果 | | |||
| |---|---| | |||
| | 目标板 | 信捷 XDM-60T4-E,STM32F407IG | | |||
| | RTOS | uC/OS-II | | |||
| | 串口 | `COM5`,9600 bit/s,8E1,从站 1 | | |||
| | Host 编译器 | GCC 4.9.2 (`tdm64-1`) | | |||
| | Host 编译选项 | C99,`-Wall -Wextra -Werror`,`PLSR_HOST_TEST` | | |||
| | IAR | EWARM 8.3.4,配置 `Modbus` | | |||
| | IAR 工程 | `EWARM/Modbus.ewp` | | |||
| | HEX | `EWARM/Modbus/Exe/Modbus.hex` | | |||
| | HEX 文本文件大小 | 142,131 B | | |||
| | Intel HEX 有效数据 | 50,513 B | | |||
| | HEX 地址范围 | `0x08000000..0x0800C550` | | |||
| | SHA256 | `97956D9EF30A9B274140B9B82F412EDD77F9637F563FCC3F552BB4B140772343` | | |||
| IAR 链接结果: | |||
| ```text | |||
| 49,004 bytes of readonly code memory | |||
| 1,509 bytes of readonly data memory | |||
| 18,660 bytes of readwrite data memory | |||
| Errors: none | |||
| Warnings: none | |||
| ``` | |||
| ## 4. 最终自动化回归 | |||
| ### 4.1 C Host 回归 | |||
| 执行命令: | |||
| ```powershell | |||
| powershell.exe -NoProfile -ExecutionPolicy Bypass ` | |||
| -File .\tests\plsr_host\run_tests.ps1 | |||
| ``` | |||
| 最终输出: | |||
| ```text | |||
| SUMMARY tests=44 assertions=1694007 failures=0 | |||
| ``` | |||
| 44 个测试组全部 PASS: | |||
| | # | 测试组 | 实际验证内容 | | |||
| |---:|---|---| | |||
| | 1 | `u64_by_u32_division` | 用 8 个边界被除数、9 个边界除数和 250,000 组确定性伪随机数据,比对 64 位除 32 位算法与 C 原生除法的商和余数。 | | |||
| | 2 | `protocol_boundaries` | 覆盖区间外、跨区、地址溢出、零长度、空缓冲、只读区、保留字、32 位字段不完整写入、非法值和控制字宽度,并确认失败写入不破坏原值。 | | |||
| | 3 | `wait_zero_one_ms` | 验证 `WAIT_TIME=0` 按最小 1 ms 处理,先进入 WAITING,1 ms 后才进入下一段。 | | |||
| | 4 | `act_zero_no_pulse` | 验证 `ACT_TIME=0` 的段不产生脉冲并在下一次 1 ms 调度跳过,后续段正常执行。 | | |||
| | 5 | `relative_absolute_zero` | 验证相对位移 0、相对位移 +5,以及当前位置等于绝对目标时的零位移完成。 | | |||
| | 6 | `self_jump_stop` | 验证单段自跳转可循环运行,并能由 STOP 正常终止。 | | |||
| | 7 | `stop_deceleration` | 验证 STOP 从 1000 Hz 按 1000 ms 减速至 100 Hz 后停止;重复 STOP 不重启减速,最终位置 902。 | | |||
| | 8 | `stop_pending_boundary` | 验证最后脉冲已完成但 Poll 尚未处理时收到 STOP,最终为 STOPPED 而非 COMPLETED。 | | |||
| | 9 | `direction_delay_one_ms` | 验证 1 ms 方向建立延时结束前不输出脉冲,满 1 ms 后启动。 | | |||
| | 10 | `ext_edge_at_natural_boundary` | 验证 EXT 边沿和自然末脉冲同时发生时只处理一次,不重复切段或计数。 | | |||
| | 11 | `ext_cut_natural_boundary_interleavings` | 覆盖 EXT 切断与自然边界的两种临界交错顺序,均正确进入后续 2000 Hz 段。 | | |||
| | 12 | `ext_edge_during_direction_delay` | 验证方向延时期间的 EXT 边沿不会在首脉冲前错误切段。 | | |||
| | 13 | `stopped_clears_pending_ext` | 验证 STOP 清除方向延时期间挂起的 EXT 边沿,重新 START 不消费旧边沿。 | | |||
| | 14 | `ext_edge_seamless_epoch` | 验证上一段的 EXT 竞态不会误切无缝衔接后的下一段。 | | |||
| | 15 | `ramp_seamless_epoch` | 验证上一段遗留的调速更新不会重定向无缝衔接后的下一段。 | | |||
| | 16 | `short_profile_boundary_matrix` | 覆盖直线、平滑、正弦三曲线与 1、2、10、99、100、101 脉冲,检查频率范围、升降趋势、对称性、计数和理论时长。 | | |||
| | 17 | `short_final_no_poll` | 验证三种曲线的 10 脉冲最终短段在脉冲间无 1 ms Poll 时仍逐脉冲减速并准确完成。 | | |||
| | 18 | `zero_to_maximum_ramp_duration` | 验证三种曲线从 0 加速到 100 kHz、1000 脉冲、20 ms 配置的单调性、峰值和总时长。 | | |||
| | 19 | `maximum_pulse_ramp_duration` | 验证三种曲线在 65,535 脉冲和 1311 ms 加速条件下采用可达峰值并准确计数。 | | |||
| | 20 | `maximum_single_ramp_no_poll` | 验证三种曲线的 65,535 脉冲单段在无 Poll 时持续逐脉冲更新并准确完成。 | | |||
| | 21 | `short_timer_failure` | 注入短段频率更新失败,验证立即关闭输出并进入 `ERROR/TIMER`,位置保留为 1。 | | |||
| | 22 | `stop_after_subsequent_handoff` | 验证无缝切入第二段后立即 STOP 能从新段频率正常减速并停止。 | | |||
| | 23 | `short_zero_speed_edges` | 验证起始/停止速度均为 0 时,1 脉冲和 2 脉冲短段仍以有效非零硬件频率准确执行。 | | |||
| | 24 | `short_dynamic_retarget` | 验证 50 脉冲短段中把目标频率由 1000 改为 2000 Hz 后安全上升,随后 STOP 正常停止。 | | |||
| | 25 | `short_ext_cut` | 验证短段执行 3 脉冲后触发 EXT,在下一脉冲边界切断,最终位置 4。 | | |||
| | 26 | `current_frequency_dynamic` | 验证当前段动态频率按安全脉冲边界生效,状态反映实际输出,运行中改脉冲数不改变启动快照。 | | |||
| | 27 | `poll_mailbox_failure` | 注入动态调频失败,验证 Poll 仅提交更新,失败在脉冲边界转为 `ERROR/TIMER`。 | | |||
| | 28 | `future_frequency_on_arrival` | 普通逐段模式中修改未来第二段频率,当前段不受影响,到达第二段时采用新值。 | | |||
| | 29 | `subsequent_future_frequency_handoff` | 无缝模式中修改未来段频率,验证预装衔接重建并以新频率无缝进入第二段。 | | |||
| | 30 | `future_frequency_last_pulse_race` | 验证未来段调频与当前段最后脉冲竞态时,第二段仍以新频率启动并准确完成。 | | |||
| | 31 | `latched_update_before_future_frequency_commit` | 验证屏蔽中断期间锁存的物理脉冲先消费旧衔接,再提交新的未来频率预装。 | | |||
| | 32 | `subsequent_no_poll_gap` | 验证两段只靠 ISR 无缝衔接,中间不依赖 Poll,输出不停止,最终位置 7。 | | |||
| | 33 | `three_single_pulse_no_poll` | 验证三个各 1 脉冲、1000/2000/3000 Hz 的段在无 Poll 时连续衔接。 | | |||
| | 34 | `handoff_queue_failures` | 两种时序下注入下一段频率排队失败,均关闭输出并转为 `ERROR/TIMER`。 | | |||
| | 35 | `command_state_restrictions` | 验证 IDLE/RUNNING 状态下 START、STOP、CLEAR、静态/动态配置写入及非法组合的限制。 | | |||
| | 36 | `command_mailbox_start_snapshot` | 验证 START 延迟执行与配置快照,后续写入和重复 START 不覆盖首个快照。 | | |||
| | 37 | `command_mailbox_stop_clear_deferred` | 验证重复 STOP/CLEAR 可幂等入队,状态变化延迟到 Poll,冲突命令返回 BUSY。 | | |||
| | 38 | `command_mailbox_validation` | 验证 START 入队前检查起始段和绝对位置有效性,非法配置不启动。 | | |||
| | 39 | `command_mailbox_start_failure` | 注入底层启动失败,验证 Poll 后进入 `ERROR/INVALID_RESOURCE`,CLEAR 可恢复。 | | |||
| | 40 | `wait_signal_positive` | 验证段后进入 WAITING,选定输入有效后由下一次 Poll 结束等待。 | | |||
| | 41 | `persistence_reinit` | 在 Host 存储模拟中验证输出、方向、2345 Hz 配置和位置 4 跨 `PlsrInit()` 恢复,CLEAR 后位置保持 0。 | | |||
| | 42 | `position_flash_save` | 验证位置稳定约 1000 ms 后仅保存一次,CLEAR 后延时产生第二次保存。 | | |||
| | 43 | `busy_reset_absolute_gate` | 模拟运动中复位,保留位置但取消绝对位置可信标志;CLEAR 后恢复绝对运动。 | | |||
| | 44 | `position_overflow` | 验证 `INT32_MAX` 正向溢出时立即停脉冲并进入 `ERROR/COUNT`。 | | |||
| 说明:Host 测试直接调用寄存器 API 和平台替身,不能代替真实 RTU 串口板测;真实 RTU 结果见第 7 节。 | |||
| ### 4.2 Python 产品测试脚本离线自检 | |||
| 执行命令: | |||
| ```powershell | |||
| py -B HostComputer\plsr_modbus_product_test.py --self-test | |||
| ``` | |||
| 结果: | |||
| ```text | |||
| Self-test passed; no serial port was opened | |||
| ``` | |||
| 覆盖: | |||
| - Modbus CRC16 参考向量及线上低字节在前的字节序。 | |||
| - 6 组有符号 32 位数的双 WORD 拆分与合并。 | |||
| - 1200 Hz 请求的 1 Hz 容差边界。 | |||
| - ST-LINK GPIO 位读回文本解析。 | |||
| ### 4.3 Python 控制面板离线自检 | |||
| 执行命令: | |||
| ```powershell | |||
| py -B HostComputer\plsr_control_panel.py --self-test | |||
| ``` | |||
| 结果: | |||
| ```text | |||
| PLSR control panel self-test passed; no GUI or serial port was opened | |||
| ``` | |||
| 覆盖 9600 波特率、X4/X5 选项、10 段地址步进、整数解析和越界拒绝、公共区 20 WORD 编码、段区 8 WORD 编码、负脉冲补码及跳转段范围校验。 | |||
| ## 5. IAR 构建 | |||
| 执行命令: | |||
| ```powershell | |||
| & 'F:\IAR Systems\Embedded Workbench 8.3\common\bin\IarBuild.exe' ` | |||
| 'EWARM\Modbus.ewp' -build Modbus -log warnings | |||
| ``` | |||
| 最终结果: | |||
| ```text | |||
| Total number of errors: 0 | |||
| Total number of warnings: 0 | |||
| ``` | |||
| `EWARM/Modbus/List/Modbus.map` 同时报告 `Errors: none`、`Warnings: none`,内存数据见第 3 节。 | |||
| ## 6. 烧录与固件产物校验 | |||
| ### 6.1 烧录 | |||
| 最终烧录使用 `EWARM/Modbus/Exe/Modbus.hex`,只擦除并写入应用占用的 Sector 0~3。ST-LINK 最终输出: | |||
| ```text | |||
| Verification...OK | |||
| ``` | |||
| 开发过程中还用 STM32CubeProgrammer 执行过两次下载校验,用于确认当时固件可以下载和校验。它们属于中间版本的烧录链路记录,不是功能测试,也不作为最终交付固件的产物证据;最终结论以上述 ST-LINK 烧录、最终 SHA256 和后续完整板测为准。 | |||
| ### 6.2 持久化区保护 | |||
| 烧录前后均读取并检查 Sector 10/11。应用 HEX 地址上限为 `0x0800C550`,不覆盖持久化区;烧录流程也没有擦除 Sector 10/11。该检查用于确认后续持久化板测不是由重新烧录清空后伪造出的初始状态。 | |||
| ### 6.3 产物一致性 | |||
| 板测烧录件和当前分支重新构建产物的 SHA256 均为: | |||
| ```text | |||
| 97956D9EF30A9B274140B9B82F412EDD77F9637F563FCC3F552BB4B140772343 | |||
| ``` | |||
| ## 7. 真实 Modbus RTU 和运动板测 | |||
| 执行入口: | |||
| ```powershell | |||
| py -B HostComputer\plsr_modbus_product_test.py ` | |||
| --port COM5 --run --phase all --allow-motion | |||
| ``` | |||
| 最终 `phase=all` 的 9 组测试全部 PASS: | |||
| | # | 用例 | 实际验证内容 | 结果 | | |||
| |---:|---|---|---| | |||
| | 1 | `fixed_map_and_exceptions` | 真实 FC03/06/10 访问固定配置、段、状态和控制区;检查保留字读零、控制字读零;验证不支持 FC01 返回 0x01,非法/跨区/只读地址返回 0x02,保留字非零、半个 DWORD 和组合控制命令返回 0x03。 | PASS | | |||
| | 2 | `positive_negative_absolute_zero` | 验证正向相对、负向相对、绝对定位及零位移,检查方向、终态和逻辑位置。 | PASS | | |||
| | 3 | `all_outputs_100khz` | 同一个 PLSR 依次选择 Y0、Y1、Y2、Y3,每路请求 100 kHz、执行 1000 个逻辑脉冲并检查完成位置。不是四轴并发。 | PASS | | |||
| | 4 | `short_final_profiles` | 对直线、平滑、正弦三种曲线执行最终 10 脉冲短段,检查终态和准确位置。 | PASS | | |||
| | 5 | `wait_time` | 执行 10 脉冲后进入 WAITING,配置等待 120 ms;检查不会异常提前完成,之后正常完成。 | PASS | | |||
| | 6 | `act_time` | 2000 脉冲目标配合 80 ms ACT,检查时间到后在 0~2000 之间切断并正常完成。 | PASS | | |||
| | 7 | `dynamic_frequency_repeated_stop` | 当前段从 500 Hz 动态改为 1200 Hz;真实状态读回 1199 Hz,在允许的 1 Hz 容差内;运行中静态输出选择写入返回 BUSY;重复 STOP 正常减速停止且未跑满 3000 脉冲。 | PASS | | |||
| | 8 | `future_segment_frequency_on_arrival` | 普通逐段模式运行第一段时修改未来第二段频率,确认当前段仍为 400 Hz,进入第二段后采用 900 Hz。 | PASS | | |||
| | 9 | `subsequent_future_frequency_handoff` | SUBSEQUENT 模式运行第一段时修改未来段频率,确认当前段不变,第二段以 900 Hz 衔接并最终累计 400 脉冲。 | PASS | | |||
| 脚本在退出时停止仍在运行的任务,并恢复进入测试前保存的公共配置和前两段配置。 | |||
| 正式烧录后还在完整 `phase=all` 之前单独执行过一次 `--phase smoke`,固定映射和异常码检查 PASS。第 9 节恢复默认值并再次硬复位后又执行了一次 smoke,结果仍为 PASS。 | |||
| ## 8. X4/X5 实体回环板测 | |||
| ### 8.1 接线和方法 | |||
| 本轮保留的自动化回环接线: | |||
| ```text | |||
| Y4 -> X4 | |||
| Y5 -> X5 | |||
| ``` | |||
| 脚本通过 ST-LINK 临时把 Y4/Y5 对应 GPIO 配置为输出,驱动 PLC 输入并直接读回 X4/X5 输入位;产品协议和正式固件没有为测试夹具新增私有寄存器。 | |||
| 开发期夹具最初尝试写 GPIO 的只写 BSRR 寄存器。旧版 `ST-LINK_CLI 3.5.0` 会在写后回读校验,因 BSRR 不可按普通寄存器回读而返回码 4。夹具随后改用 PI8/PE6 的 ODR 位带地址 `0x424402A0`、`0x42420298` 驱动输出。该失败是调试工具与只写寄存器的校验方式不兼容,不是接线、X4/X5 输入或 PLSR 功能故障。 | |||
| 执行入口: | |||
| ```powershell | |||
| py -B HostComputer\plsr_modbus_product_test.py ` | |||
| --port COM5 --run --phase x45 --allow-motion | |||
| ``` | |||
| ### 8.2 九个用例 | |||
| | # | 用例 | 实际验证内容 | 结果 | | |||
| |---:|---|---|---| | |||
| | 1 | `x45_electrical_scan` | Y4/Y5 均关闭时 X4/X5 均为低;分别打开 Y4、Y5 时对应输入为高、另一路保持低;关闭后恢复低,验证双路电气扫描和隔离。 | PASS | | |||
| | 2 | `X4_wait_signal` | 选择 X4;50 脉冲后进入 WAITING,X4 低时保持,Y4 拉有效后完成,位置仍为 50。 | PASS | | |||
| | 3 | `X4_ext_signal` | 选择 X4;5000 脉冲运动中由 Y4 触发 EXT_SIGNAL,提前切段并正常完成。 | PASS | | |||
| | 4 | `X4_ext_or_complete_natural` | 选择 X4;不触发输入,50 脉冲自然完成。 | PASS | | |||
| | 5 | `X4_ext_or_complete_trigger` | 选择 X4;5000 脉冲运动中由 Y4 外部触发,提前完成。 | PASS | | |||
| | 6 | `X5_wait_signal` | 选择 X5;50 脉冲后进入 WAITING,X5 低时保持,Y5 拉有效后完成,位置仍为 50。 | PASS | | |||
| | 7 | `X5_ext_signal` | 选择 X5;5000 脉冲运动中由 Y5 触发 EXT_SIGNAL,提前切段并正常完成。 | PASS | | |||
| | 8 | `X5_ext_or_complete_natural` | 选择 X5;不触发输入,50 脉冲自然完成。 | PASS | | |||
| | 9 | `X5_ext_or_complete_trigger` | 选择 X5;5000 脉冲运动中由 Y5 外部触发,提前完成。 | PASS | | |||
| ODR 位带夹具修正后先完成独立电气扫描,再执行完整九项用例。异常清理顺序修正后又执行了第二次完整九项回归,结果仍全部 PASS。 | |||
| ### 8.3 最终夹具清理状态 | |||
| 测试结束或异常退出均按以下顺序清理:先关闭 Y4/Y5,再停止仍在运行的 PLSR,恢复测试前配置,最后把 PI8/PE6 恢复为输入高阻并清除上下拉。最终复核结果: | |||
| - Y4/Y5 均关闭。 | |||
| - PI8/PE6 均为输入高阻、无内部上下拉。 | |||
| - X4/X5 读回均为低。 | |||
| ## 9. 持久化与硬复位板测 | |||
| 持久化测试使用真实目标板和 100 ms 硬复位,不以 Host 内存模拟代替。 | |||
| ### 9.1 保存准备 | |||
| 执行 `persistence-prepare`: | |||
| - 清零逻辑位置。 | |||
| - 写入 500 Hz、7 脉冲的特征配置。 | |||
| - 执行运动并确认 COMPLETED、位置为 7。 | |||
| - 等待后台保存完成。 | |||
| ### 9.2 第一次硬复位和恢复检查 | |||
| 执行 100 ms 硬复位后运行 `persistence-verify --cleanup`,结果: | |||
| - 上电状态为 IDLE,没有自动续跑复位前任务。 | |||
| - 逻辑位置恢复为 7。 | |||
| - 第一段频率恢复为 500 Hz。 | |||
| - 第一段脉冲数恢复为 7。 | |||
| 随后执行 CLEAR,把位置恢复为 0;写回默认配置并等待保存。 | |||
| ### 9.3 第二次硬复位和默认状态检查 | |||
| 再次执行 100 ms 硬复位后运行 `defaults-verify`,结果: | |||
| - 状态为 IDLE,逻辑位置为 0。 | |||
| - 默认方向建立延时为 10 ms。 | |||
| - 默认速度为 1000 Hz,起始速度和停止速度均为 100 Hz。 | |||
| - 第 1 段为 1000 Hz、1000 脉冲。 | |||
| - 第 2~10 段频率均为 1000 Hz、脉冲数均为 0。 | |||
| 最后又执行一次固定映射 smoke,确认默认配置恢复后 RTU 访问和异常码仍通过。 | |||
| ## 10. 测试过程中出现的问题和修复回归 | |||
| 本节记录中间失败;这些结果不是最终失败,而是用于说明本轮调试实际发现并关闭了哪些问题。 | |||
| | 阶段 | 中间现象 | 原因与处理 | 修复后结果 | | |||
| |---|---|---|---| | |||
| | 旧架构 PDU 试验 | `length == 8U`、`TestStatusAndControl() == 0` 失败 | 试验基于待废弃的旧 PDU 架构。需求复核后没有继续补旧架构,而是按 2026 固定产品接口重写;该试验路线整体废弃。 | 新固定接口由 Host `protocol_boundaries` 和真实 `fixed_map_and_exceptions` 覆盖并通过。 | | |||
| | 初期 Host | `12 tests / 262 assertions / 2 failures` | 失败项是 `wait_zero_rejected`、`act_zero_rejected`。复核明确需求后确认正确语义为 `WAIT=0` 按 1 ms,`ACT=0` 零脉冲跳段,代码和测试按正确语义修正。 | `wait_zero_one_ms`、`act_zero_no_pulse` 通过;最终 44/1,694,007/0。 | | |||
| | 扩充 EXT 测试 | Host 编译失败,`PLSR_EXT_SIGNAL` 未声明 | 测试扩充时枚举和测试引用未同步,补齐枚举/接口后重跑。 | Host 编译和完整回归通过。 | | |||
| | Modbus 裁剪中间态 | IAR `10 errors / 2 warnings` | 头文件先精简,旧源文件与 `main.c` 尚未同步清理;不是运动算法失败。随后完成最小 RTU 源文件和入口同步。 | IAR 全量构建 `0 errors / 0 warnings`。 | | |||
| | Host 脚本启动 | Windows 默认策略阻止直接运行 `.ps1` | 改用 `powershell.exe -NoProfile -ExecutionPolicy Bypass -File ...`。 | Host 脚本正常执行并通过。 | | |||
| | X4/X5 夹具初版 | `ST-LINK_CLI 3.5.0` 写 BSRR 返回码 4 | BSRR 是只写寄存器,旧 CLI 的写后回读校验不适用。改用 PI8/PE6 的 ODR 位带地址驱动 Y4/Y5。 | 独立电气扫描和完整 X4/X5 九项通过;判定为夹具工具限制,不计为产品失败。 | | |||
| | X4/X5 夹具审查 | 异常退出清理顺序不够安全 | 调整为先关闭 Y4/Y5,再停止运动、恢复配置,最后释放 GPIO;保证清理期间不会继续保持输入触发。 | 完整 X4/X5 九项重新执行并通过,最终 GPIO/输入状态复核通过。 | | |||
| 中间失败均保留在本报告中,但最终交付结论以当前基线重新执行的结果为准。 | |||
| ## 11. 明确未执行和结论边界 | |||
| 以下项目本轮**未执行**,不得从本报告的 PASS 推导为已验证: | |||
| - 未使用逻辑分析仪,因此没有验证端子真实占空比、每路真实边沿数、毛刺、窄脉冲、方向建立时间或段间物理波形连续性。 | |||
| - 未对 MCU 中断最坏响应时间和 100 kHz 下的 ISR 最坏时延做仪器测量。 | |||
| - 未做四轴同时运动或四轴联动;本实现只有一个逻辑 PLSR,Y0~Y3 是逐路选择。 | |||
| - 未做 USB CDC 与 Modbus/运动并发长稳。 | |||
| - 未做本次最小重写的长时间压力、断线重连或坏 CRC 长稳矩阵。 | |||
| - 未做真实 VBAT 断主电源测试。本轮实际执行的是调试器控制的 100 ms 硬复位;它能证明复位恢复路径,不能等价证明 VBAT/完全掉电保持。 | |||
| - Host 的持久化用例使用内存模拟;真实 Flash/复位结论仅来自第 9 节板测。 | |||
| - Host 直接调用寄存器 API,不证明 RTU 物理链路;真实 RTU 覆盖仅限第 7~9 节列出的板测。 | |||
| - 未用 IAR 8.40 构建;本轮实际验证版本为 IAR EWARM 8.3.4。 | |||
| ## 12. 最终状态 | |||
| 测试结束后目标板处于以下状态: | |||
| - 固件为 SHA256 `97956D9EF30A9B274140B9B82F412EDD77F9637F563FCC3F552BB4B140772343` 对应版本。 | |||
| - PLSR 状态为 IDLE,逻辑位置为 0。 | |||
| - 固定产品寄存器配置已恢复默认值。 | |||
| - Y4/Y5 夹具输出已关闭,PI8/PE6 已释放为输入高阻。 | |||
| - X4/X5 均读回低。 | |||
| - 最终 `defaults-verify` 和固定映射 smoke 均 PASS。 | |||
| 本报告没有引用旧实现的四轴、双 AB、长稳或逻辑分析仪结果作为本次重写证据。 | |||
| ## 13. 2026-08-13 第二批:单段硬件计数加减速 | |||
| ### 13.1 实现范围 | |||
| - PULSE/DIR、单段、完成模式、相对位置。 | |||
| - 直线、S 曲线、正弦曲线加减速。 | |||
| - PWM 由输出定时器持续产生,TIM9/TIM12 通过内部触发硬件计数。 | |||
| - 曲线频率按 1 ms 节拍计算;新 PSC/ARR/CCR 在下一个 PWM 更新边界整体生效。 | |||
| - 每次曲线更新最多产生一次边界提交中断,不按每个输出脉冲进入业务中断。 | |||
| - 仅当段长可容纳完整加减速预算时进入本批硬件曲线快路径;短曲线留待后续批次。 | |||
| ### 13.2 自动化验证 | |||
| | 项目 | 结果 | 关键结果 | | |||
| |---|---|---| | |||
| | C Host 完整回归 | PASS | 86 tests / 514,079 assertions / 0 failures | | |||
| | 第二批专属 Host 矩阵 | PASS | 三种曲线均走有限硬件计数路径,运行中位置递增,先加速后减速,最终 20,000 脉冲 | | |||
| | Python 产品测试离线自测 | PASS | 未打开串口 | | |||
| | Python 控制面板离线自测 | PASS | 未打开 GUI 或串口 | | |||
| | IAR clean build | PASS | 0 errors / 0 warnings | | |||
| | 固件烧录与校验 | PASS | Download verified successfully | | |||
| 板测固件: | |||
| ```text | |||
| HEX: EWARM/Modbus/Exe/Modbus.hex | |||
| SHA256: C2686C1E432E2F55B0695ECF21A82E4E61373CB952CB065EBB256523D2689D35 | |||
| ``` | |||
| ### 13.3 实板波形与诊断 | |||
| 测试参数:Y0/Y12、10 kHz 起始、50 kHz 目标、10 kHz 停止、加减速各 100 ms、20,000 脉冲。 | |||
| 逻辑分析仪结果由操作者反馈:**波形分析全部通过**。 | |||
| 板内最终状态: | |||
| | 项目 | 实测值 | 结果 | | |||
| |---|---:|---| | |||
| | 累计位置 | 20,000 | PASS | | |||
| | 运行状态 | 7(COMPLETED) | PASS | | |||
| | 诊断标志 | 0x007E | 计数、频率、曲线均已检查且 PASS | | |||
| | 期望脉冲数 | 20,000 | PASS | | |||
| | 实测脉冲数 | 20,000 | PASS | | |||
| | 计数误差 | 0 | PASS | | |||
| ### 13.4 性能门限 | |||
| CPU 时钟 168 MHz,使用 Cortex-M4 DWT CYCCNT 实测。 | |||
| | 路径 | 次数 | 最坏周期 | 最坏时间 | 结论 | | |||
| |---|---:|---:|---:|---| | |||
| | 1 ms 曲线计算及硬件参数排队 | 160 | 1,302 cycles | 7.75 us | PASS;按 1 kHz 最坏调度约占 0.78% CPU | | |||
| | PWM 边界参数提交中断 | 159 | 151 cycles | 0.90 us | PASS;每次曲线更新一次,不按输出脉冲触发 | | |||
| | 65536 脉冲分块中断 | 0 | 0 | 0 | 符合预期,本用例仅 20,000 脉冲 | | |||
| | 最终停止中断 | 1 | 276 cycles | 1.64 us | PASS;低于 500 cycles 门限 | | |||
| 第二批结论:单段长曲线的硬件计数、三种加减速曲线、精确停止、板内诊断和性能门限通过。 | |||
| ## 14. 2026-08-13 零加减速时间语义与 TC-PD-008 共性问题修复 | |||
| ### 14.1 修复语义 | |||
| - 加速时间为 0 ms 时,起始速度参数无效,当前段从该段设定频率直接启动。 | |||
| - 减速时间为 0 ms 时,停止速度参数无效,当前段保持该段设定频率直至完成。 | |||
| - 有加速或减速时间时,原有起始速度、停止速度和三种曲线规划保持不变。 | |||
| - 后续模式同方向连续段仍允许按下一段频率在合法边界衔接。 | |||
| 修复覆盖普通启动、方向建立延时后的启动、短段预计算、有限脉冲硬件计数快路径、自然段尾和 STOP 路径,避免只有部分路径遵守零时间语义。 | |||
| ### 14.2 Host 脉冲级回归 | |||
| 新增 `zero_ramp_times_ignore_edge_speeds`,覆盖直线、S 曲线、正弦曲线和 1、2、10 脉冲: | |||
| - 加减速均为 0 ms,起始/停止速度均故意设为 100 Hz:每个实际周期均为 100,000 Hz。 | |||
| - 仅加速为 0 ms:起始速度分别设为 100 Hz 和 50,000 Hz,两次实际逐脉冲频率序列完全一致。 | |||
| - 仅减速为 0 ms:停止速度分别设为 100 Hz 和 50,000 Hz,两次实际逐脉冲频率序列完全一致。 | |||
| 完整 Host 回归结果: | |||
| ```text | |||
| 94 tests / 515,670 assertions / 0 failures | |||
| ``` | |||
| ### 14.3 IAR、烧录和持久化区保护 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | IAR EWARM 8.3.4 全量构建 | 0 errors / 1 warning;map 正文 `Errors: none`、`Warnings: none` | | |||
| | 只读代码 | 67,586 bytes | | |||
| | 只读数据 | 1,517 bytes | | |||
| | 读写数据 | 27,335 bytes | | |||
| | HEX SHA256 | `F0B4EC12C71471B4C377E440C5442A006DA67E1095B0696BB16E20586E1B03F7` | | |||
| | ST-LINK 烧录 | `Verification...OK`,MCU 复位并运行 | | |||
| | 持久化区保护 | `0x080C0000`~`0x080FFFFF` 分为四个 64 KiB 块,烧录前后 SHA256 逐块一致 | | |||
| 旧版 `ST-LINK_CLI 3.5.0` 对单次 256 KiB Dump 卡住,因此保护校验改为四次 64 KiB 分块回读;连接探测、程序烧录和四块回读均正常。 | |||
| ### 14.4 COM5 实板内部验证 | |||
| 执行 28 个用例: | |||
| - 加减速均为 0 ms,起始/停止速度均为 100 Hz:三种曲线 x 1/2/10 脉冲。 | |||
| - 仅加速为 0 ms,起始速度为 50,000 Hz:三种曲线 x 1/2/10 脉冲。 | |||
| - 仅减速为 0 ms,停止速度为 50,000 Hz:三种曲线 x 1/2/10 脉冲。 | |||
| - 加减速均为 0 ms、100,000 Hz、100,000 脉冲的有限硬件计数长段。 | |||
| 全部 28 项满足:状态 7、错误 0、位置等于设定、诊断期望计数等于实际计数、计数误差 0、诊断原因 0、曲线失配数 0、最大频率误差 0。 | |||
| 额外读取三种曲线的零加减速 10 脉冲诊断字段,均为:请求频率 100,000 Hz、定时器期望频率 100,000 Hz、定时器实际频率 100,000 Hz、请求误差 0、样本数大于 0、失配数 0。 | |||
| ### 14.5 TC-PD-008 结论边界 | |||
| 验收表中 TC-PD-008 已填写的“多 1 个脉冲、首脉冲偏长、尾部窄脉冲”是修复前固件数据。本批固件已包含 PULSE/DIR 启动相位和最终边界终止修复,Host、板内计数及诊断均通过,但仍需用逻辑分析仪对当前 SHA256 固件复测,才能关闭端子外部 N+1、首周期和尾窄脉冲问题。原验收数据保留,不以板内结果覆盖。 | |||
| ## 15. 2026-08-14 默认频率、10 段交接与段尾慢脉冲修复 | |||
| ### 15.1 功能修复 | |||
| - 段频率为 0 时运行使用默认频率,Modbus 寄存器仍读回原始值 0;启动前和运行中写 0 均覆盖。 | |||
| - 修复有限脉冲动态 ramp 的 queued→active 提交卡住后,段 2~8 长时间停留在约 1.05 kHz 的问题。 | |||
| - ramp 提交返回 STALE 时保持 ramp 活跃,终点频率必须成功提交后才结束。 | |||
| - 边界条件命中后立即提交首个减速频点,消除额外 1 ms 轮询延迟。 | |||
| - 删除重复的频差余量;仅当一个轮询周期的脉冲数不超过斜坡面积 5% 时保留轮询余量,避免短斜坡提前到达停止速度并发送慢尾脉冲。 | |||
| - timing 上位机在旧逐脉冲 IRQ 统计为空时读取有限硬件计数 block/final IRQ 统计,适配当前硬件计数架构。 | |||
| ### 15.2 Host 全量回归 | |||
| 新增和强化用例: | |||
| - `finite_ramp_retries_terminal_frequency_after_stale` | |||
| - `complete_ten_segment_frequency_staircase` | |||
| - `ten_ms_boundary_ramp_no_slow_tail` | |||
| - `zero_segment_frequency_uses_default_speed` | |||
| - `dynamic_zero_frequency_uses_default_speed` | |||
| 10 段用例逐脉冲检查 1~10 kHz 峰值,并要求段 1~9 最后连续 `<=200 Hz` 的脉冲不超过 2 个;10 ms 边界专项同时要求峰值达到 99% 且无慢尾。 | |||
| ```text | |||
| 100 tests / 525,879 assertions / 0 failures | |||
| ``` | |||
| ### 15.3 IAR、固件与持久化区 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 warning | | |||
| | 唯一警告 | `PlsrCounterTriggerSourceAxis` set but never used | | |||
| | 只读代码 | 67,770 bytes | | |||
| | 只读数据 | 1,517 bytes | | |||
| | 读写数据 | 39,623 bytes | | |||
| | HEX 大小 | 194,945 bytes | | |||
| | HEX SHA256 | `4E4ED664CE05F364B7443711DC85E719960B52BCBCD5FFC347D76F0CCFE5D863` | | |||
| | ST-LINK | 扇区 0~3 擦写,`Verification...OK`,复位运行 | | |||
| | 持久化区 | 最新烧录前后 SHA256 均为 `44094568B88CC30AFB82D70D343AC7E67DC374A568854E6426EE21D431349783` | | |||
| ### 15.4 COM5 Pulse/Direction 板测 | |||
| 产品矩阵 12 项全部通过:固定寄存器与异常码、正负/绝对/零位移、10 段顺序、从第 10 段启动、方向与极性矩阵、四路 100 kHz、短最终轮廓、WAIT、ACT、动态频率与重复 STOP、未来段改频、后续模式交接。 | |||
| 延长 10 段测试将每段脉冲数改为 1000,逐段捕获: | |||
| ```text | |||
| 1000, 2000, 3000, 4000, 5000, | |||
| 6000, 7000, 8000, 9000, 10000 Hz | |||
| ``` | |||
| 最终位置 10,000、错误 0;末段诊断 expected 1000、actual 1000、count error 0、mismatch 0。测试后已恢复 10 段 x 100 脉冲、位置 0、IDLE。 | |||
| ### 15.5 DWT 性能 | |||
| 门限为 `<1680 cycles`(100 kHz 周期)。 | |||
| | 路径 | 实测最大值 | 结果 | | |||
| |---|---:|---| | |||
| | 四路有限计数最终 IRQ(Y0~Y3) | 344 / 400 / 340 / 404 cycles | PASS | | |||
| | 10 脉冲短轮廓 IRQ | 1657 cycles | PASS | | |||
| | 10 脉冲短轮廓生产者 | 1380 cycles | PASS | | |||
| | 65,535 脉冲长 ramp IRQ | 159 cycles | PASS | | |||
| | 65,535 脉冲 ramp 更新 | 1320 cycles(197 次) | PASS | | |||
| 完整 timing 阶段的 Pulse/Direction 部分通过;最后进入 AB Y2/Y3 负向用例时,诊断标志为 `0x00FA`,该 AB 路径按当前调试范围暂缓,不计入本批 Pulse/Direction 结论。 | |||
| ### 15.6 逻辑分析仪状态 | |||
| 14:34 的第二次 `10段.bin` 已证明段 1~10 分别达到目标频率,但段 3、7、8、9 仍有 3~4 个连续低速尾脉冲。根据实测又移除了固定 2 脉冲预装余量并重新烧录;最终还需覆盖采集一次同配置波形,确认段尾连续低速脉冲不超过 2 个。段间约 5 ms 还包含下一段 100 Hz 启动的低半周期,需与慢尾分开判断。详细数据见 `PLSR波形验收报告_2026-08-14_完成模式10段阶梯频率.md`。 | |||
| ## 16. 2026-08-14 完成模式多段启动/停止速度语义修复 | |||
| ### 16.1 修复语义 | |||
| 当各段等待条件均为“外部或脉冲发送完成”,且由脉冲发送完成自然进入下一段时,多段按一条连续轨迹处理: | |||
| - 启动速度只用于第一执行段; | |||
| - 同方向中间段在前一段内向下一段频率过渡,完成时把当前速度带入下一段; | |||
| - 停止速度只作为最后执行段的段尾目标; | |||
| - 跳转段使用实际跳转目标段频率,不误用顺序下一段; | |||
| - 外部截断、WAIT/ACT 结束、STOP 和方向反转不带速穿越,仍执行安全停机/换向流程; | |||
| - 硬件多段常频序列只在加速和减速时间都为 0 ms 时启用,避免优化路径跳过首段加速或末段减速。 | |||
| 短段脉冲预算不足时,最后一段可能在到达停止速度前耗尽设定脉冲;此时要求按减速斜率平滑收尾,不额外补发脉冲以强行达到停止速度。 | |||
| ### 16.2 Host 全量回归 | |||
| 新增 `complete_natural_boundaries_carry_speed` 和 `complete_jump_carries_speed`,并更新零时间有限序列、同向/反向方向写入的旧语义断言。覆盖三段同向顺序、跳转段、第一段启动速度、中间段速度携带、最后一段停止减速及反向安全路径。 | |||
| ```text | |||
| 102 tests / 526,542 assertions / 0 failures | |||
| ``` | |||
| ### 16.3 IAR、固件与烧录 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | IAR EWARM 8.3.4 全量构建 | 0 errors / 1 warning | | |||
| | 唯一警告 | `PlsrCounterTriggerSourceAxis` set but never used | | |||
| | 只读代码 | 67,806 bytes | | |||
| | 只读数据 | 1,517 bytes | | |||
| | 读写数据 | 39,623 bytes | | |||
| | HEX 大小 | 195,043 bytes | | |||
| | HEX SHA256 | `7C6534FA05FB61BBA8CAF7838F36BAF908612CBDE525A17F23354E8C702FB5A2` | | |||
| | ST-LINK | Sector 0~3 擦写,`Verification...OK`,复位运行 | | |||
| | 持久化区 | Sector 10~13 分块读取,四块烧录前后 SHA256 分别一致 | | |||
| ### 16.4 COM5 实板内部验证 | |||
| 三段配置为 1000/2000/3000 Hz、每段 1000 脉冲,启动 100 Hz、停止 200 Hz、加减速 10 ms。结果: | |||
| - 终态 `COMPLETED`,错误 0,位置 3000; | |||
| - 三段首次可读运行频率为 1000/2000/3000 Hz,中间段未回到 100 Hz; | |||
| - 末段诊断 expected 1000、actual 1000、count error 0、mismatch 0; | |||
| - 末段最后诊断请求频率为 1041 Hz,证明停止减速只发生在最后一段,且短段在达到 200 Hz 前按设定脉冲数正常结束; | |||
| - 固定映射与异常码 smoke 复测通过。 | |||
| 测试后恢复 10 段、每段 100 脉冲、1~10 kHz、默认 10 kHz、启动/停止 100 Hz、加减速 10 ms、位置 0、IDLE。 | |||
| ### 16.5 待逻辑分析仪确认 | |||
| 当前代码、Host、构建、烧录和板内计数均通过。新语义固件仍需覆盖采集 `10段.bin`,确认启动速度只出现在第一段、停止减速只出现在第 10 段,并重新测量段间低电平;旧波形不作为本固件的物理波形结论。 | |||
| ## 17. 2026-08-14 15:24 连续轨迹波形与末段慢尾修复 | |||
| ### 17.1 15:24 波形结果 | |||
| 波形 SHA256:`49BB40FE59A70DF41A82122AC6EAE253C48661F64E2B74C27F3EE01B3E03860B`,共 10,011,664 点,6.25 MS/s,1000 个上升沿和 1000 个下降沿。 | |||
| - 第 2~10 段起始运行频率分别为 2000、3000、4000、5000、约 6000、7000、8000、9000、10000 Hz;中间段不再回到 100 Hz,连续轨迹语义通过。 | |||
| - 段 1~9恒速频率和向下一段的段尾过渡正确。 | |||
| - 9 个段间低电平为 0.250080~0.949760 ms,均小于 1 ms;旧固件约 5~6 ms 的低电平消失。 | |||
| - 第 10 段按 10000→100 Hz 减速,但脉冲 96~100 出现 183/100/100/100 Hz 周期和最终 5 ms 高电平;连续 `<=200 Hz` 周期为 4 个,超过最多 2 个的门限。 | |||
| 因此 15:24 波形证明多段启动/停止速度作用范围修复成功,但末段慢尾仍不通过。 | |||
| ### 17.2 末段慢尾根因与修复 | |||
| 边界估算原来使用连续梯形面积;实际 `PlsrRampAdvance` 每 1 ms 使用 `1/duration` 到 `duration/duration` 的右端点频率。10 kHz→100 Hz、10 ms 的连续面积约为 51 脉冲,右端点离散面积约为 46 脉冲,导致减速提前约 5 脉冲到达停止速度。 | |||
| 修复后: | |||
| - 短斜坡按实际右端点离散面积估算,避免提前进入停止速度慢尾; | |||
| - 允许完整轮询余量的长斜坡补偿半个端点差,保持 65,535 脉冲长轮廓能够到达停止速度; | |||
| - 10 段 Host 用例现在也检查第 10 段连续 `<=200 Hz` 尾脉冲不超过 2 个。 | |||
| ### 17.3 修复后验证 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | Host 全量 | 102 tests / 526,543 assertions / 0 failures | | |||
| | 65,535 脉冲三曲线长轮廓 | 末频回归通过 | | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 warning | | |||
| | 只读代码/数据、读写数据 | 67,886 / 1,517 / 39,623 bytes | | |||
| | HEX 大小 | 195,268 bytes | | |||
| | HEX SHA256 | `5851B7714454770DD8EF8A0ED8B01379791F4EF71BDF1B795B36639B18F258AB` | | |||
| | ST-LINK | `Verification...OK`;Sector 10~13 烧录前后分块哈希一致 | | |||
| | COM5 10×100 | 位置 1000、错误 0、末段 expected/actual 100/100、count error 0、mismatch 0 | | |||
| | 末段最终板内诊断频率 | 3071 Hz,未提前进入 100 Hz 慢尾 | | |||
| 板上已恢复 10 段 x 100 脉冲、位置 0、IDLE。需再覆盖采集一次 `10段.bin`,确认物理端子的末段连续低速尾脉冲不超过 2 个。 | |||
| ## 18. 2026-08-14 非零加减速段间连续输出修复 | |||
| ### 18.1 根因 | |||
| 实测发现加减速时间为 0 ms 时段间没有间隔,而加减速时间非 0 ms 时仍有约 250~900 us 低电平。根因是两种配置走了不同硬件路径: | |||
| - 0 ms:整条同向轨迹使用硬件多段有限序列,计数器在段边界直接装载下一段,定时器不停机; | |||
| - 非 0 ms:此前限制了硬件多段序列,每段分别启动有限计数,段完成后等待主循环处理边界再启动下一段,产生一个主循环周期以内的停波。 | |||
| 该间隔不是启动/停止速度数值本身造成,而是非零斜坡配置触发了逐段 stop/start 路径。 | |||
| ### 18.2 修复 | |||
| - 非零加减速也使用同一条硬件多段有限序列; | |||
| - 序列第一步从启动速度开始,并在硬件序列持续运行期间执行 1 ms 斜坡更新; | |||
| - 中间硬件边界只切换软件段状态和诊断,不停止脉冲定时器; | |||
| - 最后一个段仍在同一序列内执行停止减速并由硬件总计数终止; | |||
| - 同向可连续段以外的方向反转和不满足序列条件的路径仍保留安全停机。 | |||
| ### 18.3 验证 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | Host 全量 | 102 tests / 526,561 assertions / 0 failures | | |||
| | 非末段边界 active 断言 | 9 个边界均保持有限序列和脉冲输出 active | | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 warning | | |||
| | 只读代码/数据、读写数据 | 67,954 / 1,517 / 39,623 bytes | | |||
| | HEX 大小 | 195,456 bytes | | |||
| | HEX SHA256 | `8778C24D3FDA364DC4B1D6FAD92E69D9090C5B8EC57D1C65DFCAC89EA0B5AC45` | | |||
| | ST-LINK | `Verification...OK`;Sector 10~13 烧录前后分块哈希一致 | | |||
| | COM5 10×100 | 位置 1000、错误 0、末段 expected/actual 100/100、count error 0、mismatch 0 | | |||
| 板上已恢复 10 段 x 100 脉冲、位置 0、IDLE。尚需新逻辑分析仪波形确认 9 个物理段边界不再出现额外低电平;未覆盖的旧 `10段.bin` 不重复分析。 | |||
| ## 19. 2026-08-14 单段 1 kHz 短斜坡与波形解析修复 | |||
| ### 19.1 `bbb.bin` 原始边沿复核 | |||
| 配置为 1 段、默认速度 10000 Hz、启动/停止速度 100 Hz、加减速时间 10 ms、段频率 1000 Hz、脉冲数 100。原始文件 `bbb.bin` 为 9,708,896 点、6.25 MS/s,共检测到 100 个上升沿和 100 个下降沿。 | |||
| | 项目 | 原固件实测 | | |||
| |---|---:| | |||
| | 首个上升沿至最后一个下降沿 | 104.001120 ms | | |||
| | 首脉冲高/低电平 | 5.000160 / 0.500000 ms | | |||
| | 首个上升沿周期 | 5.500160 ms,即 181.8129 Hz | | |||
| | 中间可测周期 | 基本为 1.000000 ms,即 1000 Hz | | |||
| | 末脉冲高电平 | 0.500000 ms | | |||
| 原理论/实测图片中的约 526 ms 红色减速尾线不是硬件输出。旧查看器把第 100 个脉冲后的采集空闲低电平当成最后一个周期,从而生成一个接近 0 Hz 的伪频率点并与前一点连线。查看器已改为只使用相邻两个上升沿计算周期;同一文件现在报告 100 个脉冲、99 个可测完整周期,不再绘制伪尾线。 | |||
| ### 19.2 固件问题与修复 | |||
| 原固件仍有真实的首末脉冲轮廓问题。100→1000 Hz 和 1000→100 Hz 按默认速度 10000 Hz、加减速 10 ms 计算,斜坡持续时间均约 0.9 ms,离散为 1 ms。有限计数路径依赖 1 ms 主循环提交频率,无法在已经开始的 100 Hz 首个高半周期内及时改频,也无法保证末脉冲前提交停止斜坡。 | |||
| 修复后,单段脉冲数不超过 1000 且存在 1 ms 短斜坡时,优先使用整段逐脉冲预计算队列;正常的较长有限斜坡、多段连续硬件序列和零斜坡恒速路径保持不变。该配置的预计算结果为:首脉冲约 550 Hz、中间 98 个脉冲为 1000 Hz、末脉冲约 550 Hz,完整周期累计约 101.64 ms。 | |||
| ### 19.3 自动验证与烧录 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | 新增精确回归 | 首/末 549~551 Hz;中间 98 个 1000 Hz;总周期 101.6~101.7 ms | | |||
| | Host 全量 | 103 tests / 526,778 assertions / 0 failures | | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 warning | | |||
| | 只读代码/数据、读写数据 | 68,006 / 1,517 / 39,623 bytes | | |||
| | HEX 大小 | 195,612 bytes | | |||
| | HEX SHA256 | `068BDC9D417E3C1DEE377BA3053CBB850B32ED3B7EBBB1E9F7653623F29F713A` | | |||
| | 烧录 | STM32CubeProgrammer 下载、校验、复位成功 | | |||
| | 持久化区 `0x080C0000~0x080FFFFF` | 烧录前后 SHA256 均为 `FC494806B0465476E51678F961A3EC63873C32915A214B31DFB4307048D3949A` | | |||
| COM5 板测已通过:位置 100、状态 COMPLETED、错误 0;诊断 expected/actual 为 100/100、count error 0、curve mismatch 0,末个诊断请求/定时器频率均为 550 Hz。测试后已清位置并恢复为 IDLE,保留同一单段配置。新逻辑分析仪波形仍待覆盖采集。 | |||
| ## 20. 2026-08-14 短斜坡单段/多段通用根治 | |||
| ### 20.1 架构修复 | |||
| 第 19 节的单段短轮廓分流仅能覆盖单段样本,不作为最终修复。根因是有限序列曾把一个逻辑段直接等同于一个硬件 step,首段加速和末段减速只能依赖 1 ms 主循环更新;只要斜坡在一个或少数脉冲内完成,单段和多段都会遇到同一问题。 | |||
| 最终实现将“硬件频率 step”和“逻辑段完成边界”拆开: | |||
| - 首段入口短斜坡和最终段出口短斜坡在启动前按脉冲域生成硬件 step; | |||
| - 计数器 IRQ 只在脉冲边界预装下一组定时器参数,不在 IRQ 内计算曲线; | |||
| - 每个硬件 step 保存逻辑段内脉冲偏移,只有逻辑段最后一个 step 发布段完成事件; | |||
| - 段内位置、剩余脉冲、诊断计数仍按逻辑段累计,不把斜坡子 step 当成新段; | |||
| - step 索引扩展为 16 bit,统一上限为 2010;step 缓冲由 Core 静态持有,平台只保存运行期指针,避免四路重复占用 RAM; | |||
| - 已删除“单段、脉冲数不超过 1000、斜坡不超过 1 ms”的临时特判。 | |||
| ### 20.2 Host 回归 | |||
| | 用例 | 覆盖内容 | 结果 | | |||
| |---|---|---| | |||
| | 单段 100 脉冲 | 100→1000→100 Hz,首末约 550 Hz,中间 98 个 1000 Hz | PASS | | |||
| | 3 段无 Poll | 入口斜坡、1000→2000→3000 Hz 硬件边界、最终出口斜坡;300 个脉冲期间不调用 `PlsrPoll1ms()` | PASS | | |||
| | 10 段无 Poll | 启停 100 Hz、段频率 1000 Hz、默认速度 10000 Hz、加减速 10 ms;9 个边界持续输出 | PASS | | |||
| | 既有完整回归 | 有限计数、动态频率、方向反转、短轮廓、AB 相、诊断、持久化和异常路径 | PASS | | |||
| 最终结果: | |||
| ```text | |||
| 104 tests / 527,097 assertions / 0 failures | |||
| ``` | |||
| ### 20.3 IAR、资源与固件 | |||
| | 项目 | 结果 | | |||
| |---|---| | |||
| | IAR EWARM 8.3.4 | 0 errors / 1 个既有未使用变量 warning | | |||
| | 只读代码 / 只读数据 / 读写数据 | 69,258 / 1,517 / 87,096 bytes | | |||
| | 通用有限 step 缓冲 | 48,240 bytes | | |||
| | HEX 大小 | 199,130 bytes | | |||
| | HEX SHA256 | `9B296CF02101F78A70F4CCF2BB89DC75AA27E8261F8AED451764854EE2106C4C` | | |||
| | ST-LINK | 下载、校验、复位和运行成功 | | |||
| ### 20.4 最坏速率门限 | |||
| 板上使用 90 kHz→100 kHz、默认速度 10 kHz、加速时间 10 ms、2000 脉冲测试。该轮廓在定时器量化合并后触发 188 次硬件 step IRQ: | |||
| | 指标 | 实测 | 100 kHz 门限 | | |||
| |---|---:|---:| | |||
| | block IRQ 最大周期 | 396 cycles,约 2.36 us | 1680 cycles,10 us | | |||
| | final IRQ 周期 | 410 cycles,约 2.44 us | 1680 cycles,10 us | | |||
| | 最小余量 | 约 7.56 us | 必须大于 0 | | |||
| 性能门限 PASS。IRQ 内无除法、开方或轮廓计算。 | |||
| ### 20.5 COM5 与持久化 | |||
| 同一最坏速率配置板测结果:状态 COMPLETED、位置 2000、错误 0、expected/actual 2000/2000、count error 0、curve mismatch 0、reason 0。 | |||
| 性能测试会按设计写入配置和位置。测试前已备份 `0x080C0000~0x080FFFFF`,测试后仅恢复 sector 10/11;恢复镜像 SHA256 与测试前均为 `7BE5A392BCCB26A510516B80F44FE8FEA287CE58E653325AA7964DDAEC38E4F1`。板上最终运行关闭 timing 的 release 固件。 | |||
| @@ -0,0 +1,37 @@ | |||
| # PLSR Python Tester | |||
| ## 输出诊断 | |||
| 上位机新增“输出诊断”页。连接并启动脉冲后,程序会随状态轮询读取设备的诊断结果;运动完成后分别显示脉冲个数、脉冲频率和加减速曲线是否与设置一致,并显示实际/设定脉冲、实际/设定频率、最大频率偏差及三类异常次数。“清除诊断记录”只清除诊断结果,运行期间不能清除。 | |||
| 诊断状态追加在原状态区之后,原地址不变:`0x200B` 为诊断状态位,`0x200C` 为曲线类型,`0x200D` 为当前曲线阶段,`0x200E~0x2017` 为期望/实际计数与频率,`0x2018~0x201A` 为异常次数。向控制寄存器 `0x3000` 写入 `0x0020` 可清除诊断记录。 | |||
| 该功能诊断的是STM32内部实际执行情况:脉冲完成中断计数与TIM寄存器反算频率。若需要发现端子断线、外部干扰或驱动器漏收脉冲,仍需示波器或外部反馈计数输入。 | |||
| 本工具已按《参数表-2026.xlsx》更新,使用 Python/tkinter 和 Modbus RTU。 | |||
| - 输出方式可选择:`PUL/DIR(脉冲+方向)`或`AB正交脉冲`,选择结果保存到设备Flash。 | |||
| - 脉冲发送模式可选择:`完成模式`在段边界停止后再启动,`后续模式`在条件允许且方向兼容时保持硬件PWM连续衔接。 | |||
| - 方向逻辑可选择正逻辑或负逻辑并保存到设备Flash;PUL/DIR运动完成、等待或暂停后方向端保持最后状态,下一次运动再按新方向更新。 | |||
| - PUL/DIR模式:Y0~Y3输出硬件PWM脉冲,Y12~Y15保持方向电平,原来的正反转接线和方向逻辑继续生效。 | |||
| - Y0~Y3分别使用PF6/TIM10、PF8/TIM13、PF7/TIM11、PF9/TIM14。 | |||
| - AB模式:A相端子可选Y0~Y3,B相端子可选Y12~Y15;正向A超前B 90°,反向B超前A 90°。 | |||
| - AB模式每完成一个四拍周期计为1个脉冲;两种模式空闲时输出均恢复高电平。 | |||
| - 相对模式支持完整32位有符号脉冲数,负数直接表示反向;原方向选项仍可把正数脉冲转换为反向。绝对模式由目标位置与当前累计位置自动判向。 | |||
| - 公共参数:`0x1000~0x1015`,其中`0x1007`为脉冲加减速模式(0=直线、1=S曲线、2=正弦曲线),`0x100B~0x100C`是脉冲默认速度,`0x100F`为输出方式(0=PUL/DIR,1=AB正交)。 | |||
| - 运行中目标频率:`0x1016~0x1017`,写入后以控制位`0x0040`立即应用;该值不修改默认速度、段表或Flash。 | |||
| - 分段参数:第 n 段基址 `0x1100 + (n-1)*0x10`,每段使用偏移 `0~7`。 | |||
| - 状态:`0x2000~0x2006`。 | |||
| - 控制:`0x3000`,bit0 启动/继续、bit1 停止、bit2 清累计位置。 | |||
| - 所有双字参数均为低地址存低 16 位。 | |||
| 运行 `start_plsr_tester.bat`,或执行 `python main.py`。首次使用如果缺少串口库,请安装 `pyserial`。 | |||
| 验证AB相时,请将双通道示波器分别接到所选A相、B相端子,并共地测量。不要仅观察单只LED判断正反向;单路波形无法显示两相的超前关系。 | |||
| 测试协议编解码: | |||
| ```bat | |||
| python -m unittest test_protocol.py | |||
| ``` | |||
| @@ -0,0 +1,474 @@ | |||
| """不依赖pyserial的Windows原生串口Modbus RTU客户端。""" | |||
| from __future__ import annotations | |||
| import ctypes | |||
| import queue | |||
| import threading | |||
| import time | |||
| import winreg | |||
| from ctypes import wintypes | |||
| from dataclasses import dataclass | |||
| from typing import List, Optional, Tuple | |||
| GENERIC_READ = 0x80000000 | |||
| GENERIC_WRITE = 0x40000000 | |||
| OPEN_EXISTING = 3 | |||
| FILE_ATTRIBUTE_NORMAL = 0x80 | |||
| PURGE_TXABORT = 0x0001 | |||
| PURGE_RXABORT = 0x0002 | |||
| PURGE_TXCLEAR = 0x0004 | |||
| PURGE_RXCLEAR = 0x0008 | |||
| INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value | |||
| class DCB(ctypes.Structure): | |||
| _fields_ = [ | |||
| ("DCBlength", wintypes.DWORD), | |||
| ("BaudRate", wintypes.DWORD), | |||
| ("flags", wintypes.DWORD), | |||
| ("wReserved", wintypes.WORD), | |||
| ("XonLim", wintypes.WORD), | |||
| ("XoffLim", wintypes.WORD), | |||
| ("ByteSize", wintypes.BYTE), | |||
| ("Parity", wintypes.BYTE), | |||
| ("StopBits", wintypes.BYTE), | |||
| ("XonChar", ctypes.c_char), | |||
| ("XoffChar", ctypes.c_char), | |||
| ("ErrorChar", ctypes.c_char), | |||
| ("EofChar", ctypes.c_char), | |||
| ("EvtChar", ctypes.c_char), | |||
| ("wReserved1", wintypes.WORD), | |||
| ] | |||
| class COMMTIMEOUTS(ctypes.Structure): | |||
| _fields_ = [ | |||
| ("ReadIntervalTimeout", wintypes.DWORD), | |||
| ("ReadTotalTimeoutMultiplier", wintypes.DWORD), | |||
| ("ReadTotalTimeoutConstant", wintypes.DWORD), | |||
| ("WriteTotalTimeoutMultiplier", wintypes.DWORD), | |||
| ("WriteTotalTimeoutConstant", wintypes.DWORD), | |||
| ] | |||
| def list_serial_ports() -> List[str]: | |||
| """从Windows注册表枚举COM端口,不需要pyserial。""" | |||
| ports = set() | |||
| path = r"HARDWARE\DEVICEMAP\SERIALCOMM" | |||
| try: | |||
| with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) as key: | |||
| index = 0 | |||
| while True: | |||
| try: | |||
| _, value, _ = winreg.EnumValue(key, index) | |||
| ports.add(str(value)) | |||
| index += 1 | |||
| except OSError: | |||
| break | |||
| except OSError: | |||
| pass | |||
| def sort_key(name: str) -> Tuple[str, int]: | |||
| prefix = "".join(ch for ch in name if not ch.isdigit()) | |||
| digits = "".join(ch for ch in name if ch.isdigit()) | |||
| return prefix, int(digits or 0) | |||
| return sorted(ports, key=sort_key) | |||
| class WinSerialPort: | |||
| """使用同步Win32 API访问串口;由后台线程调用。""" | |||
| def __init__(self) -> None: | |||
| self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) | |||
| self._handle: Optional[int] = None | |||
| self._configure_signatures() | |||
| def _configure_signatures(self) -> None: | |||
| kernel = self._kernel32 | |||
| kernel.CreateFileW.argtypes = [ | |||
| wintypes.LPCWSTR, | |||
| wintypes.DWORD, | |||
| wintypes.DWORD, | |||
| wintypes.LPVOID, | |||
| wintypes.DWORD, | |||
| wintypes.DWORD, | |||
| wintypes.HANDLE, | |||
| ] | |||
| kernel.CreateFileW.restype = wintypes.HANDLE | |||
| kernel.CloseHandle.argtypes = [wintypes.HANDLE] | |||
| kernel.CloseHandle.restype = wintypes.BOOL | |||
| kernel.BuildCommDCBW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(DCB)] | |||
| kernel.BuildCommDCBW.restype = wintypes.BOOL | |||
| kernel.SetCommState.argtypes = [wintypes.HANDLE, ctypes.POINTER(DCB)] | |||
| kernel.SetCommState.restype = wintypes.BOOL | |||
| kernel.SetCommTimeouts.argtypes = [ | |||
| wintypes.HANDLE, | |||
| ctypes.POINTER(COMMTIMEOUTS), | |||
| ] | |||
| kernel.SetCommTimeouts.restype = wintypes.BOOL | |||
| kernel.SetupComm.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] | |||
| kernel.SetupComm.restype = wintypes.BOOL | |||
| kernel.PurgeComm.argtypes = [wintypes.HANDLE, wintypes.DWORD] | |||
| kernel.PurgeComm.restype = wintypes.BOOL | |||
| kernel.ReadFile.argtypes = [ | |||
| wintypes.HANDLE, | |||
| wintypes.LPVOID, | |||
| wintypes.DWORD, | |||
| ctypes.POINTER(wintypes.DWORD), | |||
| wintypes.LPVOID, | |||
| ] | |||
| kernel.ReadFile.restype = wintypes.BOOL | |||
| kernel.WriteFile.argtypes = [ | |||
| wintypes.HANDLE, | |||
| wintypes.LPCVOID, | |||
| wintypes.DWORD, | |||
| ctypes.POINTER(wintypes.DWORD), | |||
| wintypes.LPVOID, | |||
| ] | |||
| kernel.WriteFile.restype = wintypes.BOOL | |||
| @property | |||
| def is_open(self) -> bool: | |||
| return self._handle is not None | |||
| def open(self, port_name: str, baud_rate: int = 115200) -> None: | |||
| self.close() | |||
| device_name = rf"\\.\{port_name}" | |||
| handle = self._kernel32.CreateFileW( | |||
| device_name, | |||
| GENERIC_READ | GENERIC_WRITE, | |||
| 0, | |||
| None, | |||
| OPEN_EXISTING, | |||
| FILE_ATTRIBUTE_NORMAL, | |||
| None, | |||
| ) | |||
| if handle == INVALID_HANDLE_VALUE: | |||
| self._raise_last_error(f"打开{port_name}失败") | |||
| self._handle = handle | |||
| try: | |||
| dcb = DCB() | |||
| dcb.DCBlength = ctypes.sizeof(DCB) | |||
| config = f"baud={baud_rate} parity=e data=8 stop=1" | |||
| if not self._kernel32.BuildCommDCBW(config, ctypes.byref(dcb)): | |||
| self._raise_last_error("生成串口参数失败") | |||
| if not self._kernel32.SetCommState(self._handle, ctypes.byref(dcb)): | |||
| self._raise_last_error("设置串口参数失败") | |||
| timeouts = COMMTIMEOUTS( | |||
| ReadIntervalTimeout=20, | |||
| ReadTotalTimeoutMultiplier=0, | |||
| ReadTotalTimeoutConstant=20, | |||
| WriteTotalTimeoutMultiplier=0, | |||
| WriteTotalTimeoutConstant=300, | |||
| ) | |||
| if not self._kernel32.SetCommTimeouts( | |||
| self._handle, ctypes.byref(timeouts) | |||
| ): | |||
| self._raise_last_error("设置串口超时失败") | |||
| if not self._kernel32.SetupComm(self._handle, 4096, 4096): | |||
| self._raise_last_error("设置串口缓冲区失败") | |||
| self.purge() | |||
| except Exception: | |||
| self.close() | |||
| raise | |||
| def close(self) -> None: | |||
| if self._handle is not None: | |||
| self._kernel32.CloseHandle(self._handle) | |||
| self._handle = None | |||
| def purge(self) -> None: | |||
| self._require_open() | |||
| flags = PURGE_TXABORT | PURGE_RXABORT | PURGE_TXCLEAR | PURGE_RXCLEAR | |||
| if not self._kernel32.PurgeComm(self._handle, flags): | |||
| self._raise_last_error("清理串口缓冲区失败") | |||
| def write(self, data: bytes) -> None: | |||
| self._require_open() | |||
| buffer = ctypes.create_string_buffer(data) | |||
| written = wintypes.DWORD(0) | |||
| if not self._kernel32.WriteFile( | |||
| self._handle, | |||
| buffer, | |||
| len(data), | |||
| ctypes.byref(written), | |||
| None, | |||
| ): | |||
| self._raise_last_error("串口发送失败") | |||
| if written.value != len(data): | |||
| raise OSError(f"串口只发送了{written.value}/{len(data)}字节") | |||
| def read(self, maximum: int = 256) -> bytes: | |||
| self._require_open() | |||
| buffer = ctypes.create_string_buffer(maximum) | |||
| received = wintypes.DWORD(0) | |||
| if not self._kernel32.ReadFile( | |||
| self._handle, | |||
| buffer, | |||
| maximum, | |||
| ctypes.byref(received), | |||
| None, | |||
| ): | |||
| self._raise_last_error("串口接收失败") | |||
| return buffer.raw[: received.value] | |||
| def _require_open(self) -> None: | |||
| if self._handle is None: | |||
| raise OSError("串口尚未打开") | |||
| @staticmethod | |||
| def _raise_last_error(prefix: str) -> None: | |||
| code = ctypes.get_last_error() | |||
| raise OSError(code, f"{prefix}:{ctypes.FormatError(code).strip()}") | |||
| def crc16(data: bytes) -> int: | |||
| crc = 0xFFFF | |||
| for byte in data: | |||
| crc ^= byte | |||
| for _ in range(8): | |||
| crc = ((crc >> 1) ^ 0xA001) if (crc & 1) else (crc >> 1) | |||
| return crc & 0xFFFF | |||
| def add_crc(data: bytes) -> bytes: | |||
| crc = crc16(data) | |||
| return data + bytes((crc & 0xFF, (crc >> 8) & 0xFF)) | |||
| def valid_crc(frame: bytes) -> bool: | |||
| if len(frame) < 4: | |||
| return False | |||
| received = frame[-2] | (frame[-1] << 8) | |||
| return crc16(frame[:-2]) == received | |||
| def u16(value: int) -> bytes: | |||
| return bytes(((value >> 8) & 0xFF, value & 0xFF)) | |||
| def read_u16(data: bytes, offset: int) -> int: | |||
| return (data[offset] << 8) | data[offset + 1] | |||
| def build_read_holding(slave: int, address: int, quantity: int) -> bytes: | |||
| if not 1 <= slave <= 247: | |||
| raise ValueError("从站地址必须在1到247之间") | |||
| if not 1 <= quantity <= 125: | |||
| raise ValueError("读取数量必须在1到125之间") | |||
| return add_crc(bytes((slave, 0x03)) + u16(address) + u16(quantity)) | |||
| def build_write_single(slave: int, address: int, value: int) -> bytes: | |||
| if not 1 <= slave <= 247: | |||
| raise ValueError("从站地址必须在1到247之间") | |||
| return add_crc(bytes((slave, 0x06)) + u16(address) + u16(value)) | |||
| def build_write_multiple(slave: int, address: int, values: List[int]) -> bytes: | |||
| if not 1 <= slave <= 247: | |||
| raise ValueError("从站地址必须在1到247之间") | |||
| if not 1 <= len(values) <= 123: | |||
| raise ValueError("写入数量必须在1到123之间") | |||
| payload = b"".join(u16(value) for value in values) | |||
| body = ( | |||
| bytes((slave, 0x10)) | |||
| + u16(address) | |||
| + u16(len(values)) | |||
| + bytes((len(payload),)) | |||
| + payload | |||
| ) | |||
| return add_crc(body) | |||
| @dataclass(frozen=True) | |||
| class Request: | |||
| frame: bytes | |||
| function: int | |||
| address: int | |||
| quantity_or_value: int | |||
| context: str | |||
| class ModbusClient: | |||
| """单事务后台客户端;GUI通过events队列接收结果。""" | |||
| # STM32从站由RTOS任务轮询。连续请求之间留出恢复时间,避免上一帧刚发送 | |||
| # 完成时下一帧已经进入USART,导致从站状态机漏掉请求。 | |||
| INTER_REQUEST_DELAY_S = 0.03 | |||
| RESPONSE_TIMEOUT_S = 0.8 | |||
| MAX_ATTEMPTS = 2 | |||
| def __init__(self) -> None: | |||
| self.events: "queue.Queue[tuple]" = queue.Queue() | |||
| self._requests: "queue.Queue[Optional[Request]]" = queue.Queue() | |||
| self._serial = WinSerialPort() | |||
| self._thread: Optional[threading.Thread] = None | |||
| self._stop_event = threading.Event() | |||
| self._busy_event = threading.Event() | |||
| self.slave_address = 1 | |||
| self.port_name = "" | |||
| self._last_request_finished = 0.0 | |||
| @property | |||
| def is_open(self) -> bool: | |||
| return self._serial.is_open | |||
| @property | |||
| def is_busy(self) -> bool: | |||
| return self._busy_event.is_set() or not self._requests.empty() | |||
| def open(self, port_name: str, baud_rate: int, slave_address: int) -> None: | |||
| if not 1 <= slave_address <= 247: | |||
| raise ValueError("从站地址必须在1到247之间") | |||
| self.close() | |||
| self._serial.open(port_name, baud_rate) | |||
| self.slave_address = slave_address | |||
| self.port_name = port_name | |||
| self._stop_event.clear() | |||
| self._thread = threading.Thread( | |||
| target=self._worker_loop, | |||
| name="ModbusRtuWorker", | |||
| daemon=True, | |||
| ) | |||
| self._thread.start() | |||
| def close(self) -> None: | |||
| self._stop_event.set() | |||
| if self._thread is not None and self._thread.is_alive(): | |||
| self._requests.put(None) | |||
| self._thread.join(timeout=1.0) | |||
| self._thread = None | |||
| self._busy_event.clear() | |||
| while not self._requests.empty(): | |||
| try: | |||
| self._requests.get_nowait() | |||
| except queue.Empty: | |||
| break | |||
| self._serial.close() | |||
| self._last_request_finished = 0.0 | |||
| def read_holding(self, address: int, quantity: int, context: str = "") -> None: | |||
| frame = build_read_holding(self.slave_address, address, quantity) | |||
| self._requests.put(Request(frame, 0x03, address, quantity, context)) | |||
| def write_single(self, address: int, value: int, context: str = "") -> None: | |||
| frame = build_write_single(self.slave_address, address, value) | |||
| self._requests.put(Request(frame, 0x06, address, value, context)) | |||
| def write_multiple( | |||
| self, address: int, values: List[int], context: str = "" | |||
| ) -> None: | |||
| frame = build_write_multiple(self.slave_address, address, values) | |||
| self._requests.put(Request(frame, 0x10, address, len(values), context)) | |||
| def _worker_loop(self) -> None: | |||
| while not self._stop_event.is_set(): | |||
| try: | |||
| request = self._requests.get(timeout=0.1) | |||
| except queue.Empty: | |||
| continue | |||
| if request is None: | |||
| break | |||
| self._busy_event.set() | |||
| try: | |||
| for attempt in range(1, self.MAX_ATTEMPTS + 1): | |||
| try: | |||
| self._wait_inter_request_gap() | |||
| self._execute(request) | |||
| break | |||
| except TimeoutError: | |||
| if attempt >= self.MAX_ATTEMPTS: | |||
| raise | |||
| self.events.put(("retry", request.context, attempt + 1)) | |||
| except Exception as exc: # 将后台异常送回GUI线程 | |||
| self.events.put(("error", request.context, str(exc))) | |||
| finally: | |||
| self._last_request_finished = time.monotonic() | |||
| self._busy_event.clear() | |||
| def _wait_inter_request_gap(self) -> None: | |||
| remaining = ( | |||
| self._last_request_finished + self.INTER_REQUEST_DELAY_S | |||
| - time.monotonic() | |||
| ) | |||
| if remaining > 0: | |||
| time.sleep(remaining) | |||
| def _execute(self, request: Request) -> None: | |||
| self._serial.purge() | |||
| self._serial.write(request.frame) | |||
| self.events.put(("tx", request.frame)) | |||
| deadline = time.monotonic() + self.RESPONSE_TIMEOUT_S | |||
| response = bytearray() | |||
| expected_length: Optional[int] = None | |||
| while time.monotonic() < deadline and not self._stop_event.is_set(): | |||
| chunk = self._serial.read(256) | |||
| if chunk: | |||
| response.extend(chunk) | |||
| if len(response) >= 2: | |||
| function = response[1] | |||
| if function == (request.function | 0x80): | |||
| expected_length = 5 | |||
| elif function == 0x03 and len(response) >= 3: | |||
| expected_length = 5 + response[2] | |||
| elif function in (0x06, 0x10): | |||
| expected_length = 8 | |||
| if expected_length is not None and len(response) >= expected_length: | |||
| break | |||
| if expected_length is None or len(response) < expected_length: | |||
| raise TimeoutError("等待从站响应超时") | |||
| frame = bytes(response[:expected_length]) | |||
| self.events.put(("rx", frame)) | |||
| if not valid_crc(frame): | |||
| raise ValueError("响应CRC校验失败") | |||
| if frame[0] != self.slave_address: | |||
| raise ValueError("响应从站地址不匹配") | |||
| function = frame[1] | |||
| if function == (request.function | 0x80): | |||
| self.events.put( | |||
| ("exception", request.function, frame[2], request.context) | |||
| ) | |||
| return | |||
| if function != request.function: | |||
| raise ValueError("响应功能码不匹配") | |||
| if function == 0x03: | |||
| byte_count = frame[2] | |||
| if byte_count != request.quantity_or_value * 2: | |||
| raise ValueError("读取响应数据长度不匹配") | |||
| values = [ | |||
| read_u16(frame, offset) | |||
| for offset in range(3, 3 + byte_count, 2) | |||
| ] | |||
| self.events.put( | |||
| ("read", request.address, values, request.context) | |||
| ) | |||
| return | |||
| echoed_address = read_u16(frame, 2) | |||
| echoed_value = read_u16(frame, 4) | |||
| if ( | |||
| echoed_address != request.address | |||
| or echoed_value != request.quantity_or_value | |||
| ): | |||
| raise ValueError("写响应回显与请求不一致") | |||
| self.events.put( | |||
| ( | |||
| "write", | |||
| function, | |||
| echoed_address, | |||
| echoed_value, | |||
| request.context, | |||
| ) | |||
| ) | |||
| @@ -0,0 +1,371 @@ | |||
| """PLSR 参数表-2026 的 Modbus RTU 地址、枚举和编解码工具。""" | |||
| from enum import IntEnum | |||
| from typing import Dict, List, Sequence, Tuple | |||
| DEFAULT_SLAVE_ADDRESS = 1 | |||
| DEFAULT_BAUD_RATE = 115200 | |||
| MIN_FREQUENCY_HZ = 1 | |||
| MAX_FREQUENCY_HZ = 100000 | |||
| MAX_RAMP_TIME_MS = 0xFFFF | |||
| class Register(IntEnum): | |||
| PULSE_POINT = 0x1000 | |||
| DIRECTION_POINT = 0x1001 | |||
| WAIT_SIGNAL = 0x1002 | |||
| EXT_SIGNAL = 0x1003 | |||
| SEND_MODE = 0x1004 | |||
| DIRECTION_DELAY_MS = 0x1005 | |||
| DIRECTION_LOGIC = 0x1006 | |||
| PROFILE_TYPE = 0x1007 | |||
| RUN_MODE = 0x1008 | |||
| TOTAL_SEGMENTS = 0x1009 | |||
| START_SEGMENT = 0x100A | |||
| TARGET_FREQ_LO = 0x100B | |||
| TARGET_FREQ_HI = 0x100C | |||
| START_FREQ_LO = 0x100D | |||
| START_FREQ_HI = 0x100E | |||
| OUTPUT_MODE = 0x100F | |||
| END_FREQ_LO = 0x1010 | |||
| END_FREQ_HI = 0x1011 | |||
| ACCEL_TIME = 0x1012 | |||
| DECEL_TIME = 0x1013 | |||
| MAX_FREQ_LO = 0x1014 | |||
| MAX_FREQ_HI = 0x1015 | |||
| # 瞬时运行参数,不属于公共参数,也不保存到Flash。 | |||
| RUNTIME_FREQ_LO = 0x1016 | |||
| RUNTIME_FREQ_HI = 0x1017 | |||
| SEGMENT_TABLE_BASE = 0x1100 | |||
| CUMULATIVE_POSITION_LO = 0x2000 | |||
| CUMULATIVE_POSITION_HI = 0x2001 | |||
| CURRENT_FREQ_LO = 0x2002 | |||
| CURRENT_FREQ_HI = 0x2003 | |||
| STATE = 0x2004 | |||
| CURRENT_SEGMENT = 0x2005 | |||
| ERROR = 0x2006 | |||
| CURRENT_PULSES_LO = 0x2007 | |||
| CURRENT_PULSES_HI = 0x2008 | |||
| TOTAL_PULSES_LO = 0x2009 | |||
| TOTAL_PULSES_HI = 0x200A | |||
| DIAG_STATUS = 0x200B | |||
| DIAG_PROFILE_TYPE = 0x200C | |||
| DIAG_PHASE = 0x200D | |||
| DIAG_EXPECTED_PULSES_LO = 0x200E | |||
| DIAG_EXPECTED_PULSES_HI = 0x200F | |||
| DIAG_OBSERVED_PULSES_LO = 0x2010 | |||
| DIAG_OBSERVED_PULSES_HI = 0x2011 | |||
| DIAG_EXPECTED_FREQ_LO = 0x2012 | |||
| DIAG_EXPECTED_FREQ_HI = 0x2013 | |||
| DIAG_ACTUAL_FREQ_LO = 0x2014 | |||
| DIAG_ACTUAL_FREQ_HI = 0x2015 | |||
| DIAG_MAX_FREQ_ERROR_LO = 0x2016 | |||
| DIAG_MAX_FREQ_ERROR_HI = 0x2017 | |||
| DIAG_COUNT_ERRORS = 0x2018 | |||
| DIAG_FREQ_ERRORS = 0x2019 | |||
| DIAG_PROFILE_ERRORS = 0x201A | |||
| CONTROL = 0x3000 | |||
| # 旧界面源码的兼容别名;实际通讯流程只使用上面的参数表地址。 | |||
| COMMAND = CONTROL | |||
| DIRECTION = DIRECTION_LOGIC | |||
| SEGMENT_META_BASE = SEGMENT_TABLE_BASE | |||
| SEGMENT_PULSES_DONE_HI = CURRENT_FREQ_LO | |||
| SEGMENT_PULSES_DONE_LO = CURRENT_FREQ_HI | |||
| ACTIVE_WAIT = STATE | |||
| WAIT_ELAPSED_HI = CURRENT_SEGMENT | |||
| WAIT_ELAPSED_LO = ERROR | |||
| SEGMENT_STRIDE = 0x10 | |||
| SEGMENT_USED_REGISTERS = 8 | |||
| def segment_base(index: int) -> int: | |||
| if not 0 <= index < 10: | |||
| raise ValueError("段索引必须为0~9") | |||
| return int(Register.SEGMENT_TABLE_BASE) + index * SEGMENT_STRIDE | |||
| def register_read_count(_first: Register = Register.PULSE_POINT) -> int: | |||
| """全部公共参数连续块0x1000~0x1015的长度。""" | |||
| return int(Register.MAX_FREQ_HI) - int(Register.PULSE_POINT) + 1 | |||
| class Command(IntEnum): | |||
| START = 0x0001 | |||
| STOP = 0x0002 | |||
| CLEAR_POSITION = 0x0004 | |||
| RESUME = 0x0001 # 暂停状态下再次置START即继续 | |||
| # 兼容旧界面分支;新参数表没有这些独立命令码。 | |||
| CLEAR_TOTAL = 0x0008 | |||
| SAVE_PUBLIC = 0x0010 | |||
| SAVE_OUTPUTS = SAVE_PUBLIC # 兼容旧名称;设备端现在保存全部公共参数 | |||
| CLEAR_DIAGNOSTIC = 0x0020 | |||
| UPDATE_FREQUENCY = 0x0040 | |||
| START_SEGMENTS = 0x0001 | |||
| STATUS_REGISTER_COUNT = ( | |||
| int(Register.DIAG_PROFILE_ERRORS) - int(Register.CUMULATIVE_POSITION_LO) + 1 | |||
| ) | |||
| class DiagnosticStatus(IntEnum): | |||
| ACTIVE = 0x0001 | |||
| COMPLETE = 0x0002 | |||
| COUNT_PASS = 0x0004 | |||
| FREQUENCY_PASS = 0x0008 | |||
| PROFILE_PASS = 0x0010 | |||
| COUNT_FAIL = 0x0100 | |||
| FREQUENCY_FAIL = 0x0200 | |||
| PROFILE_FAIL = 0x0400 | |||
| DIAGNOSTIC_PHASE_TEXT = { | |||
| 0: "空闲", | |||
| 1: "加速段", | |||
| 2: "匀速段", | |||
| 3: "减速段", | |||
| } | |||
| class State(IntEnum): | |||
| IDLE = 0 | |||
| RUNNING = 1 | |||
| COMPLETE = 2 | |||
| ERROR = 3 | |||
| PAUSED = 4 | |||
| WAITING = 5 | |||
| class WaitCondition(IntEnum): | |||
| WAIT_TIME = 0 | |||
| WAIT_SIGNAL = 1 | |||
| ACT_TIME = 2 | |||
| EXT_SIGNAL = 3 | |||
| EXT_OR_COMPLETE = 4 | |||
| class RunMode(IntEnum): | |||
| RELATIVE = 0 | |||
| ABSOLUTE = 1 | |||
| class SendMode(IntEnum): | |||
| COMPLETE = 0 | |||
| FOLLOW = 1 | |||
| class DirectionLogic(IntEnum): | |||
| POSITIVE = 0 | |||
| NEGATIVE = 1 | |||
| class ProfileType(IntEnum): | |||
| LINEAR = 0 | |||
| S_CURVE = 1 | |||
| SINE = 2 | |||
| class OutputMode(IntEnum): | |||
| PULSE_DIRECTION = 0 | |||
| AB_QUADRATURE = 1 | |||
| class Error(IntEnum): | |||
| NONE = 0 | |||
| BUSY = 1 | |||
| INVALID_PULSE_COUNT = 2 | |||
| INVALID_FREQUENCY = 3 | |||
| INVALID_DIRECTION = 4 | |||
| TIMER_RANGE = 5 | |||
| INVALID_COMMAND = 6 | |||
| NOT_PAUSED = 7 | |||
| NOT_ACTIVE = 8 | |||
| INVALID_PULSE_POINT = 9 | |||
| INVALID_DIRECTION_POINT = 10 | |||
| SAME_OUTPUT_POINT = 11 | |||
| FLASH_SAVE = 12 | |||
| INVALID_MODE = 13 | |||
| INVALID_PROFILE = 14 | |||
| INVALID_RAMP_TIME = 15 | |||
| INVALID_SPEED = 16 | |||
| INVALID_SEGMENT_COUNT = 17 | |||
| INVALID_START_SEGMENT = 18 | |||
| INVALID_SEGMENT_PARAM = 19 | |||
| SEGMENT_TOTAL_OVERFLOW = 20 | |||
| INVALID_WAIT_CONDITION = 21 | |||
| INVALID_INPUT_POINT = 22 | |||
| STATE_TEXT = { | |||
| State.IDLE: "空闲", State.RUNNING: "运行中", State.COMPLETE: "完成", | |||
| State.ERROR: "错误", State.PAUSED: "已暂停", State.WAITING: "条件等待中", | |||
| } | |||
| ERROR_TEXT = {item: item.name for item in Error} | |||
| ERROR_TEXT[Error.NONE] = "无错误" | |||
| WAIT_CONDITION_TEXT = { | |||
| WaitCondition.WAIT_TIME: "Wait时间", | |||
| WaitCondition.WAIT_SIGNAL: "Wait信号(全局X4/X5)", | |||
| WaitCondition.ACT_TIME: "ACT时间", | |||
| WaitCondition.EXT_SIGNAL: "EXT信号(全局X4/X5)", | |||
| WaitCondition.EXT_OR_COMPLETE: "EXT信号或脉冲完成", | |||
| } | |||
| MODE_TEXT = {RunMode.RELATIVE: "相对模式", RunMode.ABSOLUTE: "绝对模式"} | |||
| SEND_MODE_TEXT = { | |||
| SendMode.COMPLETE: "完成模式(段间停止)", | |||
| SendMode.FOLLOW: "后续模式(满足条件时连续衔接)", | |||
| } | |||
| DIRECTION_LOGIC_TEXT = { | |||
| DirectionLogic.POSITIVE: "正逻辑(正向ON,反向OFF)", | |||
| DirectionLogic.NEGATIVE: "负逻辑(正向OFF,反向ON)", | |||
| } | |||
| PROFILE_TEXT = { | |||
| ProfileType.LINEAR: "直线加减速", | |||
| ProfileType.S_CURVE: "S曲线加减速", | |||
| ProfileType.SINE: "正弦曲线加减速", | |||
| } | |||
| OUTPUT_MODE_TEXT = { | |||
| OutputMode.PULSE_DIRECTION: "PUL/DIR(脉冲+方向)", | |||
| OutputMode.AB_QUADRATURE: "AB正交脉冲", | |||
| } | |||
| INPUT_POINTS = (4, 5) | |||
| PULSE_POINTS = { | |||
| 0: "Y0 / PF6 / TIM10_CH1", 1: "Y1 / PF8 / TIM13_CH1", | |||
| 2: "Y2 / PF7 / TIM11_CH1", 3: "Y3 / PF9 / TIM14_CH1", | |||
| } | |||
| SECONDARY_POINTS = { | |||
| 0: "Y12 / PH9", 1: "Y13 / PH8", 2: "Y14 / PH7", 3: "Y15 / PH6", | |||
| } | |||
| def point_id_from_label(options: Dict[int, str], label: str) -> int: | |||
| for point, text in options.items(): | |||
| if text == label: | |||
| return point | |||
| raise ValueError(f"未知输出点:{label}") | |||
| def calculate_execution_total( | |||
| segments: Sequence[Sequence[int]], start_segment: int | |||
| ) -> Tuple[int, bool]: | |||
| """按跳转段实际路径累计脉冲;检测到循环时返回(cycle前累计值, True)。""" | |||
| if not segments or not 1 <= start_segment <= len(segments): | |||
| raise ValueError("起始执行段超出有效段范围") | |||
| current = start_segment | |||
| visited = set() | |||
| total = 0 | |||
| while current not in visited: | |||
| visited.add(current) | |||
| segment = segments[current - 1] | |||
| total += abs(int(segment[0])) | |||
| jump = int(segment[2]) | |||
| if jump == 0: | |||
| if current >= len(segments): | |||
| return total, False | |||
| current += 1 | |||
| else: | |||
| if not 1 <= jump <= len(segments): | |||
| raise ValueError("跳转段超出有效段范围") | |||
| current = jump | |||
| return total, True | |||
| PRESETS = [ | |||
| {"name": "LED可视测试:10脉冲 / 1Hz", "pulses": 10, "frequency": 1, | |||
| "acceleration_ms": 0, "deceleration_ms": 0, | |||
| "start_frequency": 1, "end_frequency": 1}, | |||
| {"name": "基础测试:1000脉冲 / 1000Hz", "pulses": 1000, "frequency": 1000, | |||
| "acceleration_ms": 1000, "deceleration_ms": 1000, | |||
| "start_frequency": 100, "end_frequency": 100}, | |||
| {"name": "长行程测试:100000脉冲 / 5000Hz", "pulses": 100000, "frequency": 5000, | |||
| "acceleration_ms": 1000, "deceleration_ms": 1000, | |||
| "start_frequency": 100, "end_frequency": 100}, | |||
| {"name": "最高频率测试:100000脉冲 / 100000Hz", "pulses": 100000, | |||
| "frequency": MAX_FREQUENCY_HZ, "acceleration_ms": 500, | |||
| "deceleration_ms": 500, "start_frequency": 100, "end_frequency": 100}, | |||
| ] | |||
| def split_u32(value: int) -> List[int]: | |||
| """参数表规定:低地址存低16位。""" | |||
| if not 0 <= value <= 0xFFFFFFFF: | |||
| raise ValueError("32位参数超出范围") | |||
| return [value & 0xFFFF, (value >> 16) & 0xFFFF] | |||
| def signed_to_u32(value: int) -> int: | |||
| if not -0x80000000 <= value <= 0x7FFFFFFF: | |||
| raise ValueError("有符号32位参数超出范围") | |||
| return value & 0xFFFFFFFF | |||
| def join_u32(low: int, high: int) -> int: | |||
| return (low & 0xFFFF) | ((high & 0xFFFF) << 16) | |||
| def join_i32(low: int, high: int) -> int: | |||
| value = join_u32(low, high) | |||
| return value - 0x100000000 if value >= 0x80000000 else value | |||
| def state_text(value: int) -> str: | |||
| try: | |||
| return STATE_TEXT[State(value)] | |||
| except (ValueError, KeyError): | |||
| return f"未知状态 {value}" | |||
| def error_text(value: int) -> str: | |||
| try: | |||
| return ERROR_TEXT[Error(value)] | |||
| except (ValueError, KeyError): | |||
| return f"未知错误 {value}" | |||
| def parse_status(registers: List[int]) -> Dict[str, int]: | |||
| """解析0x2000开始的状态区;兼容旧固件的11寄存器响应。""" | |||
| if len(registers) < 11: | |||
| raise ValueError("状态寄存器数量不足11个") | |||
| result = { | |||
| "cumulative_position": join_i32(registers[0], registers[1]), | |||
| "current_frequency": join_u32(registers[2], registers[3]), | |||
| "state": registers[4], | |||
| "current_segment": registers[5], | |||
| "error": registers[6], | |||
| "current_pulses": join_u32(registers[7], registers[8]), | |||
| "total_pulses": join_u32(registers[9], registers[10]), | |||
| } | |||
| if len(registers) >= STATUS_REGISTER_COUNT: | |||
| result.update({ | |||
| "diagnostic_status": registers[11], | |||
| "diagnostic_profile_type": registers[12], | |||
| "diagnostic_phase": registers[13], | |||
| "diagnostic_expected_pulses": join_u32(registers[14], registers[15]), | |||
| "diagnostic_observed_pulses": join_u32(registers[16], registers[17]), | |||
| "diagnostic_expected_frequency": join_u32(registers[18], registers[19]), | |||
| "diagnostic_actual_frequency": join_u32(registers[20], registers[21]), | |||
| "diagnostic_max_frequency_error": join_u32(registers[22], registers[23]), | |||
| "diagnostic_count_errors": registers[24], | |||
| "diagnostic_frequency_errors": registers[25], | |||
| "diagnostic_profile_errors": registers[26], | |||
| }) | |||
| else: | |||
| result.update({ | |||
| "diagnostic_status": 0, | |||
| "diagnostic_profile_type": 0, | |||
| "diagnostic_phase": 0, | |||
| "diagnostic_expected_pulses": 0, | |||
| "diagnostic_observed_pulses": 0, | |||
| "diagnostic_expected_frequency": 0, | |||
| "diagnostic_actual_frequency": 0, | |||
| "diagnostic_max_frequency_error": 0, | |||
| "diagnostic_count_errors": 0, | |||
| "diagnostic_frequency_errors": 0, | |||
| "diagnostic_profile_errors": 0, | |||
| }) | |||
| return result | |||
| @@ -0,0 +1,21 @@ | |||
| { | |||
| "port": "COM5", | |||
| "slave": 1, | |||
| "preset": 0, | |||
| "pulses": "10", | |||
| "frequency": 1000, | |||
| "runtime_frequency": 1000, | |||
| "maximum_frequency": 100000, | |||
| "profile_type": 0, | |||
| "acceleration_ms": 100, | |||
| "deceleration_ms": 100, | |||
| "start_frequency": 0, | |||
| "end_frequency": 0, | |||
| "direction": 0, | |||
| "run_mode": 0, | |||
| "send_mode": 0, | |||
| "direction_logic": 0, | |||
| "pulse_point": 0, | |||
| "secondary_point": 0, | |||
| "output_mode": 0 | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| @echo off | |||
| setlocal | |||
| cd /d "%~dp0" | |||
| if exist "D:\Anaconda3\python.exe" ( | |||
| "D:\Anaconda3\python.exe" -B main.py | |||
| goto :done | |||
| ) | |||
| where py >nul 2>nul | |||
| if not errorlevel 1 ( | |||
| py -3 -B main.py | |||
| goto :done | |||
| ) | |||
| where python >nul 2>nul | |||
| if not errorlevel 1 ( | |||
| python -B main.py | |||
| goto :done | |||
| ) | |||
| echo Python 3 was not found. | |||
| echo Install Python 3 and enable "Add Python to PATH", then try again. | |||
| pause | |||
| :done | |||
| endlocal | |||
| @@ -0,0 +1,152 @@ | |||
| import unittest | |||
| from modbus_rtu import build_read_holding, build_write_multiple, build_write_single, valid_crc | |||
| from plsr_protocol import ( | |||
| PULSE_POINTS, | |||
| SECONDARY_POINTS, | |||
| Command, | |||
| DirectionLogic, | |||
| Register, | |||
| SendMode, | |||
| STATUS_REGISTER_COUNT, | |||
| WaitCondition, | |||
| calculate_execution_total, | |||
| join_i32, | |||
| join_u32, | |||
| parse_status, | |||
| segment_base, | |||
| signed_to_u32, | |||
| split_u32, | |||
| register_read_count, | |||
| ) | |||
| class ParameterTable2026Tests(unittest.TestCase): | |||
| def test_low_word_is_at_low_address(self): | |||
| self.assertEqual(split_u32(0x12345678), [0x5678, 0x1234]) | |||
| self.assertEqual(join_u32(0x5678, 0x1234), 0x12345678) | |||
| self.assertEqual(join_i32(0xFF9C, 0xFFFF), -100) | |||
| def test_system_and_status_addresses(self): | |||
| self.assertEqual(int(Register.PULSE_POINT), 0x1000) | |||
| self.assertEqual(int(Register.MAX_FREQ_HI), 0x1015) | |||
| self.assertEqual(register_read_count(), 0x16) | |||
| self.assertEqual(int(Register.CUMULATIVE_POSITION_LO), 0x2000) | |||
| self.assertEqual(int(Register.CONTROL), 0x3000) | |||
| def test_segment_stride(self): | |||
| self.assertEqual(segment_base(0), 0x1100) | |||
| self.assertEqual(segment_base(9), 0x1190) | |||
| def test_start_control_frame(self): | |||
| frame = build_write_single(1, int(Register.CONTROL), int(Command.START)) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 01")) | |||
| def test_segment_frame_layout(self): | |||
| values = split_u32(1000) + split_u32((-200) & 0xFFFFFFFF) | |||
| values += [int(WaitCondition.WAIT_TIME), 500, 0, 2] | |||
| frame = build_write_multiple(1, segment_base(0), values) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[2:7], bytes.fromhex("11 00 00 08 10")) | |||
| def test_status_read_frame(self): | |||
| frame = build_read_holding( | |||
| 1, int(Register.CUMULATIVE_POSITION_LO), STATUS_REGISTER_COUNT | |||
| ) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[:6], bytes.fromhex("01 03 20 00 00 1B")) | |||
| def test_status_pulse_counts(self): | |||
| status = parse_status([30, 0, 0, 0, 2, 0, 0, 10, 0, 30, 0]) | |||
| self.assertEqual(status["current_pulses"], 10) | |||
| self.assertEqual(status["total_pulses"], 30) | |||
| def test_diagnostic_status_values(self): | |||
| registers = [0] * STATUS_REGISTER_COUNT | |||
| registers[11] = 0x001E | |||
| registers[12] = 2 | |||
| registers[13] = 3 | |||
| registers[14:16] = split_u32(1000) | |||
| registers[16:18] = split_u32(1000) | |||
| registers[18:20] = split_u32(5000) | |||
| registers[20:22] = split_u32(4998) | |||
| registers[22:24] = split_u32(2) | |||
| status = parse_status(registers) | |||
| self.assertEqual(status["diagnostic_expected_pulses"], 1000) | |||
| self.assertEqual(status["diagnostic_observed_pulses"], 1000) | |||
| self.assertEqual(status["diagnostic_actual_frequency"], 4998) | |||
| self.assertEqual(status["diagnostic_max_frequency_error"], 2) | |||
| def test_ab_reverse_segment_uses_signed_pulse_count(self): | |||
| self.assertEqual(split_u32(signed_to_u32(-100)), [0xFF9C, 0xFFFF]) | |||
| def test_b_phase_point_encoding(self): | |||
| self.assertEqual(SECONDARY_POINTS[0].split()[0], "Y12") | |||
| self.assertEqual(SECONDARY_POINTS[3].split()[0], "Y15") | |||
| def test_output_mode_register(self): | |||
| self.assertEqual(int(Register.OUTPUT_MODE), 0x100F) | |||
| def test_send_mode_register_values(self): | |||
| self.assertEqual(int(Register.SEND_MODE), 0x1004) | |||
| self.assertEqual(int(SendMode.COMPLETE), 0) | |||
| self.assertEqual(int(SendMode.FOLLOW), 1) | |||
| def test_direction_logic_register_values(self): | |||
| self.assertEqual(int(Register.DIRECTION_LOGIC), 0x1006) | |||
| self.assertEqual(int(DirectionLogic.POSITIVE), 0) | |||
| self.assertEqual(int(DirectionLogic.NEGATIVE), 1) | |||
| def test_y3_uses_pf9_tim14(self): | |||
| self.assertEqual(PULSE_POINTS[3], "Y3 / PF9 / TIM14_CH1") | |||
| def test_execution_total_starts_from_selected_segment(self): | |||
| segments = [ | |||
| (100, 1000, 0), | |||
| (200, 1000, 0), | |||
| (-300, 1000, 0), | |||
| ] | |||
| self.assertEqual(calculate_execution_total(segments, 2), (500, False)) | |||
| def test_execution_total_follows_jump_and_detects_cycle(self): | |||
| segments = [ | |||
| (100, 1000, 3), | |||
| (200, 1000, 0), | |||
| (300, 1000, 1), | |||
| ] | |||
| self.assertEqual(calculate_execution_total(segments, 1), (400, True)) | |||
| def test_clear_total_control_frame(self): | |||
| frame = build_write_single( | |||
| 1, int(Register.CONTROL), int(Command.CLEAR_TOTAL) | |||
| ) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 08")) | |||
| def test_save_outputs_control_frame(self): | |||
| frame = build_write_single( | |||
| 1, int(Register.CONTROL), int(Command.SAVE_OUTPUTS) | |||
| ) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 10")) | |||
| def test_clear_diagnostic_control_frame(self): | |||
| frame = build_write_single( | |||
| 1, int(Register.CONTROL), int(Command.CLEAR_DIAGNOSTIC) | |||
| ) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 20")) | |||
| def test_runtime_frequency_is_separate_from_default_speed(self): | |||
| self.assertEqual(int(Register.TARGET_FREQ_LO), 0x100B) | |||
| self.assertEqual(int(Register.RUNTIME_FREQ_LO), 0x1016) | |||
| frame = build_write_single( | |||
| 1, int(Register.CONTROL), int(Command.UPDATE_FREQUENCY) | |||
| ) | |||
| self.assertTrue(valid_crc(frame)) | |||
| self.assertEqual(frame[:6], bytes.fromhex("01 06 30 00 00 40")) | |||
| if __name__ == "__main__": | |||
| unittest.main() | |||
| @@ -0,0 +1,144 @@ | |||
| # PLSR Python 测试工具 | |||
| 该工具直接通过 Modbus RTU 测试当前 XDM-60T4-E/STM32 PLSR 固件。串口参数默认是 `115200、8E1、站号 1`。 | |||
| 默认 `spec` 映射严格对应 `document/参数表-2026.xlsx`:配置区 `0x1000`、10 段路径区 `0x1100..0x1197`、监控区 `0x2000`、控制寄存器 `0x3000`。双字按高字/低字组合,脉冲个数按有符号 32 位处理,频率范围按 `1..100000 Hz` 校验。`legacy` 映射保留当前旧版单段固件的零基地址 `0..107`。 | |||
| ## 最简单的打开方式 | |||
| 直接双击本目录中的 `start_plsr_test.bat`。启动程序会自动检查 Python 和 pyserial,然后打开中文图形上位机;检测到 COM9 时会优先选中它。确认参数为 `115200 / 8-E-1 / 站号 1` 后,点击“连接设备”。 | |||
| 图形界面提供以下与当前固件一致的功能: | |||
| - 设置需求版脉冲端子 Y0..Y3、方向端子 Y12..Y15; | |||
| - 设置有符号脉冲个数、`1–100000 Hz` 频率和正反方向; | |||
| - 读取配置区、10 段路径区、监控区和控制寄存器; | |||
| - 启动/继续、受控停止、重新运行、复位和累计脉冲清零; | |||
| - 完成/后续发送模式,以及相对/绝对运行模式; | |||
| - 低速正反转测试灯验证; | |||
| - 实时状态、累计脉冲、停止原因、错误码和原始寄存器诊断; | |||
| - 有上限的 TX/RX 通信日志,可清空或导出,避免日志无限增长。 | |||
| 命令行工具 `plsr_test.py` 和文字菜单 `plsr_console.py` 仍保留,可用于自动化和协议排查。 | |||
| ## 逻辑分析仪波形查看器 | |||
| 双击 `start_logic_csv_parser.bat` 可以直接打开逻辑分析仪波形查看器,不需要进入 IAR 编译。 | |||
| 查看器支持正点原子导出的两种文件: | |||
| - `CSV`:自动读取采样率、数字时间轴和所有通道; | |||
| - `BIN`:按原始采样点读取,需要填写采集时使用的采样率和通道数。 | |||
| 读取后会根据相邻同方向脉冲边沿的周期计算瞬时频率,并显示“横轴时间、纵轴频率 Hz”的运动轨迹。鼠标滚轮用于缩放时间轴,按住鼠标左键水平拖动可以平移,`适应窗口`用于恢复完整曲线。`显示通道`支持填写 `自动`、`4`、`0,1,4` 或 `0-7`。 | |||
| `测量边沿`默认选择自动,也可以指定上升沿或下降沿。`关键时间(ms)`可以填写 `100` 或 `100,200,500`,图中会在对应时间绘制标记线,并显示该节点之后最近一个完整周期测得的频率。横轴的 `0 ms` 从第一个有效脉冲边沿开始计算。 | |||
| 注意:正点原子导出的原始 `BIN` 文件没有保存采样率。界面中的 BIN 采样率必须与采集时的设备采样率一致,否则横轴时间和计算频率会不正确。 | |||
| 原来的 CSV 转换命令行脚本 `parse_logic_csv.py` 仍然保留,图形波形查看器脚本为 `parse_logic_gui.py`。 | |||
| 通信采用严格的“一次请求、一次响应”:上一帧结束或超时后才发送下一帧,并对站号、功能码、帧长度和 CRC 进行校验。接收器可以跳过残留字节重新寻找合法帧,避免 Qt 工具出现的 `0x40` 被误判成功能码的问题。 | |||
| ## 环境准备 | |||
| 在本目录执行: | |||
| ```powershell | |||
| python -m pip install -r requirements.txt | |||
| python plsr_test.py self-test | |||
| python plsr_test.py ports | |||
| ``` | |||
| 如果 Windows 的 `python` 是无效商店别名,请在 PyCharm 解释器或已安装的 Python 环境中执行相同命令。 | |||
| ## 推荐测试顺序 | |||
| 先断开伺服或机械负载,只接测试灯或示波器。以下示例使用 `COM9`,实际端口以 `ports` 输出为准。 | |||
| 1. 只读检查通信和当前保存的输出点: | |||
| ```powershell | |||
| python plsr_test.py --port COM9 status | |||
| ``` | |||
| 2. 需求版配置 Y0 输出脉冲、Y12 控制方向(`spec` 映射): | |||
| ```powershell | |||
| python plsr_test.py --port COM9 --map spec configure --pulse-output 0 --direction-output 0 | |||
| ``` | |||
| 输出点、系统参数、10 段参数和累计脉冲由固件自动保存到 Flash,不需要上位机额外发送保存命令。 | |||
| 3. 执行低速正反转自动测试: | |||
| ```powershell | |||
| python plsr_test.py --port COM9 --map spec auto-test --pulses 6 --frequency 2 --confirm-safe | |||
| ``` | |||
| 预期:正向和反向均完成 6 个脉冲,监控区 `0x2000/0x2001` 的有符号累计值可读;反向时脉冲点仍持续输出,方向点电平改变。 | |||
| 4. 持续监视状态: | |||
| ```powershell | |||
| python plsr_test.py --port COM9 --map spec monitor --interval 0.5 | |||
| ``` | |||
| 按 `Ctrl+C` 停止监视。 | |||
| ## 单项命令 | |||
| ```powershell | |||
| python plsr_test.py --port COM9 --map spec run --pulses 10 --frequency 1 --direction forward --confirm-safe | |||
| python plsr_test.py --port COM9 --map spec run --pulses -10 --frequency 1 --direction reverse --confirm-safe | |||
| python plsr_test.py --port COM9 --map spec stop | |||
| python plsr_test.py --port COM9 --map spec immediate-stop | |||
| python plsr_test.py --port COM9 --map spec reset | |||
| python plsr_test.py --port COM9 --map spec clear | |||
| ``` | |||
| 增加 `-v` 可以显示原始 TX/RX 帧: | |||
| ```powershell | |||
| python plsr_test.py --port COM9 -v status | |||
| ``` | |||
| ## 停止、继续和重新运行 | |||
| - “受控停止”会完成已经开始的当前脉冲后暂停,避免产生半个脉冲; | |||
| - 再点“启动/继续脉冲”,参数未改变时只发送剩余脉冲,例如 100 个脉冲在 50 停止后继续发送 50 个; | |||
| - “重新运行脉冲”强制从本次任务的 0 进度重新发送完整预设数量,但不会清空全局累计脉冲; | |||
| - “清零累计脉冲”只清累计值,“复位状态”只复位运行状态和错误状态; | |||
| - 立即停止仍保留在命令行 `immediate-stop` 中用于故障处置,它会立即关闭输出,当前脉冲可能被截断。 | |||
| ## 发送模式和运行模式 | |||
| - 完成模式:本段结束时保持本段速度,下一段开始后再向下一段速度加减速; | |||
| - 后续模式:本段后半段提前向下一段速度加减速,本段结束时已经到达下一段速度; | |||
| - 两种模式运行中再次发送启动命令都返回忙,不用于表示调用方是否等待; | |||
| - 相对模式:脉冲数表示本次要发送的数量,例如设置 `100` 就发送 100 个; | |||
| - 绝对模式:脉冲数表示目标位置。目标位置等于当前累计位置时不输出;不等时只发送两者的差值。 | |||
| ## 速度和加减速 | |||
| - 起始速度:开始输出时使用的速度; | |||
| - 默认速度:加速完成后的匀速速度; | |||
| - 终止速度:停止前减速阶段最后使用的速度; | |||
| - 加速时间和减速时间:分别表示加速、减速阶段的时间; | |||
| - 加减速模式 `0` 为直线,`1` 为 S 曲线,`2` 为正弦曲线。 | |||
| 当前固件在每个完整脉冲结束时更新下一脉冲的频率,参数简单,便于使用示波器观察速度变化。 | |||
| ## 掉电保存验证 | |||
| 固件在脉冲完成、受控/立即停止、累计清零或参数修改后,将系统参数、10 段参数、输出映射和累计脉冲写入带 CRC 的 Flash 记录。重新上电并连接后,“立即读取状态”应显示断电前最后一次稳定保存的累计值。 | |||
| 没有掉电检测、备份电池或 FRAM 时,运行中直接切断电源无法保证最后一个正在输出的脉冲零丢失。现场掉电保持验收应在运动完成或受控停止后断电;上电不会自动恢复断电前未完成的运动。 | |||
| ## 离线回归 | |||
| 离线测试不需要串口,也不需要安装 pyserial: | |||
| ```powershell | |||
| python -m unittest -v test_plsr_test.py | |||
| ``` | |||
| @@ -0,0 +1,515 @@ | |||
| #!/usr/bin/env python3 | |||
| """读取正点原子逻辑分析仪导出的 CSV 和 BIN 波形数据。""" | |||
| from __future__ import annotations | |||
| import array | |||
| import bisect | |||
| import csv | |||
| import io | |||
| import re | |||
| import sys | |||
| from dataclasses import dataclass | |||
| from pathlib import Path | |||
| from typing import Iterable | |||
| from parse_logic_csv import CsvParseError, detect_delimiter, read_text, remove_comment_lines | |||
| RATE_UNITS = { | |||
| "hz": 1.0, | |||
| "khz": 1_000.0, | |||
| "mhz": 1_000_000.0, | |||
| "ghz": 1_000_000_000.0, | |||
| } | |||
| MAX_BIN_TRANSITIONS = 2_000_000 | |||
| MAX_MARKER_COUNT = 10 | |||
| @dataclass | |||
| class WaveformChannel: | |||
| """保存一个数字通道的初始电平和跳变点。""" | |||
| index: int | |||
| name: str | |||
| times: list[float] | |||
| levels: list[int] | |||
| @property | |||
| def transition_count(self) -> int: | |||
| """返回真实跳变次数,不包含初始点。""" | |||
| return max(0, len(self.times) - 1) | |||
| def average_frequency(self) -> float | None: | |||
| """使用相邻上升沿估算平均频率。""" | |||
| rising_times = [ | |||
| time_value | |||
| for time_value, level in zip(self.times[1:], self.levels[1:]) | |||
| if level == 1 | |||
| ] | |||
| if len(rising_times) < 2: | |||
| return None | |||
| duration = rising_times[-1] - rising_times[0] | |||
| if duration <= 0: | |||
| return None | |||
| return (len(rising_times) - 1) / duration | |||
| @dataclass | |||
| class WaveformData: | |||
| """保存一个波形文件的公共信息。""" | |||
| path: Path | |||
| source_type: str | |||
| sample_rate: float | None | |||
| sample_count: int | None | |||
| record_count: int | |||
| start_time: float | |||
| end_time: float | |||
| channels: list[WaveformChannel] | |||
| @property | |||
| def duration(self) -> float: | |||
| """返回当前文件的可见时间长度。""" | |||
| return max(0.0, self.end_time - self.start_time) | |||
| @dataclass | |||
| class FrequencyCurve: | |||
| """保存由同方向脉冲边沿计算出的频率曲线。""" | |||
| channel_index: int | |||
| channel_name: str | |||
| edge_name: str | |||
| times: list[float] | |||
| frequencies: list[float] | |||
| @property | |||
| def start_time(self) -> float: | |||
| """返回曲线第一个有效脉冲边沿时间。""" | |||
| return self.times[0] | |||
| @property | |||
| def end_time(self) -> float: | |||
| """返回曲线最后一个有效脉冲边沿时间。""" | |||
| return self.times[-1] | |||
| def frequency_at(self, time_value: float) -> tuple[float, float]: | |||
| """返回指定时间之后最近一个完整周期的时间和频率。""" | |||
| index = bisect.bisect_left(self.times, time_value) | |||
| if index >= len(self.times): | |||
| index = len(self.times) - 1 | |||
| return self.times[index], self.frequencies[index] | |||
| def build_frequency_curve( | |||
| channel: WaveformChannel, edge_mode: str = "auto" | |||
| ) -> FrequencyCurve: | |||
| """使用相邻同方向边沿的周期计算瞬时脉冲频率。""" | |||
| if edge_mode not in ("auto", "rising", "falling"): | |||
| raise CsvParseError(f"不支持的测量边沿:{edge_mode}") | |||
| if len(channel.times) < 3: | |||
| raise CsvParseError(f"{channel.name} 的脉冲边沿不足,无法计算频率") | |||
| # times[0] 只是文件中的初始电平状态,不能当成真实边沿。 | |||
| transitions = list(zip(channel.times[1:], channel.levels[1:])) | |||
| if edge_mode == "auto": | |||
| selected_level = transitions[0][1] | |||
| else: | |||
| selected_level = 1 if edge_mode == "rising" else 0 | |||
| edge_name = "上升沿" if selected_level == 1 else "下降沿" | |||
| edge_times = [time_value for time_value, level in transitions if level == selected_level] | |||
| # 自动边沿不足时再尝试另一种边沿,避免文件从脉冲中间开始造成误判。 | |||
| if len(edge_times) < 2 and edge_mode == "auto": | |||
| selected_level = 1 - selected_level | |||
| edge_name = "上升沿" if selected_level == 1 else "下降沿" | |||
| edge_times = [ | |||
| time_value for time_value, level in transitions if level == selected_level | |||
| ] | |||
| if len(edge_times) < 2: | |||
| raise CsvParseError(f"{channel.name} 的{edge_name}不足,无法计算频率") | |||
| frequencies: list[float] = [] | |||
| valid_edge_times: list[float] = [edge_times[0]] | |||
| for index in range(1, len(edge_times)): | |||
| period = edge_times[index] - edge_times[index - 1] | |||
| if period <= 0: | |||
| continue | |||
| frequencies.append(1.0 / period) | |||
| valid_edge_times.append(edge_times[index]) | |||
| if not frequencies: | |||
| raise CsvParseError(f"{channel.name} 没有有效脉冲周期") | |||
| # 起点还没有完整周期,使用第一个完整周期的频率补齐曲线起点。 | |||
| curve_frequencies = [frequencies[0], *frequencies] | |||
| return FrequencyCurve( | |||
| channel_index=channel.index, | |||
| channel_name=channel.name, | |||
| edge_name=edge_name, | |||
| times=valid_edge_times, | |||
| frequencies=curve_frequencies, | |||
| ) | |||
| def build_frequency_curves( | |||
| channels: Iterable[WaveformChannel], edge_mode: str = "auto" | |||
| ) -> list[FrequencyCurve]: | |||
| """为所有具有足够脉冲边沿的通道生成频率曲线。""" | |||
| curves: list[FrequencyCurve] = [] | |||
| for channel in channels: | |||
| try: | |||
| curves.append(build_frequency_curve(channel, edge_mode)) | |||
| except CsvParseError: | |||
| continue | |||
| if not curves: | |||
| raise CsvParseError("所选通道没有足够的脉冲边沿,无法生成频率曲线") | |||
| return curves | |||
| def parse_marker_times_ms(text: str) -> list[float]: | |||
| """解析最多十个毫秒关键时间,并转换为秒。""" | |||
| if not text.strip(): | |||
| return [] | |||
| markers: set[float] = set() | |||
| for part in re.split(r"[,,\s]+", text.strip()): | |||
| if not part: | |||
| continue | |||
| try: | |||
| milliseconds = float(part) | |||
| except ValueError as exc: | |||
| raise CsvParseError(f"无法识别关键时间:{part!r}") from exc | |||
| if milliseconds < 0: | |||
| raise CsvParseError("关键时间不能小于 0 ms") | |||
| markers.add(milliseconds / 1_000.0) | |||
| if len(markers) > MAX_MARKER_COUNT: | |||
| raise CsvParseError(f"关键时间最多设置 {MAX_MARKER_COUNT} 个") | |||
| return sorted(markers) | |||
| def parse_engineering_number(text: str) -> float: | |||
| """解析 20 MHz、500 kHz 等带单位的数值。""" | |||
| match = re.fullmatch( | |||
| r"\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG]?[hH][zZ])?\s*", text | |||
| ) | |||
| if not match: | |||
| raise CsvParseError(f"无法识别采样率:{text!r}") | |||
| number = float(match.group(1)) | |||
| unit = (match.group(2) or "Hz").lower() | |||
| return number * RATE_UNITS[unit] | |||
| def parse_scaled_count(text: str) -> int | None: | |||
| """解析 125.351087 M 形式的采样点数量。""" | |||
| match = re.fullmatch(r"\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG])?\s*", text) | |||
| if not match: | |||
| return None | |||
| scales = {"": 1.0, "k": 1_000.0, "m": 1_000_000.0, "g": 1_000_000_000.0} | |||
| return int(float(match.group(1)) * scales[(match.group(2) or "").lower()]) | |||
| def parse_metadata(text: str) -> tuple[float | None, int | None]: | |||
| """读取 CSV 注释中的采样率和采样数量。""" | |||
| sample_rate: float | None = None | |||
| sample_count: int | None = None | |||
| for line in text.splitlines(): | |||
| stripped = line.strip() | |||
| if not stripped.startswith(";"): | |||
| continue | |||
| content = stripped[1:].strip() | |||
| key, separator, value = content.partition(":") | |||
| if not separator: | |||
| continue | |||
| key = key.strip().lower() | |||
| value = value.strip() | |||
| if key == "sample rate": | |||
| sample_rate = parse_engineering_number(value) | |||
| elif key == "sample count": | |||
| sample_count = parse_scaled_count(value) | |||
| return sample_rate, sample_count | |||
| def find_numeric_time_column(header: list[str], rows: list[list[str]]) -> int: | |||
| """优先选择正点原子 CSV 中的 Time(s) 数字时间列。""" | |||
| lowered = [name.strip().lower() for name in header] | |||
| preferred_names = ("time(s)", "time (s)", "time", "timestamp", "t", "时间") | |||
| for name in preferred_names: | |||
| if name in lowered: | |||
| return lowered.index(name) | |||
| # 没有标准名称时,选择前几行都能转换成浮点数的列。 | |||
| for column in range(len(header)): | |||
| valid = True | |||
| for row in rows[:20]: | |||
| if column >= len(row): | |||
| valid = False | |||
| break | |||
| try: | |||
| float(row[column].strip().lstrip("'")) | |||
| except ValueError: | |||
| valid = False | |||
| break | |||
| if valid: | |||
| return column | |||
| raise CsvParseError("找不到可用的数字时间列") | |||
| def find_channel_columns( | |||
| header: list[str], rows: list[list[str]], time_index: int | |||
| ) -> list[tuple[int, int, str]]: | |||
| """识别 Channel 0、CH1、D2 等数字通道列。""" | |||
| channels: list[tuple[int, int, str]] = [] | |||
| used_indexes: set[int] = set() | |||
| patterns = ( | |||
| re.compile(r"^channel\s*[_-]?\s*(\d+)$", re.IGNORECASE), | |||
| re.compile(r"^(?:ch|d)\s*[_-]?\s*(\d+)$", re.IGNORECASE), | |||
| ) | |||
| for column, name in enumerate(header): | |||
| if column == time_index: | |||
| continue | |||
| channel_index: int | None = None | |||
| for pattern in patterns: | |||
| match = pattern.fullmatch(name.strip()) | |||
| if match: | |||
| channel_index = int(match.group(1)) | |||
| break | |||
| if channel_index is None or channel_index in used_indexes: | |||
| continue | |||
| channels.append((column, channel_index, name.strip())) | |||
| used_indexes.add(channel_index) | |||
| if channels: | |||
| return sorted(channels, key=lambda item: item[1]) | |||
| # 兼容只有“时间,电平”两列的简单文件。 | |||
| for column, name in enumerate(header): | |||
| if column == time_index: | |||
| continue | |||
| valid = True | |||
| for row in rows[:100]: | |||
| if column >= len(row) or row[column].strip() not in ("0", "1"): | |||
| valid = False | |||
| break | |||
| if valid: | |||
| channels.append((column, len(channels), name.strip() or f"Channel {len(channels)}")) | |||
| if not channels: | |||
| raise CsvParseError("CSV 中没有找到高低电平通道") | |||
| return channels | |||
| def load_csv_waveform(path: Path, encoding: str = "auto") -> WaveformData: | |||
| """读取 CSV 中的全部数字通道并保存跳变点。""" | |||
| text = read_text(path, encoding) | |||
| sample_rate, sample_count = parse_metadata(text) | |||
| clean_text = remove_comment_lines(text) | |||
| if not clean_text: | |||
| raise CsvParseError("CSV 中没有有效数据") | |||
| delimiter = detect_delimiter(clean_text) | |||
| rows = list(csv.reader(io.StringIO(clean_text), delimiter=delimiter)) | |||
| if len(rows) < 2: | |||
| raise CsvParseError("CSV 缺少表头或采样数据") | |||
| header = [name.strip() for name in rows[0]] | |||
| data_rows = [row for row in rows[1:] if row] | |||
| time_index = find_numeric_time_column(header, data_rows) | |||
| channel_columns = find_channel_columns(header, data_rows, time_index) | |||
| channel_map = { | |||
| column: WaveformChannel(index, name, [], []) | |||
| for column, index, name in channel_columns | |||
| } | |||
| first_time: float | None = None | |||
| last_time: float | None = None | |||
| previous_levels: dict[int, int] = {} | |||
| valid_rows = 0 | |||
| for line_number, row in enumerate(data_rows, start=2): | |||
| needed_column = max([time_index, *channel_map.keys()]) | |||
| if len(row) <= needed_column: | |||
| raise CsvParseError(f"CSV 第 {line_number} 行列数不足") | |||
| try: | |||
| time_value = float(row[time_index].strip().lstrip("'")) | |||
| except ValueError as exc: | |||
| raise CsvParseError(f"CSV 第 {line_number} 行时间不是数字") from exc | |||
| if last_time is not None and time_value < last_time: | |||
| raise CsvParseError(f"CSV 第 {line_number} 行时间顺序错误") | |||
| if first_time is None: | |||
| first_time = time_value | |||
| last_time = time_value | |||
| valid_rows += 1 | |||
| for column, channel in channel_map.items(): | |||
| level_text = row[column].strip() | |||
| if level_text not in ("0", "1"): | |||
| raise CsvParseError( | |||
| f"CSV 第 {line_number} 行的 {channel.name} 不是 0 或 1" | |||
| ) | |||
| level = int(level_text) | |||
| if column not in previous_levels or previous_levels[column] != level: | |||
| channel.times.append(time_value) | |||
| channel.levels.append(level) | |||
| previous_levels[column] = level | |||
| if first_time is None or last_time is None: | |||
| raise CsvParseError("CSV 中没有有效采样点") | |||
| return WaveformData( | |||
| path=path, | |||
| source_type="CSV", | |||
| sample_rate=sample_rate, | |||
| sample_count=sample_count, | |||
| record_count=valid_rows, | |||
| start_time=first_time, | |||
| end_time=last_time, | |||
| channels=list(channel_map.values()), | |||
| ) | |||
| def parse_channel_selection(text: str, channel_count: int) -> list[int] | None: | |||
| """解析自动、4、0,1,4、0-7 等通道选择写法。""" | |||
| stripped = text.strip().lower() | |||
| if not stripped or stripped in ("自动", "auto", "all", "全部"): | |||
| return None | |||
| selected: set[int] = set() | |||
| for part in re.split(r"[,,\s]+", stripped): | |||
| if not part: | |||
| continue | |||
| if "-" in part: | |||
| start_text, end_text = part.split("-", 1) | |||
| if not start_text.isdigit() or not end_text.isdigit(): | |||
| raise CsvParseError(f"无法识别通道范围:{part}") | |||
| start = int(start_text) | |||
| end = int(end_text) | |||
| if start > end: | |||
| start, end = end, start | |||
| selected.update(range(start, end + 1)) | |||
| elif part.isdigit(): | |||
| selected.add(int(part)) | |||
| else: | |||
| match = re.fullmatch(r"(?:channel|ch|d)\s*(\d+)", part) | |||
| if not match: | |||
| raise CsvParseError(f"无法识别通道:{part}") | |||
| selected.add(int(match.group(1))) | |||
| invalid = [index for index in sorted(selected) if not 0 <= index < channel_count] | |||
| if invalid: | |||
| raise CsvParseError(f"通道超出 0~{channel_count - 1} 范围:{invalid}") | |||
| return sorted(selected) | |||
| def _sample_values(data: bytes, sample_width: int) -> Iterable[int]: | |||
| """把原始字节按每个采样点 1 或 2 字节解释。""" | |||
| if sample_width == 1: | |||
| return data | |||
| values = array.array("H") | |||
| values.frombytes(data) | |||
| if sys.byteorder != "little": | |||
| values.byteswap() | |||
| return values | |||
| def load_bin_waveform( | |||
| path: Path, | |||
| sample_rate: float, | |||
| channel_count: int, | |||
| selected_indexes: list[int] | None = None, | |||
| ) -> WaveformData: | |||
| """按无文件头的原始采样格式读取 BIN。""" | |||
| if sample_rate <= 0: | |||
| raise CsvParseError("BIN 采样率必须大于 0") | |||
| if not 1 <= channel_count <= 16: | |||
| raise CsvParseError("BIN 通道数只支持 1~16") | |||
| sample_width = 1 if channel_count <= 8 else 2 | |||
| data = path.read_bytes() | |||
| if not data: | |||
| raise CsvParseError("BIN 文件为空") | |||
| if len(data) % sample_width != 0: | |||
| raise CsvParseError(f"BIN 文件长度不能按 {sample_width} 字节采样点整除") | |||
| values = _sample_values(data, sample_width) | |||
| del data | |||
| sample_count = len(values) # type: ignore[arg-type] | |||
| selected = selected_indexes or list(range(channel_count)) | |||
| selected_mask = sum(1 << index for index in selected) | |||
| first_value = values[0] # type: ignore[index] | |||
| channel_map = { | |||
| index: WaveformChannel( | |||
| index=index, | |||
| name=f"Channel {index}", | |||
| times=[0.0], | |||
| levels=[(first_value >> index) & 1], | |||
| ) | |||
| for index in selected | |||
| } | |||
| previous_value = first_value | |||
| transition_total = 0 | |||
| for sample_index in range(1, sample_count): | |||
| value = values[sample_index] # type: ignore[index] | |||
| changed = (value ^ previous_value) & selected_mask | |||
| while changed: | |||
| lowest_bit = changed & -changed | |||
| channel_index = lowest_bit.bit_length() - 1 | |||
| channel = channel_map[channel_index] | |||
| channel.times.append(sample_index / sample_rate) | |||
| channel.levels.append((value >> channel_index) & 1) | |||
| transition_total += 1 | |||
| if transition_total > MAX_BIN_TRANSITIONS: | |||
| raise CsvParseError( | |||
| "BIN 跳变点过多,请在“显示通道”中只填写需要查看的通道" | |||
| ) | |||
| changed ^= lowest_bit | |||
| previous_value = value | |||
| end_time = 0.0 if sample_count <= 1 else (sample_count - 1) / sample_rate | |||
| return WaveformData( | |||
| path=path, | |||
| source_type="BIN", | |||
| sample_rate=sample_rate, | |||
| sample_count=sample_count, | |||
| record_count=sample_count, | |||
| start_time=0.0, | |||
| end_time=end_time, | |||
| channels=list(channel_map.values()), | |||
| ) | |||
| def load_waveform( | |||
| path: Path, | |||
| bin_sample_rate: float, | |||
| bin_channel_count: int, | |||
| channel_text: str, | |||
| ) -> tuple[WaveformData, list[WaveformChannel]]: | |||
| """根据扩展名读取文件,并返回需要显示的通道。""" | |||
| suffix = path.suffix.lower() | |||
| if suffix == ".csv": | |||
| data = load_csv_waveform(path) | |||
| selected_indexes = parse_channel_selection(channel_text, len(data.channels)) | |||
| if selected_indexes is None: | |||
| visible = [channel for channel in data.channels if channel.transition_count > 0] | |||
| if not visible: | |||
| visible = data.channels[:8] | |||
| else: | |||
| selected_set = set(selected_indexes) | |||
| visible = [channel for channel in data.channels if channel.index in selected_set] | |||
| elif suffix == ".bin": | |||
| selected_indexes = parse_channel_selection(channel_text, bin_channel_count) | |||
| data = load_bin_waveform( | |||
| path, | |||
| sample_rate=bin_sample_rate, | |||
| channel_count=bin_channel_count, | |||
| selected_indexes=selected_indexes, | |||
| ) | |||
| if selected_indexes is None: | |||
| visible = [channel for channel in data.channels if channel.transition_count > 0] | |||
| if not visible: | |||
| visible = data.channels[:8] | |||
| else: | |||
| visible = data.channels | |||
| else: | |||
| raise CsvParseError("只支持正点原子导出的 CSV 或 BIN 文件") | |||
| if not visible: | |||
| raise CsvParseError("所选通道没有可显示的数据") | |||
| return data, visible | |||
| @@ -0,0 +1,227 @@ | |||
| #!/usr/bin/env python3 | |||
| """解析正点原子逻辑分析仪导出的 CSV 文件。 | |||
| 文件开头以分号开头的说明行会被忽略。输出文件默认不写表头, | |||
| 每行格式为:时间,电平 | |||
| 示例: | |||
| python parse_logic_csv.py input.csv output.csv --channel CH1 --mode changes | |||
| python parse_logic_csv.py input.csv output.csv --channel 2 --mode all | |||
| """ | |||
| from __future__ import annotations | |||
| import argparse | |||
| import csv | |||
| import io | |||
| import sys | |||
| from pathlib import Path | |||
| from typing import Iterable, Sequence | |||
| TIME_NAMES = {"time", "timestamp", "时间", "时刻", "t"} | |||
| LEVEL_NAMES = { | |||
| "0": "0", | |||
| "1": "1", | |||
| "low": "0", | |||
| "l": "0", | |||
| "false": "0", | |||
| "off": "0", | |||
| "低": "0", | |||
| "低电平": "0", | |||
| "high": "1", | |||
| "h": "1", | |||
| "true": "1", | |||
| "on": "1", | |||
| "高": "1", | |||
| "高电平": "1", | |||
| } | |||
| class CsvParseError(ValueError): | |||
| """CSV 格式或参数不符合预期。""" | |||
| def read_text(path: Path, encoding: str) -> str: | |||
| """读取文本,utf-8 失败时自动尝试常见的 GBK 编码。""" | |||
| if encoding != "auto": | |||
| return path.read_text(encoding=encoding) | |||
| data = path.read_bytes() | |||
| for candidate in ("utf-8-sig", "gbk", "utf-16"): | |||
| try: | |||
| return data.decode(candidate) | |||
| except UnicodeDecodeError: | |||
| continue | |||
| raise CsvParseError(f"无法识别文件编码:{path}") | |||
| def remove_comment_lines(text: str) -> str: | |||
| """删除空行和以分号开头的逻辑分析仪说明行。""" | |||
| lines = [] | |||
| for line in text.splitlines(): | |||
| if not line.strip() or line.lstrip().startswith(";"): | |||
| continue | |||
| lines.append(line) | |||
| return "\n".join(lines) | |||
| def detect_delimiter(text: str) -> str: | |||
| """识别逗号、制表符或分号分隔格式。""" | |||
| sample_lines = text.splitlines()[:20] | |||
| sample = "\n".join(sample_lines) | |||
| try: | |||
| dialect = csv.Sniffer().sniff(sample, delimiters=",\t;") | |||
| return dialect.delimiter | |||
| except csv.Error: | |||
| counts = {delimiter: sample.count(delimiter) for delimiter in (",", "\t", ";")} | |||
| delimiter = max(counts, key=counts.get) | |||
| if counts[delimiter] == 0: | |||
| raise CsvParseError("无法识别 CSV 分隔符,请使用逗号、制表符或分号") | |||
| return delimiter | |||
| def parse_level(value: str) -> str: | |||
| """把常见的高低电平写法统一为 0 或 1。""" | |||
| text = value.strip().lower() | |||
| if text in LEVEL_NAMES: | |||
| return LEVEL_NAMES[text] | |||
| try: | |||
| number = float(text) | |||
| except ValueError as exc: | |||
| raise CsvParseError(f"无法识别电平值:{value!r}") from exc | |||
| if number == 0: | |||
| return "0" | |||
| if number == 1: | |||
| return "1" | |||
| raise CsvParseError(f"电平必须为0或1,实际得到:{value!r}") | |||
| def find_column(header: Sequence[str], name: str, description: str) -> int: | |||
| """按列名或列序号查找列,列序号从0开始。""" | |||
| name = name.strip() | |||
| if name.isdigit(): | |||
| index = int(name) | |||
| if 0 <= index < len(header): | |||
| return index | |||
| raise CsvParseError(f"{description}列序号超出范围:{index}") | |||
| lowered = [item.strip().lower() for item in header] | |||
| target = name.lower() | |||
| if target in lowered: | |||
| return lowered.index(target) | |||
| choices = ", ".join(header) | |||
| raise CsvParseError(f"找不到{description}列 {name!r},可用列:{choices}") | |||
| def find_time_column(header: Sequence[str]) -> int: | |||
| """优先查找常见时间列,找不到时默认使用第0列。""" | |||
| for index, name in enumerate(header): | |||
| if name.strip().lower() in TIME_NAMES: | |||
| return index | |||
| return 0 | |||
| def parse_rows(text: str, channel: str, time_column: str | None) -> list[tuple[str, str]]: | |||
| """读取 CSV 并返回有效的(时间,电平)采样点。""" | |||
| clean_text = remove_comment_lines(text) | |||
| if not clean_text: | |||
| raise CsvParseError("文件中没有有效数据") | |||
| delimiter = detect_delimiter(clean_text) | |||
| rows = list(csv.reader(io.StringIO(clean_text), delimiter=delimiter)) | |||
| if not rows or len(rows[0]) < 2: | |||
| raise CsvParseError("CSV 至少需要时间列和一个通道列") | |||
| header = [item.strip() for item in rows[0]] | |||
| channel_column = find_column(header, channel, "通道") | |||
| time_index = ( | |||
| find_column(header, time_column, "时间") | |||
| if time_column is not None | |||
| else find_time_column(header) | |||
| ) | |||
| points: list[tuple[str, str]] = [] | |||
| for line_number, row in enumerate(rows[1:], start=2): | |||
| if len(row) <= max(channel_column, time_index): | |||
| raise CsvParseError(f"第{line_number}行列数不足") | |||
| time_value = row[time_index].strip() | |||
| if not time_value: | |||
| raise CsvParseError(f"第{line_number}行时间为空") | |||
| points.append((time_value, parse_level(row[channel_column]))) | |||
| if not points: | |||
| raise CsvParseError("CSV 表头后没有采样数据") | |||
| return points | |||
| def compress_level_changes(points: Iterable[tuple[str, str]]) -> list[tuple[str, str]]: | |||
| """只保留首个采样点和电平发生变化的采样点。""" | |||
| output: list[tuple[str, str]] = [] | |||
| previous_level: str | None = None | |||
| for point in points: | |||
| if previous_level is None or point[1] != previous_level: | |||
| output.append(point) | |||
| previous_level = point[1] | |||
| return output | |||
| def write_points(path: Path, points: Iterable[tuple[str, str]]) -> int: | |||
| """写出时间、电平两列,不写表头。""" | |||
| count = 0 | |||
| with path.open("w", encoding="utf-8", newline="") as file: | |||
| writer = csv.writer(file, lineterminator="\n") | |||
| for time_value, level in points: | |||
| writer.writerow((time_value, level)) | |||
| count += 1 | |||
| return count | |||
| def build_argument_parser() -> argparse.ArgumentParser: | |||
| """创建命令行参数。""" | |||
| parser = argparse.ArgumentParser( | |||
| description="解析正点原子逻辑分析仪 CSV,输出时间和电平两列" | |||
| ) | |||
| parser.add_argument("input", type=Path, help="逻辑分析仪导出的 CSV 文件") | |||
| parser.add_argument("output", type=Path, help="输出 CSV 文件") | |||
| parser.add_argument( | |||
| "--channel", | |||
| required=True, | |||
| help="通道名称,例如 CH1、D0;也可以填写列序号,序号从0开始", | |||
| ) | |||
| parser.add_argument( | |||
| "--mode", | |||
| choices=("all", "changes"), | |||
| default="changes", | |||
| help="all输出全部采样点,changes只输出电平跳变坐标点(默认)", | |||
| ) | |||
| parser.add_argument( | |||
| "--time-column", | |||
| help="时间列名称或列序号,默认自动识别,找不到时使用第0列", | |||
| ) | |||
| parser.add_argument( | |||
| "--encoding", | |||
| default="auto", | |||
| choices=("auto", "utf-8-sig", "gbk", "utf-16"), | |||
| help="输入文件编码,默认自动识别", | |||
| ) | |||
| return parser | |||
| def main(argv: Sequence[str] | None = None) -> int: | |||
| """命令行入口。""" | |||
| parser = build_argument_parser() | |||
| args = parser.parse_args(argv) | |||
| try: | |||
| points = parse_rows(read_text(args.input, args.encoding), args.channel, args.time_column) | |||
| output_points = points if args.mode == "all" else compress_level_changes(points) | |||
| output_count = write_points(args.output, output_points) | |||
| except (OSError, CsvParseError) as exc: | |||
| parser.error(str(exc)) | |||
| return 2 | |||
| compression = 0.0 if not points else (1.0 - output_count / len(points)) * 100.0 | |||
| print(f"输入采样点数:{len(points)}") | |||
| print(f"输出点数:{output_count}") | |||
| print(f"点数压缩率:{compression:.2f}%") | |||
| print(f"输出文件:{args.output}") | |||
| return 0 | |||
| if __name__ == "__main__": | |||
| sys.exit(main()) | |||
| @@ -0,0 +1,765 @@ | |||
| #!/usr/bin/env python3 | |||
| """正点原子逻辑分析仪脉冲频率轨迹查看器。""" | |||
| from __future__ import annotations | |||
| import bisect | |||
| import math | |||
| import queue | |||
| import threading | |||
| import tkinter as tk | |||
| from pathlib import Path | |||
| from tkinter import filedialog, messagebox, ttk | |||
| from logic_waveform import ( | |||
| RATE_UNITS, | |||
| FrequencyCurve, | |||
| WaveformChannel, | |||
| WaveformData, | |||
| build_frequency_curves, | |||
| load_waveform, | |||
| parse_marker_times_ms, | |||
| ) | |||
| from parse_logic_csv import CsvParseError | |||
| CHANNEL_COLORS = ( | |||
| "#2563eb", | |||
| "#dc2626", | |||
| "#16a34a", | |||
| "#ea580c", | |||
| "#7c3aed", | |||
| "#0891b2", | |||
| "#c026d3", | |||
| "#4d7c0f", | |||
| ) | |||
| EDGE_MODES = { | |||
| "自动": "auto", | |||
| "上升沿": "rising", | |||
| "下降沿": "falling", | |||
| } | |||
| class LogicWaveformApp(tk.Tk): | |||
| """读取逻辑分析仪文件并显示频率随时间的变化。""" | |||
| def __init__(self) -> None: | |||
| super().__init__() | |||
| self.title("正点原子逻辑分析仪脉冲频率轨迹查看器") | |||
| self.geometry("1180x760") | |||
| self.minsize(880, 580) | |||
| self.input_var = tk.StringVar() | |||
| self.sample_rate_var = tk.StringVar(value="20") | |||
| self.sample_rate_unit_var = tk.StringVar(value="MHz") | |||
| self.bin_channel_count_var = tk.StringVar(value="16") | |||
| self.channel_var = tk.StringVar(value="自动") | |||
| self.edge_var = tk.StringVar(value="自动") | |||
| self.marker_var = tk.StringVar(value="100") | |||
| self.status_var = tk.StringVar(value="请选择正点原子导出的 CSV 或 BIN 文件") | |||
| self.file_info_var = tk.StringVar(value="尚未读取频率轨迹") | |||
| self.cursor_var = tk.StringVar(value="光标:-") | |||
| self.marker_info_var = tk.StringVar(value="关键节点:-") | |||
| self.result_queue: queue.Queue[tuple[str, object]] = queue.Queue() | |||
| self.waveform_data: WaveformData | None = None | |||
| self.visible_channels: list[WaveformChannel] = [] | |||
| self.frequency_curves: list[FrequencyCurve] = [] | |||
| self.marker_times: list[float] = [0.1] | |||
| self.frequency_origin = 0.0 | |||
| self.view_start = 0.0 | |||
| self.view_end = 1.0 | |||
| self.drag_state: tuple[int, float, float] | None = None | |||
| self.redraw_job: str | None = None | |||
| self.load_button: ttk.Button | |||
| self.canvas: tk.Canvas | |||
| self._build_widgets() | |||
| def _build_widgets(self) -> None: | |||
| """创建文件参数区和频率曲线显示区。""" | |||
| self.columnconfigure(0, weight=1) | |||
| self.rowconfigure(3, weight=1) | |||
| header = ttk.Frame(self, padding=(16, 14, 16, 8)) | |||
| header.grid(row=0, column=0, sticky="ew") | |||
| header.columnconfigure(0, weight=1) | |||
| ttk.Label( | |||
| header, | |||
| text="脉冲频率轨迹查看器", | |||
| font=("Microsoft YaHei UI", 15, "bold"), | |||
| ).grid(row=0, column=0, sticky="w") | |||
| ttk.Label(header, textvariable=self.status_var).grid( | |||
| row=0, column=1, sticky="e" | |||
| ) | |||
| controls = ttk.Frame(self, padding=(16, 0, 16, 8)) | |||
| controls.grid(row=1, column=0, sticky="ew") | |||
| controls.columnconfigure(1, weight=1) | |||
| ttk.Label(controls, text="波形文件").grid(row=0, column=0, sticky="w") | |||
| ttk.Entry(controls, textvariable=self.input_var).grid( | |||
| row=0, column=1, sticky="ew", padx=(8, 8) | |||
| ) | |||
| ttk.Button(controls, text="选择文件", command=self._choose_input).grid( | |||
| row=0, column=2, padx=(0, 8) | |||
| ) | |||
| self.load_button = ttk.Button( | |||
| controls, text="读取并显示", command=self._start_load | |||
| ) | |||
| self.load_button.grid(row=0, column=3) | |||
| options = ttk.Frame(self, padding=(16, 0, 16, 10)) | |||
| options.grid(row=2, column=0, sticky="ew") | |||
| ttk.Label(options, text="BIN采样率").grid(row=0, column=0, sticky="w") | |||
| ttk.Entry(options, textvariable=self.sample_rate_var, width=9).grid( | |||
| row=0, column=1, padx=(8, 4) | |||
| ) | |||
| ttk.Combobox( | |||
| options, | |||
| textvariable=self.sample_rate_unit_var, | |||
| values=("Hz", "kHz", "MHz", "GHz"), | |||
| state="readonly", | |||
| width=6, | |||
| ).grid(row=0, column=2, padx=(0, 18)) | |||
| ttk.Label(options, text="BIN通道数").grid(row=0, column=3, sticky="w") | |||
| ttk.Combobox( | |||
| options, | |||
| textvariable=self.bin_channel_count_var, | |||
| values=("8", "16"), | |||
| state="readonly", | |||
| width=5, | |||
| ).grid(row=0, column=4, padx=(8, 18)) | |||
| ttk.Label(options, text="显示通道").grid(row=0, column=5, sticky="w") | |||
| ttk.Entry(options, textvariable=self.channel_var, width=14).grid( | |||
| row=0, column=6, padx=(8, 8) | |||
| ) | |||
| ttk.Label(options, text="测量边沿").grid(row=1, column=0, sticky="w", pady=(8, 0)) | |||
| edge_box = ttk.Combobox( | |||
| options, | |||
| textvariable=self.edge_var, | |||
| values=tuple(EDGE_MODES), | |||
| state="readonly", | |||
| width=8, | |||
| ) | |||
| edge_box.grid(row=1, column=1, sticky="w", padx=(8, 18), pady=(8, 0)) | |||
| edge_box.bind("<<ComboboxSelected>>", lambda _event: self._apply_plot_settings()) | |||
| ttk.Label(options, text="关键时间(ms,逗号分隔,最多10个)").grid( | |||
| row=1, column=3, sticky="w", pady=(8, 0) | |||
| ) | |||
| marker_entry = ttk.Entry(options, textvariable=self.marker_var, width=28) | |||
| marker_entry.grid(row=1, column=4, columnspan=2, sticky="w", padx=(8, 8), pady=(8, 0)) | |||
| marker_entry.bind("<Return>", lambda _event: self._apply_plot_settings()) | |||
| ttk.Button(options, text="更新曲线", command=self._apply_plot_settings).grid( | |||
| row=1, column=6, sticky="w", pady=(8, 0) | |||
| ) | |||
| viewer = ttk.Frame(self, padding=(16, 0, 16, 10)) | |||
| viewer.grid(row=3, column=0, sticky="nsew") | |||
| viewer.columnconfigure(0, weight=1) | |||
| viewer.rowconfigure(2, weight=1) | |||
| info_bar = ttk.Frame(viewer) | |||
| info_bar.grid(row=0, column=0, sticky="ew", pady=(0, 6)) | |||
| info_bar.columnconfigure(0, weight=1) | |||
| ttk.Label(info_bar, textvariable=self.file_info_var).grid( | |||
| row=0, column=0, sticky="w" | |||
| ) | |||
| ttk.Label(info_bar, textvariable=self.cursor_var).grid( | |||
| row=0, column=1, sticky="e" | |||
| ) | |||
| toolbar = ttk.Frame(viewer) | |||
| toolbar.grid(row=1, column=0, sticky="ew", pady=(0, 6)) | |||
| toolbar.columnconfigure(3, weight=1) | |||
| ttk.Button(toolbar, text="放大", command=lambda: self._zoom_view(0.6)).grid( | |||
| row=0, column=0 | |||
| ) | |||
| ttk.Button(toolbar, text="缩小", command=lambda: self._zoom_view(1.6)).grid( | |||
| row=0, column=1, padx=(6, 0) | |||
| ) | |||
| ttk.Button(toolbar, text="适应窗口", command=self._fit_view).grid( | |||
| row=0, column=2, padx=(6, 0) | |||
| ) | |||
| ttk.Label( | |||
| toolbar, | |||
| textvariable=self.marker_info_var, | |||
| wraplength=800, | |||
| ).grid( | |||
| row=1, column=0, columnspan=4, sticky="w", pady=(6, 0) | |||
| ) | |||
| self.canvas = tk.Canvas( | |||
| viewer, | |||
| background="#ffffff", | |||
| highlightthickness=1, | |||
| highlightbackground="#aeb4bc", | |||
| cursor="crosshair", | |||
| ) | |||
| self.canvas.grid(row=2, column=0, sticky="nsew") | |||
| self.canvas.bind("<Configure>", lambda _event: self._schedule_redraw()) | |||
| self.canvas.bind("<MouseWheel>", self._on_mouse_wheel) | |||
| self.canvas.bind("<ButtonPress-1>", self._start_drag) | |||
| self.canvas.bind("<B1-Motion>", self._drag_view) | |||
| self.canvas.bind("<ButtonRelease-1>", self._end_drag) | |||
| self.canvas.bind("<Motion>", self._show_cursor) | |||
| self.canvas.bind("<Leave>", self._hide_cursor) | |||
| def _choose_input(self) -> None: | |||
| """选择 CSV 或 BIN 文件并立即读取。""" | |||
| path = filedialog.askopenfilename( | |||
| title="选择正点原子逻辑分析仪波形文件", | |||
| filetypes=( | |||
| ("逻辑分析仪文件", "*.csv *.bin"), | |||
| ("CSV 文件", "*.csv"), | |||
| ("BIN 文件", "*.bin"), | |||
| ("所有文件", "*.*"), | |||
| ), | |||
| ) | |||
| if not path: | |||
| return | |||
| self.input_var.set(path) | |||
| self._start_load() | |||
| def _get_bin_sample_rate(self) -> float: | |||
| """读取界面中的 BIN 采样率。""" | |||
| try: | |||
| value = float(self.sample_rate_var.get().strip()) | |||
| except ValueError as exc: | |||
| raise CsvParseError("BIN 采样率必须是数字") from exc | |||
| if value <= 0: | |||
| raise CsvParseError("BIN 采样率必须大于 0") | |||
| return value * RATE_UNITS[self.sample_rate_unit_var.get().lower()] | |||
| def _start_load(self) -> None: | |||
| """校验参数并在后台读取波形文件。""" | |||
| input_name = self.input_var.get().strip() | |||
| if not input_name: | |||
| messagebox.showwarning("未选择文件", "请先选择 CSV 或 BIN 文件。") | |||
| return | |||
| path = Path(input_name) | |||
| if not path.is_file(): | |||
| messagebox.showerror("文件不存在", f"找不到文件:\n{path}") | |||
| return | |||
| try: | |||
| sample_rate = self._get_bin_sample_rate() | |||
| channel_count = int(self.bin_channel_count_var.get()) | |||
| parse_marker_times_ms(self.marker_var.get()) | |||
| except (ValueError, CsvParseError) as exc: | |||
| messagebox.showerror("参数错误", str(exc)) | |||
| return | |||
| self.load_button.configure(state="disabled") | |||
| self.status_var.set("正在读取并计算频率……") | |||
| self.file_info_var.set(f"正在读取:{path.name}") | |||
| worker = threading.Thread( | |||
| target=self._load_worker, | |||
| args=(path, sample_rate, channel_count, self.channel_var.get()), | |||
| daemon=True, | |||
| ) | |||
| worker.start() | |||
| self.after(100, self._check_load_result) | |||
| def _load_worker( | |||
| self, | |||
| path: Path, | |||
| sample_rate: float, | |||
| channel_count: int, | |||
| channel_text: str, | |||
| ) -> None: | |||
| """在线程中解析较大的 BIN 文件。""" | |||
| try: | |||
| result = load_waveform(path, sample_rate, channel_count, channel_text) | |||
| self.result_queue.put(("ok", result)) | |||
| except (OSError, CsvParseError) as exc: | |||
| self.result_queue.put(("error", str(exc))) | |||
| def _check_load_result(self) -> None: | |||
| """接收后台解析结果并生成频率曲线。""" | |||
| try: | |||
| result_type, result = self.result_queue.get_nowait() | |||
| except queue.Empty: | |||
| self.after(100, self._check_load_result) | |||
| return | |||
| self.load_button.configure(state="normal") | |||
| if result_type == "error": | |||
| self.status_var.set("读取失败") | |||
| self.file_info_var.set("波形文件读取失败") | |||
| messagebox.showerror("读取失败", str(result)) | |||
| return | |||
| data, channels = result # type: ignore[misc] | |||
| self.waveform_data = data | |||
| self.visible_channels = channels | |||
| try: | |||
| self._rebuild_frequency_curves() | |||
| except CsvParseError as exc: | |||
| self.status_var.set("频率计算失败") | |||
| messagebox.showerror("频率计算失败", str(exc)) | |||
| return | |||
| self.file_info_var.set(self._build_file_info(data)) | |||
| self.status_var.set(f"已生成 {len(self.frequency_curves)} 条频率曲线") | |||
| def _apply_plot_settings(self) -> None: | |||
| """应用测量边沿和关键时间设置。""" | |||
| if self.waveform_data is None: | |||
| return | |||
| try: | |||
| self._rebuild_frequency_curves() | |||
| except CsvParseError as exc: | |||
| messagebox.showerror("曲线设置错误", str(exc)) | |||
| def _rebuild_frequency_curves(self) -> None: | |||
| """按当前边沿设置重新计算频率轨迹。""" | |||
| edge_mode = EDGE_MODES[self.edge_var.get()] | |||
| self.marker_times = parse_marker_times_ms(self.marker_var.get()) | |||
| self.frequency_curves = build_frequency_curves( | |||
| self.visible_channels, edge_mode | |||
| ) | |||
| self.frequency_origin = min(curve.start_time for curve in self.frequency_curves) | |||
| self.view_start = self.frequency_origin | |||
| self.view_end = max(curve.end_time for curve in self.frequency_curves) | |||
| if self.view_end <= self.view_start: | |||
| self.view_end = self.view_start + 1e-6 | |||
| self._update_marker_summary() | |||
| self.cursor_var.set("光标:-") | |||
| self._schedule_redraw() | |||
| @staticmethod | |||
| def _build_file_info(data: WaveformData) -> str: | |||
| """生成文件采样信息。""" | |||
| parts = [data.source_type] | |||
| if data.sample_rate: | |||
| parts.append(f"采样率 {format_frequency(data.sample_rate)}") | |||
| if data.sample_count is not None: | |||
| parts.append(f"采样点 {data.sample_count:,}") | |||
| if data.source_type == "CSV": | |||
| parts.append(f"记录行 {data.record_count:,}") | |||
| return " | ".join(parts) | |||
| def _update_marker_summary(self) -> None: | |||
| """在工具栏显示所有关键节点的测量结果。""" | |||
| if not self.marker_times or not self.frequency_curves: | |||
| self.marker_info_var.set("关键节点:-") | |||
| return | |||
| summaries = [] | |||
| for marker in self.marker_times: | |||
| target_time = self.frequency_origin + marker | |||
| values = [] | |||
| for curve in self.frequency_curves: | |||
| if target_time > curve.end_time: | |||
| values.append(f"{curve.channel_name} 超出范围") | |||
| continue | |||
| measured_time, frequency = curve.frequency_at(target_time) | |||
| measured_ms = (measured_time - self.frequency_origin) * 1_000.0 | |||
| values.append( | |||
| f"{curve.channel_name} {format_frequency(frequency)}" | |||
| f"@{measured_ms:.3f}ms" | |||
| ) | |||
| summaries.append(f"{marker * 1000:g}ms:" + " | ".join(values)) | |||
| self.marker_info_var.set(" ".join(summaries)) | |||
| def _plot_bounds(self) -> tuple[float, float, float, float]: | |||
| """返回频率曲线绘图区边界。""" | |||
| width = max(1, self.canvas.winfo_width()) | |||
| height = max(1, self.canvas.winfo_height()) | |||
| return 92.0, 35.0, max(93.0, width - 24.0), max(36.0, height - 58.0) | |||
| def _schedule_redraw(self) -> None: | |||
| """合并连续的重绘请求。""" | |||
| if self.redraw_job is None: | |||
| self.redraw_job = self.after_idle(self._draw_frequency_chart) | |||
| def _draw_frequency_chart(self) -> None: | |||
| """绘制频率随时间变化的曲线。""" | |||
| self.redraw_job = None | |||
| self.canvas.delete("all") | |||
| if not self.frequency_curves: | |||
| self.canvas.create_text( | |||
| self.canvas.winfo_width() / 2, | |||
| self.canvas.winfo_height() / 2, | |||
| text="请选择包含脉冲的 CSV 或 BIN 文件", | |||
| fill="#6b7280", | |||
| font=("Microsoft YaHei UI", 12), | |||
| ) | |||
| return | |||
| left, top, right, bottom = self._plot_bounds() | |||
| plot_width = right - left | |||
| plot_height = bottom - top | |||
| time_span = max(self.view_end - self.view_start, 1e-15) | |||
| maximum_frequency = nice_frequency_max(self._visible_max_frequency()) | |||
| # 绘制横向频率网格和纵轴刻度。 | |||
| for tick in range(9): | |||
| ratio = tick / 8.0 | |||
| y = bottom - ratio * plot_height | |||
| frequency = ratio * maximum_frequency | |||
| self.canvas.create_line(left, y, right, y, fill="#e2e5e9", width=1) | |||
| self.canvas.create_text( | |||
| left - 9, | |||
| y, | |||
| anchor="e", | |||
| text=format_hz_tick(frequency), | |||
| fill="#4b5563", | |||
| font=("Consolas", 9), | |||
| ) | |||
| # 绘制时间网格,横轴从第一个有效脉冲边沿开始计时。 | |||
| time_scale, time_unit = choose_time_unit(time_span) | |||
| for tick in range(11): | |||
| ratio = tick / 10.0 | |||
| x = left + ratio * plot_width | |||
| time_value = self.view_start + ratio * time_span | |||
| relative_time = (time_value - self.frequency_origin) * time_scale | |||
| self.canvas.create_line(x, top, x, bottom, fill="#edf0f3", width=1) | |||
| self.canvas.create_text( | |||
| x, | |||
| bottom + 18, | |||
| text=format_tick(relative_time), | |||
| fill="#4b5563", | |||
| font=("Consolas", 9), | |||
| ) | |||
| self.canvas.create_text( | |||
| 20, | |||
| (top + bottom) / 2, | |||
| text="脉冲频率 (Hz)", | |||
| angle=90, | |||
| fill="#111827", | |||
| font=("Microsoft YaHei UI", 10), | |||
| ) | |||
| self.canvas.create_text( | |||
| (left + right) / 2, | |||
| bottom + 42, | |||
| text=f"脉冲开始后的时间 ({time_unit})", | |||
| fill="#111827", | |||
| font=("Microsoft YaHei UI", 10), | |||
| ) | |||
| for curve_number, curve in enumerate(self.frequency_curves): | |||
| color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)] | |||
| self._draw_curve( | |||
| curve, left, right, top, bottom, maximum_frequency, color | |||
| ) | |||
| self.canvas.create_line( | |||
| left + curve_number * 190, | |||
| 16, | |||
| left + 20 + curve_number * 190, | |||
| 16, | |||
| fill=color, | |||
| width=3, | |||
| ) | |||
| self.canvas.create_text( | |||
| left + 26 + curve_number * 190, | |||
| 16, | |||
| anchor="w", | |||
| text=f"{curve.channel_name}({curve.edge_name})", | |||
| fill="#1f2937", | |||
| font=("Microsoft YaHei UI", 9, "bold"), | |||
| ) | |||
| self._draw_markers(left, right, top, bottom, maximum_frequency) | |||
| self.canvas.create_line(left, top, left, bottom, fill="#4b5563", width=1) | |||
| self.canvas.create_line(left, bottom, right, bottom, fill="#4b5563", width=1) | |||
| def _visible_max_frequency(self) -> float: | |||
| """读取当前时间窗口中的最大频率。""" | |||
| maximum = 0.0 | |||
| for curve in self.frequency_curves: | |||
| start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1) | |||
| end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1) | |||
| if start < end: | |||
| maximum = max(maximum, max(curve.frequencies[start:end])) | |||
| return max(maximum, 1.0) | |||
| def _draw_curve( | |||
| self, | |||
| curve: FrequencyCurve, | |||
| left: float, | |||
| right: float, | |||
| top: float, | |||
| bottom: float, | |||
| maximum_frequency: float, | |||
| color: str, | |||
| ) -> None: | |||
| """绘制一条频率轨迹。""" | |||
| start = max(0, bisect.bisect_left(curve.times, self.view_start) - 1) | |||
| end = min(len(curve.times), bisect.bisect_right(curve.times, self.view_end) + 1) | |||
| if end - start < 2: | |||
| return | |||
| time_span = self.view_end - self.view_start | |||
| plot_width = right - left | |||
| plot_height = bottom - top | |||
| indexes = list(range(start, end)) | |||
| maximum_points = max(100, int(plot_width * 3)) | |||
| if len(indexes) > maximum_points: | |||
| indexes = decimate_curve_indexes( | |||
| curve.frequencies, start, end, maximum_points | |||
| ) | |||
| coordinates: list[float] = [] | |||
| for index in indexes: | |||
| x = left + (curve.times[index] - self.view_start) / time_span * plot_width | |||
| y = bottom - curve.frequencies[index] / maximum_frequency * plot_height | |||
| coordinates.extend((x, y)) | |||
| self.canvas.create_line(*coordinates, fill=color, width=2, smooth=False) | |||
| def _draw_markers( | |||
| self, | |||
| left: float, | |||
| right: float, | |||
| top: float, | |||
| bottom: float, | |||
| maximum_frequency: float, | |||
| ) -> None: | |||
| """标出用户设置的关键时间和对应频率。""" | |||
| time_span = self.view_end - self.view_start | |||
| plot_height = bottom - top | |||
| for marker in self.marker_times: | |||
| target_time = self.frequency_origin + marker | |||
| if not self.view_start <= target_time <= self.view_end: | |||
| continue | |||
| x = left + (target_time - self.view_start) / time_span * (right - left) | |||
| self.canvas.create_line( | |||
| x, top, x, bottom, fill="#d97706", width=1, dash=(5, 4) | |||
| ) | |||
| self.canvas.create_text( | |||
| x + 5, | |||
| top + 4, | |||
| anchor="nw", | |||
| text=f"{marker * 1000:g} ms", | |||
| fill="#92400e", | |||
| font=("Microsoft YaHei UI", 9, "bold"), | |||
| ) | |||
| for curve_number, curve in enumerate(self.frequency_curves): | |||
| if target_time > curve.end_time: | |||
| continue | |||
| measured_time, frequency = curve.frequency_at(target_time) | |||
| point_x = left + (measured_time - self.view_start) / time_span * (right - left) | |||
| point_y = bottom - frequency / maximum_frequency * plot_height | |||
| color = CHANNEL_COLORS[curve.channel_index % len(CHANNEL_COLORS)] | |||
| self.canvas.create_oval( | |||
| point_x - 4, | |||
| point_y - 4, | |||
| point_x + 4, | |||
| point_y + 4, | |||
| fill="#ffffff", | |||
| outline=color, | |||
| width=2, | |||
| ) | |||
| self.canvas.create_text( | |||
| point_x + 7, | |||
| point_y - 7 - curve_number * 17, | |||
| anchor="sw", | |||
| text=format_frequency(frequency), | |||
| fill=color, | |||
| font=("Microsoft YaHei UI", 9, "bold"), | |||
| ) | |||
| def _full_time_range(self) -> tuple[float, float]: | |||
| """返回所有频率曲线的完整时间范围。""" | |||
| start = self.frequency_origin | |||
| end = max(curve.end_time for curve in self.frequency_curves) | |||
| return start, max(end, start + 1e-15) | |||
| def _fit_view(self) -> None: | |||
| """恢复频率曲线的完整时间范围。""" | |||
| if not self.frequency_curves: | |||
| return | |||
| self.view_start, self.view_end = self._full_time_range() | |||
| self._schedule_redraw() | |||
| def _zoom_view(self, factor: float, anchor_x: float | None = None) -> None: | |||
| """以鼠标位置或窗口中心为基准缩放时间轴。""" | |||
| if not self.frequency_curves: | |||
| return | |||
| left, _top, right, _bottom = self._plot_bounds() | |||
| ratio = 0.5 | |||
| if anchor_x is not None and right > left: | |||
| ratio = min(1.0, max(0.0, (anchor_x - left) / (right - left))) | |||
| old_span = self.view_end - self.view_start | |||
| full_start, full_end = self._full_time_range() | |||
| full_span = full_end - full_start | |||
| minimum_span = max(full_span / 1_000_000_000.0, 1e-12) | |||
| new_span = min(full_span, max(minimum_span, old_span * factor)) | |||
| anchor_time = self.view_start + old_span * ratio | |||
| new_start = anchor_time - new_span * ratio | |||
| self._set_view_range(new_start, new_start + new_span) | |||
| def _set_view_range(self, start: float, end: float) -> None: | |||
| """限制时间窗口不能移出频率曲线范围。""" | |||
| if not self.frequency_curves: | |||
| return | |||
| full_start, full_end = self._full_time_range() | |||
| span = min(end - start, full_end - full_start) | |||
| if start < full_start: | |||
| start = full_start | |||
| if start + span > full_end: | |||
| start = full_end - span | |||
| self.view_start = start | |||
| self.view_end = start + span | |||
| self._schedule_redraw() | |||
| def _on_mouse_wheel(self, event: tk.Event) -> None: | |||
| """使用鼠标滚轮缩放时间轴。""" | |||
| factor = 0.75 if event.delta > 0 else 1.35 | |||
| self._zoom_view(factor, float(event.x)) | |||
| def _start_drag(self, event: tk.Event) -> None: | |||
| """记录拖动开始位置。""" | |||
| if self.frequency_curves: | |||
| self.drag_state = (event.x, self.view_start, self.view_end) | |||
| def _drag_view(self, event: tk.Event) -> None: | |||
| """按鼠标水平位移平移时间轴。""" | |||
| if self.drag_state is None: | |||
| return | |||
| start_x, original_start, original_end = self.drag_state | |||
| left, _top, right, _bottom = self._plot_bounds() | |||
| if right <= left: | |||
| return | |||
| time_shift = -(event.x - start_x) / (right - left) * ( | |||
| original_end - original_start | |||
| ) | |||
| self._set_view_range(original_start + time_shift, original_end + time_shift) | |||
| def _end_drag(self, _event: tk.Event) -> None: | |||
| """结束鼠标拖动。""" | |||
| self.drag_state = None | |||
| def _show_cursor(self, event: tk.Event) -> None: | |||
| """显示鼠标时间位置和各通道的实测频率。""" | |||
| if not self.frequency_curves: | |||
| return | |||
| left, top, right, bottom = self._plot_bounds() | |||
| if not left <= event.x <= right or not top <= event.y <= bottom: | |||
| self._hide_cursor(event) | |||
| return | |||
| ratio = (event.x - left) / (right - left) | |||
| time_value = self.view_start + ratio * (self.view_end - self.view_start) | |||
| relative_time = time_value - self.frequency_origin | |||
| values = [] | |||
| for curve in self.frequency_curves: | |||
| _measured_time, frequency = curve.frequency_at(time_value) | |||
| values.append(f"{curve.channel_name} {format_frequency(frequency)}") | |||
| self.canvas.delete("cursor") | |||
| self.canvas.create_line( | |||
| event.x, | |||
| top, | |||
| event.x, | |||
| bottom, | |||
| fill="#374151", | |||
| dash=(3, 3), | |||
| tags="cursor", | |||
| ) | |||
| self.cursor_var.set( | |||
| f"光标 {format_duration(relative_time)}:" + " | ".join(values) | |||
| ) | |||
| def _hide_cursor(self, _event: tk.Event | None = None) -> None: | |||
| """清除鼠标光标线。""" | |||
| self.canvas.delete("cursor") | |||
| self.cursor_var.set("光标:-") | |||
| def nice_frequency_max(value: float) -> float: | |||
| """把纵轴最大频率向上取为 1、2、5、10 的整倍数。""" | |||
| if value <= 0: | |||
| return 1.0 | |||
| exponent = 10 ** math.floor(math.log10(value)) | |||
| fraction = value / exponent | |||
| for candidate in (1.0, 2.0, 5.0, 10.0): | |||
| if fraction <= candidate: | |||
| return candidate * exponent | |||
| return 10.0 * exponent | |||
| def decimate_curve_indexes( | |||
| frequencies: list[float], start: int, end: int, maximum_points: int | |||
| ) -> list[int]: | |||
| """抽取曲线点,同时保留每个时间桶内的最高值和最低值。""" | |||
| point_count = end - start | |||
| if point_count <= maximum_points: | |||
| return list(range(start, end)) | |||
| bucket_count = max(1, maximum_points // 2) | |||
| indexes = [start] | |||
| for bucket in range(bucket_count): | |||
| bucket_start = start + int(bucket * point_count / bucket_count) | |||
| bucket_end = start + int((bucket + 1) * point_count / bucket_count) | |||
| bucket_end = min(end, max(bucket_start + 1, bucket_end)) | |||
| minimum_index = min( | |||
| range(bucket_start, bucket_end), key=frequencies.__getitem__ | |||
| ) | |||
| maximum_index = max( | |||
| range(bucket_start, bucket_end), key=frequencies.__getitem__ | |||
| ) | |||
| indexes.extend(sorted((minimum_index, maximum_index))) | |||
| indexes.append(end - 1) | |||
| return sorted(set(indexes)) | |||
| def choose_time_unit(span: float) -> tuple[float, str]: | |||
| """根据当前时间范围选择合适的横轴单位。""" | |||
| if span >= 1.0: | |||
| return 1.0, "s" | |||
| if span >= 0.001: | |||
| return 1_000.0, "ms" | |||
| if span >= 0.000001: | |||
| return 1_000_000.0, "us" | |||
| return 1_000_000_000.0, "ns" | |||
| def format_tick(value: float) -> str: | |||
| """格式化时间轴刻度。""" | |||
| absolute = abs(value) | |||
| if absolute >= 1000: | |||
| return f"{value:.0f}" | |||
| if absolute >= 10: | |||
| return f"{value:.2f}" | |||
| return f"{value:.3f}" | |||
| def format_hz_tick(frequency: float) -> str: | |||
| """以 Hz 为单位格式化纵轴刻度。""" | |||
| if frequency >= 100: | |||
| return f"{frequency:.0f}" | |||
| if frequency >= 10: | |||
| return f"{frequency:.1f}" | |||
| return f"{frequency:.2f}" | |||
| def format_frequency(frequency: float) -> str: | |||
| """使用 Hz、kHz、MHz 或 GHz 显示测量结果。""" | |||
| if frequency >= 1_000_000_000: | |||
| return f"{frequency / 1_000_000_000:.4g} GHz" | |||
| if frequency >= 1_000_000: | |||
| return f"{frequency / 1_000_000:.4g} MHz" | |||
| if frequency >= 1_000: | |||
| return f"{frequency / 1_000:.4g} kHz" | |||
| return f"{frequency:.4g} Hz" | |||
| def format_duration(duration: float) -> str: | |||
| """使用合适单位显示时间长度。""" | |||
| if duration >= 1.0: | |||
| return f"{duration:.6g} s" | |||
| if duration >= 0.001: | |||
| return f"{duration * 1_000:.6g} ms" | |||
| if duration >= 0.000001: | |||
| return f"{duration * 1_000_000:.6g} us" | |||
| return f"{duration * 1_000_000_000:.6g} ns" | |||
| def main() -> None: | |||
| """启动脉冲频率轨迹查看器。""" | |||
| app = LogicWaveformApp() | |||
| app.mainloop() | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -0,0 +1,216 @@ | |||
| #!/usr/bin/env python3 | |||
| """Interactive console for technicians testing the PLSR firmware.""" | |||
| from __future__ import annotations | |||
| import time | |||
| import plsr_test | |||
| def ask_int(prompt: str, default: int, minimum: int, maximum: int) -> int: | |||
| while True: | |||
| raw = input(f"{prompt} [{default}]: ").strip() | |||
| if not raw: | |||
| return default | |||
| try: | |||
| value = int(raw) | |||
| except ValueError: | |||
| print("请输入整数。") | |||
| continue | |||
| if minimum <= value <= maximum: | |||
| return value | |||
| print(f"允许范围:{minimum}..{maximum}") | |||
| def ask_yes(prompt: str) -> bool: | |||
| return input(f"{prompt} [y/N]: ").strip().lower() in ("y", "yes") | |||
| def choose_port() -> str: | |||
| if plsr_test.list_ports is None: | |||
| raise plsr_test.PlsrError("缺少 pyserial,请重新运行启动批处理。") | |||
| ports = sorted(plsr_test.list_ports.comports(), key=lambda item: item.device) | |||
| if ports: | |||
| print("\n检测到的串口:") | |||
| for index, item in enumerate(ports, 1): | |||
| print(f" {index}. {item.device:<8} {item.description}") | |||
| devices = [item.device for item in ports] | |||
| default = "COM9" if "COM9" in devices else (devices[0] if devices else "COM9") | |||
| selected = input(f"请输入测试串口 [{default}]: ").strip().upper() | |||
| return selected or default | |||
| def confirm_motion() -> bool: | |||
| print("\n安全确认:断开机械/伺服负载,仅连接测试灯或示波器。") | |||
| return ask_yes("已满足上述条件,允许输出脉冲吗?") | |||
| def show_menu(port: str, protocol: str) -> None: | |||
| print( | |||
| f""" | |||
| ================ PLSR Python 测试工具 ================ | |||
| 当前串口:{port} 参数:115200 / 8E1 / 站号1 / 映射:{protocol} | |||
| 1. 读取一次状态 | |||
| 2. 持续监视状态 | |||
| 3. 配置并保存脉冲点/方向点 | |||
| 4. 正向低速灯测 | |||
| 5. 反向低速灯测 | |||
| 6. 正反向自动测试 | |||
| 7. 受控停止 | |||
| 8. 立即停止 | |||
| 9. 复位 | |||
| 10. 清零累计脉冲 | |||
| 11. 显示原始通信帧开关 | |||
| 12. 更换串口 | |||
| 0. 退出 | |||
| ====================================================== | |||
| """ | |||
| ) | |||
| def run_motion(client: plsr_test.ModbusRtuClient, direction: int) -> None: | |||
| if not confirm_motion(): | |||
| print("已取消。") | |||
| return | |||
| if client.protocol == plsr_test.MAP_SPEC: | |||
| pulses = ask_int("脉冲数(可负,0 不允许)", 10, -2147483648, 2147483647) | |||
| while pulses == 0: | |||
| print("脉冲数不能为 0。") | |||
| pulses = ask_int("脉冲数(可负,0 不允许)", 10, -2147483648, 2147483647) | |||
| frequency = ask_int("频率 Hz(1..100000,肉眼观察建议 1..5)", 2, 1, 100000) | |||
| else: | |||
| pulses = ask_int("脉冲数", 10, 1, 0xFFFFFFFF) | |||
| frequency = ask_int("频率 Hz(肉眼观察建议 1..5)", 2, 1, 1000) | |||
| client.start_motion(pulses, frequency, direction) | |||
| result = plsr_test.wait_for_terminal(client, max(10.0, pulses / frequency + 5.0)) | |||
| if result.state == 5: | |||
| print("PASS:运动正常完成。") | |||
| else: | |||
| print(f"FAIL:运动结束状态为 {result.state_name}。") | |||
| def auto_test(client: plsr_test.ModbusRtuClient) -> None: | |||
| if not confirm_motion(): | |||
| print("已取消。") | |||
| return | |||
| if client.protocol == plsr_test.MAP_SPEC: | |||
| pulse_output = ask_int("脉冲输出 Y0..Y3", 0, 0, 3) | |||
| direction_output = ask_int("方向输出 Y12..Y15(填 0..3)", 0, 0, 3) | |||
| else: | |||
| pulse_output = ask_int("脉冲输出 Q 点", 4, 0, 7) | |||
| direction_output = ask_int("方向输出 Q 点", 1, 0, 7) | |||
| if pulse_output == direction_output: | |||
| print("脉冲点和方向点不能相同。") | |||
| return | |||
| pulses = ask_int("每个方向的脉冲数", 6, 1, 10000) | |||
| frequency = ask_int("频率 Hz", 2, 1, 100000 if client.protocol == plsr_test.MAP_SPEC else 1000) | |||
| save = False if client.protocol == plsr_test.MAP_SPEC else ask_yes("将 Q 点配置保存到 Flash 吗?") | |||
| plsr_test.print_snapshot(client.configure_outputs(pulse_output, direction_output, save)) | |||
| client.command(plsr_test.COMMAND_CLEAR_COUNT) | |||
| print("\n开始正向测试,请观察脉冲灯。") | |||
| client.start_motion(pulses, frequency, 0) | |||
| forward = plsr_test.wait_for_terminal(client, max(10.0, pulses / frequency + 5.0)) | |||
| if forward.state != 5 or forward.total_pulses != pulses: | |||
| raise plsr_test.PlsrError( | |||
| f"正向失败:state={forward.state_name}, total={forward.total_pulses}, expected={pulses}" | |||
| ) | |||
| print("\n开始反向测试,脉冲灯应继续闪烁,方向点电平应改变。") | |||
| client.start_motion(pulses, frequency, 1) | |||
| reverse = plsr_test.wait_for_terminal(client, max(10.0, pulses / frequency + 5.0)) | |||
| expected = pulses * 2 | |||
| if reverse.state != 5 or reverse.total_pulses != expected: | |||
| raise plsr_test.PlsrError( | |||
| f"反向失败:state={reverse.state_name}, total={reverse.total_pulses}, expected={expected}" | |||
| ) | |||
| print(f"PASS:正反向自动测试完成,累计脉冲={reverse.total_pulses}。") | |||
| def run_menu(port: str, protocol: str) -> str | None: | |||
| verbose = False | |||
| with plsr_test.ModbusRtuClient(port, timeout=1.0, retries=2, verbose=verbose, protocol=protocol) as client: | |||
| print("\n串口连接成功。") | |||
| try: | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| except plsr_test.PlsrError as exc: | |||
| print(f"首次状态读取失败:{exc}") | |||
| while True: | |||
| show_menu(port, protocol) | |||
| choice = input("请选择操作: ").strip() | |||
| try: | |||
| if choice == "0": | |||
| return None | |||
| if choice == "1": | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| elif choice == "2": | |||
| print("持续监视中,按 Ctrl+C 返回菜单。") | |||
| try: | |||
| while True: | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| time.sleep(0.5) | |||
| except KeyboardInterrupt: | |||
| print("\n已停止监视。") | |||
| elif choice == "3": | |||
| if protocol == plsr_test.MAP_SPEC: | |||
| pulse = ask_int("脉冲输出 Y0..Y3", 0, 0, 3) | |||
| direction = ask_int("方向输出 Y12..Y15(填 0..3)", 0, 0, 3) | |||
| else: | |||
| pulse = ask_int("脉冲输出 Q 点", 4, 0, 7) | |||
| direction = ask_int("方向输出 Q 点", 1, 0, 7) | |||
| result = client.configure_outputs(pulse, direction, save=False) | |||
| plsr_test.print_snapshot(result) | |||
| print("配置已写入设备。需求版是否掉电保存由固件保存策略决定。") | |||
| elif choice == "4": | |||
| run_motion(client, 0) | |||
| elif choice == "5": | |||
| run_motion(client, 1) | |||
| elif choice == "6": | |||
| auto_test(client) | |||
| elif choice == "7": | |||
| client.command(plsr_test.COMMAND_STOP) | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| elif choice == "8": | |||
| client.command(plsr_test.COMMAND_IMMEDIATE_STOP) | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| elif choice == "9": | |||
| client.command(plsr_test.COMMAND_RESET) | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| elif choice == "10": | |||
| client.command(plsr_test.COMMAND_CLEAR_COUNT) | |||
| plsr_test.print_snapshot(client.read_snapshot()) | |||
| elif choice == "11": | |||
| verbose = not verbose | |||
| client.verbose = verbose | |||
| print(f"原始帧显示:{'开启' if verbose else '关闭'}") | |||
| elif choice == "12": | |||
| return choose_port() | |||
| else: | |||
| print("无效选项。") | |||
| except (OSError, ValueError, plsr_test.PlsrError) as exc: | |||
| print(f"操作失败:{exc}") | |||
| def main() -> int: | |||
| print("PLSR Python 测试工具") | |||
| protocol = input("协议映射 spec(参数表-2026)/ legacy(旧固件) [spec]: ").strip().lower() or plsr_test.MAP_SPEC | |||
| if protocol not in (plsr_test.MAP_SPEC, plsr_test.MAP_LEGACY): | |||
| print("无效映射,使用 spec。") | |||
| protocol = plsr_test.MAP_SPEC | |||
| port = choose_port() | |||
| while port: | |||
| try: | |||
| port = run_menu(port, protocol) | |||
| except (OSError, plsr_test.PlsrError) as exc: | |||
| print(f"\n无法使用 {port}:{exc}") | |||
| if not ask_yes("重新选择串口吗?"): | |||
| return 1 | |||
| port = choose_port() | |||
| print("测试工具已退出。") | |||
| return 0 | |||
| if __name__ == "__main__": | |||
| raise SystemExit(main()) | |||
| @@ -0,0 +1,741 @@ | |||
| #!/usr/bin/env python3 | |||
| """PLSR Modbus RTU test client. | |||
| The ``spec`` map follows ``document/参数表-2026.xlsx``. ``legacy`` keeps | |||
| compatibility with the first single-segment firmware image in this project. | |||
| """ | |||
| from __future__ import annotations | |||
| import argparse | |||
| import sys | |||
| import time | |||
| from dataclasses import dataclass, field | |||
| from typing import Optional, Sequence | |||
| try: | |||
| import serial | |||
| from serial.tools import list_ports | |||
| except ImportError: | |||
| serial = None | |||
| list_ports = None | |||
| SLAVE_ADDRESS = 1 | |||
| DEFAULT_BAUD_RATE = 115200 | |||
| MAP_SPEC = "spec" | |||
| MAP_LEGACY = "legacy" | |||
| # Requirement-map addresses. These are holding-register offsets, not 4xxxx | |||
| # display addresses. A 32-bit value uses high word first, low word second. | |||
| REG_CFG_PULSE_OUTPUT = 0x1000 | |||
| REG_CFG_DIRECTION_OUTPUT = 0x1001 | |||
| REG_CFG_WAIT_INPUT = 0x1002 | |||
| REG_CFG_EXT_INPUT = 0x1003 | |||
| REG_CFG_SEND_MODE = 0x1004 | |||
| REG_CFG_DIRECTION_DELAY_MS = 0x1005 | |||
| REG_CFG_DIRECTION_LOGIC = 0x1006 | |||
| REG_CFG_ACCEL_MODE = 0x1007 | |||
| REG_CFG_RUN_MODE = 0x1008 | |||
| REG_CFG_SEGMENT_COUNT = 0x1009 | |||
| REG_CFG_START_SEGMENT = 0x100A | |||
| REG_CFG_DEFAULT_SPEED = 0x100B | |||
| REG_CFG_START_SPEED = 0x100D | |||
| REG_CFG_END_SPEED = 0x1010 | |||
| REG_CFG_ACCEL_TIME_MS = 0x1012 | |||
| REG_CFG_DECEL_TIME_MS = 0x1013 | |||
| REG_SEGMENT_BASE = 0x1100 | |||
| REG_SEGMENT_STRIDE = 0x10 | |||
| REG_MONITOR_TOTAL_PULSES = 0x2000 | |||
| REG_MONITOR_FREQUENCY = 0x2002 | |||
| REG_MONITOR_STATE = 0x2004 | |||
| REG_MONITOR_SEGMENT = 0x2005 | |||
| REG_MONITOR_ERROR = 0x2006 | |||
| REG_MONITOR_STOP_REASON = 0x2007 | |||
| REG_CONTROL = 0x3000 | |||
| # Legacy zero-based map used by the current minimal firmware image. | |||
| REG_STATE = 0 | |||
| REG_FLAGS = 1 | |||
| REG_CURRENT_FREQUENCY = 3 | |||
| REG_STOP_REASON = 5 | |||
| REG_LAST_ERROR = 6 | |||
| REG_TOTAL_PULSES_HIGH = 10 | |||
| REG_TOTAL_PULSES_LOW = 11 | |||
| REG_COMMAND = 100 | |||
| REG_PULSES_HIGH = 102 | |||
| REG_PULSES_LOW = 103 | |||
| REG_FREQUENCY = 104 | |||
| REG_DIRECTION = 105 | |||
| REG_PULSE_OUTPUT = 106 | |||
| REG_DIRECTION_OUTPUT = 107 | |||
| COMMAND_START = 1 | |||
| COMMAND_STOP = 2 | |||
| COMMAND_RESET = 3 | |||
| COMMAND_CLEAR_COUNT = 4 | |||
| COMMAND_IMMEDIATE_STOP = 5 | |||
| COMMAND_SAVE_CONFIG = 6 | |||
| COMMAND_RESTART = 7 | |||
| CONTROL_START = 1 << 0 | |||
| CONTROL_STOP = 1 << 1 | |||
| CONTROL_CLEAR_COUNT = 1 << 2 | |||
| CONTROL_RESTART = 1 << 3 | |||
| CONTROL_RESET = 1 << 4 | |||
| CONTROL_IMMEDIATE_STOP = 1 << 5 | |||
| STATE_NAMES = { | |||
| 0: "Idle", | |||
| 1: "Armed", | |||
| 2: "Busy", | |||
| 3: "Wait", | |||
| 4: "Decelerating", | |||
| 5: "Done", | |||
| 6: "Stopped", | |||
| 7: "Error", | |||
| } | |||
| ERROR_NAMES = { | |||
| 0: "OK", | |||
| 1: "Invalid argument", | |||
| 2: "Busy", | |||
| 3: "Resource", | |||
| 4: "State", | |||
| 5: "Wait timeout", | |||
| 6: "Path range", | |||
| 7: "Loop limit", | |||
| 8: "External stop", | |||
| 9: "Config CRC", | |||
| 10: "Communication", | |||
| 11: "Timer", | |||
| } | |||
| WAIT_CONDITION_NAMES = { | |||
| 0: "WAIT time", | |||
| 1: "WAIT signal", | |||
| 2: "ACT time", | |||
| 3: "EXT signal", | |||
| 4: "EXT signal or pulse done", | |||
| } | |||
| MANDATORY_TEST_ITEMS = ( | |||
| ("M01", "Y0..Y3 pulse output selection", "automatic register round-trip + oscilloscope"), | |||
| ("M02", "Y12..Y15 direction output selection and logic", "automatic register round-trip + level observation"), | |||
| ("M03", "positive/negative signed pulse count", "automatic motion/count + direction observation"), | |||
| ("M04", "1..100000 Hz and online frequency update", "register boundary + oscilloscope/frequency counter"), | |||
| ("M05", "complete/follow-up send mode", "state transition observation"), | |||
| ("M06", "relative/absolute mode including equal-position no-op", "motion/count observation"), | |||
| ("M07", "linear/S-curve/sine acceleration", "configuration round-trip + oscilloscope capture"), | |||
| ("M08", "start/default/end speed and acceleration/deceleration time", "configuration round-trip + waveform timing"), | |||
| ("M09", "1..10 segments and selectable start segment", "10-segment round-trip + execution sequence"), | |||
| ("M10", "WAIT time and WAIT signal", "timing + manual X4/X5 trigger"), | |||
| ("M11", "ACT time and EXT signal", "timing + manual X4/X5 trigger"), | |||
| ("M12", "EXT signal or pulse completion", "manual EXT trigger and natural completion branches"), | |||
| ("M13", "jump=0/default next/end and explicit jump", "current-segment monitor sequence"), | |||
| ("M14", "cumulative signed count and communication clear", "automatic monitor/control check"), | |||
| ("M15", "stop, state, current segment and error diagnostics", "automatic state/error monitor check"), | |||
| ("M16", "power-loss save/load and power-up parameter restore", "manual power-cycle and readback"), | |||
| ) | |||
| def mandatory_test_checklist() -> str: | |||
| return "\n".join(f"{code} {name} [{method}]" for code, name, method in MANDATORY_TEST_ITEMS) | |||
| class PlsrError(RuntimeError): | |||
| pass | |||
| class CommunicationError(PlsrError): | |||
| pass | |||
| class ModbusException(PlsrError): | |||
| def __init__(self, function: int, code: int) -> None: | |||
| super().__init__(f"Modbus exception: function=0x{function:02X}, code=0x{code:02X}") | |||
| self.function = function | |||
| self.code = code | |||
| def crc16(data: bytes) -> int: | |||
| value = 0xFFFF | |||
| for byte in data: | |||
| value ^= byte | |||
| for _ in range(8): | |||
| value = ((value >> 1) ^ 0xA001) if value & 1 else value >> 1 | |||
| return value | |||
| def with_crc(body: bytes) -> bytes: | |||
| value = crc16(body) | |||
| return body + bytes((value & 0xFF, value >> 8)) | |||
| def read_holding_request(slave: int, start: int, count: int) -> bytes: | |||
| return with_crc(bytes((slave, 0x03)) + start.to_bytes(2, "big") + count.to_bytes(2, "big")) | |||
| def write_register_request(slave: int, address: int, value: int) -> bytes: | |||
| return with_crc(bytes((slave, 0x06)) + address.to_bytes(2, "big") + value.to_bytes(2, "big")) | |||
| def write_registers_request(slave: int, address: int, values: Sequence[int]) -> bytes: | |||
| if not 1 <= len(values) <= 123: | |||
| raise ValueError("multiple-register write count must be 1..123") | |||
| payload = b"".join(int(v).to_bytes(2, "big") for v in values) | |||
| body = bytes((slave, 0x10)) + address.to_bytes(2, "big") + len(values).to_bytes(2, "big") | |||
| return with_crc(body + bytes((len(payload),)) + payload) | |||
| def _frame_length(buffer: bytearray, offset: int, expected_function: int) -> Optional[int]: | |||
| if len(buffer) - offset < 2: | |||
| return None | |||
| function = buffer[offset + 1] | |||
| if function == (expected_function | 0x80): | |||
| return 5 | |||
| if function != expected_function: | |||
| return -1 | |||
| if function in (0x01, 0x03): | |||
| if len(buffer) - offset < 3: | |||
| return None | |||
| return 5 + buffer[offset + 2] | |||
| if function in (0x05, 0x06, 0x0F, 0x10): | |||
| return 8 | |||
| return -1 | |||
| def extract_response(buffer: bytearray, slave: int, expected_function: int) -> Optional[bytes]: | |||
| """Find a valid response while tolerating stale bytes and fragmentation.""" | |||
| for offset in range(max(0, len(buffer) - 260), len(buffer)): | |||
| if buffer[offset] != slave: | |||
| continue | |||
| length = _frame_length(buffer, offset, expected_function) | |||
| if length is None: | |||
| continue | |||
| if length < 0 or len(buffer) - offset < length: | |||
| continue | |||
| candidate = bytes(buffer[offset : offset + length]) | |||
| if int.from_bytes(candidate[-2:], "little") == crc16(candidate[:-2]): | |||
| del buffer[: offset + length] | |||
| return candidate | |||
| if len(buffer) > 512: | |||
| del buffer[:-260] | |||
| return None | |||
| def encode_u32(value: int) -> tuple[int, int]: | |||
| if not 0 <= value <= 0xFFFFFFFF: | |||
| raise ValueError("unsigned 32-bit value must be 0..4294967295") | |||
| return (value >> 16) & 0xFFFF, value & 0xFFFF | |||
| def encode_i32(value: int) -> tuple[int, int]: | |||
| if not -0x80000000 <= value <= 0x7FFFFFFF: | |||
| raise ValueError("signed 32-bit value must be -2147483648..2147483647") | |||
| return encode_u32(value & 0xFFFFFFFF) | |||
| def decode_u32(high: int, low: int) -> int: | |||
| return ((high & 0xFFFF) << 16) | (low & 0xFFFF) | |||
| def decode_i32(high: int, low: int) -> int: | |||
| value = decode_u32(high, low) | |||
| return value - 0x100000000 if value & 0x80000000 else value | |||
| def validate_frequency(value: int, *, allow_zero: bool = False) -> None: | |||
| """Validate a frequency value used by the PLSR protocol.""" | |||
| minimum = 0 if allow_zero else 1 | |||
| if not minimum <= value <= 100_000: | |||
| lower = "0" if allow_zero else "1" | |||
| raise ValueError(f"frequency must be {lower}..100000 Hz") | |||
| @dataclass | |||
| class SegmentParameters: | |||
| frequency_hz: int = 1000 | |||
| pulses: int = 0 | |||
| wait_condition: int = 0 | |||
| wait_time_ms: int = 0 | |||
| act_time_ms: int = 0 | |||
| jump_segment: int = 0 | |||
| def validate(self, index: int, total_segments: int = 10) -> None: | |||
| validate_frequency(self.frequency_hz, allow_zero=True) | |||
| if not -0x80000000 <= self.pulses <= 0x7FFFFFFF: | |||
| raise ValueError(f"segment {index}: pulses must be signed 32-bit") | |||
| if self.wait_condition not in WAIT_CONDITION_NAMES: | |||
| raise ValueError(f"segment {index}: wait condition must be 0..4") | |||
| if not 0 <= self.wait_time_ms <= 0xFFFF or not 0 <= self.act_time_ms <= 0xFFFF: | |||
| raise ValueError(f"segment {index}: wait/act time must be 0..65535 ms") | |||
| if not 0 <= self.jump_segment <= total_segments: | |||
| raise ValueError(f"segment {index}: jump segment must be 0..{total_segments}") | |||
| @dataclass | |||
| class PlsrConfiguration: | |||
| pulse_output: int = 0 | |||
| direction_output: int = 0 | |||
| wait_input: int = 0 | |||
| ext_input: int = 0 | |||
| send_mode: int = 0 | |||
| direction_delay_ms: int = 0 | |||
| direction_logic: int = 0 | |||
| accel_mode: int = 0 | |||
| run_mode: int = 0 | |||
| segment_count: int = 1 | |||
| start_segment: int = 1 | |||
| default_speed_hz: int = 1000 | |||
| start_speed_hz: int = 1 | |||
| end_speed_hz: int = 1 | |||
| accel_time_ms: int = 0 | |||
| decel_time_ms: int = 0 | |||
| segments: list[SegmentParameters] = field(default_factory=lambda: [SegmentParameters() for _ in range(10)]) | |||
| def validate(self) -> None: | |||
| if not 0 <= self.pulse_output <= 3: | |||
| raise ValueError("pulse output must be Y0..Y3 (0..3)") | |||
| if not 0 <= self.direction_output <= 3: | |||
| raise ValueError("direction output must be Y12..Y15 (0..3)") | |||
| if not 0 <= self.wait_input <= 1 or not 0 <= self.ext_input <= 1: | |||
| raise ValueError("WAIT/EXT input must be X4 or X5 (0..1)") | |||
| if self.send_mode not in (0, 1): | |||
| raise ValueError("send mode must be 0 (complete) or 1 (follow-up)") | |||
| if self.direction_logic not in (0, 1): | |||
| raise ValueError("direction logic must be 0 (positive) or 1 (negative)") | |||
| if self.accel_mode not in (0, 1, 2): | |||
| raise ValueError("acceleration mode must be 0, 1 or 2") | |||
| if self.run_mode not in (0, 1): | |||
| raise ValueError("run mode must be 0 (relative) or 1 (absolute)") | |||
| if not 0 <= self.direction_delay_ms <= 0xFFFF: | |||
| raise ValueError("direction delay must be 0..65535 ms") | |||
| if not 1 <= self.segment_count <= 10: | |||
| raise ValueError("segment count must be 1..10") | |||
| if not 1 <= self.start_segment <= self.segment_count: | |||
| raise ValueError("start segment must be within 1..segment count") | |||
| validate_frequency(self.default_speed_hz) | |||
| validate_frequency(self.start_speed_hz, allow_zero=True) | |||
| validate_frequency(self.end_speed_hz, allow_zero=True) | |||
| if not 0 <= self.accel_time_ms <= 0xFFFF or not 0 <= self.decel_time_ms <= 0xFFFF: | |||
| raise ValueError("acceleration/deceleration time must be 0..65535 ms") | |||
| if len(self.segments) < 10: | |||
| raise ValueError("configuration must contain 10 segment slots") | |||
| for index in range(self.segment_count): | |||
| self.segments[index].validate(index + 1, self.segment_count) | |||
| @dataclass(frozen=True) | |||
| class Snapshot: | |||
| # First eight fields retain the legacy GUI/test API. | |||
| state: int | |||
| flags: int | |||
| current_frequency: int | |||
| stop_reason: int | |||
| last_error: int | |||
| total_pulses: int | |||
| pulse_output: int | |||
| direction_output: int | |||
| current_segment: int = 0 | |||
| protocol: str = MAP_LEGACY | |||
| @property | |||
| def state_name(self) -> str: | |||
| return STATE_NAMES.get(self.state, f"Unknown({self.state})") | |||
| @property | |||
| def error_name(self) -> str: | |||
| return ERROR_NAMES.get(self.last_error, f"Unknown({self.last_error})") | |||
| class ModbusRtuClient: | |||
| def __init__(self, port: str, baud_rate: int = DEFAULT_BAUD_RATE, slave: int = SLAVE_ADDRESS, | |||
| timeout: float = 0.6, retries: int = 2, verbose: bool = False, | |||
| protocol: str = MAP_LEGACY) -> None: | |||
| if protocol not in (MAP_SPEC, MAP_LEGACY): | |||
| raise ValueError("protocol must be 'spec' or 'legacy'") | |||
| self.port, self.baud_rate, self.slave = port, baud_rate, slave | |||
| self.timeout, self.retries, self.verbose, self.protocol = timeout, retries, verbose, protocol | |||
| self._serial = None | |||
| def __enter__(self) -> "ModbusRtuClient": | |||
| self.open() | |||
| return self | |||
| def __exit__(self, exc_type, exc_value, traceback) -> None: | |||
| self.close() | |||
| def open(self) -> None: | |||
| if serial is None: | |||
| raise PlsrError("pyserial is not installed; run: python -m pip install -r requirements.txt") | |||
| self._serial = serial.Serial(port=self.port, baudrate=self.baud_rate, bytesize=serial.EIGHTBITS, | |||
| parity=serial.PARITY_EVEN, stopbits=serial.STOPBITS_ONE, | |||
| timeout=0.05, write_timeout=self.timeout) | |||
| self._serial.reset_input_buffer() | |||
| self._serial.reset_output_buffer() | |||
| def close(self) -> None: | |||
| if self._serial is not None: | |||
| self._serial.close() | |||
| self._serial = None | |||
| def _log_frame(self, prefix: str, frame: bytes) -> None: | |||
| if self.verbose: | |||
| print(f"{time.strftime('%H:%M:%S')} {prefix} {frame.hex(' ').upper()}") | |||
| def _receive(self, expected_function: int) -> bytes: | |||
| if self._serial is None: | |||
| raise CommunicationError("serial port is not open") | |||
| deadline, buffer = time.monotonic() + self.timeout, bytearray() | |||
| while time.monotonic() < deadline: | |||
| waiting = self._serial.in_waiting | |||
| chunk = self._serial.read(waiting if waiting > 0 else 1) | |||
| if chunk: | |||
| buffer.extend(chunk) | |||
| response = extract_response(buffer, self.slave, expected_function) | |||
| if response is not None: | |||
| self._log_frame("RX", response) | |||
| if response[1] == (expected_function | 0x80): | |||
| raise ModbusException(expected_function, response[2]) | |||
| return response | |||
| tail = bytes(buffer[-32:]).hex(" ").upper() if buffer else "<none>" | |||
| raise CommunicationError(f"response timeout; received tail: {tail}") | |||
| def transaction(self, request: bytes, expected_function: int) -> bytes: | |||
| if self._serial is None: | |||
| raise CommunicationError("serial port is not open") | |||
| last_error: Optional[Exception] = None | |||
| for attempt in range(self.retries + 1): | |||
| if attempt: | |||
| time.sleep(0.1) | |||
| try: | |||
| if self._serial is None: | |||
| self.open() | |||
| self._serial.reset_input_buffer() | |||
| self._log_frame("TX", request) | |||
| written = self._serial.write(request) | |||
| self._serial.flush() | |||
| if written != len(request): | |||
| last_error = CommunicationError( | |||
| f"short serial write: {written}/{len(request)}" | |||
| ) | |||
| continue | |||
| return self._receive(expected_function) | |||
| except ModbusException: | |||
| raise | |||
| except CommunicationError as exc: | |||
| last_error = exc | |||
| if (attempt > 0) and (attempt < self.retries): | |||
| try: | |||
| self.close() | |||
| self.open() | |||
| except (OSError, PlsrError) as reopen_error: | |||
| last_error = reopen_error | |||
| except OSError as exc: | |||
| last_error = exc | |||
| if attempt < self.retries: | |||
| try: | |||
| self.close() | |||
| self.open() | |||
| except (OSError, PlsrError) as reopen_error: | |||
| last_error = reopen_error | |||
| raise CommunicationError(f"transaction failed after {self.retries + 1} attempts: {last_error}") | |||
| def read_holding(self, start: int, count: int) -> list[int]: | |||
| if not 0 <= start <= 0xFFFF or not 1 <= count <= 125: | |||
| raise ValueError("invalid holding-register range") | |||
| response = self.transaction(read_holding_request(self.slave, start, count), 0x03) | |||
| if response[2] != count * 2: | |||
| raise CommunicationError(f"unexpected byte count: {response[2]}, expected {count * 2}") | |||
| payload = response[3:-2] | |||
| return [int.from_bytes(payload[i:i + 2], "big") for i in range(0, len(payload), 2)] | |||
| def write_register(self, address: int, value: int) -> None: | |||
| if not 0 <= address <= 0xFFFF or not 0 <= value <= 0xFFFF: | |||
| raise ValueError("register address/value out of range") | |||
| request = write_register_request(self.slave, address, value) | |||
| if self.transaction(request, 0x06) != request: | |||
| raise CommunicationError("write response does not echo the request") | |||
| def write_registers(self, address: int, values: Sequence[int]) -> None: | |||
| request = write_registers_request(self.slave, address, values) | |||
| response = self.transaction(request, 0x10) | |||
| if response[:6] != request[:6] or int.from_bytes(response[-2:], "little") != crc16(response[:-2]): | |||
| raise CommunicationError("multiple-write response does not echo address/count") | |||
| def read_u32(self, address: int) -> int: | |||
| values = self.read_holding(address, 2) | |||
| return decode_u32(values[0], values[1]) | |||
| def read_i32(self, address: int) -> int: | |||
| values = self.read_holding(address, 2) | |||
| return decode_i32(values[0], values[1]) | |||
| def write_u32(self, address: int, value: int) -> None: | |||
| self.write_registers(address, encode_u32(value)) | |||
| def write_i32(self, address: int, value: int) -> None: | |||
| self.write_registers(address, encode_i32(value)) | |||
| def set_running_frequency(self, frequency: int) -> Snapshot: | |||
| """修改当前运行段频率,不重新启动脉冲任务。""" | |||
| validate_frequency(frequency) | |||
| if self.protocol == MAP_LEGACY: | |||
| self.write_register(REG_FREQUENCY, frequency) | |||
| return self.read_snapshot() | |||
| snapshot = self.read_snapshot() | |||
| if snapshot.state not in (1, 2, 3, 4): | |||
| raise PlsrError("motion is not running") | |||
| if not 1 <= snapshot.current_segment <= 10: | |||
| raise PlsrError("current segment is invalid") | |||
| address = REG_SEGMENT_BASE + (snapshot.current_segment - 1) * REG_SEGMENT_STRIDE | |||
| self.write_u32(address, frequency) | |||
| return self.read_snapshot() | |||
| def command(self, value: int) -> None: | |||
| if self.protocol == MAP_LEGACY: | |||
| self.write_register(REG_COMMAND, value) | |||
| return | |||
| if value == COMMAND_START: | |||
| self.write_register(REG_CONTROL, CONTROL_START) | |||
| elif value == COMMAND_STOP: | |||
| self.write_register(REG_CONTROL, CONTROL_STOP) | |||
| elif value == COMMAND_IMMEDIATE_STOP: | |||
| self.write_register(REG_CONTROL, CONTROL_IMMEDIATE_STOP) | |||
| elif value == COMMAND_CLEAR_COUNT: | |||
| self.write_register(REG_CONTROL, CONTROL_CLEAR_COUNT) | |||
| elif value == COMMAND_RESET: | |||
| self.write_register(REG_CONTROL, CONTROL_RESET) | |||
| elif value == COMMAND_RESTART: | |||
| self.write_register(REG_CONTROL, CONTROL_RESTART) | |||
| elif value == COMMAND_SAVE_CONFIG: | |||
| raise PlsrError("the requirement map has no save bit; configuration persistence is firmware-managed") | |||
| else: | |||
| raise ValueError(f"unsupported command: {value}") | |||
| def read_snapshot(self) -> Snapshot: | |||
| if self.protocol == MAP_LEGACY: | |||
| status = self.read_holding(0, 32) | |||
| outputs = self.read_holding(REG_PULSE_OUTPUT, 2) | |||
| return Snapshot(status[REG_STATE], status[REG_FLAGS], status[REG_CURRENT_FREQUENCY], | |||
| status[REG_STOP_REASON], status[REG_LAST_ERROR], | |||
| decode_u32(status[REG_TOTAL_PULSES_HIGH], status[REG_TOTAL_PULSES_LOW]), | |||
| outputs[0], outputs[1]) | |||
| monitor = self.read_holding(REG_MONITOR_TOTAL_PULSES, 8) | |||
| config = self.read_holding(REG_CFG_PULSE_OUTPUT, 2) | |||
| state = monitor[4] | |||
| flags = 1 if state in (1, 2, 3, 4) else (2 if state == 5 else (4 if state == 7 else 0)) | |||
| return Snapshot(state, flags, decode_u32(monitor[2], monitor[3]), monitor[7], monitor[6], | |||
| decode_i32(monitor[0], monitor[1]), config[0], config[1], monitor[5], MAP_SPEC) | |||
| def read_configuration(self) -> PlsrConfiguration: | |||
| if self.protocol != MAP_SPEC: | |||
| raise PlsrError("configuration object is only available in spec protocol") | |||
| values = self.read_holding(REG_CFG_PULSE_OUTPUT, REG_CFG_DECEL_TIME_MS - REG_CFG_PULSE_OUTPUT + 1) | |||
| cfg = PlsrConfiguration( | |||
| pulse_output=values[0], direction_output=values[1], wait_input=values[2], ext_input=values[3], | |||
| send_mode=values[4], direction_delay_ms=values[5], direction_logic=values[6], accel_mode=values[7], | |||
| run_mode=values[8], segment_count=values[9], start_segment=values[10], | |||
| default_speed_hz=decode_u32(values[11], values[12]), start_speed_hz=decode_u32(values[13], values[14]), | |||
| end_speed_hz=decode_u32(values[16], values[17]), accel_time_ms=values[18], decel_time_ms=values[19], | |||
| ) | |||
| cfg.segments = [self.read_segment(index + 1) for index in range(10)] | |||
| return cfg | |||
| def write_configuration(self, config: PlsrConfiguration, save: bool = False) -> None: | |||
| if self.protocol != MAP_SPEC: | |||
| raise PlsrError("configuration object is only available in spec protocol") | |||
| config.validate() | |||
| values = [config.pulse_output, config.direction_output, config.wait_input, config.ext_input, | |||
| config.send_mode, config.direction_delay_ms, config.direction_logic, config.accel_mode, | |||
| config.run_mode, config.segment_count, config.start_segment] | |||
| values.extend(encode_u32(config.default_speed_hz)) | |||
| values.extend(encode_u32(config.start_speed_hz)) | |||
| values.append(0) # 0x100F reserved | |||
| values.extend(encode_u32(config.end_speed_hz)) | |||
| values.extend((config.accel_time_ms, config.decel_time_ms)) | |||
| self.write_registers(REG_CFG_PULSE_OUTPUT, values) | |||
| for index in range(1, config.segment_count + 1): | |||
| self.write_segment(index, config.segments[index - 1]) | |||
| if save: | |||
| # There is no save register in the revised parameter table. Keep | |||
| # the argument for GUI compatibility and make the limitation clear. | |||
| raise PlsrError("configuration written to RAM; add the firmware save command before requesting Flash save") | |||
| def read_segment(self, index: int) -> SegmentParameters: | |||
| if self.protocol != MAP_SPEC or not 1 <= index <= 10: | |||
| raise ValueError("segment index must be 1..10") | |||
| base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE | |||
| values = self.read_holding(base, 8) | |||
| return SegmentParameters(decode_u32(values[0], values[1]), decode_i32(values[2], values[3]), | |||
| values[4], values[5], values[6], values[7]) | |||
| def write_segment(self, index: int, segment: SegmentParameters) -> None: | |||
| if self.protocol != MAP_SPEC or not 1 <= index <= 10: | |||
| raise ValueError("segment index must be 1..10") | |||
| segment.validate(index) | |||
| base = REG_SEGMENT_BASE + (index - 1) * REG_SEGMENT_STRIDE | |||
| self.write_registers(base, [*encode_u32(segment.frequency_hz), *encode_i32(segment.pulses), | |||
| segment.wait_condition, segment.wait_time_ms, segment.act_time_ms, | |||
| segment.jump_segment]) | |||
| def configure_outputs(self, pulse_output: int, direction_output: int, save: bool = False) -> Snapshot: | |||
| if self.protocol == MAP_LEGACY: | |||
| if not 0 <= pulse_output <= 7 or not 0 <= direction_output <= 7 or pulse_output == direction_output: | |||
| raise ValueError("legacy output points must be distinct Q0..Q7") | |||
| snapshot = self.read_snapshot() | |||
| if snapshot.state == 2: | |||
| raise PlsrError("configuration is blocked while motion is Busy") | |||
| self.write_register(REG_PULSE_OUTPUT, pulse_output) | |||
| self.write_register(REG_DIRECTION_OUTPUT, direction_output) | |||
| if save: | |||
| self.command(COMMAND_SAVE_CONFIG) | |||
| return self.read_snapshot() | |||
| if not 0 <= pulse_output <= 3 or not 0 <= direction_output <= 3: | |||
| raise ValueError("outputs must be Y0..Y3 and Y12..Y15") | |||
| if self.read_snapshot().state in (1, 2, 3, 4): | |||
| raise PlsrError("configuration is blocked while motion is active") | |||
| self.write_register(REG_CFG_PULSE_OUTPUT, pulse_output) | |||
| self.write_register(REG_CFG_DIRECTION_OUTPUT, direction_output) | |||
| return self.read_snapshot() | |||
| def start_motion(self, pulses: int, frequency: int, direction: int = 0, | |||
| restart: bool = False) -> None: | |||
| if self.protocol == MAP_LEGACY: | |||
| if not 1 <= pulses <= 0xFFFFFFFF: | |||
| raise ValueError("legacy pulses must be 1..4294967295") | |||
| if not 1 <= frequency <= 1000: | |||
| raise ValueError("legacy firmware test module supports 1..1000 Hz") | |||
| if direction not in (0, 1): | |||
| raise ValueError("direction must be 0 or 1") | |||
| self.write_register(REG_PULSES_HIGH, (pulses >> 16) & 0xFFFF) | |||
| self.write_register(REG_PULSES_LOW, pulses & 0xFFFF) | |||
| self.write_register(REG_FREQUENCY, frequency) | |||
| self.write_register(REG_DIRECTION, direction) | |||
| self.command(COMMAND_RESTART if restart else COMMAND_START) | |||
| return | |||
| validate_frequency(frequency) | |||
| if pulses == 0 or not -0x80000000 <= pulses <= 0x7FFFFFFF: | |||
| raise ValueError("spec pulse count must be non-zero signed 32-bit") | |||
| if direction not in (0, 1): | |||
| raise ValueError("direction must be 0 or 1") | |||
| if direction == 1 and pulses > 0: | |||
| pulses = -pulses | |||
| cfg = self.read_configuration() | |||
| cfg.segments[cfg.start_segment - 1] = SegmentParameters(frequency, pulses) | |||
| cfg.segment_count = max(cfg.segment_count, cfg.start_segment) | |||
| cfg.validate() | |||
| self.write_segment(cfg.start_segment, cfg.segments[cfg.start_segment - 1]) | |||
| self.command(COMMAND_RESTART if restart else COMMAND_START) | |||
| def print_snapshot(snapshot: Snapshot) -> None: | |||
| print(f"state={snapshot.state_name:<14} segment={snapshot.current_segment:<2} " | |||
| f"frequency={snapshot.current_frequency}Hz total_pulses={snapshot.total_pulses} " | |||
| f"error={snapshot.last_error}:{snapshot.error_name} outputs={snapshot.pulse_output}/{snapshot.direction_output}") | |||
| def wait_for_terminal(client: ModbusRtuClient, timeout: float, interval: float = 0.25) -> Snapshot: | |||
| deadline, previous = time.monotonic() + timeout, None | |||
| while time.monotonic() < deadline: | |||
| snapshot = client.read_snapshot() | |||
| current = (snapshot.state, snapshot.total_pulses, snapshot.last_error) | |||
| if current != previous: | |||
| print_snapshot(snapshot) | |||
| previous = current | |||
| if snapshot.state in (5, 6, 7): | |||
| return snapshot | |||
| time.sleep(interval) | |||
| try: | |||
| client.command(COMMAND_STOP) | |||
| except PlsrError: | |||
| pass | |||
| raise PlsrError("motion timeout; stop command was attempted") | |||
| def require_safe_confirmation(args: argparse.Namespace) -> None: | |||
| if not args.confirm_safe: | |||
| raise PlsrError("motion output is blocked; disconnect the mechanism and add --confirm-safe") | |||
| def offline_self_test() -> None: | |||
| assert crc16(bytes.fromhex("01 03 00 00 00 0A")) == 0xCDC5 | |||
| assert read_holding_request(1, 0, 32).hex(" ") == "01 03 00 00 00 20 44 12" | |||
| assert encode_i32(-1) == (0xFFFF, 0xFFFF) | |||
| assert decode_i32(0x8000, 0) == -2147483648 | |||
| request = write_registers_request(1, REG_SEGMENT_BASE, [100, 0, 0xFFFF, 0xFFFF, 0, 0, 0, 0]) | |||
| assert request[1] == 0x10 and request[6] == 16 | |||
| response = with_crc(bytes.fromhex("01 03 04 00 05 00 06")) | |||
| noisy = bytearray(bytes.fromhex("40 00 00") + response) | |||
| assert extract_response(noisy, 1, 0x03) == response | |||
| print("PASS CRC16, signed double-word, FC16 and response resynchronization") | |||
| def add_connection_options(parser: argparse.ArgumentParser) -> None: | |||
| parser.add_argument("--port", help="serial port, for example COM9") | |||
| parser.add_argument("--baud", type=int, default=DEFAULT_BAUD_RATE) | |||
| parser.add_argument("--slave", type=int, default=SLAVE_ADDRESS) | |||
| parser.add_argument("--timeout", type=float, default=0.6) | |||
| parser.add_argument("--retries", type=int, default=2) | |||
| parser.add_argument("--map", choices=(MAP_SPEC, MAP_LEGACY), default=MAP_SPEC, | |||
| help="spec follows 参数表-2026.xlsx; legacy follows the first firmware image") | |||
| parser.add_argument("-v", "--verbose", action="store_true") | |||
| def build_parser() -> argparse.ArgumentParser: | |||
| parser = argparse.ArgumentParser(description="PLSR firmware Modbus RTU test tool") | |||
| add_connection_options(parser) | |||
| subs = parser.add_subparsers(dest="command_name", required=True) | |||
| subs.add_parser("ports") | |||
| subs.add_parser("self-test") | |||
| subs.add_parser("mandatory-list") | |||
| subs.add_parser("status") | |||
| monitor = subs.add_parser("monitor"); monitor.add_argument("--interval", type=float, default=0.5) | |||
| run = subs.add_parser("run"); run.add_argument("--pulses", type=int, default=10); run.add_argument("--frequency", type=int, default=2) | |||
| run.add_argument("--direction", choices=("forward", "reverse"), default="forward"); run.add_argument("--wait-timeout", type=float, default=30.0); run.add_argument("--no-wait", action="store_true"); run.add_argument("--confirm-safe", action="store_true") | |||
| subs.add_parser("stop"); subs.add_parser("immediate-stop"); subs.add_parser("reset"); subs.add_parser("clear") | |||
| config = subs.add_parser("configure"); config.add_argument("--pulse-output", type=int, required=True); config.add_argument("--direction-output", type=int, required=True); config.add_argument("--save", action="store_true") | |||
| auto = subs.add_parser("auto-test"); auto.add_argument("--pulses", type=int, default=6); auto.add_argument("--frequency", type=int, default=2); auto.add_argument("--confirm-safe", action="store_true"); auto.add_argument("--wait-timeout", type=float, default=30.0) | |||
| return parser | |||
| def run_connected_command(args: argparse.Namespace) -> None: | |||
| if not args.port: | |||
| raise PlsrError("--port is required for this command") | |||
| with ModbusRtuClient(args.port, args.baud, args.slave, args.timeout, args.retries, args.verbose, args.map) as client: | |||
| if args.command_name == "status": print_snapshot(client.read_snapshot()) | |||
| elif args.command_name == "monitor": | |||
| while True: print_snapshot(client.read_snapshot()); time.sleep(max(0.1, args.interval)) | |||
| elif args.command_name == "run": | |||
| require_safe_confirmation(args); client.start_motion(args.pulses, args.frequency, int(args.direction == "reverse")) | |||
| if not args.no_wait and wait_for_terminal(client, args.wait_timeout).state != 5: raise PlsrError("motion did not finish normally") | |||
| elif args.command_name == "configure": print_snapshot(client.configure_outputs(args.pulse_output, args.direction_output, args.save)) | |||
| elif args.command_name == "stop": client.command(COMMAND_STOP); print_snapshot(client.read_snapshot()) | |||
| elif args.command_name == "immediate-stop": client.command(COMMAND_IMMEDIATE_STOP); print_snapshot(client.read_snapshot()) | |||
| elif args.command_name == "reset": client.command(COMMAND_RESET); print_snapshot(client.read_snapshot()) | |||
| elif args.command_name == "clear": client.command(COMMAND_CLEAR_COUNT); print_snapshot(client.read_snapshot()) | |||
| elif args.command_name == "auto-test": | |||
| require_safe_confirmation(args); client.command(COMMAND_CLEAR_COUNT); client.start_motion(args.pulses, args.frequency, 0); first = wait_for_terminal(client, args.wait_timeout) | |||
| if first.state != 5: raise PlsrError("forward test failed") | |||
| client.start_motion(args.pulses, args.frequency, 1); second = wait_for_terminal(client, args.wait_timeout) | |||
| if second.state != 5: raise PlsrError("reverse test failed") | |||
| print("PASS forward/reverse mandatory smoke test") | |||
| def main(argv: Optional[Sequence[str]] = None) -> int: | |||
| args = build_parser().parse_args(argv) | |||
| try: | |||
| if args.command_name == "self-test": offline_self_test() | |||
| elif args.command_name == "mandatory-list": print(mandatory_test_checklist()) | |||
| elif args.command_name == "ports": | |||
| if list_ports is None: raise PlsrError("pyserial is not installed") | |||
| for item in sorted(list_ports.comports(), key=lambda p: p.device): print(f"{item.device:<8} {item.description}") | |||
| else: run_connected_command(args) | |||
| return 0 | |||
| except KeyboardInterrupt: return 130 | |||
| except (OSError, ValueError, PlsrError) as exc: | |||
| print(f"ERROR: {exc}", file=sys.stderr); return 1 | |||
| if __name__ == "__main__": | |||
| raise SystemExit(main()) | |||
| @@ -0,0 +1 @@ | |||
| pyserial>=3.5,<4 | |||
| @@ -0,0 +1,25 @@ | |||
| @echo off | |||
| setlocal | |||
| title Logic Waveform Viewer | |||
| cd /d "%~dp0" | |||
| set "PYTHON_EXE=" | |||
| if exist "%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\pythonw.exe" set "PYTHON_EXE=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\pythonw.exe" | |||
| if defined PYTHON_EXE goto python_found | |||
| where pythonw >nul 2>nul | |||
| if not errorlevel 1 set "PYTHON_EXE=pythonw" | |||
| if defined PYTHON_EXE goto python_found | |||
| where python >nul 2>nul | |||
| if not errorlevel 1 set "PYTHON_EXE=python" | |||
| if defined PYTHON_EXE goto python_found | |||
| echo No usable Python runtime was found. | |||
| echo Please install Python 3.9 or later. | |||
| pause | |||
| exit /b 1 | |||
| :python_found | |||
| "%PYTHON_EXE%" "%~dp0parse_logic_gui.py" | |||
| exit /b %ERRORLEVEL% | |||
| @@ -0,0 +1,51 @@ | |||
| @echo off | |||
| setlocal | |||
| chcp 65001 >nul | |||
| title PLSR Python Upper Computer | |||
| cd /d "%~dp0" | |||
| set "PYTHONUTF8=1" | |||
| set "PYTHONIOENCODING=utf-8" | |||
| set "PYTHON_EXE=%USERPROFILE%\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe" | |||
| set "PYTHON_ARGS=" | |||
| if exist "%PYTHON_EXE%" goto python_found | |||
| where py >nul 2>nul | |||
| if not errorlevel 1 ( | |||
| set "PYTHON_EXE=py" | |||
| set "PYTHON_ARGS=-3" | |||
| goto python_found | |||
| ) | |||
| where python >nul 2>nul | |||
| if not errorlevel 1 ( | |||
| set "PYTHON_EXE=python" | |||
| goto python_found | |||
| ) | |||
| echo No usable Python runtime was found. | |||
| echo Install Python 3.9 or later, then run this file again. | |||
| pause | |||
| exit /b 1 | |||
| :python_found | |||
| "%PYTHON_EXE%" %PYTHON_ARGS% -c "import serial" >nul 2>nul | |||
| if errorlevel 1 ( | |||
| echo Installing pyserial... | |||
| "%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -r requirements.txt | |||
| if errorlevel 1 ( | |||
| echo Failed to install pyserial. | |||
| pause | |||
| exit /b 1 | |||
| ) | |||
| ) | |||
| "%PYTHON_EXE%" %PYTHON_ARGS% plsr_gui.py | |||
| set "PLSR_EXIT=%ERRORLEVEL%" | |||
| if not "%PLSR_EXIT%"=="0" ( | |||
| echo. | |||
| echo PLSR GUI exited with code %PLSR_EXIT%. | |||
| pause | |||
| ) | |||
| exit /b %PLSR_EXIT% | |||
| @@ -0,0 +1,112 @@ | |||
| """测试逻辑分析仪 CSV/BIN 波形解析。""" | |||
| from __future__ import annotations | |||
| import struct | |||
| import tempfile | |||
| import unittest | |||
| from pathlib import Path | |||
| from logic_waveform import ( | |||
| WaveformChannel, | |||
| build_frequency_curve, | |||
| load_bin_waveform, | |||
| load_csv_waveform, | |||
| parse_channel_selection, | |||
| parse_marker_times_ms, | |||
| ) | |||
| from parse_logic_csv import CsvParseError | |||
| class LogicWaveformTest(unittest.TestCase): | |||
| """验证两种导出格式的关键解析结果。""" | |||
| def test_load_csv_waveform(self) -> None: | |||
| """CSV 应识别采样信息、数字时间列和通道跳变。""" | |||
| content = """; Sample rate: 20 MHz | |||
| ; Sample count: 10 | |||
| SystemTime,Time(s),Channel 0,Channel 1 | |||
| '2026-08-14 10:00:00,0.0,0,1 | |||
| '2026-08-14 10:00:00,0.1,1,1 | |||
| '2026-08-14 10:00:00,0.2,1,0 | |||
| """ | |||
| with tempfile.TemporaryDirectory() as directory: | |||
| path = Path(directory) / "wave.csv" | |||
| path.write_text(content, encoding="utf-8") | |||
| data = load_csv_waveform(path) | |||
| self.assertEqual(data.sample_rate, 20_000_000) | |||
| self.assertEqual(data.sample_count, 10) | |||
| self.assertEqual(data.record_count, 3) | |||
| self.assertEqual(data.start_time, 0.0) | |||
| self.assertEqual(data.end_time, 0.2) | |||
| self.assertEqual(data.channels[0].times, [0.0, 0.1]) | |||
| self.assertEqual(data.channels[0].levels, [0, 1]) | |||
| self.assertEqual(data.channels[1].times, [0.0, 0.2]) | |||
| self.assertEqual(data.channels[1].levels, [1, 0]) | |||
| def test_load_16_channel_bin_waveform(self) -> None: | |||
| """16 通道 BIN 应按小端 16 位采样点读取。""" | |||
| samples = (0x0000, 0x0010, 0x0010, 0x0000) | |||
| with tempfile.TemporaryDirectory() as directory: | |||
| path = Path(directory) / "wave.bin" | |||
| path.write_bytes(struct.pack("<4H", *samples)) | |||
| data = load_bin_waveform(path, 10.0, 16, [4]) | |||
| channel = data.channels[0] | |||
| self.assertEqual(data.sample_count, 4) | |||
| self.assertAlmostEqual(data.duration, 0.3) | |||
| self.assertEqual(channel.index, 4) | |||
| self.assertEqual(channel.times, [0.0, 0.1, 0.3]) | |||
| self.assertEqual(channel.levels, [0, 1, 0]) | |||
| def test_parse_channel_selection(self) -> None: | |||
| """通道输入应支持单个、列表和范围。""" | |||
| self.assertIsNone(parse_channel_selection("自动", 16)) | |||
| self.assertEqual(parse_channel_selection("4", 16), [4]) | |||
| self.assertEqual(parse_channel_selection("0,2,4-6", 16), [0, 2, 4, 5, 6]) | |||
| def test_build_frequency_curve_with_automatic_falling_edge(self) -> None: | |||
| """负逻辑脉冲应自动使用第一个真实下降沿计算频率。""" | |||
| channel = WaveformChannel( | |||
| index=4, | |||
| name="Channel 4", | |||
| times=[0.0, 0.1, 0.11, 0.2, 0.21, 0.3, 0.31], | |||
| levels=[1, 0, 1, 0, 1, 0, 1], | |||
| ) | |||
| curve = build_frequency_curve(channel, "auto") | |||
| self.assertEqual(curve.edge_name, "下降沿") | |||
| self.assertEqual(curve.times, [0.1, 0.2, 0.3]) | |||
| for frequency in curve.frequencies: | |||
| self.assertAlmostEqual(frequency, 10.0) | |||
| measured_time, frequency = curve.frequency_at(0.2) | |||
| self.assertEqual(measured_time, 0.2) | |||
| self.assertEqual(frequency, 10.0) | |||
| def test_build_frequency_curve_with_rising_edge(self) -> None: | |||
| """指定上升沿时应按相邻上升沿周期换算频率。""" | |||
| channel = WaveformChannel( | |||
| index=0, | |||
| name="Channel 0", | |||
| times=[0.0, 0.01, 0.015, 0.02, 0.0225, 0.025, 0.0275], | |||
| levels=[0, 1, 0, 1, 0, 1, 0], | |||
| ) | |||
| curve = build_frequency_curve(channel, "rising") | |||
| self.assertEqual(curve.edge_name, "上升沿") | |||
| self.assertAlmostEqual(curve.frequencies[0], 100.0) | |||
| self.assertAlmostEqual(curve.frequencies[-1], 200.0) | |||
| def test_parse_marker_times_ms(self) -> None: | |||
| """关键时间应支持多个毫秒数值。""" | |||
| self.assertEqual(parse_marker_times_ms("100, 200,500"), [0.1, 0.2, 0.5]) | |||
| def test_parse_marker_times_ms_rejects_more_than_ten_unique_values(self) -> None: | |||
| """关键时间超过十个时应给出明确错误。""" | |||
| with self.assertRaisesRegex(CsvParseError, "最多设置 10 个"): | |||
| parse_marker_times_ms(",".join(str(value) for value in range(11))) | |||
| if __name__ == "__main__": | |||
| unittest.main() | |||
| @@ -0,0 +1,113 @@ | |||
| import queue | |||
| import unittest | |||
| from unittest.mock import patch | |||
| from plsr_gui import PlsrGui | |||
| from plsr_test import COMMAND_RESET, Snapshot | |||
| class PollingUiTests(unittest.TestCase): | |||
| def setUp(self): | |||
| self.app = PlsrGui() | |||
| self.app.withdraw() | |||
| self.worker_jobs = self.app.jobs | |||
| self.app.jobs = queue.Queue() | |||
| self.snapshot = Snapshot(0, 0, 0, 0, 0, 0, 4, 1) | |||
| self.app.connected = True | |||
| self.app.last_snapshot = self.snapshot | |||
| self.app._update_controls() | |||
| def tearDown(self): | |||
| self.app.closing = True | |||
| self.worker_jobs.put({"name": "shutdown"}) | |||
| self.app.destroy() | |||
| def _states(self): | |||
| widgets = ( | |||
| self.app.connect_button, | |||
| self.app.save_outputs_button, | |||
| self.app.start_button, | |||
| self.app.stop_button, | |||
| self.app.emergency_button, | |||
| self.app.lamp_test_button, | |||
| self.app.reset_button, | |||
| self.app.clear_button, | |||
| self.app.pulse_output_combo, | |||
| self.app.direction_output_combo, | |||
| self.app.pulses_spin, | |||
| self.app.frequency_spin, | |||
| self.app.apply_frequency_button, | |||
| self.app.forward_radio, | |||
| self.app.reverse_radio, | |||
| ) | |||
| return tuple(str(widget.cget("state")) for widget in widgets) | |||
| def test_running_state_allows_frequency_update_only(self): | |||
| self.app.last_snapshot = Snapshot(2, 1, 1000, 0, 0, 10, 0, 0, 1) | |||
| self.app._update_controls() | |||
| self.assertEqual(str(self.app.frequency_spin.cget("state")), "normal") | |||
| self.assertEqual( | |||
| str(self.app.apply_frequency_button.cget("state")), "normal" | |||
| ) | |||
| self.assertEqual(str(self.app.pulses_spin.cget("state")), "disabled") | |||
| self.assertEqual(str(self.app.forward_radio.cget("state")), "disabled") | |||
| def test_background_poll_does_not_toggle_controls(self): | |||
| before = self._states() | |||
| self.assertTrue(self.app._submit("snapshot", purpose="poll")) | |||
| self.assertTrue(self.app.poll_active) | |||
| self.assertEqual(self._states(), before) | |||
| self.app._handle_result( | |||
| { | |||
| "type": "result", | |||
| "name": "snapshot", | |||
| "purpose": "poll", | |||
| "result": self.snapshot, | |||
| } | |||
| ) | |||
| self.assertEqual(self._states(), before) | |||
| def test_click_during_poll_is_deferred(self): | |||
| self.app._submit("snapshot", purpose="poll") | |||
| accepted = self.app._submit( | |||
| "command", purpose="reset", command=COMMAND_RESET | |||
| ) | |||
| self.assertTrue(accepted) | |||
| self.assertIsNotNone(self.app.deferred_job) | |||
| self.app._handle_result( | |||
| { | |||
| "type": "result", | |||
| "name": "snapshot", | |||
| "purpose": "poll", | |||
| "result": self.snapshot, | |||
| } | |||
| ) | |||
| first = self.app.jobs.get_nowait() | |||
| second = self.app.jobs.get_nowait() | |||
| self.assertEqual(first["purpose"], "poll") | |||
| self.assertEqual(second["purpose"], "reset") | |||
| self.assertTrue(self.app.job_active) | |||
| self.assertFalse(self.app.poll_active) | |||
| def test_lamp_test_keeps_existing_cumulative_count(self): | |||
| self.app.auto_phase = "forward" | |||
| self.app.run_baseline = 40 | |||
| self.app.lamp_test_baseline = 40 | |||
| self.app._advance_lamp_test(Snapshot(5, 2, 2, 0, 0, 46, 4, 1)) | |||
| reverse = self.app.jobs.get_nowait() | |||
| self.assertEqual(reverse["purpose"], "auto_reverse") | |||
| self.assertTrue(reverse["restart"]) | |||
| with patch("plsr_gui.messagebox.showinfo"): | |||
| self.app._advance_lamp_test(Snapshot(5, 2, 2, 0, 0, 40, 4, 1)) | |||
| self.assertIsNone(self.app.auto_phase) | |||
| if __name__ == "__main__": | |||
| unittest.main(verbosity=2) | |||
| @@ -0,0 +1,168 @@ | |||
| import unittest | |||
| import plsr_test | |||
| class FakeSerial: | |||
| def __init__(self, response): | |||
| self.response = response | |||
| self.rx = bytearray() | |||
| self.writes = [] | |||
| @property | |||
| def in_waiting(self): | |||
| return len(self.rx) | |||
| def reset_input_buffer(self): | |||
| self.rx.clear() | |||
| def write(self, data): | |||
| self.writes.append(bytes(data)) | |||
| self.rx.extend(bytes.fromhex("40 00") + self.response) | |||
| return len(data) | |||
| def flush(self): | |||
| pass | |||
| def read(self, size): | |||
| chunk_size = min(size, 3, len(self.rx)) | |||
| data = bytes(self.rx[:chunk_size]) | |||
| del self.rx[:chunk_size] | |||
| return data | |||
| class ProtocolTests(unittest.TestCase): | |||
| def test_known_crc(self): | |||
| self.assertEqual(plsr_test.crc16(bytes.fromhex("01 03 00 00 00 0A")), 0xCDC5) | |||
| def test_status_request(self): | |||
| frame = plsr_test.read_holding_request(1, 0, 32) | |||
| self.assertEqual(frame.hex(" "), "01 03 00 00 00 20 44 12") | |||
| def test_write_register_request(self): | |||
| frame = plsr_test.write_register_request(1, 100, 1) | |||
| self.assertEqual(frame, plsr_test.with_crc(bytes.fromhex("01 06 00 64 00 01"))) | |||
| def test_parser_recovers_from_stale_bytes(self): | |||
| response = plsr_test.with_crc(bytes.fromhex("01 03 04 00 05 00 06")) | |||
| buffer = bytearray(bytes.fromhex("40 00 00 03 40") + response) | |||
| self.assertEqual(plsr_test.extract_response(buffer, 1, 3), response) | |||
| def test_parser_waits_for_partial_frame(self): | |||
| response = plsr_test.with_crc(bytes.fromhex("01 03 04 00 05 00 06")) | |||
| buffer = bytearray(response[:5]) | |||
| self.assertIsNone(plsr_test.extract_response(buffer, 1, 3)) | |||
| buffer.extend(response[5:]) | |||
| self.assertEqual(plsr_test.extract_response(buffer, 1, 3), response) | |||
| def test_snapshot_combines_high_and_low_words(self): | |||
| snapshot = plsr_test.Snapshot(5, 2, 10, 0, 0, (1 << 16) | 2, 4, 1) | |||
| self.assertEqual(snapshot.total_pulses, 65538) | |||
| self.assertEqual(snapshot.state_name, "Done") | |||
| def test_spec_snapshot_reads_stop_reason_and_signed_total(self): | |||
| class SnapshotClient(plsr_test.ModbusRtuClient): | |||
| def read_holding(self, address, count): | |||
| if address == plsr_test.REG_MONITOR_TOTAL_PULSES: | |||
| return [0xFFFF, 0xFFFE, 0, 2, 6, 1, 0, 1] | |||
| return [2, 1] | |||
| snapshot = SnapshotClient("FAKE", protocol=plsr_test.MAP_SPEC).read_snapshot() | |||
| self.assertEqual(snapshot.total_pulses, -2) | |||
| self.assertEqual(snapshot.stop_reason, 1) | |||
| self.assertEqual(snapshot.state_name, "Stopped") | |||
| def test_client_handles_fragmented_noisy_response(self): | |||
| response = plsr_test.with_crc(bytes.fromhex("01 03 04 00 05 00 06")) | |||
| fake = FakeSerial(response) | |||
| client = plsr_test.ModbusRtuClient("FAKE", timeout=0.1, retries=0) | |||
| client._serial = fake | |||
| self.assertEqual(client.read_holding(0, 2), [5, 6]) | |||
| self.assertEqual(len(fake.writes), 1) | |||
| def test_signed_double_word_round_trip(self): | |||
| self.assertEqual(plsr_test.encode_i32(-2147483648), (0x8000, 0x0000)) | |||
| self.assertEqual(plsr_test.encode_i32(-1), (0xFFFF, 0xFFFF)) | |||
| self.assertEqual(plsr_test.decode_i32(0x7FFF, 0xFFFF), 2147483647) | |||
| self.assertEqual(plsr_test.decode_i32(0x8000, 0), -2147483648) | |||
| def test_spec_configuration_validation(self): | |||
| cfg = plsr_test.PlsrConfiguration( | |||
| pulse_output=3, direction_output=3, segment_count=10, | |||
| segments=[plsr_test.SegmentParameters(100000, -1, 4, 10, 20, 0) for _ in range(10)], | |||
| ) | |||
| cfg.validate() | |||
| zero_speed_cfg = plsr_test.PlsrConfiguration( | |||
| start_speed_hz=0, | |||
| end_speed_hz=0, | |||
| segments=[plsr_test.SegmentParameters(0, 1)] + [plsr_test.SegmentParameters() for _ in range(9)], | |||
| ) | |||
| zero_speed_cfg.validate() | |||
| with self.assertRaisesRegex(ValueError, "frequency must be 1..100000 Hz"): | |||
| plsr_test.PlsrConfiguration(default_speed_hz=0).validate() | |||
| with self.assertRaises(ValueError): | |||
| plsr_test.PlsrConfiguration(segment_count=11).validate() | |||
| def test_send_and_run_modes_accept_only_zero_or_one(self): | |||
| config = plsr_test.PlsrConfiguration(send_mode=1, run_mode=1) | |||
| config.validate() | |||
| with self.assertRaises(ValueError): | |||
| plsr_test.PlsrConfiguration(send_mode=2).validate() | |||
| with self.assertRaises(ValueError): | |||
| plsr_test.PlsrConfiguration(run_mode=2).validate() | |||
| def test_spec_segment_request_uses_fc16_and_signed_count(self): | |||
| frame = plsr_test.write_registers_request( | |||
| 1, plsr_test.REG_SEGMENT_BASE, | |||
| [100000 >> 16, 100000 & 0xFFFF, 0xFFFF, 0xFFFF, 4, 10, 20, 0], | |||
| ) | |||
| self.assertEqual(frame[1], 0x10) | |||
| self.assertEqual(frame[2:6], bytes.fromhex("11 00 00 08")) | |||
| self.assertEqual(frame[6], 16) | |||
| def test_spec_commands_have_independent_control_bits(self): | |||
| client = plsr_test.ModbusRtuClient("FAKE", protocol=plsr_test.MAP_SPEC) | |||
| writes = [] | |||
| client.write_register = lambda address, value: writes.append((address, value)) | |||
| client.command(plsr_test.COMMAND_STOP) | |||
| client.command(plsr_test.COMMAND_IMMEDIATE_STOP) | |||
| client.command(plsr_test.COMMAND_CLEAR_COUNT) | |||
| client.command(plsr_test.COMMAND_RESET) | |||
| client.command(plsr_test.COMMAND_RESTART) | |||
| self.assertEqual(writes, [ | |||
| (plsr_test.REG_CONTROL, plsr_test.CONTROL_STOP), | |||
| (plsr_test.REG_CONTROL, plsr_test.CONTROL_IMMEDIATE_STOP), | |||
| (plsr_test.REG_CONTROL, plsr_test.CONTROL_CLEAR_COUNT), | |||
| (plsr_test.REG_CONTROL, plsr_test.CONTROL_RESET), | |||
| (plsr_test.REG_CONTROL, plsr_test.CONTROL_RESTART), | |||
| ]) | |||
| def test_running_frequency_updates_current_segment_only(self): | |||
| client = plsr_test.ModbusRtuClient( | |||
| "FAKE", protocol=plsr_test.MAP_SPEC | |||
| ) | |||
| snapshots = [ | |||
| plsr_test.Snapshot( | |||
| 2, 1, 1000, 0, 0, 25, 0, 0, 3, plsr_test.MAP_SPEC | |||
| ), | |||
| plsr_test.Snapshot( | |||
| 2, 1, 5000, 0, 0, 25, 0, 0, 3, plsr_test.MAP_SPEC | |||
| ), | |||
| ] | |||
| writes = [] | |||
| client.read_snapshot = lambda: snapshots.pop(0) | |||
| client.write_u32 = lambda address, value: writes.append((address, value)) | |||
| result = client.set_running_frequency(5000) | |||
| expected_address = ( | |||
| plsr_test.REG_SEGMENT_BASE + 2 * plsr_test.REG_SEGMENT_STRIDE | |||
| ) | |||
| self.assertEqual(writes, [(expected_address, 5000)]) | |||
| self.assertEqual(result.current_frequency, 5000) | |||
| if __name__ == "__main__": | |||
| unittest.main(verbosity=2) | |||
| @@ -0,0 +1,266 @@ | |||
| # PLSR 完整参数与多段脉冲操作说明 | |||
| ## 1. 文档用途 | |||
| 本文档用于说明 Python 上位机中“完整参数与多段”页面的使用方法,并提供可以直接照着填写的测试示例。 | |||
| 测试前请先断开伺服和机械负载,只连接测试灯或示波器。确认输出点、电源和公共端接线正确后,再进行低频测试。 | |||
| ## 2. 让参数生效的正确顺序 | |||
| 完整参数和多段脉冲必须按照下面的顺序操作: | |||
| 1. 串口映射选择 `spec`,连接设备。 | |||
| 2. 打开“完整参数与多段”页面。 | |||
| 3. 设置上方“系统与运动参数”。 | |||
| 4. 逐段编辑参数,每编辑完一段都点击“应用当前段”。 | |||
| 5. 全部段编辑完成后,点击“校验并写入全部”。 | |||
| 6. 等待界面提示“完整参数写入并回读校验完成”。 | |||
| 7. 点击“从设备读取全部”,确认读回的数据和设置一致。 | |||
| 8. 回到“控制与状态”页面。 | |||
| 9. 把“目标脉冲数”和“频率”设置为起始段相同的值。 | |||
| 10. 点击“启动/继续脉冲”,多段路径才会真正开始执行。 | |||
| > “应用当前段”只把右侧编辑框的数据更新到上位机左侧表格,不会写入设备。 | |||
| > | |||
| > “校验并写入全部”才会把系统参数和有效段写入设备。 | |||
| > | |||
| > “从设备读取全部”只读取参数,不会启动脉冲。 | |||
| ## 3. 当前界面的重要注意事项 | |||
| 目前“控制与状态”页面的“启动/继续脉冲”会把该页面中的目标脉冲数和频率再次写入起始段。 | |||
| 因此,多段测试时必须保证: | |||
| - 控制页“目标脉冲数” = 起始段“脉冲数”; | |||
| - 控制页“频率” = 起始段“频率”; | |||
| - 起始段为正脉冲时选择正向; | |||
| - 起始段为负脉冲时选择反向。 | |||
| 如果两边数值不同,起始段会被控制页数值覆盖,但后续段仍使用完整参数页中的设置。 | |||
| ## 4. 系统与运动参数说明 | |||
| | 参数 | 说明 | 建议测试值 | | |||
| |---|---|---:| | |||
| | WAIT信号 | `WAIT信号`条件使用的输入点 | X4 | | |||
| | EXT信号 | `EXT信号`条件使用的输入点 | X5 | | |||
| | 发送模式 | 决定段边界速度处理方式 | 先用完成模式 | | |||
| | 方向逻辑 | 方向输出的逻辑设置 | 正逻辑 | | |||
| | 加减速模式 | 直线、S曲线、正弦曲线 | 先用直线 | | |||
| | 运行模式 | 相对位置或绝对位置 | 先用相对 | | |||
| | 方向延时 | 方向变化后的等待时间 | 10 ms | | |||
| | 总段数 | 本次有效段数量 | 1~10 | | |||
| | 起始段 | 从指定段开始执行 | 1 | | |||
| | 默认速度 | 公共匀速参考值 | 1000 Hz | | |||
| | 起始速度 | 第一段或换向后开始时的速度 | 100 Hz | | |||
| | 终止速度 | 最后一段结束前的目标速度 | 100 Hz | | |||
| | 加速时间 | 从起始速度变化到当前段速度的时间 | 100 ms | | |||
| | 减速时间 | 最后一段变化到终止速度的时间 | 100 ms | | |||
| 当前固件注意事项: | |||
| - 每一段实际运行目标频率以该段的“频率”参数为准;该值为 `0 Hz` 时,按定位手册规则使用“默认速度”发送脉冲; | |||
| - “默认速度 / 默认加速时间”和“默认速度 / 默认减速时间”分别决定固定的加减速斜率,当前段起始速度和目标速度只决定斜率作用的区间,不参与斜率计算; | |||
| - “方向逻辑”和“方向延时”目前可以写入和保存,但还没有接入实际方向输出时序,暂时不能作为最终验收项。 | |||
| 固定斜率定义: | |||
| ```text | |||
| 加速斜率 = 默认速度 / 默认加速时间 | |||
| 减速斜率 = 默认速度 / 默认减速时间 | |||
| ``` | |||
| 例如默认速度为 `1000 Hz`、默认加速时间为 `100 ms` 时,加速斜率为 `10 Hz/ms`;起始速度为 `100 Hz`、当前段目标为 `500 Hz` 时,应在约 `40 ms` 达到目标。实现按已流逝时间更新频率,不按脉冲序号平均分配加速段。 | |||
| ### 4.1 完成模式 | |||
| 本段脉冲发送完成时,速度仍保持本段速度。下一段开始后,再从上一段速度向下一段速度变化。 | |||
| 示例: | |||
| - 第1段:500 Hz,1000个脉冲; | |||
| - 第2段:1000 Hz,2000个脉冲。 | |||
| 第1段结束时仍是500 Hz,第2段开始后再由500 Hz向1000 Hz变化。 | |||
| ### 4.2 后续模式 | |||
| 本段脉冲发送完成前,速度已经向下一段速度变化。本段结束时已经达到下一段速度,下一段直接接着运行。 | |||
| 使用上面的示例时,第1段后半部分会从500 Hz向1000 Hz变化,第2段开始时直接使用1000 Hz。 | |||
| ### 4.3 相对模式 | |||
| 每段“脉冲数”表示该段需要移动的脉冲数量: | |||
| - 正数表示正向; | |||
| - 负数表示反向; | |||
| - 每一段都在上一段结束位置的基础上继续移动。 | |||
| ### 4.4 绝对模式 | |||
| 每段“脉冲数”表示目标累计位置: | |||
| - 当前累计位置小于目标位置时正向运行; | |||
| - 当前累计位置大于目标位置时反向运行; | |||
| - 当前累计位置等于目标位置时,本段不输出脉冲。 | |||
| 绝对模式测试前建议先点击“清零累计脉冲”,避免原有累计位置影响测试结果。 | |||
| ## 5. 单段参数说明 | |||
| | 参数 | 范围 | 说明 | | |||
| |---|---:|---| | |||
| | 频率 | 0~100000 Hz | 当前段目标频率;0 表示使用系统默认速度 | | |||
| | 脉冲数 | -2147483648~2147483647 | 正数正向,负数反向;有效段不能设置为0 | | |||
| | 等待条件 | 5种 | 当前段完成后或条件满足后切换 | | |||
| | WAIT时间 | 0~65535 ms | `WAIT时间`使用的等待时间 | | |||
| | ACT时间 | 0~65535 ms | `ACT时间`使用的时间 | | |||
| | 跳转段 | 0~总段数 | 0表示默认进入下一段,最后一段为0表示结束 | | |||
| ### 5.1 跳转段规则 | |||
| - 中间段设置为0:进入下一段; | |||
| - 最后一段设置为0:结束整个多段任务; | |||
| - 设置为指定段号:条件满足后跳转到该段; | |||
| - 跳转到自身:会形成循环,测试时要准备受控停止; | |||
| - 跳转段不能大于“总段数”。 | |||
| ## 6. 等待条件的当前行为 | |||
| | 等待条件 | 当前固件行为 | 测试方法 | | |||
| |---|---|---| | |||
| | WAIT时间 | 当前段完成后等待指定毫秒,再跳转 | 设置500 ms,观察两段之间的间隔 | | |||
| | WAIT信号 | 当前段完成后等待X4或X5有效,再跳转 | 将对应X点拉为有效低电平 | | |||
| | ACT时间 | 当前实现为本段完成后按ACT时间等待 | 设置500 ms,观察段间间隔 | | |||
| | EXT信号 | 当前实现为本段完成后等待X4或X5有效 | 本段完成后触发对应X点 | | |||
| | EXT或完成 | 当前段完成后直接跳转 | 当前不能验证运行中EXT提前切段 | | |||
| 注意:题目的最终定义要求 `ACT时间`、`EXT信号`、`EXT或完成`能够在当前段运行过程中提前切段。当前固件尚未完整实现这个提前切段行为,现阶段不要按该预期进行验收。 | |||
| X4、X5当前按低电平有效处理。输入悬空可能造成误触发,测试时应保证输入电平稳定。 | |||
| ## 7. 三段连续运行测试示例 | |||
| 这组参数不需要外部输入信号,适合第一次验证多段功能。 | |||
| ### 7.1 系统参数 | |||
| | 参数 | 设置值 | | |||
| |---|---:| | |||
| | 发送模式 | 完成模式 | | |||
| | 加减速模式 | 直线 | | |||
| | 运行模式 | 相对 | | |||
| | 总段数 | 3 | | |||
| | 起始段 | 1 | | |||
| | 默认速度 | 1000 Hz | | |||
| | 起始速度 | 100 Hz | | |||
| | 终止速度 | 100 Hz | | |||
| | 加速时间 | 100 ms | | |||
| | 减速时间 | 100 ms | | |||
| ### 7.2 段参数 | |||
| | 段 | 频率 | 脉冲数 | 等待条件 | WAIT时间 | ACT时间 | 跳转段 | | |||
| |---:|---:|---:|---|---:|---:|---:| | |||
| | 1 | 500 Hz | 1000 | WAIT时间 | 500 ms | 0 | 0 | | |||
| | 2 | 1000 Hz | 2000 | WAIT时间 | 500 ms | 0 | 0 | | |||
| | 3 | 2000 Hz | -1000 | WAIT时间 | 0 ms | 0 | 0 | | |||
| ### 7.3 编辑步骤 | |||
| 1. 右侧“段号”选择1,填写第1段参数,点击“应用当前段”。 | |||
| 2. “段号”选择2,填写第2段参数,点击“应用当前段”。 | |||
| 3. “段号”选择3,填写第3段参数,点击“应用当前段”。 | |||
| 4. 检查左侧表格的前3行是否正确。 | |||
| 5. 点击“校验并写入全部”。 | |||
| 6. 点击“从设备读取全部”,确认参数没有变化。 | |||
| 7. 回到“控制与状态”页面。 | |||
| 8. 设置目标脉冲数为1000,频率为500 Hz,方向选择正向。 | |||
| 9. 点击“启动/继续脉冲”。 | |||
| ### 7.4 预期结果 | |||
| 1. 第1段以500 Hz发送1000个正向脉冲; | |||
| 2. 等待约500 ms; | |||
| 3. 第2段以1000 Hz发送2000个正向脉冲; | |||
| 4. 等待约500 ms; | |||
| 5. 第3段以2000 Hz发送1000个反向脉冲; | |||
| 6. 最后一段跳转段为0,任务结束; | |||
| 7. 如果开始前累计值为0,最终累计值应约为2000。 | |||
| 计算过程:`1000 + 2000 - 1000 = 2000`。 | |||
| ## 8. WAIT信号测试示例 | |||
| 1. 总段数设置为2,起始段设置为1。 | |||
| 2. WAIT信号选择X4。 | |||
| 3. 第1段设置500 Hz、1000脉冲、等待条件选择WAIT信号、跳转段为0。 | |||
| 4. 第2段设置1000 Hz、1000脉冲、等待条件选择WAIT时间、WAIT时间为0、跳转段为0。 | |||
| 5. 逐段点击“应用当前段”,再点击“校验并写入全部”。 | |||
| 6. 控制页设置1000脉冲、500 Hz、正向,然后启动。 | |||
| 7. 第1段结束后,运行状态应显示等待。 | |||
| 8. 将X4输入拉为有效低电平,第2段应开始运行。 | |||
| ## 9. 跳转循环测试示例 | |||
| 循环测试有持续输出风险,只能在测试灯或示波器条件下进行。 | |||
| 1. 总段数设置为2。 | |||
| 2. 第1段跳转段设置为2。 | |||
| 3. 第2段跳转段设置为1。 | |||
| 4. 两段等待条件都设置WAIT时间,WAIT时间建议500 ms。 | |||
| 5. 启动后第1段和第2段会循环运行。 | |||
| 6. 使用“受控停止”结束测试。 | |||
| ## 10. 运行中修改频率 | |||
| 1. 启动多段任务。 | |||
| 2. 在“控制与状态”页面修改频率输入框。 | |||
| 3. 点击“应用频率”。 | |||
| 4. 新频率只修改当前正在运行的段,不暂停任务,也不清零剩余脉冲。 | |||
| 5. 进入下一段后,下一段仍使用其自身配置的频率。 | |||
| ## 11. 常见问题 | |||
| ### 11.1 点击“应用当前段”后设备没有动作 | |||
| 这是正常现象。“应用当前段”只更新上位机临时表格,还需要点击“校验并写入全部”,最后回到控制页点击启动。 | |||
| ### 11.2 写入后只有第1段运行 | |||
| 检查以下内容: | |||
| - 总段数是否大于1; | |||
| - 第2段到最后有效段的脉冲数是否为0; | |||
| - 中间段的跳转段是否超过总段数; | |||
| - 当前段是否停在WAIT信号或EXT信号等待; | |||
| - 最后错误是否为参数无效。 | |||
| ### 11.3 参数写入后又变了 | |||
| 控制页启动时会覆盖起始段的脉冲数和频率。启动前应保证控制页数值与起始段一致。 | |||
| ### 11.4 设备一直显示等待 | |||
| 如果等待条件为WAIT信号或EXT信号,检查所选X4/X5是否已经达到有效低电平。没有接输入信号时,建议先使用WAIT时间测试。 | |||
| ### 11.5 修改了总段数,但第4段没有执行 | |||
| 只有段号小于或等于总段数的段才有效。例如总段数为3时,第4~10段不会执行。 | |||
| ## 12. 推荐验收顺序 | |||
| 1. 单段、低频、无加减速; | |||
| 2. 三段连续运行; | |||
| 3. 正反向累计计数; | |||
| 4. WAIT时间; | |||
| 5. WAIT信号; | |||
| 6. 完成模式与后续模式波形; | |||
| 7. 运行中修改频率; | |||
| 8. S曲线和正弦曲线; | |||
| 9. 绝对模式; | |||
| 10. 跳转循环; | |||
| 11. ACT和EXT提前切段功能在固件完善后再验收。 | |||
| @@ -0,0 +1,152 @@ | |||
| # 正点原子逻辑分析仪波形查看器交接文档 | |||
| ## 1. 当前目标 | |||
| 本工具用于直接查看正点原子 ATK-Logic 导出的逻辑分析仪数据,不进入 IAR,不需要先转换成 CSV 再用 Excel 查看。 | |||
| 当前主图不是高低电平图,而是: | |||
| - 横轴:脉冲开始后的时间; | |||
| - 纵轴:脉冲频率,单位 Hz; | |||
| - 曲线:由相邻同方向脉冲边沿的时间差计算得到的瞬时频率; | |||
| - 关键节点:例如 `100 ms`,在图上画竖线、圆点并标出该节点后的最近完整周期频率。 | |||
| ## 2. 直接使用 | |||
| 双击: | |||
| ```text | |||
| PythonTest\start_logic_csv_parser.bat | |||
| ``` | |||
| 不需要打开 IAR,也不需要编译 PLC 工程。 | |||
| 界面操作: | |||
| 1. 点击“选择文件”,选择 `.csv` 或 `.bin`; | |||
| 2. CSV 直接点击读取; | |||
| 3. BIN 先填写采集时的采样率和通道数,再点击“读取并显示”; | |||
| 4. “显示通道”可以填写 `自动`、`4`、`0,1,4` 或 `0-7`; | |||
| 5. “测量边沿”可以选自动、上升沿、下降沿; | |||
| 6. “关键时间(ms)”可以填写 `100` 或 `100,200,500`; | |||
| 7. 鼠标滚轮缩放时间,按住鼠标左键水平拖动,点击“适应窗口”恢复全范围。 | |||
| ## 3. 文件结构 | |||
| ### `logic_waveform.py` | |||
| 解析和计算核心: | |||
| - `load_csv_waveform()`:读取 CSV 元数据、时间列和通道跳变; | |||
| - `load_bin_waveform()`:读取无文件头的原始 BIN; | |||
| - `load_waveform()`:根据扩展名选择 CSV 或 BIN; | |||
| - `WaveformChannel`:保存一个数字通道的跳变时间和电平; | |||
| - `FrequencyCurve`:保存频率曲线时间点和频率值; | |||
| - `build_frequency_curve()`:按边沿计算瞬时频率; | |||
| - `build_frequency_curves()`:批量生成通道曲线; | |||
| - `parse_marker_times_ms()`:解析关键时间输入。 | |||
| ### `parse_logic_gui.py` | |||
| Tkinter 图形界面: | |||
| - 文件选择和后台读取; | |||
| - 频率曲线绘制; | |||
| - Hz 纵轴和时间横轴; | |||
| - 关键时间标记; | |||
| - 缩放、拖动、光标频率显示; | |||
| - 自动、上升沿、下降沿选择。 | |||
| ### `parse_logic_csv.py` | |||
| 原来的 CSV 通用解析和 CSV 输出命令行工具仍然保留,GUI 当前不把保存 CSV 作为主流程。 | |||
| ### `test_logic_waveform.py` | |||
| 波形解析测试,覆盖: | |||
| - CSV 元数据和通道跳变; | |||
| - 16 通道 BIN; | |||
| - 通道选择; | |||
| - 上升沿、下降沿和自动边沿频率计算; | |||
| - 关键时间解析。 | |||
| ## 4. 频率计算定义 | |||
| 原始 CSV/BIN 中保存的是数字电平,不是频率。程序按以下方式换算: | |||
| ```text | |||
| 频率 = 1 / 相邻同方向边沿的时间差 | |||
| ``` | |||
| 注意事项: | |||
| - `times[0]` 是文件初始电平状态,不作为真实边沿; | |||
| - 自动模式使用捕获到的第一个真实边沿方向; | |||
| - 负逻辑脉冲通常应使用下降沿,正逻辑脉冲通常应使用上升沿; | |||
| - 第一个完整周期的频率会补到曲线起点; | |||
| - 关键时间显示的是该时间之后最近一个完整周期的实测频率,不是理论插值值; | |||
| - 横轴 `0 ms` 是第一个有效脉冲边沿,不是 CSV 文件的系统时间起点。 | |||
| ## 5. 已验证真实文件 | |||
| 测试目录: | |||
| ```text | |||
| D:\xinjiedianqi\Study\TrainCamp_zengbingjie_PLSR\测试脉冲数据 | |||
| ``` | |||
| ### `DL16.csv` | |||
| - 文件注释采样率:20 MHz; | |||
| - 原始采样点数:125,351,087; | |||
| - CSV 跳变记录:8,001 行; | |||
| - 自动识别到 `Channel 4` 有脉冲变化; | |||
| - 自动选择下降沿; | |||
| - `100 ms` 节点最近测量时间约 `100.094 ms`; | |||
| - 该节点频率约 `361.984 Hz`; | |||
| - 随后曲线继续升高到约 `500 Hz`。 | |||
| ### `zxjs.bin` | |||
| - 文件大小约 49 MB; | |||
| - 该 BIN 没有文件头和采样率字段; | |||
| - 按 16 通道、每采样点 2 字节可读取; | |||
| - 测试使用 20 MHz、指定 Channel 4 时可以成功生成频率曲线; | |||
| - BIN 的采样率必须由用户按实际采集设置填写,否则时间和频率都会按比例错误。 | |||
| ## 6. 测试命令 | |||
| 在项目根目录执行: | |||
| ```powershell | |||
| python -m unittest discover -s PythonTest -p "test_*.py" | |||
| python -m py_compile PythonTest\logic_waveform.py PythonTest\parse_logic_gui.py | |||
| ``` | |||
| 当前最后一次验证结果: | |||
| ```text | |||
| Ran 24 tests | |||
| OK | |||
| ``` | |||
| 双击启动文件也已验证能够创建 `pythonw.exe` 图形进程。 | |||
| ## 7. 后续建议 | |||
| 后续如果继续完善,优先考虑: | |||
| 1. 增加“目标频率/理论加速曲线”输入,与实测曲线叠加对比; | |||
| 2. 增加加速度单位 `Hz/s` 显示; | |||
| 3. 对长时间空闲段显示 0 Hz,而不是只显示跨空闲段后的低频点; | |||
| 4. 增加频率曲线 CSV/图片导出,但不要恢复为唯一查看方式; | |||
| 5. 如果获得正点原子 BIN 的官方采样率或工程文件格式,再把 BIN 的采样率自动识别补齐。 | |||
| ## 8. 当前已知限制 | |||
| - BIN 原始数据没有采样率,程序无法仅凭 BIN 内容推断真实时间; | |||
| - 频率是由同方向边沿测得的周期计算出来的,边沿抖动会反映在曲线上; | |||
| - 当前关键时间默认按“第一个有效脉冲边沿”为零点,不使用 CSV 中的绝对系统时间; | |||
| - 当前主界面显示频率曲线,原始 0/1 电平阶梯图不再是主视图; | |||
| - 工作区本身存在其他 PLC、文档和测试文件修改,后续对话不要使用 `git reset --hard` 或整体回退。 | |||
| @@ -16,6 +16,8 @@ extern "C" { | |||
| #define PLSR_OUTPUT_MODE_ADDRESS (0x1200U) | |||
| #define PLSR_DIAGNOSTIC_FIRST_ADDRESS (0x2100U) | |||
| #define PLSR_DIAGNOSTIC_LAST_ADDRESS (0x2119U) | |||
| #define PLSR_QUEUE_DIAGNOSTIC_FIRST_ADDRESS (0x2200U) | |||
| #define PLSR_QUEUE_DIAGNOSTIC_LAST_ADDRESS (0x2208U) | |||
| #define PLSR_DIAGNOSTIC_CONTROL_ADDRESS (0x3100U) | |||
| typedef enum | |||
| @@ -448,7 +448,8 @@ static uint8_t PlsrPlannerSameSetting( | |||
| static uint8_t PlsrPlannerBuildStep(PLSR_PLANNER_CONTEXT *context, | |||
| uint32_t requestedHz, | |||
| PLSR_PLATFORM_TIMER_SETTING *setting) | |||
| PLSR_PLATFORM_TIMER_SETTING *setting, | |||
| uint32_t *normalizedRequestedHz) | |||
| { | |||
| if (requestedHz == 0UL) | |||
| { | |||
| @@ -458,6 +459,7 @@ static uint8_t PlsrPlannerBuildStep(PLSR_PLANNER_CONTEXT *context, | |||
| { | |||
| requestedHz = PLSR_FREQUENCY_MAX_HZ; | |||
| } | |||
| *normalizedRequestedHz = requestedHz; | |||
| return PlsrPlatformBuildTimerSetting(context->block.pulseOutput, | |||
| PLSR_OUTPUT_PULSE_DIR, | |||
| requestedHz, setting); | |||
| @@ -465,7 +467,8 @@ static uint8_t PlsrPlannerBuildStep(PLSR_PLANNER_CONTEXT *context, | |||
| static uint8_t PlsrPlannerTakeRampStep( | |||
| PLSR_PLANNER_CONTEXT *context, | |||
| PLSR_PLATFORM_TIMER_SETTING *setting) | |||
| PLSR_PLATFORM_TIMER_SETTING *setting, | |||
| uint32_t *requestedFrequencyHz) | |||
| { | |||
| uint64_t nextBoundaryQ32; | |||
| uint64_t desiredDeltaQ32; | |||
| @@ -526,7 +529,8 @@ static uint8_t PlsrPlannerTakeRampStep( | |||
| { | |||
| requestedHz = context->lastRampHz; | |||
| } | |||
| if (PlsrPlannerBuildStep(context, (uint32_t)requestedHz, setting) == 0U) | |||
| if (PlsrPlannerBuildStep(context, (uint32_t)requestedHz, setting, | |||
| requestedFrequencyHz) == 0U) | |||
| { | |||
| return 0U; | |||
| } | |||
| @@ -548,6 +552,7 @@ static uint8_t PlsrPlannerTakeRampStep( | |||
| static uint8_t PlsrPlannerTakeStep(PLSR_PLANNER_CONTEXT *context, | |||
| PLSR_PLATFORM_TIMER_SETTING *setting, | |||
| uint32_t *requestedFrequencyHz, | |||
| uint32_t *repeatCount) | |||
| { | |||
| uint32_t entryEnd = context->entryPulses; | |||
| @@ -565,20 +570,23 @@ static uint8_t PlsrPlannerTakeStep(PLSR_PLANNER_CONTEXT *context, | |||
| PlsrPlannerStartRamp(context, 1U, context->startHz, | |||
| context->peakHz, context->entryPulses); | |||
| } | |||
| return PlsrPlannerTakeRampStep(context, setting); | |||
| return PlsrPlannerTakeRampStep(context, setting, | |||
| requestedFrequencyHz); | |||
| } | |||
| if (context->generatedPulses < steadyEnd) | |||
| { | |||
| *repeatCount = steadyEnd - context->generatedPulses; | |||
| context->rampKind = 0U; | |||
| return PlsrPlannerBuildStep(context, context->peakHz, setting); | |||
| return PlsrPlannerBuildStep(context, context->peakHz, setting, | |||
| requestedFrequencyHz); | |||
| } | |||
| if (context->rampKind != 2U) | |||
| { | |||
| PlsrPlannerStartRamp(context, 2U, context->peakHz, | |||
| context->endHz, context->exitPulses); | |||
| } | |||
| return PlsrPlannerTakeRampStep(context, setting); | |||
| return PlsrPlannerTakeRampStep(context, setting, | |||
| requestedFrequencyHz); | |||
| } | |||
| PLSR_PLANNER_STATUS PlsrPlannerBegin(PLSR_PLANNER_CONTEXT *context, | |||
| @@ -695,6 +703,7 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, | |||
| uint16_t capacity) | |||
| { | |||
| PLSR_PLATFORM_TIMER_SETTING setting; | |||
| uint32_t requestedFrequencyHz; | |||
| uint32_t repeatCount; | |||
| uint16_t produced = 0U; | |||
| @@ -706,7 +715,8 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, | |||
| while ((produced < capacity) | |||
| && (context->generatedPulses < context->block.pulseBudget)) | |||
| { | |||
| if (PlsrPlannerTakeStep(context, &setting, &repeatCount) == 0U) | |||
| if (PlsrPlannerTakeStep(context, &setting, &requestedFrequencyHz, | |||
| &repeatCount) == 0U) | |||
| { | |||
| context->active = 0U; | |||
| break; | |||
| @@ -714,6 +724,8 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, | |||
| if ((produced != 0U) | |||
| && (PlsrPlannerSameSetting(&output[produced - 1U].setting, | |||
| &setting) != 0U) | |||
| && (output[produced - 1U].requestedFrequencyHz | |||
| == requestedFrequencyHz) | |||
| && (output[produced - 1U].repeatCount | |||
| <= 0xFFFFFFFFUL - repeatCount)) | |||
| { | |||
| @@ -722,6 +734,7 @@ uint16_t PlsrPlannerGenerate(PLSR_PLANNER_CONTEXT *context, | |||
| else | |||
| { | |||
| output[produced].setting = setting; | |||
| output[produced].requestedFrequencyHz = requestedFrequencyHz; | |||
| output[produced].repeatCount = repeatCount; | |||
| produced++; | |||
| } | |||
| @@ -35,6 +35,7 @@ typedef struct | |||
| typedef struct | |||
| { | |||
| PLSR_PLATFORM_TIMER_SETTING setting; | |||
| uint32_t requestedFrequencyHz; | |||
| uint32_t repeatCount; | |||
| } PLSR_STREAM_ITEM; | |||
| @@ -91,6 +91,10 @@ uint8_t PlsrPlatformRetargetFiniteStop(uint8_t pulseOutput, | |||
| uint32_t drainPulses); | |||
| uint8_t PlsrPlatformFiniteRetargetReady(uint8_t pulseOutput, | |||
| uint32_t *activeFrequencyHz); | |||
| uint8_t PlsrPlatformFinitePipelineSnapshot(uint8_t pulseOutput, | |||
| uint32_t *committedPulses, | |||
| uint32_t *tailFrequencyHz, | |||
| uint8_t *startsNextSegment); | |||
| uint8_t PlsrPlatformFiniteProgress(uint8_t pulseOutput, | |||
| uint32_t *completedPulses); | |||
| uint8_t PlsrPlatformTakeFiniteCompletion(uint8_t pulseOutput, | |||
| @@ -527,6 +527,27 @@ uint8_t PlsrPlatformFiniteRetargetReady(uint8_t pulseOutput, | |||
| return (PlsrHostFiniteStepCount[pulseOutput] == 0U) ? 1U : 0U; | |||
| } | |||
| uint8_t PlsrPlatformFinitePipelineSnapshot(uint8_t pulseOutput, | |||
| uint32_t *committedPulses, | |||
| uint32_t *tailFrequencyHz, | |||
| uint8_t *startsNextSegment) | |||
| { | |||
| uint32_t remaining; | |||
| if ((pulseOutput > 3U) || (committedPulses == NULL) | |||
| || (tailFrequencyHz == NULL) || (startsNextSegment == NULL) | |||
| || (PlsrHostFiniteActive[pulseOutput] == 0U)) | |||
| { | |||
| return 0U; | |||
| } | |||
| remaining = PlsrHostFiniteTarget[pulseOutput] | |||
| - PlsrHostFiniteEmitted[pulseOutput]; | |||
| *committedPulses = remaining; | |||
| *tailFrequencyHz = PlsrHostQueuedFrequency[pulseOutput]; | |||
| *startsNextSegment = 0U; | |||
| return 1U; | |||
| } | |||
| uint8_t PlsrPlatformFiniteProgress(uint8_t pulseOutput, | |||
| uint32_t *completedPulses) | |||
| { | |||
| @@ -3410,6 +3431,63 @@ uint8_t PlsrPlatformFiniteRetargetReady(uint8_t pulseOutput, | |||
| return ready; | |||
| } | |||
| uint8_t PlsrPlatformFinitePipelineSnapshot(uint8_t pulseOutput, | |||
| uint32_t *committedPulses, | |||
| uint32_t *tailFrequencyHz, | |||
| uint8_t *startsNextSegment) | |||
| { | |||
| TIM_TypeDef *counter; | |||
| uint32_t criticalState; | |||
| uint32_t blockCount; | |||
| uint32_t remaining; | |||
| if ((pulseOutput > 3U) || (committedPulses == NULL) | |||
| || (tailFrequencyHz == NULL) || (startsNextSegment == NULL)) | |||
| { | |||
| return 0U; | |||
| } | |||
| criticalState = PlsrPlatformEnterCritical(); | |||
| if ((PlsrFiniteActive[pulseOutput] == 0U) | |||
| || (PlsrFiniteStreamActive[pulseOutput] == 0U) | |||
| || (PlsrCounterIndexByOutput[pulseOutput] >= PLSR_COUNTER_COUNT)) | |||
| { | |||
| PlsrPlatformExitCritical(criticalState); | |||
| return 0U; | |||
| } | |||
| counter = PlsrCounters[PlsrCounterIndexByOutput[pulseOutput]]; | |||
| blockCount = (uint16_t)counter->CNT; | |||
| if (blockCount >= PlsrFiniteCounterPreload[pulseOutput]) | |||
| { | |||
| blockCount -= PlsrFiniteCounterPreload[pulseOutput]; | |||
| } | |||
| else | |||
| { | |||
| blockCount = 0UL; | |||
| } | |||
| remaining = PlsrFiniteRemainingPulses[pulseOutput]; | |||
| if (blockCount > remaining) | |||
| { | |||
| blockCount = remaining; | |||
| } | |||
| remaining -= blockCount; | |||
| *tailFrequencyHz = PlsrTimerActiveFrequencyHz[pulseOutput]; | |||
| *startsNextSegment = 0U; | |||
| if (PlsrFiniteStreamNextValid[pulseOutput] != 0U) | |||
| { | |||
| uint32_t nextPulses = PlsrFiniteStreamNextPulses[pulseOutput]; | |||
| remaining = (nextPulses > (0xFFFFFFFFUL - remaining)) | |||
| ? 0xFFFFFFFFUL : remaining + nextPulses; | |||
| *tailFrequencyHz = | |||
| PlsrFiniteStreamNextSetting[pulseOutput].actualFrequencyHz; | |||
| *startsNextSegment = | |||
| PlsrFiniteStreamNextStartsSegment[pulseOutput]; | |||
| } | |||
| *committedPulses = remaining; | |||
| PlsrPlatformExitCritical(criticalState); | |||
| return 1U; | |||
| } | |||
| uint8_t PlsrPlatformFiniteProgress(uint8_t pulseOutput, | |||
| uint32_t *completedPulses) | |||
| { | |||