BMS 温度门限参数表实战:同一批 12 台柜子,8℃ 时有的禁充、有的 10A、有的 20A——我把散在 7 个文件里的 23 处门限收成一张 10 行表,顺手算出无回滞时继电器只够撑 9 天

2026年10月21日 0 By admin

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 自己(加热、均衡、风扇) 效率下降、可用容量变小

四张门挂在一根轴上,但每张门缺一不可的五个参数是同一套:

  1. 作用轴:取哪一路温度(最低单体、最高单体、还是加热膜附近)
  2. 进入门限:什么温度开始动作
  3. 退出门限:什么温度解除动作
  4. 最短保持时间 dwell:越限要持续多久才认
  5. 动作与恢复条件:动作是什么、什么条件下允许恢复

第 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 单调递增,加载时:

  1. magic 不对 → 用固件内置默认值,不报错,记一条日志
  2. CRC 不对 → 用备份槽;备份也不对 → 用默认值
  3. version 比固件低 → 逐条按 ID 合并,新增 ID 取默认值,删除的 ID 忽略
  4. 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 厂标定值为准。