第四节:开路电压法修正——OCV-SOC曲线标定、查表法实现、静置时间判断

各位同学,咱们接着聊。

上一节讲了安时积分怎么被误差慢慢"吃掉"精度。那怎么给它"喂解药"呢?最经典的一招,就是开路电压法修正。

说白了,就是利用电池静置后的开路电压(OCV),反推出当前的真实SOC。然后拿这个真实值,去修正安时积分累积的偏差。

这个方法,我在做第一块BMS样机时就用了。简单、可靠,直到今天,几乎所有量产BMS都离不开它。

4.1 OCV-SOC曲线标定——你得先有把"尺子"

开路电压法修正的前提,是你手里有一张准确的OCV-SOC关系表。这张表,就是你的"尺子"。

没有这把尺子,你测出开路电压也没用,因为不知道它对应多少SOC。

4.1.1 标定流程

我个人习惯,标定OCV曲线时,会按以下步骤来:

  1. 准备电池:充满电(SOC=100%),静置2小时以上。
  2. 放电:以0.05C小电流放电,放出5%的容量。然后静置1小时。
  3. 记录:静置结束前10秒,记录端电压。这个电压就是该SOC点下的OCV。
  4. 重复:继续放电5%,静置,记录。直到SOC=0%。
  5. 充电方向:同样步骤,从0%充到100%,再记录一组数据。
  6. 取平均:充放电两组数据取平均,作为最终标定值。
注意:标定时的温度要控制好。我建议在25℃恒温箱里做。温度一变,OCV曲线会漂移。后面我们会讲温度补偿,但标定这一步,温度必须固定。

4.1.2 典型数据长什么样?

以三元锂电池为例,标定出来的数据大概是这样:

SOC (%) OCV (V) - 25℃ OCV (V) - 0℃ OCV (V) - 45℃
100 4.200 4.180 4.210
90 4.080 4.050 4.095
80 3.980 3.940 3.995
70 3.900 3.860 3.915
60 3.820 3.780 3.835
50 3.750 3.710 3.765
40 3.680 3.640 3.695
30 3.600 3.560 3.615
20 3.480 3.440 3.495
10 3.300 3.260 3.315
0 3.000 2.960 3.015

你看,SOC在中间段(30%-70%)时,OCV变化比较平缓。两头(0%-10%和90%-100%)变化很陡。这就是为什么开路电压法在中间段精度一般,但在两端很准。

4.2 查表法实现——别用公式,用查表

拿到标定数据后,怎么用?

有些同学喜欢拟合一个多项式公式。我劝你别这么做。为什么?

因为OCV-SOC曲线不是简单的多项式能描述的。你硬要拟合,要么阶数太高导致计算量大,要么精度不够。我在项目里吃过这个亏,后来老老实实改用查表法了。

4.2.1 线性插值查表

查表法的核心就两步:

  1. 找到当前OCV在表中的位置(两个相邻点之间)。
  2. 用线性插值算出对应的SOC。

代码实现很简单,我直接贴出来:

/**
 * @brief 通过OCV查表获取SOC(线性插值)
 * @param ocv_mv 当前开路电压,单位mV
 * @param table  OCV-SOC标定表,按OCV升序排列
 * @param size   表的大小
 * @return SOC值,单位0.1%
 */
int16_t ocv_to_soc(int32_t ocv_mv, const OCV_Table_Type *table, uint16_t size)
{
    uint16_t i;
    int32_t  soc;
    
    // 边界检查:低于最低OCV
    if (ocv_mv <= table[0].ocv_mv) {
        return table[0].soc_x10;
    }
    
    // 边界检查:高于最高OCV
    if (ocv_mv >= table[size-1].ocv_mv) {
        return table[size-1].soc_x10;
    }
    
    // 查找区间
    for (i = 0; i < size-1; i++) {
        if (ocv_mv >= table[i].ocv_mv && ocv_mv < table[i+1].ocv_mv) {
            // 线性插值
            // SOC = SOC_low + (OCV - OCV_low) * (SOC_high - SOC_low) / (OCV_high - OCV_low)
            int32_t delta_ocv = table[i+1].ocv_mv - table[i].ocv_mv;
            int32_t delta_soc = table[i+1].soc_x10 - table[i].soc_x10;
            
            // 防止除零
            if (delta_ocv == 0) {
                return table[i].soc_x10;
            }
            
            soc = table[i].soc_x10 + (ocv_mv - table[i].ocv_mv) * delta_soc / delta_ocv;
            return (int16_t)soc;
        }
    }
    
    // 理论上不会走到这里
    return table[size-1].soc_x10;
}
小技巧:我习惯把SOC放大10倍存储(单位0.1%),这样整数运算就能保证0.1%的精度,完全够用。浮点数运算在MCU上太慢了,能不用就不用。

4.2.2 二分查找优化

上面的代码是顺序查找。如果表很大(比如100个点),每次查表都要遍历,太慢了。

我建议用二分查找。代码稍微复杂一点,但速度快得多:

int16_t ocv_to_soc_binary(int32_t ocv_mv, const OCV_Table_Type *table, uint16_t size)
{
    uint16_t low = 0;
    uint16_t high = size - 1;
    uint16_t mid;
    
    // 边界检查
    if (ocv_mv <= table[low].ocv_mv)  return table[low].soc_x10;
    if (ocv_mv >= table[high].ocv_mv) return table[high].soc_x10;
    
    while (high - low > 1) {
        mid = (low + high) / 2;
        if (ocv_mv < table[mid].ocv_mv) {
            high = mid;
        } else {
            low = mid;
        }
    }
    
    // 此时low和high是相邻的两个点
    int32_t delta_ocv = table[high].ocv_mv - table[low].ocv_mv;
    int32_t delta_soc = table[high].soc_x10 - table[low].soc_x10;
    
    if (delta_ocv == 0) return table[low].soc_x10;
    
    return table[low].soc_x10 + (ocv_mv - table[low].ocv_mv) * delta_soc / delta_ocv;
}

你看,二分查找最多查log2(N)次。100个点的表,最多查7次。比顺序查找快了一个数量级。

4.3 静置时间判断——别急着修正

有了OCV-SOC表,是不是随时都能用?

不是的。你得等电池"静下来"。

电池刚停止充放电时,端电压不是真正的开路电压。因为内部还有极化效应,电压会慢慢回升(或下降)。这个过程叫"静置"或"弛豫"。

4.3.1 静置多久才够?

这个时间取决于电池化学体系和温度。我总结了一个经验值:

电池类型 25℃静置时间 0℃静置时间 -10℃静置时间
三元锂 30分钟 60分钟 120分钟
磷酸铁锂 60分钟 120分钟 180分钟
钛酸锂 20分钟 40分钟 80分钟
核心原则:静置时间不够,测出来的电压偏低(放电后)或偏高(充电后),查表得到的SOC会偏大或偏小。修正反而引入更大误差。我见过有人因为静置时间设得太短,SOC在充电后直接跳变10%以上,客户投诉说"你们的BMS是不是坏了"。

4.3.2 如何判断静置完成?

不能光靠定时器。因为电池状态不同,需要的静置时间也不同。

我推荐用"电压变化率"来判断:

/**
 * @brief 判断电池是否已静置完成
 * @param voltage_now    当前电压
 * @param voltage_1min   1分钟前的电压
 * @param voltage_5min   5分钟前的电压
 * @return 1: 静置完成, 0: 未完成
 */
uint8_t is_rest_complete(int32_t voltage_now, int32_t voltage_1min, int32_t voltage_5min)
{
    // 条件1:1分钟内电压变化小于2mV
    if (abs(voltage_now - voltage_1min) > 2) {
        return 0;
    }
    
    // 条件2:5分钟内电压变化小于5mV
    if (abs(voltage_now - voltage_5min) > 5) {
        return 0;
    }
    
    // 条件3:电流为0(或小于静置阈值)
    // 这个由外部判断,这里假设已经满足
    
    return 1;
}

这个判断逻辑,我一般在BMS的主循环里每1秒调用一次。当返回1时,就认为电池静置完成了,可以触发OCV修正。

4.3.3 修正时机选择

静置完成后,是不是立刻修正?

我建议再等一等。等什么?等安时积分和OCV查表的结果差异稳定下来。

具体做法:

  1. 静置完成后,连续读取3次OCV(间隔10秒)。
  2. 3次查表得到的SOC取平均。
  3. 如果这个平均值与安时积分结果的偏差超过5%,才进行修正。
  4. 修正时,直接把安时积分的SOC替换为OCV查表结果。
注意:修正不要太频繁。我建议每次修正后,至少等电池再次经历一次完整的充放电循环,才允许下一次修正。否则,如果电池一直处于静置状态,你会反复修正,反而让SOC来回跳。

4.4 实战中的坑

最后,分享几个我踩过的坑:

  • 坑一:磷酸铁锂的OCV平台。磷酸铁锂的OCV曲线在30%-80%区间几乎是一条平线。电压变化只有几毫伏。这时候查表法基本失效。我的解决方案是:在平台区,降低OCV修正的权重,主要依赖安时积分。
  • 坑二:温度补偿。前面说了,温度会影响OCV。我建议至少标定25℃和0℃两组数据,中间温度用线性插值。更精确的做法是标定-10℃、0℃、25℃、45℃四组。
  • 坑三:老化影响。电池用久了,OCV曲线会变化。我一般建议每半年或每100次循环,重新标定一次。或者在BMS里预留一个"在线自学习"功能,根据实际使用数据微调OCV表。

好了,这一节的内容就到这里。下一节,我们会讲卡尔曼滤波——一种更高级的SOC估算方法。它能自动融合安时积分和OCV修正,不需要你手动判断静置时间。但前提是,你得先把这一节的基础打牢。

有什么问题,欢迎在课程群里交流。咱们下节见。