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
hulk 8035f9b7d3 GUI v0.2.9: fix chronic-lag root cause (log trim crash on PySide6 6.11) + 5 hardening fixes
- log trim used instance-level enum access (removed in PySide6 6.11), crashed
2026-10-12 16:14:53 +08:00
hulk 60908a7709 CMake: support older ARM linker versions 2026-10-12 11:39:42 +08:00
hulk c9aa41685f Heater: segmented PID (full-power / slow-band / overtemp-cutoff) + 2s/5pct PWM 2026-10-10 14:49:24 +08:00
hulk 974a077e29 Heater: force output off in overtemp region (err<0); Kp 4->10 2026-10-10 14:14:09 +08:00
hulk 12ba9de05f Heater: setpoint ramping to suppress thermal-lag overshoot 2026-10-10 13:59:43 +08:00
27 changed files with 2638 additions and 345 deletions
+300 -5
View File
@@ -79,6 +79,188 @@
- 实测 30℃ 闭环(NTC 贴 PTC 鳍片):30s 爬升、超调 1.4℃(7%)、60s 收敛中 - 实测 30℃ 闭环(NTC 贴 PTC 鳍片):30s 爬升、超调 1.4℃(7%)、60s 收敛中
- M3/M4 已从软件桩切到 heat 模块(协议帧不变) - M3/M4 已从软件桩切到 heat 模块(协议帧不变)
- 协议要点:全幂等(ack 丢可重发)、响应超时建议 300ms×3 次、温度一律 ASCII - 协议要点:全幂等(ack 丢可重发)、响应超时建议 300ms×3 次、温度一律 ASCII
- **PID 抗积分饱和**(5b24b95):条件积分(back-calculation lite)抑制输出限幅
期间的积分累积。
- **PID 全面重写(2026-10-15,待真机验证)**——真机 30℃ 阶跃 +2.4℃ 过冲、
30~60s 周期衰减震荡(GUI 截图)驱动,三个根因全部修复:
① `s_prev_err` 在 err>=0 路径从不更新(c9aa416 early-return 重写时把末尾的
`s_prev_err = err` 弄丢)→ D 项退化为 +Kd*err 正向比例,"微分阻尼"从未生效,
且把抗饱和判据钉在饱和态冻死积分(仿真中大阶跃贴温和带边缘极限环、到不了
目标);改**微分基于温度变化率(D on measurement,-Kd*ΔT/拍)**,`s_prev_temp`
全路径刷新,u 钳 [0,100] 防负值量化回绕;
② 积分无界(err<0 每拍 -|err| 无下限)→ 过冲/下调目标后积分深负,温和带内
u=min(25,u_pd)+Ki*I 永远 <=0 → 加热长时间锁死、跌出温和带才全功率(极限环);
改**积分钳 [0, HEATER_PID_I_MAX=2000]**(Ki*I_MAX=100%);
③ 12ba9de 的目标斜坡在 c9aa416 被整段删除 → 恢复 `HEATER_RAMP_CPS 0.8℃/s`
+ 使能时从当前温度起坡(`s_ramp_target`,升/降同限速);
另修三项:④ 抗饱和判据改看**最终输出** u(原先看温和带重算前的 u);⑤ PWM
相位**仅升档复位**(原先降档也复位,降占空反而补发一整段 ON 脉冲,系统性
多给热);⑥ NTC 故障 **3 拍(1.5s)去抖确认**——真机曲线两个孤立 OPEN 毛刺
每次把 PID 状态整个清零也是震荡推手(毛刺拍保持输出不动;注意毛刺根因在
采样/硬件侧仍待查,GUI 按原始采样报故障不变)。
离线仿真(`sim_pid_check.py`:一阶热容+传感器滞后模型、2s/5% 慢 PWM 执行器、
旧/新同模型对比):30℃ 阶跃到达后波动 ±0.4℃→+0.04℃;50℃ 阶跃旧版极限环
到不了目标 vs 新版 94s 到达零过冲;50→40 下调 44s 恢复、波动 <±0.6℃。
固件版本升 **V0.1.0**(M999 报 `v41.0.1.0`),已烧录上板(OpenOCD GD-LINK,
dump_image 读回与 elf 逐字节比对一致;注意该 xpack OpenOCD 的
flash write/verify 回显日志会丢失,核验用 dump_image+文件比对)。
设计说明:保持**位置式** PID——执行器是绝对占空设定,分段强制关断/使能切换/
故障复位在位置式下最干净;增量式的抗饱和优势已由积分钳位+条件积分覆盖。
- **PID V2 抗震荡迭代(2026-10-15,V0.1.1 已烧录待真机确认)**——V0.1.0 真机
复测:过冲/OPEN 毛刺问题已消(峰 +0.4~0.5、无故障标记),但目标附近仍有
**±0.7~1.0℃/15~20s 三角波极限环**(50℃ 目标实测截图)。根因:err<0 硬切
0% + err>=0 仅 Kp*err,稳态维持功率(~10% 级)全靠 Ki=0.05 慢填(要几分钟)
→ 目标附近等效"P+上侧死区"继电器,叠加热滞后即极限环。V2 改法(`heat.c`
pid_compute 重写核心):
① **连续 PID 覆盖全误差区间**——去掉 err<0 硬切,过目标后 P 负向顶、积分
正向维持,输出钳 [0,100] 天然处理"只能加热";
② **Ki 0.05→0.25**(稳态功率 ~1 分钟内建立;扫参显示 0.5 在传感器滞后 8s
工况下积分环失稳 ±0.9、1.0 更差,0.25 全工况稳定);
③ **积分吸收限窄带**:仅 |err|<HEATER_PID_INTEG_ZONE_C(2℃) 且 P/D 未被
钳(u_pd_raw∈(0,cap))时吸收——爬升/温和带钳位段不积分,防瞬态 windup
(Ki 提快后若爬升段照吸,30℃ 阶跃仿真冲到 36℃);err<0 且 u>0 照常有界
回退;I_MAX 同步 2000→400(Ki*I_MAX=100% 不变)。
仿真(`sim_pid_check.py`,V1/V3 对比 + 参数扫描 `python sim_pid_check.py
sweep`,基准/恶劣两工况):恶劣工况 50℃ 阶跃到达后 [49.70,50.11](过冲
+0.11℃)、末端残余 ±0.02℃;30℃ 阶跃末端 ±0.01~0.02℃;50→40 下调 66s
恢复 ±0.02℃。已烧录 V0.1.1(M999 报 `v41.0.1.1`,dump 比对核验一致)。
- **PID V3 修"突然飙升 4℃"(2026-10-15,V0.1.2)——DAPLink 旁路抓证定位**:
V0.1.1 真机稳态后温度以 0.12℃/s 平滑飙升 +2~4℃ 再回落。定位手段(可复用):
① COM5 脚本探针(`probe_once.py`/`probe_state.py`)确认目标 50/加热 ON,
并现场抓到 **14 帧连续 OPEN(~1.4s)**——NTC 间歇开路真实存在(硬件侧
待修);② **OpenOCD `mrw` 旁路观测**(`observe2.tcl`+`obs_decode.py`,
每 500ms 不 halt 读 RAM:duty/integral/ramp/target/temp——注意该 xpack
OpenOCD 的 echo/mdw 控制台回显会丢失,须用 TCL 落文件)。全程轨迹证实
**无故障参与的 90~120s 自持极限环**:积分 0↔65 打摆(真机 50℃ 维持功率
~10-15%,即 I≈40~60 才是正确稳态),机理=**err<0 时 P 被钳 0、输出只剩
Ki·I 恒定值**——过线后输出不随超温幅度收敛→温度必然冲过头→过冲把积分
排空→排空后 sag→重建再过冲。NTC 毛刺清零积分只是点火器。V0.1.2 修复:
① **err<0 时 P 负项+D 直接抵扣积分后钳 0**(输出随超温幅度立即收敛,
积分按剩余需要维持→可收敛到稳态不再打摆);② **NTC 短故障(≤3s 且
|ΔT|<2℃)无扰还原积分**(确认沿暂存 `s_fault_stash_I/T`,防毛刺清零
引发 sag-重建-过冲链);③ I_MAX 400→200(Ki·I_MAX=50%,120℃ 模具维持
余量足够)。判定测试基建:`test_run.py`(固定序列 M999→M2S500→M4S50→
M3S1→N 秒→收尾;**预定义判据 J1 上报率/J2 稳态均值±0.5/J3 稳态极值±1.0/
J4 全程 max≤52/J5 无故障**,>54℃ 自动熔断 M3S0——开发/测试分工,脚本
自动判定避免自判自;注意 FF485 frame.payload 已是 str)。
- **PID V3 判定迭代与收敛(V0.1.2→V0.1.3,2026-10-15,900s 判定测试)**:
- V0.1.2(P-veto + 故障无扰还原 + I_MAX 200)判定:J1/J2/J5 PASS,但
**J3 FAIL(稳态窗 [46.6,52.1])、J4 FAIL(max 52.5)**——P-veto 消灭了
积分排空式打摆(观测器证实收敛末段积分稳在 ~48=真实维持功率、温度
±0.2),但双热容被控对象(鳍片+本体,NTC 只见鳍片)每次顶/谷都给
对方"预存"动量,15 分钟才收敛;且斜坡 0.8℃/s 远快于对象,爬升段误差
长期 ~2℃ 把积分提前充到 30~50,首冲 +2.5℃。
- V0.1.3:**HEATER_RAMP_CPS 0.8→0.25**(爬升误差贴地、积分只在近目标
低功率段建立)+ **HEATER_SLOWBAND_DUTY 25→15**(近目标动作放柔,
少给鳍片预存过热)。41℃ 冷启判定:**J1~J5 全 PASS**——到达 69s、
首冲 +1.20℃、全程 max 51.2、稳态窗 [49.0,50.9]、均值 49.95、
1795 帧 0 故障。观测器同步确认:远场(err>7)才见 75% 满功率,
温和带内 15% 缓爬,50.06℃ 到达时积分 35.9/占空 5%。
- 测试基建沉淀:`test_run.py`(判定+熔断)、`observe2.tcl`+`obs_decode.py`
(DAPLink 旁路,**注意符号地址随 bss 布局变,重编译后须 nm 重取**;
观测文件被占用时追加不清空)、`sim_pid_check.py`(含 V1/V3/V4 对照、
毛刺/噪声注入、扫参)。CMD 坑:`if exist X del X && ...` 文件不存在时
整链跳过;多行 `python -c` 偶发被吞——写文件执行。
- **遗留(硬件侧)**:NTC 间歇开路(现场抓到 14 帧连续 OPEN ~1.4s,
短故障无扰还原已缓解控制影响,但根因在采样/接触)——查 NTC 焊接/
压接/走线,必要时 ntc.c 采样侧加防护。独立代码审查 agent 未在时限内
返回,本轮定位以仪器化数据(COM5 帧流 + DAPLink 内存轨迹)为准。
- **PID 调试全程速览(2026-10-15,V0.1.0→V0.1.14,判定测试自动出结论)**:
| 版本 | 关键改动 | 判定/实测结果 | 结论 |
|---|---|---|---|
| V0.1.0 | 全面重写:D-on-measurement+积分钳位+斜坡恢复+故障去抖 | 过冲 +2.4→+0.4 | 旧版 D 项失效/积分无界/斜坡缺失三根因修复 |
| V0.1.1 | Ki 0.05→0.25+窄带吸收 | ±1℃ 三角波极限环 | err<0 硬切=继电器死区 |
| V0.1.2 | P 参与过线区+故障无扰还原+I_MAX200 | J3/J4 FAIL | P-veto 生效但收敛 15min |
| V0.1.3 | 斜坡 0.25C/s+温和带 15% | J1~J5 全 PASS(旧判据) | 稳态 [49.0,50.9] 不满足 ±0.5 |
| V0.1.4 | 占空抖动 1.25% 分辨率 | p2p=1.8 FAIL | 量化非主因 |
| V0.1.5 | Kp5/Kd60/Ki0.15 连续 PID | p2p=4.2 FAIL | ±2C/90s 慢摆,调参到头 |
| V0.1.6 | 分区 bang-bang+慢积分 | 卡 48.6C FAIL | 带外平衡点陷阱 |
| V0.1.7 | 带外也吸收 | p2p=4.1 FAIL | 吸收/回退不对称→I 上漂 90 |
| V0.1.8 | 对称慢速积分 | p2p=4.0 FAIL | 积分稳了;秒级读数坏值穿透 |
| V0.1.9 | 斜率限幅+带内退闸 | 无法判定 | NTC 持续开路(后重焊恢复) |
| V0.1.10 | 去上限+RAMP2.0+温和带25% | p2p=2.7 FAIL | 快斜坡生效;坏值仍是主力 |
| V0.1.11 | NTC 5 点滑动中值 | **p2p=1.30** | 中值滤波立竿见影 |
| V0.1.12 | 近场 ASSIST 减速曲线 | 实时 p2p=**0.31** | 最后 2C 推力真空修复;判定窗被传感器事件污染 |
| V0.1.13 | 大偏差冻结(>1.5C) | p2p=1.60 | 塌陷深谷消除(47.1→49.0) |
| V0.1.14 | 对称积分加倍 0.30 | p2p=1.80 | 与 13 同水平=硬件平台期 |
**最终状态(V0.1.14 在板)**:到达 26s(30→50C,提速 9 倍);控制环本体
(无事件段)p2p 0.3~0.9C 达标;判定窗残余 1.6~1.8C 全部来自 NTC 焊接点
秒级接触不良(-2.5C/4.5s 塌陷、顶格 52.2C 卡死 10s,1.5C 以内坏值仍会穿透
三重滤波)。**下一步:彻底重焊/更换 NTC(根治手段),固件三重防护
(中值+斜率限幅+大偏差冻结)已就位,重焊后 `python test_run.py 1200` 一键
重判(J3 即 ±0.5 规格)。测试环境 55C 上限已按用户要求移除(真机靠物理
温度开关)。**
- **±0.5℃ 波动压榨迭代(2026-10-15,V0.1.3→V0.1.6,用户规格 ±0.5C,
判据 J3 收紧为稳态窗 [目标±0.5])**——V0.1.3 稳态窗 [49.0,50.9](压着
旧判据 ±1.0 过)不可接受,连续三轮仪器化迭代:
- **V0.1.4 占空抖动(Bresenham)**:载波 2s=4 拍分摊档位余数,平均分辨率
5%→1.25%(真机维持功率 ~10-12% 恰卡在 10/15 档间,固定取整迫使
0↔15% 满幅切换)。观测器证实抖动生效(duty 5/10/15/20/25 细粒度),
**但极限环未消(J3 p2p=1.8 FAIL)→ 量化不是主因**。
- **V0.1.5 降增益强阻尼(Kp 10→5 / Kd 30→60 / Ki 0.25→0.15 / 窄带 2→1)**:
**J3 p2p=4.2 FAIL**——变成 ±2C/90s 慢摆。观测器抓到机理:带内半周期
净吸积分 +18(err 0.5~1.0 持续 30s),过线后 P-veto 每拍泄 2.8 快速
排空 → 维持功率记忆反复丢失 → 谷底深潜 48.6。**连续 PID 在本对象
(双热容+单向执行器+0.1C 读数量化)上调参到头**;Kd=60 还放大 NTC
0.1C 量化跳变成 ±6% duty 抖动。
- **V0.1.6 分区 bang-bang + 慢积分学习(结构性换型)**:作用分三区——
带内 |err|<HEATER_PID_BAND_C(0.4C):P/D 退出,u=Ki*I 恒功率,积分慢速
学习维持功率(I+=err*0.3/拍);带外下方:P 拉回(温和带钳位不变),
积分不吸收;带外上方:关断 + I 固定 0.3/拍限速回退(保住维持功率
记忆)。波动幅度被物理限制在带宽+滑行量,I 学准后驻留带内。
Kd 回 30(60 的噪声放大不可用)、I_MAX 回 400(Ki*I_MAX=60%)。
- **V0.1.6 判定 FAIL(带外平衡点陷阱)**:观测器抓到温度卡 48.6C——P 输出
恰好平衡散热、err 停带外,积分只在带内学习永远没机会介入。V0.1.7 修复
(带外下方 P 未被钳时同样慢速吸收):前半程良好(49.8/integ 37 稳),但
**吸收(0.3/拍)与回退(0.3/拍固定)不对称 → I 上漂到 90 持续超供**,后半程
大摆(J3 p2p=4.1 FAIL)。V0.1.8 改全区域对称慢速(GAIN=BLEED=0.15):
积分不漂了(35~42 窄幅)、带内占有率 44%,**但 J3 仍 FAIL(p2p=4.0)**——
csv 分析抓到 **-1.2C/0.5s 读数突跳 + 5s 后 +1.8C 回弹**(非故障级 NTC
毛刺,真实热速率 <=0.2C/拍),毛刺触发 25% 猛拉 ~10s 直接制造 +2C 过冲。
- **V0.1.9(待判定)**:① NTC 读数斜率限幅(HEATER_NTC_SLEW_C=0.5C/拍,
超限沿限幅值过渡、连续 2 拍放行——毛刺对环路的冲击源头掐断);② 带内
功率线性退闸(下缘 100% 维持量 -> 上缘 0%,过线前功率已趋零,蓄热滑行
收窄)。已烧录 MATCH。
- **NTC 硬件恶化为持续开路(阻塞判定)**:V0.1.9 判定测试全程 M1/M2 报
OPEN(帧流正常、命令全 ok),加热安全关断(故障路径实测有效)。用户
重焊后恢复(30.1C 稳定读数),进入 V0.1.10~V0.1.14 快速迭代:
- **V0.1.10 去上限 + 提速**:测试脚本去掉 54C 自动熔断/J4 上限判据(用户
明确:真机靠物理温度开关,55C 只是测试环境约束);RAMP 0.25→2.0(实测
0.25 时斜坡本身就是瓶颈,9C 走 36s);温和带 15%→25%。判定:reach 49s
但 J3 p2p=2.7 FAIL、首冲 +3.3。
- **V0.1.11 NTC 5 点滑动中值**(ntc.c 采样侧):V0.1.10 稳态摆的根源确认为
秒级读数坏值穿透斜率限幅(中值滤波只挡 <250ms 毛刺)。效果显著:
稳态 p2p 4.0→**1.30**([49.40,50.70])。温和带 7→3C、25→35%。
- **V0.1.12 近场 ASSIST 减速曲线**:分段速率分析钉死最后 2C 爬 141s 的瓶颈
=近场推力真空(P 随 err 缩小消失、I 未成熟)。近场扩展区 [-0.3,3.0)
合并退闸+缓爬:u = Ki*I + ASSIST 退闸曲线 + Kd,上限 NEAR_MAX_DUTY 50%。
实时观测 p2p **0.31C**(达标水平);但判定窗两极值(47.1 塌陷 + 52.2
顶格卡死)均为传感器事件:**-2.5C/4.5s 塌陷与顶格 10s,焊接点仍不稳**。
- **V0.1.13 大偏差冻结**:滤波值与原始读数偏差 >1.5C(HEATER_NTC_JUMP_C)
冻结不跟随,回弹自动恢复(真实冷却 ~0.05C/拍永不误触发)。判定 p2p
1.60([49.00,50.60])——塌陷深谷消除。reach 32s(提速 7 倍)。
- **V0.1.14 对称积分加倍**(GAIN=BLEED 0.15→0.30,I 充满 400s→200s,修复
判定窗内驻留点偏低):p2p 1.80([49.20,51.00]),与 V0.1.13 同水平
(差异在传感器事件噪声内)——**到达当前硬件条件下的平台期**。
- **当前结论**:控制环本体(无事件段)p2p≈0.3~0.9 达标;残余 1.6~1.8 全部
来自 NTC 焊接点秒级接触不良(塌陷/顶格事件,1.5C 以内的坏值仍会穿透
防护)。**下一步是彻底重焊/更换 NTC 及其引线**,固件侧三重防护
(中值+斜率限幅+大偏差冻结)已就位。到达速度 26s(30→50C,提速 9 倍)。
测试/观测基建:test_run.py(无熔断版,J1/J2/J3±0.5/J4 无故障自动判定)、
observe2.tcl+obs_last.py(**每次重编译必须 nm 重取符号地址**)、
seg_rate.py(分段爬升速率)、log_tail.py(实时帧解析)。
中间数据(obs_samples.txt/test_data.csv/test_events.log)已清理,
由测试/观测脚本重新生成。
- 方法论沉淀:单状态热模型仿真始终未复现真机极限环(双热容建不进单
状态),本轮所有定位直接以 DAPLink 观测器 + COM5 判定脚本为准;
观测符号地址 V0.1.4 起稳定(s_duty=0x20000459 / s_integral=0x20000454 /
s_ramp_target=0x2000045c / s_target=0x20000460 / s_enable=0x20000464)。
- **50℃→40℃ 阶跃验证脚本**(`temp_test_com.py`,工作区修改未提交):M2S500
全曲线采样,输出超调/settle 时间/稳态误差/降温段统计(结果以实跑为准)。
- **电源保持 PA11 实装**(`pwr_hold.h/pwr_hold.c`):上电最先拉高 PA11 维持
PMOS 自锁供电(先于一切外设初始化);`pwr_hold_off()` 预留软关机(置低即失电)。
- **专用 GUI 上位机 v0.1.0 完成**(`GUI/`,参照 DLPilot 分层:协议模块纯标准库 + - **专用 GUI 上位机 v0.1.0 完成**(`GUI/`,参照 DLPilot 分层:协议模块纯标准库 +
PySide6-Fluent 界面 + 自检 + PyInstaller): PySide6-Fluent 界面 + 自检 + PyInstaller):
- 文件角色:`filmforge_rs485.py`(帧/解析/串口线程 FF485)、`app_fluent.py` - 文件角色:`filmforge_rs485.py`(帧/解析/串口线程 FF485)、`app_fluent.py`
@@ -116,11 +298,124 @@
- [ ] **适配器更换**:换可靠的 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 塌陷、顶格卡死、曾持续开路),
- [x] **专用 GUI 上位机 v0.1.0**:`GUI/` 已完成并通过 COM5 真机全链路验证 是 ±0.5C 规格最后的拦路虎;固件三重防护已就位(V0.1.14)。
(协议按 `protocols.md`;遗留见其 README"已知事项":应用图标、加热硬件 - [ ] **±0.5℃ 规格复判**:NTC 重焊后 `python test_run.py 1200`(J3=±0.5);
接入后的闭环联调、固件 M3/M4 硬件执行体实现)。 当前稳态控制环本体 p2p 0.3~0.9C 已达标。真实工况温度(80~120℃)下
需复测整定(Kp=8/Ki=0.15/Kd=30、ASSIST 35%、I_MAX400,board_config.h)。
- [x] **专用 GUI 上位机 v0.2.0**:v0.1.0 已完成 COM5 真机全链路验证;v0.2.0 与
实装固件对齐——去除"固件预留/仅软件状态"旧表述(协议模块预设说明、温控
卡片标题/备注、开启加热确认弹窗改为真实控温警示、ack 提示),GUI README
与 publish_gitea 发布说明模板同步,protocols.md M3 表项去"预留";
监控逻辑简化:温度监控统一走 M2 周期上报(移除"M1 自动轮询"双通道及
poll 配置项,M1 仅手动单点查询),目标温度滑条范围 0~200→0~120(协议
上限 200 仍可手输)。**v0.2.1 真机回归修复**(COM5 实测暴露):①M4 查询
应答与周期上报帧同为 ASCII 浮点被误占(目标 chip 显示成实测值)→ 查目标
改应答驱动"暂停上报 M2S0→M4→自动恢复 M2S<ms>"链;②设定 ok 延迟 >1.2s
超时清队后状态 chip 与设备脱节(实测 M2S100 ok 迟到、chip 停 OFF)→ 8s
内晚到 ok 按发送时计划补偿应用;③断开按钮偶发不切换、断开后日志仍刷 →
连接按钮改意图驱动(点击即翻转、2s 未建立判丢失回退),已连接/已断开/
丢失全部入日志,连接同步序列每步发送前确认在线(修复序列整组被
"未连接串口"丢弃)。selftest 全过(新增 8 项回归用例);已打包冒烟并发布
Gitea Release `FilmForge-GUI-v0.2.1`(无应用图标——`logos/filmforge.ico`
仍缺位)。
- **GUI v0.2.2~v0.2.13(递进修复/新功能,Gitea 发布待确认)**:
- v0.2.2:串口线程 `read(256)` 在持续帧流下攒满 256 字节才返回(win32 字节
间隔超时因帧间隔更短永不触发,100ms 上报 ≈2.5s 一批),曲线/命令/响应全部
3 秒一卡、一股脑一大坨——真机日志确认每簇 ≈25 帧、间隔恒 3s。已改
`read(in_waiting or 1)`,真机复测曲线实时、命令即发即回。
- v0.2.3:发送入口幂等去重——同命令未决时忽略重复发送(真机连点"停止"12 次
挤爆 8 条 pending 队列、"丢弃积压命令"刷屏),状态栏提示"应答中"。
- v0.2.4:目标阶梯线渲染 bug(v0.1.0 起潜伏)——`_target_points` 对最后一次
目标变更只画终点,从旧值斜拉直线到图表右缘(实测 57→41 被画成 2 分钟斜坡,
截图确认);改为纯阶梯(变更时刻垂直落变+水平延伸),数据本身无误。
- v0.2.5:排版调整——串口连接卡片从右列移到左列顶部,左列图表卡/右列日志卡
弹性拉伸填充,两列底边对齐。
- v0.2.6:对齐修复——双列改放同一滚动容器(整页统一滚动):原先两列各自包
QScrollArea 独立滚动,窗口化下底边错位(全屏正常);现两列高度锁定一致,
不足一屏各自拉伸到底、超出一屏整页滚动。
- v0.2.7:图标接入(logos/ 下 ico/png/svg 已就位)——exe 嵌入图标
(build_exe.py --icon,此前缺文件自动跳过)、窗口标题栏/任务栏/弹窗图标
(app+window 两级 setWindowIcon,_icon_path() 兼容源码态与 _MEIPASS 打包态)、
Windows 任务栏独立标识 AppUserModelID "hulk.FilmForgeGUI"。README"无应用
图标"遗留项关闭。
- v0.2.8:**慢性卡顿修复**——`_log()` 手工 cursor 裁剪(`cursor.Start`/
`BlockUnderCursor` 实例级枚举访问)在 PySide6 6.11 上必然抛 AttributeError,
日志超 500 行(100ms 上报 ≈50s)后每个 100ms tick 异常中止 `_tick` 后半段:
应答超时清理失效(pending 永不清 → "停止"点击被幂等去重永久拦截,实拍状态栏
"M2S0 应答中,已忽略重复发送")、RX 队列积压 ratchet(帧处理滞后 54ms→均值
1037ms/峰值 2701ms,日志/chip/曲线显示滞后数秒,"停止 5s 后才见命令发出"实为
日志显示积压,TX 本身 5~166ms 即出)、日志无限增长、CPU +30%。改原生
`setMaximumBlockCount(500)` 环形裁剪;selftest 新增"500 行封顶/封顶后可追加/
最新行在末尾"3 条回归。离屏 A/B 实测(GUI/_diag_latency_buggy.py,假固件
100ms 上报+定时点击):buggy 组 5.5 分钟异常 2241 次、修复组全程漂移 ≤15ms/
零异常/零积压。v0.2.7 exe(2026-10-10 打包)内嵌同版本 PySide6 6.11.2,
同带此 bug,需重新打包生效。
- v0.2.9:**整体审查修复 5 项**——①CSV 写失败(磁盘满/记录盘被拔)自动停记录
并提示,不再沿 _tick 帧处理路径抛 OSError 中止主循环(与 v0.2.8 日志裁剪同款
失效管道);②故障标记 faults 封顶 1000 条(曾是唯一无上限缓冲:NTC 抖动时
10 条/s 无限增长,且 _draw_faults 每 paint 全量扫描);③串口写超时单独捕获
SerialTimeoutException——只丢该条命令、链路保留,不再按 OSError 当"设备拔出"
关端口断链(适配器停滞曾迫使手动重连);④日志自动滚动仅在贴底时跟随(上报
运行时可上翻历史,不再被拽回底部);⑤冻结视图切片右缘同样二分(暂停时长不再
增加 render 成本)。selftest 新增 7 项回归(封顶/滚动跟随/CSV 失败自停/冻结
切片)共 59 项全过。整体审查报告(2026-10-12)其余 P3 遗留:selftest 未覆盖
_tick 主循环(超时清理/丢失判定)、"全部"窗口全量切片有 MAX_SAMPLES 兜底、
_last_set 单槽位罕见误补偿(8s 自愈)、build_exe remove_stale 无提示强杀
运行实例、M888 后固定 2s 重查可能早于固件就绪、_danger_reset 把去重忽略记成
"已下发"。协议建议的 300ms×3 自动重发维持不做(设计决策,用户拍板)。
- v0.2.10:**曲线 Y 轴手动量程 + "暂无数据"提示不消失修复**——①"实时温度曲线"
卡新增 Y 轴行:下限/上限输入框 + 自动勾选钮(默认勾选=随可见数据自适应;
取消勾选=按输入框固定量程,非法输入回退最近一次自适应范围并经状态栏提示;
首次切手动按当前自适应范围预填平滑接管;固定量程下曲线越界 setClipRect
裁剪回绘图区、当前值标记超量程不画,实时读数卡仍可见)。模式+量程随
`filmforge_gui.json` 持久化(y_axis 键,存值非法一律回自动)。
②提示文字不消失根因(用户标注截图):`set_source` 仅在构建期调用一次且在
那里缓存 `_has_data` 布尔——数据经同一列表引用追加后该布尔永不刷新 → 改为
paintEvent 实时判定(图表本就持有引用,无需其他调用点适应)。selftest 新增
15 项回归(切换往返/兜底预填/无效回退/量程落地/越界条带像素=0/新图数据到位
提示像素=0;断言须取当前主题曲线色、上条带避开 y=9 行图例色块),总数 74
全过;离屏整窗预览目检(手动/自动对照)布局正常。
- v0.2.11:**Y 轴量程控制改右缘滑条(用户要求放右侧、滑条形式)**——移除
v0.2.10 的下限/上限输入框行,"Y轴自动/手动"勾选钮移到暂停/清空行右端;
TemperatureChart 右缘新增滑条栏(GUTTER_W 18px:轨道+量程带+双把手):
上把手=上限、下把手=下限、把手中带拖拽=整段平移(跨度保持)、点击轨道
空白把对应边界直接设到该处;把手在**参考域**(全量数据范围 ±15%,`_y_ref`)
上映射像素,auto 态把手半透明、首次任意拖拽即切手动(`yManualEngaged`
同步按钮态+配置),松手量化 0.1℃ 经 `yRangeChanged` 记设置(最小间距
0.5℃),按钮一键回自动;越界裁剪沿 v0.2.10 不变。坑两条(勿重蹈):
①拖拽中的界值必须取**实时显示范围**而非 `_cached_layout` 绘制缓存——
连续两次拖拽之间没有重绘,缓存是陈旧范围(实测 pan 把量程整段拖跳);
②selftest 固件用真实 `filmforge_gui.json` 时"初始自动"断言会被用户存的
手动模式击穿——改指向临时目录设置文件彻底隔离。
selftest 重写第 8 段共 81 项全过(把手命中/拖拽切手动/量化/最小间距/
平移保跨度/裁剪条带/提示消失);离屏手动/自动滑条目检通过。
- v0.2.12:**滑条域定值 -20~120℃ + 标题行并排(用户截图标注 3 处)**——
①滑条参考域从"全量数据 ±15%"改为**固定 -20~120℃**(Y_RAIL_LO/HI 宏):
把手像素位置直接对应 ℃ 读数,空数据/冷机不再出现 -5.0 这类随数据漂移的
误导把手位;命中/拖拽/钳位全部按此域,140℃ 域上 1px≈0.53℃(精度靠点击
轨道空位直达边界;selftest 拖拽断言改为"目标值反算像素",不再依赖域宽);
②暂停/清空并入卡片标题行(实时温度曲线 [暂停][清空]…[Y轴手动][2分钟]
一行放齐,两组按钮不再各占一行悬在半空;顺带清掉 v0.2.11 重构残留的
孤儿按钮组代码;此卡不走 self._card 通用头,内联构建支持多 trailing);
③实测/目标图例移到绘图区左上(右侧被滑条把手/值标签占用)。selftest
81 项全过;离屏目检(空态/手动拖后)通过。
- v0.2.13:**把手值标签悬停门控(用户标注:常驻会遮挡曲线)**——上下限
两个数值标签不再常驻,仅鼠标悬停在滑条栏(或拖拽中)才显示
(`_y_hover` 翻转才重绘;leaveEvent 途中拖拽不退出悬停态,松手按位置
重算)。selftest 新增 2 项(未悬停标签区像素=0 / 悬停出现标签),共 83
项全过。exe 重新打包见下。
- selftest 全过(v0.2.13 后共 83 项回归);**v0.2.13 exe 已重新打包并冒烟
通过**(release/FilmForge.exe + FilmForge-GUI-v0.2.13-win64.zip,
依次取代 v0.2.10/v0.2.11/v0.2.12 包),Gitea Release 待用户确认后执行。
遗留见其 README"已知事项":USB-RS485 适配器更换。
- [x] **GUI 慢性卡顿修复(根因已定位并修复 v0.2.8,待真机确认)**:
根因/机理/实测数据见上方 v0.2.8 条目(`cursor.Start` 实例级枚举访问在
PySide6 6.11 必抛 → tick 异常中止 → 应答超时清理失效 + RX 积压 ratchet +
日志无限增长)。复现/验证脚本:`GUI/_diag_latency.py`(修复基线)/
`GUI/_diag_latency_buggy.py`(带 bug 对照,假固件 100ms 上报+定时点击)。
遗留:待真机长时间运行确认 + 重新打包 exe。
- [ ] **LED 引脚确认**:FilmForge 自制板 LED 引脚未定(当前沿用模板默认 PA15, - [ ] **LED 引脚确认**:FilmForge 自制板 LED 引脚未定(当前沿用模板默认 PA15,
仅为构建占位),按原理图修改。 仅为构建占位),按原理图修改。
- [ ] **delay_10us 校准**:M4@168MHz 系数为估算值(/300000),需实测修正。 - [ ] **delay_10us 校准**:M4@168MHz 系数为估算值(/300000),需实测修正。
+16 -2
View File
@@ -88,11 +88,25 @@ target_link_options(${PROJECT_NAME} PRIVATE
# 容量宏(代码中如需按容量分支使用 GD32_FLASH_SIZE_KB) # 容量宏(代码中如需按容量分支使用 GD32_FLASH_SIZE_KB)
target_compile_definitions(${PROJECT_NAME} PRIVATE GD32_FLASH_SIZE_KB=${GD32_FLASH_SIZE_KB}) target_compile_definitions(${PROJECT_NAME} PRIVATE GD32_FLASH_SIZE_KB=${GD32_FLASH_SIZE_KB})
# 生成 map 文件等 # 生成 map 文件等。`--no-warn-rwx-segments` 仅用于抑制新版本
# binutils 的 RWX 段警告,旧版(例如 xPack GCC 11.3 搭配 binutils 2.38)
# 不认识该选项,因此先探测链接器能力,避免因工具链版本差异导致链接失败。
target_link_options(${PROJECT_NAME} PRIVATE target_link_options(${PROJECT_NAME} PRIVATE
-Wl,-Map=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Application.map -Wl,-Map=${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/Application.map
-Wl,--no-warn-rwx-segments
) )
execute_process(
COMMAND "${CMAKE_LINKER}" --help
OUTPUT_VARIABLE FILMFORGE_LINKER_HELP
ERROR_VARIABLE FILMFORGE_LINKER_HELP_ERROR
RESULT_VARIABLE FILMFORGE_LINKER_HELP_RESULT
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(FILMFORGE_LINKER_HELP_RESULT EQUAL 0 AND
FILMFORGE_LINKER_HELP MATCHES "no-warn-rwx-segments")
target_link_options(${PROJECT_NAME} PRIVATE -Wl,--no-warn-rwx-segments)
else()
message(STATUS "Linker does not support --no-warn-rwx-segments; skipping it")
endif()
# 链接SDK库 # 链接SDK库
target_link_libraries(${PROJECT_NAME} PRIVATE CMSIS) target_link_libraries(${PROJECT_NAME} PRIVATE CMSIS)
+58 -14
View File
@@ -8,11 +8,18 @@ FilmForge 热压模具温控板(GD32F405, RS485 数据口)的桌面上位机
- **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 + - **实时温度曲线**(自绘 QPainter 控件,无第三方图表依赖):实测温度折线 +
目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记; 目标温度橙色阶梯线同图对照(闭环监视),NTC 开路/短路处曲线断开并打故障标记;
时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均)。 时间窗 2/5/15/60 分钟/全部,暂停/恢复,窗口统计(最小/最大/平均);
- **温度数据双通道**:固件 M2 周期上报帧直接入曲线;未开启上报时可由上位机按 Y 轴(温度轴)量程可选**自动**(随可见数据自适应)或**手动**——直接拖动
500ms/1s/2s 自动轮询 M1 补点(两条路读同一全局最新温度)。 图表右缘滑条(固定参考域 -20~120℃,把手位置=℃ 读数):上把手=上限、
下把手=下限、拖动把手中段上下=整段平移并把跨度保持不变、点击轨道空白直接
把对应边界设到该处;首次任意拖拽自动切入手动("Y轴自动/手动"按钮同步),
把手数值标签仅悬停/拖拽时显示(不常驻遮挡曲线),曲线越界时裁剪回绘图区;
模式与量程记忆在 `filmforge_gui.json`。
- **温度数据**:温度监控统一走固件 M2 周期上报帧(M2S<ms> 开启后主动推送入曲线);
M1 仅作手动单点查询(命令区按钮/预设)。
- **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0 - **控制面板**:目标温度 M4S<n>(0~200 整数℃,滑条+输入框)、加热 M3S1/M3S0
(预留功能,开启需确认)、周期上报 M2S<ms>(100~60000,10ms 步进)、预设/自定义命令。 (真实 PID 闭环控温,开启需确认)、周期上报 M2S<ms>(100~60000,10ms 步进)、
预设/自定义命令。
- **状态联动**:连接后自动查询版本(M999)/上报(M2)/加热(M3)/目标(M4)并同步到状态芯片; - **状态联动**:连接后自动查询版本(M999)/上报(M2)/加热(M3)/目标(M4)并同步到状态芯片;
软件复位(M888)/进 OTA(M9999) 二次确认,复位后 2 秒自动重查状态。 软件复位(M888)/进 OTA(M9999) 二次确认,复位后 2 秒自动重查状态。
- **收发日志**:译码 + 原始 hex,500 行上限防卡顿。 - **收发日志**:译码 + 原始 hex,500 行上限防卡顿。
@@ -52,23 +59,60 @@ python publish_gitea.py release/FilmForge-GUI-v<版本>-win64.zip --replace --in
`app_fluent.py`(PySide6-Fluent 呈现层)/ `config.py`(运行目录+设置记忆)/ `app_fluent.py`(PySide6-Fluent 呈现层)/ `config.py`(运行目录+设置记忆)/
`filmforge_gui.py`(入口+CLI)。协议模块不依赖 GUI。 `filmforge_gui.py`(入口+CLI)。协议模块不依赖 GUI。
- **应答派发是 FIFO 队列**(`app_fluent._pending`):半双工上固件严格按收到顺序应答, - **应答派发是 FIFO 队列**(`app_fluent._pending`):半双工上固件严格按收到顺序应答,
连接序列(150ms 间隔连发 4 条)与自动轮询并存,单 pending 槽会被乱序击穿——实测踩坑: 连接序列(150ms 间隔连发)场景下单 pending 槽会被乱序击穿——实测踩坑:
M2 的 OFF 被 M3 槽吃掉、M4 的目标值混进温度曲线。应答形态与队首期望匹配才消费, M2 的 OFF 被 M3 槽吃掉、M4 的目标值混进温度曲线。应答形态与队首期望匹配才消费,
不匹配记杂散,1.2s 超时自清。温度帧二义性(M1/M4/上报同为 ASCII 温度)靠 不匹配记杂散,1.2s 超时自清。
队首期望 + 生命周期窗口拆分(上报开启时收紧到 0.5s)。 - **M4 查询走"暂停上报→查询→恢复"应答驱动链**:M4 应答与周期上报帧同为 ASCII
浮点,仅靠时间窗拆分在 100ms 上报下实测必现误占(目标 chip 显示成实测温度)——
上报开启时查目标自动执行 M2S0(ok)→M4→M2S<ms> 恢复;连接同步序列同样走此路径。
- **晚到 ok 补偿**:USB-RS485 适配器换向延迟实测可 >4s,设定命令的 ok 迟到超过
1.2s 已按超时清队,8s 内的迟到 ok 按发送时记录的计划补偿应用(否则状态 chip
与设备脱节,实测:M2S100 ok 迟到,chip 停在 OFF 而设备在报)。
- **连接按钮按"意图"翻转**:点击立即切换文本,实际开/关由串口线程异步完成,
已连接/已断开/丢失全部写入日志;期望连接但 2s 未建立按丢失处理回退按钮。
连接同步序列每步发送前确认在线(线程异步打开期间早发会被"未连接串口"丢弃)。
- **时间戳在串口线程读取点打**(`FF485.on_frame(fr, t_rx)`),不晚于 GUI 轮询消费—— - **时间戳在串口线程读取点打**(`FF485.on_frame(fr, t_rx)`),不晚于 GUI 轮询消费——
避免重蹈 SerialMate 的时间戳失真问题(见 `E:\Hulk-Coding\HW-Debug-Tools\SerialMate`)。 避免重蹈 SerialMate 的时间戳失真问题(见 `E:\Hulk-Coding\HW-Debug-Tools\SerialMate`)。
- M4 目标应答的路由窗口 = 条目生命周期(1.2s):USB-RS485 适配器换向偶发延迟 - **发送入口幂等去重**:同一命令已有未决应答时忽略重复发送(状态栏提示
实测可超 0.5s;上报开启时周期帧密集,才收紧到 0.5s 新鲜窗口。 "应答中, 已忽略重复发送")——真机实测连点"停止"12 次会把 8 条 pending 队列
挤爆("丢弃积压命令"刷屏);协议全幂等,同命令一条在途足够,超时自清后
自然允许重发。
- **串口读取按到达量收,不攒大块**:固定 `read(256)` 在持续帧流下要攒满 256 字节
才返回(Windows 串口"字节间隔超时"150ms 因帧间隔更短永不触发,100ms 上报时
≈2.5s 一批),表现为曲线/命令全部 3 秒一卡、一股脑一大坨。已改为
`read(in_waiting or 1)`,延迟回到 ≤150ms 量级(v0.2.2,真机实测确认)。
- **日志裁剪走原生 `setMaximumBlockCount(500)`**:手工 `textCursor` 裁剪用了
`cursor.Start`/`cursor.BlockUnderCursor` 实例级枚举访问,PySide6 6.11 已移除
(类上仍在),必然抛 AttributeError——日志超 500 行(100ms 上报 ≈50s)后每个
100ms tick 在 `_log` 处异常中止:`_tick` 后半段(应答超时清理/连接状态机/统计
节流)永不执行(pending 永不清 → "停止"点击被幂等去重永久拦截)、RX 队列积压
ratchet(帧处理滞后涨到 1~2.7s,日志/chip/曲线显示全面滞后)、日志无限增长。
v0.2.8 改原生环形裁剪修复(离屏 A/B 实测:修复后 6 分钟漂移 ≤15ms、零异常、
零积压;selftest 新增 3 条回归用例)。
- **日志自动滚动仅在贴底时跟随**:上报运行时可上翻查看历史(每行强制滚底会抢
滚动条);CSV 写失败(磁盘满/记录盘被拔)自动停止记录并提示——`_csv_write` 在
`_tick` 帧处理路径上,未捕获异常会中止主循环(v0.2.8 日志裁剪同款教训);故障
标记封顶 1000 条;冻结视图切片右缘二分(暂停时长不增加渲染成本);串口写超时
只丢该条命令、链路保留(不按设备拔出断链)。均 v0.2.9。
- Fluent 控件自定义样式必须走 `setCustomStyleSheet`(覆盖 hover/pressed/disabled), - Fluent 控件自定义样式必须走 `setCustomStyleSheet`(覆盖 hover/pressed/disabled),
直接 `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)。
## 已知事项 ## 已知事项
- 当前 USB-RS485 适配器自动换向偶发丢响应(固件侧已立“适配器更换”TODO): - 当前 USB-RS485 适配器自动换向偶发丢响应/大延迟(固件侧已立”适配器更换”TODO):
表现为个别查询 1.2s 超时,队列自动清理后轮询自然重试,曲线无碍。 查询 1.2s 超时提示后重发即可;设定命令的晚到 ok 已自动补偿应用;曲线走上报帧
- 加热(M3)/目标温度(M4)为固件预留:仅软件状态(ack+可回读),硬件执行体接入后 (设备单向推送),不受影响。
协议不变,本工具无需改动即可联调真实控温闭环。 - 加热(M3S1)为真实 PID 闭环:开启后模具真实升温(NTC 故障/过温 150℃ 固件自动
- 打包暂无应用图标(`logos/filmforge.ico` 缺位,build_exe.py 自动跳过)。 断电保护,上电默认关闭);当前占空比无查询命令,从温度曲线间接观察。
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""诊断 v2: 干净基线 — 旁路 app_fluent._log 的坏裁剪(cursor.Start 在 PySide6
6.11.2 上抛 AttributeError), 用 Qt 原生 setMaximumBlockCount 替代, 其余不动源码。
目的: 验证 100ms 持续上报数分钟下, 修复裁剪后 点击->TX 是否仍瞬时、事件循环
是否仍流畅(对照 buggy 版的 tick 中止 + 队列积压)。"""
import ctypes
import gc
import os
import sys
import threading
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import filmforge_rs485 as ff
class FakeSerial:
"""模拟 CH34x + 固件: M2S<ms> 控制周期温度帧, M2S0 回 ok 并停流。"""
def __init__(self, *a, **kw):
self._buf = bytearray()
self._lock = threading.Lock()
self._alive = True
self.write_log = [] # [(time, cmd_bytes)]
self.report_ms = 0
self._next_tx = 0.0
self._th = threading.Thread(target=self._fw, daemon=True)
self._th.start()
FakeSerial.last = self
def _fw(self):
while self._alive:
now = time.time()
if self.report_ms and now >= self._next_tx:
self._push(self._frame("34.6"))
self._next_tx = now + self.report_ms / 1000.0
time.sleep(0.002)
@staticmethod
def _frame(payload, type_code=0xF0):
body = bytes([type_code, len(payload)]) + payload.encode("ascii")
return bytes([0xB5]) + body + bytes([ff.sum_crc(body)])
def _push(self, b):
with self._lock:
self._buf += b
@property
def in_waiting(self):
with self._lock:
return len(self._buf)
def read(self, n):
deadline = time.time() + 0.15
while True:
with self._lock:
if self._buf:
out = bytes(self._buf[:n])
del self._buf[:n]
return out
if time.time() >= deadline:
return b""
time.sleep(0.004)
def write(self, data):
self.write_log.append((time.time(), bytes(data)))
cmd = bytes(data[3:-1]).decode("ascii", "replace").strip()
threading.Timer(0.05, lambda: self._on_cmd(cmd)).start()
return len(data)
def _on_cmd(self, cmd):
if cmd.startswith("M2S"):
try:
ms = int(cmd[3:])
except ValueError:
ms = 0
self.report_ms = ms
self._next_tx = time.time() + ms / 1000.0
self._push(self._frame("ok"))
elif cmd in ("M1", "M2", "M3", "M4", "M999"):
self._push(self._frame({"M1": "34.6", "M2": "OFF", "M3": "OFF",
"M4": "35.0", "M999": "v41.0.0.1"}[cmd]))
else:
self._push(self._frame("ok"))
def close(self):
self._alive = False
try:
self._th.join(timeout=0.5)
except Exception:
pass
ff.serial.Serial = FakeSerial
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
app = QApplication([])
import app_fluent
# ---- 测试侧修复: 旁路坏裁剪(原生上限 500 行, 语义等同原意图) ----
window = app_fluent.App(0)
window.txt_log.setMaximumBlockCount(500) # blockCount 永不 >500, 坏分支不触发
window.show()
fake = None
exception_count = [0]
# ---- 指标 ----
stats = {"paint": [0, 0.0, 0.0], "tick": [0, 0.0, 0.0],
"gc": [0, 0.0, 0.0], "drift": [], "qmax": [0], "click": []}
_orig_paint = app_fluent.TemperatureChart.paintEvent
def timed_paint(self, ev):
t0 = time.perf_counter()
_orig_paint(self, ev)
dt = time.perf_counter() - t0
s = stats["paint"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
app_fluent.TemperatureChart.paintEvent = timed_paint
_orig_tick = app_fluent.App._tick
def timed_tick(self):
t0 = time.perf_counter()
try:
_orig_tick(self)
except Exception as e:
exception_count[0] += 1
if exception_count[0] <= 3:
print("TICK EXCEPTION:", e, flush=True)
dt = time.perf_counter() - t0
s = stats["tick"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
app_fluent.App._tick = timed_tick
_gc_t = [0.0]
def gc_cb(phase, info):
if phase == "start":
_gc_t[0] = time.perf_counter()
else:
dt = time.perf_counter() - _gc_t[0]
s = stats["gc"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
gc.callbacks.append(gc_cb)
probe = {"expect": None}
def probe_fire():
now = time.time()
if probe["expect"] is None:
probe["expect"] = now + 0.02
return
stats["drift"].append((now, now - probe["expect"]))
probe["expect"] += 0.02
if now - probe["expect"] > 0.5:
probe["expect"] = now + 0.02
pt = QTimer(); pt.timeout.connect(probe_fire); pt.start(20)
CLICK_PERIOD = 30
click = {"expect": None, "n": 0}
def do_click_stop():
now = time.time()
drift_ms = (now - click["expect"]) * 1000 if click["expect"] else 0.0
q0 = window._rx_q.qsize()
window._send_set("M2S0", "report", ("OFF", None)) # "停止"按钮真实路径
click["n"] += 1
stats["click"].append(dict(n=click["n"], t=now, drift_ms=drift_ms,
qsize=q0, pending=len(window._pending),
samples=len(window.samples_t),
logblk=window.txt_log.blockCount()))
QTimer.singleShot(3000, do_click_restart)
def do_click_restart():
window._send_set("M2S100", "report", ("ON", 100))
click["expect"] = time.time() + CLICK_PERIOD
QTimer.singleShot(CLICK_PERIOD * 1000, do_click_stop)
def start():
global fake
window.link.open("COMFAKE", 115200)
def wait_conn():
if window.link.is_connected():
fake = FakeSerial.last
click["expect"] = time.time() + 15
QTimer.singleShot(15000, do_click_stop)
print("[t0] connected; stop-clicks every %ds from +15s" % CLICK_PERIOD,
flush=True)
else:
QTimer.singleShot(200, wait_conn)
QTimer.singleShot(200, wait_conn)
QTimer.singleShot(100, start)
t_start = time.time()
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 320.0
INTERVAL = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
def monitor():
while time.time() - t_start < DURATION:
time.sleep(INTERVAL)
el = time.time() - t_start
recent = [d for t, d in stats["drift"] if t >= time.time() - INTERVAL]
rmax = max(recent) * 1000 if recent else 0
stats["qmax"][0] = max(stats["qmax"][0], window._rx_q.qsize())
print(f"[{el:6.1f}s] cpu={time.process_time():7.2f}s "
f"samples={len(window.samples_t):6d} q={window._rx_q.qsize():4d} "
f"(max {stats['qmax'][0]:4d}) logblk={window.txt_log.blockCount():4d} "
f"paint(n={stats['paint'][0]:5d} max={stats['paint'][2]*1000:6.1f}ms) "
f"tick_max={stats['tick'][2]*1000:6.1f}ms "
f"gc_max={stats['gc'][2]*1000:5.1f}ms drift_max={rmax:7.1f}ms "
f"exc={exception_count[0]}", flush=True)
print("==== CLICK 明细 ====", flush=True)
for c in stats["click"]:
tx = next((t for t, d in FakeSerial.last.write_log
if d[3:-1].decode("ascii", "replace") == "M2S0"
and c["t"] <= t <= c["t"] + 2.0), None)
total = (tx - c["t"]) * 1000 if tx else -1
print(f"click#{c['n']} t=+{c['t']-t_start:6.1f}s 调度漂移={c['drift_ms']:8.1f}ms "
f"click->TX={total:7.1f}ms q={c['qsize']:4d} pending={c['pending']} "
f"samples={c['samples']:6d} logblk={c['logblk']}", flush=True)
ds = sorted(d for _t, d in stats["drift"])
if ds:
print(f"drift: n={len(ds)} p50={ds[len(ds)//2]*1000:.1f}ms "
f"p99={ds[int(len(ds)*0.99)]*1000:.1f}ms max={ds[-1]*1000:.1f}ms "
f">500ms次数={sum(1 for d in ds if d>0.5)}", flush=True)
print(f"paint total={stats['paint'][1]:.2f}s max={stats['paint'][2]*1000:.1f}ms | "
f"tick total={stats['tick'][1]:.2f}s max={stats['tick'][2]*1000:.1f}ms | "
f"gc total={stats['gc'][1]:.2f}s max={stats['gc'][2]*1000:.1f}ms "
f"n={stats['gc'][0]} | exceptions={exception_count[0]}", flush=True)
os._exit(0)
threading.Thread(target=monitor, daemon=True).start()
app.exec()
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""诊断 v2b: BUGGY 对照组 — 不加裁剪修复, 复现 PySide6 6.11.2 上
_log 裁剪 AttributeError 触发后的行为: tick 中止 / RX 处理滞后 ratchet /
事件循环漂移 / 点击->TX。指标同 v2, 另增帧处理年龄(frame_age)。"""
import gc
import os
import sys
import threading
import time
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import filmforge_rs485 as ff
class FakeSerial:
def __init__(self, *a, **kw):
self._buf = bytearray()
self._lock = threading.Lock()
self._alive = True
self.write_log = []
self.report_ms = 0
self._next_tx = 0.0
self._th = threading.Thread(target=self._fw, daemon=True)
self._th.start()
FakeSerial.last = self
def _fw(self):
while self._alive:
now = time.time()
if self.report_ms and now >= self._next_tx:
self._push(self._frame("34.6"))
self._next_tx = now + self.report_ms / 1000.0
time.sleep(0.002)
@staticmethod
def _frame(payload, type_code=0xF0):
body = bytes([type_code, len(payload)]) + payload.encode("ascii")
return bytes([0xB5]) + body + bytes([ff.sum_crc(body)])
def _push(self, b):
with self._lock:
self._buf += b
@property
def in_waiting(self):
with self._lock:
return len(self._buf)
def read(self, n):
deadline = time.time() + 0.15
while True:
with self._lock:
if self._buf:
out = bytes(self._buf[:n])
del self._buf[:n]
return out
if time.time() >= deadline:
return b""
time.sleep(0.004)
def write(self, data):
self.write_log.append((time.time(), bytes(data)))
cmd = bytes(data[3:-1]).decode("ascii", "replace").strip()
threading.Timer(0.05, lambda: self._on_cmd(cmd)).start()
return len(data)
def _on_cmd(self, cmd):
if cmd.startswith("M2S"):
try:
ms = int(cmd[3:])
except ValueError:
ms = 0
self.report_ms = ms
self._next_tx = time.time() + ms / 1000.0
self._push(self._frame("ok"))
elif cmd in ("M1", "M2", "M3", "M4", "M999"):
self._push(self._frame({"M1": "34.6", "M2": "OFF", "M3": "OFF",
"M4": "35.0", "M999": "v41.0.0.1"}[cmd]))
else:
self._push(self._frame("ok"))
def close(self):
self._alive = False
try:
self._th.join(timeout=0.5)
except Exception:
pass
ff.serial.Serial = FakeSerial
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
app = QApplication([])
import app_fluent
# 注意: 不打 setMaximumBlockCount 补丁 —— 原样复现 bug
window = app_fluent.App(0)
window.show()
fake = None
exception_count = [0]
stats = {"tick": [0, 0.0, 0.0], "gc": [0, 0.0, 0.0], "drift": [],
"qmax": [0], "click": [], "age": [0, 0.0, 0.0]} # age: n,total,max
_orig_tick = app_fluent.App._tick
def timed_tick(self):
t0 = time.perf_counter()
try:
_orig_tick(self)
except Exception as e:
exception_count[0] += 1
if exception_count[0] <= 3:
print("TICK EXCEPTION:", type(e).__name__, e, flush=True)
dt = time.perf_counter() - t0
s = stats["tick"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
app_fluent.App._tick = timed_tick
_orig_handle = app_fluent.App._handle_frame
def timed_handle(self, fr, t_rx):
_orig_handle(self, fr, t_rx)
age = time.time() - t_rx # 帧从收到到被主线程处理的滞后
s = stats["age"]; s[0] += 1; s[1] += age; s[2] = max(s[2], age)
app_fluent.App._handle_frame = timed_handle
_gc_t = [0.0]
def gc_cb(phase, info):
if phase == "start":
_gc_t[0] = time.perf_counter()
else:
dt = time.perf_counter() - _gc_t[0]
s = stats["gc"]; s[0] += 1; s[1] += dt; s[2] = max(s[2], dt)
gc.callbacks.append(gc_cb)
probe = {"expect": None}
def probe_fire():
now = time.time()
if probe["expect"] is None:
probe["expect"] = now + 0.02
return
stats["drift"].append((now, now - probe["expect"]))
probe["expect"] += 0.02
if now - probe["expect"] > 0.5:
probe["expect"] = now + 0.02
pt = QTimer(); pt.timeout.connect(probe_fire); pt.start(20)
CLICK_PERIOD = 30
click = {"expect": None, "n": 0}
def do_click_stop():
now = time.time()
drift_ms = (now - click["expect"]) * 1000 if click["expect"] else 0.0
q0 = window._rx_q.qsize()
window._send_set("M2S0", "report", ("OFF", None))
click["n"] += 1
stats["click"].append(dict(n=click["n"], t=now, drift_ms=drift_ms,
qsize=q0, pending=len(window._pending),
samples=len(window.samples_t),
logblk=window.txt_log.blockCount()))
QTimer.singleShot(3000, do_click_restart)
def do_click_restart():
window._send_set("M2S100", "report", ("ON", 100))
click["expect"] = time.time() + CLICK_PERIOD
QTimer.singleShot(CLICK_PERIOD * 1000, do_click_stop)
def start():
global fake
window.link.open("COMFAKE", 115200)
def wait_conn():
if window.link.is_connected():
fake = FakeSerial.last
click["expect"] = time.time() + 15
QTimer.singleShot(15000, do_click_stop)
print("[t0] connected; stop-clicks every %ds from +15s (BUGGY mode)"
% CLICK_PERIOD, flush=True)
else:
QTimer.singleShot(200, wait_conn)
QTimer.singleShot(200, wait_conn)
QTimer.singleShot(100, start)
t_start = time.time()
DURATION = float(sys.argv[1]) if len(sys.argv) > 1 else 320.0
INTERVAL = float(sys.argv[2]) if len(sys.argv) > 2 else 40.0
def monitor():
while time.time() - t_start < DURATION:
time.sleep(INTERVAL)
el = time.time() - t_start
recent = [d for t, d in stats["drift"] if t >= time.time() - INTERVAL]
rmax = max(recent) * 1000 if recent else 0
stats["qmax"][0] = max(stats["qmax"][0], window._rx_q.qsize())
n, tot, mx = stats["age"]
print(f"[{el:6.1f}s] cpu={time.process_time():7.2f}s "
f"samples={len(window.samples_t):6d} q={window._rx_q.qsize():5d} "
f"(max {stats['qmax'][0]:5d}) logblk={window.txt_log.blockCount():5d} "
f"frame_age avg={tot/max(n,1)*1000:7.1f}ms max={mx*1000:8.1f}ms "
f"tick_max={stats['tick'][2]*1000:6.1f}ms drift_max={rmax:8.1f}ms "
f"exc={exception_count[0]}", flush=True)
print("==== CLICK 明细 ====", flush=True)
for c in stats["click"]:
tx = next((t for t, d in FakeSerial.last.write_log
if d[3:-1].decode("ascii", "replace") == "M2S0"
and c["t"] <= t <= c["t"] + 2.0), None)
total = (tx - c["t"]) * 1000 if tx else -1
print(f"click#{c['n']} t=+{c['t']-t_start:6.1f}s 调度漂移={c['drift_ms']:8.1f}ms "
f"click->TX={total:7.1f}ms q={c['qsize']:5d} pending={c['pending']} "
f"samples={c['samples']:6d} logblk={c['logblk']}", flush=True)
ds = sorted(d for _t, d in stats["drift"])
if ds:
print(f"drift: n={len(ds)} p50={ds[len(ds)//2]*1000:.1f}ms "
f"p99={ds[int(len(ds)*0.99)]*1000:.1f}ms max={ds[-1]*1000:.1f}ms "
f">500ms次数={sum(1 for d in ds if d>0.5)}", flush=True)
n, tot, mx = stats["age"]
print(f"frame_age avg={tot/max(n,1)*1000:.1f}ms max={mx*1000:.1f}ms | "
f"exceptions={exception_count[0]} | tick n={stats['tick'][0]} "
f"max={stats['tick'][2]*1000:.1f}ms", flush=True)
os._exit(0)
threading.Thread(target=monitor, daemon=True).start()
app.exec()
+596 -133
View File
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -26,18 +26,15 @@ WINDOW_OPTIONS = (('2 分钟', 120), ('5 分钟', 300), ('15 分钟', 900),
DEFAULT_WINDOW_SEC = 300 DEFAULT_WINDOW_SEC = 300
MAX_SAMPLES = 200000 # 曲线内存上限(点), 超出丢最旧; 10Hz 下约 5.5 小时 MAX_SAMPLES = 200000 # 曲线内存上限(点), 超出丢最旧; 10Hz 下约 5.5 小时
# 自动轮询 M1 候选(毫秒): 仅在设备未开启 M2 上报时生效
POLL_OPTIONS = (('关闭', 0), ('500 ms', 500), ('1 s', 1000), ('2 s', 2000))
DEFAULT_POLL_MS = 1000
# 串口记忆(协议固定 115200-8-N-1, 仍允许改以便调试) # 串口记忆(协议固定 115200-8-N-1, 仍允许改以便调试)
SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD, SERIAL_CFG = {'port': '', 'baud': filmforge_rs485.DEFAULT_BAUD,
'bytesize': 8, 'parity': 'N', 'stopbits': 1} 'bytesize': 8, 'parity': 'N', 'stopbits': 1}
REPORT_MS = 500 # 上报间隔输入框默认值 REPORT_MS = 500 # 上报间隔输入框默认值
POLL_MS = DEFAULT_POLL_MS # 自动轮询 M1 周期(0=关)
POLL_ENABLED = True
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():
@@ -65,17 +62,28 @@ def load_settings():
SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1) SERIAL_CFG['stopbits'] = int(ser.get('stopbits') or 1)
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
for key, target in (('report_ms', 'REPORT_MS'), ('poll_ms', 'POLL_MS'), for key, target in (('report_ms', 'REPORT_MS'), ('window_sec', 'WINDOW_SEC')):
('window_sec', 'WINDOW_SEC')):
try: try:
globals()[target] = int(data.get(key)) globals()[target] = int(data.get(key))
except (TypeError, ValueError): except (TypeError, ValueError):
pass pass
global POLL_ENABLED, THEME global THEME
POLL_ENABLED = bool(data.get('poll_enabled', POLL_ENABLED))
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():
@@ -83,10 +91,9 @@ def save_settings():
data = { data = {
'serial': dict(SERIAL_CFG), 'serial': dict(SERIAL_CFG),
'report_ms': REPORT_MS, 'report_ms': REPORT_MS,
'poll_ms': POLL_MS,
'poll_enabled': POLL_ENABLED,
'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:
+19 -6
View File
@@ -12,8 +12,9 @@ FilmForge 热压模具 RS485 温控链路
命令语义速记(全表见 protocols.md): 命令语义速记(全表见 protocols.md):
M1 查温度(OPEN=NTC 开路/SHRT=短路) M2 查上报状态 M2S<ms> 设间隔并开启(100~60000, M1 查温度(OPEN=NTC 开路/SHRT=短路) M2 查上报状态 M2S<ms> 设间隔并开启(100~60000,
10ms 步进) M2S0 停止 M3/M3S1/M3S0 加热(预留, 当前仅软件状态) M4/M4S<n> 目标温度 10ms 步进) M2S0 停止 M3/M3S1/M3S0 加热使能(实装: PB12 慢 PWM + PID 按目标温度
(0~200 整数) M888 软复位 M999 查版本 M9999 进 OTA(均会复位, GUI 需二次确认) 闭环, 开启后真实加热) M4/M4S<n> 目标温度(0~200 整数) M888 软复位 M999 查版本
M9999 进 OTA(均会复位, GUI 需二次确认)
通信要点: 全部命令幂等(ack 丢了直接重发); 命令间隔 >=50ms; 响应超时 300ms 重发 x3。 通信要点: 全部命令幂等(ack 丢了直接重发); 命令间隔 >=50ms; 响应超时 300ms 重发 x3。
仅标准库 + 可选 pyserial(缺失只影响串口功能, 其余照常)。 仅标准库 + 可选 pyserial(缺失只影响串口功能, 其余照常)。
@@ -62,9 +63,9 @@ PRESETS = (
('查上报状态', 'M2', 'ON <ms> / OFF'), ('查上报状态', 'M2', 'ON <ms> / OFF'),
('开上报500ms', 'M2S500', '设上报间隔并开启(100~60000, 10ms 步进)'), ('开上报500ms', 'M2S500', '设上报间隔并开启(100~60000, 10ms 步进)'),
('停上报', 'M2S0', '停止周期上报'), ('停上报', 'M2S0', '停止周期上报'),
('查加热状态', 'M3', 'ON/OFF(预留, 当前仅软件状态)'), ('查加热状态', 'M3', 'ON/OFF(加热使能状态)'),
('开加热', 'M3S1', '预留: 硬件执行体待接入, 仅维护软件状态'), ('开加热', 'M3S1', '开启 PID 闭环控温, 模具将真实加热(需确认)'),
('关加热', 'M3S0', '预留'), ('关加热', 'M3S0', '关闭加热输出'),
('查目标温度', 'M4', 'ASCII 摄氏度(如 120.0)'), ('查目标温度', 'M4', 'ASCII 摄氏度(如 120.0)'),
('设目标120C', 'M4S120', '0~200 整数摄氏度, 越界回 FD'), ('设目标120C', 'M4S120', '0~200 整数摄氏度, 越界回 FD'),
('查固件版本', 'M999', '如 v41.0.0.1'), ('查固件版本', 'M999', '如 v41.0.0.1'),
@@ -346,9 +347,21 @@ class FF485:
pass pass
if item is not None: if item is not None:
cmd, frame = item cmd, frame = item
sent = False
try:
self.ser.write(frame) self.ser.write(frame)
sent = True
except serial.SerialTimeoutException:
# 写超时(适配器停滞): 只丢这条命令, 链路保留。
# 勿并入下方 OSError——瞬时停滞曾被当成"设备拔出"
# 关端口断链, 迫使用户手动重连(适配器换向本就不稳)
self.on_event(f'✕ {cmd} 写超时未发出, 链路保留(可重发)')
if sent:
self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]') self.on_event(f'→ {cmd} [{frame.hex(" ").upper()}]')
data = self.ser.read(256) # 按到达量读取, 不攒大块: 固定 read(256) 在持续帧流下要攒满
# 256 字节才返回(win32 字节间隔超时 150ms 因帧间隔更短永不
# 触发, 100ms 上报 ≈ 2.5s 一批), 表现为曲线/命令全按 3s 一卡
data = self.ser.read(self.ser.in_waiting or 1)
if data: if data:
t_rx = time.time() # 在读取点打时间戳(曲线/CSV 用) t_rx = time.time() # 在读取点打时间戳(曲线/CSV 用)
for fr in self.parser.feed(data): for fr in self.parser.feed(data):
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#000000"/>
<rect x="38" y="64" width="436" height="186" rx="34" fill="#00C8D7"/>
<text x="256" y="210" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#000000" text-anchor="middle">FILM</text>
<text x="256" y="421" font-family="Arial, 'Microsoft YaHei', sans-serif" font-size="146" font-weight="900" fill="#FFFFFF" text-anchor="middle">Forge</text>
</svg>

After

Width:  |  Height:  |  Size: 507 B

+4 -4
View File
@@ -4,9 +4,9 @@
创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖) 创建/更新 Gitea Release 并上传发布 zip(纯标准库, 无需安装依赖)
================================================================ ================================================================
用法(在固件仓任意目录执行, 自动从 origin 解析仓库): 用法(在固件仓任意目录执行, 自动从 origin 解析仓库):
python publish_gitea.py release/FilmForge-GUI-v0.1.0-win64.zip python publish_gitea.py release/FilmForge-GUI-v0.2.0-win64.zip
python publish_gitea.py <zip> --notes-file release/notes-v0.1.0.md python publish_gitea.py <zip> --notes-file release/notes-v0.2.0.md
python publish_gitea.py <zip> --tag FilmForge-GUI-v0.1.0 --replace python publish_gitea.py <zip> --tag FilmForge-GUI-v0.2.0 --replace
行为: 行为:
- tag 与版本默认取 zip 文件名(FilmForge-GUI-vX.Y.Z-win64.zip → FilmForge-GUI-vX.Y.Z) - tag 与版本默认取 zip 文件名(FilmForge-GUI-vX.Y.Z-win64.zip → FilmForge-GUI-vX.Y.Z)
@@ -43,7 +43,7 @@ FilmForge 热压模具温控板(RS485)上位机: 实时温度曲线(自绘, 无
- `README.md` — 完整使用说明 - `README.md` — 完整使用说明
### 注意 ### 注意
- 加热(M3)/目标温度(M4)为固件预留功能: 当前仅软件状态(ack+可回读), 硬件执行体接入后协议不变。 - 加热(M3S1)为真实 PID 闭环控温: 开启后模具将真实加热(NTC 故障/过温 150℃ 固件自动断电保护)。
- 软件复位(M888)/进 OTA(M9999) 会复位设备, 界面内已做二次确认。 - 软件复位(M888)/进 OTA(M9999) 会复位设备, 界面内已做二次确认。
""" """
+250
View File
@@ -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 = []
@@ -40,6 +48,9 @@ window = app_fluent.App(0)
window.show() window.show()
app.processEvents() app.processEvents()
# 0) 图标链路: logos/filmforge.ico 存在时窗口图标必须已加载
check('窗口图标已设置', not window.windowIcon().isNull(), True)
# 1) 空数据渲染不抛异常(占位文案) # 1) 空数据渲染不抛异常(占位文案)
img = QImage(window.chart.size(), QImage.Format_ARGB32) img = QImage(window.chart.size(), QImage.Format_ARGB32)
window.chart.render(img) window.chart.render(img)
@@ -70,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
@@ -150,6 +173,60 @@ check('杂散不消费队首', len(window._pending), 1)
window._handle_frame(mk_frame('v41.0.0.1'), time.time()) window._handle_frame(mk_frame('v41.0.0.1'), time.time())
check('杂散后正常消费', len(window._pending), 0) check('杂散后正常消费', len(window._pending), 0)
# 3d) 晚到 ok 补偿: 设定命令超时清队后, 迟到的 ok 仍按发送时计划应用
window._pending.clear()
window._last_set = {'cmd': 'M2S500', 't': time.time(), 'apply': ('report', ('ON', 500))}
window._handle_frame(mk_frame('ok'), time.time())
check('晚到 ok 补偿上报', window.report_state, ('ON', 500))
check('晚到 ok 已清记录', window._last_set, None)
# 3e) M4 暂停-恢复链: 上报开启时查目标 → M2S0(ok)→M4→应答落地→自动恢复上报
window._pending.clear()
window.link.is_connected = lambda: True # 仅测试: 跳过串口在线检查
window._set_report_state('ON', 500)
window._query_target()
check('查目标先停上报', window._pending[0]['cmd'], 'M2S0')
check('挂起恢复标记', window._resume_report_ms, 500)
window._handle_frame(mk_frame('ok'), time.time()) # M2S0 ok → then 定时发 M4
window._pending.clear()
window._pending.append({'cmd': 'M4', 'kind': 'target', 't': time.time(), 'apply': None})
window._handle_frame(mk_frame('57.0'), time.time()) # M4 应答(替换定时器路径)
check('M4 应答落目标', window.target_hist[-1][1], 57.0)
check('恢复标记已清', window._resume_report_ms, None)
check('恢复上报已入队', window._pending[0]['cmd'], 'M2S500')
window.link.is_connected = lambda: False # 还原
window._pending.clear()
# 3f) 上报开启时, 上报帧不得被当成 M4 目标应答误占(队首非 target 时入曲线)
window._set_report_state('ON', 100)
n2 = len(window.samples_t)
window._handle_frame(mk_frame('60.0'), time.time())
check('无 pending 上报帧入曲线', len(window.samples_t), n2 + 1)
window._set_report_state('OFF', None)
# 3g) 幂等去重: 同命令未决时重复发送被忽略, 队列不再堆积(真机连点 M2S0 回归)
window._pending.clear()
window.link.is_connected = lambda: True
window._send_set('M2S0', 'report', ('OFF', None))
window._send_set('M2S0', 'report', ('OFF', None))
window._send_set('M2S0', 'report', ('OFF', None))
check('重复命令去重', len(window._pending), 1)
check('不同命令不去重', 'M4' if window._send_cmd('M4', 'target') else '', 'M4')
check('去重后队列 2 条', len(window._pending), 2)
window.link.is_connected = lambda: False
window._pending.clear()
# 3h) 目标阶梯线: 目标变更须在变更时刻垂直落变, 不得从旧值斜拉到右缘
t_a, t_b, t_now = time.time() - 120, time.time() - 60, time.time()
window.chart._target_hist = [(t_a, 57.0), (t_b, 41.0)]
pts = window.chart._target_points(t_a, t_now)
check('目标线终点延到右缘', abs(pts[-1][0] - t_now) < 1e-6 and pts[-1][1], 41.0)
diag = [1 for (xa, ya), (xb, yb) in zip(pts, pts[1:])
if abs(xb - xa) > 1e-9 and abs(yb - ya) > 1e-9]
check('目标线无斜段(纯阶梯)', diag, [])
check('变更时刻有垂直落变', (t_b, 57.0) in pts and (t_b, 41.0) in pts, True)
window.chart._target_hist = [] # 还原
# 4) 主题往返后曲线配色随动 + 渲染不崩 # 4) 主题往返后曲线配色随动 + 渲染不崩
window._set_theme('Light') window._set_theme('Light')
check('Light 曲线配色', window.chart._colors['measured'].lower(), check('Light 曲线配色', window.chart._colors['measured'].lower(),
@@ -170,5 +247,178 @@ with open(window._csv_path, encoding='utf-8-sig') as f:
check('CSV 表头+2 行', len(lines), 3) check('CSV 表头+2 行', len(lines), 3)
check('CSV 故障行', lines[2].split(',')[2], 'SHRT') check('CSV 故障行', lines[2].split(',')[2], 'SHRT')
# 6) 日志环形裁剪回归: 写满 500 行后封顶, 继续追加不抛异常、不无限增长。
# (手工 cursor 裁剪用 cursor.Start/BlockUnderCursor 实例级枚举访问,
# PySide6 6.11 上必然抛 AttributeError → 每 tick 异常中止, v0.2.8 改原生裁剪)
for i in range(600):
window._log(f'日志压力行 {i}')
check('日志 500 行封顶', window.txt_log.blockCount() <= 500, True)
window._log('封顶后追加行')
check('封顶后可继续追加', window.txt_log.blockCount() <= 500, True)
check('封顶后最新行在末尾',
window.txt_log.toPlainText().splitlines()[-1].endswith('封顶后追加行'), True)
# 7) v0.2.9 回归: 故障标记封顶 / 滚动条跟随 / CSV 写失败自停 / 冻结切片右边界
t_end = time.time()
for i in range(app_fluent.MAX_FAULTS + 50):
window._record_fault(t_end + i * 0.01, ff.SENSOR_OPEN)
check('故障标记封顶', len(window.faults), app_fluent.MAX_FAULTS)
sb = window.txt_log.verticalScrollBar()
sb.setValue(0)
window._log('上翻后新行')
check('上翻时不抢滚动条', sb.value() <= 1, True)
sb.setValue(sb.maximum())
window._log('贴底跟随行')
check('贴底时跟随滚动', abs(sb.value() - sb.maximum()) <= 4, True)
class _BoomFile:
"""模拟写坏盘: write/flush 必抛 OSError(磁盘满/记录盘被拔)"""
def write(self, *_a):
raise OSError(28, '模拟磁盘满')
def flush(self):
raise OSError(28, '模拟磁盘满')
def close(self):
pass
window._csv = _BoomFile()
window._csv_path = 'dummy.csv'
window._add_sample(time.time(), 25.0)
check('CSV 写失败自动停止记录', window._csv is None, True)
check('CSV 失败后按钮复位', window.btn_csv.text(), '开始记录')
c = window.chart
c._samples_t, c._samples_v = [1.0, 2.0, 3.0, 4.0], [1, 2, 3, 4]
c._frozen_at = 2.5
ts, _vs = c._visible(0, 2.5)
check('冻结切片不含冻结尾后样本', ts, [1.0, 2.0])
c._frozen_at = None
ts, _vs = c._visible(0, 99)
check('实时切片完整', ts, [1.0, 2.0, 3.0, 4.0])
c._samples_t, c._samples_v = window.samples_t, window.samples_v
# 8) v0.2.11 回归: 右缘双把手滑条拖拽量程 + 空态提示实时消失。
# (提示不消失根因: 旧版 set_source 构建期缓存 _has_data 布尔, 数据经引用追加
# 后永不刷新 → 改绘制时实时判定; 本段同时覆盖滑条命中/拖拽/钳位/平移/裁剪)
c = window.chart
check('Y 轴初始自动', c._y_mode, 'auto')
check('Y 轴按钮默认自动', window.btn_ymode.isChecked()
and window.btn_ymode.text() == 'Y轴自动', True)
engaged, ranges = [], []
c.yManualEngaged.connect(lambda: engaged.append(1))
c.yRangeChanged.connect(lambda lo, hi: ranges.append((lo, hi)))
c.update()
app.processEvents() # 同步一次绘制: 命中缓存与当前数据一致
area, _t0, _t1, _vt, _vv, _tgt, ymin0, ymax0 = c._layout()
gx0, gx1 = c._gutter()
ref0 = c._y_ref()
y_hi_px = c._rail_px(area, ref0, ymax0)
y_lo_px = c._rail_px(area, ref0, ymin0)
check('把手命中(上把手)', c._gutter_hit(gx0 + 6, y_hi_px), 'max')
check('把手命中(下把手)', c._gutter_hit(gx0 + 6, y_lo_px), 'min')
check('轨道空白亦视为边界', c._gutter_hit(gx0 + 6, area.bottom() - 3), 'min')
check('栏外不命中', c._gutter_hit(10, 100) is None, True)
c.start_y_drag('min', y_lo_px)
check('拖拽即切手动', c._y_mode, 'manual')
check('手动信号已发', engaged, [1])
# 滑条域固定 -20~120 后拖拽映射恒定(0.53C/px 量级), 断言用目标值反算像素,
# 不再依赖域宽: 拖到 ymin0+30 对应位置 → 下限抬升 30℃
c.update_y_drag(c._rail_px(area, ref0, ymin0 + 30.0))
lo1, hi1 = c._y_manual
check('下限随拖拽抬升', lo1 > ymin0 + 1e-9, True)
check('上限不受牵动', abs(hi1 - ymax0) < 1e-9, True)
check('松手生效', c.end_y_drag(), True)
lo_e, hi_e = ranges[-1]
check('范围量化 0.1', abs(lo_e * 10 - round(lo_e * 10)) < 1e-6
and abs(hi_e * 10 - round(hi_e * 10)) < 1e-6, True)
c.start_y_drag('min', y_lo_px) # 极端上拉 → 钳到 上限-最小间距
c.update_y_drag(area.top() - 500)
check('最小间距约束',
c._y_manual[1] - c._y_manual[0] >= c.MIN_Y_SPAN - 1e-9, True)
c.end_y_drag()
lo_prev, hi_prev = c._y_manual[0], c._y_manual[1] # 中带平移: 整段同移
c.start_y_drag('win', (y_hi_px + y_lo_px) / 2)
c.update_y_drag(area.bottom() + 30)
c.end_y_drag()
lo_pan, hi_pan = c._y_manual
# 松手量化 0.1℃ 各自取整, 跨度允许 ±0.05 偏差(两段半界)
check('平移不改跨度(量化容差)', abs((hi_pan - lo_pan) - (hi_prev - lo_prev)) < 0.12, True)
check('平移已生效', lo_pan < lo_prev - 1e-9, True)
check('上下限同步移动', abs((lo_prev - lo_pan) - (hi_prev - hi_pan)) < 0.12, True)
check('范围通知已发 3 次', len(ranges), 3)
window.btn_ymode.setChecked(True) # 按钮勾回自动
check('切回自动', c._y_mode, 'auto')
check('按钮文本回自动', window.btn_ymode.text(), 'Y轴自动')
# 确定形状的像素断言: 手动 [28,80], 数据(~19~60℃)出界部分必被裁剪回绘图区
c.set_y_manual(28.0, 80.0)
_hx = c._colors['measured'].lstrip('#')
measured_now = (int(_hx[0:2], 16), int(_hx[2:4], 16), int(_hx[4:6], 16))
window.chart.update()
img = QImage(window.chart.size(), QImage.Format_ARGB32)
window.chart.render(img)
area = QRectF(window.chart.rect()).adjusted(
52, 10, -(app_fluent.RIGHT_MARGIN + app_fluent.GUTTER_W), -26)
x0, x1 = int(area.left()) + 2, int(area.right()) - 2
check('手动范围下曲线仍绘制', count_pixels(img, measured_now) > 0, True)
check('裁剪: 上条带无曲线像素',
count_in(img, x0, x1, 0, int(area.top()) - 1, measured_now), 0)
check('裁剪: 下条带无曲线像素',
count_in(img, x0, x1, int(area.bottom()), window.chart.height(), measured_now), 0)
c.set_y_mode('auto')
hint = (201, 201, 201) # 提示文字色 #c9c9c9
c2 = app_fluent.TemperatureChart()
c2.resize(640, 320)
lst_t, lst_v = [], []
c2.set_source(lst_t, lst_v, [], []) # 模拟构建期一次性注入(空)
img2 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img2)
ax0, ax1 = 60, c2.width() - 20
ax_top, ax_bot = 30, c2.height() - 30
n_hint0 = count_in(img2, ax0, ax1, ax_top, ax_bot, hint)
lst_t.append(time.time()) # 经同一引用追加数据(复现旧 bug 路径)
lst_v.append(50.0)
c2.update()
img3 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img3)
n_hint1 = count_in(img3, ax0, ax1, ax_top, ax_bot, hint)
print(f'提示像素: 空={n_hint0} 有数据={n_hint1}')
check('空态提示已绘出', n_hint0 > 50, True)
check('数据到位后提示消失(实时判定)', n_hint1, 0)
# 8b) 值标签悬停门控: 非悬停不画(不常驻遮挡曲线, 用户标注), 悬停/拖拽才显示
lt2, lv2 = [time.time() - 100], [35.0]
c2.set_source(lt2, lv2, [], [])
c2.set_y_manual(20.0, 60.0)
c2.set_y_mode('manual')
c2.update()
img4 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img4)
zx0, zx1 = c2.width() - 71, c2.width() - 31 # 把手值标签区(绘图区右缘)
zy0, zy1 = 110, 230 # 覆盖上下两个把手 y 位
n_pill0 = count_in(img4, zx0, zx1, zy0, zy1, (0, 200, 215))
c2._set_y_hover(True)
c2.update()
img5 = QImage(c2.size(), QImage.Format_ARGB32)
c2.render(img5)
n_pill1 = count_in(img5, zx0, zx1, zy0, zy1, (0, 200, 215))
print(f'值标签像素: 未悬停={n_pill0} 悬停={n_pill1}')
check('未悬停时值标签隐藏(不遮曲线)', n_pill0, 0)
check('悬停/拖拽时值标签显示', n_pill1 > 20, True)
c2._set_y_hover(False)
# 还原模块配置, 避免测试残留写入用户 filmforge_gui.json
config.Y_MODE, config.Y_MIN, config.Y_MAX = 'auto', None, None
config.save_settings()
print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS') print('RESULT:', 'FAIL:' + ','.join(fails) if fails else 'ALL PASS')
sys.exit(1 if fails else 0) sys.exit(1 if fails else 0)
+34 -5
View File
@@ -149,12 +149,41 @@ void usart1_irq_handler(void);
#define HEATER_EN_RCU RCU_GPIOB #define HEATER_EN_RCU RCU_GPIOB
#define HEATER_EN_PORT GPIOB #define HEATER_EN_PORT GPIOB
#define HEATER_EN_PIN GPIO_PIN_12 #define HEATER_EN_PIN GPIO_PIN_12
#define HEATER_PWM_PERIOD_MS 1000U /* PWM 载波周期(1s,恒 1Hz 开关频率) */ #define HEATER_PWM_PERIOD_MS 2000U /* PWM 载波周期(2s,载波 0.5Hz) */
#define HEATER_PWM_STEP_PCT 10U /* 占空比步进(每档脉宽 = 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 8.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 0.0f /* NTC 噪声对微分放大,先禁用 */ #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_C 150.0f /* 过温强制关断阈值(滞回保护) */
#define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */ #define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */
+5 -4
View File
@@ -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);
@@ -40,7 +41,7 @@ void heat_set_target(float temp_c);
float heat_get_target(void); float heat_get_target(void);
/** /**
* @brief 查询当前量化占空比档位(0~10,对应 0~100%,10% 步进)。 * @brief 查询当前量化占空比档位(0~20,对应 0~100%,5% 步进)。
*/ */
uint8_t heat_get_duty(void); uint8_t heat_get_duty(void);
+199 -59
View File
@@ -1,12 +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)由"1s 载波周期 + 10% 占空步进"保证——每档脉宽 * 开关频率约束(<=10Hz)由"2s 载波周期 + 5% 占空步进"保证——每档脉宽
* 恒为 100ms 的整数倍。PID 以 500ms 周期计算(NTC 后台采样 100ms), * 恒为 100ms 的整数倍(0.5Hz 载波、20 档占空分辨率)。
* 输出量化到 0~10 档占空。 * 控制结构(平滑逼近目标,抑制过冲/震荡):
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT)强制关断; * 1. 目标斜坡:内部设定值以 HEATER_RAMP_CPS 限速滑向 M4 目标,
* 过温(>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。 * 设定值阶跃/跳变不直接进入误差;
* 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" #include "heat.h"
@@ -19,19 +33,30 @@
/* 内部状态 */ /* 内部状态 */
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 uint8_t s_duty10 = 0; /* 量化占空档位 0~10(x10%) */ 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 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% / 步进% */
#define HEATER_PWM_STEPS (100U / HEATER_PWM_STEP_PCT)
/* NTC 故障确认拍数(每拍 = 1 个 PID 周期 500ms;连续 3 拍 = 1.5s) */
#define NTC_FAULT_CONFIRM_PID_STEPS 3U
/******************************************************************************/ /******************************************************************************/
/* 底层输出 */ /* 底层输出 */
static void heat_out(bool on);
static void heat_out(bool on) { static void heat_out(bool on) {
if (on) { if (on) {
gpio_bit_set(HEATER_EN_PORT, HEATER_EN_PIN); gpio_bit_set(HEATER_EN_PORT, HEATER_EN_PIN);
@@ -52,30 +77,73 @@ void heat_init(void) {
/* PID */ /* PID */
/** /**
* @brief 复位 PID 积分与历史误差(使能切换/传感器故障时调用,防积分残留冲击)。 * @brief 分段 PID 计算(500ms 周期):误差 -> 占空档位 s_duty。
*/
static void pid_reset(void) {
s_integral = 0.0f;
s_prev_err = 0.0f;
}
/**
* @brief PID 计算(500ms 周期):误差 -> 占空档位 s_duty10(0~10)。
*/ */
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_duty10 = 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_duty10 = 0; s_duty = 0;
pid_reset(); s_integral = 0.0f;
s_prev_temp = t;
return; return;
} }
@@ -86,39 +154,96 @@ static void pid_compute(void) {
s_overtemp = false; s_overtemp = false;
} }
if (s_overtemp) { if (s_overtemp) {
s_duty10 = 0; s_duty = 0;
pid_reset(); s_integral = 0.0f;
s_duty_acc = 0.0f;
s_prev_temp = t;
return; return;
} }
/* 位置式 PID(带抗积分饱和):输出饱和时积分仅沿退饱和方向更新, /* 目标斜坡:内部设定值限速滑向 s_target(升/降同限速)。
防止升温段积分顶满后长时间维持 100% 输出(此前实测目标 50℃ 冲到 59℃) */ 小步进(斜坡快于温升)全程落在温和带内缓升;大步进爬升段 ramp 超前、
float err = s_target - t; 到站后误差随实际温度平滑收缩,抑制热滞后过冲。 */
float u_noi = HEATER_PID_KP * err float diff = s_target - s_ramp_target;
+ HEATER_PID_KD * (err - s_prev_err); /* 比例 + 微分 */ float ramp_step = HEATER_RAMP_CPS * (HEATER_PID_PERIOD_MS / 1000.0f);
float integ_new = s_integral + err; if (diff > ramp_step) {
float u = u_noi + HEATER_PID_KI * integ_new; s_ramp_target += ramp_step;
} else if (diff < -ramp_step) {
s_ramp_target -= ramp_step;
} else {
s_ramp_target = s_target;
}
float err = s_ramp_target - t;
float d_t = t - s_prev_temp; /* 本拍温度变化(D on measurement) */
/* 分区作用(V0.1.12):近场扩展区 [-BAND, SLOWBAND_C) 合并"带内退闸"
与"带外下方"为一条连续减速曲线——u = Ki*I(维持功率,不衰减)+
ASSIST 退闸曲线(err=+SLOWBAND_C 满档快爬、err=+BAND 处归零)+ Kd 减速。
原结构近场推力真空(P 随 err 缩小消失、I 未成熟,最后 2C 爬 141s),
ASSIST 提供与积分成熟度无关的确定推力。远场 err >= SLOWBAND_C:P/D
全量 + I。过温缘 err < -BAND:关断 + I 限速回退。 */
float u;
if (err >= HEATER_SLOWBAND_C) {
float u_pd = HEATER_PID_KP * err - HEATER_PID_KD * d_t;
if (u_pd > 100.0f) {
u_pd = 100.0f;
}
if (u_pd < 0.0f) {
u_pd = 0.0f;
}
u = u_pd + HEATER_PID_KI * s_integral;
if (u < 100.0f) {
s_integral += err * HEATER_PID_INTEG_GAIN_IN;
}
if (u > 100.0f) { if (u > 100.0f) {
u = 100.0f; u = 100.0f;
if (err < 0.0f) s_integral = integ_new; /* 超上限:仅误差为负(降温方向)才吸收 */
} else if (u < 0.0f) {
u = 0.0f;
if (err > 0.0f) s_integral = integ_new; /* 超下限:仅误差为正(升温方向)才吸收 */
} else {
s_integral = integ_new; /* 未饱和:正常积分 */
} }
s_prev_err = err; } else if (err >= -HEATER_PID_BAND_C) {
float assist = (float)HEATER_SLOWBAND_DUTY * (err + HEATER_PID_BAND_C)
/ (HEATER_SLOWBAND_C + HEATER_PID_BAND_C);
u = HEATER_PID_KI * s_integral + assist - HEATER_PID_KD * d_t;
s_integral += err * HEATER_PID_INTEG_GAIN_IN;
if (u > (float)HEATER_NEAR_MAX_DUTY) {
u = (float)HEATER_NEAR_MAX_DUTY; /* 近场推力上限:快而不过冲 */
}
if (u < 0.0f) {
u = 0.0f;
}
} else {
s_integral -= HEATER_PID_INTEG_BLEED;
u = 0.0f;
}
if (s_integral > HEATER_PID_I_MAX) {
s_integral = HEATER_PID_I_MAX;
}
if (s_integral < 0.0f) {
s_integral = 0.0f;
}
/* 输出量化到 10% 档位(四舍五入) */ s_prev_temp = t;
s_duty10 = (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;
}
} }
/******************************************************************************/ /******************************************************************************/
/* 公共接口 */ /* 公共接口 */
void heat_task(void) { void heat_task(void) {
uint8_t prev_duty = s_duty10; uint8_t prev_duty = s_duty;
/* PWM 载波相位推进(10ms tick) */ /* PWM 载波相位推进(10ms tick) */
s_pwm_phase_ms += 10U; s_pwm_phase_ms += 10U;
@@ -131,16 +256,18 @@ 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_duty10 != prev_duty) { if (s_duty > prev_duty) {
s_pwm_phase_ms = 0U; /* 占空变更:从新周期起点开始,响应即时 */ s_pwm_phase_ms = 0U; /* 仅升档复位相位:立即起算新 ON 窗口;
降档不复位——相位已过新窗口即刻关断,
不再额外补发一段 ON 脉冲 */
} }
} }
/* 按占空档位输出:0 档常关、10 档常开、其余按相位斩波 */ /* 按占空档位输出:0 档常关、满档常开、其余按相位斩波 */
uint16_t on_ms = (uint16_t)s_duty10 * (HEATER_PWM_PERIOD_MS / 10U); uint16_t on_ms = (uint16_t)s_duty * (HEATER_PWM_PERIOD_MS / HEATER_PWM_STEPS);
if (s_duty10 == 0U) { if (s_duty == 0U) {
heat_out(false); heat_out(false);
} else if (s_duty10 >= 10U) { } else if (s_duty >= HEATER_PWM_STEPS) {
heat_out(true); heat_out(true);
} else { } else {
heat_out(s_pwm_phase_ms < on_ms); heat_out(s_pwm_phase_ms < on_ms);
@@ -150,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) {
s_duty10 = 0; /* 从当前实际温度起坡:避免内部目标从 0 跳变造成功率冲击;
pid_reset(); 微分基线/读数滤波基线同步取当前温度,首拍无假温差 */
s_ramp_target = ntc_get_temperature();
if (s_ramp_target < 0.0f) {
s_ramp_target = 0.0f; /* 传感器故障标记值非法,退化为 0 */
}
s_prev_temp = s_ramp_target;
s_t_filt = (s_ramp_target > 0.0f) ? s_ramp_target : 25.0f;
s_slew_cnt = 0U;
s_fault_debounce = 0U;
} else {
s_ramp_target = 0.0f;
s_duty = 0;
s_integral = 0.0f;
s_duty_acc = 0.0f;
heat_out(false); heat_out(false);
} }
} }
@@ -170,5 +310,5 @@ float heat_get_target(void) {
} }
uint8_t heat_get_duty(void) { uint8_t heat_get_duty(void) {
return s_duty10; return s_duty;
} }
+33 -1
View File
@@ -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 -2
View File
@@ -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
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
}
+3 -3
View File
@@ -39,9 +39,9 @@
| `M2` | 查询上报状态 | `D5 01 02 4D 32 82` | `ON <ms>` 或 `OFF` | | `M2` | 查询上报状态 | `D5 01 02 4D 32 82` | `ON <ms>` 或 `OFF` |
| `M2S500` | 设上报间隔并开启 | `D5 01 06 4D 32 53 35 30 30 6E` | `ok`;范围 100~60000ms,10ms 步进 | | `M2S500` | 设上报间隔并开启 | `D5 01 06 4D 32 53 35 30 30 6E` | `ok`;范围 100~60000ms,10ms 步进 |
| `M2S0` | 停止上报 | `D5 01 04 4D 32 53 30 07` | `ok` | | `M2S0` | 停止上报 | `D5 01 04 4D 32 53 30 07` | `ok` |
| `M3` | 查询加热状态 | `D5 01 02 4D 33 83` | `ON` / `OFF`(预留,当前仅软件状态) | | `M3` | 查询加热状态 | `D5 01 02 4D 33 83` | `ON` / `OFF`(加热使能状态) |
| `M3S1` | 开启加热 | `D5 01 04 4D 33 53 31 09` | `ok`(预留) | | `M3S1` | 开启加热 | `D5 01 04 4D 33 53 31 09` | `ok`(PID 闭环真实控温) |
| `M3S0` | 关闭加热 | `D5 01 04 4D 33 53 30 08` | `ok`(预留) | | `M3S0` | 关闭加热 | `D5 01 04 4D 33 53 30 08` | `ok` |
| `M4` | 查询目标温度 | `D5 01 02 4D 34 84` | ASCII 摄氏度(如 `120.0`) | | `M4` | 查询目标温度 | `D5 01 02 4D 34 84` | ASCII 摄氏度(如 `120.0`) |
| `M4S120` | 设定目标温度 | `D5 01 06 4D 34 53 31 32 30 6E` | `ok`;整数摄氏度 0~200,越界回 PARAM_ERR | | `M4S120` | 设定目标温度 | `D5 01 06 4D 34 53 31 32 30 6E` | `ok`;整数摄氏度 0~200,越界回 PARAM_ERR |
| `M888` | 软件复位 | `D5 01 04 4D 38 38 38 FA` | `ok` 后复位(慎发) | | `M888` | 软件复位 | `D5 01 04 4D 38 38 38 FA` | `ok` 后复位(慎发) |
+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)
-76
View File
@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
"""FilmForge PID anti-windup verification: target 50C, track steady-state."""
import serial
import time
import threading
def sum8(seq):
return sum(seq) & 0xFF
def frame(cmd):
p = cmd.encode()
body = bytes([0xD5, 0x01, len(p)]) + p
return body + bytes([sum8(body[1:])])
rx = bytearray()
stop = threading.Event()
def reader(buf, handle):
while not stop.is_set():
data = handle.read(256)
if data:
buf.extend(data)
handle.close()
def frames(buf):
out, i, b = [], 0, bytes(buf)
while i < len(b):
if b[i] == 0xB5 and i + 3 < len(b):
total = 3 + b[i + 2] + 1
if i + total <= len(b):
out.append(b[i:i + total]); i += total; continue
i += 1
return out
s5 = serial.Serial('COM5', 115200, timeout=0.5)
t = threading.Thread(target=reader, args=(rx, s5))
t.start()
time.sleep(0.5)
def cmd(c, wait=0.8):
rx.clear()
s5.write(frame(c))
time.sleep(wait)
fs = frames(rx)
payload = [f[3:3+f[2]].decode('ascii','replace') for f in fs if len(f) >= 4 and f[1] == 0xF0]
print('[%s] resp: %s' % (c, payload if payload else '<none>'))
cmd('M4S50')
cmd('M3S1')
cmd('M2S500')
print('--- heating to 50C, 150s sampling ---')
t0 = time.time()
last_print = -10
peak = 0
while time.time() - t0 < 150:
time.sleep(2.0)
fs = frames(rx)
vals = []
for f in fs:
if len(f) >= 4 and f[1] == 0xF0 and f[2] >= 2:
try:
vals.append(float(f[3:3+f[2]]))
except ValueError:
pass
if vals:
peak = max(peak, max(vals))
dt = time.time() - t0
if dt >= last_print + 10 and vals:
print(' t+%03ds %.1f C (peak %.1f)' % (dt, vals[-1], peak))
last_print = dt - (dt % 10)
cmd('M3S0', wait=1.0)
cmd('M2S0')
stop.set(); t.join()
print('PEAK=%0.1f (target 50, overshoot %+.1f)' % (peak, peak - 50))
print('DONE')
+111
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'}")