generated from hulk/gd32f4xx_template_cmake_vscode
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
000781e2f5 | ||
|
|
2f43efb94b | ||
|
|
8900240c65 | ||
|
|
8035f9b7d3 | ||
|
|
60908a7709 | ||
|
|
c9aa41685f | ||
|
|
974a077e29 | ||
|
|
12ba9de05f |
@@ -79,6 +79,188 @@
|
||||
- 实测 30℃ 闭环(NTC 贴 PTC 鳍片):30s 爬升、超调 1.4℃(7%)、60s 收敛中
|
||||
- M3/M4 已从软件桩切到 heat 模块(协议帧不变)
|
||||
- 协议要点:全幂等(ack 丢可重发)、响应超时建议 300ms×3 次、温度一律 ASCII
|
||||
- **PID 抗积分饱和**(5b24b95):条件积分(back-calculation lite)抑制输出限幅
|
||||
期间的积分累积。
|
||||
- **PID 全面重写(2026-10-15,待真机验证)**——真机 30℃ 阶跃 +2.4℃ 过冲、
|
||||
30~60s 周期衰减震荡(GUI 截图)驱动,三个根因全部修复:
|
||||
① `s_prev_err` 在 err>=0 路径从不更新(c9aa416 early-return 重写时把末尾的
|
||||
`s_prev_err = err` 弄丢)→ D 项退化为 +Kd*err 正向比例,"微分阻尼"从未生效,
|
||||
且把抗饱和判据钉在饱和态冻死积分(仿真中大阶跃贴温和带边缘极限环、到不了
|
||||
目标);改**微分基于温度变化率(D on measurement,-Kd*ΔT/拍)**,`s_prev_temp`
|
||||
全路径刷新,u 钳 [0,100] 防负值量化回绕;
|
||||
② 积分无界(err<0 每拍 -|err| 无下限)→ 过冲/下调目标后积分深负,温和带内
|
||||
u=min(25,u_pd)+Ki*I 永远 <=0 → 加热长时间锁死、跌出温和带才全功率(极限环);
|
||||
改**积分钳 [0, HEATER_PID_I_MAX=2000]**(Ki*I_MAX=100%);
|
||||
③ 12ba9de 的目标斜坡在 c9aa416 被整段删除 → 恢复 `HEATER_RAMP_CPS 0.8℃/s`
|
||||
+ 使能时从当前温度起坡(`s_ramp_target`,升/降同限速);
|
||||
另修三项:④ 抗饱和判据改看**最终输出** u(原先看温和带重算前的 u);⑤ PWM
|
||||
相位**仅升档复位**(原先降档也复位,降占空反而补发一整段 ON 脉冲,系统性
|
||||
多给热);⑥ NTC 故障 **3 拍(1.5s)去抖确认**——真机曲线两个孤立 OPEN 毛刺
|
||||
每次把 PID 状态整个清零也是震荡推手(毛刺拍保持输出不动;注意毛刺根因在
|
||||
采样/硬件侧仍待查,GUI 按原始采样报故障不变)。
|
||||
离线仿真(`sim_pid_check.py`:一阶热容+传感器滞后模型、2s/5% 慢 PWM 执行器、
|
||||
旧/新同模型对比):30℃ 阶跃到达后波动 ±0.4℃→+0.04℃;50℃ 阶跃旧版极限环
|
||||
到不了目标 vs 新版 94s 到达零过冲;50→40 下调 44s 恢复、波动 <±0.6℃。
|
||||
固件版本升 **V0.1.0**(M999 报 `v41.0.1.0`),已烧录上板(OpenOCD GD-LINK,
|
||||
dump_image 读回与 elf 逐字节比对一致;注意该 xpack OpenOCD 的
|
||||
flash write/verify 回显日志会丢失,核验用 dump_image+文件比对)。
|
||||
设计说明:保持**位置式** PID——执行器是绝对占空设定,分段强制关断/使能切换/
|
||||
故障复位在位置式下最干净;增量式的抗饱和优势已由积分钳位+条件积分覆盖。
|
||||
- **PID V2 抗震荡迭代(2026-10-15,V0.1.1 已烧录待真机确认)**——V0.1.0 真机
|
||||
复测:过冲/OPEN 毛刺问题已消(峰 +0.4~0.5、无故障标记),但目标附近仍有
|
||||
**±0.7~1.0℃/15~20s 三角波极限环**(50℃ 目标实测截图)。根因:err<0 硬切
|
||||
0% + err>=0 仅 Kp*err,稳态维持功率(~10% 级)全靠 Ki=0.05 慢填(要几分钟)
|
||||
→ 目标附近等效"P+上侧死区"继电器,叠加热滞后即极限环。V2 改法(`heat.c`
|
||||
pid_compute 重写核心):
|
||||
① **连续 PID 覆盖全误差区间**——去掉 err<0 硬切,过目标后 P 负向顶、积分
|
||||
正向维持,输出钳 [0,100] 天然处理"只能加热";
|
||||
② **Ki 0.05→0.25**(稳态功率 ~1 分钟内建立;扫参显示 0.5 在传感器滞后 8s
|
||||
工况下积分环失稳 ±0.9、1.0 更差,0.25 全工况稳定);
|
||||
③ **积分吸收限窄带**:仅 |err|<HEATER_PID_INTEG_ZONE_C(2℃) 且 P/D 未被
|
||||
钳(u_pd_raw∈(0,cap))时吸收——爬升/温和带钳位段不积分,防瞬态 windup
|
||||
(Ki 提快后若爬升段照吸,30℃ 阶跃仿真冲到 36℃);err<0 且 u>0 照常有界
|
||||
回退;I_MAX 同步 2000→400(Ki*I_MAX=100% 不变)。
|
||||
仿真(`sim_pid_check.py`,V1/V3 对比 + 参数扫描 `python sim_pid_check.py
|
||||
sweep`,基准/恶劣两工况):恶劣工况 50℃ 阶跃到达后 [49.70,50.11](过冲
|
||||
+0.11℃)、末端残余 ±0.02℃;30℃ 阶跃末端 ±0.01~0.02℃;50→40 下调 66s
|
||||
恢复 ±0.02℃。已烧录 V0.1.1(M999 报 `v41.0.1.1`,dump 比对核验一致)。
|
||||
- **PID V3 修"突然飙升 4℃"(2026-10-15,V0.1.2)——DAPLink 旁路抓证定位**:
|
||||
V0.1.1 真机稳态后温度以 0.12℃/s 平滑飙升 +2~4℃ 再回落。定位手段(可复用):
|
||||
① COM5 脚本探针(`probe_once.py`/`probe_state.py`)确认目标 50/加热 ON,
|
||||
并现场抓到 **14 帧连续 OPEN(~1.4s)**——NTC 间歇开路真实存在(硬件侧
|
||||
待修);② **OpenOCD `mrw` 旁路观测**(`observe2.tcl`+`obs_decode.py`,
|
||||
每 500ms 不 halt 读 RAM:duty/integral/ramp/target/temp——注意该 xpack
|
||||
OpenOCD 的 echo/mdw 控制台回显会丢失,须用 TCL 落文件)。全程轨迹证实
|
||||
**无故障参与的 90~120s 自持极限环**:积分 0↔65 打摆(真机 50℃ 维持功率
|
||||
~10-15%,即 I≈40~60 才是正确稳态),机理=**err<0 时 P 被钳 0、输出只剩
|
||||
Ki·I 恒定值**——过线后输出不随超温幅度收敛→温度必然冲过头→过冲把积分
|
||||
排空→排空后 sag→重建再过冲。NTC 毛刺清零积分只是点火器。V0.1.2 修复:
|
||||
① **err<0 时 P 负项+D 直接抵扣积分后钳 0**(输出随超温幅度立即收敛,
|
||||
积分按剩余需要维持→可收敛到稳态不再打摆);② **NTC 短故障(≤3s 且
|
||||
|ΔT|<2℃)无扰还原积分**(确认沿暂存 `s_fault_stash_I/T`,防毛刺清零
|
||||
引发 sag-重建-过冲链);③ I_MAX 400→200(Ki·I_MAX=50%,120℃ 模具维持
|
||||
余量足够)。判定测试基建:`test_run.py`(固定序列 M999→M2S500→M4S50→
|
||||
M3S1→N 秒→收尾;**预定义判据 J1 上报率/J2 稳态均值±0.5/J3 稳态极值±1.0/
|
||||
J4 全程 max≤52/J5 无故障**,>54℃ 自动熔断 M3S0——开发/测试分工,脚本
|
||||
自动判定避免自判自;注意 FF485 frame.payload 已是 str)。
|
||||
- **PID V3 判定迭代与收敛(V0.1.2→V0.1.3,2026-10-15,900s 判定测试)**:
|
||||
- V0.1.2(P-veto + 故障无扰还原 + I_MAX 200)判定:J1/J2/J5 PASS,但
|
||||
**J3 FAIL(稳态窗 [46.6,52.1])、J4 FAIL(max 52.5)**——P-veto 消灭了
|
||||
积分排空式打摆(观测器证实收敛末段积分稳在 ~48=真实维持功率、温度
|
||||
±0.2),但双热容被控对象(鳍片+本体,NTC 只见鳍片)每次顶/谷都给
|
||||
对方"预存"动量,15 分钟才收敛;且斜坡 0.8℃/s 远快于对象,爬升段误差
|
||||
长期 ~2℃ 把积分提前充到 30~50,首冲 +2.5℃。
|
||||
- V0.1.3:**HEATER_RAMP_CPS 0.8→0.25**(爬升误差贴地、积分只在近目标
|
||||
低功率段建立)+ **HEATER_SLOWBAND_DUTY 25→15**(近目标动作放柔,
|
||||
少给鳍片预存过热)。41℃ 冷启判定:**J1~J5 全 PASS**——到达 69s、
|
||||
首冲 +1.20℃、全程 max 51.2、稳态窗 [49.0,50.9]、均值 49.95、
|
||||
1795 帧 0 故障。观测器同步确认:远场(err>7)才见 75% 满功率,
|
||||
温和带内 15% 缓爬,50.06℃ 到达时积分 35.9/占空 5%。
|
||||
- 测试基建沉淀:`test_run.py`(判定+熔断)、`observe2.tcl`+`obs_decode.py`
|
||||
(DAPLink 旁路,**注意符号地址随 bss 布局变,重编译后须 nm 重取**;
|
||||
观测文件被占用时追加不清空)、`sim_pid_check.py`(含 V1/V3/V4 对照、
|
||||
毛刺/噪声注入、扫参)。CMD 坑:`if exist X del X && ...` 文件不存在时
|
||||
整链跳过;多行 `python -c` 偶发被吞——写文件执行。
|
||||
- **遗留(硬件侧)**:NTC 间歇开路(现场抓到 14 帧连续 OPEN ~1.4s,
|
||||
短故障无扰还原已缓解控制影响,但根因在采样/接触)——查 NTC 焊接/
|
||||
压接/走线,必要时 ntc.c 采样侧加防护。独立代码审查 agent 未在时限内
|
||||
返回,本轮定位以仪器化数据(COM5 帧流 + DAPLink 内存轨迹)为准。
|
||||
- **PID 调试全程速览(2026-10-15,V0.1.0→V0.1.14,判定测试自动出结论)**:
|
||||
|
||||
| 版本 | 关键改动 | 判定/实测结果 | 结论 |
|
||||
|---|---|---|---|
|
||||
| V0.1.0 | 全面重写:D-on-measurement+积分钳位+斜坡恢复+故障去抖 | 过冲 +2.4→+0.4 | 旧版 D 项失效/积分无界/斜坡缺失三根因修复 |
|
||||
| V0.1.1 | Ki 0.05→0.25+窄带吸收 | ±1℃ 三角波极限环 | err<0 硬切=继电器死区 |
|
||||
| V0.1.2 | P 参与过线区+故障无扰还原+I_MAX200 | J3/J4 FAIL | P-veto 生效但收敛 15min |
|
||||
| V0.1.3 | 斜坡 0.25C/s+温和带 15% | J1~J5 全 PASS(旧判据) | 稳态 [49.0,50.9] 不满足 ±0.5 |
|
||||
| V0.1.4 | 占空抖动 1.25% 分辨率 | p2p=1.8 FAIL | 量化非主因 |
|
||||
| V0.1.5 | Kp5/Kd60/Ki0.15 连续 PID | p2p=4.2 FAIL | ±2C/90s 慢摆,调参到头 |
|
||||
| V0.1.6 | 分区 bang-bang+慢积分 | 卡 48.6C FAIL | 带外平衡点陷阱 |
|
||||
| V0.1.7 | 带外也吸收 | p2p=4.1 FAIL | 吸收/回退不对称→I 上漂 90 |
|
||||
| V0.1.8 | 对称慢速积分 | p2p=4.0 FAIL | 积分稳了;秒级读数坏值穿透 |
|
||||
| V0.1.9 | 斜率限幅+带内退闸 | 无法判定 | NTC 持续开路(后重焊恢复) |
|
||||
| V0.1.10 | 去上限+RAMP2.0+温和带25% | p2p=2.7 FAIL | 快斜坡生效;坏值仍是主力 |
|
||||
| V0.1.11 | NTC 5 点滑动中值 | **p2p=1.30** | 中值滤波立竿见影 |
|
||||
| V0.1.12 | 近场 ASSIST 减速曲线 | 实时 p2p=**0.31** | 最后 2C 推力真空修复;判定窗被传感器事件污染 |
|
||||
| V0.1.13 | 大偏差冻结(>1.5C) | p2p=1.60 | 塌陷深谷消除(47.1→49.0) |
|
||||
| V0.1.14 | 对称积分加倍 0.30 | p2p=1.80 | 与 13 同水平=硬件平台期 |
|
||||
|
||||
**最终状态(V0.1.14 在板)**:到达 26s(30→50C,提速 9 倍);控制环本体
|
||||
(无事件段)p2p 0.3~0.9C 达标;判定窗残余 1.6~1.8C 全部来自 NTC 焊接点
|
||||
秒级接触不良(-2.5C/4.5s 塌陷、顶格 52.2C 卡死 10s,1.5C 以内坏值仍会穿透
|
||||
三重滤波)。**下一步:彻底重焊/更换 NTC(根治手段),固件三重防护
|
||||
(中值+斜率限幅+大偏差冻结)已就位,重焊后 `python test_run.py 1200` 一键
|
||||
重判(J3 即 ±0.5 规格)。测试环境 55C 上限已按用户要求移除(真机靠物理
|
||||
温度开关)。**
|
||||
- **±0.5℃ 波动压榨迭代(2026-10-15,V0.1.3→V0.1.6,用户规格 ±0.5C,
|
||||
判据 J3 收紧为稳态窗 [目标±0.5])**——V0.1.3 稳态窗 [49.0,50.9](压着
|
||||
旧判据 ±1.0 过)不可接受,连续三轮仪器化迭代:
|
||||
- **V0.1.4 占空抖动(Bresenham)**:载波 2s=4 拍分摊档位余数,平均分辨率
|
||||
5%→1.25%(真机维持功率 ~10-12% 恰卡在 10/15 档间,固定取整迫使
|
||||
0↔15% 满幅切换)。观测器证实抖动生效(duty 5/10/15/20/25 细粒度),
|
||||
**但极限环未消(J3 p2p=1.8 FAIL)→ 量化不是主因**。
|
||||
- **V0.1.5 降增益强阻尼(Kp 10→5 / Kd 30→60 / Ki 0.25→0.15 / 窄带 2→1)**:
|
||||
**J3 p2p=4.2 FAIL**——变成 ±2C/90s 慢摆。观测器抓到机理:带内半周期
|
||||
净吸积分 +18(err 0.5~1.0 持续 30s),过线后 P-veto 每拍泄 2.8 快速
|
||||
排空 → 维持功率记忆反复丢失 → 谷底深潜 48.6。**连续 PID 在本对象
|
||||
(双热容+单向执行器+0.1C 读数量化)上调参到头**;Kd=60 还放大 NTC
|
||||
0.1C 量化跳变成 ±6% duty 抖动。
|
||||
- **V0.1.6 分区 bang-bang + 慢积分学习(结构性换型)**:作用分三区——
|
||||
带内 |err|<HEATER_PID_BAND_C(0.4C):P/D 退出,u=Ki*I 恒功率,积分慢速
|
||||
学习维持功率(I+=err*0.3/拍);带外下方:P 拉回(温和带钳位不变),
|
||||
积分不吸收;带外上方:关断 + I 固定 0.3/拍限速回退(保住维持功率
|
||||
记忆)。波动幅度被物理限制在带宽+滑行量,I 学准后驻留带内。
|
||||
Kd 回 30(60 的噪声放大不可用)、I_MAX 回 400(Ki*I_MAX=60%)。
|
||||
- **V0.1.6 判定 FAIL(带外平衡点陷阱)**:观测器抓到温度卡 48.6C——P 输出
|
||||
恰好平衡散热、err 停带外,积分只在带内学习永远没机会介入。V0.1.7 修复
|
||||
(带外下方 P 未被钳时同样慢速吸收):前半程良好(49.8/integ 37 稳),但
|
||||
**吸收(0.3/拍)与回退(0.3/拍固定)不对称 → I 上漂到 90 持续超供**,后半程
|
||||
大摆(J3 p2p=4.1 FAIL)。V0.1.8 改全区域对称慢速(GAIN=BLEED=0.15):
|
||||
积分不漂了(35~42 窄幅)、带内占有率 44%,**但 J3 仍 FAIL(p2p=4.0)**——
|
||||
csv 分析抓到 **-1.2C/0.5s 读数突跳 + 5s 后 +1.8C 回弹**(非故障级 NTC
|
||||
毛刺,真实热速率 <=0.2C/拍),毛刺触发 25% 猛拉 ~10s 直接制造 +2C 过冲。
|
||||
- **V0.1.9(待判定)**:① NTC 读数斜率限幅(HEATER_NTC_SLEW_C=0.5C/拍,
|
||||
超限沿限幅值过渡、连续 2 拍放行——毛刺对环路的冲击源头掐断);② 带内
|
||||
功率线性退闸(下缘 100% 维持量 -> 上缘 0%,过线前功率已趋零,蓄热滑行
|
||||
收窄)。已烧录 MATCH。
|
||||
- **NTC 硬件恶化为持续开路(阻塞判定)**:V0.1.9 判定测试全程 M1/M2 报
|
||||
OPEN(帧流正常、命令全 ok),加热安全关断(故障路径实测有效)。用户
|
||||
重焊后恢复(30.1C 稳定读数),进入 V0.1.10~V0.1.14 快速迭代:
|
||||
- **V0.1.10 去上限 + 提速**:测试脚本去掉 54C 自动熔断/J4 上限判据(用户
|
||||
明确:真机靠物理温度开关,55C 只是测试环境约束);RAMP 0.25→2.0(实测
|
||||
0.25 时斜坡本身就是瓶颈,9C 走 36s);温和带 15%→25%。判定:reach 49s
|
||||
但 J3 p2p=2.7 FAIL、首冲 +3.3。
|
||||
- **V0.1.11 NTC 5 点滑动中值**(ntc.c 采样侧):V0.1.10 稳态摆的根源确认为
|
||||
秒级读数坏值穿透斜率限幅(中值滤波只挡 <250ms 毛刺)。效果显著:
|
||||
稳态 p2p 4.0→**1.30**([49.40,50.70])。温和带 7→3C、25→35%。
|
||||
- **V0.1.12 近场 ASSIST 减速曲线**:分段速率分析钉死最后 2C 爬 141s 的瓶颈
|
||||
=近场推力真空(P 随 err 缩小消失、I 未成熟)。近场扩展区 [-0.3,3.0)
|
||||
合并退闸+缓爬:u = Ki*I + ASSIST 退闸曲线 + Kd,上限 NEAR_MAX_DUTY 50%。
|
||||
实时观测 p2p **0.31C**(达标水平);但判定窗两极值(47.1 塌陷 + 52.2
|
||||
顶格卡死)均为传感器事件:**-2.5C/4.5s 塌陷与顶格 10s,焊接点仍不稳**。
|
||||
- **V0.1.13 大偏差冻结**:滤波值与原始读数偏差 >1.5C(HEATER_NTC_JUMP_C)
|
||||
冻结不跟随,回弹自动恢复(真实冷却 ~0.05C/拍永不误触发)。判定 p2p
|
||||
1.60([49.00,50.60])——塌陷深谷消除。reach 32s(提速 7 倍)。
|
||||
- **V0.1.14 对称积分加倍**(GAIN=BLEED 0.15→0.30,I 充满 400s→200s,修复
|
||||
判定窗内驻留点偏低):p2p 1.80([49.20,51.00]),与 V0.1.13 同水平
|
||||
(差异在传感器事件噪声内)——**到达当前硬件条件下的平台期**。
|
||||
- **当前结论**:控制环本体(无事件段)p2p≈0.3~0.9 达标;残余 1.6~1.8 全部
|
||||
来自 NTC 焊接点秒级接触不良(塌陷/顶格事件,1.5C 以内的坏值仍会穿透
|
||||
防护)。**下一步是彻底重焊/更换 NTC 及其引线**,固件侧三重防护
|
||||
(中值+斜率限幅+大偏差冻结)已就位。到达速度 26s(30→50C,提速 9 倍)。
|
||||
测试/观测基建:test_run.py(无熔断版,J1/J2/J3±0.5/J4 无故障自动判定)、
|
||||
observe2.tcl+obs_last.py(**每次重编译必须 nm 重取符号地址**)、
|
||||
seg_rate.py(分段爬升速率)、log_tail.py(实时帧解析)。
|
||||
中间数据(obs_samples.txt/test_data.csv/test_events.log)已清理,
|
||||
由测试/观测脚本重新生成。
|
||||
- 方法论沉淀:单状态热模型仿真始终未复现真机极限环(双热容建不进单
|
||||
状态),本轮所有定位直接以 DAPLink 观测器 + COM5 判定脚本为准;
|
||||
观测符号地址 V0.1.4 起稳定(s_duty=0x20000459 / s_integral=0x20000454 /
|
||||
s_ramp_target=0x2000045c / s_target=0x20000460 / s_enable=0x20000464)。
|
||||
- **50℃→40℃ 阶跃验证脚本**(`temp_test_com.py`,工作区修改未提交):M2S500
|
||||
全曲线采样,输出超调/settle 时间/稳态误差/降温段统计(结果以实跑为准)。
|
||||
- **电源保持 PA11 实装**(`pwr_hold.h/pwr_hold.c`):上电最先拉高 PA11 维持
|
||||
PMOS 自锁供电(先于一切外设初始化);`pwr_hold_off()` 预留软关机(置低即失电)。
|
||||
- **专用 GUI 上位机 v0.1.0 完成**(`GUI/`,参照 DLPilot 分层:协议模块纯标准库 +
|
||||
PySide6-Fluent 界面 + 自检 + PyInstaller):
|
||||
- 文件角色:`filmforge_rs485.py`(帧/解析/串口线程 FF485)、`app_fluent.py`
|
||||
@@ -116,11 +298,124 @@
|
||||
- [ ] **适配器更换**:换可靠的 USB-RS485 适配器(当前自动方向切换间歇丢响应)。
|
||||
- [x] **加热驱动 + PID 闭环**(PB12 慢 PWM 1s/10% 步进 + PID 500ms 周期,
|
||||
安全保护齐全;实测 30℃ 闭环通过:30s 爬升、超调 1.4℃、60s 收敛中)。
|
||||
- [ ] **PID 参数整定**:初值 Kp=8/Ki=0.05/Kd=0(board_config.h 宏),按真实
|
||||
工况温度(80~120℃)与负载重新整定,抑制 ~7% 超调。
|
||||
- [x] **专用 GUI 上位机 v0.1.0**:`GUI/` 已完成并通过 COM5 真机全链路验证
|
||||
(协议按 `protocols.md`;遗留见其 README"已知事项":应用图标、加热硬件
|
||||
接入后的闭环联调、固件 M3/M4 硬件执行体实现)。
|
||||
- [ ] **NTC 彻底重焊/更换(当前头号事项)**:焊接点秒级接触不良已用
|
||||
DAPLink+COM5 数据链钉死(-2.5C/4.5s 塌陷、顶格卡死、曾持续开路),
|
||||
是 ±0.5C 规格最后的拦路虎;固件三重防护已就位(V0.1.14)。
|
||||
- [ ] **±0.5℃ 规格复判**:NTC 重焊后 `python test_run.py 1200`(J3=±0.5);
|
||||
当前稳态控制环本体 p2p 0.3~0.9C 已达标。真实工况温度(80~120℃)下
|
||||
需复测整定(Kp=8/Ki=0.15/Kd=30、ASSIST 35%、I_MAX400,board_config.h)。
|
||||
- [x] **专用 GUI 上位机 v0.2.0**:v0.1.0 已完成 COM5 真机全链路验证;v0.2.0 与
|
||||
实装固件对齐——去除"固件预留/仅软件状态"旧表述(协议模块预设说明、温控
|
||||
卡片标题/备注、开启加热确认弹窗改为真实控温警示、ack 提示),GUI README
|
||||
与 publish_gitea 发布说明模板同步,protocols.md M3 表项去"预留";
|
||||
监控逻辑简化:温度监控统一走 M2 周期上报(移除"M1 自动轮询"双通道及
|
||||
poll 配置项,M1 仅手动单点查询),目标温度滑条范围 0~200→0~120(协议
|
||||
上限 200 仍可手输)。**v0.2.1 真机回归修复**(COM5 实测暴露):①M4 查询
|
||||
应答与周期上报帧同为 ASCII 浮点被误占(目标 chip 显示成实测值)→ 查目标
|
||||
改应答驱动"暂停上报 M2S0→M4→自动恢复 M2S<ms>"链;②设定 ok 延迟 >1.2s
|
||||
超时清队后状态 chip 与设备脱节(实测 M2S100 ok 迟到、chip 停 OFF)→ 8s
|
||||
内晚到 ok 按发送时计划补偿应用;③断开按钮偶发不切换、断开后日志仍刷 →
|
||||
连接按钮改意图驱动(点击即翻转、2s 未建立判丢失回退),已连接/已断开/
|
||||
丢失全部入日志,连接同步序列每步发送前确认在线(修复序列整组被
|
||||
"未连接串口"丢弃)。selftest 全过(新增 8 项回归用例);已打包冒烟并发布
|
||||
Gitea Release `FilmForge-GUI-v0.2.1`(无应用图标——`logos/filmforge.ico`
|
||||
仍缺位)。
|
||||
- **GUI v0.2.2~v0.2.13(递进修复/新功能,Gitea 发布待确认)**:
|
||||
- v0.2.2:串口线程 `read(256)` 在持续帧流下攒满 256 字节才返回(win32 字节
|
||||
间隔超时因帧间隔更短永不触发,100ms 上报 ≈2.5s 一批),曲线/命令/响应全部
|
||||
3 秒一卡、一股脑一大坨——真机日志确认每簇 ≈25 帧、间隔恒 3s。已改
|
||||
`read(in_waiting or 1)`,真机复测曲线实时、命令即发即回。
|
||||
- v0.2.3:发送入口幂等去重——同命令未决时忽略重复发送(真机连点"停止"12 次
|
||||
挤爆 8 条 pending 队列、"丢弃积压命令"刷屏),状态栏提示"应答中"。
|
||||
- v0.2.4:目标阶梯线渲染 bug(v0.1.0 起潜伏)——`_target_points` 对最后一次
|
||||
目标变更只画终点,从旧值斜拉直线到图表右缘(实测 57→41 被画成 2 分钟斜坡,
|
||||
截图确认);改为纯阶梯(变更时刻垂直落变+水平延伸),数据本身无误。
|
||||
- v0.2.5:排版调整——串口连接卡片从右列移到左列顶部,左列图表卡/右列日志卡
|
||||
弹性拉伸填充,两列底边对齐。
|
||||
- v0.2.6:对齐修复——双列改放同一滚动容器(整页统一滚动):原先两列各自包
|
||||
QScrollArea 独立滚动,窗口化下底边错位(全屏正常);现两列高度锁定一致,
|
||||
不足一屏各自拉伸到底、超出一屏整页滚动。
|
||||
- v0.2.7:图标接入(logos/ 下 ico/png/svg 已就位)——exe 嵌入图标
|
||||
(build_exe.py --icon,此前缺文件自动跳过)、窗口标题栏/任务栏/弹窗图标
|
||||
(app+window 两级 setWindowIcon,_icon_path() 兼容源码态与 _MEIPASS 打包态)、
|
||||
Windows 任务栏独立标识 AppUserModelID "hulk.FilmForgeGUI"。README"无应用
|
||||
图标"遗留项关闭。
|
||||
- v0.2.8:**慢性卡顿修复**——`_log()` 手工 cursor 裁剪(`cursor.Start`/
|
||||
`BlockUnderCursor` 实例级枚举访问)在 PySide6 6.11 上必然抛 AttributeError,
|
||||
日志超 500 行(100ms 上报 ≈50s)后每个 100ms tick 异常中止 `_tick` 后半段:
|
||||
应答超时清理失效(pending 永不清 → "停止"点击被幂等去重永久拦截,实拍状态栏
|
||||
"M2S0 应答中,已忽略重复发送")、RX 队列积压 ratchet(帧处理滞后 54ms→均值
|
||||
1037ms/峰值 2701ms,日志/chip/曲线显示滞后数秒,"停止 5s 后才见命令发出"实为
|
||||
日志显示积压,TX 本身 5~166ms 即出)、日志无限增长、CPU +30%。改原生
|
||||
`setMaximumBlockCount(500)` 环形裁剪;selftest 新增"500 行封顶/封顶后可追加/
|
||||
最新行在末尾"3 条回归。离屏 A/B 实测(GUI/_diag_latency_buggy.py,假固件
|
||||
100ms 上报+定时点击):buggy 组 5.5 分钟异常 2241 次、修复组全程漂移 ≤15ms/
|
||||
零异常/零积压。v0.2.7 exe(2026-10-10 打包)内嵌同版本 PySide6 6.11.2,
|
||||
同带此 bug,需重新打包生效。
|
||||
- v0.2.9:**整体审查修复 5 项**——①CSV 写失败(磁盘满/记录盘被拔)自动停记录
|
||||
并提示,不再沿 _tick 帧处理路径抛 OSError 中止主循环(与 v0.2.8 日志裁剪同款
|
||||
失效管道);②故障标记 faults 封顶 1000 条(曾是唯一无上限缓冲:NTC 抖动时
|
||||
10 条/s 无限增长,且 _draw_faults 每 paint 全量扫描);③串口写超时单独捕获
|
||||
SerialTimeoutException——只丢该条命令、链路保留,不再按 OSError 当"设备拔出"
|
||||
关端口断链(适配器停滞曾迫使手动重连);④日志自动滚动仅在贴底时跟随(上报
|
||||
运行时可上翻历史,不再被拽回底部);⑤冻结视图切片右缘同样二分(暂停时长不再
|
||||
增加 render 成本)。selftest 新增 7 项回归(封顶/滚动跟随/CSV 失败自停/冻结
|
||||
切片)共 59 项全过。整体审查报告(2026-10-12)其余 P3 遗留:selftest 未覆盖
|
||||
_tick 主循环(超时清理/丢失判定)、"全部"窗口全量切片有 MAX_SAMPLES 兜底、
|
||||
_last_set 单槽位罕见误补偿(8s 自愈)、build_exe remove_stale 无提示强杀
|
||||
运行实例、M888 后固定 2s 重查可能早于固件就绪、_danger_reset 把去重忽略记成
|
||||
"已下发"。协议建议的 300ms×3 自动重发维持不做(设计决策,用户拍板)。
|
||||
- v0.2.10:**曲线 Y 轴手动量程 + "暂无数据"提示不消失修复**——①"实时温度曲线"
|
||||
卡新增 Y 轴行:下限/上限输入框 + 自动勾选钮(默认勾选=随可见数据自适应;
|
||||
取消勾选=按输入框固定量程,非法输入回退最近一次自适应范围并经状态栏提示;
|
||||
首次切手动按当前自适应范围预填平滑接管;固定量程下曲线越界 setClipRect
|
||||
裁剪回绘图区、当前值标记超量程不画,实时读数卡仍可见)。模式+量程随
|
||||
`filmforge_gui.json` 持久化(y_axis 键,存值非法一律回自动)。
|
||||
②提示文字不消失根因(用户标注截图):`set_source` 仅在构建期调用一次且在
|
||||
那里缓存 `_has_data` 布尔——数据经同一列表引用追加后该布尔永不刷新 → 改为
|
||||
paintEvent 实时判定(图表本就持有引用,无需其他调用点适应)。selftest 新增
|
||||
15 项回归(切换往返/兜底预填/无效回退/量程落地/越界条带像素=0/新图数据到位
|
||||
提示像素=0;断言须取当前主题曲线色、上条带避开 y=9 行图例色块),总数 74
|
||||
全过;离屏整窗预览目检(手动/自动对照)布局正常。
|
||||
- v0.2.11:**Y 轴量程控制改右缘滑条(用户要求放右侧、滑条形式)**——移除
|
||||
v0.2.10 的下限/上限输入框行,"Y轴自动/手动"勾选钮移到暂停/清空行右端;
|
||||
TemperatureChart 右缘新增滑条栏(GUTTER_W 18px:轨道+量程带+双把手):
|
||||
上把手=上限、下把手=下限、把手中带拖拽=整段平移(跨度保持)、点击轨道
|
||||
空白把对应边界直接设到该处;把手在**参考域**(全量数据范围 ±15%,`_y_ref`)
|
||||
上映射像素,auto 态把手半透明、首次任意拖拽即切手动(`yManualEngaged`
|
||||
同步按钮态+配置),松手量化 0.1℃ 经 `yRangeChanged` 记设置(最小间距
|
||||
0.5℃),按钮一键回自动;越界裁剪沿 v0.2.10 不变。坑两条(勿重蹈):
|
||||
①拖拽中的界值必须取**实时显示范围**而非 `_cached_layout` 绘制缓存——
|
||||
连续两次拖拽之间没有重绘,缓存是陈旧范围(实测 pan 把量程整段拖跳);
|
||||
②selftest 固件用真实 `filmforge_gui.json` 时"初始自动"断言会被用户存的
|
||||
手动模式击穿——改指向临时目录设置文件彻底隔离。
|
||||
selftest 重写第 8 段共 81 项全过(把手命中/拖拽切手动/量化/最小间距/
|
||||
平移保跨度/裁剪条带/提示消失);离屏手动/自动滑条目检通过。
|
||||
- v0.2.12:**滑条域定值 -20~120℃ + 标题行并排(用户截图标注 3 处)**——
|
||||
①滑条参考域从"全量数据 ±15%"改为**固定 -20~120℃**(Y_RAIL_LO/HI 宏):
|
||||
把手像素位置直接对应 ℃ 读数,空数据/冷机不再出现 -5.0 这类随数据漂移的
|
||||
误导把手位;命中/拖拽/钳位全部按此域,140℃ 域上 1px≈0.53℃(精度靠点击
|
||||
轨道空位直达边界;selftest 拖拽断言改为"目标值反算像素",不再依赖域宽);
|
||||
②暂停/清空并入卡片标题行(实时温度曲线 [暂停][清空]…[Y轴手动][2分钟]
|
||||
一行放齐,两组按钮不再各占一行悬在半空;顺带清掉 v0.2.11 重构残留的
|
||||
孤儿按钮组代码;此卡不走 self._card 通用头,内联构建支持多 trailing);
|
||||
③实测/目标图例移到绘图区左上(右侧被滑条把手/值标签占用)。selftest
|
||||
81 项全过;离屏目检(空态/手动拖后)通过。
|
||||
- v0.2.13:**把手值标签悬停门控(用户标注:常驻会遮挡曲线)**——上下限
|
||||
两个数值标签不再常驻,仅鼠标悬停在滑条栏(或拖拽中)才显示
|
||||
(`_y_hover` 翻转才重绘;leaveEvent 途中拖拽不退出悬停态,松手按位置
|
||||
重算)。selftest 新增 2 项(未悬停标签区像素=0 / 悬停出现标签),共 83
|
||||
项全过。exe 重新打包见下。
|
||||
- selftest 全过(v0.2.13 后共 83 项回归);**v0.2.13 exe 已重新打包并冒烟
|
||||
通过**(release/FilmForge.exe + FilmForge-GUI-v0.2.13-win64.zip,
|
||||
依次取代 v0.2.10/v0.2.11/v0.2.12 包),Gitea Release 待用户确认后执行。
|
||||
遗留见其 README"已知事项":USB-RS485 适配器更换。
|
||||
- [x] **GUI 慢性卡顿修复(根因已定位并修复 v0.2.8,待真机确认)**:
|
||||
根因/机理/实测数据见上方 v0.2.8 条目(`cursor.Start` 实例级枚举访问在
|
||||
PySide6 6.11 必抛 → tick 异常中止 → 应答超时清理失效 + RX 积压 ratchet +
|
||||
日志无限增长)。复现/验证脚本:`GUI/_diag_latency.py`(修复基线)/
|
||||
`GUI/_diag_latency_buggy.py`(带 bug 对照,假固件 100ms 上报+定时点击)。
|
||||
遗留:待真机长时间运行确认 + 重新打包 exe。
|
||||
- [ ] **LED 引脚确认**:FilmForge 自制板 LED 引脚未定(当前沿用模板默认 PA15,
|
||||
仅为构建占位),按原理图修改。
|
||||
- [ ] **delay_10us 校准**:M4@168MHz 系数为估算值(/300000),需实测修正。
|
||||
|
||||
+16
-2
@@ -88,11 +88,25 @@ target_link_options(${PROJECT_NAME} PRIVATE
|
||||
# 容量宏(代码中如需按容量分支使用 GD32_FLASH_SIZE_KB)
|
||||
target_compile_definitions(${PROJECT_NAME} PRIVATE GD32_FLASH_SIZE_KB=${GD32_FLASH_SIZE_KB})
|
||||
|
||||
# 生成 map 文件等
|
||||
# 生成 map 文件等。`--no-warn-rwx-segments` 仅用于抑制新版本
|
||||
# binutils 的 RWX 段警告,旧版(例如 xPack GCC 11.3 搭配 binutils 2.38)
|
||||
# 不认识该选项,因此先探测链接器能力,避免因工具链版本差异导致链接失败。
|
||||
target_link_options(${PROJECT_NAME} PRIVATE
|
||||
-Wl,-Map=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Application.map
|
||||
-Wl,--no-warn-rwx-segments
|
||||
)
|
||||
execute_process(
|
||||
COMMAND "${CMAKE_LINKER}" --help
|
||||
OUTPUT_VARIABLE FILMFORGE_LINKER_HELP
|
||||
ERROR_VARIABLE FILMFORGE_LINKER_HELP_ERROR
|
||||
RESULT_VARIABLE FILMFORGE_LINKER_HELP_RESULT
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
)
|
||||
if(FILMFORGE_LINKER_HELP_RESULT EQUAL 0 AND
|
||||
FILMFORGE_LINKER_HELP MATCHES "no-warn-rwx-segments")
|
||||
target_link_options(${PROJECT_NAME} PRIVATE -Wl,--no-warn-rwx-segments)
|
||||
else()
|
||||
message(STATUS "Linker does not support --no-warn-rwx-segments; skipping it")
|
||||
endif()
|
||||
|
||||
# 链接SDK库
|
||||
target_link_libraries(${PROJECT_NAME} PRIVATE CMSIS)
|
||||
|
||||
+58
-14
@@ -8,11 +8,18 @@ FilmForge 热压模具温控板(GD32F405, RS485 数据口)的桌面上位机
|
||||
|
||||
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
|
||||
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
|
||||
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均)。
|
||||
- **温度数据双通道**:固件 M2 周期上报帧直接入曲线;未开启上报时可由上位机按
|
||||
500ms/1s/2s 自动轮询 M1 补点(两条路读同一全局最新温度)。
|
||||
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均);
|
||||
Y 轴(温度轴)量程可选**自动**(随可见数据自适应)或**手动**——直接拖动
|
||||
图表右缘滑条(固定参考域 -20~120℃,把手位置=℃ 读数):上把手=上限、
|
||||
下把手=下限、拖动把手中段上下=整段平移并把跨度保持不变、点击轨道空白直接
|
||||
把对应边界设到该处;首次任意拖拽自动切入手动("Y轴自动/手动"按钮同步),
|
||||
把手数值标签仅悬停/拖拽时显示(不常驻遮挡曲线),曲线越界时裁剪回绘图区;
|
||||
模式与量程记忆在 `filmforge_gui.json`。
|
||||
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
|
||||
M1 仅作手动单点查询(命令区按钮/预设)。
|
||||
- **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0
|
||||
(预留功能,开启需确认)、周期上报 M2S<ms>(100~60000,10ms 步进)、预设/自定义命令。
|
||||
(真实 PID 闭环控温,开启需确认)、周期上报 M2S<ms>(100~60000,10ms 步进)、
|
||||
预设/自定义命令。
|
||||
- **状态联动**:连接后自动查询版本(M999)/上报(M2)/加热(M3)/目标(M4)并同步到状态芯片;
|
||||
软件复位(M888)/进 OTA(M9999) 二次确认,复位后 2 秒自动重查状态。
|
||||
- **收发日志**:译码 + 原始 hex,500 行上限防卡顿。
|
||||
@@ -52,23 +59,60 @@ python publish_gitea.py release/FilmForge-GUI-v<版本>-win64.zip --replace --in
|
||||
`app_fluent.py`(PySide6-Fluent 呈现层)/ `config.py`(运行目录+设置记忆)/
|
||||
`filmforge_gui.py`(入口+CLI)。协议模块不依赖 GUI。
|
||||
- **应答派发是 FIFO 队列**(`app_fluent._pending`):半双工上固件严格按收到顺序应答,
|
||||
连接序列(150ms 间隔连发 4 条)与自动轮询并存,单 pending 槽会被乱序击穿——实测踩坑:
|
||||
连接序列(150ms 间隔连发)场景下单 pending 槽会被乱序击穿——实测踩坑:
|
||||
M2 的 OFF 被 M3 槽吃掉、M4 的目标值混进温度曲线。应答形态与队首期望匹配才消费,
|
||||
不匹配记杂散,1.2s 超时自清。温度帧二义性(M1/M4/上报同为 ASCII 温度)靠
|
||||
队首期望 + 生命周期窗口拆分(上报开启时收紧到 0.5s)。
|
||||
不匹配记杂散,1.2s 超时自清。
|
||||
- **M4 查询走"暂停上报→查询→恢复"应答驱动链**:M4 应答与周期上报帧同为 ASCII
|
||||
浮点,仅靠时间窗拆分在 100ms 上报下实测必现误占(目标 chip 显示成实测温度)——
|
||||
上报开启时查目标自动执行 M2S0(ok)→M4→M2S<ms> 恢复;连接同步序列同样走此路径。
|
||||
- **晚到 ok 补偿**:USB-RS485 适配器换向延迟实测可 >4s,设定命令的 ok 迟到超过
|
||||
1.2s 已按超时清队,8s 内的迟到 ok 按发送时记录的计划补偿应用(否则状态 chip
|
||||
与设备脱节,实测:M2S100 ok 迟到,chip 停在 OFF 而设备在报)。
|
||||
- **连接按钮按"意图"翻转**:点击立即切换文本,实际开/关由串口线程异步完成,
|
||||
已连接/已断开/丢失全部写入日志;期望连接但 2s 未建立按丢失处理回退按钮。
|
||||
连接同步序列每步发送前确认在线(线程异步打开期间早发会被"未连接串口"丢弃)。
|
||||
- **时间戳在串口线程读取点打**(`FF485.on_frame(fr, t_rx)`),不晚于 GUI 轮询消费——
|
||||
避免重蹈 SerialMate 的时间戳失真问题(见 `E:\Hulk-Coding\HW-Debug-Tools\SerialMate`)。
|
||||
- M4 目标应答的路由窗口 = 条目生命周期(1.2s):USB-RS485 适配器换向偶发延迟
|
||||
实测可超 0.5s;上报开启时周期帧密集,才收紧到 0.5s 新鲜窗口。
|
||||
- **发送入口幂等去重**:同一命令已有未决应答时忽略重复发送(状态栏提示
|
||||
"应答中, 已忽略重复发送")——真机实测连点"停止"12 次会把 8 条 pending 队列
|
||||
挤爆("丢弃积压命令"刷屏);协议全幂等,同命令一条在途足够,超时自清后
|
||||
自然允许重发。
|
||||
- **串口读取按到达量收,不攒大块**:固定 `read(256)` 在持续帧流下要攒满 256 字节
|
||||
才返回(Windows 串口"字节间隔超时"150ms 因帧间隔更短永不触发,100ms 上报时
|
||||
≈2.5s 一批),表现为曲线/命令全部 3 秒一卡、一股脑一大坨。已改为
|
||||
`read(in_waiting or 1)`,延迟回到 ≤150ms 量级(v0.2.2,真机实测确认)。
|
||||
- **日志裁剪走原生 `setMaximumBlockCount(500)`**:手工 `textCursor` 裁剪用了
|
||||
`cursor.Start`/`cursor.BlockUnderCursor` 实例级枚举访问,PySide6 6.11 已移除
|
||||
(类上仍在),必然抛 AttributeError——日志超 500 行(100ms 上报 ≈50s)后每个
|
||||
100ms tick 在 `_log` 处异常中止:`_tick` 后半段(应答超时清理/连接状态机/统计
|
||||
节流)永不执行(pending 永不清 → "停止"点击被幂等去重永久拦截)、RX 队列积压
|
||||
ratchet(帧处理滞后涨到 1~2.7s,日志/chip/曲线显示全面滞后)、日志无限增长。
|
||||
v0.2.8 改原生环形裁剪修复(离屏 A/B 实测:修复后 6 分钟漂移 ≤15ms、零异常、
|
||||
零积压;selftest 新增 3 条回归用例)。
|
||||
- **日志自动滚动仅在贴底时跟随**:上报运行时可上翻查看历史(每行强制滚底会抢
|
||||
滚动条);CSV 写失败(磁盘满/记录盘被拔)自动停止记录并提示——`_csv_write` 在
|
||||
`_tick` 帧处理路径上,未捕获异常会中止主循环(v0.2.8 日志裁剪同款教训);故障
|
||||
标记封顶 1000 条;冻结视图切片右缘二分(暂停时长不增加渲染成本);串口写超时
|
||||
只丢该条命令、链路保留(不按设备拔出断链)。均 v0.2.9。
|
||||
- Fluent 控件自定义样式必须走 `setCustomStyleSheet`(覆盖 hover/pressed/disabled),
|
||||
直接 `setStyleSheet` 会在切主题时被库重刷(DLPilot 同款坑);标题字号走 QFont。
|
||||
- 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点
|
||||
按步进抽稀(NaN 断口保留)。
|
||||
- **是否有数据由绘制时实时判定**(`paintEvent` 直接看数据列表引用):图表只在
|
||||
构建时调用一次 `set_source`,旧版在这里缓存过 `_has_data` 布尔——数据经引用
|
||||
追加后永不刷新,“暂无数据”提示文字在有数据后仍绘在曲线上(实测截图暴露)。
|
||||
手动 Y 轴模式下曲线/目标线**裁剪回绘图区**(`setClipRect`),当前值标记
|
||||
超出量程时不画(实时读数卡片仍可见)。v0.2.10。
|
||||
- **Y 轴滑条的两套坐标系别混用**:把手像素位置在**参考域**(全量数据范围
|
||||
±15%,`_y_ref`)上映射,与"显示量程"是两回事;拖拽中的界值必须取**实时
|
||||
显示范围**(manual=`_y_manual`,auto=当前自适应值),不能取绘制缓存
|
||||
`_cached_layout` 的陈旧范围——连续两次拖拽之间没有重绘,实测曾把 pan 的
|
||||
量程整段拖跳(v0.2.11)。
|
||||
|
||||
## 已知事项
|
||||
|
||||
- 当前 USB-RS485 适配器自动换向偶发丢响应(固件侧已立“适配器更换”TODO):
|
||||
表现为个别查询 1.2s 超时,队列自动清理后轮询自然重试,曲线无碍。
|
||||
- 加热(M3)/目标温度(M4)为固件预留:仅软件状态(ack+可回读),硬件执行体接入后
|
||||
协议不变,本工具无需改动即可联调真实控温闭环。
|
||||
- 打包暂无应用图标(`logos/filmforge.ico` 缺位,build_exe.py 自动跳过)。
|
||||
- 当前 USB-RS485 适配器自动换向偶发丢响应/大延迟(固件侧已立”适配器更换”TODO):
|
||||
查询 1.2s 超时提示后重发即可;设定命令的晚到 ok 已自动补偿应用;曲线走上报帧
|
||||
(设备单向推送),不受影响。
|
||||
- 加热(M3S1)为真实 PID 闭环:开启后模具真实升温(NTC 故障/过温 150℃ 固件自动
|
||||
断电保护,上电默认关闭);当前占空比无查询命令,从温度曲线间接观察。
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""诊断 v2: 干净基线 — 旁路 app_fluent._log 的坏裁剪(cursor.Start 在 PySide6
|
||||
6.11.2 上抛 AttributeError), 用 Qt 原生 setMaximumBlockCount 替代, 其余不动源码。
|
||||
目的: 验证 100ms 持续上报数分钟下, 修复裁剪后 点击->TX 是否仍瞬时、事件循环
|
||||
是否仍流畅(对照 buggy 版的 tick 中止 + 队列积压)。"""
|
||||
import ctypes
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import filmforge_rs485 as ff
|
||||
|
||||
|
||||
class FakeSerial:
|
||||
"""模拟 CH34x + 固件: M2S<ms> 控制周期温度帧, M2S0 回 ok 并停流。"""
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
self._buf = bytearray()
|
||||
self._lock = threading.Lock()
|
||||
self._alive = True
|
||||
self.write_log = [] # [(time, cmd_bytes)]
|
||||
self.report_ms = 0
|
||||
self._next_tx = 0.0
|
||||
self._th = threading.Thread(target=self._fw, daemon=True)
|
||||
self._th.start()
|
||||
FakeSerial.last = self
|
||||
|
||||
def _fw(self):
|
||||
while self._alive:
|
||||
now = time.time()
|
||||
if self.report_ms and now >= self._next_tx:
|
||||
self._push(self._frame("34.6"))
|
||||
self._next_tx = now + self.report_ms / 1000.0
|
||||
time.sleep(0.002)
|
||||
|
||||
@staticmethod
|
||||
def _frame(payload, type_code=0xF0):
|
||||
body = bytes([type_code, len(payload)]) + payload.encode("ascii")
|
||||
return bytes([0xB5]) + body + bytes([ff.sum_crc(body)])
|
||||
|
||||
def _push(self, b):
|
||||
with self._lock:
|
||||
self._buf += b
|
||||
|
||||
@property
|
||||
def in_waiting(self):
|
||||
with self._lock:
|
||||
return len(self._buf)
|
||||
|
||||
def read(self, n):
|
||||
deadline = time.time() + 0.15
|
||||
while True:
|
||||
with self._lock:
|
||||
if self._buf:
|
||||
out = bytes(self._buf[:n])
|
||||
del self._buf[:n]
|
||||
return out
|
||||
if time.time() >= deadline:
|
||||
return b""
|
||||
time.sleep(0.004)
|
||||
|
||||
def write(self, data):
|
||||
self.write_log.append((time.time(), bytes(data)))
|
||||
cmd = bytes(data[3:-1]).decode("ascii", "replace").strip()
|
||||
threading.Timer(0.05, lambda: self._on_cmd(cmd)).start()
|
||||
return len(data)
|
||||
|
||||
def _on_cmd(self, cmd):
|
||||
if cmd.startswith("M2S"):
|
||||
try:
|
||||
ms = int(cmd[3:])
|
||||
except ValueError:
|
||||
ms = 0
|
||||
self.report_ms = ms
|
||||
self._next_tx = time.time() + ms / 1000.0
|
||||
self._push(self._frame("ok"))
|
||||
elif cmd in ("M1", "M2", "M3", "M4", "M999"):
|
||||
self._push(self._frame({"M1": "34.6", "M2": "OFF", "M3": "OFF",
|
||||
"M4": "35.0", "M999": "v41.0.0.1"}[cmd]))
|
||||
else:
|
||||
self._push(self._frame("ok"))
|
||||
|
||||
def close(self):
|
||||
self._alive = False
|
||||
try:
|
||||
self._th.join(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
ff.serial.Serial = FakeSerial
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
import app_fluent
|
||||
|
||||
# ---- 测试侧修复: 旁路坏裁剪(原生上限 500 行, 语义等同原意图) ----
|
||||
window = app_fluent.App(0)
|
||||
window.txt_log.setMaximumBlockCount(500) # blockCount 永不 >500, 坏分支不触发
|
||||
window.show()
|
||||
fake = None
|
||||
exception_count = [0]
|
||||
|
||||
# ---- 指标 ----
|
||||
stats = {"paint": [0, 0.0, 0.0], "tick": [0, 0.0, 0.0],
|
||||
"gc": [0, 0.0, 0.0], "drift": [], "qmax": [0], "click": []}
|
||||
|
||||
_orig_paint = app_fluent.TemperatureChart.paintEvent
|
||||
def timed_paint(self, ev):
|
||||
t0 = time.perf_counter()
|
||||
_orig_paint(self, ev)
|
||||
dt = time.perf_counter() - t0
|
||||
s = stats["paint"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
|
||||
app_fluent.TemperatureChart.paintEvent = timed_paint
|
||||
|
||||
_orig_tick = app_fluent.App._tick
|
||||
def timed_tick(self):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
_orig_tick(self)
|
||||
except Exception as e:
|
||||
exception_count[0] += 1
|
||||
if exception_count[0] <= 3:
|
||||
print("TICK EXCEPTION:", e, flush=True)
|
||||
dt = time.perf_counter() - t0
|
||||
s = stats["tick"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
|
||||
app_fluent.App._tick = timed_tick
|
||||
|
||||
_gc_t = [0.0]
|
||||
def gc_cb(phase, info):
|
||||
if phase == "start":
|
||||
_gc_t[0] = time.perf_counter()
|
||||
else:
|
||||
dt = time.perf_counter() - _gc_t[0]
|
||||
s = stats["gc"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
|
||||
gc.callbacks.append(gc_cb)
|
||||
|
||||
probe = {"expect": None}
|
||||
def probe_fire():
|
||||
now = time.time()
|
||||
if probe["expect"] is None:
|
||||
probe["expect"] = now + 0.02
|
||||
return
|
||||
stats["drift"].append((now, now - probe["expect"]))
|
||||
probe["expect"] += 0.02
|
||||
if now - probe["expect"] > 0.5:
|
||||
probe["expect"] = now + 0.02
|
||||
pt = QTimer(); pt.timeout.connect(probe_fire); pt.start(20)
|
||||
|
||||
CLICK_PERIOD = 30
|
||||
click = {"expect": None, "n": 0}
|
||||
|
||||
def do_click_stop():
|
||||
now = time.time()
|
||||
drift_ms = (now - click["expect"]) * 1000 if click["expect"] else 0.0
|
||||
q0 = window._rx_q.qsize()
|
||||
window._send_set("M2S0", "report", ("OFF", None)) # "停止"按钮真实路径
|
||||
click["n"] += 1
|
||||
stats["click"].append(dict(n=click["n"], t=now, drift_ms=drift_ms,
|
||||
qsize=q0, pending=len(window._pending),
|
||||
samples=len(window.samples_t),
|
||||
logblk=window.txt_log.blockCount()))
|
||||
QTimer.singleShot(3000, do_click_restart)
|
||||
|
||||
def do_click_restart():
|
||||
window._send_set("M2S100", "report", ("ON", 100))
|
||||
click["expect"] = time.time() + CLICK_PERIOD
|
||||
QTimer.singleShot(CLICK_PERIOD * 1000, do_click_stop)
|
||||
|
||||
def start():
|
||||
global fake
|
||||
window.link.open("COMFAKE", 115200)
|
||||
def wait_conn():
|
||||
if window.link.is_connected():
|
||||
fake = FakeSerial.last
|
||||
click["expect"] = time.time() + 15
|
||||
QTimer.singleShot(15000, do_click_stop)
|
||||
print("[t0] connected; stop-clicks every %ds from +15s" % CLICK_PERIOD,
|
||||
flush=True)
|
||||
else:
|
||||
QTimer.singleShot(200, wait_conn)
|
||||
QTimer.singleShot(200, wait_conn)
|
||||
QTimer.singleShot(100, start)
|
||||
|
||||
t_start = time.time()
|
||||
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 320.0
|
||||
INTERVAL = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
|
||||
|
||||
def monitor():
|
||||
while time.time() - t_start < DURATION:
|
||||
time.sleep(INTERVAL)
|
||||
el = time.time() - t_start
|
||||
recent = [d for t, d in stats["drift"] if t >= time.time() - INTERVAL]
|
||||
rmax = max(recent) * 1000 if recent else 0
|
||||
stats["qmax"][0] = max(stats["qmax"][0], window._rx_q.qsize())
|
||||
print(f"[{el:6.1f}s] cpu={time.process_time():7.2f}s "
|
||||
f"samples={len(window.samples_t):6d} q={window._rx_q.qsize():4d} "
|
||||
f"(max {stats['qmax'][0]:4d}) logblk={window.txt_log.blockCount():4d} "
|
||||
f"paint(n={stats['paint'][0]:5d} max={stats['paint'][2]*1000:6.1f}ms) "
|
||||
f"tick_max={stats['tick'][2]*1000:6.1f}ms "
|
||||
f"gc_max={stats['gc'][2]*1000:5.1f}ms drift_max={rmax:7.1f}ms "
|
||||
f"exc={exception_count[0]}", flush=True)
|
||||
print("==== CLICK 明细 ====", flush=True)
|
||||
for c in stats["click"]:
|
||||
tx = next((t for t, d in FakeSerial.last.write_log
|
||||
if d[3:-1].decode("ascii", "replace") == "M2S0"
|
||||
and c["t"] <= t <= c["t"] + 2.0), None)
|
||||
total = (tx - c["t"]) * 1000 if tx else -1
|
||||
print(f"click#{c['n']} t=+{c['t']-t_start:6.1f}s 调度漂移={c['drift_ms']:8.1f}ms "
|
||||
f"click->TX={total:7.1f}ms q={c['qsize']:4d} pending={c['pending']} "
|
||||
f"samples={c['samples']:6d} logblk={c['logblk']}", flush=True)
|
||||
ds = sorted(d for _t, d in stats["drift"])
|
||||
if ds:
|
||||
print(f"drift: n={len(ds)} p50={ds[len(ds)//2]*1000:.1f}ms "
|
||||
f"p99={ds[int(len(ds)*0.99)]*1000:.1f}ms max={ds[-1]*1000:.1f}ms "
|
||||
f">500ms次数={sum(1 for d in ds if d>0.5)}", flush=True)
|
||||
print(f"paint total={stats['paint'][1]:.2f}s max={stats['paint'][2]*1000:.1f}ms | "
|
||||
f"tick total={stats['tick'][1]:.2f}s max={stats['tick'][2]*1000:.1f}ms | "
|
||||
f"gc total={stats['gc'][1]:.2f}s max={stats['gc'][2]*1000:.1f}ms "
|
||||
f"n={stats['gc'][0]} | exceptions={exception_count[0]}", flush=True)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=monitor, daemon=True).start()
|
||||
app.exec()
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""诊断 v2b: BUGGY 对照组 — 不加裁剪修复, 复现 PySide6 6.11.2 上
|
||||
_log 裁剪 AttributeError 触发后的行为: tick 中止 / RX 处理滞后 ratchet /
|
||||
事件循环漂移 / 点击->TX。指标同 v2, 另增帧处理年龄(frame_age)。"""
|
||||
import gc
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import filmforge_rs485 as ff
|
||||
|
||||
|
||||
class FakeSerial:
|
||||
def __init__(self, *a, **kw):
|
||||
self._buf = bytearray()
|
||||
self._lock = threading.Lock()
|
||||
self._alive = True
|
||||
self.write_log = []
|
||||
self.report_ms = 0
|
||||
self._next_tx = 0.0
|
||||
self._th = threading.Thread(target=self._fw, daemon=True)
|
||||
self._th.start()
|
||||
FakeSerial.last = self
|
||||
|
||||
def _fw(self):
|
||||
while self._alive:
|
||||
now = time.time()
|
||||
if self.report_ms and now >= self._next_tx:
|
||||
self._push(self._frame("34.6"))
|
||||
self._next_tx = now + self.report_ms / 1000.0
|
||||
time.sleep(0.002)
|
||||
|
||||
@staticmethod
|
||||
def _frame(payload, type_code=0xF0):
|
||||
body = bytes([type_code, len(payload)]) + payload.encode("ascii")
|
||||
return bytes([0xB5]) + body + bytes([ff.sum_crc(body)])
|
||||
|
||||
def _push(self, b):
|
||||
with self._lock:
|
||||
self._buf += b
|
||||
|
||||
@property
|
||||
def in_waiting(self):
|
||||
with self._lock:
|
||||
return len(self._buf)
|
||||
|
||||
def read(self, n):
|
||||
deadline = time.time() + 0.15
|
||||
while True:
|
||||
with self._lock:
|
||||
if self._buf:
|
||||
out = bytes(self._buf[:n])
|
||||
del self._buf[:n]
|
||||
return out
|
||||
if time.time() >= deadline:
|
||||
return b""
|
||||
time.sleep(0.004)
|
||||
|
||||
def write(self, data):
|
||||
self.write_log.append((time.time(), bytes(data)))
|
||||
cmd = bytes(data[3:-1]).decode("ascii", "replace").strip()
|
||||
threading.Timer(0.05, lambda: self._on_cmd(cmd)).start()
|
||||
return len(data)
|
||||
|
||||
def _on_cmd(self, cmd):
|
||||
if cmd.startswith("M2S"):
|
||||
try:
|
||||
ms = int(cmd[3:])
|
||||
except ValueError:
|
||||
ms = 0
|
||||
self.report_ms = ms
|
||||
self._next_tx = time.time() + ms / 1000.0
|
||||
self._push(self._frame("ok"))
|
||||
elif cmd in ("M1", "M2", "M3", "M4", "M999"):
|
||||
self._push(self._frame({"M1": "34.6", "M2": "OFF", "M3": "OFF",
|
||||
"M4": "35.0", "M999": "v41.0.0.1"}[cmd]))
|
||||
else:
|
||||
self._push(self._frame("ok"))
|
||||
|
||||
def close(self):
|
||||
self._alive = False
|
||||
try:
|
||||
self._th.join(timeout=0.5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
ff.serial.Serial = FakeSerial
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
import app_fluent
|
||||
|
||||
# 注意: 不打 setMaximumBlockCount 补丁 —— 原样复现 bug
|
||||
window = app_fluent.App(0)
|
||||
window.show()
|
||||
fake = None
|
||||
exception_count = [0]
|
||||
|
||||
stats = {"tick": [0, 0.0, 0.0], "gc": [0, 0.0, 0.0], "drift": [],
|
||||
"qmax": [0], "click": [], "age": [0, 0.0, 0.0]} # age: n,total,max
|
||||
|
||||
_orig_tick = app_fluent.App._tick
|
||||
def timed_tick(self):
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
_orig_tick(self)
|
||||
except Exception as e:
|
||||
exception_count[0] += 1
|
||||
if exception_count[0] <= 3:
|
||||
print("TICK EXCEPTION:", type(e).__name__, e, flush=True)
|
||||
dt = time.perf_counter() - t0
|
||||
s = stats["tick"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
|
||||
app_fluent.App._tick = timed_tick
|
||||
|
||||
_orig_handle = app_fluent.App._handle_frame
|
||||
def timed_handle(self, fr, t_rx):
|
||||
_orig_handle(self, fr, t_rx)
|
||||
age = time.time() - t_rx # 帧从收到到被主线程处理的滞后
|
||||
s = stats["age"]; s[0] += 1; s[1] += age; s[2] = max(s[2], age)
|
||||
app_fluent.App._handle_frame = timed_handle
|
||||
|
||||
_gc_t = [0.0]
|
||||
def gc_cb(phase, info):
|
||||
if phase == "start":
|
||||
_gc_t[0] = time.perf_counter()
|
||||
else:
|
||||
dt = time.perf_counter() - _gc_t[0]
|
||||
s = stats["gc"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
|
||||
gc.callbacks.append(gc_cb)
|
||||
|
||||
probe = {"expect": None}
|
||||
def probe_fire():
|
||||
now = time.time()
|
||||
if probe["expect"] is None:
|
||||
probe["expect"] = now + 0.02
|
||||
return
|
||||
stats["drift"].append((now, now - probe["expect"]))
|
||||
probe["expect"] += 0.02
|
||||
if now - probe["expect"] > 0.5:
|
||||
probe["expect"] = now + 0.02
|
||||
pt = QTimer(); pt.timeout.connect(probe_fire); pt.start(20)
|
||||
|
||||
CLICK_PERIOD = 30
|
||||
click = {"expect": None, "n": 0}
|
||||
|
||||
def do_click_stop():
|
||||
now = time.time()
|
||||
drift_ms = (now - click["expect"]) * 1000 if click["expect"] else 0.0
|
||||
q0 = window._rx_q.qsize()
|
||||
window._send_set("M2S0", "report", ("OFF", None))
|
||||
click["n"] += 1
|
||||
stats["click"].append(dict(n=click["n"], t=now, drift_ms=drift_ms,
|
||||
qsize=q0, pending=len(window._pending),
|
||||
samples=len(window.samples_t),
|
||||
logblk=window.txt_log.blockCount()))
|
||||
QTimer.singleShot(3000, do_click_restart)
|
||||
|
||||
def do_click_restart():
|
||||
window._send_set("M2S100", "report", ("ON", 100))
|
||||
click["expect"] = time.time() + CLICK_PERIOD
|
||||
QTimer.singleShot(CLICK_PERIOD * 1000, do_click_stop)
|
||||
|
||||
def start():
|
||||
global fake
|
||||
window.link.open("COMFAKE", 115200)
|
||||
def wait_conn():
|
||||
if window.link.is_connected():
|
||||
fake = FakeSerial.last
|
||||
click["expect"] = time.time() + 15
|
||||
QTimer.singleShot(15000, do_click_stop)
|
||||
print("[t0] connected; stop-clicks every %ds from +15s (BUGGY mode)"
|
||||
% CLICK_PERIOD, flush=True)
|
||||
else:
|
||||
QTimer.singleShot(200, wait_conn)
|
||||
QTimer.singleShot(200, wait_conn)
|
||||
QTimer.singleShot(100, start)
|
||||
|
||||
t_start = time.time()
|
||||
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 320.0
|
||||
INTERVAL = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
|
||||
|
||||
def monitor():
|
||||
while time.time() - t_start < DURATION:
|
||||
time.sleep(INTERVAL)
|
||||
el = time.time() - t_start
|
||||
recent = [d for t, d in stats["drift"] if t >= time.time() - INTERVAL]
|
||||
rmax = max(recent) * 1000 if recent else 0
|
||||
stats["qmax"][0] = max(stats["qmax"][0], window._rx_q.qsize())
|
||||
n, tot, mx = stats["age"]
|
||||
print(f"[{el:6.1f}s] cpu={time.process_time():7.2f}s "
|
||||
f"samples={len(window.samples_t):6d} q={window._rx_q.qsize():5d} "
|
||||
f"(max {stats['qmax'][0]:5d}) logblk={window.txt_log.blockCount():5d} "
|
||||
f"frame_age avg={tot/max(n,1)*1000:7.1f}ms max={mx*1000:8.1f}ms "
|
||||
f"tick_max={stats['tick'][2]*1000:6.1f}ms drift_max={rmax:8.1f}ms "
|
||||
f"exc={exception_count[0]}", flush=True)
|
||||
print("==== CLICK 明细 ====", flush=True)
|
||||
for c in stats["click"]:
|
||||
tx = next((t for t, d in FakeSerial.last.write_log
|
||||
if d[3:-1].decode("ascii", "replace") == "M2S0"
|
||||
and c["t"] <= t <= c["t"] + 2.0), None)
|
||||
total = (tx - c["t"]) * 1000 if tx else -1
|
||||
print(f"click#{c['n']} t=+{c['t']-t_start:6.1f}s 调度漂移={c['drift_ms']:8.1f}ms "
|
||||
f"click->TX={total:7.1f}ms q={c['qsize']:5d} pending={c['pending']} "
|
||||
f"samples={c['samples']:6d} logblk={c['logblk']}", flush=True)
|
||||
ds = sorted(d for _t, d in stats["drift"])
|
||||
if ds:
|
||||
print(f"drift: n={len(ds)} p50={ds[len(ds)//2]*1000:.1f}ms "
|
||||
f"p99={ds[int(len(ds)*0.99)]*1000:.1f}ms max={ds[-1]*1000:.1f}ms "
|
||||
f">500ms次数={sum(1 for d in ds if d>0.5)}", flush=True)
|
||||
n, tot, mx = stats["age"]
|
||||
print(f"frame_age avg={tot/max(n,1)*1000:.1f}ms max={mx*1000:.1f}ms | "
|
||||
f"exceptions={exception_count[0]} | tick n={stats['tick'][0]} "
|
||||
f"max={stats['tick'][2]*1000:.1f}ms", flush=True)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=monitor, daemon=True).start()
|
||||
app.exec()
|
||||
+596
-133
File diff suppressed because it is too large
Load Diff
+19
-12
@@ -26,18 +26,15 @@ WINDOW_OPTIONS = (('2 分钟', 120), ('5 分钟', 300), ('15 分钟', 900),
|
||||
DEFAULT_WINDOW_SEC = 300
|
||||
MAX_SAMPLES = 200000 # 曲线内存上限(点), 超出丢最旧; 10Hz 下约 5.5 小时
|
||||
|
||||
# 自动轮询 M1 候选(毫秒): 仅在设备未开启 M2 上报时生效
|
||||
POLL_OPTIONS = (('关闭', 0), ('500 ms', 500), ('1 s', 1000), ('2 s', 2000))
|
||||
DEFAULT_POLL_MS = 1000
|
||||
|
||||
# 串口记忆(协议固定 115200-8-N-1, 仍允许改以便调试)
|
||||
SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD,
|
||||
'bytesize': 8, 'parity': 'N', 'stopbits': 1}
|
||||
REPORT_MS = 500 # 上报间隔输入框默认值
|
||||
POLL_MS = DEFAULT_POLL_MS # 自动轮询 M1 周期(0=关)
|
||||
POLL_ENABLED = True
|
||||
WINDOW_SEC = DEFAULT_WINDOW_SEC
|
||||
THEME = 'System'
|
||||
Y_MODE = 'auto' # 曲线 Y 轴(温度轴): 'auto'=自适应 / 'manual'=固定范围
|
||||
Y_MIN = None # 手动 Y 轴上下限(℃), 仅 Y_MODE='manual' 且合法时生效
|
||||
Y_MAX = None
|
||||
|
||||
|
||||
def load_settings():
|
||||
@@ -65,17 +62,28 @@ def load_settings():
|
||||
SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for key, target in (('report_ms', 'REPORT_MS'), ('poll_ms', 'POLL_MS'),
|
||||
('window_sec', 'WINDOW_SEC')):
|
||||
for key, target in (('report_ms', 'REPORT_MS'), ('window_sec', 'WINDOW_SEC')):
|
||||
try:
|
||||
globals()[target] = int(data.get(key))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
global POLL_ENABLED, THEME
|
||||
POLL_ENABLED = bool(data.get('poll_enabled', POLL_ENABLED))
|
||||
global THEME
|
||||
theme = str(data.get('theme') or '')
|
||||
if theme in ('Dark', 'Light', 'System'):
|
||||
THEME = theme
|
||||
ax = data.get('y_axis')
|
||||
if isinstance(ax, dict):
|
||||
mode = str(ax.get('mode') or '')
|
||||
if mode in ('auto', 'manual'):
|
||||
globals()['Y_MODE'] = mode
|
||||
try:
|
||||
globals()['Y_MIN'] = round(float(ax.get('min')), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
globals()['Y_MAX'] = round(float(ax.get('max')), 2)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def save_settings():
|
||||
@@ -83,10 +91,9 @@ def save_settings():
|
||||
data = {
|
||||
'serial': dict(SERIAL_CFG),
|
||||
'report_ms': REPORT_MS,
|
||||
'poll_ms': POLL_MS,
|
||||
'poll_enabled': POLL_ENABLED,
|
||||
'window_sec': WINDOW_SEC,
|
||||
'theme': THEME,
|
||||
'y_axis': {'mode': Y_MODE, 'min': Y_MIN, 'max': Y_MAX},
|
||||
}
|
||||
try:
|
||||
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||
|
||||
+19
-6
@@ -12,8 +12,9 @@ FilmForge 热压模具 RS485 温控链路
|
||||
|
||||
命令语义速记(全表见 protocols.md):
|
||||
M1 查温度(OPEN=NTC 开路/SHRT=短路) M2 查上报状态 M2S<ms> 设间隔并开启(100~60000,
|
||||
10ms 步进) M2S0 停止 M3/M3S1/M3S0 加热(预留, 当前仅软件状态) M4/M4S<n> 目标温度
|
||||
(0~200 整数) M888 软复位 M999 查版本 M9999 进 OTA(均会复位, GUI 需二次确认)
|
||||
10ms 步进) M2S0 停止 M3/M3S1/M3S0 加热使能(实装: PB12 慢 PWM + PID 按目标温度
|
||||
闭环, 开启后真实加热) M4/M4S<n> 目标温度(0~200 整数) M888 软复位 M999 查版本
|
||||
M9999 进 OTA(均会复位, GUI 需二次确认)
|
||||
通信要点: 全部命令幂等(ack 丢了直接重发); 命令间隔 >=50ms; 响应超时 300ms 重发 x3。
|
||||
|
||||
仅标准库 + 可选 pyserial(缺失只影响串口功能, 其余照常)。
|
||||
@@ -62,9 +63,9 @@ PRESETS = (
|
||||
('查上报状态', 'M2', 'ON <ms> / OFF'),
|
||||
('开上报500ms', 'M2S500', '设上报间隔并开启(100~60000, 10ms 步进)'),
|
||||
('停上报', 'M2S0', '停止周期上报'),
|
||||
('查加热状态', 'M3', 'ON/OFF(预留, 当前仅软件状态)'),
|
||||
('开加热', 'M3S1', '预留: 硬件执行体待接入, 仅维护软件状态'),
|
||||
('关加热', 'M3S0', '预留'),
|
||||
('查加热状态', 'M3', 'ON/OFF(加热使能状态)'),
|
||||
('开加热', 'M3S1', '开启 PID 闭环控温, 模具将真实加热(需确认)'),
|
||||
('关加热', 'M3S0', '关闭加热输出'),
|
||||
('查目标温度', 'M4', 'ASCII 摄氏度(如 120.0)'),
|
||||
('设目标120C', 'M4S120', '0~200 整数摄氏度, 越界回 FD'),
|
||||
('查固件版本', 'M999', '如 v41.0.0.1'),
|
||||
@@ -346,9 +347,21 @@ class FF485:
|
||||
pass
|
||||
if item is not None:
|
||||
cmd, frame = item
|
||||
sent = False
|
||||
try:
|
||||
self.ser.write(frame)
|
||||
sent = True
|
||||
except serial.SerialTimeoutException:
|
||||
# 写超时(适配器停滞): 只丢这条命令, 链路保留。
|
||||
# 勿并入下方 OSError——瞬时停滞曾被当成"设备拔出"
|
||||
# 关端口断链, 迫使用户手动重连(适配器换向本就不稳)
|
||||
self.on_event(f'✕ {cmd} 写超时未发出, 链路保留(可重发)')
|
||||
if sent:
|
||||
self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]')
|
||||
data = self.ser.read(256)
|
||||
# 按到达量读取, 不攒大块: 固定 read(256) 在持续帧流下要攒满
|
||||
# 256 字节才返回(win32 字节间隔超时 150ms 因帧间隔更短永不
|
||||
# 触发, 100ms 上报 ≈ 2.5s 一批), 表现为曲线/命令全按 3s 一卡
|
||||
data = self.ser.read(self.ser.in_waiting or 1)
|
||||
if data:
|
||||
t_rx = time.time() # 在读取点打时间戳(曲线/CSV 用)
|
||||
for fr in self.parser.feed(data):
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#000000"/>
|
||||
<rect x="38" y="64" width="436" height="186" rx="34" fill="#00C8D7"/>
|
||||
<text x="256" y="210" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#000000" text-anchor="middle">FILM</text>
|
||||
<text x="256" y="421" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#FFFFFF" text-anchor="middle">Forge</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 507 B |
@@ -4,9 +4,9 @@
|
||||
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
|
||||
================================================================
|
||||
用法(在固件仓任意目录执行, 自动从 origin 解析仓库):
|
||||
python publish_gitea.py release/FilmForge-GUI-v0.1.0-win64.zip
|
||||
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md
|
||||
python publish_gitea.py <zip> --tag FilmForge-GUI-v0.1.0 --replace
|
||||
python publish_gitea.py release/FilmForge-GUI-v0.2.0-win64.zip
|
||||
python publish_gitea.py <zip> --notes-file release/notes-v0.2.0.md
|
||||
python publish_gitea.py <zip> --tag FilmForge-GUI-v0.2.0 --replace
|
||||
|
||||
行为:
|
||||
- tag 与版本默认取 zip 文件名(FilmForge-GUI-vX.Y.Z-win64.zip → FilmForge-GUI-vX.Y.Z)
|
||||
@@ -43,7 +43,7 @@ FilmForge 热压模具温控板(RS485)上位机: 实时温度曲线(自绘, 无
|
||||
- `README.md` — 完整使用说明
|
||||
|
||||
### 注意
|
||||
- 加热(M3)/目标温度(M4)为固件预留功能: 当前仅软件状态(ack+可回读), 硬件执行体接入后协议不变。
|
||||
- 加热(M3S1)为真实 PID 闭环控温: 开启后模具将真实加热(NTC 故障/过温 150℃ 固件自动断电保护)。
|
||||
- 软件复位(M888)/进 OTA(M9999) 会复位设备, 界面内已做二次确认。
|
||||
"""
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import math
|
||||
import os
|
||||
from collections import deque
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
@@ -15,9 +16,16 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import filmforge_rs485 as ff
|
||||
import config
|
||||
from PySide6.QtCore import QRectF
|
||||
from PySide6.QtGui import QImage, QPainter
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
# 设置文件指向临时目录: 隔离用户真实 filmforge_gui.json(用户可能展存着手动
|
||||
# Y 轴等状态), 测试一律从"首次启动默认"出发, 残留写入不留真实配置盘
|
||||
_tmpdir = tempfile.mkdtemp(prefix='filmforge_selftest_')
|
||||
config.SETTINGS_FILE = os.path.join(_tmpdir, 'settings.json')
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
@@ -40,6 +48,9 @@ window = app_fluent.App(0)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
|
||||
# 0) 图标链路: logos/filmforge.ico 存在时窗口图标必须已加载
|
||||
check('窗口图标已设置', not window.windowIcon().isNull(), True)
|
||||
|
||||
# 1) 空数据渲染不抛异常(占位文案)
|
||||
img = QImage(window.chart.size(), QImage.Format_ARGB32)
|
||||
window.chart.render(img)
|
||||
@@ -70,6 +81,18 @@ def count_pixels(img, rgb, tol=30):
|
||||
return n
|
||||
|
||||
|
||||
def count_in(img, x0, x1, y0, y1, rgb, tol=30):
|
||||
"""区域内逐像素计数(stride 1): 用于条带级断言(如越界裁剪)与空态提示"""
|
||||
n = 0
|
||||
for y in range(y0, y1):
|
||||
for x in range(x0, x1):
|
||||
c = img.pixelColor(x, y)
|
||||
if (abs(c.red() - rgb[0]) < tol and abs(c.green() - rgb[1]) < tol
|
||||
and abs(c.blue() - rgb[2]) < tol):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7
|
||||
target = (232, 131, 58) # 目标线 #E8833A
|
||||
fault = (217, 83, 79) # 故障 #D9534F
|
||||
@@ -150,6 +173,60 @@ check('杂散不消费队首', len(window._pending), 1)
|
||||
window._handle_frame(mk_frame('v41.0.0.1'), time.time())
|
||||
check('杂散后正常消费', len(window._pending), 0)
|
||||
|
||||
# 3d) 晚到 ok 补偿: 设定命令超时清队后, 迟到的 ok 仍按发送时计划应用
|
||||
window._pending.clear()
|
||||
window._last_set = {'cmd': 'M2S500', 't': time.time(), 'apply': ('report', ('ON', 500))}
|
||||
window._handle_frame(mk_frame('ok'), time.time())
|
||||
check('晚到 ok 补偿上报', window.report_state, ('ON', 500))
|
||||
check('晚到 ok 已清记录', window._last_set, None)
|
||||
|
||||
# 3e) M4 暂停-恢复链: 上报开启时查目标 → M2S0(ok)→M4→应答落地→自动恢复上报
|
||||
window._pending.clear()
|
||||
window.link.is_connected = lambda: True # 仅测试: 跳过串口在线检查
|
||||
window._set_report_state('ON', 500)
|
||||
window._query_target()
|
||||
check('查目标先停上报', window._pending[0]['cmd'], 'M2S0')
|
||||
check('挂起恢复标记', window._resume_report_ms, 500)
|
||||
window._handle_frame(mk_frame('ok'), time.time()) # M2S0 ok → then 定时发 M4
|
||||
window._pending.clear()
|
||||
window._pending.append({'cmd': 'M4', 'kind': 'target', 't': time.time(), 'apply': None})
|
||||
window._handle_frame(mk_frame('57.0'), time.time()) # M4 应答(替换定时器路径)
|
||||
check('M4 应答落目标', window.target_hist[-1][1], 57.0)
|
||||
check('恢复标记已清', window._resume_report_ms, None)
|
||||
check('恢复上报已入队', window._pending[0]['cmd'], 'M2S500')
|
||||
window.link.is_connected = lambda: False # 还原
|
||||
window._pending.clear()
|
||||
|
||||
# 3f) 上报开启时, 上报帧不得被当成 M4 目标应答误占(队首非 target 时入曲线)
|
||||
window._set_report_state('ON', 100)
|
||||
n2 = len(window.samples_t)
|
||||
window._handle_frame(mk_frame('60.0'), time.time())
|
||||
check('无 pending 上报帧入曲线', len(window.samples_t), n2 + 1)
|
||||
window._set_report_state('OFF', None)
|
||||
|
||||
# 3g) 幂等去重: 同命令未决时重复发送被忽略, 队列不再堆积(真机连点 M2S0 回归)
|
||||
window._pending.clear()
|
||||
window.link.is_connected = lambda: True
|
||||
window._send_set('M2S0', 'report', ('OFF', None))
|
||||
window._send_set('M2S0', 'report', ('OFF', None))
|
||||
window._send_set('M2S0', 'report', ('OFF', None))
|
||||
check('重复命令去重', len(window._pending), 1)
|
||||
check('不同命令不去重', 'M4' if window._send_cmd('M4', 'target') else '', 'M4')
|
||||
check('去重后队列 2 条', len(window._pending), 2)
|
||||
window.link.is_connected = lambda: False
|
||||
window._pending.clear()
|
||||
|
||||
# 3h) 目标阶梯线: 目标变更须在变更时刻垂直落变, 不得从旧值斜拉到右缘
|
||||
t_a, t_b, t_now = time.time() - 120, time.time() - 60, time.time()
|
||||
window.chart._target_hist = [(t_a, 57.0), (t_b, 41.0)]
|
||||
pts = window.chart._target_points(t_a, t_now)
|
||||
check('目标线终点延到右缘', abs(pts[-1][0] - t_now) < 1e-6 and pts[-1][1], 41.0)
|
||||
diag = [1 for (xa, ya), (xb, yb) in zip(pts, pts[1:])
|
||||
if abs(xb - xa) > 1e-9 and abs(yb - ya) > 1e-9]
|
||||
check('目标线无斜段(纯阶梯)', diag, [])
|
||||
check('变更时刻有垂直落变', (t_b, 57.0) in pts and (t_b, 41.0) in pts, True)
|
||||
window.chart._target_hist = [] # 还原
|
||||
|
||||
# 4) 主题往返后曲线配色随动 + 渲染不崩
|
||||
window._set_theme('Light')
|
||||
check('Light 曲线配色', window.chart._colors['measured'].lower(),
|
||||
@@ -170,5 +247,178 @@ with open(window._csv_path, encoding='utf-8-sig') as f:
|
||||
check('CSV 表头+2 行', len(lines), 3)
|
||||
check('CSV 故障行', lines[2].split(',')[2], 'SHRT')
|
||||
|
||||
# 6) 日志环形裁剪回归: 写满 500 行后封顶, 继续追加不抛异常、不无限增长。
|
||||
# (手工 cursor 裁剪用 cursor.Start/BlockUnderCursor 实例级枚举访问,
|
||||
# PySide6 6.11 上必然抛 AttributeError → 每 tick 异常中止, v0.2.8 改原生裁剪)
|
||||
for i in range(600):
|
||||
window._log(f'日志压力行 {i}')
|
||||
check('日志 500 行封顶', window.txt_log.blockCount() <= 500, True)
|
||||
window._log('封顶后追加行')
|
||||
check('封顶后可继续追加', window.txt_log.blockCount() <= 500, True)
|
||||
check('封顶后最新行在末尾',
|
||||
window.txt_log.toPlainText().splitlines()[-1].endswith('封顶后追加行'), True)
|
||||
|
||||
# 7) v0.2.9 回归: 故障标记封顶 / 滚动条跟随 / CSV 写失败自停 / 冻结切片右边界
|
||||
t_end = time.time()
|
||||
for i in range(app_fluent.MAX_FAULTS + 50):
|
||||
window._record_fault(t_end + i * 0.01, ff.SENSOR_OPEN)
|
||||
check('故障标记封顶', len(window.faults), app_fluent.MAX_FAULTS)
|
||||
|
||||
sb = window.txt_log.verticalScrollBar()
|
||||
sb.setValue(0)
|
||||
window._log('上翻后新行')
|
||||
check('上翻时不抢滚动条', sb.value() <= 1, True)
|
||||
sb.setValue(sb.maximum())
|
||||
window._log('贴底跟随行')
|
||||
check('贴底时跟随滚动', abs(sb.value() - sb.maximum()) <= 4, True)
|
||||
|
||||
|
||||
class _BoomFile:
|
||||
"""模拟写坏盘: write/flush 必抛 OSError(磁盘满/记录盘被拔)"""
|
||||
def write(self, *_a):
|
||||
raise OSError(28, '模拟磁盘满')
|
||||
def flush(self):
|
||||
raise OSError(28, '模拟磁盘满')
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
window._csv = _BoomFile()
|
||||
window._csv_path = 'dummy.csv'
|
||||
window._add_sample(time.time(), 25.0)
|
||||
check('CSV 写失败自动停止记录', window._csv is None, True)
|
||||
check('CSV 失败后按钮复位', window.btn_csv.text(), '开始记录')
|
||||
|
||||
c = window.chart
|
||||
c._samples_t, c._samples_v = [1.0, 2.0, 3.0, 4.0], [1, 2, 3, 4]
|
||||
c._frozen_at = 2.5
|
||||
ts, _vs = c._visible(0, 2.5)
|
||||
check('冻结切片不含冻结尾后样本', ts, [1.0, 2.0])
|
||||
c._frozen_at = None
|
||||
ts, _vs = c._visible(0, 99)
|
||||
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
|
||||
c._samples_t, c._samples_v = window.samples_t, window.samples_v
|
||||
|
||||
# 8) v0.2.11 回归: 右缘双把手滑条拖拽量程 + 空态提示实时消失。
|
||||
# (提示不消失根因: 旧版 set_source 构建期缓存 _has_data 布尔, 数据经引用追加
|
||||
# 后永不刷新 → 改绘制时实时判定; 本段同时覆盖滑条命中/拖拽/钳位/平移/裁剪)
|
||||
c = window.chart
|
||||
check('Y 轴初始自动', c._y_mode, 'auto')
|
||||
check('Y 轴按钮默认自动', window.btn_ymode.isChecked()
|
||||
and window.btn_ymode.text() == 'Y轴自动', True)
|
||||
|
||||
engaged, ranges = [], []
|
||||
c.yManualEngaged.connect(lambda: engaged.append(1))
|
||||
c.yRangeChanged.connect(lambda lo, hi: ranges.append((lo, hi)))
|
||||
|
||||
c.update()
|
||||
app.processEvents() # 同步一次绘制: 命中缓存与当前数据一致
|
||||
area, _t0, _t1, _vt, _vv, _tgt, ymin0, ymax0 = c._layout()
|
||||
gx0, gx1 = c._gutter()
|
||||
ref0 = c._y_ref()
|
||||
y_hi_px = c._rail_px(area, ref0, ymax0)
|
||||
y_lo_px = c._rail_px(area, ref0, ymin0)
|
||||
check('把手命中(上把手)', c._gutter_hit(gx0 + 6, y_hi_px), 'max')
|
||||
check('把手命中(下把手)', c._gutter_hit(gx0 + 6, y_lo_px), 'min')
|
||||
check('轨道空白亦视为边界', c._gutter_hit(gx0 + 6, area.bottom() - 3), 'min')
|
||||
check('栏外不命中', c._gutter_hit(10, 100) is None, True)
|
||||
|
||||
c.start_y_drag('min', y_lo_px)
|
||||
check('拖拽即切手动', c._y_mode, 'manual')
|
||||
check('手动信号已发', engaged, [1])
|
||||
# 滑条域固定 -20~120 后拖拽映射恒定(0.53C/px 量级), 断言用目标值反算像素,
|
||||
# 不再依赖域宽: 拖到 ymin0+30 对应位置 → 下限抬升 30℃
|
||||
c.update_y_drag(c._rail_px(area, ref0, ymin0 + 30.0))
|
||||
lo1, hi1 = c._y_manual
|
||||
check('下限随拖拽抬升', lo1 > ymin0 + 1e-9, True)
|
||||
check('上限不受牵动', abs(hi1 - ymax0) < 1e-9, True)
|
||||
check('松手生效', c.end_y_drag(), True)
|
||||
lo_e, hi_e = ranges[-1]
|
||||
check('范围量化 0.1', abs(lo_e * 10 - round(lo_e * 10)) < 1e-6
|
||||
and abs(hi_e * 10 - round(hi_e * 10)) < 1e-6, True)
|
||||
|
||||
c.start_y_drag('min', y_lo_px) # 极端上拉 → 钳到 上限-最小间距
|
||||
c.update_y_drag(area.top() - 500)
|
||||
check('最小间距约束',
|
||||
c._y_manual[1] - c._y_manual[0] >= c.MIN_Y_SPAN - 1e-9, True)
|
||||
c.end_y_drag()
|
||||
|
||||
lo_prev, hi_prev = c._y_manual[0], c._y_manual[1] # 中带平移: 整段同移
|
||||
c.start_y_drag('win', (y_hi_px + y_lo_px) / 2)
|
||||
c.update_y_drag(area.bottom() + 30)
|
||||
c.end_y_drag()
|
||||
lo_pan, hi_pan = c._y_manual
|
||||
# 松手量化 0.1℃ 各自取整, 跨度允许 ±0.05 偏差(两段半界)
|
||||
check('平移不改跨度(量化容差)', abs((hi_pan - lo_pan) - (hi_prev - lo_prev)) < 0.12, True)
|
||||
check('平移已生效', lo_pan < lo_prev - 1e-9, True)
|
||||
check('上下限同步移动', abs((lo_prev - lo_pan) - (hi_prev - hi_pan)) < 0.12, True)
|
||||
check('范围通知已发 3 次', len(ranges), 3)
|
||||
|
||||
window.btn_ymode.setChecked(True) # 按钮勾回自动
|
||||
check('切回自动', c._y_mode, 'auto')
|
||||
check('按钮文本回自动', window.btn_ymode.text(), 'Y轴自动')
|
||||
|
||||
# 确定形状的像素断言: 手动 [28,80], 数据(~19~60℃)出界部分必被裁剪回绘图区
|
||||
c.set_y_manual(28.0, 80.0)
|
||||
_hx = c._colors['measured'].lstrip('#')
|
||||
measured_now = (int(_hx[0:2], 16), int(_hx[2:4], 16), int(_hx[4:6], 16))
|
||||
window.chart.update()
|
||||
img = QImage(window.chart.size(), QImage.Format_ARGB32)
|
||||
window.chart.render(img)
|
||||
area = QRectF(window.chart.rect()).adjusted(
|
||||
52, 10, -(app_fluent.RIGHT_MARGIN + app_fluent.GUTTER_W), -26)
|
||||
x0, x1 = int(area.left()) + 2, int(area.right()) - 2
|
||||
check('手动范围下曲线仍绘制', count_pixels(img, measured_now) > 0, True)
|
||||
check('裁剪: 上条带无曲线像素',
|
||||
count_in(img, x0, x1, 0, int(area.top()) - 1, measured_now), 0)
|
||||
check('裁剪: 下条带无曲线像素',
|
||||
count_in(img, x0, x1, int(area.bottom()), window.chart.height(), measured_now), 0)
|
||||
c.set_y_mode('auto')
|
||||
|
||||
hint = (201, 201, 201) # 提示文字色 #c9c9c9
|
||||
c2 = app_fluent.TemperatureChart()
|
||||
c2.resize(640, 320)
|
||||
lst_t, lst_v = [], []
|
||||
c2.set_source(lst_t, lst_v, [], []) # 模拟构建期一次性注入(空)
|
||||
img2 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||
c2.render(img2)
|
||||
ax0, ax1 = 60, c2.width() - 20
|
||||
ax_top, ax_bot = 30, c2.height() - 30
|
||||
n_hint0 = count_in(img2, ax0, ax1, ax_top, ax_bot, hint)
|
||||
lst_t.append(time.time()) # 经同一引用追加数据(复现旧 bug 路径)
|
||||
lst_v.append(50.0)
|
||||
c2.update()
|
||||
img3 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||
c2.render(img3)
|
||||
n_hint1 = count_in(img3, ax0, ax1, ax_top, ax_bot, hint)
|
||||
print(f'提示像素: 空={n_hint0} 有数据={n_hint1}')
|
||||
check('空态提示已绘出', n_hint0 > 50, True)
|
||||
check('数据到位后提示消失(实时判定)', n_hint1, 0)
|
||||
|
||||
# 8b) 值标签悬停门控: 非悬停不画(不常驻遮挡曲线, 用户标注), 悬停/拖拽才显示
|
||||
lt2, lv2 = [time.time() - 100], [35.0]
|
||||
c2.set_source(lt2, lv2, [], [])
|
||||
c2.set_y_manual(20.0, 60.0)
|
||||
c2.set_y_mode('manual')
|
||||
c2.update()
|
||||
img4 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||
c2.render(img4)
|
||||
zx0, zx1 = c2.width() - 71, c2.width() - 31 # 把手值标签区(绘图区右缘)
|
||||
zy0, zy1 = 110, 230 # 覆盖上下两个把手 y 位
|
||||
n_pill0 = count_in(img4, zx0, zx1, zy0, zy1, (0, 200, 215))
|
||||
c2._set_y_hover(True)
|
||||
c2.update()
|
||||
img5 = QImage(c2.size(), QImage.Format_ARGB32)
|
||||
c2.render(img5)
|
||||
n_pill1 = count_in(img5, zx0, zx1, zy0, zy1, (0, 200, 215))
|
||||
print(f'值标签像素: 未悬停={n_pill0} 悬停={n_pill1}')
|
||||
check('未悬停时值标签隐藏(不遮曲线)', n_pill0, 0)
|
||||
check('悬停/拖拽时值标签显示', n_pill1 > 20, True)
|
||||
c2._set_y_hover(False)
|
||||
|
||||
# 还原模块配置, 避免测试残留写入用户 filmforge_gui.json
|
||||
config.Y_MODE, config.Y_MIN, config.Y_MAX = 'auto', None, None
|
||||
config.save_settings()
|
||||
|
||||
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||
sys.exit(1 if fails else 0)
|
||||
|
||||
+34
-5
@@ -149,12 +149,41 @@ void usart1_irq_handler(void);
|
||||
#define HEATER_EN_RCU RCU_GPIOB
|
||||
#define HEATER_EN_PORT GPIOB
|
||||
#define HEATER_EN_PIN GPIO_PIN_12
|
||||
#define HEATER_PWM_PERIOD_MS 1000U /* PWM 载波周期(1s,恒 1Hz 开关频率) */
|
||||
#define HEATER_PWM_STEP_PCT 10U /* 占空比步进(每档脉宽 = 100ms) */
|
||||
#define HEATER_PWM_PERIOD_MS 2000U /* PWM 载波周期(2s,载波 0.5Hz) */
|
||||
#define HEATER_PWM_STEP_PCT 5U /* 占空比步进(每档脉宽 = 100ms,维持最小开关频率约束) */
|
||||
#define HEATER_PID_PERIOD_MS 500U /* PID 计算周期 */
|
||||
#define HEATER_PID_KP 8.0f /* PID 参数:初值,需上板实测整定 */
|
||||
#define HEATER_PID_KI 0.05f
|
||||
#define HEATER_PID_KD 0.0f /* NTC 噪声对微分放大,先禁用 */
|
||||
#define HEATER_PID_KP 8.0f /* PID 参数:带外下方 P 拉回增益(带内 P 退出) */
|
||||
#define HEATER_PID_KI 0.15f /* 输出映射:u 贡献 = KI*I;I 全区域对称慢速学习 */
|
||||
#define HEATER_PID_KD 30.0f /* 微分(仅带外下方起作用):爬升段提前退功率 */
|
||||
#define HEATER_PID_BAND_C 0.3f /* 作用分区带宽:带内恒功率(Ki*I),带外
|
||||
开关+P 修正——波动幅度被物理限制在
|
||||
带宽+滑行量 */
|
||||
#define HEATER_PID_INTEG_GAIN_IN 0.30f /* 带内/带外下方积分学习增益(对称):
|
||||
I += err*GAIN;0.15 时 I 充满需 ~400s,
|
||||
判定窗内驻留点偏低 0.5C(ASSIST 补缺口
|
||||
但驻留偏欠温),加倍后 ~200s 到位 */
|
||||
#define HEATER_PID_INTEG_BLEED 0.30f /* 带外上方每拍固定回退(=GAIN,对称) */
|
||||
#define HEATER_NTC_SLEW_C 0.5f /* NTC 读数斜率限幅(C/PID 拍):单拍变化超过
|
||||
此值判为读数毛刺(真实热速率 <=0.2C/拍),
|
||||
沿限幅值过渡、连续 2 拍超限放行真实值 */
|
||||
#define HEATER_NTC_JUMP_C 1.5f /* 秒级坏值冻结阈值:斜率超限持续且累计偏差超过
|
||||
此值时冻结滤波值(真机焊接点接触不良会产出
|
||||
-2.5C/4.5s 塌陷与顶格卡死,中值滤波挡不住) */
|
||||
#define HEATER_PID_I_MAX 400.0f /* 积分钳位上限:KI*I_MAX = 60% 占空(120C 模具
|
||||
维持功率余量);下限恒为 0 */
|
||||
#define HEATER_FAULT_RESTORE_MAX_S 3.0f /* NTC 短故障(去抖确认后恢复)无扰还原积分的
|
||||
时长上限;超过则从零重建(安全优先) */
|
||||
#define HEATER_RAMP_CPS 2.0f /* 内部设定值斜坡速率(℃/s):实测 0.25 时斜坡
|
||||
本身就是瓶颈(9C 走 36s,加热器只是"追"设定点);
|
||||
2.0 基本不设障,到达速度由加热功率与温和带
|
||||
上限决定。分区控制器爬升段不积分,快斜坡
|
||||
不会像旧连续 PID 那样预充积分导致过冲 */
|
||||
#define HEATER_SLOWBAND_C 3.0f /* 温和带宽度:缓爬段(原 7C 太宽,25% 上限下
|
||||
净升温仅 0.08C/s,爬升 234s 的主瓶颈) */
|
||||
#define HEATER_SLOWBAND_DUTY 35U /* 近场缓爬辅助满档(%):err=+SLOWBAND_C 处
|
||||
的推力,随 err 线性退闸(提供与积分成熟度
|
||||
无关的确定爬升推力,修复最后 2C 爬 141s) */
|
||||
#define HEATER_NEAR_MAX_DUTY 50U /* 近场扩展区推力上限(%):快而不过冲 */
|
||||
#define HEATER_OVERTEMP_C 150.0f /* 过温强制关断阈值(滞回保护) */
|
||||
#define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */
|
||||
|
||||
|
||||
+5
-4
@@ -12,9 +12,10 @@ void heat_init(void);
|
||||
|
||||
/**
|
||||
* @brief 加热控制任务(在主循环中周期调用,主循环周期 10ms)。
|
||||
* @details 内部包含:慢 PWM 载波(1s 周期、10% 步进,脉宽恒 >=100ms)、
|
||||
* PID 计算(500ms 周期,输出量化到占空档位)、安全保护
|
||||
* (传感器故障强制关断 / 过温滞回保护 / 未使能强制关断)。
|
||||
* @details 内部包含:慢 PWM 载波(2s 周期、5% 步进,脉宽恒 >=100ms)、
|
||||
* 目标斜坡 + 连续 PID(500ms 周期,全误差区间连续输出、微分基于
|
||||
* 温度变化率、积分窄带吸收+钳位)、安全保护(传感器故障去抖确认后
|
||||
* 强制关断 / 过温滞回保护 / 未使能强制关断)。
|
||||
*/
|
||||
void heat_task(void);
|
||||
|
||||
@@ -40,7 +41,7 @@ void heat_set_target(float temp_c);
|
||||
float heat_get_target(void);
|
||||
|
||||
/**
|
||||
* @brief 查询当前量化占空比档位(0~10,对应 0~100%,10% 步进)。
|
||||
* @brief 查询当前量化占空比档位(0~20,对应 0~100%,5% 步进)。
|
||||
*/
|
||||
uint8_t heat_get_duty(void);
|
||||
|
||||
|
||||
+199
-59
@@ -1,12 +1,26 @@
|
||||
/**
|
||||
* @file heat.c
|
||||
* @brief 加热控制:慢 PWM 驱动(PB12)+ PID 控温闭环 + 安全保护。
|
||||
* @brief 加热控制:慢 PWM 驱动(PB12)+ 斜坡设定值 + 分段 PID 控温闭环 + 安全保护。
|
||||
* @details 硬件:PB12=HEAT_EN,高 = 加热开(2N7002K 电平转换 → VBE2412 PMOS → 24V)。
|
||||
* 开关频率约束(<=10Hz)由"1s 载波周期 + 10% 占空步进"保证——每档脉宽
|
||||
* 恒为 100ms 的整数倍。PID 以 500ms 周期计算(NTC 后台采样 100ms),
|
||||
* 输出量化到 0~10 档占空。
|
||||
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT)强制关断;
|
||||
* 过温(>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。
|
||||
* 开关频率约束(<=10Hz)由"2s 载波周期 + 5% 占空步进"保证——每档脉宽
|
||||
* 恒为 100ms 的整数倍(0.5Hz 载波、20 档占空分辨率)。
|
||||
* 控制结构(平滑逼近目标,抑制过冲/震荡):
|
||||
* 1. 目标斜坡:内部设定值以 HEATER_RAMP_CPS 限速滑向 M4 目标,
|
||||
* 设定值阶跃/跳变不直接进入误差;
|
||||
* 2. 全误差区间连续输出:err >= 温和带 P/D 全量;0 <= err < 温和带
|
||||
* P/D 钳到 HEATER_SLOWBAND_DUTY(积分不参与钳);err < 0(超过
|
||||
* 内部目标)P 负项直接抵扣积分后钳 0——P 参与使输出随超温幅度
|
||||
* 立即收敛,积分只按剩余需要维持(若 P 铰 0 积分独撑,过线后
|
||||
* 输出不随 err 变化,形成 sag->重建->过冲->排空自持极限环);
|
||||
* 3. 微分基于温度变化率(D on measurement,-Kd*ΔT/拍):设定值
|
||||
* 变化不产生微分踢;升温时提前退功率,回落越过目标时提前止跌;
|
||||
* 4. 积分钳位 [0, HEATER_PID_I_MAX],吸收仅限目标近旁窄带
|
||||
* (HEATER_PID_INTEG_ZONE_C)且 P/D 未被钳时(防爬升段
|
||||
* windup);err<0 且 u>0 时小量回退,积分向稳态收敛;
|
||||
* 稳态维持功率由积分承载。
|
||||
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT,连续 3 拍去抖确认,
|
||||
* 单拍毛刺不触发;短故障恢复无扰还原积分)强制关断;过温
|
||||
* (>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。
|
||||
*/
|
||||
|
||||
#include "heat.h"
|
||||
@@ -19,19 +33,30 @@
|
||||
/* 内部状态 */
|
||||
|
||||
static bool s_enable = false; /* 加热使能(M3) */
|
||||
static float s_target = 0.0f; /* 目标温度(M4) */
|
||||
static uint8_t s_duty10 = 0; /* 量化占空档位 0~10(x10%) */
|
||||
static float s_target = 0.0f; /* 目标温度(M4,外部设定值) */
|
||||
static float s_ramp_target = 0.0f; /* 内部斜坡目标(以 HEATER_RAMP_CPS 滑向 s_target) */
|
||||
static uint8_t s_duty = 0; /* 量化占空档位 0~HEATER_PWM_STEPS(x STEP%) */
|
||||
static bool s_overtemp = false; /* 过温锁存(滞回恢复) */
|
||||
static float s_integral = 0.0f; /* PID 积分项 */
|
||||
static float s_prev_err = 0.0f; /* PID 上次误差 */
|
||||
static float s_integral = 0.0f; /* PID 积分累加(钳 [0, HEATER_PID_I_MAX]) */
|
||||
static float s_prev_temp = 0.0f; /* 上拍温度(微分 on measurement 用) */
|
||||
static float s_duty_acc = 0.0f; /* 占空抖动累加器(档位单位,<1) */
|
||||
static uint8_t s_fault_debounce = 0; /* NTC 故障去抖(连续故障拍数,255 封顶记时长) */
|
||||
static float s_fault_stash_I = 0.0f; /* 故障确认时刻积分暂存(短故障无扰还原用) */
|
||||
static float s_fault_stash_T = 0.0f; /* 故障前末次正常温度(还原条件校验用) */
|
||||
static float s_t_filt = 25.0f; /* 斜率限幅后的温度(读数毛刺防护) */
|
||||
static uint8_t s_slew_cnt = 0; /* 连续超斜率拍数(真实快速变化放行计数) */
|
||||
static uint16_t s_pwm_phase_ms = 0; /* PWM 载波相位 0..PERIOD-1 */
|
||||
static uint16_t s_pid_divider = 0; /* PID 周期分频(10ms 圈) */
|
||||
|
||||
/* 档位总数 = 100% / 步进% */
|
||||
#define HEATER_PWM_STEPS (100U / HEATER_PWM_STEP_PCT)
|
||||
|
||||
/* NTC 故障确认拍数(每拍 = 1 个 PID 周期 500ms;连续 3 拍 = 1.5s) */
|
||||
#define NTC_FAULT_CONFIRM_PID_STEPS 3U
|
||||
|
||||
/******************************************************************************/
|
||||
/* 底层输出 */
|
||||
|
||||
static void heat_out(bool on);
|
||||
|
||||
static void heat_out(bool on) {
|
||||
if (on) {
|
||||
gpio_bit_set(HEATER_EN_PORT, HEATER_EN_PIN);
|
||||
@@ -52,30 +77,73 @@ void heat_init(void) {
|
||||
/* PID */
|
||||
|
||||
/**
|
||||
* @brief 复位 PID 积分与历史误差(使能切换/传感器故障时调用,防积分残留冲击)。
|
||||
*/
|
||||
static void pid_reset(void) {
|
||||
s_integral = 0.0f;
|
||||
s_prev_err = 0.0f;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief PID 计算(500ms 周期):误差 -> 占空档位 s_duty10(0~10)。
|
||||
* @brief 分段 PID 计算(500ms 周期):误差 -> 占空档位 s_duty。
|
||||
*/
|
||||
static void pid_compute(void) {
|
||||
float t = ntc_get_temperature();
|
||||
float t_raw = ntc_get_temperature();
|
||||
|
||||
/* 传感器故障(开路/短路):安全第一,强制关断并复位 PID */
|
||||
if (t <= -273.0f) {
|
||||
s_duty10 = 0;
|
||||
pid_reset();
|
||||
return;
|
||||
/* NTC 读数斜率限幅(V0.1.9)+ 大偏差冻结(V0.1.13):真实热速率
|
||||
<=0.2C/拍,单拍 |Δ|>0.5C 判为读数毛刺,沿限幅值过渡,连续 2 拍超限
|
||||
后——若累计偏差 > HEATER_NTC_JUMP_C(秒级塌陷/卡死事件,真机实测
|
||||
-2.5C/4.5s 塌陷与 +2.3C/10s 顶格,焊接点接触不良)则冻结滤波值
|
||||
不再跟随,直到读数回弹到偏差带内自动恢复。真实温度变化永远不会
|
||||
触发冻结(最大冷却 ~0.05C/拍)。过温方向冻结偏差最大 1.5C,安全。 */
|
||||
float t = t_raw;
|
||||
if (t_raw > -273.0f) {
|
||||
float d_raw = t_raw - s_t_filt;
|
||||
if (d_raw > HEATER_NTC_SLEW_C || d_raw < -HEATER_NTC_SLEW_C) {
|
||||
if (s_slew_cnt < 2U) {
|
||||
s_slew_cnt++;
|
||||
t = s_t_filt + ((d_raw > 0.0f) ? HEATER_NTC_SLEW_C : -HEATER_NTC_SLEW_C);
|
||||
} else if (d_raw > HEATER_NTC_JUMP_C || d_raw < -HEATER_NTC_JUMP_C) {
|
||||
t = s_t_filt; /* 秒级坏值:冻结 */
|
||||
} else {
|
||||
s_slew_cnt = 2U; /* 偏差带内:放行 */
|
||||
}
|
||||
} else {
|
||||
s_slew_cnt = 0U;
|
||||
}
|
||||
s_t_filt = t;
|
||||
} else {
|
||||
s_slew_cnt = 0U;
|
||||
}
|
||||
|
||||
/* 未使能:强制关断 */
|
||||
/* NTC 故障去抖:单拍毛刺(采样/EMI)保持输出不动;连续确认才安全关断。
|
||||
毛刺拍不更新任何 PID 状态(含 s_prev_temp),下拍用真实温差继续。
|
||||
确认沿暂存积分与故障前温度——短故障恢复时无扰还原(见恢复分支)。 */
|
||||
if (t <= -273.0f) {
|
||||
if (s_fault_debounce < 255U) {
|
||||
s_fault_debounce++;
|
||||
}
|
||||
if (s_fault_debounce >= NTC_FAULT_CONFIRM_PID_STEPS) {
|
||||
if (s_fault_debounce == NTC_FAULT_CONFIRM_PID_STEPS) {
|
||||
s_fault_stash_I = s_integral;
|
||||
s_fault_stash_T = s_prev_temp;
|
||||
}
|
||||
s_duty = 0;
|
||||
s_integral = 0.0f;
|
||||
s_duty_acc = 0.0f;
|
||||
s_prev_temp = t;
|
||||
}
|
||||
return;
|
||||
}
|
||||
/* 故障恢复:短故障(<=HEATER_FAULT_RESTORE_MAX_S)且温度未显著漂移
|
||||
(|ΔT|<2℃)时还原暂存积分——避免毛刺清零后稳态功率丢失引发
|
||||
sag->重建->过冲循环;长故障/温度已漂移则从零重建(安全优先) */
|
||||
if (s_fault_debounce >= NTC_FAULT_CONFIRM_PID_STEPS) {
|
||||
float fault_dur_s = (float)s_fault_debounce * (HEATER_PID_PERIOD_MS / 1000.0f);
|
||||
float drift = t - s_fault_stash_T;
|
||||
if (fault_dur_s <= HEATER_FAULT_RESTORE_MAX_S && drift < 2.0f && drift > -2.0f) {
|
||||
s_integral = s_fault_stash_I;
|
||||
}
|
||||
}
|
||||
s_fault_debounce = 0U;
|
||||
|
||||
/* 未使能:强制关断(每拍刷新微分基线,恢复时无假温差) */
|
||||
if (!s_enable) {
|
||||
s_duty10 = 0;
|
||||
pid_reset();
|
||||
s_duty = 0;
|
||||
s_integral = 0.0f;
|
||||
s_prev_temp = t;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,39 +154,96 @@ static void pid_compute(void) {
|
||||
s_overtemp = false;
|
||||
}
|
||||
if (s_overtemp) {
|
||||
s_duty10 = 0;
|
||||
pid_reset();
|
||||
s_duty = 0;
|
||||
s_integral = 0.0f;
|
||||
s_duty_acc = 0.0f;
|
||||
s_prev_temp = t;
|
||||
return;
|
||||
}
|
||||
|
||||
/* 位置式 PID(带抗积分饱和):输出饱和时积分仅沿退饱和方向更新,
|
||||
防止升温段积分顶满后长时间维持 100% 输出(此前实测目标 50℃ 冲到 59℃) */
|
||||
float err = s_target - t;
|
||||
float u_noi = HEATER_PID_KP * err
|
||||
+ HEATER_PID_KD * (err - s_prev_err); /* 比例 + 微分 */
|
||||
float integ_new = s_integral + err;
|
||||
float u = u_noi + HEATER_PID_KI * integ_new;
|
||||
/* 目标斜坡:内部设定值限速滑向 s_target(升/降同限速)。
|
||||
小步进(斜坡快于温升)全程落在温和带内缓升;大步进爬升段 ramp 超前、
|
||||
到站后误差随实际温度平滑收缩,抑制热滞后过冲。 */
|
||||
float diff = s_target - s_ramp_target;
|
||||
float ramp_step = HEATER_RAMP_CPS * (HEATER_PID_PERIOD_MS / 1000.0f);
|
||||
if (diff > ramp_step) {
|
||||
s_ramp_target += ramp_step;
|
||||
} else if (diff < -ramp_step) {
|
||||
s_ramp_target -= ramp_step;
|
||||
} else {
|
||||
s_ramp_target = s_target;
|
||||
}
|
||||
|
||||
float err = s_ramp_target - t;
|
||||
float d_t = t - s_prev_temp; /* 本拍温度变化(D on measurement) */
|
||||
|
||||
/* 分区作用(V0.1.12):近场扩展区 [-BAND, SLOWBAND_C) 合并"带内退闸"
|
||||
与"带外下方"为一条连续减速曲线——u = Ki*I(维持功率,不衰减)+
|
||||
ASSIST 退闸曲线(err=+SLOWBAND_C 满档快爬、err=+BAND 处归零)+ Kd 减速。
|
||||
原结构近场推力真空(P 随 err 缩小消失、I 未成熟,最后 2C 爬 141s),
|
||||
ASSIST 提供与积分成熟度无关的确定推力。远场 err >= SLOWBAND_C:P/D
|
||||
全量 + I。过温缘 err < -BAND:关断 + I 限速回退。 */
|
||||
float u;
|
||||
if (err >= HEATER_SLOWBAND_C) {
|
||||
float u_pd = HEATER_PID_KP * err - HEATER_PID_KD * d_t;
|
||||
if (u_pd > 100.0f) {
|
||||
u_pd = 100.0f;
|
||||
}
|
||||
if (u_pd < 0.0f) {
|
||||
u_pd = 0.0f;
|
||||
}
|
||||
u = u_pd + HEATER_PID_KI * s_integral;
|
||||
if (u < 100.0f) {
|
||||
s_integral += err * HEATER_PID_INTEG_GAIN_IN;
|
||||
}
|
||||
if (u > 100.0f) {
|
||||
u = 100.0f;
|
||||
if (err < 0.0f) s_integral = integ_new; /* 超上限:仅误差为负(降温方向)才吸收 */
|
||||
} else if (u < 0.0f) {
|
||||
u = 0.0f;
|
||||
if (err > 0.0f) s_integral = integ_new; /* 超下限:仅误差为正(升温方向)才吸收 */
|
||||
} else {
|
||||
s_integral = integ_new; /* 未饱和:正常积分 */
|
||||
}
|
||||
s_prev_err = err;
|
||||
} else if (err >= -HEATER_PID_BAND_C) {
|
||||
float assist = (float)HEATER_SLOWBAND_DUTY * (err + HEATER_PID_BAND_C)
|
||||
/ (HEATER_SLOWBAND_C + HEATER_PID_BAND_C);
|
||||
u = HEATER_PID_KI * s_integral + assist - HEATER_PID_KD * d_t;
|
||||
s_integral += err * HEATER_PID_INTEG_GAIN_IN;
|
||||
if (u > (float)HEATER_NEAR_MAX_DUTY) {
|
||||
u = (float)HEATER_NEAR_MAX_DUTY; /* 近场推力上限:快而不过冲 */
|
||||
}
|
||||
if (u < 0.0f) {
|
||||
u = 0.0f;
|
||||
}
|
||||
} else {
|
||||
s_integral -= HEATER_PID_INTEG_BLEED;
|
||||
u = 0.0f;
|
||||
}
|
||||
if (s_integral > HEATER_PID_I_MAX) {
|
||||
s_integral = HEATER_PID_I_MAX;
|
||||
}
|
||||
if (s_integral < 0.0f) {
|
||||
s_integral = 0.0f;
|
||||
}
|
||||
|
||||
/* 输出量化到 10% 档位(四舍五入) */
|
||||
s_duty10 = (uint8_t)((u + (float)HEATER_PWM_STEP_PCT / 2.0f) / (float)HEATER_PWM_STEP_PCT);
|
||||
s_prev_temp = t;
|
||||
|
||||
/* 输出 Bresenham 抖动量化:载波 2s = 4 个 PID 拍,档位余数按拍分摊,
|
||||
平均分辨率 5%/4 = 1.25%。稳态维持功率(实测 ~10-12%)恰落在整数档
|
||||
之间时,固定取整会迫使输出在相邻档间满幅切换(±1℃ 极限环残因);
|
||||
抖动后 PI 可停在真实需要的平均功率上。脉宽仍为 100ms 整数倍,
|
||||
开关频率仍 0.5Hz,硬件约束不变 */
|
||||
s_duty_acc += u / (float)HEATER_PWM_STEP_PCT;
|
||||
if (s_duty_acc >= (float)(HEATER_PWM_STEPS + 1)) {
|
||||
s_duty_acc = (float)(HEATER_PWM_STEPS + 1);
|
||||
}
|
||||
{
|
||||
uint8_t level = (uint8_t)s_duty_acc; /* u >= 0,截断即 floor */
|
||||
s_duty_acc -= (float)level;
|
||||
s_duty = level;
|
||||
}
|
||||
}
|
||||
|
||||
/******************************************************************************/
|
||||
/* 公共接口 */
|
||||
|
||||
void heat_task(void) {
|
||||
uint8_t prev_duty = s_duty10;
|
||||
uint8_t prev_duty = s_duty;
|
||||
|
||||
/* PWM 载波相位推进(10ms tick) */
|
||||
s_pwm_phase_ms += 10U;
|
||||
@@ -131,16 +256,18 @@ void heat_task(void) {
|
||||
if (s_pid_divider >= HEATER_PID_PERIOD_MS) {
|
||||
s_pid_divider = 0U;
|
||||
pid_compute();
|
||||
if (s_duty10 != prev_duty) {
|
||||
s_pwm_phase_ms = 0U; /* 占空变更:从新周期起点开始,响应即时 */
|
||||
if (s_duty > prev_duty) {
|
||||
s_pwm_phase_ms = 0U; /* 仅升档复位相位:立即起算新 ON 窗口;
|
||||
降档不复位——相位已过新窗口即刻关断,
|
||||
不再额外补发一段 ON 脉冲 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 按占空档位输出:0 档常关、10 档常开、其余按相位斩波 */
|
||||
uint16_t on_ms = (uint16_t)s_duty10 * (HEATER_PWM_PERIOD_MS / 10U);
|
||||
if (s_duty10 == 0U) {
|
||||
/* 按占空档位输出:0 档常关、满档常开、其余按相位斩波 */
|
||||
uint16_t on_ms = (uint16_t)s_duty * (HEATER_PWM_PERIOD_MS / HEATER_PWM_STEPS);
|
||||
if (s_duty == 0U) {
|
||||
heat_out(false);
|
||||
} else if (s_duty10 >= 10U) {
|
||||
} else if (s_duty >= HEATER_PWM_STEPS) {
|
||||
heat_out(true);
|
||||
} else {
|
||||
heat_out(s_pwm_phase_ms < on_ms);
|
||||
@@ -150,9 +277,22 @@ void heat_task(void) {
|
||||
void heat_set_enable(bool on) {
|
||||
if (s_enable == on) return; /* 幂等 */
|
||||
s_enable = on;
|
||||
if (!on) {
|
||||
s_duty10 = 0;
|
||||
pid_reset();
|
||||
if (on) {
|
||||
/* 从当前实际温度起坡:避免内部目标从 0 跳变造成功率冲击;
|
||||
微分基线/读数滤波基线同步取当前温度,首拍无假温差 */
|
||||
s_ramp_target = ntc_get_temperature();
|
||||
if (s_ramp_target < 0.0f) {
|
||||
s_ramp_target = 0.0f; /* 传感器故障标记值非法,退化为 0 */
|
||||
}
|
||||
s_prev_temp = s_ramp_target;
|
||||
s_t_filt = (s_ramp_target > 0.0f) ? s_ramp_target : 25.0f;
|
||||
s_slew_cnt = 0U;
|
||||
s_fault_debounce = 0U;
|
||||
} else {
|
||||
s_ramp_target = 0.0f;
|
||||
s_duty = 0;
|
||||
s_integral = 0.0f;
|
||||
s_duty_acc = 0.0f;
|
||||
heat_out(false);
|
||||
}
|
||||
}
|
||||
@@ -170,5 +310,5 @@ float heat_get_target(void) {
|
||||
}
|
||||
|
||||
uint8_t heat_get_duty(void) {
|
||||
return s_duty10;
|
||||
return s_duty;
|
||||
}
|
||||
|
||||
@@ -97,11 +97,43 @@ float ntc_read_temperature(void) {
|
||||
static float s_ntc_last_temp = -273.15f; /* 最新温度(初值 = 开路标记) */
|
||||
static uint16_t s_ntc_sample_divider = 0; /* 主循环 10ms 圈分频计数 */
|
||||
|
||||
/* 滑动中值滤波(V0.1.11):真机读数毛刺可持续 1.4s+(曾抓到 14 帧连续
|
||||
OPEN),会穿透控制环的斜率限幅并触发猛拉。最近 NTC_MEDIAN_N 个样本取
|
||||
中值——单点/短串毛刺被完全剔除,滞后仅 ~0.2s(远小于低通滤波)。
|
||||
故障标记值(-273/-274)参与排序:偶发 OPEN 被中值滤掉,连续 >=3 帧才
|
||||
成为中值(等效又一层去抖),故障判定语义不变。 */
|
||||
#define NTC_MEDIAN_N 5U
|
||||
static float s_ntc_win[NTC_MEDIAN_N];
|
||||
static uint8_t s_ntc_win_idx = 0;
|
||||
static uint8_t s_ntc_win_fill = 0;
|
||||
|
||||
void ntc_sample_task(void) {
|
||||
/* 主循环周期 10ms:按 NTC_SAMPLE_PERIOD_MS 分频触发一次采样 */
|
||||
if (++s_ntc_sample_divider >= (NTC_SAMPLE_PERIOD_MS / 10U)) {
|
||||
s_ntc_sample_divider = 0;
|
||||
s_ntc_last_temp = ntc_read_temperature();
|
||||
|
||||
float v = ntc_read_temperature();
|
||||
s_ntc_win[s_ntc_win_idx] = v;
|
||||
s_ntc_win_idx = (uint8_t)((s_ntc_win_idx + 1U) % NTC_MEDIAN_N);
|
||||
if (s_ntc_win_fill < NTC_MEDIAN_N) {
|
||||
s_ntc_win_fill++;
|
||||
}
|
||||
|
||||
/* 插入排序后取中值(5 点,开销可忽略) */
|
||||
float tmp[NTC_MEDIAN_N];
|
||||
for (uint8_t i = 0; i < s_ntc_win_fill; i++) {
|
||||
tmp[i] = s_ntc_win[i];
|
||||
}
|
||||
for (uint8_t i = 1; i < s_ntc_win_fill; i++) {
|
||||
float key = tmp[i];
|
||||
int8_t j = (int8_t)(i - 1);
|
||||
while (j >= 0 && tmp[j] > key) {
|
||||
tmp[j + 1] = tmp[j];
|
||||
j--;
|
||||
}
|
||||
tmp[j + 1] = key;
|
||||
}
|
||||
s_ntc_last_temp = tmp[s_ntc_win_fill / 2U];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
set(PROJECT_NAME "FilmForge_FW")
|
||||
set(BOARD_TYPE_CODE 41)
|
||||
set(VERSION_MAJOR 0)
|
||||
set(VERSION_MINOR 0)
|
||||
set(VERSION_PATCH 1)
|
||||
set(VERSION_MINOR 1)
|
||||
set(VERSION_PATCH 14)
|
||||
set(VERSION "V${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
|
||||
string(TIMESTAMP BUILD_DATE "%Y-%m-%d")
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Parse live temperature frames from test_events.log tail (test writes it live)."""
|
||||
import re
|
||||
import sys
|
||||
|
||||
n = int(sys.argv[1]) if len(sys.argv) > 1 else 60
|
||||
rows = []
|
||||
for ln in open("test_events.log", encoding="utf-8", errors="replace"):
|
||||
m = re.match(r"^([\d.]+) . ([\d.]+) \[B5", ln)
|
||||
if m:
|
||||
rows.append((float(m.group(1)), float(m.group(2))))
|
||||
print(f"{len(rows)} temp frames total")
|
||||
if len(rows) > 10:
|
||||
t1, v1 = rows[8]
|
||||
t2, v2 = rows[-1]
|
||||
print(f"{v1:.1f}C@{t1:.0f}s -> {v2:.1f}C@{t2:.0f}s slope={(v2-v1)/(t2-t1):.3f} C/s")
|
||||
for t, v in rows[-8:]:
|
||||
print(f" t={t:7.1f}s {v:6.1f}C")
|
||||
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Decode obs_samples.txt (openocd mrw words, V0.1.2 layout) into quantities."""
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def f(w):
|
||||
return struct.unpack("<f", struct.pack("<i", int(w)))[0]
|
||||
|
||||
|
||||
def u8(word, byte):
|
||||
return (int(word) >> (8 * byte)) & 0xFF
|
||||
|
||||
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else "obs_samples.txt"
|
||||
rows = []
|
||||
for ln in open(path, encoding="utf-8"):
|
||||
parts = ln.split()
|
||||
if len(parts) != 11 or not parts[0].isdigit():
|
||||
continue
|
||||
v = parts
|
||||
rows.append({
|
||||
"t": int(v[0]) / 1000.0,
|
||||
"stash_T": f(v[1]), "stash_I": f(v[2]), "fault": u8(v[3], 0),
|
||||
"prev": f(v[4]), "integ": f(v[5]), "duty": u8(v[6], 1),
|
||||
"ramp": f(v[7]), "tgt": f(v[8]), "en": u8(v[9], 0), "temp": f(v[10]),
|
||||
})
|
||||
|
||||
if not rows:
|
||||
print("no rows")
|
||||
sys.exit(1)
|
||||
|
||||
t0 = rows[0]["t"]
|
||||
print(f"{'t':>7} {'temp':>7} {'tgt':>6} {'ramp':>7} {'duty%':>5} {'integ':>8} {'prev':>7} {'fl':>3} {'en'}")
|
||||
for r in rows:
|
||||
print(f"{r['t']-t0:7.1f} {r['temp']:7.2f} {r['tgt']:6.1f} {r['ramp']:7.2f} "
|
||||
f"{r['duty']*5:5d} {r['integ']:8.2f} {r['prev']:7.2f} {r['fault']:3d} {r['en']}")
|
||||
|
||||
print("\n-- events (duty/fault/integ-zero 变化) --")
|
||||
last = None
|
||||
for r in rows:
|
||||
key = (r["fault"], r["duty"], r["integ"] == 0.0)
|
||||
if last is not None and key != last[0]:
|
||||
print(f"t={r['t']-t0:7.1f}s fault={r['fault']} duty={r['duty']*5}% integ={r['integ']:.2f} "
|
||||
f"temp={r['temp']:.2f}")
|
||||
last = (key, r)
|
||||
n_fault = sum(1 for r in rows if r["fault"] > 0)
|
||||
temps = [r["temp"] for r in rows]
|
||||
ints = [r["integ"] for r in rows if r["en"]]
|
||||
print(f"\nsamples={len(rows)} fault_beats={n_fault} temp[{min(temps):.2f},{max(temps):.2f}] "
|
||||
f"integ[{min(ints) if ints else 0:.1f},{max(ints) if ints else 0:.1f}] (en 段)")
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Decode the LAST N raw rows of obs_samples.txt (guaranteed current writer)."""
|
||||
import struct
|
||||
import sys
|
||||
|
||||
|
||||
def f(w):
|
||||
w = int(w) & 0xFFFFFFFF
|
||||
return struct.unpack("<f", struct.pack("<I", w))[0]
|
||||
|
||||
|
||||
def u8(w, b):
|
||||
return (int(w) >> (8 * b)) & 0xFF
|
||||
|
||||
|
||||
n = int(sys.argv[1]) if len(sys.argv) > 1 else 400
|
||||
stride = int(sys.argv[2]) if len(sys.argv) > 2 else 10
|
||||
lines = open("obs_samples.txt", encoding="utf-8").read().splitlines()
|
||||
rows = [l.split() for l in lines if len(l.split()) == 11 and l.split()[0].isdigit()]
|
||||
rows = rows[-n:]
|
||||
t0 = int(rows[0][0])
|
||||
print(f"{len(rows)} rows (last), stride {stride}:")
|
||||
for r in rows[::stride]:
|
||||
print(f"t={(int(r[0])-t0)/1000:7.1f} temp={f(r[10]):6.2f} duty={u8(r[6],1)*5:3d}% "
|
||||
f"integ={f(r[5]):6.2f} fault={u8(r[3],0)} en={u8(r[9],0)}")
|
||||
temps = [f(r[10]) for r in rows]
|
||||
ints = [f(r[5]) for r in rows]
|
||||
print(f"window: temp[{min(temps):.2f},{max(temps):.2f}] p2p={max(temps)-min(temps):.2f} "
|
||||
f"integ[{min(ints):.1f},{max(ints):.1f}]")
|
||||
@@ -0,0 +1,28 @@
|
||||
# DAPLink observer V0.1.12: writes PID state to obs_samples.txt every 500ms
|
||||
# W1@0x20000460 fault_stash_T(f) W2@0x20000464 fault_stash_I(f) W3@0x20000468 b0=fault_debounce
|
||||
# W4@0x20000470 prev_temp(f) W5@0x20000474 integral(f) W6@0x20000478 b1=duty
|
||||
# W7@0x2000047c ramp_target(f) W8@0x20000480 target(f) W9@0x20000484 b0=enable
|
||||
# W10@0x200001d0 ntc_last_temp(f)
|
||||
|
||||
set LOGF "E:/Hulk-Coding/FilmForge/obs_samples.txt"
|
||||
set g_f [open $LOGF a]
|
||||
proc logline {s} { global g_f; puts $g_f $s; flush $g_f }
|
||||
|
||||
logline "OBS start [clock milliseconds]"
|
||||
if {[catch {init} err]} { logline "ERR init: $err" }
|
||||
while {1} {
|
||||
if {[catch {
|
||||
set w1 [mrw 0x20000460]
|
||||
set w2 [mrw 0x20000464]
|
||||
set w3 [mrw 0x20000468]
|
||||
set w4 [mrw 0x20000470]
|
||||
set w5 [mrw 0x20000474]
|
||||
set w6 [mrw 0x20000478]
|
||||
set w7 [mrw 0x2000047c]
|
||||
set w8 [mrw 0x20000480]
|
||||
set w9 [mrw 0x20000484]
|
||||
set w10 [mrw 0x200001d0]
|
||||
logline "[clock milliseconds] $w1 $w2 $w3 $w4 $w5 $w6 $w7 $w8 $w9 $w10"
|
||||
} err]} { logline "ERR loop: $err" }
|
||||
sleep 500
|
||||
}
|
||||
+3
-3
@@ -39,9 +39,9 @@
|
||||
| `M2` | 查询上报状态 | `D5 01 02 4D 32 82` | `ON <ms>` 或 `OFF` |
|
||||
| `M2S500` | 设上报间隔并开启 | `D5 01 06 4D 32 53 35 30 30 6E` | `ok`;范围 100~60000ms,10ms 步进 |
|
||||
| `M2S0` | 停止上报 | `D5 01 04 4D 32 53 30 07` | `ok` |
|
||||
| `M3` | 查询加热状态 | `D5 01 02 4D 33 83` | `ON` / `OFF`(预留,当前仅软件状态) |
|
||||
| `M3S1` | 开启加热 | `D5 01 04 4D 33 53 31 09` | `ok`(预留) |
|
||||
| `M3S0` | 关闭加热 | `D5 01 04 4D 33 53 30 08` | `ok`(预留) |
|
||||
| `M3` | 查询加热状态 | `D5 01 02 4D 33 83` | `ON` / `OFF`(加热使能状态) |
|
||||
| `M3S1` | 开启加热 | `D5 01 04 4D 33 53 31 09` | `ok`(PID 闭环真实控温) |
|
||||
| `M3S0` | 关闭加热 | `D5 01 04 4D 33 53 30 08` | `ok` |
|
||||
| `M4` | 查询目标温度 | `D5 01 02 4D 34 84` | ASCII 摄氏度(如 `120.0`) |
|
||||
| `M4S120` | 设定目标温度 | `D5 01 06 4D 34 53 31 32 30 6E` | `ok`;整数摄氏度 0~200,越界回 PARAM_ERR |
|
||||
| `M888` | 软件复位 | `D5 01 04 4D 38 38 38 FA` | `ok` 后复位(慎发) |
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Segment climb-rate analysis from test_events.log (2C buckets)."""
|
||||
import re
|
||||
|
||||
rows = []
|
||||
for ln in open("test_events.log", encoding="utf-8", errors="replace"):
|
||||
m = re.match(r"^([\d.]+) . ([\d.]+) \[B5", ln)
|
||||
if m:
|
||||
rows.append((float(m.group(1)), float(m.group(2))))
|
||||
|
||||
if not rows:
|
||||
print("no frames")
|
||||
raise SystemExit
|
||||
|
||||
t0 = rows[0][0]
|
||||
print(f"{len(rows)} frames, span {rows[-1][0]-t0:.0f}s")
|
||||
|
||||
segs = {}
|
||||
for t, v in rows:
|
||||
segs.setdefault(int(v // 2) * 2, []).append((t, v))
|
||||
for k in sorted(segs):
|
||||
seg = segs[k]
|
||||
dt = seg[-1][0] - seg[0][0]
|
||||
dv = seg[-1][1] - seg[0][1]
|
||||
rate = dv / dt if dt > 0 else 0
|
||||
print(f" [{k:3d}-{k+2:3d}C] {dt:6.1f}s rate={rate:+.3f} C/s ({seg[0][1]:.1f}->{seg[-1][1]:.1f})")
|
||||
@@ -0,0 +1,355 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""V1(已烧录 0.1.0)vs V2(连续 PI 候选)离线对比仿真。
|
||||
|
||||
V1 问题:err<0 硬切 0% + err>=0 只有 Kp*err,稳态维持功率全靠 Ki=0.05 慢慢填
|
||||
(填到 ~10% 要几分钟)→ 目标附近等效"P+上侧死区"继电器 → ±1℃/15~20s 极限环
|
||||
(真机 2026-10-15 截图实测形态)。
|
||||
V2:连续 PID 公式覆盖 err<0(输出钳 [0,100] 天然处理"不能制冷"),
|
||||
Ki 0.05→0.5、I_MAX 2000→200(Ki*I_MAX=100% 不变)。
|
||||
|
||||
热模型按本次实测标定:50℃ 处冷却 ~0.1 C/s、~12% 占空净升 ~0.12 C/s
|
||||
→ a≈1.9 C/s@100%,b≈0.0036/s(50℃ 维持功率 ≈5%)。
|
||||
"""
|
||||
|
||||
KP, KI, KD = 10.0, 0.05, 30.0 # V1
|
||||
KP2, KI2, KD2 = 10.0, 0.5, 30.0 # V2 候选
|
||||
RAMP_CPS = 0.8
|
||||
SLOWBAND_C, SLOWBAND_DUTY = 7.0, 25.0
|
||||
I_MAX, I_MAX2 = 2000.0, 200.0
|
||||
PWM_PERIOD_MS, PWM_STEP, PID_PERIOD_MS = 2000, 5, 500
|
||||
|
||||
A, B, TA, TAU_S = 1.9, 0.0036, 22.0, 4.0
|
||||
|
||||
|
||||
def plant_step(Tc, Ts, power, dt):
|
||||
Tc += (A * power - B * (Tc - TA)) * dt
|
||||
Ts += (Tc - Ts) / TAU_S * dt
|
||||
return Tc, Ts
|
||||
|
||||
|
||||
class Base:
|
||||
_last_power = 0.0 # 上一拍 PWM 输出(传感器加热偏置建模用)
|
||||
|
||||
def __init__(self, init_temp=None):
|
||||
self.enable = False
|
||||
self.target = 0.0
|
||||
self.duty = 0
|
||||
self.integral = 0.0
|
||||
self.phase_ms = 0
|
||||
self.divider = 0
|
||||
self.Tc = TA if init_temp is None else init_temp
|
||||
self.Ts = self.Tc
|
||||
|
||||
def tick(self):
|
||||
prev_duty = self.duty
|
||||
self.phase_ms = (self.phase_ms + 10) % PWM_PERIOD_MS
|
||||
self.divider += 10
|
||||
if self.divider >= PID_PERIOD_MS:
|
||||
self.divider = 0
|
||||
self.pid_compute()
|
||||
if self.duty > prev_duty:
|
||||
self.phase_ms = 0
|
||||
on_ms = self.duty * (PWM_PERIOD_MS // (100 // PWM_STEP))
|
||||
power = 0.0 if self.duty == 0 else (
|
||||
1.0 if self.duty >= 100 // PWM_STEP else (1.0 if self.phase_ms < on_ms else 0.0))
|
||||
self.Tc, self.Ts = plant_step(self.Tc, self.Ts, power, 0.01)
|
||||
self._last_power = power
|
||||
return power
|
||||
|
||||
def quant(self, u):
|
||||
u = max(0.0, min(100.0, u))
|
||||
return int((u + PWM_STEP / 2.0) / PWM_STEP)
|
||||
|
||||
def ramp_to(self):
|
||||
diff = self.target - self.ramp_target
|
||||
step = RAMP_CPS * (PID_PERIOD_MS / 1000.0)
|
||||
if diff > step:
|
||||
self.ramp_target += step
|
||||
elif diff < -step:
|
||||
self.ramp_target -= step
|
||||
else:
|
||||
self.ramp_target = self.target
|
||||
|
||||
def set_enable(self, on):
|
||||
if self.enable == on:
|
||||
return
|
||||
self.enable = on
|
||||
if on:
|
||||
self.ramp_target = max(0.0, self.Ts)
|
||||
self.prev_temp = self.ramp_target
|
||||
else:
|
||||
self.ramp_target = self.duty = 0
|
||||
self.integral = 0.0
|
||||
|
||||
|
||||
class V1(Base):
|
||||
"""已烧录 0.1.0:err<0 硬切 + 慢积分。"""
|
||||
|
||||
def pid_compute(self):
|
||||
t = self.Ts
|
||||
if not self.enable:
|
||||
self.duty, self.integral, self.prev_temp = 0, 0.0, t
|
||||
return
|
||||
self.ramp_to()
|
||||
err = self.ramp_target - t
|
||||
d_t = t - self.prev_temp
|
||||
if err < 0:
|
||||
self.integral = max(0.0, self.integral + err)
|
||||
self.prev_temp = t
|
||||
self.duty = 0
|
||||
return
|
||||
u_pd = KP * err - KD * d_t
|
||||
if err < SLOWBAND_C:
|
||||
u_pd = min(SLOWBAND_DUTY, u_pd)
|
||||
u_pd = max(0.0, u_pd)
|
||||
u = u_pd + KI * self.integral
|
||||
if u < 100.0:
|
||||
self.integral = min(I_MAX, self.integral + err)
|
||||
self.prev_temp = t
|
||||
self.duty = self.quant(u)
|
||||
|
||||
|
||||
class V2(Base):
|
||||
"""候选:连续 PID(无 err<0 硬切)+ 快积分 + 执行器受限不吸收。
|
||||
|
||||
吸收条件:err>0 时仅当 P/D 未被钳(0 < u_pd_raw < 当前上限,即执行器
|
||||
真实响应误差)才吸收——爬升/温和带钳位段不积分,防瞬态 windup;
|
||||
err<0 时只要 u>0 就回退(维持功率受 P 负项与 D 共同压制)。
|
||||
"""
|
||||
|
||||
def pid_compute(self):
|
||||
t = self.Ts
|
||||
if not self.enable:
|
||||
self.duty, self.integral, self.prev_temp = 0, 0.0, t
|
||||
return
|
||||
self.ramp_to()
|
||||
err = self.ramp_target - t
|
||||
d_t = t - self.prev_temp
|
||||
u_pd_raw = KP2 * err - KD2 * d_t
|
||||
cap = SLOWBAND_DUTY if err < SLOWBAND_C else 100.0
|
||||
u_pd = max(0.0, min(cap, u_pd_raw))
|
||||
u = u_pd + KI2 * self.integral
|
||||
if (err > 0 and 0.0 < u_pd_raw < cap) or (err < 0 and u > 0.0):
|
||||
self.integral = max(0.0, min(I_MAX2, self.integral + err))
|
||||
self.prev_temp = t
|
||||
self.duty = self.quant(u)
|
||||
|
||||
|
||||
class V3(Base):
|
||||
"""V3:连续 PID + 积分吸收限定在目标近旁窄带(|err|<ZONE)。
|
||||
|
||||
远处/爬升段不积分(slowband 钳位 + P 已经在工作),只有贴近目标才允许
|
||||
积分建立稳态功率——杜绝瞬态 windup;err<0 且 u>0 时照常回退。
|
||||
参数从 V3_KP/KI/KD 全局取,便于扫参。"""
|
||||
|
||||
def pid_compute(self):
|
||||
t = self.Ts
|
||||
if not self.enable:
|
||||
self.duty, self.integral, self.prev_temp = 0, 0.0, t
|
||||
return
|
||||
self.ramp_to()
|
||||
err = self.ramp_target - t
|
||||
d_t = t - self.prev_temp
|
||||
u_pd_raw = V3_KP * err - V3_KD * d_t
|
||||
cap = SLOWBAND_DUTY if err < SLOWBAND_C else 100.0
|
||||
u_pd = max(0.0, min(cap, u_pd_raw))
|
||||
u = u_pd + V3_KI * self.integral
|
||||
if (0.0 < err < V3_ZONE and 0.0 < u_pd_raw < cap) or (err < 0 and u > 0.0):
|
||||
self.integral = max(0.0, min(V3_IMAX, self.integral + err))
|
||||
self.prev_temp = t
|
||||
self.duty = self.quant(u)
|
||||
|
||||
|
||||
V3_KP, V3_KI, V3_KD, V3_ZONE, V3_IMAX = 10.0, 0.25, 30.0, 2.0, 400.0
|
||||
|
||||
|
||||
class V4(Base):
|
||||
"""V4(final) = V3 + ① err<0 时 P 负项直接抵扣积分(总量钳 [0,100])
|
||||
② 积分窄带 2.0->0.6(单次爬升最多喂 ~I=12 ≈ 真机维持功率,防膨胀)
|
||||
③ 故障无扰恢复(<3s 且 |ΔT|<2℃ 还原暂存积分)
|
||||
真机根因(2026-10-15 DAPLink 抓证 t=397~486s):积分爬升段膨胀到 65
|
||||
(维持仅需 ~12)→ 过线后 P 钳 0、Ki·I 顶 10~20% 慢泄 → +2℃ 飙升 →
|
||||
泄光下潜 → 再爬再喂,自持极限环(无毛刺也发生)。"""
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, **kw)
|
||||
self.fault_beats = 0
|
||||
self.stash_I = 0.0
|
||||
self.stash_T = 0.0
|
||||
self.glitch_now = False
|
||||
self.noise = __import__("random").Random(20261015)
|
||||
|
||||
def sensor(self):
|
||||
t = -273.15 if self.glitch_now else self.Ts
|
||||
if t > -100.0:
|
||||
t += self.noise.uniform(-0.05, 0.05) # 读数噪声
|
||||
t -= 0.15 if self._last_power > 0.5 else 0.0 # 加热时鳍片梯度偏低读
|
||||
return t
|
||||
|
||||
def pid_compute(self):
|
||||
t = self.sensor()
|
||||
if t <= -273.0:
|
||||
if self.fault_beats < 255:
|
||||
self.fault_beats += 1
|
||||
if self.fault_beats == 3:
|
||||
self.stash_I = self.integral
|
||||
self.stash_T = self.prev_temp
|
||||
if self.fault_beats >= 3:
|
||||
self.duty = 0
|
||||
self.integral = 0.0
|
||||
self.prev_temp = t
|
||||
return
|
||||
if self.fault_beats >= 3:
|
||||
dur = self.fault_beats * (PID_PERIOD_MS / 1000.0)
|
||||
if dur <= 3.0 and abs(t - self.stash_T) < 2.0:
|
||||
self.integral = min(V4_IMAX, self.stash_I)
|
||||
self.fault_beats = 0
|
||||
if not self.enable:
|
||||
self.duty, self.integral, self.prev_temp = 0, 0.0, t
|
||||
return
|
||||
self.ramp_to()
|
||||
err = self.ramp_target - t
|
||||
d_t = t - self.prev_temp
|
||||
u_pd_raw = V3_KP * err - V3_KD * d_t
|
||||
cap = SLOWBAND_DUTY if err < SLOWBAND_C else 100.0
|
||||
if err >= 0.0:
|
||||
u_pd = max(0.0, min(cap, u_pd_raw))
|
||||
u = u_pd + V3_KI * self.integral
|
||||
else:
|
||||
u = u_pd_raw + V3_KI * self.integral # P 负项直接抵扣积分
|
||||
if (0.0 < err < V4_ZONE and 0.0 < u_pd_raw < cap) or (err < 0 and u > 0.0):
|
||||
self.integral = max(0.0, min(V4_IMAX, self.integral + err))
|
||||
u = max(0.0, min(100.0, u))
|
||||
self.prev_temp = t
|
||||
self.duty = self.quant(u)
|
||||
|
||||
|
||||
V4_ZONE, V4_IMAX = 0.6, 160.0
|
||||
|
||||
|
||||
class V4_OLD(Base):
|
||||
"""V3 原样(err<0 积分独撑 + 窄带 2.0 + 故障清零重建),毛刺/噪声注入对照。"""
|
||||
|
||||
def __init__(self, *a, **kw):
|
||||
super().__init__(*a, **kw)
|
||||
self.fault_beats = 0
|
||||
self.glitch_now = False
|
||||
self.noise = __import__("random").Random(20261015)
|
||||
|
||||
def sensor(self):
|
||||
t = -273.15 if self.glitch_now else self.Ts
|
||||
if t > -100.0:
|
||||
t += self.noise.uniform(-0.05, 0.05)
|
||||
t -= 0.15 if self._last_power > 0.5 else 0.0
|
||||
return t
|
||||
|
||||
def pid_compute(self):
|
||||
t = self.sensor()
|
||||
if t <= -273.0:
|
||||
if self.fault_beats < 3:
|
||||
self.fault_beats += 1
|
||||
if self.fault_beats >= 3:
|
||||
self.duty = 0
|
||||
self.integral = 0.0
|
||||
self.prev_temp = t
|
||||
return
|
||||
self.fault_beats = 0
|
||||
if not self.enable:
|
||||
self.duty, self.integral, self.prev_temp = 0, 0.0, t
|
||||
return
|
||||
self.ramp_to()
|
||||
err = self.ramp_target - t
|
||||
d_t = t - self.prev_temp
|
||||
u_pd_raw = V3_KP * err - V3_KD * d_t
|
||||
cap = SLOWBAND_DUTY if err < SLOWBAND_C else 100.0
|
||||
u_pd = max(0.0, min(cap, u_pd_raw))
|
||||
u = u_pd + V3_KI * self.integral # err<0:P 钳 0,积分独撑
|
||||
if (0.0 < err < V3_ZONE and 0.0 < u_pd_raw < cap) or (err < 0 and u > 0.0):
|
||||
self.integral = max(0.0, min(V3_IMAX, self.integral + err))
|
||||
u = max(0.0, min(100.0, u))
|
||||
self.prev_temp = t
|
||||
self.duty = self.quant(u)
|
||||
|
||||
|
||||
def run(cls, label, events, init_temp=None, end=420, plant=None, glitches=()):
|
||||
global A, B, TAU_S
|
||||
if plant:
|
||||
A, B, TAU_S = plant
|
||||
p = cls(init_temp)
|
||||
samples = []
|
||||
idx, t_sim = 0, 0.0
|
||||
events = sorted(events, key=lambda e: e[0])
|
||||
while t_sim < end:
|
||||
if idx < len(events) and t_sim >= events[idx][0] - 1e-9:
|
||||
_, act = events[idx]
|
||||
if act == "enable":
|
||||
p.set_enable(True)
|
||||
elif act == "disable":
|
||||
p.set_enable(False)
|
||||
else:
|
||||
p.target = act
|
||||
idx += 1
|
||||
if hasattr(p, "glitch_now"):
|
||||
p.glitch_now = any(gs <= t_sim < gs + gd for gs, gd in glitches)
|
||||
p.tick()
|
||||
samples.append(p.Ts)
|
||||
t_sim += 0.01
|
||||
|
||||
def stats(t0, t1, tgt):
|
||||
seg = samples[int(t0 / 0.01):int(t1 / 0.01)]
|
||||
if not seg:
|
||||
return
|
||||
reach = next((i * 0.01 for i, v in enumerate(seg) if abs(v - tgt) <= 0.3), None)
|
||||
if reach is None:
|
||||
print(f" [{t0:4.0f}-{t1:4.0f}s tgt={tgt:g}] 未进 ±0.3 带(段内 "
|
||||
f"{min(seg):.2f}~{max(seg):.2f})")
|
||||
return
|
||||
r = int(reach / 0.01)
|
||||
post = seg[r:]
|
||||
tail = seg[max(r, len(seg) - 6000):]
|
||||
print(f" [{t0:4.0f}-{t1:4.0f}s tgt={tgt:g}] 进入±0.3带 {reach:5.1f}s | 到达后 "
|
||||
f"[{min(post):.2f},{max(post):.2f}] | 末60s [{min(tail):.2f},{max(tail):.2f}] "
|
||||
f"±{(max(tail)-min(tail))/2:.2f}")
|
||||
|
||||
print(f"\n== {label} ==")
|
||||
for i, (t0, act) in enumerate(events):
|
||||
if isinstance(act, float) or isinstance(act, int):
|
||||
t1 = events[i + 1][0] if i + 1 < len(events) else end
|
||||
stats(t0 + 2, min(t1, end), act)
|
||||
for gs, gd in glitches:
|
||||
seg = samples[int(gs / 0.01):int(min(gs + 120, end) / 0.01)]
|
||||
if seg:
|
||||
print(f" [glitch @{gs:g}s +{gd:g}s] 120s 窗内 [{min(seg):.2f},{max(seg):.2f}] "
|
||||
f"max|Δ|={max(abs(v - p.target) for v in seg):.2f}")
|
||||
return samples
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 基准工况(按实测斜率标定):a=1.9 b=0.0036 tau=4
|
||||
BASE = (1.9, 0.0036, 4.0)
|
||||
# 恶劣工况:维持功率 ~11%(b=0.0075)+ 传感器滞后 8s
|
||||
HARSH = (1.9, 0.0075, 8.0)
|
||||
|
||||
import sys
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "sweep":
|
||||
# 扫参:V3 参数网格,两工况各跑 50C 阶跃 + 从上方使能
|
||||
for kp, ki, kd in [(10, 0.5, 30), (10, 0.5, 10), (10, 0.5, 0),
|
||||
(15, 0.5, 30), (10, 0.25, 30), (10, 1.0, 30),
|
||||
(10, 0.5, 50), (15, 0.5, 10)]:
|
||||
V3_KP, V3_KI, V3_KD = float(kp), float(ki), float(kd)
|
||||
print(f"\n### V3 Kp={kp} Ki={ki} Kd={kd}")
|
||||
run(V3, f"HARSH 50C 阶跃", [(0, "enable"), (1, 50.0)], end=420, plant=HARSH)
|
||||
run(V3, f"HARSH 从上方使能", [(0, "enable"), (1, 50.0)], init_temp=51.0, end=420, plant=HARSH)
|
||||
run(V3, f"BASE 50C 阶跃", [(0, "enable"), (1, 50.0)], end=420, plant=BASE)
|
||||
sys.exit(0)
|
||||
|
||||
# C:镜像本次实测——从 51C(高于目标)使能,目标 50
|
||||
GL = [(200, 1.4), (320, 1.4)] # 毛刺注入:1.4s NTC 开路 ×2(真机 DAPLink 抓证形态)
|
||||
EV = [(0, "enable"), (1, 50.0)]
|
||||
run(V4_OLD, "V3(旧) 毛刺注入 tgt=50(恶劣)", EV, init_temp=51.0, end=460, plant=HARSH, glitches=GL)
|
||||
run(V4, "V4 毛刺注入 tgt=50(恶劣)", EV, init_temp=51.0, end=460, plant=HARSH, glitches=GL)
|
||||
run(V4, "V4 毛刺注入 tgt=50(基准)", EV, init_temp=51.0, end=460, plant=BASE, glitches=GL)
|
||||
# 回归:无毛刺主场景
|
||||
run(V4, "V4 50C 阶跃(恶劣)", EV, end=420, plant=HARSH)
|
||||
run(V4, "V4 30C 阶跃(基准)", [(0, "enable"), (1, 30.0)], plant=BASE)
|
||||
run(V4, "V4 50->40 下调(恶劣)", [(0, "enable"), (1, 50.0), (400, 40.0)], end=700, plant=HARSH)
|
||||
@@ -1,76 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FilmForge PID anti-windup verification: target 50C, track steady-state."""
|
||||
import serial
|
||||
import time
|
||||
import threading
|
||||
|
||||
def sum8(seq):
|
||||
return sum(seq) & 0xFF
|
||||
|
||||
def frame(cmd):
|
||||
p = cmd.encode()
|
||||
body = bytes([0xD5, 0x01, len(p)]) + p
|
||||
return body + bytes([sum8(body[1:])])
|
||||
|
||||
rx = bytearray()
|
||||
stop = threading.Event()
|
||||
|
||||
def reader(buf, handle):
|
||||
while not stop.is_set():
|
||||
data = handle.read(256)
|
||||
if data:
|
||||
buf.extend(data)
|
||||
handle.close()
|
||||
|
||||
def frames(buf):
|
||||
out, i, b = [], 0, bytes(buf)
|
||||
while i < len(b):
|
||||
if b[i] == 0xB5 and i + 3 < len(b):
|
||||
total = 3 + b[i + 2] + 1
|
||||
if i + total <= len(b):
|
||||
out.append(b[i:i + total]); i += total; continue
|
||||
i += 1
|
||||
return out
|
||||
|
||||
s5 = serial.Serial('COM5', 115200, timeout=0.5)
|
||||
t = threading.Thread(target=reader, args=(rx, s5))
|
||||
t.start()
|
||||
time.sleep(0.5)
|
||||
|
||||
def cmd(c, wait=0.8):
|
||||
rx.clear()
|
||||
s5.write(frame(c))
|
||||
time.sleep(wait)
|
||||
fs = frames(rx)
|
||||
payload = [f[3:3+f[2]].decode('ascii','replace') for f in fs if len(f) >= 4 and f[1] == 0xF0]
|
||||
print('[%s] resp: %s' % (c, payload if payload else '<none>'))
|
||||
|
||||
cmd('M4S50')
|
||||
cmd('M3S1')
|
||||
cmd('M2S500')
|
||||
print('--- heating to 50C, 150s sampling ---')
|
||||
t0 = time.time()
|
||||
last_print = -10
|
||||
peak = 0
|
||||
while time.time() - t0 < 150:
|
||||
time.sleep(2.0)
|
||||
fs = frames(rx)
|
||||
vals = []
|
||||
for f in fs:
|
||||
if len(f) >= 4 and f[1] == 0xF0 and f[2] >= 2:
|
||||
try:
|
||||
vals.append(float(f[3:3+f[2]]))
|
||||
except ValueError:
|
||||
pass
|
||||
if vals:
|
||||
peak = max(peak, max(vals))
|
||||
dt = time.time() - t0
|
||||
if dt >= last_print + 10 and vals:
|
||||
print(' t+%03ds %.1f C (peak %.1f)' % (dt, vals[-1], peak))
|
||||
last_print = dt - (dt % 10)
|
||||
cmd('M3S0', wait=1.0)
|
||||
cmd('M2S0')
|
||||
|
||||
stop.set(); t.join()
|
||||
print('PEAK=%0.1f (target 50, overshoot %+.1f)' % (peak, peak - 50))
|
||||
print('DONE')
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""FilmForge 温控闭环自动测试(COM5,RS485)。
|
||||
|
||||
固定测试序列:M999(版本) -> M2S500(0.5s 上报) -> M4S50(目标) -> M3S1(加热)
|
||||
-> 采样 DURATION 秒 -> M3S0/M2S0 收尾。
|
||||
|
||||
不设温度上限(用户明确:真机靠物理温度开关保护,测试环境上限由用户控制)。
|
||||
|
||||
判定标准(脚本自动输出,不掺人工解释):
|
||||
J1 上报流:>2 帧/s 连续; J2 稳态窗(最后 300s):|均值-目标|<=0.5C
|
||||
J3 稳态窗极值:[目标-0.5, 目标+0.5](用户规格 ±0.5C)
|
||||
J4 无 OPEN/SHRT 故障值
|
||||
输出:test_data.csv(t,temp 全序列)+ test_events.log(全部收发)+ 终端摘要。
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
|
||||
sys.path.insert(0, "GUI")
|
||||
import filmforge_rs485 as ff
|
||||
|
||||
PORT = "COM5"
|
||||
TARGET = 50.0
|
||||
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 600.0
|
||||
|
||||
rows = [] # (t, temp or None)
|
||||
events = []
|
||||
faults = []
|
||||
version = None
|
||||
t0 = time.time()
|
||||
|
||||
|
||||
def on_event(line):
|
||||
events.append((time.time() - t0, line))
|
||||
with open("test_events.log", "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.time():.3f} {line}\n")
|
||||
|
||||
|
||||
def on_status(msg):
|
||||
on_event(f"[STATUS] {msg}")
|
||||
|
||||
|
||||
def on_frame(frame, t_rx):
|
||||
payload = frame.payload if isinstance(frame.payload, str) else \
|
||||
frame.payload.decode("ascii", errors="replace")
|
||||
try:
|
||||
val = float(payload)
|
||||
except ValueError:
|
||||
val = None
|
||||
if val is not None and val > -100.0: # 正常温度
|
||||
rows.append((time.time() - t0, val))
|
||||
elif val is not None: # OPEN=-273.15 / SHORT=-274.15
|
||||
faults.append((time.time() - t0, val))
|
||||
on_event(f"[FAULT] {payload}")
|
||||
else:
|
||||
if payload.startswith("v"):
|
||||
global version
|
||||
version = payload
|
||||
on_event(f"[FRAME] {payload!r}")
|
||||
|
||||
|
||||
link = ff.FF485(on_event=on_event, on_status=on_status, on_frame=on_frame)
|
||||
link.open(PORT)
|
||||
time.sleep(0.5)
|
||||
if not link.is_connected():
|
||||
print("OPEN COM5 FAILED")
|
||||
sys.exit(1)
|
||||
|
||||
# ---- 序列 ----
|
||||
link.send("M999")
|
||||
time.sleep(1.0)
|
||||
link.send("M2S500")
|
||||
time.sleep(1.0)
|
||||
link.send("M4S50")
|
||||
time.sleep(1.0)
|
||||
link.send("M3S1")
|
||||
on_event("[SEQ] heat on, target 50")
|
||||
|
||||
t_end = time.time() + DURATION
|
||||
while time.time() < t_end:
|
||||
time.sleep(1.0)
|
||||
|
||||
link.send("M3S0")
|
||||
time.sleep(0.5)
|
||||
link.send("M2S0")
|
||||
time.sleep(1.0)
|
||||
link.close()
|
||||
|
||||
# ---- 判定输出 ----
|
||||
with open("test_data.csv", "w", encoding="utf-8") as f:
|
||||
f.write("t,temp\n")
|
||||
for t, v in rows:
|
||||
f.write(f"{t:.2f},{v:.2f}\n")
|
||||
|
||||
print(f"version={version} frames={len(rows)} faults={len(faults)}")
|
||||
if rows:
|
||||
temps = [v for _, v in rows]
|
||||
print(f"info global_max={max(temps):.2f} global_min={min(temps):.2f}")
|
||||
settle = [v for t, v in rows if t >= rows[-1][0] - 300.0] or temps
|
||||
mean = sum(settle) / len(settle)
|
||||
print(f"J2 settle_mean={mean:.2f} (|{-TARGET + mean:.2f}|<=0.5? {'PASS' if abs(mean - TARGET) <= 0.5 else 'FAIL'})")
|
||||
ok3 = max(settle) <= TARGET + 0.5 and min(settle) >= TARGET - 0.5
|
||||
print(f"J3 settle_min={min(settle):.2f} settle_max={max(settle):.2f} "
|
||||
f"p2p={max(settle) - min(settle):.2f} ([{TARGET - 0.5:.1f},{TARGET + 0.5:.1f}]? {'PASS' if ok3 else 'FAIL'})")
|
||||
cross = next((t for t, v in rows if abs(v - TARGET) <= 0.3), None)
|
||||
if cross:
|
||||
post = [(t, v) for t, v in rows if t >= cross]
|
||||
print(f"reach={cross:.0f}s post_max={max(v for _, v in post):.2f} "
|
||||
f"(overshoot {max(v for _, v in post) - TARGET:+.2f})")
|
||||
print(f"J4 faults={'PASS' if not faults else 'FAIL(' + str(len(faults)) + ')'}")
|
||||
print(f"J1 report_rate={'PASS' if len(rows) >= DURATION * 1.6 else 'FAIL'}")
|
||||
Reference in New Issue
Block a user