Heater: segmented PID (full-power / slow-band / overtemp-cutoff) + 2s/5pct PWM

This commit is contained in:
2026-10-10 14:49:24 +08:00
parent 974a077e29
commit c9aa41685f
10 changed files with 114 additions and 71 deletions
+8 -3
View File
@@ -155,9 +155,14 @@
- v0.2.6:对齐修复——双列改放同一滚动容器(整页统一滚动):原先两列各自包 - v0.2.6:对齐修复——双列改放同一滚动容器(整页统一滚动):原先两列各自包
QScrollArea 独立滚动,窗口化下底边错位(全屏正常);现两列高度锁定一致, QScrollArea 独立滚动,窗口化下底边错位(全屏正常);现两列高度锁定一致,
不足一屏各自拉伸到底、超出一屏整页滚动。 不足一屏各自拉伸到底、超出一屏整页滚动。
- selftest 全过(累计新增 14 项回归用例);exe 已本地打包冒烟,Gitea 发布 - v0.2.7:图标接入(logos/ 下 ico/png/svg 已就位)——exe 嵌入图标
待用户确认后执行。 (build_exe.py --icon,此前缺文件自动跳过)、窗口标题栏/任务栏/弹窗图标
遗留见其 README"已知事项":应用图标、USB-RS485 适配器更换。 (app+window 两级 setWindowIcon,_icon_path() 兼容源码态与 _MEIPASS 打包态)、
Windows 任务栏独立标识 AppUserModelID "hulk.FilmForgeGUI"。README"无应用
图标"遗留项关闭。
- selftest 全过(累计新增 15 项回归用例,含窗口图标非空检查);exe 已本地
打包冒烟,Gitea 发布待用户确认后执行。
遗留见其 README"已知事项":USB-RS485 适配器更换。
- [ ] **LED 引脚确认**:FilmForge 自制板 LED 引脚未定(当前沿用模板默认 PA15, - [ ] **LED 引脚确认**:FilmForge 自制板 LED 引脚未定(当前沿用模板默认 PA15,
仅为构建占位),按原理图修改。 仅为构建占位),按原理图修改。
- [ ] **delay_10us 校准**:M4@168MHz 系数为估算值(/300000),需实测修正。 - [ ] **delay_10us 校准**:M4@168MHz 系数为估算值(/300000),需实测修正。
-1
View File
@@ -87,4 +87,3 @@ python publish_gitea.py release/FilmForge-GUI-v<版本>-win64.zip --replace --in
(设备单向推送),不受影响。 (设备单向推送),不受影响。
- 加热(M3S1)为真实 PID 闭环:开启后模具真实升温(NTC 故障/过温 150℃ 固件自动 - 加热(M3S1)为真实 PID 闭环:开启后模具真实升温(NTC 故障/过温 150℃ 固件自动
断电保护,上电默认关闭);当前占空比无查询命令,从温度曲线间接观察。 断电保护,上电默认关闭);当前占空比无查询命令,从温度曲线间接观察。
- 打包暂无应用图标(`logos/filmforge.ico` 缺位,build_exe.py 自动跳过)。
+23 -2
View File
@@ -46,7 +46,7 @@ if getattr(sys, "frozen", False) and os.name == "nt":
pass pass
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer from PySide6.QtCore import QPointF, QRectF, Qt, QTimer
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPen, QPalette from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPainterPath, QPen, QPalette
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit, QApplication, QHBoxLayout, QLabel, QMainWindow, QMessageBox, QPlainTextEdit,
QScrollArea, QSplitter, QVBoxLayout, QWidget, QScrollArea, QSplitter, QVBoxLayout, QWidget,
@@ -60,7 +60,7 @@ import config
import filmforge_rs485 as ff import filmforge_rs485 as ff
APP_NAME = "FilmForge" APP_NAME = "FilmForge"
APP_VERSION = "0.2.6" APP_VERSION = "0.2.7"
REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge" REPO_URL = "https://gitea.hulk.wang/hulk/FilmForge"
MONO = QFont("Consolas", 10) MONO = QFont("Consolas", 10)
CHIP_IDLE = "#666666" CHIP_IDLE = "#666666"
@@ -364,6 +364,9 @@ class App(QMainWindow):
self._system_palette = app.palette() if app else QPalette() self._system_palette = app.palette() if app else QPalette()
self._system_style = app.styleSheet() if app else "" self._system_style = app.styleSheet() if app else ""
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} — 热压模具温控监视台") self.setWindowTitle(f"{APP_NAME} v{APP_VERSION} — 热压模具温控监视台")
icon = _icon_path() # 标题栏图标(run_gui 另设 app 级 + 任务栏标识)
if os.path.exists(icon):
self.setWindowIcon(QIcon(icon))
self.resize(1440, 920) self.resize(1440, 920)
self.setMinimumSize(1150, 720) self.setMinimumSize(1150, 720)
@@ -1326,6 +1329,15 @@ def _single_instance():
return True return True
def _icon_path():
"""窗口图标路径: 打包版取 _MEIPASS(build_exe.py --add-data 内嵌), 源码版取 logos/。"""
bundled = os.path.join(getattr(sys, "_MEIPASS", ""), "filmforge.ico")
if os.path.exists(bundled):
return bundled
return os.path.join(os.path.dirname(os.path.abspath(__file__)),
"logos", "filmforge.ico")
def run_gui(auto_close=0): def run_gui(auto_close=0):
if not _single_instance(): if not _single_instance():
app = QApplication.instance() or QApplication(sys.argv) app = QApplication.instance() or QApplication(sys.argv)
@@ -1333,6 +1345,15 @@ def run_gui(auto_close=0):
return return
app = QApplication.instance() or QApplication(sys.argv) app = QApplication.instance() or QApplication(sys.argv)
app.setApplicationName(APP_NAME) app.setApplicationName(APP_NAME)
if os.name == "nt":
try: # 任务栏独立标识: 缺省时任务栏可能沿用宿主默认图标并错误分组
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"hulk.FilmForge.GUI")
except Exception:
pass
icon = _icon_path()
if os.path.exists(icon):
app.setWindowIcon(QIcon(icon)) # 主窗/弹窗/任务栏统一图标
setTheme(Theme.AUTO) setTheme(Theme.AUTO)
window = App(auto_close) window = App(auto_close)
window.show() window.show()
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

+3
View File
@@ -40,6 +40,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)
+5 -3
View File
@@ -149,13 +149,15 @@ 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 10.0f /* PID 参数:超温前退功率更快(配合超温强制关断) */ #define HEATER_PID_KP 10.0f /* PID 参数:超温前退功率更快(配合超温强制关断) */
#define HEATER_PID_KI 0.05f #define HEATER_PID_KI 0.05f
#define HEATER_PID_KD 30.0f /* 微分阻尼:按误差变化率提前刹车(抑制爬升惯性) */ #define HEATER_PID_KD 30.0f /* 微分阻尼:按误差变化率提前刹车(抑制爬升惯性) */
#define HEATER_RAMP_CPS 0.8f /* 目标斜坡速率(℃/s):内部设定值缓升,抑制热滞后过冲 */ #define HEATER_SLOWBAND_C 7.0f /* 温和带宽度:距目标小于此值时占空上限受限
(低速爬完最后一段,减少蓄热惯性过冲) */
#define HEATER_SLOWBAND_DUTY 25U /* 温和带内占空上限(%) */
#define HEATER_OVERTEMP_C 150.0f /* 过温强制关断阈值(滞回保护) */ #define HEATER_OVERTEMP_C 150.0f /* 过温强制关断阈值(滞回保护) */
#define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */ #define HEATER_OVERTEMP_HYST 5.0f /* 过温恢复滞回 */
+1 -1
View File
@@ -40,7 +40,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);
+68 -61
View File
@@ -1,10 +1,14 @@
/** /**
* @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 档占空。 * 控制为分段式:
* err >= 温和带 全量 PID(满功率爬升);
* 0 <= err < 温和带 正常 PID 但占空上限压到温和档(小功率缓升,
* 减少进带蓄热,降低越过目标后的惯性过冲);
* err < 0(超温) 强制关断 + 积分按负误差回退(执行器只能加热不能制冷)。
* @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT)强制关断; * @note 安全保护:上电默认关闭;NTC 传感器故障(OPEN/SHRT)强制关断;
* 过温(>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。 * 过温(>= HEATER_OVERTEMP_C)滞回保护;未使能强制关断。
*/ */
@@ -19,20 +23,20 @@
/* 内部状态 */ /* 内部状态 */
static bool s_enable = false; /* 加热使能(M3) */ static bool s_enable = false; /* 加热使能(M3) */
static float s_target = 0.0f; /* 目标温度(M4,外部设定值) */ static float s_target = 0.0f; /* 目标温度(M4) */
static float s_ramp_target = 0.0f; /* 内部斜坡目标(以 HEATER_RAMP_CPS 滑向 s_target) */ static uint8_t s_duty = 0; /* 量化占空档位 0~HEATER_PWM_STEPS(x STEP%) */
static uint8_t s_duty10 = 0; /* 量化占空档位 0~10(x10%) */
static bool s_overtemp = false; /* 过温锁存(滞回恢复) */ static bool s_overtemp = false; /* 过温锁存(滞回恢复) */
static float s_integral = 0.0f; /* PID 积分项 */ static float s_integral = 0.0f; /* PID 积分项 */
static float s_prev_err = 0.0f; /* PID 上次误差 */ static float s_prev_err = 0.0f; /* PID 上次误差 */
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)
/******************************************************************************/ /******************************************************************************/
/* 底层输出 */ /* 底层输出 */
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);
@@ -61,21 +65,28 @@ static void pid_reset(void) {
} }
/** /**
* @brief PID 计算(500ms 周期):误差 -> 占空档位 s_duty10(0~10)。 * @brief 积分试算值(err 一步积分;供抗饱和逻辑条件采纳)。
*/
static float integ_step(float err) {
return s_integral + err;
}
/**
* @brief 分段 PID 计算(500ms 周期):误差 -> 占空档位 s_duty。
*/ */
static void pid_compute(void) { static void pid_compute(void) {
float t = ntc_get_temperature(); float t = ntc_get_temperature();
/* 传感器故障(开路/短路):安全第一,强制关断并复位 PID */ /* 传感器故障(开路/短路):安全第一,强制关断并复位 PID */
if (t <= -273.0f) { if (t <= -273.0f) {
s_duty10 = 0; s_duty = 0;
pid_reset(); pid_reset();
return; return;
} }
/* 未使能:强制关断 */ /* 未使能:强制关断 */
if (!s_enable) { if (!s_enable) {
s_duty10 = 0; s_duty = 0;
pid_reset(); pid_reset();
return; return;
} }
@@ -87,57 +98,60 @@ 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(); pid_reset();
return; return;
} }
/* 目标斜坡:内部设定值以受限速率滑向 s_target。 float err = s_target - t;
满功率爬升段 ramp 超前于实际温升(不受影响);接近目标后 ramp 先到站停住,
误差随实际温度逼近而平滑收缩 -> 功率缓退,抑制热传导滞后造成的过冲。 */ /* 超温区间:执行器只能加热不能制冷,输出必须为 0;
float diff = s_target - s_ramp_target; 积分按负误差回退,为温度回落后的重新加热保留正确初始态 */
float step = HEATER_RAMP_CPS * (HEATER_PID_PERIOD_MS / 1000.0f); if (err < 0.0f) {
if (diff > step) { s_integral = integ_step(err);
s_ramp_target += step; s_prev_err = err;
} else if (diff < -step) { s_duty = 0;
s_ramp_target -= step; return;
} else {
s_ramp_target = s_target;
} }
/* 位置式 PID(带抗积分饱和):输出饱和时积分仅沿退饱和方向更新, /* 位置式 PID(带抗积分饱和) */
防止升温段积分顶满后长时间维持 100% 输出(此前实测目标 50℃ 冲到 59℃) */ float u = HEATER_PID_KP * err
float err = s_ramp_target - t; + HEATER_PID_KD * (err - s_prev_err)
float u_noi = HEATER_PID_KP * err + HEATER_PID_KI * integ_step(err);
+ HEATER_PID_KD * (err - s_prev_err); /* 比例 + 微分 */
float integ_new = s_integral + err; if (u > 100.0f) {
/* 超温区间(实际温度高于内部目标):加热器只能加热、不能制冷, u = 100.0f;
输出必须为 0(此前积分正残量会算出小占空在超调段续灌加热); /* 饱和:err>=0 分支积分不吸收(防 windup) */
积分按负误差同步回退,为温度回落后的重新加热保留正确初始态 */
if (err < 0.0f) {
s_integral = integ_new;
s_prev_err = err;
s_duty10 = 0;
} else { } else {
/* 误差为正(未达/低于内部目标):正常 PID + 抗积分饱和 */ s_integral = integ_step(err);
float u = u_noi + HEATER_PID_KI * integ_new; }
/* 温和带:距目标不足 HEATER_SLOWBAND_C 时,比例/微分即时响应受限
(小功率缓升,减少进带蓄热、降低惯性过冲);积分项不受限——
稳态维持功率由积分缓慢顶到实际需要值 */
if (err < HEATER_SLOWBAND_C) {
float u_pd = HEATER_PID_KP * err + HEATER_PID_KD * (err - s_prev_err);
if (u_pd > (float)HEATER_SLOWBAND_DUTY) {
u_pd = (float)HEATER_SLOWBAND_DUTY;
}
if (u_pd < 0.0f) {
u_pd = 0.0f;
}
u = u_pd + HEATER_PID_KI * s_integral;
if (u > 100.0f) { if (u > 100.0f) {
u = 100.0f; u = 100.0f;
/* 超上限:仅误差为负才吸收——err>=0 分支不会发生,保持积分不动 */
} else {
s_integral = integ_new;
} }
/* 输出量化到 10% 档位(四舍五入) */
s_duty10 = (uint8_t)((u + (float)HEATER_PWM_STEP_PCT / 2.0f) / (float)HEATER_PWM_STEP_PCT);
} }
s_prev_err = err;
/* 输出量化到档位(四舍五入) */
s_duty = (uint8_t)((u + (float)HEATER_PWM_STEP_PCT / 2.0f) / (float)HEATER_PWM_STEP_PCT);
} }
/******************************************************************************/ /******************************************************************************/
/* 公共接口 */ /* 公共接口 */
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;
@@ -150,16 +164,16 @@ 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; /* 占空变更:从新周期起点开始,响应即时 */
} }
} }
/* 按占空档位输出: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);
@@ -169,15 +183,8 @@ void heat_task(void) {
void heat_set_enable(bool on) { void heat_set_enable(bool on) {
if (s_enable == on) return; /* 幂等 */ if (s_enable == on) return; /* 幂等 */
s_enable = on; s_enable = on;
if (on) { if (!on) {
/* 从当前实际温度起坡,避免内部目标从 0 跳变造成功率冲击 */ s_duty = 0;
s_ramp_target = ntc_get_temperature();
if (s_ramp_target < 0.0f) {
s_ramp_target = 0.0f; /* 传感器故障标记值非法,退化为 0 */
}
} else {
s_ramp_target = 0.0f;
s_duty10 = 0;
pid_reset(); pid_reset();
heat_out(false); heat_out(false);
} }
@@ -196,5 +203,5 @@ float heat_get_target(void) {
} }
uint8_t heat_get_duty(void) { uint8_t heat_get_duty(void) {
return s_duty10; return s_duty;
} }