generated from hulk/gd32f4xx_template_cmake_vscode
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
000781e2f5 | ||
|
|
2f43efb94b | ||
|
|
8900240c65 |
@@ -81,6 +81,182 @@
|
|||||||
- 协议要点:全幂等(ack 丢可重发)、响应超时建议 300ms×3 次、温度一律 ASCII
|
- 协议要点:全幂等(ack 丢可重发)、响应超时建议 300ms×3 次、温度一律 ASCII
|
||||||
- **PID 抗积分饱和**(5b24b95):条件积分(back-calculation lite)抑制输出限幅
|
- **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
|
- **50℃→40℃ 阶跃验证脚本**(`temp_test_com.py`,工作区修改未提交):M2S500
|
||||||
全曲线采样,输出超调/settle 时间/稳态误差/降温段统计(结果以实跑为准)。
|
全曲线采样,输出超调/settle 时间/稳态误差/降温段统计(结果以实跑为准)。
|
||||||
- **电源保持 PA11 实装**(`pwr_hold.h/pwr_hold.c`):上电最先拉高 PA11 维持
|
- **电源保持 PA11 实装**(`pwr_hold.h/pwr_hold.c`):上电最先拉高 PA11 维持
|
||||||
@@ -122,8 +298,12 @@
|
|||||||
- [ ] **适配器更换**:换可靠的 USB-RS485 适配器(当前自动方向切换间歇丢响应)。
|
- [ ] **适配器更换**:换可靠的 USB-RS485 适配器(当前自动方向切换间歇丢响应)。
|
||||||
- [x] **加热驱动 + PID 闭环**(PB12 慢 PWM 1s/10% 步进 + PID 500ms 周期,
|
- [x] **加热驱动 + PID 闭环**(PB12 慢 PWM 1s/10% 步进 + PID 500ms 周期,
|
||||||
安全保护齐全;实测 30℃ 闭环通过:30s 爬升、超调 1.4℃、60s 收敛中)。
|
安全保护齐全;实测 30℃ 闭环通过:30s 爬升、超调 1.4℃、60s 收敛中)。
|
||||||
- [ ] **PID 参数整定**:初值 Kp=8/Ki=0.05/Kd=0(board_config.h 宏),按真实
|
- [ ] **NTC 彻底重焊/更换(当前头号事项)**:焊接点秒级接触不良已用
|
||||||
工况温度(80~120℃)与负载重新整定,抑制 ~7% 超调。
|
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 与
|
- [x] **专用 GUI 上位机 v0.2.0**:v0.1.0 已完成 COM5 真机全链路验证;v0.2.0 与
|
||||||
实装固件对齐——去除"固件预留/仅软件状态"旧表述(协议模块预设说明、温控
|
实装固件对齐——去除"固件预留/仅软件状态"旧表述(协议模块预设说明、温控
|
||||||
卡片标题/备注、开启加热确认弹窗改为真实控温警示、ack 提示),GUI README
|
卡片标题/备注、开启加热确认弹窗改为真实控温警示、ack 提示),GUI README
|
||||||
@@ -140,7 +320,7 @@
|
|||||||
"未连接串口"丢弃)。selftest 全过(新增 8 项回归用例);已打包冒烟并发布
|
"未连接串口"丢弃)。selftest 全过(新增 8 项回归用例);已打包冒烟并发布
|
||||||
Gitea Release `FilmForge-GUI-v0.2.1`(无应用图标——`logos/filmforge.ico`
|
Gitea Release `FilmForge-GUI-v0.2.1`(无应用图标——`logos/filmforge.ico`
|
||||||
仍缺位)。
|
仍缺位)。
|
||||||
- **GUI v0.2.2~v0.2.4(已修复待真机确认,未发布)**:
|
- **GUI v0.2.2~v0.2.13(递进修复/新功能,Gitea 发布待确认)**:
|
||||||
- v0.2.2:串口线程 `read(256)` 在持续帧流下攒满 256 字节才返回(win32 字节
|
- v0.2.2:串口线程 `read(256)` 在持续帧流下攒满 256 字节才返回(win32 字节
|
||||||
间隔超时因帧间隔更短永不触发,100ms 上报 ≈2.5s 一批),曲线/命令/响应全部
|
间隔超时因帧间隔更短永不触发,100ms 上报 ≈2.5s 一批),曲线/命令/响应全部
|
||||||
3 秒一卡、一股脑一大坨——真机日志确认每簇 ≈25 帧、间隔恒 3s。已改
|
3 秒一卡、一股脑一大坨——真机日志确认每簇 ≈25 帧、间隔恒 3s。已改
|
||||||
@@ -185,8 +365,50 @@
|
|||||||
_last_set 单槽位罕见误补偿(8s 自愈)、build_exe remove_stale 无提示强杀
|
_last_set 单槽位罕见误补偿(8s 自愈)、build_exe remove_stale 无提示强杀
|
||||||
运行实例、M888 后固定 2s 重查可能早于固件就绪、_danger_reset 把去重忽略记成
|
运行实例、M888 后固定 2s 重查可能早于固件就绪、_danger_reset 把去重忽略记成
|
||||||
"已下发"。协议建议的 300ms×3 自动重发维持不做(设计决策,用户拍板)。
|
"已下发"。协议建议的 300ms×3 自动重发维持不做(设计决策,用户拍板)。
|
||||||
- selftest 全过(累计新增 15 项回归用例,含窗口图标非空检查);exe 已本地
|
- v0.2.10:**曲线 Y 轴手动量程 + "暂无数据"提示不消失修复**——①"实时温度曲线"
|
||||||
打包冒烟,Gitea 发布待用户确认后执行。
|
卡新增 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 适配器更换。
|
遗留见其 README"已知事项":USB-RS485 适配器更换。
|
||||||
- [x] **GUI 慢性卡顿修复(根因已定位并修复 v0.2.8,待真机确认)**:
|
- [x] **GUI 慢性卡顿修复(根因已定位并修复 v0.2.8,待真机确认)**:
|
||||||
根因/机理/实测数据见上方 v0.2.8 条目(`cursor.Start` 实例级枚举访问在
|
根因/机理/实测数据见上方 v0.2.8 条目(`cursor.Start` 实例级枚举访问在
|
||||||
|
|||||||
+17
-1
@@ -8,7 +8,13 @@ FilmForge 热压模具温控板(GD32F405, RS485 数据口)的桌面上位机
|
|||||||
|
|
||||||
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
|
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
|
||||||
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
|
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
|
||||||
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均)。
|
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均);
|
||||||
|
Y 轴(温度轴)量程可选**自动**(随可见数据自适应)或**手动**——直接拖动
|
||||||
|
图表右缘滑条(固定参考域 -20~120℃,把手位置=℃ 读数):上把手=上限、
|
||||||
|
下把手=下限、拖动把手中段上下=整段平移并把跨度保持不变、点击轨道空白直接
|
||||||
|
把对应边界设到该处;首次任意拖拽自动切入手动("Y轴自动/手动"按钮同步),
|
||||||
|
把手数值标签仅悬停/拖拽时显示(不常驻遮挡曲线),曲线越界时裁剪回绘图区;
|
||||||
|
模式与量程记忆在 `filmforge_gui.json`。
|
||||||
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
|
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
|
||||||
M1 仅作手动单点查询(命令区按钮/预设)。
|
M1 仅作手动单点查询(命令区按钮/预设)。
|
||||||
- **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0
|
- **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0
|
||||||
@@ -92,6 +98,16 @@ python publish_gitea.py release/FilmForge-GUI-v<版本>-win64.zip --replace --in
|
|||||||
直接 `setStyleSheet` 会在切主题时被库重刷(DLPilot 同款坑);标题字号走 QFont。
|
直接 `setStyleSheet` 会在切主题时被库重刷(DLPilot 同款坑);标题字号走 QFont。
|
||||||
- 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点
|
- 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点
|
||||||
按步进抽稀(NaN 断口保留)。
|
按步进抽稀(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)。
|
||||||
|
|
||||||
## 已知事项
|
## 已知事项
|
||||||
|
|
||||||
|
|||||||
+344
-23
@@ -45,8 +45,8 @@ if getattr(sys, "frozen", False) and os.name == "nt":
|
|||||||
except (AttributeError, OSError):
|
except (AttributeError, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
|
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
|
||||||
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
|
from PySide6.QtGui import QColor, QCursor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
|
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
|
||||||
QScrollArea, QSplitter, QVBoxLayout, QWidget,
|
QScrollArea, QSplitter, QVBoxLayout, QWidget,
|
||||||
@@ -60,7 +60,7 @@ import config
|
|||||||
import filmforge_rs485 as ff
|
import filmforge_rs485 as ff
|
||||||
|
|
||||||
APP_NAME = "FilmForge"
|
APP_NAME = "FilmForge"
|
||||||
APP_VERSION = "0.2.9"
|
APP_VERSION = "0.2.13"
|
||||||
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
|
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
|
||||||
MONO = QFont("Consolas", 10)
|
MONO = QFont("Consolas", 10)
|
||||||
CHIP_IDLE = "#666666"
|
CHIP_IDLE = "#666666"
|
||||||
@@ -77,6 +77,13 @@ CHART_COLORS_LIGHT = dict(bg="#ffffff", grid="#e3e8ee", text="#5a6572",
|
|||||||
measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR)
|
measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR)
|
||||||
|
|
||||||
X_TICK_STEPS = (1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600)
|
X_TICK_STEPS = (1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600)
|
||||||
|
RIGHT_MARGIN = 12 # 主绘图区右缘留白(px)
|
||||||
|
GUTTER_W = 18 # 右侧 Y 轴滑条栏宽(px)
|
||||||
|
HANDLE_H = 9 # 滑条把手厚(px)
|
||||||
|
HANDLE_HIT = 12 # 把手命中半径(px)
|
||||||
|
MIN_Y_SPAN = 0.5 # 手动 Y 量程最小间距(℃)
|
||||||
|
Y_RAIL_LO = -20.0 # 滑条固定参考域(℃, 用户拍板): 把手位置=℃ 读数
|
||||||
|
Y_RAIL_HI = 120.0 # (覆盖工艺全程 0~120 与冷启动低温, 不随数据漂移)
|
||||||
DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disabled "
|
DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disabled "
|
||||||
"{ background-color: #D9534F; color: white; border: none; }")
|
"{ background-color: #D9534F; color: white; border: none; }")
|
||||||
|
|
||||||
@@ -84,10 +91,16 @@ DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disa
|
|||||||
class TemperatureChart(QWidget):
|
class TemperatureChart(QWidget):
|
||||||
"""自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。
|
"""自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。
|
||||||
|
|
||||||
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色独立设置。
|
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色/Y 轴模式独立设置。
|
||||||
曲线语义: 实测=折线(NTC 故障处 NaN 断开+底部红标), 目标=阶梯横线(橙虚线)。
|
曲线语义: 实测=折线(NTC 故障处 NaN 断开+底部红标), 目标=阶梯横线(橙虚线)。
|
||||||
|
Y 轴(auto)=按可见数据自适应; Y 轴(manual)=右缘滑条双把手固定量程
|
||||||
|
(上把手=上限/下把手=下限/拖中带=整段平移, 曲线裁剪回绘图区)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
yManualEngaged = Signal() # 首次拖拽滑条: 从 auto 切入 manual
|
||||||
|
yRangeChanged = Signal(float, float) # 松手: 量化 0.1℃ 后的范围
|
||||||
|
MIN_Y_SPAN = MIN_Y_SPAN # 手动量程最小间距(℃, 供测试断言)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setMinimumHeight(300)
|
self.setMinimumHeight(300)
|
||||||
@@ -98,13 +111,24 @@ class TemperatureChart(QWidget):
|
|||||||
self._window_sec = config.DEFAULT_WINDOW_SEC
|
self._window_sec = config.DEFAULT_WINDOW_SEC
|
||||||
self._frozen_at = None # None=实时跟随; 否则冻结该时刻
|
self._frozen_at = None # None=实时跟随; 否则冻结该时刻
|
||||||
self._colors = dict(CHART_COLORS_DARK)
|
self._colors = dict(CHART_COLORS_DARK)
|
||||||
self._has_data = False
|
self._y_mode = "auto" # 'auto'=按可见数据自适应; 'manual'=固定范围
|
||||||
|
self._y_manual = None # 手动范围 (ymin, ymax); None=未设
|
||||||
|
self._y_drag = None # 拖拽态: dict(kind/y0/lo/hi/h/ref) 或 None
|
||||||
|
self._drag_cursor = False # 拖拽中 override 光标(保证 release 还原)
|
||||||
|
self._y_hover = False # 鼠标在滑条栏上: 值标签仅此时显示(不常驻遮曲线)
|
||||||
|
self._paint_area = None # 最近绘制的布局缓存: 命中/拖拽复用免重算
|
||||||
|
self._paint_range = (-5.0, 55.0)
|
||||||
|
self._paint_ref = None
|
||||||
|
self.setMouseTracking(True) # 悬停把手换光标(mouseMove 无按键也来)
|
||||||
|
self.last_auto_range = (-5.0, 55.0) # 最近绘制的自适应范围(切手动时预填)
|
||||||
|
|
||||||
# ---- 数据/状态注入 ----
|
# ---- 数据/状态注入 ----
|
||||||
def set_source(self, samples_t, samples_v, target_hist, faults):
|
def set_source(self, samples_t, samples_v, target_hist, faults):
|
||||||
|
"""注入数据列表引用(原地变更可见)。仅在构建时调用一次: 是否有数据
|
||||||
|
必须由绘制时实时判定, 旧版在这里缓存 _has_data 布尔, 数据经引用
|
||||||
|
追加后永不刷新 → "暂无数据"提示文字不消失(实测踩坑)。"""
|
||||||
self._samples_t, self._samples_v = samples_t, samples_v
|
self._samples_t, self._samples_v = samples_t, samples_v
|
||||||
self._target_hist, self._faults = target_hist, faults
|
self._target_hist, self._faults = target_hist, faults
|
||||||
self._has_data = bool(samples_t) or bool(faults)
|
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_window(self, sec):
|
def set_window(self, sec):
|
||||||
@@ -119,6 +143,18 @@ class TemperatureChart(QWidget):
|
|||||||
self._colors = dict(colors)
|
self._colors = dict(colors)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def set_y_mode(self, mode):
|
||||||
|
self._y_mode = mode if mode in ("auto", "manual") else "auto"
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def set_y_manual(self, lo, hi):
|
||||||
|
lo, hi = float(lo), float(hi)
|
||||||
|
if not lo < hi:
|
||||||
|
return False
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self.update()
|
||||||
|
return True
|
||||||
|
|
||||||
# ---- 视窗 ----
|
# ---- 视窗 ----
|
||||||
def _view(self):
|
def _view(self):
|
||||||
t1 = self._frozen_at if self._frozen_at is not None else time.time()
|
t1 = self._frozen_at if self._frozen_at is not None else time.time()
|
||||||
@@ -186,28 +222,249 @@ class TemperatureChart(QWidget):
|
|||||||
return pts
|
return pts
|
||||||
|
|
||||||
# ---- 绘制 ----
|
# ---- 绘制 ----
|
||||||
|
def _layout(self):
|
||||||
|
"""一次算齐布局(转换区/S 轴范围), paint 与滑条命中/拖拽共用, 保证一致"""
|
||||||
|
area = QRectF(self.rect()).adjusted(52, 10, -(RIGHT_MARGIN + GUTTER_W), -26)
|
||||||
|
t0, t1 = self._view()
|
||||||
|
vis_t, vis_v = self._visible(t0, t1)
|
||||||
|
tgt = self._target_points(t0, t1)
|
||||||
|
ymin, ymax = self._resolve_y_range(vis_v, tgt)
|
||||||
|
return area, t0, t1, vis_t, vis_v, tgt, ymin, ymax
|
||||||
|
|
||||||
def paintEvent(self, _event):
|
def paintEvent(self, _event):
|
||||||
p = QPainter(self)
|
p = QPainter(self)
|
||||||
c = self._colors
|
c = self._colors
|
||||||
p.fillRect(self.rect(), QColor(c["bg"]))
|
p.fillRect(self.rect(), QColor(c["bg"]))
|
||||||
area = QRectF(self.rect()).adjusted(52, 10, -12, -26)
|
area, t0, t1, vis_t, vis_v, tgt, ymin, ymax = self._layout()
|
||||||
t0, t1 = self._view()
|
self._paint_area = area
|
||||||
vis_t, vis_v = self._visible(t0, t1)
|
self._paint_range = (ymin, ymax)
|
||||||
tgt = self._target_points(t0, t1)
|
self._paint_ref = self._y_ref()
|
||||||
ymin, ymax = self._y_range(vis_v, tgt)
|
|
||||||
sy = self._scale_y(area, ymin, ymax)
|
sy = self._scale_y(area, ymin, ymax)
|
||||||
sx = self._scale_x(area, t0, t1)
|
sx = self._scale_x(area, t0, t1)
|
||||||
|
|
||||||
self._draw_grid(p, area, t0, t1, ymin, ymax, sx, sy)
|
self._draw_grid(p, area, t0, t1, ymin, ymax, sx, sy)
|
||||||
|
p.save()
|
||||||
|
p.setClipRect(area) # 手动范围下数据可越界, 裁剪回绘图区
|
||||||
self._draw_target(p, tgt, sx, sy)
|
self._draw_target(p, tgt, sx, sy)
|
||||||
self._draw_measured(p, vis_t, vis_v, sx, sy)
|
self._draw_measured(p, vis_t, vis_v, sx, sy)
|
||||||
|
p.restore()
|
||||||
|
self._draw_marker(p, area, vis_t, vis_v, sx, sy)
|
||||||
self._draw_faults(p, area, t0, t1)
|
self._draw_faults(p, area, t0, t1)
|
||||||
self._draw_legend(p)
|
self._draw_y_gutter(p, area, ymin, ymax)
|
||||||
if not self._has_data:
|
self._draw_legend(p, area)
|
||||||
|
if not (self._samples_t or self._faults): # 实时判定: 数据到位即消失
|
||||||
p.setPen(QColor(c["text"]))
|
p.setPen(QColor(c["text"]))
|
||||||
p.drawText(self.rect(), Qt.AlignCenter,
|
p.drawText(self.rect(), Qt.AlignCenter,
|
||||||
"暂无数据 — 连接设备后开启周期上报(M2S<ms>)即可实时推送; 单点查询用 M1")
|
"暂无数据 — 连接设备后开启周期上报(M2S<ms>)即可实时推送; 单点查询用 M1")
|
||||||
|
|
||||||
|
def _resolve_y_range(self, vis_v, tgt_pts):
|
||||||
|
"""本次绘制使用的 Y 轴范围: manual 模式用固定值, 否则按可见数据自适应;
|
||||||
|
空数据时自适应值是缺省 (-5,55), 不得覆盖 last_auto_range(切手动预填用)"""
|
||||||
|
auto = self._y_range(vis_v, tgt_pts)
|
||||||
|
if self._y_mode == "manual" and self._y_manual is not None:
|
||||||
|
return self._y_manual
|
||||||
|
pts = tgt_pts or []
|
||||||
|
if [v for v in vis_v if v == v] or [v for _t, v in pts]:
|
||||||
|
self.last_auto_range = auto
|
||||||
|
return auto
|
||||||
|
|
||||||
|
# ---- 右缘 Y 轴滑条(手动量程) ----
|
||||||
|
def _y_ref(self):
|
||||||
|
"""滑条固定参考域(用户拍板 -20~120℃): 把手像素位置直接对应 ℃ 读数,
|
||||||
|
空数据/冷机不再出现随数据漂移的误导位置"""
|
||||||
|
return Y_RAIL_LO, Y_RAIL_HI
|
||||||
|
|
||||||
|
def _gutter(self):
|
||||||
|
w = self.width()
|
||||||
|
return w - RIGHT_MARGIN - GUTTER_W, w - RIGHT_MARGIN
|
||||||
|
|
||||||
|
def _cached_layout(self):
|
||||||
|
"""命中/拖拽用绘制缓存; 从未绘制过(如测试直接调)则补算一次"""
|
||||||
|
if self._paint_area is None:
|
||||||
|
area, _t0, _t1, _vt, _vv, _tgt, ymin, ymax = self._layout()
|
||||||
|
self._paint_area = area
|
||||||
|
self._paint_range = (ymin, ymax)
|
||||||
|
self._paint_ref = self._y_ref()
|
||||||
|
return self._paint_area, self._paint_range, self._paint_ref
|
||||||
|
|
||||||
|
def _rail_px(self, area, ref, v):
|
||||||
|
ref_lo, ref_hi = ref
|
||||||
|
v = min(max(v, ref_lo), ref_hi)
|
||||||
|
return area.bottom() - (v - ref_lo) / max(ref_hi - ref_lo, 1e-9) * area.height()
|
||||||
|
|
||||||
|
def _rail_value(self, area, ref, y):
|
||||||
|
ref_lo, ref_hi = ref
|
||||||
|
frac = min(max((area.bottom() - y) / max(area.height(), 1e-6), 0.0), 1.0)
|
||||||
|
return ref_lo + frac * (ref_hi - ref_lo)
|
||||||
|
|
||||||
|
def _gutter_hit(self, x, y):
|
||||||
|
"""右栏命中: 就近把手优先, 轨道空白区视为就近边界点击, 上下把手间为整段平移"""
|
||||||
|
x0, x1 = self._gutter()
|
||||||
|
if not x0 - 3 <= x <= x1 + 3:
|
||||||
|
return None
|
||||||
|
area, (lo, hi), ref = self._cached_layout()
|
||||||
|
y_hi = self._rail_px(area, ref, hi)
|
||||||
|
y_lo = self._rail_px(area, ref, lo)
|
||||||
|
d_hi, d_lo = abs(y - y_hi), abs(y - y_lo)
|
||||||
|
if min(d_hi, d_lo) <= HANDLE_HIT:
|
||||||
|
return "max" if d_hi <= d_lo else "min"
|
||||||
|
if y_hi <= y <= y_lo:
|
||||||
|
return "win"
|
||||||
|
return "max" if y < y_hi else "min"
|
||||||
|
|
||||||
|
def start_y_drag(self, kind, y):
|
||||||
|
"""按下滑条(测试可直接调): auto 模式以当前显示范围起坡并切手动。
|
||||||
|
几何(绘图区/参考域)可用绘制缓存, 界值必须取实时显示值——拖拽间存在
|
||||||
|
未重绘的域间修改(实测: pan 用陈旧界值把量程拖跳)。
|
||||||
|
kind: 'min'(下把手)=下限 / 'max'(上把手)=上限 / 'win'=整段平移"""
|
||||||
|
area, rng, ref = self._cached_layout()
|
||||||
|
if self._y_mode != "manual":
|
||||||
|
lo, hi = rng
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self._y_mode = "manual"
|
||||||
|
self.yManualEngaged.emit()
|
||||||
|
else:
|
||||||
|
lo, hi = self._y_manual
|
||||||
|
self._y_drag = {"kind": kind, "y0": y, "lo": lo, "hi": hi,
|
||||||
|
"h": area.height(), "ref": ref}
|
||||||
|
if not self._drag_cursor:
|
||||||
|
QApplication.setOverrideCursor(QCursor(Qt.SizeVerCursor))
|
||||||
|
self._drag_cursor = True
|
||||||
|
self.update()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_y_drag(self, y):
|
||||||
|
"""拖拽中: 手把直接跟鼠标(参考域映射+钳位), 中带按参考跨度平移"""
|
||||||
|
d = self._y_drag
|
||||||
|
if d is None:
|
||||||
|
return False
|
||||||
|
area, _rng, _ref_unused = self._cached_layout()
|
||||||
|
ref = d["ref"]
|
||||||
|
ref_span = max(ref[1] - ref[0], 1e-9)
|
||||||
|
h = max(d["h"], 1e-6)
|
||||||
|
lo, hi = d["lo"], d["hi"]
|
||||||
|
if d["kind"] == "max":
|
||||||
|
hi = max(self._rail_value(area, ref, y), lo + MIN_Y_SPAN)
|
||||||
|
elif d["kind"] == "min":
|
||||||
|
lo = min(self._rail_value(area, ref, y), hi - MIN_Y_SPAN)
|
||||||
|
else:
|
||||||
|
delta = -(y - d["y0"]) / h * ref_span
|
||||||
|
delta = min(max(delta, ref[0] - lo), ref[1] - hi)
|
||||||
|
lo, hi = lo + delta, hi + delta
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self.update()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def end_y_drag(self):
|
||||||
|
"""松手: 量化 0.1℃ 并通知(防御四舍五入挤掉间距)"""
|
||||||
|
if self._y_drag is None:
|
||||||
|
return False
|
||||||
|
lo, hi = self._y_manual
|
||||||
|
lo, hi = round(lo, 1), round(hi + 1e-9, 1)
|
||||||
|
if not lo < hi:
|
||||||
|
hi = lo + 0.1
|
||||||
|
self._y_manual = (lo, hi)
|
||||||
|
self._y_drag = None
|
||||||
|
self.update()
|
||||||
|
self.yRangeChanged.emit(lo, hi)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def mousePressEvent(self, event):
|
||||||
|
pos = event.position()
|
||||||
|
kind = self._gutter_hit(pos.x(), pos.y())
|
||||||
|
if kind is not None:
|
||||||
|
self.start_y_drag(kind, pos.y())
|
||||||
|
event.accept()
|
||||||
|
else:
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def mouseMoveEvent(self, event):
|
||||||
|
pos = event.position()
|
||||||
|
if self._y_drag is not None:
|
||||||
|
self.update_y_drag(pos.y())
|
||||||
|
self._set_y_hover(True)
|
||||||
|
return
|
||||||
|
hit = self._gutter_hit(pos.x(), pos.y()) is not None
|
||||||
|
self._set_y_hover(hit) # 值标签仅悬停/拖拽时显示
|
||||||
|
if hit:
|
||||||
|
self.setCursor(Qt.SizeVerCursor)
|
||||||
|
else:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def mouseReleaseEvent(self, event):
|
||||||
|
pos = event.position()
|
||||||
|
if self._y_drag is not None:
|
||||||
|
self.end_y_drag()
|
||||||
|
if self._drag_cursor:
|
||||||
|
QApplication.restoreOverrideCursor()
|
||||||
|
self._drag_cursor = False
|
||||||
|
self.update_y_drag_hover(pos)
|
||||||
|
event.ignore()
|
||||||
|
|
||||||
|
def update_y_drag_hover(self, pos):
|
||||||
|
hit = self._gutter_hit(pos.x(), pos.y()) is not None
|
||||||
|
self._set_y_hover(hit)
|
||||||
|
if hit:
|
||||||
|
self.setCursor(Qt.SizeVerCursor)
|
||||||
|
else:
|
||||||
|
self.unsetCursor()
|
||||||
|
|
||||||
|
def _set_y_hover(self, on):
|
||||||
|
"""滑条悬停态翻转才重绘(悬停/拖拽期间才画把手值标签)"""
|
||||||
|
if self._y_hover == on:
|
||||||
|
return
|
||||||
|
self._y_hover = on
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def leaveEvent(self, _event):
|
||||||
|
if self._y_drag is None:
|
||||||
|
self.unsetCursor()
|
||||||
|
self._set_y_hover(False) # 悬停态途中拖拽不退出(松手后按位置重算)
|
||||||
|
|
||||||
|
def _draw_y_gutter(self, p, area, ymin, ymax):
|
||||||
|
"""右缘滑条栏: 轨道 + 手动量程带 + 双把手。值标签仅鼠标悬停/拖拽时显示
|
||||||
|
(常驻会遮挡曲线, 用户标注); auto 模式把手半透明——直接按住即可切手动。"""
|
||||||
|
c = self._colors
|
||||||
|
accent = QColor(c["measured"])
|
||||||
|
manual = self._y_mode == "manual"
|
||||||
|
show_pills = self._y_hover or self._y_drag is not None
|
||||||
|
ref = self._y_ref()
|
||||||
|
x0, x1 = self._gutter()
|
||||||
|
x_c = (x0 + x1) / 2
|
||||||
|
y_hi = self._rail_px(area, ref, ymax)
|
||||||
|
y_lo = self._rail_px(area, ref, ymin)
|
||||||
|
|
||||||
|
p.setPen(QPen(QColor(c["grid"]), 1))
|
||||||
|
p.drawLine(QPointF(x_c, area.top()), QPointF(x_c, area.bottom()))
|
||||||
|
if manual and y_hi + 2 < y_lo:
|
||||||
|
band = QColor(accent)
|
||||||
|
band.setAlpha(38)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(band)
|
||||||
|
p.drawRect(QRectF(x_c - 2, y_hi, 4, y_lo - y_hi))
|
||||||
|
|
||||||
|
for y_px, label in ((y_hi, f"{ymax:.1f}"), (y_lo, f"{ymin:.1f}")):
|
||||||
|
bar = QColor(accent)
|
||||||
|
if not manual:
|
||||||
|
bar.setAlpha(110)
|
||||||
|
p.setPen(Qt.NoPen)
|
||||||
|
p.setBrush(bar)
|
||||||
|
p.drawRoundedRect(QRectF(x_c - 7, y_px - HANDLE_H / 2, 14, HANDLE_H), 3, 3)
|
||||||
|
p.setPen(QPen(QColor(c["bg"]), 1))
|
||||||
|
for dy in (-1.5, 1.5):
|
||||||
|
p.drawLine(QPointF(x_c - 4, y_px + dy), QPointF(x_c + 4, y_px + dy))
|
||||||
|
if not show_pills:
|
||||||
|
continue
|
||||||
|
pill = QRectF(area.right() - 41, y_px - 8, 38, 16)
|
||||||
|
p.setBrush(QColor(c["bg"]))
|
||||||
|
p.setPen(QPen(QColor(c["grid"]), 1))
|
||||||
|
p.drawRoundedRect(pill, 4, 4)
|
||||||
|
font = self.font()
|
||||||
|
font.setPointSizeF(7.5)
|
||||||
|
p.setFont(font)
|
||||||
|
p.setPen(QColor(c["measured"]))
|
||||||
|
p.drawText(pill, Qt.AlignCenter, label)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _y_range(vis_v, tgt_pts):
|
def _y_range(vis_v, tgt_pts):
|
||||||
vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts]
|
vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts]
|
||||||
@@ -312,13 +569,18 @@ class TemperatureChart(QWidget):
|
|||||||
path.lineTo(x, y)
|
path.lineTo(x, y)
|
||||||
p.drawPath(path)
|
p.drawPath(path)
|
||||||
|
|
||||||
last_t = last_v = None # 当前值标记: 视窗内最后一个有效样本
|
def _draw_marker(self, p, area, ts, vs, sx, sy):
|
||||||
|
"""当前值标记: 视窗内最后一个有效样本。超出当前 Y 范围(手动模式)不画。"""
|
||||||
|
last_t = last_v = None
|
||||||
for t, v in zip(reversed(ts), reversed(vs)):
|
for t, v in zip(reversed(ts), reversed(vs)):
|
||||||
if v == v:
|
if v == v:
|
||||||
last_t, last_v = t, v
|
last_t, last_v = t, v
|
||||||
break
|
break
|
||||||
if last_t is not None:
|
if last_t is None:
|
||||||
|
return
|
||||||
x, y = sx(last_t), sy(last_v)
|
x, y = sx(last_t), sy(last_v)
|
||||||
|
if not area.top() <= y <= area.bottom():
|
||||||
|
return
|
||||||
p.setPen(Qt.NoPen)
|
p.setPen(Qt.NoPen)
|
||||||
p.setBrush(QColor(self._colors["measured"]))
|
p.setBrush(QColor(self._colors["measured"]))
|
||||||
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
|
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
|
||||||
@@ -327,7 +589,10 @@ class TemperatureChart(QWidget):
|
|||||||
font.setPointSizeF(8.5)
|
font.setPointSizeF(8.5)
|
||||||
font.setBold(True)
|
font.setBold(True)
|
||||||
p.setFont(font)
|
p.setFont(font)
|
||||||
p.drawText(QPointF(min(x + 6, self.width() - 54), max(y - 6, 14)),
|
# 文字通常画在点上方; 贴顶边放不下时换到点下方, 再钳回绘图区内
|
||||||
|
ty = y - 6 if y - 14 >= area.top() else y + 12
|
||||||
|
ty = min(max(ty, area.top() + 9), area.bottom() - 2)
|
||||||
|
p.drawText(QPointF(min(x + 6, area.right() - 44), ty),
|
||||||
f"{last_v:.1f}℃")
|
f"{last_v:.1f}℃")
|
||||||
|
|
||||||
def _draw_faults(self, p, area, t0, t1):
|
def _draw_faults(self, p, area, t0, t1):
|
||||||
@@ -349,12 +614,13 @@ class TemperatureChart(QWidget):
|
|||||||
last_x = x
|
last_x = x
|
||||||
labeled += 1
|
labeled += 1
|
||||||
|
|
||||||
def _draw_legend(self, p):
|
def _draw_legend(self, p, area):
|
||||||
c = self._colors
|
c = self._colors
|
||||||
font = self.font()
|
font = self.font()
|
||||||
font.setPointSizeF(8.5)
|
font.setPointSizeF(8.5)
|
||||||
p.setFont(font)
|
p.setFont(font)
|
||||||
x = self.width() - 150
|
# 图例放绘图区左上: 右侧被滑条把手/值标签占用(用户标注)
|
||||||
|
x = area.left() + 6
|
||||||
y = 16
|
y = 16
|
||||||
for color, label in ((c["measured"], "实测"), (c["target"], "目标")):
|
for color, label in ((c["measured"], "实测"), (c["target"], "目标")):
|
||||||
p.setPen(Qt.NoPen)
|
p.setPen(Qt.NoPen)
|
||||||
@@ -550,15 +816,36 @@ class App(QMainWindow):
|
|||||||
win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建
|
win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建
|
||||||
win_combo.currentIndexChanged.connect(self._on_window)
|
win_combo.currentIndexChanged.connect(self._on_window)
|
||||||
self.cbo_window = win_combo
|
self.cbo_window = win_combo
|
||||||
row = QHBoxLayout()
|
|
||||||
|
card = CardWidget(self)
|
||||||
|
body = QVBoxLayout(card)
|
||||||
|
body.setContentsMargins(14, 12, 14, 14)
|
||||||
|
body.setSpacing(8)
|
||||||
|
# ---- 标题行 = [标题][暂停][清空] ... [Y轴手动][窗口] 一行放齐:
|
||||||
|
# 暂停/清空并入标题行、Y 轴按钮不再单独换行(用户标注: 两个按钮组都
|
||||||
|
# 悬在半空布局脱节)。不走 self._card 的通用头, 需要多 trailing 控件。
|
||||||
|
header = QHBoxLayout()
|
||||||
|
header.addWidget(StrongBodyLabel("实时温度曲线", card))
|
||||||
|
header.addSpacing(8)
|
||||||
self.btn_pause = self._button("暂停", self._toggle_pause)
|
self.btn_pause = self._button("暂停", self._toggle_pause)
|
||||||
|
header.addWidget(self.btn_pause)
|
||||||
self.btn_clear = self._button("清空", self._clear_plot)
|
self.btn_clear = self._button("清空", self._clear_plot)
|
||||||
row.addWidget(self.btn_pause)
|
header.addWidget(self.btn_clear)
|
||||||
row.addWidget(self.btn_clear)
|
header.addStretch(1)
|
||||||
card, body = self._card("实时温度曲线", trailing=win_combo)
|
# ---- Y 轴(温度轴)量程: 自动适配 / 图表右缘滑条手动拖拽 ----
|
||||||
body.addLayout(row)
|
self.btn_ymode = PushButton("Y轴自动", card)
|
||||||
|
self.btn_ymode.setCheckable(True)
|
||||||
|
self.btn_ymode.setChecked(True) # 默认自动; 先就位默认态再连信号
|
||||||
|
self.btn_ymode.setToolTip("勾选=随曲线数据自适应量程; 取消勾选或直接拖动"
|
||||||
|
"图表右缘滑条=固定量程, 再勾选恢复自动")
|
||||||
|
self.btn_ymode.toggled.connect(self._on_ymode)
|
||||||
|
header.addWidget(self.btn_ymode)
|
||||||
|
header.addWidget(win_combo)
|
||||||
|
body.addLayout(header)
|
||||||
self.chart = TemperatureChart(card)
|
self.chart = TemperatureChart(card)
|
||||||
self.chart.set_source(self.samples_t, self.samples_v, self.target_hist, self.faults)
|
self.chart.set_source(self.samples_t, self.samples_v, self.target_hist, self.faults)
|
||||||
|
self.chart.yManualEngaged.connect(self._on_y_drag_engaged)
|
||||||
|
self.chart.yRangeChanged.connect(self._on_y_range_changed)
|
||||||
body.addWidget(self.chart, 1)
|
body.addWidget(self.chart, 1)
|
||||||
layout.addWidget(card, 2)
|
layout.addWidget(card, 2)
|
||||||
|
|
||||||
@@ -719,6 +1006,17 @@ class App(QMainWindow):
|
|||||||
self.cbo_window.setCurrentIndex(widx)
|
self.cbo_window.setCurrentIndex(widx)
|
||||||
self.chart.set_window(config.WINDOW_SEC)
|
self.chart.set_window(config.WINDOW_SEC)
|
||||||
self.cbo_theme.setCurrentText(config.THEME)
|
self.cbo_theme.setCurrentText(config.THEME)
|
||||||
|
# ---- Y 轴量程记忆恢复: 存有合法手动值且上次为手动时才复用, 否则回自动 ----
|
||||||
|
stored = (config.Y_MIN is not None and config.Y_MAX is not None
|
||||||
|
and config.Y_MIN < config.Y_MAX)
|
||||||
|
manual = stored and config.Y_MODE == "manual"
|
||||||
|
self.btn_ymode.blockSignals(True)
|
||||||
|
self.btn_ymode.setChecked(not manual)
|
||||||
|
self.btn_ymode.blockSignals(False)
|
||||||
|
self.btn_ymode.setText("Y轴手动" if manual else "Y轴自动")
|
||||||
|
if manual:
|
||||||
|
self.chart.set_y_manual(config.Y_MIN, config.Y_MAX)
|
||||||
|
self.chart.set_y_mode("manual")
|
||||||
|
|
||||||
# ================= 主题 =================
|
# ================= 主题 =================
|
||||||
def _set_theme(self, value):
|
def _set_theme(self, value):
|
||||||
@@ -1233,6 +1531,29 @@ class App(QMainWindow):
|
|||||||
config.save_settings()
|
config.save_settings()
|
||||||
self.chart.set_window(sec)
|
self.chart.set_window(sec)
|
||||||
|
|
||||||
|
# ---- Y 轴(温度轴)量程 ----
|
||||||
|
def _on_ymode(self, checked):
|
||||||
|
"""勾选=随数据自适应量程; 取消勾选=固定量程(右缘滑条拖拽)"""
|
||||||
|
manual = not checked
|
||||||
|
self.chart.set_y_mode("manual" if manual else "auto")
|
||||||
|
self.btn_ymode.setText("Y轴手动" if manual else "Y轴自动")
|
||||||
|
config.Y_MODE = "manual" if manual else "auto"
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
|
def _on_y_drag_engaged(self):
|
||||||
|
"""滑条首次拖拽: 图表内部已切手动, 这里同步按钮态并落设置"""
|
||||||
|
self.btn_ymode.blockSignals(True)
|
||||||
|
self.btn_ymode.setChecked(False)
|
||||||
|
self.btn_ymode.blockSignals(False)
|
||||||
|
self.btn_ymode.setText("Y轴手动")
|
||||||
|
config.Y_MODE = "manual"
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
|
def _on_y_range_changed(self, lo, hi):
|
||||||
|
"""滑条松手: 图表已量化 0.1℃, 直接范围记忆"""
|
||||||
|
config.Y_MIN, config.Y_MAX = lo, hi
|
||||||
|
config.save_settings()
|
||||||
|
|
||||||
def _toggle_pause(self):
|
def _toggle_pause(self):
|
||||||
if self._frozen_at is None:
|
if self._frozen_at is None:
|
||||||
self._frozen_at = time.time()
|
self._frozen_at = time.time()
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD,
|
|||||||
REPORT_MS = 500 # 上报间隔输入框默认值
|
REPORT_MS = 500 # 上报间隔输入框默认值
|
||||||
WINDOW_SEC = DEFAULT_WINDOW_SEC
|
WINDOW_SEC = DEFAULT_WINDOW_SEC
|
||||||
THEME = 'System'
|
THEME = 'System'
|
||||||
|
Y_MODE = 'auto' # 曲线 Y 轴(温度轴): 'auto'=自适应 / 'manual'=固定范围
|
||||||
|
Y_MIN = None # 手动 Y 轴上下限(℃), 仅 Y_MODE='manual' 且合法时生效
|
||||||
|
Y_MAX = None
|
||||||
|
|
||||||
|
|
||||||
def load_settings():
|
def load_settings():
|
||||||
@@ -68,6 +71,19 @@ def load_settings():
|
|||||||
theme = str(data.get('theme') or '')
|
theme = str(data.get('theme') or '')
|
||||||
if theme in ('Dark', 'Light', 'System'):
|
if theme in ('Dark', 'Light', 'System'):
|
||||||
THEME = theme
|
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():
|
def save_settings():
|
||||||
@@ -77,6 +93,7 @@ def save_settings():
|
|||||||
'report_ms': REPORT_MS,
|
'report_ms': REPORT_MS,
|
||||||
'window_sec': WINDOW_SEC,
|
'window_sec': WINDOW_SEC,
|
||||||
'theme': THEME,
|
'theme': THEME,
|
||||||
|
'y_axis': {'mode': Y_MODE, 'min': Y_MIN, 'max': Y_MAX},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import math
|
|||||||
import os
|
import os
|
||||||
from collections import deque
|
from collections import deque
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
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__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
import filmforge_rs485 as ff
|
import filmforge_rs485 as ff
|
||||||
|
import config
|
||||||
|
from PySide6.QtCore import QRectF
|
||||||
from PySide6.QtGui import QImage, QPainter
|
from PySide6.QtGui import QImage, QPainter
|
||||||
from PySide6.QtWidgets import QApplication
|
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 = []
|
fails = []
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +81,18 @@ def count_pixels(img, rgb, tol=30):
|
|||||||
return n
|
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
|
measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7
|
||||||
target = (232, 131, 58) # 目标线 #E8833A
|
target = (232, 131, 58) # 目标线 #E8833A
|
||||||
fault = (217, 83, 79) # 故障 #D9534F
|
fault = (217, 83, 79) # 故障 #D9534F
|
||||||
@@ -279,5 +299,126 @@ ts, _vs = c._visible(0, 99)
|
|||||||
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
|
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
|
||||||
c._samples_t, c._samples_v = window.samples_t, window.samples_v
|
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')
|
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
|
||||||
sys.exit(1 if fails else 0)
|
sys.exit(1 if fails else 0)
|
||||||
|
|||||||
+32
-6
@@ -152,12 +152,38 @@ void usart1_irq_handler(void);
|
|||||||
#define HEATER_PWM_PERIOD_MS 2000U /* PWM 载波周期(2s,载波 0.5Hz) */
|
#define HEATER_PWM_PERIOD_MS 2000U /* PWM 载波周期(2s,载波 0.5Hz) */
|
||||||
#define HEATER_PWM_STEP_PCT 5U /* 占空比步进(每档脉宽 = 100ms,维持最小开关频率约束) */
|
#define HEATER_PWM_STEP_PCT 5U /* 占空比步进(每档脉宽 = 100ms,维持最小开关频率约束) */
|
||||||
#define HEATER_PID_PERIOD_MS 500U /* PID 计算周期 */
|
#define HEATER_PID_PERIOD_MS 500U /* PID 计算周期 */
|
||||||
#define HEATER_PID_KP 10.0f /* PID 参数:超温前退功率更快(配合超温强制关断) */
|
#define HEATER_PID_KP 8.0f /* PID 参数:带外下方 P 拉回增益(带内 P 退出) */
|
||||||
#define HEATER_PID_KI 0.05f
|
#define HEATER_PID_KI 0.15f /* 输出映射:u 贡献 = KI*I;I 全区域对称慢速学习 */
|
||||||
#define HEATER_PID_KD 30.0f /* 微分阻尼:按误差变化率提前刹车(抑制爬升惯性) */
|
#define HEATER_PID_KD 30.0f /* 微分(仅带外下方起作用):爬升段提前退功率 */
|
||||||
#define HEATER_SLOWBAND_C 7.0f /* 温和带宽度:距目标小于此值时占空上限受限
|
#define HEATER_PID_BAND_C 0.3f /* 作用分区带宽:带内恒功率(Ki*I),带外
|
||||||
(低速爬完最后一段,减少蓄热惯性过冲) */
|
开关+P 修正——波动幅度被物理限制在
|
||||||
#define HEATER_SLOWBAND_DUTY 25U /* 温和带内占空上限(%) */
|
带宽+滑行量 */
|
||||||
|
#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_C 150.0f /* 过温强制关断阈值(滞回保护) */
|
||||||
#define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */
|
#define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -12,9 +12,10 @@ void heat_init(void);
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 加热控制任务(在主循环中周期调用,主循环周期 10ms)。
|
* @brief 加热控制任务(在主循环中周期调用,主循环周期 10ms)。
|
||||||
* @details 内部包含:慢 PWM 载波(1s 周期、10% 步进,脉宽恒 >=100ms)、
|
* @details 内部包含:慢 PWM 载波(2s 周期、5% 步进,脉宽恒 >=100ms)、
|
||||||
* PID 计算(500ms 周期,输出量化到占空档位)、安全保护
|
* 目标斜坡 + 连续 PID(500ms 周期,全误差区间连续输出、微分基于
|
||||||
* (传感器故障强制关断 / 过温滞回保护 / 未使能强制关断)。
|
* 温度变化率、积分窄带吸收+钳位)、安全保护(传感器故障去抖确认后
|
||||||
|
* 强制关断 / 过温滞回保护 / 未使能强制关断)。
|
||||||
*/
|
*/
|
||||||
void heat_task(void);
|
void heat_task(void);
|
||||||
|
|
||||||
|
|||||||
+175
-68
@@ -1,16 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* @file heat.c
|
* @file heat.c
|
||||||
* @brief 加热控制:慢 PWM 驱动(PB12)+ 分段 PID 控温闭环 + 安全保护。
|
* @brief 加热控制:慢 PWM 驱动(PB12)+ 斜坡设定值 + 分段 PID 控温闭环 + 安全保护。
|
||||||
* @details 硬件:PB12=HEAT_EN,高 = 加热开(2N7002K 电平转换 → VBE2412 PMOS → 24V)。
|
* @details 硬件:PB12=HEAT_EN,高 = 加热开(2N7002K 电平转换 → VBE2412 PMOS → 24V)。
|
||||||
* 开关频率约束(<=10Hz)由"2s 载波周期 + 5% 占空步进"保证——每档脉宽
|
* 开关频率约束(<=10Hz)由"2s 载波周期 + 5% 占空步进"保证——每档脉宽
|
||||||
* 恒为 100ms 的整数倍(0.5Hz 载波、20 档占空分辨率)。
|
* 恒为 100ms 的整数倍(0.5Hz 载波、20 档占空分辨率)。
|
||||||
* 控制为分段式:
|
* 控制结构(平滑逼近目标,抑制过冲/震荡):
|
||||||
* err >= 温和带 全量 PID(满功率爬升);
|
* 1. 目标斜坡:内部设定值以 HEATER_RAMP_CPS 限速滑向 M4 目标,
|
||||||
* 0 <= err < 温和带 正常 PID 但占空上限压到温和档(小功率缓升,
|
* 设定值阶跃/跳变不直接进入误差;
|
||||||
* 减少进带蓄热,降低越过目标后的惯性过冲);
|
* 2. 全误差区间连续输出:err >= 温和带 P/D 全量;0 <= err < 温和带
|
||||||
* err < 0(超温) 强制关断 + 积分按负误差回退(执行器只能加热不能制冷)。
|
* P/D 钳到 HEATER_SLOWBAND_DUTY(积分不参与钳);err < 0(超过
|
||||||
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT)强制关断;
|
* 内部目标)P 负项直接抵扣积分后钳 0——P 参与使输出随超温幅度
|
||||||
* 过温(>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。
|
* 立即收敛,积分只按剩余需要维持(若 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"
|
#include "heat.h"
|
||||||
@@ -23,17 +33,27 @@
|
|||||||
/* 内部状态 */
|
/* 内部状态 */
|
||||||
|
|
||||||
static bool s_enable = false; /* 加热使能(M3) */
|
static bool s_enable = false; /* 加热使能(M3) */
|
||||||
static float s_target = 0.0f; /* 目标温度(M4) */
|
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 uint8_t s_duty = 0; /* 量化占空档位 0~HEATER_PWM_STEPS(x STEP%) */
|
||||||
static bool s_overtemp = false; /* 过温锁存(滞回恢复) */
|
static bool s_overtemp = false; /* 过温锁存(滞回恢复) */
|
||||||
static float s_integral = 0.0f; /* PID 积分项 */
|
static float s_integral = 0.0f; /* PID 积分累加(钳 [0, HEATER_PID_I_MAX]) */
|
||||||
static float s_prev_err = 0.0f; /* PID 上次误差 */
|
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_pwm_phase_ms = 0; /* PWM 载波相位 0..PERIOD-1 */
|
||||||
static uint16_t s_pid_divider = 0; /* PID 周期分频(10ms 圈) */
|
static uint16_t s_pid_divider = 0; /* PID 周期分频(10ms 圈) */
|
||||||
|
|
||||||
/* 档位总数 = 100% / 步进% */
|
/* 档位总数 = 100% / 步进% */
|
||||||
#define HEATER_PWM_STEPS (100U / HEATER_PWM_STEP_PCT)
|
#define HEATER_PWM_STEPS (100U / HEATER_PWM_STEP_PCT)
|
||||||
|
|
||||||
|
/* NTC 故障确认拍数(每拍 = 1 个 PID 周期 500ms;连续 3 拍 = 1.5s) */
|
||||||
|
#define NTC_FAULT_CONFIRM_PID_STEPS 3U
|
||||||
|
|
||||||
/******************************************************************************/
|
/******************************************************************************/
|
||||||
/* 底层输出 */
|
/* 底层输出 */
|
||||||
|
|
||||||
@@ -56,38 +76,74 @@ void heat_init(void) {
|
|||||||
/******************************************************************************/
|
/******************************************************************************/
|
||||||
/* PID */
|
/* PID */
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief 复位 PID 积分与历史误差(使能切换/传感器故障时调用,防积分残留冲击)。
|
|
||||||
*/
|
|
||||||
static void pid_reset(void) {
|
|
||||||
s_integral = 0.0f;
|
|
||||||
s_prev_err = 0.0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief 积分试算值(err 一步积分;供抗饱和逻辑条件采纳)。
|
|
||||||
*/
|
|
||||||
static float integ_step(float err) {
|
|
||||||
return s_integral + err;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief 分段 PID 计算(500ms 周期):误差 -> 占空档位 s_duty。
|
* @brief 分段 PID 计算(500ms 周期):误差 -> 占空档位 s_duty。
|
||||||
*/
|
*/
|
||||||
static void pid_compute(void) {
|
static void pid_compute(void) {
|
||||||
float t = ntc_get_temperature();
|
float t_raw = ntc_get_temperature();
|
||||||
|
|
||||||
/* 传感器故障(开路/短路):安全第一,强制关断并复位 PID */
|
/* NTC 读数斜率限幅(V0.1.9)+ 大偏差冻结(V0.1.13):真实热速率
|
||||||
if (t <= -273.0f) {
|
<=0.2C/拍,单拍 |Δ|>0.5C 判为读数毛刺,沿限幅值过渡,连续 2 拍超限
|
||||||
s_duty = 0;
|
后——若累计偏差 > HEATER_NTC_JUMP_C(秒级塌陷/卡死事件,真机实测
|
||||||
pid_reset();
|
-2.5C/4.5s 塌陷与 +2.3C/10s 顶格,焊接点接触不良)则冻结滤波值
|
||||||
return;
|
不再跟随,直到读数回弹到偏差带内自动恢复。真实温度变化永远不会
|
||||||
|
触发冻结(最大冷却 ~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) {
|
if (!s_enable) {
|
||||||
s_duty = 0;
|
s_duty = 0;
|
||||||
pid_reset();
|
s_integral = 0.0f;
|
||||||
|
s_prev_temp = t;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,52 +155,88 @@ static void pid_compute(void) {
|
|||||||
}
|
}
|
||||||
if (s_overtemp) {
|
if (s_overtemp) {
|
||||||
s_duty = 0;
|
s_duty = 0;
|
||||||
pid_reset();
|
s_integral = 0.0f;
|
||||||
|
s_duty_acc = 0.0f;
|
||||||
|
s_prev_temp = t;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
float err = s_target - t;
|
/* 目标斜坡:内部设定值限速滑向 s_target(升/降同限速)。
|
||||||
|
小步进(斜坡快于温升)全程落在温和带内缓升;大步进爬升段 ramp 超前、
|
||||||
/* 超温区间:执行器只能加热不能制冷,输出必须为 0;
|
到站后误差随实际温度平滑收缩,抑制热滞后过冲。 */
|
||||||
积分按负误差回退,为温度回落后的重新加热保留正确初始态 */
|
float diff = s_target - s_ramp_target;
|
||||||
if (err < 0.0f) {
|
float ramp_step = HEATER_RAMP_CPS * (HEATER_PID_PERIOD_MS / 1000.0f);
|
||||||
s_integral = integ_step(err);
|
if (diff > ramp_step) {
|
||||||
s_prev_err = err;
|
s_ramp_target += ramp_step;
|
||||||
s_duty = 0;
|
} else if (diff < -ramp_step) {
|
||||||
return;
|
s_ramp_target -= ramp_step;
|
||||||
}
|
|
||||||
|
|
||||||
/* 位置式 PID(带抗积分饱和) */
|
|
||||||
float u = HEATER_PID_KP * err
|
|
||||||
+ HEATER_PID_KD * (err - s_prev_err)
|
|
||||||
+ HEATER_PID_KI * integ_step(err);
|
|
||||||
|
|
||||||
if (u > 100.0f) {
|
|
||||||
u = 100.0f;
|
|
||||||
/* 饱和:err>=0 分支积分不吸收(防 windup) */
|
|
||||||
} else {
|
} else {
|
||||||
s_integral = integ_step(err);
|
s_ramp_target = s_target;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 温和带:距目标不足 HEATER_SLOWBAND_C 时,比例/微分即时响应受限
|
float err = s_ramp_target - t;
|
||||||
(小功率缓升,减少进带蓄热、降低惯性过冲);积分项不受限——
|
float d_t = t - s_prev_temp; /* 本拍温度变化(D on measurement) */
|
||||||
稳态维持功率由积分缓慢顶到实际需要值 */
|
|
||||||
if (err < HEATER_SLOWBAND_C) {
|
/* 分区作用(V0.1.12):近场扩展区 [-BAND, SLOWBAND_C) 合并"带内退闸"
|
||||||
float u_pd = HEATER_PID_KP * err + HEATER_PID_KD * (err - s_prev_err);
|
与"带外下方"为一条连续减速曲线——u = Ki*I(维持功率,不衰减)+
|
||||||
if (u_pd > (float)HEATER_SLOWBAND_DUTY) {
|
ASSIST 退闸曲线(err=+SLOWBAND_C 满档快爬、err=+BAND 处归零)+ Kd 减速。
|
||||||
u_pd = (float)HEATER_SLOWBAND_DUTY;
|
原结构近场推力真空(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) {
|
if (u_pd < 0.0f) {
|
||||||
u_pd = 0.0f;
|
u_pd = 0.0f;
|
||||||
}
|
}
|
||||||
u = u_pd + HEATER_PID_KI * s_integral;
|
u = u_pd + HEATER_PID_KI * s_integral;
|
||||||
|
if (u < 100.0f) {
|
||||||
|
s_integral += err * HEATER_PID_INTEG_GAIN_IN;
|
||||||
|
}
|
||||||
if (u > 100.0f) {
|
if (u > 100.0f) {
|
||||||
u = 100.0f;
|
u = 100.0f;
|
||||||
}
|
}
|
||||||
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输出量化到档位(四舍五入) */
|
s_prev_temp = t;
|
||||||
s_duty = (uint8_t)((u + (float)HEATER_PWM_STEP_PCT / 2.0f) / (float)HEATER_PWM_STEP_PCT);
|
|
||||||
|
/* 输出 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/******************************************************************************/
|
/******************************************************************************/
|
||||||
@@ -164,8 +256,10 @@ void heat_task(void) {
|
|||||||
if (s_pid_divider >= HEATER_PID_PERIOD_MS) {
|
if (s_pid_divider >= HEATER_PID_PERIOD_MS) {
|
||||||
s_pid_divider = 0U;
|
s_pid_divider = 0U;
|
||||||
pid_compute();
|
pid_compute();
|
||||||
if (s_duty != prev_duty) {
|
if (s_duty > prev_duty) {
|
||||||
s_pwm_phase_ms = 0U; /* 占空变更:从新周期起点开始,响应即时 */
|
s_pwm_phase_ms = 0U; /* 仅升档复位相位:立即起算新 ON 窗口;
|
||||||
|
降档不复位——相位已过新窗口即刻关断,
|
||||||
|
不再额外补发一段 ON 脉冲 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +277,22 @@ void heat_task(void) {
|
|||||||
void heat_set_enable(bool on) {
|
void heat_set_enable(bool on) {
|
||||||
if (s_enable == on) return; /* 幂等 */
|
if (s_enable == on) return; /* 幂等 */
|
||||||
s_enable = on;
|
s_enable = on;
|
||||||
if (!on) {
|
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_duty = 0;
|
||||||
pid_reset();
|
s_integral = 0.0f;
|
||||||
|
s_duty_acc = 0.0f;
|
||||||
heat_out(false);
|
heat_out(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,11 +97,43 @@ float ntc_read_temperature(void) {
|
|||||||
static float s_ntc_last_temp = -273.15f; /* 最新温度(初值 = 开路标记) */
|
static float s_ntc_last_temp = -273.15f; /* 最新温度(初值 = 开路标记) */
|
||||||
static uint16_t s_ntc_sample_divider = 0; /* 主循环 10ms 圈分频计数 */
|
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) {
|
void ntc_sample_task(void) {
|
||||||
/* 主循环周期 10ms:按 NTC_SAMPLE_PERIOD_MS 分频触发一次采样 */
|
/* 主循环周期 10ms:按 NTC_SAMPLE_PERIOD_MS 分频触发一次采样 */
|
||||||
if (++s_ntc_sample_divider >= (NTC_SAMPLE_PERIOD_MS / 10U)) {
|
if (++s_ntc_sample_divider >= (NTC_SAMPLE_PERIOD_MS / 10U)) {
|
||||||
s_ntc_sample_divider = 0;
|
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(PROJECT_NAME "FilmForge_FW")
|
||||||
set(BOARD_TYPE_CODE 41)
|
set(BOARD_TYPE_CODE 41)
|
||||||
set(VERSION_MAJOR 0)
|
set(VERSION_MAJOR 0)
|
||||||
set(VERSION_MINOR 0)
|
set(VERSION_MINOR 1)
|
||||||
set(VERSION_PATCH 1)
|
set(VERSION_PATCH 14)
|
||||||
set(VERSION "V${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
|
set(VERSION "V${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
|
||||||
string(TIMESTAMP BUILD_DATE "%Y-%m-%d")
|
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
|
||||||
|
}
|
||||||
+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)
|
||||||
+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