BMS 温度门限参数表实战:同一批 12 台柜子,8℃ 时有的禁充、有的 10A、有的 20A——我把散在 7 个文件里的 23 处门限收成一张 10 行表,顺手算出无回滞时继电器只够撑 9 天
11 月 3 日早上 7 点 12 分,运维群里连甩三张截图。
1 号柜:电池温度 8.1℃,充电限流 20.0A,状态正常。
5 号柜:8.0℃,10.0A。
9 号柜:7.9℃,0A,状态字亮着”低温禁充”。
同一批采购、同一型号、同一根 485 总线、同一台 EMS 在同一秒轮询。三块电池给出的答案差了一个数量级,还包括一个”什么都不给”。
现场的直觉是硬件坏了。我把三台的固件版本号拉出来排在一起,答案立刻变了:
| 柜号 | 固件 | 文件里的低温门限 | 8℃ 时的行为 |
|---|---|---|---|
| 9 号 | v1.2.9 | #define CHG_LOW_TEMP 8 |
判”低于 8℃”→ 禁充 |
| 5 号 | v1.3.7 | #define CHG_LOW_TEMP 5 |
过门,但限流表 5℃ 只有 0.05C = 10A |
| 1 号 | v1.4.2 | 表内 -5℃ 禁充,限流表 -5~15℃ 分四档 | 8℃ 落在 0.10C = 20A 档 |
8.0 这个数,正好落在三条线的夹缝里。三台都没坏,三台都在严格执行自己的代码。
这篇不讲”温度对电池有什么影响”,那个 118 篇 和 120 篇 已经算过两遍。这篇讲一件更工程的事:同一根温度轴上有四张门,每张门都有五个必须显式给出的参数,而绝大多数固件把其中三个漏掉了。 漏掉的后果,一个是同批设备行为分裂,另一个是继电器在门限附近按秒级频率开合——我算了一下,单门限无回滞的那一版,10 万次寿命的继电器只够撑 9 天。
一、先数一遍门限:不是没有,是有 23 个
在处理”三台行为不一致”之前,我做了一件很土的事:把 v1.4.2 的源码全量 grep 了一遍温度比较。
grep -rnE "temp|TEMP|Temp" --include=*.c --include=*.h | grep -E "<|>|==" | wc -l
23 处。分布:
- 3 处走
#define,值分别是 0、5、8(就是上面那三个版本残留) - 7 处直接写字面量,形如
if (t < 2)、if (cell_temp > 55) - 4 处把 25.0 当”常温基准”算系数(
k = 1.0 + 0.004 * (25 - t)) - 2 处在 CAN 上报里又做了一次温度分区判断,判据和本地保护不一致
- 剩下 7 处是日志文案和注释
23 处温度比较里,真正独立的设计决定不超过 10 个。剩下 13 个是同一个决定的副本,而且副本之间已经不一致了。
这就是问题的形状:门限不是没有,是散。 散在 bms_protect.c、chg_ctrl.c、heater.c、balance.c、can_report.c、modbus_slave.c、app_main.c 七个文件里,互相之间没有任何约束关系。
于是出现了一种很难查的故障:本地保护认为”8℃ 允许充电”,CAN 上报的状态字认为”5℃ 以下禁充”,上位机拿状态字做逻辑,两边打架。上位机显示禁充、BMS 实际在充——运维看到这一幕的第一反应是”通信错了”,其实是两套门限。
二、四张门,五种参数
把 23 处收拢,本质上只有四类判据。它们的响应时间、失效后果、责任方各不相同,所以不能用一套阈值草草代替。
| 门 | 触发时间尺度 | 谁执行 | 失效后果 |
|---|---|---|---|
| 硬件保护门 | 微秒~毫秒 | AFE / 比较器 / 硬件关断 | 热失控 |
| 软件禁用门 | 秒 | BMS 主控 | 析锂、永久容量损失 |
| 限流门 | 秒~分钟 | BMS 算值,PCS 执行 | 加速老化、跳机 |
| 优化门 | 分钟~小时 | BMS 自己(加热、均衡、风扇) | 效率下降、可用容量变小 |
四张门挂在一根轴上,但每张门缺一不可的五个参数是同一套:
- 作用轴:取哪一路温度(最低单体、最高单体、还是加热膜附近)
- 进入门限:什么温度开始动作
- 退出门限:什么温度解除动作
- 最短保持时间 dwell:越限要持续多久才认
- 动作与恢复条件:动作是什么、什么条件下允许恢复
第 2、3 条合起来才是”回滞”。工程上最常见的三种写法,只有第三种是对的:
/* 写法一:单门限,最常见的错 */
if (t < 8.0f) chg_block = 1;
else chg_block = 0;
/* 写法二:阈值 + 常数回滞,符号容易写反 */
if (t < 8.0f - 3.0f) chg_block = 1;
else if (t > 8.0f + 3.0f) chg_block = 0;
/* 低温门写成 +3 就是反的:本意是 5℃ 进、11℃ 出,实际变成 5℃ 进、11℃ 才出
看上去对,但换成高温门 if (t > 55 + 3) 就成了 58℃ 才进,方向全错 */
/* 写法三:成对给 enter / exit,方向由表里的语义决定 */
if (!chg_block && t <= enter && dwell_ok(TRIP_CHG_LO)) chg_block = 1;
if ( chg_block && t >= exit && dwell_ok(RESET_CHG_LO)) chg_block = 0;
写法二的坑我在三家的代码里都见过。符号错误在低温侧和高温侧恰好相反,所以只测冬天不测夏天,永远测不出来。
三、一根温度轴上的八个区段
把四张门叠起来,温度轴被切成八段。这张表是后面所有讨论的地基,也是给运维看的那张表——他们不需要知道寄存器,只需要知道”现在几度、能干什么”。
| 区段 | 温度范围 | 充电 | 放电 | 加热 | 均衡 |
|---|---|---|---|---|---|
| A 极冷 | ≤ -20℃ | 禁 | 禁 | 开 | 禁 |
| B 冷 | -20 ~ -5℃ | 禁 | 0.2~0.5C | 开 | 禁 |
| C 凉 | -5 ~ 5℃ | 0.05C | 0.8~1.0C | 开→关 | 禁 |
| D 常温 | 5 ~ 15℃ | 0.05→0.50C | 1.0C | 关 | 12℃ 起 |
| E 舒适 | 15 ~ 45℃ | 1.0C | 1.0C | 关 | 开 |
| F 热 | 45 ~ 55℃ | 0.8→0.2C | 0.8~0.5C | 关 | 禁 |
| G 过热 | 55 ~ 60℃ | 禁 | 0.2C | 关 | 禁 |
| H 危险 | > 60℃ | 禁 | 禁 | 关 | 禁 |
按 16S 200Ah(额定 1C = 200A)展开成电流:
| 温度 | 充电限流 | 放电限流 |
|---|---|---|
| -20℃ | 0 | 40A(0.20C) |
| -10℃ | 0 | 100A(0.50C) |
| -5℃ | 10A(0.05C) | 130A(0.65C) |
| 0℃ | 10A(0.05C) | 160A(0.80C) |
| 5℃ | 20A(0.10C) | 180A(0.90C) |
| 10℃ | 40A(0.20C) | 200A(1.00C) |
| 15℃ | 100A(0.50C) | 200A(1.00C) |
| 25℃ | 200A(1.00C) | 200A(1.00C) |
| 45℃ | 160A(0.80C) | 160A(0.80C) |
| 50℃ | 100A(0.50C) | 100A(0.50C) |
| 55℃ | 0 | 40A(0.20C) |
| 60℃ | 0 | 0 |
充电这一列和 118 篇 那张 0℃ 限流表是同一套口径,可以直接对账:0.05C = 10A → CC 段 14h14m,加 CV 24 分钟,总共 14h37m;5℃ 0.10C = 20A → 8h32m;10℃ 0.20C = 40A → 4h22m;15℃ 0.50C = 100A,但充电器只有 50A,所以 3h10m——和 25℃ 的 3h13m 几乎一样,这就是 118 篇那个”15℃ 以上充电器成瓶颈”的判据。
表里 -20~-5℃ 那两格充电写 0,不是曲线插值的结果,而是 T03 禁用门整段盖住了限流曲线:曲线在该段其实还有值(-10℃ 插出约 6.7A),但永远不会被用到。这是有意保留的——曲线保持单调连续,哪天把禁用门从 -5℃ 挪到 -10℃,不会突然冒出一个断点。禁用门与限流曲线在同一段重叠时,禁用门优先,这是优先级而不是巧合。
注意 -20℃ 那两个 0 之间还夹着 40A 放电。 这是有意为之:极冷时完全不许放电会让 BMS 自己也没法自检预充,而 0.2C 的放电对 LFP 在 -20℃ 是可接受的(容量掉一半,但不析锂)。禁充和禁放的门限从来不是同一个数,凡是写成同一个常量的代码,一定有一侧是错的。
四、回滞不是”加个常数”,先把采样误差算清楚
“回滞取 3℃”这句话在全国至少几万台设备里被抄过,但很少有人能说清 3 是怎么来的。它应该由采样链路的不确定度反推。
4.1 分压链路的五项误差
NTC 10k/B3950,上拉 10k,Vref 3.3V,12 位 ADC,散热常数 2mW/K。逐项算:
| 温度 | R(kΩ) | V(V) | dV/dT(mV/K) | 量化(K) | R±1% | B±1% | 上拉±1% | 自热(K) | 独立基准1% |
|---|---|---|---|---|---|---|---|---|---|
| -30℃ | 200.20 | 3.1430 | -9.99 | 0.081 | 0.149 | 0.448 | 0.149 | 0.025 | 3.146 |
| -20℃ | 105.38 | 3.0140 | -16.10 | 0.050 | 0.161 | 0.382 | 0.162 | 0.043 | 1.872 |
| -10℃ | 58.25 | 2.8165 | -23.54 | 0.034 | 0.174 | 0.309 | 0.175 | 0.068 | 1.196 |
| 0℃ | 33.62 | 2.5435 | -30.87 | 0.026 | 0.188 | 0.229 | 0.188 | 0.096 | 0.824 |
| 5℃ | 25.92 | 2.3814 | -33.84 | 0.024 | 0.195 | 0.186 | 0.195 | 0.109 | 0.704 |
| 25℃ | 10.00 | 1.6500 | -36.66 | 0.022 | 0.224 | 0.000 | 0.224 | 0.136 | 0.450 |
| 45℃ | 4.35 | 1.0000 | -27.20 | 0.030 | 0.255 | 0.214 | 0.255 | 0.115 | 0.368 |
| 55℃ | 2.98 | 0.7573 | -21.40 | 0.038 | 0.271 | 0.331 | 0.271 | 0.096 | 0.354 |
| 60℃ | 2.49 | 0.6571 | -18.73 | 0.043 | 0.279 | 0.392 | 0.280 | 0.087 | 0.351 |
三个反直觉的点,每一个都能直接改设计:
第一,B 值公差是大头,而且两头大、中间小。 25℃ 处 B 值误差对读数的影响严格为 0(B 值公差只改变曲线斜率,不改变 25℃ 那个锚点),到了 -20℃ 就有 0.38K,-30℃ 有 0.45K。所以”我在常温校验过,误差 0.1℃”这句话,对低温段完全不成立。
第二,自热峰值不在高温端,在 25℃。 因为 NTC 功耗 P = V²/R,而 V 随温度下降、R 也随温度下降,两者在 R = 上拉电阻那一刻取极值——也就是 25℃。算出来 0.136K,是 60℃ 处(0.087K)的 1.6 倍。这条直接决定上拉电阻取值:上拉从 10k 改成 1k,电流翻 10 倍,自热翻 100 倍,25℃ 处就是 13.6K 的自热漂移,整条曲线报废。上拉不是”越小越好,抗干扰强”,它有下限。
第三,独立基准的 1% 会在低温段变成 3K 的误差。 这一项最大,也最容易白扔。
4.2 比例激励:一个几乎免费的 3K
上表最后一列是”ADC 基准独立、误差 1%”的情形:0℃ 时 0.82K,-20℃ 时 1.87K,-30℃ 时 3.15K。这一项比 NTC 本身所有误差加起来还大。
但如果分压的激励源和 ADC 的基准是同一路电压(ratiometric,比例式测量),这一项严格抵消:
raw = 4095 × Vref × R_ntc / (R_ntc + R_pull) ÷ Vref = 4095 × R_ntc / (R_ntc + R_pull)
Vref 在分子分母各出现一次,直接约掉。基准漂 5% 也不影响读数。
代价是零:只要把 NTC 上拉接到 ADC 的 REF 引脚,而不是接到另一路 3.3V。设计评审时我见过太多次”上拉接 3V3、基准用内部 1.2V 带隙”,一算就是白白多出 1~3K 的低温误差——然后再去买 0.1% 的精密电阻来”解决”,其实方向完全错了。
合成结果:
| 温度 | RSS(比例激励) | 最坏和(比例激励) | RSS(独立基准) |
|---|---|---|---|
| -20℃ | 0.49K | 1.00K | 1.94K |
| 0℃ | 0.42K | 0.93K | 0.92K |
| 25℃ | 0.40K | 0.81K | 0.60K |
| 55℃ | 0.55K | 1.21K | 0.66K |
4.3 反推最小回滞
回滞要盖住的是”最坏和”,不是 RSS。因为 RSS 假设各误差独立随机,而工程上必须按最坏情况设计判据——门限误动的后果不是”平均一下”能接受的。
取 2 倍最坏和、再向上取整到 1K:
| 门限位置 | 最坏和 | 建议回滞 |
|---|---|---|
| -20℃ | 1.00K | 2K |
| -5℃ | 0.94K | 2K |
| 0℃ | 0.93K | 2K |
| 5℃ | 0.91K | 2K |
| 12℃ | 0.88K | 2K |
| 25℃ | 0.81K | 2K |
| 45℃ | 1.07K | 3K |
| 52℃ | 1.16K | 3K |
| 55℃ | 1.21K | 3K |
| 60℃ | 1.28K | 3K |
这张表解释了两件事:为什么低温侧 2K 就够(误差小),为什么高温侧必须给 3K(误差大)。而现场那台”回滞写了 0.5K”的板子,0.5K 连 0℃ 的量化加 R 公差都盖不住——它的门限其实是个随机数。
五、没有回滞的代价:继电器 9 天
回滞的作用常被说成”防止抖动”,太抽象。我搭了个集总热模型,把这件事算成继电器动作次数。
模型两个节点:
- 包体:C = 93.8 kJ/K(92kg × 1.02 kJ/kgK),向环境漏热 k = 5 W/K → 时间常数 τ = 5.2 小时,200W 加热的平衡温升 ΔT_max = 40K
- 局部节点:加热膜覆盖的 5kg 电池块,C = 4.5 kJ/K,与包体耦合 20 W/K → τ = 225 秒
- 环境:1 ± 4℃ 正弦(冬季阴天),包体从 0℃ 起步,仿真 24 小时
- 测量:一阶滤波 τ=8s,叠加 σ=0.10K 噪声
结果:
| 控制方案 | 动作次数/天 | 折算/年 | 耗电 kWh/天 | 继电器寿命 |
|---|---|---|---|---|
| 单门限 8℃ / 无回滞 / 无 dwell | 11068 | 4,039,820 | 0.836 | 9 天 |
| 开 5 关 7 / 2K 回滞 / 无 dwell | 310 | 113,150 | 0.562 | 0.9 年 |
| 开 5 关 8 / 3K 回滞 / 无 dwell | 214 | 78,110 | 0.583 | 1.3 年 |
| 开 5 关 12 / 7K 回滞 / 无 dwell | 72 | 26,280 | 0.628 | 3.8 年 |
| 开 5 关 12 / 7K 回滞 / dwell 60s | 58 | 21,170 | 0.622 | 4.7 年 |
| 开 5 关 12 / 7K 回滞 / dwell 300s | 36 | 13,140 | 0.650 | 7.6 年 |
第一行的机理值得说清楚:单门限时,加热到 8℃ 关断,局部节点立刻开始向包体漏热,速率 = 20 × (8 − 3) / 4500 ≈ 0.022 K/s,也就是不到 1 秒就跌回 7.99℃,于是马上重开。周期由 1 秒的仿真步长和噪声主导,一天一万一千次。
10 万次机械寿命的小型功率继电器,9 天报废。这跟”冬天设备坏得多”是同一件事——不是冷坏的,是继电器开关坏的。
10 万次机械寿命的功率继电器,按无回滞的那版跑,9 天到寿。这跟”冬天设备坏得多”是同一件事——不是冷坏的,是开关坏的。
顺带一个反直觉:回滞放大到 7K 之后,耗电反而比 2K 回滞时高 12%,但比无回滞时低 25%。 因为无回滞那一版在门限附近反复通断,每次都要重新加热刚刚散掉的表层热量,白白浪费。所以”回滞大一点省电”这个说法也不对,它不是单调的——2K 到 7K 之间能量略有上升(平均温度抬高了),7K 之后才开始降。能效和寿命在这里不是同一个优化方向,必须先保寿命。
六、dwell:时间不是回滞的替代品
表里从 72 次降到 36 次的那一步,靠的不是回滞而是 dwell(最短保持时间)。很多代码把这两者混为一谈,写成”温度越限持续 60 秒才动作”就算完事,结果回滞是 0——这样噪声照样能穿过,只是每次误动作被延后 60 秒,一天还是几千次。
正确的分工:
- 回滞管幅度:噪声的峰峰值有多大,回滞就至少要比它大
- dwell 管时间:真实的温度事件持续多久,dwell 就至少要比它短
dwell 的下限由 NTC 自身的热时间常数决定:传感器本体要和被测物达到热平衡,通常 τ = 5~30 秒(贴片式 NTC 小、金属壳封装的 NTC 大)。dwell 至少取 3~5 倍 τ 才有意义,本文取 60 秒对应 τ ≈ 12~20 秒的探头。
dwell 的上限由受害者的忍受时间决定。这里有一条可以直接用的不等式,和 114 篇 那条”检测 + 去抖要小于负载 hold-up”是同一个形状:
5 × τ_NTC ≤ dwell ≤ 保护响应要求 − 限流生效延迟
以硬件过温 65℃ 为例:保护响应要求是”5 秒内必须关断”,限流生效延迟(PCS 收到新限值到实际降流)约 200ms,那么 dwell 上限约 4.8 秒,取下限 5 秒都不够——所以硬件保护门根本不该有 dwell,它是唯一一条 dwell = 1(即每周期都判)的条目。 表里 T10 就是这么写的,1 秒,实际上等于”立即”。
反过来,加热门的 dwell 可以给到 120~300 秒,因为电池包的热惯性是小时量级,晚开两分钟加热没人受影响。同一个字段,在不同的门上差两个数量级,这就是为什么它必须进表,不能写成全局常量。
七、轴选错:三根温度轴差 83 倍
四张门不能共用一根轴。这是我在现场见过最多、也最难解释的一类”功能不工作”。
| 轴 | 取法 | 用于 | 理由 |
|---|---|---|---|
| T_min | 所有单体中最小的 | 禁充、限流、均衡 | 最冷的那节决定能不能充(析锂从最冷点开始) |
| T_max | 所有单体中最大的 | 高温降额、过温保护 | 最热的那节决定会不会热失控 |
| T_local | 加热膜附近的独立探头 | 加热控制 | 加热是局部的,等全包热起来要 5 小时 |
第三行是关键。看仿真里那两个时间常数:包体 τ = 5.2 小时,局部 τ = 225 秒,差 83 倍。
如果加热控制用 T_min(电芯最冷点):200W 要把 93.8 kJ/K 的整体从 0℃ 抬到 12℃,需要 93.8 × 12 = 1126 kJ = 313 Wh,理论上 1.56 小时,考虑漏热实际 2 小时以上。这两个小时里加热器一直在满功率烧,运维看到的现象是”加热器烫手、电芯还是冷的”。
如果加热控制用 T_local:225 秒量级就能到位,然后靠包体内导热慢慢均温。
现场那句”升到 12℃ 了还在烧”,绝大多数不是控制逻辑错,是 exit 判断读错了轴——用的是 T_min,而 T_min 在全包均温之前永远到不了 12℃。控制器忠实执行了一条永远不成立的退出条件。
控制器不是不听话,它是在忠实执行一条永远不成立的退出条件。所有”功能不工作”的现场问题里,这一类最贵——因为代码每一行都对,错的是”读哪根线”。
顺带这条也解释了”两只 NTC 差 6℃”的现象:一只贴在加热膜上(T_local,升得快),一只贴在电芯上(T_min,升得慢)。不是探头坏了,是它们本来就在测两个不同的东西。布线时把这两根线的标签写清楚,比校准重要。
同一个道理反过来用也成立:充电限流必须取 T_min,取平均会出事。 4 簇电池里 3 簇 10℃、1 簇 -2℃,平均 7℃,按 7℃ 给 0.14C 充电,那一簇 -2℃ 的电芯就在析锂边界上。这和 113 篇 讲的”多簇聚合限流取 min”是同一条规则,只是从簇间下沉到了单体间。
八、参数表:10 行、16 字节、一个故障码
收拢的载体是这张表。每条记录 16 字节:
typedef struct {
uint16_t id; /* 也是故障码低位,T03 -> 0x0103 */
uint8_t type; /* 0 禁用 1 限流 2 优化 3 硬件 */
uint8_t axis; /* 0 T_min 1 T_max 2 T_local */
int16_t enter; /* 0.1℃ */
int16_t exit; /* 0.1℃ */
uint16_t dwell_s; /* 秒 */
uint8_t action; /* 动作编码 */
uint8_t rsrv[5];
} temp_gate_t;
十条,覆盖四张门:
| ID | 名称 | 轴 | 方向 | 进入 | 退出 | dwell | 动作 |
|---|---|---|---|---|---|---|---|
| T01 | NTCRaw_Valid | T_min | 无方向 | -40.0℃ | -40.0℃ | 5s | SENSOR_FAULT |
| T02 | DischargeBlock_Lo | T_min | 升温退出 | -20.0℃ | -18.0℃ | 30s | DCL=0 |
| T03 | ChargeBlock_Lo | T_min | 升温退出 | -5.0℃ | -3.0℃ | 60s | CCL=0 |
| T04 | ChargeLimit_Lo | T_min | 升温退出 | -5.0℃ | 15.0℃ | 60s | CCL 按表插值 |
| T05 | HeaterOn | T_local | 升温退出 | 5.0℃ | 12.0℃ | 120s | HEATER=1 |
| T06 | BalanceEnable | T_min | 降温退出 | 12.0℃ | 8.0℃ | 300s | BAL=1 |
| T07 | DerateOn | T_max | 降温退出 | 45.0℃ | 43.0℃ | 60s | DERATE=1 |
| T08 | ChargeBlock_Hi | T_max | 降温退出 | 55.0℃ | 52.0℃ | 30s | CCL=0 |
| T09 | DischargeBlock_Hi | T_max | 降温退出 | 60.0℃ | 57.0℃ | 30s | DCL=0 |
| T10 | HwOverTemp | T_max | 降温退出 | 65.0℃ | 60.0℃ | 1s | HW_TRIP |
几个刻意的设计决定:
ID 即故障码。 T03 触发时报 0x0103,高位 0x01 表示温度类。运维看到 0x0103 直接翻到表的第三行,不需要查代码、不需要问人。这比”温度故障 = 0x21″这类编码强一个量级——后者永远需要在文档和代码之间来回跳。
“方向”是显式字段,不是靠 enter/exit 大小推断。 低温门是”低于 enter 进、高于 exit 出”(exit > enter),高温门是”高于 enter 进、低于 exit 出”(exit < enter)。如果让代码去猜,遇到 T02 那种”enter = -20、exit = -18″没问题,但遇到 T06 均衡门”12℃ 起、8℃ 停”(enter > exit 却是个低温侧的使能门),猜方向必然出错。让表写清楚,代码只做比较,不做推理。
T10 的 dwell = 1,不是 1 分钟。 前面算过,硬件过温的响应预算只有 5 秒,容不下任何去抖。
单位全是定点整数。 温度 0.1℃,dwell 1 秒,电流 0.01C。整个模块里没有一个 float,也没有 printf("%f")——这条和 MCU 上”零 malloc + 禁 %f”的加固是同一条纪律,因为百分号 f 的浮点格式化会拖进几 KB 的 libm 和一次不可预测的栈峰值。
九、阶梯还是线性:定点插值的三个坑
限流门不是”过/不过”,而是一条曲线。曲线怎么存、怎么算,决定了低温段会不会出现断崖。
第一坑:阶梯的台阶太大。 本文的放电限流表在 -10℃ 有个 100A → 160A 的台阶,高温侧 50℃ 有个 160A → 100A 的台阶。60A 的阶跃意味着:负载 120A 时,温度从 49.9 跨到 50.1,PCS 从”允许”直接变”过载跳机”。加 3K 回滞之后,49.9 和 50.1 都合法——同一个物理状态有两个合法答案,这是最坏的一种设计。
阶梯与线性在同一批点上的差:
| 温度 | 阶梯 | 线性 | 差 |
|---|---|---|---|
| -5℃ | 100.0A | 130.0A | 30.0A |
| -2℃ | 100.0A | 148.0A | 48.0A |
| 4.8℃ | 160.0A | 179.2A | 19.2A |
| 5.2℃ | 160.0A | 180.8A | 20.8A |
低温段 30~48A 的差别不是小数:48A 对 200Ah 就是 0.24C,直接跨过了一个完整的限流档。
第二坑:定点插值的舍入方向。 C 的整数除法在分子为负时向零截断,Python 的 // 是向下取整,两者差 1 个 LSB:
| 输入(0.1℃) | float | trunc | floor | trunc 误差 | floor 误差 |
|---|---|---|---|---|---|
| 35.3℃ | 99.40 | 100.00 | 99.00 | +0.60 | -0.40 |
| 0.7℃ | 5.70 | 5.00 | 5.00 | -0.70 | -0.70 |
1 个 LSB 在 0.01C 单位下就是 2A。听上去很小,但当这条曲线被用来做”限流值回读自校验”时(把算出来的值和上位机读到的值比对),1 LSB 的固定偏差会让自校验永久报警。处理办法是把舍入方向写进规范:限流一律向安全侧取整(取小),并在注释里注明”这不是 bug”。
第三坑:乘积溢出。 用 (y2-y1) * (x-x1) / (x2-x1) 时:
| 量纲 | 乘积 | 结论 |
|---|---|---|
| 温度 0.1℃ × 电流 0.01C | 600 × 10000 = 6,000,000 | 安全 |
| raw ADC × 原始电流 | 4095 × 65535 = 268,365,825 | 安全 |
| 0.01℃ × 0.001C | 6000 × 100000 = 600,000,000 | 安全 |
| 0.001℃ × 0.001C | 600000 × 100000 = 60,000,000,000 | 溢出(int32 上限 2.1e9) |
归一化到 0.1℃ / 0.01C 之后,最大乘积 6e6,留 350 倍余量。“提高分辨率更准”这句话在定点算术里是错的——把单位从 0.1℃ 提到 0.01℃,插值的乘积就要超 int32,得换 int64,而 int64 除法在 Cortex-M0/M3 上是软件实现,一次 200~1600 周期。在最紧的那个 100ms 任务里,这就是压垮调度的那根稻草。
十、多路取 min,并且说清是谁在压着
一根轴算出了温度限流,但它不是最终值。最终值是四路的交集:
CCL_final = min(CCL_temp, CCL_soc, CCL_volt, CCL_user)
四个场景,同样的”EMS 要 30A”:
| 场景 | 温度 | SOC | 电压 | 用户 | 最终 | 受限项 |
|---|---|---|---|---|---|---|
| 冬季 0℃ / SOC 60% | 0.05C = 10A | 0.60C | 1.00C | 0.30C | 10A | 温度 |
| 冬季 0℃ / SOC 95% / 已到 CV | 0.05C = 10A | 0.95C | 0.00C | 0.30C | 0A | 电压 |
| 冬季 0℃ / SOC 15% | 0.05C = 10A | 0.15C | 1.00C | 0.30C | 10A | 温度 |
| 常温 25℃ / 要 30A | 1.00C | 0.60C | 1.00C | 0.30C | 60A | 用户 |
| 高温 52℃ / 要 60A | 0.20C = 40A | 0.60C | 1.00C | 0.60C | 40A | 温度 |
第二行是现场最常见的一次误判:最终 0A,但受限项是电压,不是温度。 运维看到”0A”就往温度上查,查一上午;实际上电池已经到恒压点,BMS 完全正常。
所以取 min 之后必须再输出一个”受限项”编码:
- 0 无限制 / 1 温度 / 2 SOC / 3 电压 / 4 用户或 EMS / 5 硬件保护 / 6 通信超时
这 3 个 bit 放进状态字或者标准预留区,成本是零,省下的是现场一上午。116 篇 里 T/CIAPS 0009 的 0x0110~0x0135 是预留区,厂商私有字段正好放这里——注意这是私有扩展,别指望第三方 EMS 认得,文档要自己出。
十一、NTC 断线:四种写法,只有一种是对的
温度门限的全部前提是”这个温度是真的”。而这一环是整个链路里最容易被写成”默认值”的地方。
先看物理量。NTC 在下、上拉在上、同源激励:
| 条件 | R | V(V) | raw(12bit) |
|---|---|---|---|
| 开路(断线) | 1e12 | 3.3000 | 4095 |
| -40℃ 下限 | 401.9k | 3.2199 | 3996 |
| 25℃ | 10.00k | 1.6500 | 2048 |
| 125℃ 上限 | 359Ω | 0.1143 | 142 |
| 短路 | 0 | 0.0000 | 0 |
合法窗口 142 ≤ raw ≤ 3996。判据:raw > 4050 判开路、raw < 20 判短路,连续 5 次采样(5 × 100ms)确认。
四种我在真实代码里见过的写法:
/* 一、最坏:越界返回常温 */
if (raw == 0 || raw >= 4095) return 25.0f;
/* 断线时 BMS 认为电池 25℃,冬天真实 -5℃,于是按 1.0C 满流充。
这是唯一一类会当场烧东西的写法。 */
/* 二、稍好但错:越界返回上一次的值 */
if (raw == 0 || raw >= 4095) return last_temp;
/* 断线瞬间的温度被冻结,加热器按 -3℃ 一直烧,直到热熔断体动作。 */
/* 三、方向对但判据错:只看 raw == 0 和 raw == 4095 */
if (raw == 0 || raw == 4095) return FAULT;
/* ADC 噪声下几乎不会精确命中端点,实测断线时读到 4093~4095 都有,
于是漏判。必须用窗口,不能用等号。 */
/* 四、对的:窗口 + 计数 + 最保守降级 */
if (raw < 20 || raw > 4050) {
if (++fault_cnt >= 5) {
temp_state = SENSOR_FAULT;
ccl = 0; /* 禁充 */
dcl = 0.2C; /* 限放,留出保护动作窗口 */
report_fault(0x0101); /* T01 */
}
} else {
fault_cnt = 0;
}
第四条里的”限放 0.2C”值得单独说:断线之后完全禁放会让 BMS 失去自检预充能力、也让 PCS 无法做绝缘检测(108 篇 那条链路要用主回路),而保留 0.2C 既能维持基本功能,又能在真出问题时把能量限制在可控范围。
还有一个硬件级的坑:接法反过来(NTC 在上、上拉在下)时,开路读 0、短路读 4095,两条判据完全互换。 写代码前拿万用表拔一次探头,看是 Vref 还是 0V。这一步花 30 秒,能省掉一批”冬天莫名其妙满流充电”的返修。
十二、落 NVM:192 字节,13.7 年
参数表要能改、要掉电保持、要能升级。这三个要求互相牵制。
尺寸。 10 条 × 16B = 160B 表体,加 32B 头(magic 4 + version 2 + count 2 + crc16 2 + timestamp 4 + 保留 18)= 192B。单槽取 1KB(对齐到扇区最小写单位),双备份 2KB。
CRC。 CRC-16/CCITT,多项式 0x1021,初值 0xFFFF。校验值 '123456789' → 0x29B1,这是行业标准锚点,用来验证自己的实现没写错。
擦写寿命。 这是最容易翻车的一项,10k 次/扇区,双槽轮换 → 5k 次/槽:
| 写入策略 | 每次写入频率 | 寿命 |
|---|---|---|
| 每次上电写一次 | 5 次/天 | 2.7 年 |
| 温度状态变化就写 | 约 40 次/天 | 0.3 年 |
| 每天改一次参数 | 1 次/天 | 13.7 年 |
| 每月改一次 | 1/30 次/天 | 411 年 |
第一、二行是真实事故:把”当前温度状态”当成配置一起落 NVM,扇区三个月就磨穿。规则很简单——运行态绝不落 NVM,只落配置;写前比对新旧值,相同就不写。
版本迁移。 表头 version 单调递增,加载时:
- magic 不对 → 用固件内置默认值,不报错,记一条日志
- CRC 不对 → 用备份槽;备份也不对 → 用默认值
- version 比固件低 → 逐条按 ID 合并,新增 ID 取默认值,删除的 ID 忽略
- version 比固件高 → 整表拒绝,用默认值(旧固件不认识新字段,硬读会解析错)
第 3 条里的”未知 ID 忽略不报错”很关键。反过来做(遇到未知 ID 就整表拒绝)会让降级回滚变成灾难:刷回旧固件,参数全丢,现场要重新标定一遍。
原子性。 单条记录的 16 字节写入要保证原子:要么整条双缓冲后指针切换,要么用”写影子 → 校验 → 提交”三步。中途掉电留下半条记录,是最难查的一类参数错乱——电压门限读成了电流门限的量纲,数值合法、语义全错。
十三、十条断言,跑在 CI 里
表是可以被改的,所以表必须能被验。我写了十条断言,每次改表在 PC 上跑一遍,30 毫秒出结果:
- ☐ ID 唯一,且与故障码一一对应
- ☐ 同一张曲线内温度节点严格递增
- ☐ 充电限流在 -20~25℃ 段单调不减
- ☐ 充电限流在 25~60℃ 段单调不增
- ☐ 放电限流双向单调
- ☐ 任一门的 enter 不等于 exit(无回滞)
- ☐ 低温门 exit > enter;高温门 exit < enter
- ☐ 每条门都有 dwell ≥ 1
- ☐ 回滞 ≥ 2 × 采样最坏和
- ☐ 门限顺序不倒置:禁充低 < 加热开 < 加热关 < 降额起 < 禁充高 < 禁放高
人为注入三处错误,捕获结果:
[ERR] ID 重复
[ERR] CCL 在低温段非单调不减:50(10) -> 100(8)
[ERR] T07 DerateOn 高温门方向反了:exit 48.0 应 < enter 45.0
[ERR] T07 DupId 缺 dwell
[WARN] T05 HeaterOn 回滞 0.5K 小于 3σ 采样不确定度
第 3 条是最有价值的一条。enter = 45, exit = 48——如果代码按”低温门”的写法实现(t <= exit 才退出),这条门就永远退不出去,45℃ 降额之后再也不恢复。而这种错误在常温测试里永远不出现,只有到了夏天、并且要等温度降下来才暴露。
第 2 条也值得说:50(10) -> 100(8),5℃ 给 0.10C、10℃ 反而给 0.08C。这种错误不会报任何异常,只会让”越暖充得越慢”,运维只会觉得”冬天就这样”。
这十条断言不是形式主义,它们每一条都对应一次真实的现场故障。
十四、三套预设:换一行参数,不用重烧固件
门限的取值本质上是风险偏好,不是物理常数。同一套代码,三家客户要三种取法:
| 项 | 保守 | 标准 | 激进 |
|---|---|---|---|
| 加热开 | 8℃ | 5℃ | 0℃ |
| 加热关 | 15℃ | 12℃ | 10℃ |
| 降额起 | 40℃ | 45℃ | 50℃ |
| 高温禁充 | 50℃ | 55℃ | 58℃ |
同一台柜子、同一个冬天、同一个仿真:
| 预设 | 加热耗电 kWh/天 | 动作次数/天 | 包体最低温 |
|---|---|---|---|
| 保守 | 1.037 | 78 | 0.0℃ |
| 标准 | 0.631 | 50 | 0.0℃ |
| 激进 | 0.136 | 6 | -0.1℃ |
保守档一天烧 1.04 kWh,是标准档的 1.64 倍;激进档只要 0.14 kWh,是标准档的 22%,代价是 0~5℃ 段一直卡在 0.05C 档,充满时间从 8.5 小时拉到 14.5 小时(118 篇 的账)。
这三套的差别不在代码里,只在这张表里。 这就回答了开头那个问题:同批设备行为分裂,不该靠刷三个固件版本解决,而应该让一套固件读三份参数、参数带版本号、EMS 侧能查到每台机器当前用的是哪份。
顺带一句:参数表不要做成”运行时可逐条改”。 如果允许在线改单条,就会出现”改了 CCL 表的节点温度、还没改电流”的中间态,插值瞬间给出一个谁也没算过的数——比如 10℃ 节点被推到 12℃、电流还是 0.20C,于是 11℃ 时按 0.20C 算,而按新表的语义 11℃ 应该是 0.10C。整表替换 + CRC 校验 + 一次性 swap,是唯一的正确姿势。
十五、排障表
| 现象 | 层 |
|---|---|
| 同批柜子 8℃ 有的禁充有的满充 | 参数表版本不一致,不是硬件 |
| 门限附近继电器每分钟响一次 | 缺回滞或缺 dwell |
| 加热到 12℃ 还在烧 | exit 判断读错了轴(用了 T_min 而不是 T_local) |
| 降温后永远不恢复充电 | 单向锁存没写复位条件 |
| NTC 拔掉后显示 25℃ | 越界分支返回常温,fail-open,最危险 |
| 两只 NTC 差 6℃ | 一只贴加热膜、一只贴电芯,轴不同不是故障 |
| EMS 要 30A 但只有 0A,报 BMS 故障 | 受限项是电压,缺 limiting source 上报 |
| 参数改完重启就丢 | 写 NVM 没校验,或没等写完成就复位 |
| 参数表 CRC 错后整机不启动 | 没有默认值回退分支 |
| -20℃ 显示 -23℃,差 3℃ | B 值/上拉公差叠加,或非比例激励 |
| 均衡常年不开 | 均衡窗口被低温限流挤没了(120 篇 的 24 分钟窗口) |
| 夏天 45℃ 后电流断崖 | 阶梯表 20% 跳变且无回滞 |
第十二行补一句为什么 45℃ 就降额、而不是等到 55℃ 才管:按”每升 10℃ 老化速率翻倍”的经验口径,45℃ 常年运行 1 个日历年抵 4 个 25℃ 年,55℃ 抵 8 个。降额从 45℃ 起不是为了防炸,是为了防老。 这个口径是工程经验(对应 Ea ≈ 0.6 eV 的 Arrhenius 近似),不是标准条款,用在内部评估可以,写进验收文件要注明出处。
十六、可以直接抄的清单
- ☐ 全量 grep 一遍温度比较,把散落的门限列成一份清单(本文那次是 23 处 / 7 个文件)
- ☐ 四张门分开:硬件保护 / 禁用 / 限流 / 优化,响应时间差两个数量级,不能共用一套阈值
- ☐ 每条门给五个参数:轴、enter、exit、dwell、动作与恢复条件。缺一个就是隐患
- ☐ 回滞由采样最坏和反推:比例激励下低温侧 2K、高温侧 3K
- ☐ 分压激励与 ADC 基准同源(ratiometric),白送 1~3K 的低温精度
- ☐ 上拉电阻别低于 10k,自热在 25℃ 处取峰值(0.136K,1k 上拉会变成 13.6K)
- ☐ 单门限无回滞 = 继电器 9 天报废;dwell 与回滞分工,别互相替代
- ☐ 三根轴分开走线并打标签:T_min 管充电、T_max 管保护、T_local 管加热
- ☐ 加热控制的 exit 必须读 T_local,包体 τ 与局部 τ 差 83 倍
- ☐ 表用定点:温度 0.1℃、电流 0.01C、dwell 1s,全模块禁 float 与百分号 f
- ☐ ID 即故障码,运维看码就能翻到表行
- ☐ 取 min 之后必须输出 limiting source,否则”0A”永远查错方向
- ☐ NTC 断线用窗口判据(142~3996)不用等号,确认后禁充 + 限放 0.2C
- ☐ 运行态不落 NVM,只落配置;写前比对,相同不写;双槽轮换
- ☐ 表整表替换 + CRC + 版本号,禁止在线逐条改
- ☐ 十条断言进 CI,改表就跑
最后回到 11 月 3 日那三张截图。处理方式不是把 9 号柜刷成 1 号柜的固件。那样下周会有另一台柜子在 5.1℃ 上分裂——因为根因不是版本落后,是门限没有单一真相源。
真正的修法是三步:把 23 处收成 10 行;把 10 行做成一张带版本号、带 CRC、能被 lint 的表;把表的当前版本和受限项一起上报。做完之后,运维群里那张截图会变成同一行字:T=8.0 / CCL=20.0A / limit=TEMP / cfg=v1.5.0。三台机器一致,或者不一致的地方一眼能指出来。
至于 118 篇的 CCL 表、120 篇的加热门限、这篇的高温降额,现在都挂在了同一根温度轴上。下一个问题自然就来了:这根轴上还有一根线没画——NTC 坏了、漂了、两只不一致的时候,这套门限怎么知道自己站在错的温度上。 断线只是最粗的一种,缓慢漂移才是真麻烦:它不报警、不越界,只是让所有的门都整体平移几度。下一篇把采样链路的四种故障(断线、短路、漂移、双探头不一致)的在线判据和注入测试拆开算。
本文所有门限值、误差预算与动作次数均由配套脚本 temp_threshold_calc.py 计算,含 243 项自检断言;限流表口径与站内 118/120 两篇一致,可直接对账。文中门限取值为典型工程口径,实机须以电芯规格书与 PACK 厂标定值为准。