Author SHA1 Message Date
hulk 000781e2f5 GUI v0.2.13: chart Y-axis range control (right-edge dual-handle slider)
- TemperatureChart gains a right-edge slider on a FIXED -20..120C rail
  (user-specified): top handle = upper bound, bottom handle = lower bound,
  drag middle band = pan keeping span, click free rail = snap that bound.
  First drag engages manual mode (yManualEngaged -> sync button state +
  config); release quantizes to 0.1C via yRangeChanged, min span 0.5C.
  Bound values during drag must come from live display range, not the
  stale paint cache (two drags without repaint made pan jump the range).
- Auto/manual toggle moved into the card header (was a wrapped second
  row); pause/clear merged into header too; legend moved to top-left.
- Value labels next to handles only visible on hover/drag (constant
  labels covered the curve per user feedback).
- Out-of-range curves clipped back to the plot area (setClipRect);
  current-value marker suppressed outside the manual range.
- Draft textbox rows from v0.2.10 superseded; versions v0.2.10..v0.2.13.
- Fixed "no data yet" hint never disappearing: set_source cached a
  stale _has_data bool once at build time while data flows through the
  same list references; now evaluated live at paint time.
- config: y_axis key persists mode/bounds; invalid stored values fall
  back to auto. selftest settings isolated to a temp dir (user json with
  stored manual state broke the fresh-default assertions).
- selftest_gui: slider hit/drag/clamp/pan/clipping regression + hint
  gating; 83 checks all pass.
2026-10-15 21:30:21 +08:00
hulk 2f43efb94b Tools: PID debug toolchain from V0.1.x bring-up
test_run.py: judged closed-loop test over COM5 (fixed sequence, J1~J4
2026-10-15 20:55:29 +08:00
hulk 8900240c65 Heater: PID v0.1.14 - banded controller + sensor glitch guards
Rebuild PID as three-zone banded controller: far field P/D full power,
2026-10-15 20:55:02 +08:00
17 changed files with 1620 additions and 119 deletions
+227 -5
View File
@@ -81,6 +81,182 @@
- 协议要点:全幂等(ack 丢可重发)、响应超时建议 300ms×3 次、温度一律 ASCII
- **PID 抗积分饱和**(5b24b95):条件积分(back-calculation lite)抑制输出限幅
期间的积分累积。
- **PID 全面重写(2026-10-15,待真机验证)**——真机 30℃ 阶跃 +2.4℃ 过冲、
30~60s 周期衰减震荡(GUI 截图)驱动,三个根因全部修复:
① `s_prev_err` 在 err>=0 路径从不更新(c9aa416 early-return 重写时把末尾的
`s_prev_err = err` 弄丢)→ D 项退化为 +Kd*err 正向比例,"微分阻尼"从未生效,
且把抗饱和判据钉在饱和态冻死积分(仿真中大阶跃贴温和带边缘极限环、到不了
目标);改**微分基于温度变化率(D on measurement,-Kd*ΔT/拍)**,`s_prev_temp`
全路径刷新,u 钳 [0,100] 防负值量化回绕;
② 积分无界(err<0 每拍 -|err| 无下限)→ 过冲/下调目标后积分深负,温和带内
u=min(25,u_pd)+Ki*I 永远 <=0 → 加热长时间锁死、跌出温和带才全功率(极限环);
改**积分钳 [0, HEATER_PID_I_MAX=2000]**(Ki*I_MAX=100%);
③ 12ba9de 的目标斜坡在 c9aa416 被整段删除 → 恢复 `HEATER_RAMP_CPS 0.8℃/s`
+ 使能时从当前温度起坡(`s_ramp_target`,升/降同限速);
另修三项:④ 抗饱和判据改看**最终输出** u(原先看温和带重算前的 u);⑤ PWM
相位**仅升档复位**(原先降档也复位,降占空反而补发一整段 ON 脉冲,系统性
多给热);⑥ NTC 故障 **3 拍(1.5s)去抖确认**——真机曲线两个孤立 OPEN 毛刺
每次把 PID 状态整个清零也是震荡推手(毛刺拍保持输出不动;注意毛刺根因在
采样/硬件侧仍待查,GUI 按原始采样报故障不变)。
离线仿真(`sim_pid_check.py`:一阶热容+传感器滞后模型、2s/5% 慢 PWM 执行器、
旧/新同模型对比):30℃ 阶跃到达后波动 ±0.4℃→+0.04℃;50℃ 阶跃旧版极限环
到不了目标 vs 新版 94s 到达零过冲;50→40 下调 44s 恢复、波动 <±0.6℃。
固件版本升 **V0.1.0**(M999 报 `v41.0.1.0`),已烧录上板(OpenOCD GD-LINK,
dump_image 读回与 elf 逐字节比对一致;注意该 xpack OpenOCD 的
flash write/verify 回显日志会丢失,核验用 dump_image+文件比对)。
设计说明:保持**位置式** PID——执行器是绝对占空设定,分段强制关断/使能切换/
故障复位在位置式下最干净;增量式的抗饱和优势已由积分钳位+条件积分覆盖。
- **PID V2 抗震荡迭代(2026-10-15,V0.1.1 已烧录待真机确认)**——V0.1.0 真机
复测:过冲/OPEN 毛刺问题已消(峰 +0.4~0.5、无故障标记),但目标附近仍有
**±0.7~1.0℃/15~20s 三角波极限环**(50℃ 目标实测截图)。根因:err<0 硬切
0% + err>=0 仅 Kp*err,稳态维持功率(~10% 级)全靠 Ki=0.05 慢填(要几分钟)
→ 目标附近等效"P+上侧死区"继电器,叠加热滞后即极限环。V2 改法(`heat.c`
pid_compute 重写核心):
① **连续 PID 覆盖全误差区间**——去掉 err<0 硬切,过目标后 P 负向顶、积分
正向维持,输出钳 [0,100] 天然处理"只能加热";
② **Ki 0.05→0.25**(稳态功率 ~1 分钟内建立;扫参显示 0.5 在传感器滞后 8s
工况下积分环失稳 ±0.9、1.0 更差,0.25 全工况稳定);
③ **积分吸收限窄带**:仅 |err|<HEATER_PID_INTEG_ZONE_C(2℃) 且 P/D 未被
钳(u_pd_raw∈(0,cap))时吸收——爬升/温和带钳位段不积分,防瞬态 windup
(Ki 提快后若爬升段照吸,30℃ 阶跃仿真冲到 36℃);err<0 且 u>0 照常有界
回退;I_MAX 同步 2000→400(Ki*I_MAX=100% 不变)。
仿真(`sim_pid_check.py`,V1/V3 对比 + 参数扫描 `python sim_pid_check.py
sweep`,基准/恶劣两工况):恶劣工况 50℃ 阶跃到达后 [49.70,50.11](过冲
+0.11℃)、末端残余 ±0.02℃;30℃ 阶跃末端 ±0.01~0.02℃;50→40 下调 66s
恢复 ±0.02℃。已烧录 V0.1.1(M999 报 `v41.0.1.1`,dump 比对核验一致)。
- **PID V3 修"突然飙升 4℃"(2026-10-15,V0.1.2)——DAPLink 旁路抓证定位**:
V0.1.1 真机稳态后温度以 0.12℃/s 平滑飙升 +2~4℃ 再回落。定位手段(可复用):
① COM5 脚本探针(`probe_once.py`/`probe_state.py`)确认目标 50/加热 ON,
并现场抓到 **14 帧连续 OPEN(~1.4s)**——NTC 间歇开路真实存在(硬件侧
待修);② **OpenOCD `mrw` 旁路观测**(`observe2.tcl`+`obs_decode.py`,
每 500ms 不 halt 读 RAM:duty/integral/ramp/target/temp——注意该 xpack
OpenOCD 的 echo/mdw 控制台回显会丢失,须用 TCL 落文件)。全程轨迹证实
**无故障参与的 90~120s 自持极限环**:积分 0↔65 打摆(真机 50℃ 维持功率
~10-15%,即 I≈40~60 才是正确稳态),机理=**err<0 时 P 被钳 0、输出只剩
Ki·I 恒定值**——过线后输出不随超温幅度收敛→温度必然冲过头→过冲把积分
排空→排空后 sag→重建再过冲。NTC 毛刺清零积分只是点火器。V0.1.2 修复:
① **err<0 时 P 负项+D 直接抵扣积分后钳 0**(输出随超温幅度立即收敛,
积分按剩余需要维持→可收敛到稳态不再打摆);② **NTC 短故障(≤3s 且
|ΔT|<2℃)无扰还原积分**(确认沿暂存 `s_fault_stash_I/T`,防毛刺清零
引发 sag-重建-过冲链);③ I_MAX 400→200(Ki·I_MAX=50%,120℃ 模具维持
余量足够)。判定测试基建:`test_run.py`(固定序列 M999→M2S500→M4S50→
M3S1→N 秒→收尾;**预定义判据 J1 上报率/J2 稳态均值±0.5/J3 稳态极值±1.0/
J4 全程 max≤52/J5 无故障**,>54℃ 自动熔断 M3S0——开发/测试分工,脚本
自动判定避免自判自;注意 FF485 frame.payload 已是 str)。
- **PID V3 判定迭代与收敛(V0.1.2→V0.1.3,2026-10-15,900s 判定测试)**:
- V0.1.2(P-veto + 故障无扰还原 + I_MAX 200)判定:J1/J2/J5 PASS,但
**J3 FAIL(稳态窗 [46.6,52.1])、J4 FAIL(max 52.5)**——P-veto 消灭了
积分排空式打摆(观测器证实收敛末段积分稳在 ~48=真实维持功率、温度
±0.2),但双热容被控对象(鳍片+本体,NTC 只见鳍片)每次顶/谷都给
对方"预存"动量,15 分钟才收敛;且斜坡 0.8℃/s 远快于对象,爬升段误差
长期 ~2℃ 把积分提前充到 30~50,首冲 +2.5℃。
- V0.1.3:**HEATER_RAMP_CPS 0.8→0.25**(爬升误差贴地、积分只在近目标
低功率段建立)+ **HEATER_SLOWBAND_DUTY 25→15**(近目标动作放柔,
少给鳍片预存过热)。41℃ 冷启判定:**J1~J5 全 PASS**——到达 69s、
首冲 +1.20℃、全程 max 51.2、稳态窗 [49.0,50.9]、均值 49.95、
1795 帧 0 故障。观测器同步确认:远场(err>7)才见 75% 满功率,
温和带内 15% 缓爬,50.06℃ 到达时积分 35.9/占空 5%。
- 测试基建沉淀:`test_run.py`(判定+熔断)、`observe2.tcl`+`obs_decode.py`
(DAPLink 旁路,**注意符号地址随 bss 布局变,重编译后须 nm 重取**;
观测文件被占用时追加不清空)、`sim_pid_check.py`(含 V1/V3/V4 对照、
毛刺/噪声注入、扫参)。CMD 坑:`if exist X del X && ...` 文件不存在时
整链跳过;多行 `python -c` 偶发被吞——写文件执行。
- **遗留(硬件侧)**:NTC 间歇开路(现场抓到 14 帧连续 OPEN ~1.4s,
短故障无扰还原已缓解控制影响,但根因在采样/接触)——查 NTC 焊接/
压接/走线,必要时 ntc.c 采样侧加防护。独立代码审查 agent 未在时限内
返回,本轮定位以仪器化数据(COM5 帧流 + DAPLink 内存轨迹)为准。
- **PID 调试全程速览(2026-10-15,V0.1.0→V0.1.14,判定测试自动出结论)**:
| 版本 | 关键改动 | 判定/实测结果 | 结论 |
|---|---|---|---|
| V0.1.0 | 全面重写:D-on-measurement+积分钳位+斜坡恢复+故障去抖 | 过冲 +2.4→+0.4 | 旧版 D 项失效/积分无界/斜坡缺失三根因修复 |
| V0.1.1 | Ki 0.05→0.25+窄带吸收 | ±1℃ 三角波极限环 | err<0 硬切=继电器死区 |
| V0.1.2 | P 参与过线区+故障无扰还原+I_MAX200 | J3/J4 FAIL | P-veto 生效但收敛 15min |
| V0.1.3 | 斜坡 0.25C/s+温和带 15% | J1~J5 全 PASS(旧判据) | 稳态 [49.0,50.9] 不满足 ±0.5 |
| V0.1.4 | 占空抖动 1.25% 分辨率 | p2p=1.8 FAIL | 量化非主因 |
| V0.1.5 | Kp5/Kd60/Ki0.15 连续 PID | p2p=4.2 FAIL | ±2C/90s 慢摆,调参到头 |
| V0.1.6 | 分区 bang-bang+慢积分 | 卡 48.6C FAIL | 带外平衡点陷阱 |
| V0.1.7 | 带外也吸收 | p2p=4.1 FAIL | 吸收/回退不对称→I 上漂 90 |
| V0.1.8 | 对称慢速积分 | p2p=4.0 FAIL | 积分稳了;秒级读数坏值穿透 |
| V0.1.9 | 斜率限幅+带内退闸 | 无法判定 | NTC 持续开路(后重焊恢复) |
| V0.1.10 | 去上限+RAMP2.0+温和带25% | p2p=2.7 FAIL | 快斜坡生效;坏值仍是主力 |
| V0.1.11 | NTC 5 点滑动中值 | **p2p=1.30** | 中值滤波立竿见影 |
| V0.1.12 | 近场 ASSIST 减速曲线 | 实时 p2p=**0.31** | 最后 2C 推力真空修复;判定窗被传感器事件污染 |
| V0.1.13 | 大偏差冻结(>1.5C) | p2p=1.60 | 塌陷深谷消除(47.1→49.0) |
| V0.1.14 | 对称积分加倍 0.30 | p2p=1.80 | 与 13 同水平=硬件平台期 |
**最终状态(V0.1.14 在板)**:到达 26s(30→50C,提速 9 倍);控制环本体
(无事件段)p2p 0.3~0.9C 达标;判定窗残余 1.6~1.8C 全部来自 NTC 焊接点
秒级接触不良(-2.5C/4.5s 塌陷、顶格 52.2C 卡死 10s,1.5C 以内坏值仍会穿透
三重滤波)。**下一步:彻底重焊/更换 NTC(根治手段),固件三重防护
(中值+斜率限幅+大偏差冻结)已就位,重焊后 `python test_run.py 1200` 一键
重判(J3 即 ±0.5 规格)。测试环境 55C 上限已按用户要求移除(真机靠物理
温度开关)。**
- **±0.5℃ 波动压榨迭代(2026-10-15,V0.1.3→V0.1.6,用户规格 ±0.5C,
判据 J3 收紧为稳态窗 [目标±0.5])**——V0.1.3 稳态窗 [49.0,50.9](压着
旧判据 ±1.0 过)不可接受,连续三轮仪器化迭代:
- **V0.1.4 占空抖动(Bresenham)**:载波 2s=4 拍分摊档位余数,平均分辨率
5%→1.25%(真机维持功率 ~10-12% 恰卡在 10/15 档间,固定取整迫使
0↔15% 满幅切换)。观测器证实抖动生效(duty 5/10/15/20/25 细粒度),
**但极限环未消(J3 p2p=1.8 FAIL)→ 量化不是主因**。
- **V0.1.5 降增益强阻尼(Kp 10→5 / Kd 30→60 / Ki 0.25→0.15 / 窄带 2→1)**:
**J3 p2p=4.2 FAIL**——变成 ±2C/90s 慢摆。观测器抓到机理:带内半周期
净吸积分 +18(err 0.5~1.0 持续 30s),过线后 P-veto 每拍泄 2.8 快速
排空 → 维持功率记忆反复丢失 → 谷底深潜 48.6。**连续 PID 在本对象
(双热容+单向执行器+0.1C 读数量化)上调参到头**;Kd=60 还放大 NTC
0.1C 量化跳变成 ±6% duty 抖动。
- **V0.1.6 分区 bang-bang + 慢积分学习(结构性换型)**:作用分三区——
带内 |err|<HEATER_PID_BAND_C(0.4C):P/D 退出,u=Ki*I 恒功率,积分慢速
学习维持功率(I+=err*0.3/拍);带外下方:P 拉回(温和带钳位不变),
积分不吸收;带外上方:关断 + I 固定 0.3/拍限速回退(保住维持功率
记忆)。波动幅度被物理限制在带宽+滑行量,I 学准后驻留带内。
Kd 回 30(60 的噪声放大不可用)、I_MAX 回 400(Ki*I_MAX=60%)。
- **V0.1.6 判定 FAIL(带外平衡点陷阱)**:观测器抓到温度卡 48.6C——P 输出
恰好平衡散热、err 停带外,积分只在带内学习永远没机会介入。V0.1.7 修复
(带外下方 P 未被钳时同样慢速吸收):前半程良好(49.8/integ 37 稳),但
**吸收(0.3/拍)与回退(0.3/拍固定)不对称 → I 上漂到 90 持续超供**,后半程
大摆(J3 p2p=4.1 FAIL)。V0.1.8 改全区域对称慢速(GAIN=BLEED=0.15):
积分不漂了(35~42 窄幅)、带内占有率 44%,**但 J3 仍 FAIL(p2p=4.0)**——
csv 分析抓到 **-1.2C/0.5s 读数突跳 + 5s 后 +1.8C 回弹**(非故障级 NTC
毛刺,真实热速率 <=0.2C/拍),毛刺触发 25% 猛拉 ~10s 直接制造 +2C 过冲。
- **V0.1.9(待判定)**:① NTC 读数斜率限幅(HEATER_NTC_SLEW_C=0.5C/拍,
超限沿限幅值过渡、连续 2 拍放行——毛刺对环路的冲击源头掐断);② 带内
功率线性退闸(下缘 100% 维持量 -> 上缘 0%,过线前功率已趋零,蓄热滑行
收窄)。已烧录 MATCH。
- **NTC 硬件恶化为持续开路(阻塞判定)**:V0.1.9 判定测试全程 M1/M2 报
OPEN(帧流正常、命令全 ok),加热安全关断(故障路径实测有效)。用户
重焊后恢复(30.1C 稳定读数),进入 V0.1.10~V0.1.14 快速迭代:
- **V0.1.10 去上限 + 提速**:测试脚本去掉 54C 自动熔断/J4 上限判据(用户
明确:真机靠物理温度开关,55C 只是测试环境约束);RAMP 0.25→2.0(实测
0.25 时斜坡本身就是瓶颈,9C 走 36s);温和带 15%→25%。判定:reach 49s
但 J3 p2p=2.7 FAIL、首冲 +3.3。
- **V0.1.11 NTC 5 点滑动中值**(ntc.c 采样侧):V0.1.10 稳态摆的根源确认为
秒级读数坏值穿透斜率限幅(中值滤波只挡 <250ms 毛刺)。效果显著:
稳态 p2p 4.0→**1.30**([49.40,50.70])。温和带 7→3C、25→35%。
- **V0.1.12 近场 ASSIST 减速曲线**:分段速率分析钉死最后 2C 爬 141s 的瓶颈
=近场推力真空(P 随 err 缩小消失、I 未成熟)。近场扩展区 [-0.3,3.0)
合并退闸+缓爬:u = Ki*I + ASSIST 退闸曲线 + Kd,上限 NEAR_MAX_DUTY 50%。
实时观测 p2p **0.31C**(达标水平);但判定窗两极值(47.1 塌陷 + 52.2
顶格卡死)均为传感器事件:**-2.5C/4.5s 塌陷与顶格 10s,焊接点仍不稳**。
- **V0.1.13 大偏差冻结**:滤波值与原始读数偏差 >1.5C(HEATER_NTC_JUMP_C)
冻结不跟随,回弹自动恢复(真实冷却 ~0.05C/拍永不误触发)。判定 p2p
1.60([49.00,50.60])——塌陷深谷消除。reach 32s(提速 7 倍)。
- **V0.1.14 对称积分加倍**(GAIN=BLEED 0.15→0.30,I 充满 400s→200s,修复
判定窗内驻留点偏低):p2p 1.80([49.20,51.00]),与 V0.1.13 同水平
(差异在传感器事件噪声内)——**到达当前硬件条件下的平台期**。
- **当前结论**:控制环本体(无事件段)p2p≈0.3~0.9 达标;残余 1.6~1.8 全部
来自 NTC 焊接点秒级接触不良(塌陷/顶格事件,1.5C 以内的坏值仍会穿透
防护)。**下一步是彻底重焊/更换 NTC 及其引线**,固件侧三重防护
(中值+斜率限幅+大偏差冻结)已就位。到达速度 26s(30→50C,提速 9 倍)。
测试/观测基建:test_run.py(无熔断版,J1/J2/J3±0.5/J4 无故障自动判定)、
observe2.tcl+obs_last.py(**每次重编译必须 nm 重取符号地址**)、
seg_rate.py(分段爬升速率)、log_tail.py(实时帧解析)。
中间数据(obs_samples.txt/test_data.csv/test_events.log)已清理,
由测试/观测脚本重新生成。
- 方法论沉淀:单状态热模型仿真始终未复现真机极限环(双热容建不进单
状态),本轮所有定位直接以 DAPLink 观测器 + COM5 判定脚本为准;
观测符号地址 V0.1.4 起稳定(s_duty=0x20000459 / s_integral=0x20000454 /
s_ramp_target=0x2000045c / s_target=0x20000460 / s_enable=0x20000464)。
- **50℃→40℃ 阶跃验证脚本**(`temp_test_com.py`,工作区修改未提交):M2S500
全曲线采样,输出超调/settle 时间/稳态误差/降温段统计(结果以实跑为准)。
- **电源保持 PA11 实装**(`pwr_hold.h/pwr_hold.c`):上电最先拉高 PA11 维持
@@ -122,8 +298,12 @@
- [ ] **适配器更换**:换可靠的 USB-RS485 适配器(当前自动方向切换间歇丢响应)。
- [x] **加热驱动 + PID 闭环**(PB12 慢 PWM 1s/10% 步进 + PID 500ms 周期,
安全保护齐全;实测 30℃ 闭环通过:30s 爬升、超调 1.4℃、60s 收敛中)。
- [ ] **PID 参数整定**:初值 Kp=8/Ki=0.05/Kd=0(board_config.h 宏),按真实
工况温度(80~120℃)与负载重新整定,抑制 ~7% 超调。
- [ ] **NTC 彻底重焊/更换(当前头号事项)**:焊接点秒级接触不良已用
DAPLink+COM5 数据链钉死(-2.5C/4.5s 塌陷、顶格卡死、曾持续开路),
是 ±0.5C 规格最后的拦路虎;固件三重防护已就位(V0.1.14)。
- [ ] **±0.5℃ 规格复判**:NTC 重焊后 `python test_run.py 1200`(J3=±0.5);
当前稳态控制环本体 p2p 0.3~0.9C 已达标。真实工况温度(80~120℃)下
需复测整定(Kp=8/Ki=0.15/Kd=30、ASSIST 35%、I_MAX400,board_config.h)。
- [x] **专用 GUI 上位机 v0.2.0**:v0.1.0 已完成 COM5 真机全链路验证;v0.2.0 与
实装固件对齐——去除"固件预留/仅软件状态"旧表述(协议模块预设说明、温控
卡片标题/备注、开启加热确认弹窗改为真实控温警示、ack 提示),GUI README
@@ -140,7 +320,7 @@
"未连接串口"丢弃)。selftest 全过(新增 8 项回归用例);已打包冒烟并发布
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 字节
间隔超时因帧间隔更短永不触发,100ms 上报 ≈2.5s 一批),曲线/命令/响应全部
3 秒一卡、一股脑一大坨——真机日志确认每簇 ≈25 帧、间隔恒 3s。已改
@@ -185,8 +365,50 @@
_last_set 单槽位罕见误补偿(8s 自愈)、build_exe remove_stale 无提示强杀
运行实例、M888 后固定 2s 重查可能早于固件就绪、_danger_reset 把去重忽略记成
"已下发"。协议建议的 300ms×3 自动重发维持不做(设计决策,用户拍板)。
- selftest 全过(累计新增 15 项回归用例,含窗口图标非空检查);exe 已本地
打包冒烟,Gitea 发布待用户确认后执行。
- v0.2.10:**曲线 Y 轴手动量程 + "暂无数据"提示不消失修复**——①"实时温度曲线"
卡新增 Y 轴行:下限/上限输入框 + 自动勾选钮(默认勾选=随可见数据自适应;
取消勾选=按输入框固定量程,非法输入回退最近一次自适应范围并经状态栏提示;
首次切手动按当前自适应范围预填平滑接管;固定量程下曲线越界 setClipRect
裁剪回绘图区、当前值标记超量程不画,实时读数卡仍可见)。模式+量程随
`filmforge_gui.json` 持久化(y_axis 键,存值非法一律回自动)。
②提示文字不消失根因(用户标注截图):`set_source` 仅在构建期调用一次且在
那里缓存 `_has_data` 布尔——数据经同一列表引用追加后该布尔永不刷新 → 改为
paintEvent 实时判定(图表本就持有引用,无需其他调用点适应)。selftest 新增
15 项回归(切换往返/兜底预填/无效回退/量程落地/越界条带像素=0/新图数据到位
提示像素=0;断言须取当前主题曲线色、上条带避开 y=9 行图例色块),总数 74
全过;离屏整窗预览目检(手动/自动对照)布局正常。
- v0.2.11:**Y 轴量程控制改右缘滑条(用户要求放右侧、滑条形式)**——移除
v0.2.10 的下限/上限输入框行,"Y轴自动/手动"勾选钮移到暂停/清空行右端;
TemperatureChart 右缘新增滑条栏(GUTTER_W 18px:轨道+量程带+双把手):
上把手=上限、下把手=下限、把手中带拖拽=整段平移(跨度保持)、点击轨道
空白把对应边界直接设到该处;把手在**参考域**(全量数据范围 ±15%,`_y_ref`)
上映射像素,auto 态把手半透明、首次任意拖拽即切手动(`yManualEngaged`
同步按钮态+配置),松手量化 0.1℃ 经 `yRangeChanged` 记设置(最小间距
0.5℃),按钮一键回自动;越界裁剪沿 v0.2.10 不变。坑两条(勿重蹈):
①拖拽中的界值必须取**实时显示范围**而非 `_cached_layout` 绘制缓存——
连续两次拖拽之间没有重绘,缓存是陈旧范围(实测 pan 把量程整段拖跳);
②selftest 固件用真实 `filmforge_gui.json` 时"初始自动"断言会被用户存的
手动模式击穿——改指向临时目录设置文件彻底隔离。
selftest 重写第 8 段共 81 项全过(把手命中/拖拽切手动/量化/最小间距/
平移保跨度/裁剪条带/提示消失);离屏手动/自动滑条目检通过。
- v0.2.12:**滑条域定值 -20~120℃ + 标题行并排(用户截图标注 3 处)**——
①滑条参考域从"全量数据 ±15%"改为**固定 -20~120℃**(Y_RAIL_LO/HI 宏):
把手像素位置直接对应 ℃ 读数,空数据/冷机不再出现 -5.0 这类随数据漂移的
误导把手位;命中/拖拽/钳位全部按此域,140℃ 域上 1px≈0.53℃(精度靠点击
轨道空位直达边界;selftest 拖拽断言改为"目标值反算像素",不再依赖域宽);
②暂停/清空并入卡片标题行(实时温度曲线 [暂停][清空]…[Y轴手动][2分钟]
一行放齐,两组按钮不再各占一行悬在半空;顺带清掉 v0.2.11 重构残留的
孤儿按钮组代码;此卡不走 self._card 通用头,内联构建支持多 trailing);
③实测/目标图例移到绘图区左上(右侧被滑条把手/值标签占用)。selftest
81 项全过;离屏目检(空态/手动拖后)通过。
- v0.2.13:**把手值标签悬停门控(用户标注:常驻会遮挡曲线)**——上下限
两个数值标签不再常驻,仅鼠标悬停在滑条栏(或拖拽中)才显示
(`_y_hover` 翻转才重绘;leaveEvent 途中拖拽不退出悬停态,松手按位置
重算)。selftest 新增 2 项(未悬停标签区像素=0 / 悬停出现标签),共 83
项全过。exe 重新打包见下。
- selftest 全过(v0.2.13 后共 83 项回归);**v0.2.13 exe 已重新打包并冒烟
通过**(release/FilmForge.exe + FilmForge-GUI-v0.2.13-win64.zip,
依次取代 v0.2.10/v0.2.11/v0.2.12 包),Gitea Release 待用户确认后执行。
遗留见其 README"已知事项":USB-RS485 适配器更换。
- [x] **GUI 慢性卡顿修复(根因已定位并修复 v0.2.8,待真机确认)**:
根因/机理/实测数据见上方 v0.2.8 条目(`cursor.Start` 实例级枚举访问在
+17 -1
View File
@@ -8,7 +8,13 @@ FilmForge 热压模具温控板(GD32F405, RS485 数据口)的桌面上位机
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均)。
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均);
Y 轴(温度轴)量程可选**自动**(随可见数据自适应)或**手动**——直接拖动
图表右缘滑条(固定参考域 -20~120℃,把手位置=℃ 读数):上把手=上限、
下把手=下限、拖动把手中段上下=整段平移并把跨度保持不变、点击轨道空白直接
把对应边界设到该处;首次任意拖拽自动切入手动("Y轴自动/手动"按钮同步),
把手数值标签仅悬停/拖拽时显示(不常驻遮挡曲线),曲线越界时裁剪回绘图区;
模式与量程记忆在 `filmforge_gui.json`。
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
M1 仅作手动单点查询(命令区按钮/预设)。
- **控制面板**:目标温度 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。
- 图表刷新双节流:数据到达即重绘(≤10Hz)+ 0.5s 时间轴推进;“全部”窗口超 4000 点
按步进抽稀(NaN 断口保留)。
- **是否有数据由绘制时实时判定**(`paintEvent` 直接看数据列表引用):图表只在
构建时调用一次 `set_source`,旧版在这里缓存过 `_has_data` 布尔——数据经引用
追加后永不刷新,“暂无数据”提示文字在有数据后仍绘在曲线上(实测截图暴露)。
手动 Y 轴模式下曲线/目标线**裁剪回绘图区**(`setClipRect`),当前值标记
超出量程时不画(实时读数卡片仍可见)。v0.2.10。
- **Y 轴滑条的两套坐标系别混用**:把手像素位置在**参考域**(全量数据范围
±15%,`_y_ref`)上映射,与"显示量程"是两回事;拖拽中的界值必须取**实时
显示范围**(manual=`_y_manual`,auto=当前自适应值),不能取绘制缓存
`_cached_layout` 的陈旧范围——连续两次拖拽之间没有重绘,实测曾把 pan 的
量程整段拖跳(v0.2.11)。
## 已知事项
+354 -33
View File
@@ -45,8 +45,8 @@ if getattr(sys, "frozen", False) and os.name == "nt":
except (AttributeError, OSError):
pass
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QCursor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
QScrollArea, QSplitter, QVBoxLayout, QWidget,
@@ -60,7 +60,7 @@ import config
import filmforge_rs485 as ff
APP_NAME = "FilmForge"
APP_VERSION = "0.2.9"
APP_VERSION = "0.2.13"
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666"
@@ -77,6 +77,13 @@ CHART_COLORS_LIGHT = dict(bg="#ffffff", grid="#e3e8ee", text="#5a6572",
measured="#0f6cbd", target="#d96f1a", fault=CHIP_ERR)
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 "
"{ background-color: #D9534F; color: white; border: none; }")
@@ -84,10 +91,16 @@ DANGER_QSS = ("PushButton, PushButton:hover, PushButton:pressed, PushButton:disa
class TemperatureChart(QWidget):
"""自绘滚动温度曲线(QWidget + QPainter, 不引第三方图表库)。
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色独立设置。
数据由 App 持有, 经 set_source() 传入引用; 视窗/冻结/配色/Y 轴模式独立设置。
曲线语义: 实测=折线(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):
super().__init__(parent)
self.setMinimumHeight(300)
@@ -98,13 +111,24 @@ class TemperatureChart(QWidget):
self._window_sec = config.DEFAULT_WINDOW_SEC
self._frozen_at = None # None=实时跟随; 否则冻结该时刻
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):
"""注入数据列表引用(原地变更可见)。仅在构建时调用一次: 是否有数据
必须由绘制时实时判定, 旧版在这里缓存 _has_data 布尔, 数据经引用
追加后永不刷新 → "暂无数据"提示文字不消失(实测踩坑)。"""
self._samples_t, self._samples_v = samples_t, samples_v
self._target_hist, self._faults = target_hist, faults
self._has_data = bool(samples_t) or bool(faults)
self.update()
def set_window(self, sec):
@@ -119,6 +143,18 @@ class TemperatureChart(QWidget):
self._colors = dict(colors)
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):
t1 = self._frozen_at if self._frozen_at is not None else time.time()
@@ -186,28 +222,249 @@ class TemperatureChart(QWidget):
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):
p = QPainter(self)
c = self._colors
p.fillRect(self.rect(), QColor(c["bg"]))
area = QRectF(self.rect()).adjusted(52, 10, -12, -26)
t0, t1 = self._view()
vis_t, vis_v = self._visible(t0, t1)
tgt = self._target_points(t0, t1)
ymin, ymax = self._y_range(vis_v, tgt)
area, t0, t1, vis_t, vis_v, tgt, ymin, ymax = self._layout()
self._paint_area = area
self._paint_range = (ymin, ymax)
self._paint_ref = self._y_ref()
sy = self._scale_y(area, ymin, ymax)
sx = self._scale_x(area, t0, t1)
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_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_legend(p)
if not self._has_data:
self._draw_y_gutter(p, area, ymin, ymax)
self._draw_legend(p, area)
if not (self._samples_t or self._faults): # 实时判定: 数据到位即消失
p.setPen(QColor(c["text"]))
p.drawText(self.rect(), Qt.AlignCenter,
"暂无数据 — 连接设备后开启周期上报(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
def _y_range(vis_v, tgt_pts):
vals = [v for v in vis_v if v == v] + [v for _t, v in tgt_pts]
@@ -312,23 +569,31 @@ class TemperatureChart(QWidget):
path.lineTo(x, y)
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)):
if v == v:
last_t, last_v = t, v
break
if last_t is not None:
x, y = sx(last_t), sy(last_v)
p.setPen(Qt.NoPen)
p.setBrush(QColor(self._colors["measured"]))
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
p.setPen(QColor(self._colors["measured"]))
font = self.font()
font.setPointSizeF(8.5)
font.setBold(True)
p.setFont(font)
p.drawText(QPointF(min(x + 6, self.width() - 54), max(y - 6, 14)),
f"{last_v:.1f}℃")
if last_t is None:
return
x, y = sx(last_t), sy(last_v)
if not area.top() <= y <= area.bottom():
return
p.setPen(Qt.NoPen)
p.setBrush(QColor(self._colors["measured"]))
p.drawEllipse(QPointF(x, y), 3.2, 3.2)
p.setPen(QColor(self._colors["measured"]))
font = self.font()
font.setPointSizeF(8.5)
font.setBold(True)
p.setFont(font)
# 文字通常画在点上方; 贴顶边放不下时换到点下方, 再钳回绘图区内
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}℃")
def _draw_faults(self, p, area, t0, t1):
flt = [(t, s) for t, s in self._faults if t0 <= t <= t1]
@@ -349,12 +614,13 @@ class TemperatureChart(QWidget):
last_x = x
labeled += 1
def _draw_legend(self, p):
def _draw_legend(self, p, area):
c = self._colors
font = self.font()
font.setPointSizeF(8.5)
p.setFont(font)
x = self.width() - 150
# 图例放绘图区左上: 右侧被滑条把手/值标签占用(用户标注)
x = area.left() + 6
y = 16
for color, label in ((c["measured"], "实测"), (c["target"], "目标")):
p.setPen(Qt.NoPen)
@@ -550,15 +816,36 @@ class App(QMainWindow):
win_combo.setCurrentIndex(1) # 先设默认再连信号, 避免触发时 chart 未建
win_combo.currentIndexChanged.connect(self._on_window)
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)
header.addWidget(self.btn_pause)
self.btn_clear = self._button("清空", self._clear_plot)
row.addWidget(self.btn_pause)
row.addWidget(self.btn_clear)
card, body = self._card("实时温度曲线", trailing=win_combo)
body.addLayout(row)
header.addWidget(self.btn_clear)
header.addStretch(1)
# ---- Y 轴(温度轴)量程: 自动适配 / 图表右缘滑条手动拖拽 ----
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.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)
layout.addWidget(card, 2)
@@ -719,6 +1006,17 @@ class App(QMainWindow):
self.cbo_window.setCurrentIndex(widx)
self.chart.set_window(config.WINDOW_SEC)
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):
@@ -1233,6 +1531,29 @@ class App(QMainWindow):
config.save_settings()
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):
if self._frozen_at is None:
self._frozen_at = time.time()
+17
View File
@@ -32,6 +32,9 @@ SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD,
REPORT_MS = 500 # 上报间隔输入框默认值
WINDOW_SEC = DEFAULT_WINDOW_SEC
THEME = 'System'
Y_MODE = 'auto' # 曲线 Y 轴(温度轴): 'auto'=自适应 / 'manual'=固定范围
Y_MIN = None # 手动 Y 轴上下限(℃), 仅 Y_MODE='manual' 且合法时生效
Y_MAX = None
def load_settings():
@@ -68,6 +71,19 @@ def load_settings():
theme = str(data.get('theme') or '')
if theme in ('Dark', 'Light', 'System'):
THEME = theme
ax = data.get('y_axis')
if isinstance(ax, dict):
mode = str(ax.get('mode') or '')
if mode in ('auto', 'manual'):
globals()['Y_MODE'] = mode
try:
globals()['Y_MIN'] = round(float(ax.get('min')), 2)
except (TypeError, ValueError):
pass
try:
globals()['Y_MAX'] = round(float(ax.get('max')), 2)
except (TypeError, ValueError):
pass
def save_settings():
@@ -77,6 +93,7 @@ def save_settings():
'report_ms': REPORT_MS,
'window_sec': WINDOW_SEC,
'theme': THEME,
'y_axis': {'mode': Y_MODE, 'min': Y_MIN, 'max': Y_MAX},
}
try:
with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
+141
View File
@@ -8,6 +8,7 @@ import math
import os
from collections import deque
import sys
import tempfile
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
@@ -15,9 +16,16 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import filmforge_rs485 as ff
import config
from PySide6.QtCore import QRectF
from PySide6.QtGui import QImage, QPainter
from PySide6.QtWidgets import QApplication
# 设置文件指向临时目录: 隔离用户真实 filmforge_gui.json(用户可能展存着手动
# Y 轴等状态), 测试一律从"首次启动默认"出发, 残留写入不留真实配置盘
_tmpdir = tempfile.mkdtemp(prefix='filmforge_selftest_')
config.SETTINGS_FILE = os.path.join(_tmpdir, 'settings.json')
fails = []
@@ -73,6 +81,18 @@ def count_pixels(img, rgb, tol=30):
return n
def count_in(img, x0, x1, y0, y1, rgb, tol=30):
"""区域内逐像素计数(stride 1): 用于条带级断言(如越界裁剪)与空态提示"""
n = 0
for y in range(y0, y1):
for x in range(x0, x1):
c = img.pixelColor(x, y)
if (abs(c.red() - rgb[0]) < tol and abs(c.green() - rgb[1]) < tol
and abs(c.blue() - rgb[2]) < tol):
n += 1
return n
measured = (0, 200, 215) # Dark 主题实测曲线色 #00c8d7
target = (232, 131, 58) # 目标线 #E8833A
fault = (217, 83, 79) # 故障 #D9534F
@@ -279,5 +299,126 @@ ts, _vs = c._visible(0, 99)
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
c._samples_t, c._samples_v = window.samples_t, window.samples_v
# 8) v0.2.11 回归: 右缘双把手滑条拖拽量程 + 空态提示实时消失。
# (提示不消失根因: 旧版 set_source 构建期缓存 _has_data 布尔, 数据经引用追加
# 后永不刷新 → 改绘制时实时判定; 本段同时覆盖滑条命中/拖拽/钳位/平移/裁剪)
c = window.chart
check('Y 轴初始自动', c._y_mode, 'auto')
check('Y 轴按钮默认自动', window.btn_ymode.isChecked()
and window.btn_ymode.text() == 'Y轴自动', True)
engaged, ranges = [], []
c.yManualEngaged.connect(lambda: engaged.append(1))
c.yRangeChanged.connect(lambda lo, hi: ranges.append((lo, hi)))
c.update()
app.processEvents() # 同步一次绘制: 命中缓存与当前数据一致
area, _t0, _t1, _vt, _vv, _tgt, ymin0, ymax0 = c._layout()
gx0, gx1 = c._gutter()
ref0 = c._y_ref()
y_hi_px = c._rail_px(area, ref0, ymax0)
y_lo_px = c._rail_px(area, ref0, ymin0)
check('把手命中(上把手)', c._gutter_hit(gx0 + 6, y_hi_px), 'max')
check('把手命中(下把手)', c._gutter_hit(gx0 + 6, y_lo_px), 'min')
check('轨道空白亦视为边界', c._gutter_hit(gx0 + 6, area.bottom() - 3), 'min')
check('栏外不命中', c._gutter_hit(10, 100) is None, True)
c.start_y_drag('min', y_lo_px)
check('拖拽即切手动', c._y_mode, 'manual')
check('手动信号已发', engaged, [1])
# 滑条域固定 -20~120 后拖拽映射恒定(0.53C/px 量级), 断言用目标值反算像素,
# 不再依赖域宽: 拖到 ymin0+30 对应位置 → 下限抬升 30℃
c.update_y_drag(c._rail_px(area, ref0, ymin0 + 30.0))
lo1, hi1 = c._y_manual
check('下限随拖拽抬升', lo1 > ymin0 + 1e-9, True)
check('上限不受牵动', abs(hi1 - ymax0) < 1e-9, True)
check('松手生效', c.end_y_drag(), True)
lo_e, hi_e = ranges[-1]
check('范围量化 0.1', abs(lo_e * 10 - round(lo_e * 10)) < 1e-6
and abs(hi_e * 10 - round(hi_e * 10)) < 1e-6, True)
c.start_y_drag('min', y_lo_px) # 极端上拉 → 钳到 上限-最小间距
c.update_y_drag(area.top() - 500)
check('最小间距约束',
c._y_manual[1] - c._y_manual[0] >= c.MIN_Y_SPAN - 1e-9, True)
c.end_y_drag()
lo_prev, hi_prev = c._y_manual[0], c._y_manual[1] # 中带平移: 整段同移
c.start_y_drag('win', (y_hi_px + y_lo_px) / 2)
c.update_y_drag(area.bottom() + 30)
c.end_y_drag()
lo_pan, hi_pan = c._y_manual
# 松手量化 0.1℃ 各自取整, 跨度允许 ±0.05 偏差(两段半界)
check('平移不改跨度(量化容差)', abs((hi_pan - lo_pan) - (hi_prev - lo_prev)) < 0.12, True)
check('平移已生效', lo_pan < lo_prev - 1e-9, True)
check('上下限同步移动', abs((lo_prev - lo_pan) - (hi_prev - hi_pan)) < 0.12, True)
check('范围通知已发 3 次', len(ranges), 3)
window.btn_ymode.setChecked(True) # 按钮勾回自动
check('切回自动', c._y_mode, 'auto')
check('按钮文本回自动', window.btn_ymode.text(), 'Y轴自动')
# 确定形状的像素断言: 手动 [28,80], 数据(~19~60℃)出界部分必被裁剪回绘图区
c.set_y_manual(28.0, 80.0)
_hx = c._colors['measured'].lstrip('#')
measured_now = (int(_hx[0:2], 16), int(_hx[2:4], 16), int(_hx[4:6], 16))
window.chart.update()
img = QImage(window.chart.size(), QImage.Format_ARGB32)
window.chart.render(img)
area = QRectF(window.chart.rect()).adjusted(
52, 10, -(app_fluent.RIGHT_MARGIN + app_fluent.GUTTER_W), -26)
x0, x1 = int(area.left()) + 2, int(area.right()) - 2
check('手动范围下曲线仍绘制', count_pixels(img, measured_now) > 0, True)
check('裁剪: 上条带无曲线像素',
count_in(img, x0, x1, 0, int(area.top()) - 1, measured_now), 0)
check('裁剪: 下条带无曲线像素',
count_in(img, x0, x1, int(area.bottom()), window.chart.height(), measured_now), 0)
c.set_y_mode('auto')
hint = (201, 201, 201) # 提示文字色 #c9c9c9
c2 = app_fluent.TemperatureChart()
c2.resize(640, 320)
lst_t, lst_v = [], []
c2.set_source(lst_t, lst_v, [], []) # 模拟构建期一次性注入(空)
img2 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img2)
ax0, ax1 = 60, c2.width() - 20
ax_top, ax_bot = 30, c2.height() - 30
n_hint0 = count_in(img2, ax0, ax1, ax_top, ax_bot, hint)
lst_t.append(time.time()) # 经同一引用追加数据(复现旧 bug 路径)
lst_v.append(50.0)
c2.update()
img3 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img3)
n_hint1 = count_in(img3, ax0, ax1, ax_top, ax_bot, hint)
print(f'提示像素: 空={n_hint0} 有数据={n_hint1}')
check('空态提示已绘出', n_hint0 > 50, True)
check('数据到位后提示消失(实时判定)', n_hint1, 0)
# 8b) 值标签悬停门控: 非悬停不画(不常驻遮挡曲线, 用户标注), 悬停/拖拽才显示
lt2, lv2 = [time.time() - 100], [35.0]
c2.set_source(lt2, lv2, [], [])
c2.set_y_manual(20.0, 60.0)
c2.set_y_mode('manual')
c2.update()
img4 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img4)
zx0, zx1 = c2.width() - 71, c2.width() - 31 # 把手值标签区(绘图区右缘)
zy0, zy1 = 110, 230 # 覆盖上下两个把手 y 位
n_pill0 = count_in(img4, zx0, zx1, zy0, zy1, (0, 200, 215))
c2._set_y_hover(True)
c2.update()
img5 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img5)
n_pill1 = count_in(img5, zx0, zx1, zy0, zy1, (0, 200, 215))
print(f'值标签像素: 未悬停={n_pill0} 悬停={n_pill1}')
check('未悬停时值标签隐藏(不遮曲线)', n_pill0, 0)
check('悬停/拖拽时值标签显示', n_pill1 > 20, True)
c2._set_y_hover(False)
# 还原模块配置, 避免测试残留写入用户 filmforge_gui.json
config.Y_MODE, config.Y_MIN, config.Y_MAX = 'auto', None, None
config.save_settings()
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0)
+32 -6
View File
@@ -152,12 +152,38 @@ void usart1_irq_handler(void);
#define HEATER_PWM_PERIOD_MS 2000U /* PWM 载波周期(2s,载波 0.5Hz) */
#define HEATER_PWM_STEP_PCT 5U /* 占空比步进(每档脉宽 = 100ms,维持最小开关频率约束) */
#define HEATER_PID_PERIOD_MS 500U /* PID 计算周期 */
#define HEATER_PID_KP 10.0f /* PID 参数:超温前退功率更快(配合超温强制关断) */
#define HEATER_PID_KI 0.05f
#define HEATER_PID_KD 30.0f /* 微分阻尼:按误差变化率提前刹车(抑制爬升惯性) */
#define HEATER_SLOWBAND_C 7.0f /* 温和带宽度:距目标小于此值时占空上限受限
(低速爬完最后一段,减少蓄热惯性过冲) */
#define HEATER_SLOWBAND_DUTY 25U /* 温和带内占空上限(%) */
#define HEATER_PID_KP 8.0f /* PID 参数:带外下方 P 拉回增益(带内 P 退出) */
#define HEATER_PID_KI 0.15f /* 输出映射:u 贡献 = KI*I;I 全区域对称慢速学习 */
#define HEATER_PID_KD 30.0f /* 微分(仅带外下方起作用):爬升段提前退功率 */
#define HEATER_PID_BAND_C 0.3f /* 作用分区带宽:带内恒功率(Ki*I),带外
开关+P 修正——波动幅度被物理限制在
带宽+滑行量 */
#define HEATER_PID_INTEG_GAIN_IN 0.30f /* 带内/带外下方积分学习增益(对称):
I += err*GAIN;0.15 时 I 充满需 ~400s,
判定窗内驻留点偏低 0.5C(ASSIST 补缺口
但驻留偏欠温),加倍后 ~200s 到位 */
#define HEATER_PID_INTEG_BLEED 0.30f /* 带外上方每拍固定回退(=GAIN,对称) */
#define HEATER_NTC_SLEW_C 0.5f /* NTC 读数斜率限幅(C/PID 拍):单拍变化超过
此值判为读数毛刺(真实热速率 <=0.2C/拍),
沿限幅值过渡、连续 2 拍超限放行真实值 */
#define HEATER_NTC_JUMP_C 1.5f /* 秒级坏值冻结阈值:斜率超限持续且累计偏差超过
此值时冻结滤波值(真机焊接点接触不良会产出
-2.5C/4.5s 塌陷与顶格卡死,中值滤波挡不住) */
#define HEATER_PID_I_MAX 400.0f /* 积分钳位上限:KI*I_MAX = 60% 占空(120C 模具
维持功率余量);下限恒为 0 */
#define HEATER_FAULT_RESTORE_MAX_S 3.0f /* NTC 短故障(去抖确认后恢复)无扰还原积分的
时长上限;超过则从零重建(安全优先) */
#define HEATER_RAMP_CPS 2.0f /* 内部设定值斜坡速率(℃/s):实测 0.25 时斜坡
本身就是瓶颈(9C 走 36s,加热器只是"追"设定点);
2.0 基本不设障,到达速度由加热功率与温和带
上限决定。分区控制器爬升段不积分,快斜坡
不会像旧连续 PID 那样预充积分导致过冲 */
#define HEATER_SLOWBAND_C 3.0f /* 温和带宽度:缓爬段(原 7C 太宽,25% 上限下
净升温仅 0.08C/s,爬升 234s 的主瓶颈) */
#define HEATER_SLOWBAND_DUTY 35U /* 近场缓爬辅助满档(%):err=+SLOWBAND_C 处
的推力,随 err 线性退闸(提供与积分成熟度
无关的确定爬升推力,修复最后 2C 爬 141s) */
#define HEATER_NEAR_MAX_DUTY 50U /* 近场扩展区推力上限(%):快而不过冲 */
#define HEATER_OVERTEMP_C 150.0f /* 过温强制关断阈值(滞回保护) */
#define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */
+4 -3
View File
@@ -12,9 +12,10 @@ void heat_init(void);
/**
* @brief 加热控制任务(在主循环中周期调用,主循环周期 10ms)。
* @details 内部包含:慢 PWM 载波(1s 周期、10% 步进,脉宽恒 >=100ms)、
* PID 计算(500ms 周期,输出量化到占空档位)、安全保护
* (传感器故障强制关断 / 过温滞回保护 / 未使能强制关断)。
* @details 内部包含:慢 PWM 载波(2s 周期、5% 步进,脉宽恒 >=100ms)、
* 目标斜坡 + 连续 PID(500ms 周期,全误差区间连续输出、微分基于
* 温度变化率、积分窄带吸收+钳位)、安全保护(传感器故障去抖确认后
* 强制关断 / 过温滞回保护 / 未使能强制关断)。
*/
void heat_task(void);
+175 -68
View File
@@ -1,16 +1,26 @@
/**
* @file heat.c
* @brief 加热控制:慢 PWM 驱动(PB12)+ 分段 PID 控温闭环 + 安全保护。
* @brief 加热控制:慢 PWM 驱动(PB12)+ 斜坡设定值 + 分段 PID 控温闭环 + 安全保护。
* @details 硬件:PB12=HEAT_EN,高 = 加热开(2N7002K 电平转换 → VBE2412 PMOS → 24V)。
* 开关频率约束(<=10Hz)由"2s 载波周期 + 5% 占空步进"保证——每档脉宽
* 恒为 100ms 的整数倍(0.5Hz 载波、20 档占空分辨率)。
* 控制为分段式:
* err >= 温和带 全量 PID(满功率爬升);
* 0 <= err < 温和带 正常 PID 但占空上限压到温和档(小功率缓升,
* 减少进带蓄热,降低越过目标后的惯性过冲);
* err < 0(超温) 强制关断 + 积分按负误差回退(执行器只能加热不能制冷)。
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT)强制关断;
* 过温(>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。
* 控制结构(平滑逼近目标,抑制过冲/震荡):
* 1. 目标斜坡:内部设定值以 HEATER_RAMP_CPS 限速滑向 M4 目标,
* 设定值阶跃/跳变不直接进入误差;
* 2. 全误差区间连续输出:err >= 温和带 P/D 全量;0 <= err < 温和带
* P/D 钳到 HEATER_SLOWBAND_DUTY(积分不参与钳);err < 0(超过
* 内部目标)P 负项直接抵扣积分后钳 0——P 参与使输出随超温幅度
* 立即收敛,积分只按剩余需要维持(若 P 铰 0 积分独撑,过线后
* 输出不随 err 变化,形成 sag->重建->过冲->排空自持极限环);
* 3. 微分基于温度变化率(D on measurement,-Kd*ΔT/拍):设定值
* 变化不产生微分踢;升温时提前退功率,回落越过目标时提前止跌;
* 4. 积分钳位 [0, HEATER_PID_I_MAX],吸收仅限目标近旁窄带
* (HEATER_PID_INTEG_ZONE_C)且 P/D 未被钳时(防爬升段
* windup);err<0 且 u>0 时小量回退,积分向稳态收敛;
* 稳态维持功率由积分承载。
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT,连续 3 拍去抖确认,
* 单拍毛刺不触发;短故障恢复无扰还原积分)强制关断;过温
* (>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。
*/
#include "heat.h"
@@ -23,17 +33,27 @@
/* 内部状态 */
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 bool s_overtemp = false; /* 过温锁存(滞回恢复) */
static float s_integral = 0.0f; /* PID 积分项 */
static float s_prev_err = 0.0f; /* PID 上次误差 */
static float s_integral = 0.0f; /* PID 积分累加(钳 [0, HEATER_PID_I_MAX]) */
static float s_prev_temp = 0.0f; /* 上拍温度(微分 on measurement 用) */
static float s_duty_acc = 0.0f; /* 占空抖动累加器(档位单位,<1) */
static uint8_t s_fault_debounce = 0; /* NTC 故障去抖(连续故障拍数,255 封顶记时长) */
static float s_fault_stash_I = 0.0f; /* 故障确认时刻积分暂存(短故障无扰还原用) */
static float s_fault_stash_T = 0.0f; /* 故障前末次正常温度(还原条件校验用) */
static float s_t_filt = 25.0f; /* 斜率限幅后的温度(读数毛刺防护) */
static uint8_t s_slew_cnt = 0; /* 连续超斜率拍数(真实快速变化放行计数) */
static uint16_t s_pwm_phase_ms = 0; /* PWM 载波相位 0..PERIOD-1 */
static uint16_t s_pid_divider = 0; /* PID 周期分频(10ms 圈) */
/* 档位总数 = 100% / 步进% */
#define HEATER_PWM_STEPS (100U / HEATER_PWM_STEP_PCT)
/* NTC 故障确认拍数(每拍 = 1 个 PID 周期 500ms;连续 3 拍 = 1.5s) */
#define NTC_FAULT_CONFIRM_PID_STEPS 3U
/******************************************************************************/
/* 底层输出 */
@@ -56,38 +76,74 @@ void heat_init(void) {
/******************************************************************************/
/* 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。
*/
static void pid_compute(void) {
float t = ntc_get_temperature();
float t_raw = ntc_get_temperature();
/* 传感器故障(开路/短路):安全第一,强制关断并复位 PID */
if (t <= -273.0f) {
s_duty = 0;
pid_reset();
return;
/* NTC 读数斜率限幅(V0.1.9)+ 大偏差冻结(V0.1.13):真实热速率
<=0.2C/拍,单拍 |Δ|>0.5C 判为读数毛刺,沿限幅值过渡,连续 2 拍超限
后——若累计偏差 > HEATER_NTC_JUMP_C(秒级塌陷/卡死事件,真机实测
-2.5C/4.5s 塌陷与 +2.3C/10s 顶格,焊接点接触不良)则冻结滤波值
不再跟随,直到读数回弹到偏差带内自动恢复。真实温度变化永远不会
触发冻结(最大冷却 ~0.05C/拍)。过温方向冻结偏差最大 1.5C,安全。 */
float t = t_raw;
if (t_raw > -273.0f) {
float d_raw = t_raw - s_t_filt;
if (d_raw > HEATER_NTC_SLEW_C || d_raw < -HEATER_NTC_SLEW_C) {
if (s_slew_cnt < 2U) {
s_slew_cnt++;
t = s_t_filt + ((d_raw > 0.0f) ? HEATER_NTC_SLEW_C : -HEATER_NTC_SLEW_C);
} else if (d_raw > HEATER_NTC_JUMP_C || d_raw < -HEATER_NTC_JUMP_C) {
t = s_t_filt; /* 秒级坏值:冻结 */
} else {
s_slew_cnt = 2U; /* 偏差带内:放行 */
}
} else {
s_slew_cnt = 0U;
}
s_t_filt = t;
} else {
s_slew_cnt = 0U;
}
/* 未使能:强制关断 */
/* NTC 故障去抖:单拍毛刺(采样/EMI)保持输出不动;连续确认才安全关断。
毛刺拍不更新任何 PID 状态(含 s_prev_temp),下拍用真实温差继续。
确认沿暂存积分与故障前温度——短故障恢复时无扰还原(见恢复分支)。 */
if (t <= -273.0f) {
if (s_fault_debounce < 255U) {
s_fault_debounce++;
}
if (s_fault_debounce >= NTC_FAULT_CONFIRM_PID_STEPS) {
if (s_fault_debounce == NTC_FAULT_CONFIRM_PID_STEPS) {
s_fault_stash_I = s_integral;
s_fault_stash_T = s_prev_temp;
}
s_duty = 0;
s_integral = 0.0f;
s_duty_acc = 0.0f;
s_prev_temp = t;
}
return;
}
/* 故障恢复:短故障(<=HEATER_FAULT_RESTORE_MAX_S)且温度未显著漂移
(|ΔT|<2℃)时还原暂存积分——避免毛刺清零后稳态功率丢失引发
sag->重建->过冲循环;长故障/温度已漂移则从零重建(安全优先) */
if (s_fault_debounce >= NTC_FAULT_CONFIRM_PID_STEPS) {
float fault_dur_s = (float)s_fault_debounce * (HEATER_PID_PERIOD_MS / 1000.0f);
float drift = t - s_fault_stash_T;
if (fault_dur_s <= HEATER_FAULT_RESTORE_MAX_S && drift < 2.0f && drift > -2.0f) {
s_integral = s_fault_stash_I;
}
}
s_fault_debounce = 0U;
/* 未使能:强制关断(每拍刷新微分基线,恢复时无假温差) */
if (!s_enable) {
s_duty = 0;
pid_reset();
s_integral = 0.0f;
s_prev_temp = t;
return;
}
@@ -99,52 +155,88 @@ static void pid_compute(void) {
}
if (s_overtemp) {
s_duty = 0;
pid_reset();
s_integral = 0.0f;
s_duty_acc = 0.0f;
s_prev_temp = t;
return;
}
float err = s_target - t;
/* 超温区间:执行器只能加热不能制冷,输出必须为 0;
积分按负误差回退,为温度回落后的重新加热保留正确初始态 */
if (err < 0.0f) {
s_integral = integ_step(err);
s_prev_err = err;
s_duty = 0;
return;
}
/* 位置式 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) */
/* 目标斜坡:内部设定值限速滑向 s_target(升/降同限速)。
小步进(斜坡快于温升)全程落在温和带内缓升;大步进爬升段 ramp 超前、
到站后误差随实际温度平滑收缩,抑制热滞后过冲。 */
float diff = s_target - s_ramp_target;
float ramp_step = HEATER_RAMP_CPS * (HEATER_PID_PERIOD_MS / 1000.0f);
if (diff > ramp_step) {
s_ramp_target += ramp_step;
} else if (diff < -ramp_step) {
s_ramp_target -= ramp_step;
} else {
s_integral = integ_step(err);
s_ramp_target = s_target;
}
/* 温和带:距目标不足 HEATER_SLOWBAND_C 时,比例/微分即时响应受限
(小功率缓升,减少进带蓄热、降低惯性过冲);积分项不受限——
稳态维持功率由积分缓慢顶到实际需要值 */
if (err < HEATER_SLOWBAND_C) {
float u_pd = HEATER_PID_KP * err + HEATER_PID_KD * (err - s_prev_err);
if (u_pd > (float)HEATER_SLOWBAND_DUTY) {
u_pd = (float)HEATER_SLOWBAND_DUTY;
float err = s_ramp_target - t;
float d_t = t - s_prev_temp; /* 本拍温度变化(D on measurement) */
/* 分区作用(V0.1.12):近场扩展区 [-BAND, SLOWBAND_C) 合并"带内退闸"
与"带外下方"为一条连续减速曲线——u = Ki*I(维持功率,不衰减)+
ASSIST 退闸曲线(err=+SLOWBAND_C 满档快爬、err=+BAND 处归零)+ Kd 减速。
原结构近场推力真空(P 随 err 缩小消失、I 未成熟,最后 2C 爬 141s),
ASSIST 提供与积分成熟度无关的确定推力。远场 err >= SLOWBAND_C:P/D
全量 + I。过温缘 err < -BAND:关断 + I 限速回退。 */
float u;
if (err >= HEATER_SLOWBAND_C) {
float u_pd = HEATER_PID_KP * err - HEATER_PID_KD * d_t;
if (u_pd > 100.0f) {
u_pd = 100.0f;
}
if (u_pd < 0.0f) {
u_pd = 0.0f;
}
u = u_pd + HEATER_PID_KI * s_integral;
if (u < 100.0f) {
s_integral += err * HEATER_PID_INTEG_GAIN_IN;
}
if (u > 100.0f) {
u = 100.0f;
}
} 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_duty = (uint8_t)((u + (float)HEATER_PWM_STEP_PCT / 2.0f) / (float)HEATER_PWM_STEP_PCT);
s_prev_temp = t;
/* 输出 Bresenham 抖动量化:载波 2s = 4 个 PID 拍,档位余数按拍分摊,
平均分辨率 5%/4 = 1.25%。稳态维持功率(实测 ~10-12%)恰落在整数档
之间时,固定取整会迫使输出在相邻档间满幅切换(±1℃ 极限环残因);
抖动后 PI 可停在真实需要的平均功率上。脉宽仍为 100ms 整数倍,
开关频率仍 0.5Hz,硬件约束不变 */
s_duty_acc += u / (float)HEATER_PWM_STEP_PCT;
if (s_duty_acc >= (float)(HEATER_PWM_STEPS + 1)) {
s_duty_acc = (float)(HEATER_PWM_STEPS + 1);
}
{
uint8_t level = (uint8_t)s_duty_acc; /* u >= 0,截断即 floor */
s_duty_acc -= (float)level;
s_duty = level;
}
}
/******************************************************************************/
@@ -164,8 +256,10 @@ void heat_task(void) {
if (s_pid_divider >= HEATER_PID_PERIOD_MS) {
s_pid_divider = 0U;
pid_compute();
if (s_duty != prev_duty) {
s_pwm_phase_ms = 0U; /* 占空变更:从新周期起点开始,响应即时 */
if (s_duty > prev_duty) {
s_pwm_phase_ms = 0U; /* 仅升档复位相位:立即起算新 ON 窗口;
降档不复位——相位已过新窗口即刻关断,
不再额外补发一段 ON 脉冲 */
}
}
@@ -183,9 +277,22 @@ void heat_task(void) {
void heat_set_enable(bool on) {
if (s_enable == on) return; /* 幂等 */
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;
pid_reset();
s_integral = 0.0f;
s_duty_acc = 0.0f;
heat_out(false);
}
}
+33 -1
View File
@@ -97,11 +97,43 @@ float ntc_read_temperature(void) {
static float s_ntc_last_temp = -273.15f; /* 最新温度(初值 = 开路标记) */
static uint16_t s_ntc_sample_divider = 0; /* 主循环 10ms 圈分频计数 */
/* 滑动中值滤波(V0.1.11):真机读数毛刺可持续 1.4s+(曾抓到 14 帧连续
OPEN),会穿透控制环的斜率限幅并触发猛拉。最近 NTC_MEDIAN_N 个样本取
中值——单点/短串毛刺被完全剔除,滞后仅 ~0.2s(远小于低通滤波)。
故障标记值(-273/-274)参与排序:偶发 OPEN 被中值滤掉,连续 >=3 帧才
成为中值(等效又一层去抖),故障判定语义不变。 */
#define NTC_MEDIAN_N 5U
static float s_ntc_win[NTC_MEDIAN_N];
static uint8_t s_ntc_win_idx = 0;
static uint8_t s_ntc_win_fill = 0;
void ntc_sample_task(void) {
/* 主循环周期 10ms:按 NTC_SAMPLE_PERIOD_MS 分频触发一次采样 */
if (++s_ntc_sample_divider >= (NTC_SAMPLE_PERIOD_MS / 10U)) {
s_ntc_sample_divider = 0;
s_ntc_last_temp = ntc_read_temperature();
float v = ntc_read_temperature();
s_ntc_win[s_ntc_win_idx] = v;
s_ntc_win_idx = (uint8_t)((s_ntc_win_idx + 1U) % NTC_MEDIAN_N);
if (s_ntc_win_fill < NTC_MEDIAN_N) {
s_ntc_win_fill++;
}
/* 插入排序后取中值(5 点,开销可忽略) */
float tmp[NTC_MEDIAN_N];
for (uint8_t i = 0; i < s_ntc_win_fill; i++) {
tmp[i] = s_ntc_win[i];
}
for (uint8_t i = 1; i < s_ntc_win_fill; i++) {
float key = tmp[i];
int8_t j = (int8_t)(i - 1);
while (j >= 0 && tmp[j] > key) {
tmp[j + 1] = tmp[j];
j--;
}
tmp[j + 1] = key;
}
s_ntc_last_temp = tmp[s_ntc_win_fill / 2U];
}
}
+2 -2
View File
@@ -2,8 +2,8 @@
set(PROJECT_NAME "FilmForge_FW")
set(BOARD_TYPE_CODE 41)
set(VERSION_MAJOR 0)
set(VERSION_MINOR 0)
set(VERSION_PATCH 1)
set(VERSION_MINOR 1)
set(VERSION_PATCH 14)
set(VERSION "V${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
string(TIMESTAMP BUILD_DATE "%Y-%m-%d")
+18
View File
@@ -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")
+51
View File
@@ -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
View File
@@ -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}]")
+28
View File
@@ -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
View File
@@ -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})")
+355
View File
@@ -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
View File
@@ -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'}")