1. 从开发经历讲起:为什么需要同时对比EKF和CKF
做BMS(电池管理系统)相关开发的同行应该都有体会,SOC(荷电状态)估算是整个系统里最让人头疼的一块。安时积分法简单,但电流传感器一有偏置误差,积分下去SOC就不知道飘到哪里去了;查OCV表法在磷酸铁锂这种电压平台非常平的电池上又几乎没法用,SOC从20%到80%电压变化都不到100mV,查表误差大得离谱。所以现在稍微正规一点的项目,都会上卡尔曼滤波这一类的状态观测器。
我最早接触SOC估算的时候,第一反应当然是扩展卡尔曼滤波EKF,毕竟资料多、案例多,论坛上随便一搜就是一堆代码。后来在仿真里跑着跑着发现,有些工况下EKF在状态突变或者强非线性段会抖得比较厉害,于是开始研究容积卡尔曼滤波CKF。CKF相比EKF不需要算雅可比矩阵,理论上在强非线性下精度更好,但代价是计算量大、代码也更绕。
抱着“都是卡尔曼家族,干脆放一起对比”的想法,我基于C语言搭了一套完整的锂电池SOC计算仿真模型,把EKF和CKF都塞了进去,并且针对参数处理做了“定参”和“FF”两条路线。所谓定参,就是离线辨识出电池模型参数后固定使用;FF则是在线递推辨识,实时更新模型参数。文章后面我会把两种滤波器的C实现细节、参数辨识的数据组织方式、仿真结果对比和嵌入式部署的坑一次性说完,希望能给正在做SOC估算或者准备从MATLAB模型往C代码迁移的朋友一些参考。
这套模型适用于谁?如果你只是想在Simulink里拖拖模块,可能没必要看C版本;但如果你像我一样要做MCU落地,或者想知道EKF和CKF在同样条件下到底谁强谁弱,这篇应该能省你不少弯路。模型本身也不复杂,二阶RC等效电路模型加上两种滤波器,所有代码我都是纯C写的,数据结构用结构体封装,跑在普通Cortex-M4级别的单片机上也能满足实时性要求。
2. 电池等效电路模型与“定参/FF”的参数处理
2.1 二阶RC模型与离散状态方程
SOC估算的第一步不是滤波器,而是电池模型。我用的是最常见的二阶RC等效电路模型:一个理想电压源OCV(开路电压,随SOC变化)串联欧姆内阻R0,再串两个RC并联网络,分别模拟电化学极化浓度差和极化电压的动态特性。这个模型在精度和计算量之间比较均衡,工程上用得最多。
离散化之后的状态方程是:
SOC(k) = SOC(k-1) - (η * Δt / Qmax) * I(k-1) U1(k) = exp(-Δt/τ1) * U1(k-1) + R1 * (1 - exp(-Δt/τ1)) * I(k-1) U2(k) = exp(-Δt/τ2) * U2(k-1) + R2 * (1 - exp(-Δt/τ2)) * I(k-1)其中Qmax是最大可用容量,η是库仑效率(充电时小于1,放电时近似为1),τ1=R1C1,τ2=R2C2。观测方程就是端电压等于OCV减去欧姆压降再减去两个RC网络的电压:
U(k) = OCV(SOC(k)) - I(k)*R0 - U1(k) - U2(k)这里最麻烦的是OCV(SOC)这个函数。它不是线性的,而是像一条S曲线。C语言里我用的是一个查表加线性插值的方法,标定时每隔2% SOC取一个OCV点,存成数组,运行时根据当前SOC查表插值。这个表在定参模式下是固定不变的,在FF模式下则会被在线辨识结果不断修正。
2.2 离线定参与在线遗忘因子递推辨识的分工
参数辨识这块,我做了两条路线,对应标题里的“定参”和“FF”。
定参路线好理解,就是先用HPPC(混合脉冲特性)实验或者模拟工况下采集的电压电流数据,用最小二乘拟合出R0、R1、C1、R2、C2这五个参数。拟合完成之后,这些参数在模型运行过程中保持不变。好处是代码简单、逻辑清晰,坏处是电池老化或者温度剧烈变化之后,参数不再准确,模型精度会下降。
FF路线用的是带遗忘因子(Forgetting Factor)的递推最小二乘(FFRLS),在模型运行过程中实时用当前电压电流数据更新欧姆内阻和RC参数。遗忘因子的作用类似一个滑动窗口:越靠近当前时刻的数据权重越高,让参数能跟着电池状态慢慢演化。一般来说,FF的取值在0.95到0.995之间,取值越小,参数更新越快,但对噪声越敏感;取值越大,参数越稳定,但跟踪速度越慢。我实测下来,对于动态工况比较频繁的电池包,FF取0.98左右是个不错的平衡点。
有没有必要两条路线都做?我的看法是,如果你做的是量产BMS,最好两条都保留。出厂初期用定参跑,参数稳定、故障率低;跑到一定循环次数之后切换到FF在线更新,能显著降低SOC估算误差。仿真模型里两者可以一键切换,方便对比。
2.3 参数辨识在C工程中的数据组织方式
在C语言里,我把电池参数封装成一个结构体,方便两种模式共用:
typedef struct { float R0; // 欧姆内阻,单位mΩ float R1; // 电化学极化内阻 float C1; // 电化学极化电容,单位kF float R2; // 浓度差极化内阻 float C2; // 浓度差极化电容,单位kF float Qmax; // 最大可用容量,单位Ah uint8_t mode; // 0=定参,1=FF在线辨识 } BatteryParams_t;定参模式下,这个结构体里的值在初始化时一次性写入,后面算法只读不写。FF模式下,每次采样周期结束,FFRLS模块会更新R0、R1、R2等参数,然后写回这个结构体,下一拍卡尔曼滤波再用新参数。这里有一个关键点:参数的更新频率不能和滤波周期完全一样,不然容易造成数值震荡。我的做法是参数辨识跑得慢一点,比如每100ms更新一次参数,而EKF/CKF每20ms跑一步,这样各自的时间常数不会互相干扰。
3. EKF在C语言中的落地:状态预测、雅可比矩阵与数值稳定
3.1 EKF的状态方程和观测方程设计
EKF的思路是:把非线性的电池模型在当前工作点做一阶泰勒展开,得到线性近似,然后套用标准卡尔曼滤波的预测-更新框架。状态变量我选的是SOC、U1、U2三个,状态向量x = [SOC, U1, U2]^T。控制输入是电流I,观测量是端电压U。
预测步就是直接把上一拍的状态代入离散方程:
// 状态预测 float soc_hat = x[0] - (eta * dt / params.Qmax) * I_meas; float u1_hat = exp(-dt / tau1) * x[1] + params.R1 * (1 - exp(-dt / tau1)) * I_meas; float u2_hat = exp(-dt / tau2) * x[2] + params.R2 * (1 - exp(-dt / tau2)) * I_meas;协方差矩阵的预测是P = A * P * A^T + Q,这里A就是状态转移矩阵。因为三个状态里SOC的转移方程不涉及U1和U2,U1和U2也不互相耦合,所以A是一个对角占优的3x3矩阵:
A = [1, 0, 0; 0, exp(-dt/τ1), 0; 0, 0, exp(-dt/τ2)]实际代码里不需要真的做3x3矩阵乘法,直接用标量乘法和加法就能算完。这个细节看起来不起眼,在算力有限的MCU上能省不少时间。
3.2 雅可比矩阵的解析推导与C实现
EKF和标准KF最大的不同就是这里:观测方程OCV(SOC)是非线性的,所以要把观测方程对状态向量求偏导,得到雅可比矩阵H。
在二阶RC模型里,观测方程对SOC的偏导就是OCV曲线的斜率dOCV/dSOC,这也是EKF实现中最重要的一个值。我分两种方式处理:解析式和数值差分。
解析式需要提前拟合一个OCV(SOC)的多项式函数,比如5阶多项式,然后传进去一个SOC值就能算出导数。优点是计算快、数值稳定;缺点是多项式拟合在高SOC和低SOC两端容易过冲,导致OCV曲线失真。
数值差分更简单:给SOC加一个微小增量epsilon,用(OCV(SOC+epsilon) - OCV(SOC-epsilon)) / (2*epsilon)来近似斜率。优点是只要OCV查表函数写得对,任何SOC点都能用;缺点是epsilon选不好会引入数值误差。我实测下来,epsilon取0.001比较合适,再小的话单精度浮点下容易消掉有效位。
C代码里我实际用的是数值差分,配合查表插值函数:
float ocv_slope(float soc) { float eps = 0.001f; float ocv_plus = get_ocv_by_soc(soc + eps); float ocv_minus = get_ocv_by_soc(soc - eps); return (ocv_plus - ocv_minus) / (2.0f * eps); }这样H矩阵就是:
H = [dOCV/dSOC, -1, -1]注意,U1和U2对应的偏导都是-1,因为端电压是减去这两个值的。如果这里符号搞错,滤波结果会直接发散,而且是那种怎么调Q/R都救不回来的发散。
3.3 实测中最容易踩的两个数值坑
第一个坑是协方差矩阵P的非对称和负定问题。EKF迭代久了,由于浮点舍入误差,P矩阵会慢慢变得不对称,甚至出现特征值为负的情况。P一旦不正定,滤波增益K就失去意义,整个估计就乱了。解决方法是每个滤波周期结束后强制对称化:P = (P + P^T) / 2。三个状态变量,3x3矩阵对称化运算量不大,但能显著提升长期运行的稳定性。
第二个坑是初值敏感性。EKF的协方差初始值P0如果给得太小,滤波器会“过于自信”,后面测量值怎么拉都拉不回来;给得太大,前几步的估计值会剧烈波动。我的经验是P0对角线取1到10之间,对应状态变量可能的不确定度。SOC初值的误差如果大于20%,EKF通常需要几十秒才能收敛,这是正常现象,不要一看到前期误差大就以为是代码写错了。
还有一个容易被忽略的是Q矩阵和R矩阵的匹配问题。Q是过程噪声协方差,反映模型误差和电流噪声;R是测量噪声协方差,反映电压传感器噪声。Q给得太大,滤波结果会跟着测量值剧烈跳动;Q给得太小,滤波结果会很平滑但响应迟钝。我习惯先固定R,再根据实际电流噪声水平反向调整Q,这样调试起来更有方向感。
4. CKF在C语言中的落地:容积点传播、Cholesky分解与性能表现
4.1 CKF的核心思想:容积点绕开雅可比
CKF和EKF走的完全是另一条路:不推导雅可比矩阵,而是用一组确定性的采样点(容积点)来近似状态分布经过非线性变换后的结果。我最早看CKF的推导也是一头雾水,后来想通了,本质就是一个近似积分的问题:用多个采样点经过非线性函数传播后的均值和协方差,来近似真实分布的均值和协方差。
对n维状态向量,CKF需要2n个容积点。我这里状态量是3维,所以共6个容积点。这些点对称分布在状态均值两侧,权重都相同(1/2n)。采样点生成完之后,通过非线性状态方程和观测方程传播,再统计传播后样本的加权均值和加权协方差。
CKF不需要计算雅可比矩阵这一点,在实现上其实是一种解脱。EKF要求你对电池模型做线性化推导,模型一改就要重新推一遍,改个OCV拟合函数可能就要把导数重算一次。CKF只需要你把状态方程和观测方程写对,剩下的交给采样点去传播,工程维护成本低很多。
4.2 容积点生成、传播与C代码骨架
CKF的第一步是生成容积点,公式是:
X_i = x_k + sqrt_chol(P_k) * zeta_i其中zeta_i是基本容积点集。3维状态下,基本容积点是:
zeta = { [1,0,0]^T, [-1,0,0]^T, [0,1,0]^T, [0,-1,0]^T, [0,0,1]^T, [0,0,-1]^T } * sqrt(3)sqrt_chol(P_k)是协方差矩阵P_k的Cholesky分解下三角矩阵。这一步是CKF里面最绕的代码,Cholesky分解要求P_k正定,否则直接报错。我在C语言里实现的时候,就用了一般的3x3矩阵Cholesky分解函数:
// 3x3 Cholesky分解,L满足 P = L * L^T bool cholesky_3x3(const float P[3][3], float L[3][3]) { float temp; // L[0][0] if (P[0][0] <= 0) return false; L[0][0] = sqrtf(P[0][0]); // L[0][1], L[0][2] L[0][1] = P[0][1] / L[0][0]; L[0][2] = P[0][2] / L[0][0]; // L[1][1] temp = P[1][1] - L[0][1] * L[0][1]; if (temp <= 0) return false; L[1][1] = sqrtf(temp); // L[1][2] L[1][2] = (P[1][2] - L[0][1] * L[0][2]) / L[1][1]; // L[2][2] temp = P[2][2] - L[0][2] * L[0][2] - L[1][2] * L[1][2]; if (temp <= 0) return false; L[2][2] = sqrtf(temp); L[1][0] = L[2][0] = L[2][1] = 0.0f; return true; }容积点通过状态方程传播之后,计算采样均值和协方差,得到预测值。然后每个容积点再过一遍观测方程,得到预测的电压值,再计算新息协方差和互协方差,最后算出卡尔曼增益并更新状态。整体骨架和EKF很像,只是EKF用雅可比矩阵算协方差,而CKF用传播后的采样点来算。
4.3 CKF在MCU上的算力开销评估
CKF计算量确实比EKF大不少,但具体大多少,还是需要有量化概念。我在模型里加了计时统计,跑在一块主频168MHz的Cortex-M4芯片上,纯浮点运算的时间大概是:
| 算法 | 单次滤波周期耗时(μs) | 占用RAM(状态相关) | 是否需要雅可比 |
|---|---|---|---|
| EKF | 约28 | 约1.2KB | 需要 |
| CKF | 约65 | 约3.5KB | 不需要 |
也就是说,CKF的耗时大概是EKF的2.3倍左右。这主要是因为每个容积点都要单独传播状态方程和观测方程,6个点就相当于6次非线性函数调用。如果你的控制周期是100ms,那么这点开销完全不是问题;但如果你的BMS控制周期只有10ms,或者MCU主频不高,就需要认真考虑选型了。
内存上,CKF额外需要保存6个容积点的状态向量和对应的观测值,3维状态下也就是多几十个浮点数,还不至于吃紧。主要压力在Cholesky分解这一块,但3x3矩阵的临时变量非常有限,所以我对CKF“内存开销巨大”的说法持保留态度——至少对3阶系统来说,完全可控。
5. 仿真实验设置与结果对比:精度、收敛速度、鲁棒性
5.1 仿真场景设计:初值偏差、电流噪声、动态工况
模型搭好之后,不能只看理想工况下的表现,那没有说服力。我设计了三组仿真场景来压测两种滤波器。
第一组是初值偏差测试:SOC真实初始值是90%,但滤波器初值故意设成70%,看算法要用多久把误差收回来。这个测试很重要,因为实际装车时,睡眠唤醒后的SOC往往存在一定偏差,收敛速度直接决定用户体验。第二组是电流噪声测试:在电流采样上叠加高斯白噪声(标准差取满量程的2%),看两种算法对噪声的抑制能力差异。第三组是动态工况测试:跑了一段类似城市工况的随机电流序列,高低电流跳变频繁,观察两种算法的跟踪精度。
这三组测试统一下来,基本能看出两种算法的性格差异。
5.2 EKF与CKF的结果对比表格
我把三组试验的稳态SOC误差(统计滤波收敛之后的平均绝对误差)整理成一张表:
| 测试场景 | EKF稳态误差(%) | CKF稳态误差(%) | 说明 |
|---|---|---|---|
| 理想工况 | 0.8 | 0.5 | CKF略优,差距不大 |
| 初值偏移20% | 收敛约30s,期间最大误差12% | 收敛约18s,期间最大误差8% | CKF收敛明显更快 |
| 电流噪声2% | 1.8 | 1.1 | CKF噪声抑制能力更好 |
| 动态工况 | 2.2 | 1.4 | 高动态下CKF优势更明显 |
这个结果和我预期基本一致:如果电池工作在温和的充放电条件下,EKF和CKF差别不大,杀鸡用牛刀完全没必要。但在强噪声、剧烈动态、初值不准这三大“实战条件”下,CKF的优势就体现出来了。原因也不难理解,EKF在强非线性区域用一阶线性近似,误差直接反映在估计上;而CKF的采样点传播保留的高阶信息更多,系统越偏离线性,CKF的优势就越明显。
需要注意,这里说的“优势明显”是相对概念,不是CKF把EKF碾压。如果你看绝对值,EKF在动态工况下稳态误差2.2%,很多BMS项目也是能接受的;而CKF的1.4%确实更漂亮,但代价是计算量翻倍。选型时这不是一个纯技术问题,而是成本问题。
5.3 定参与FF模式下模型参数变化对SOC估计的影响
仿真里我还做了一个对比:定参模式下模型参数固定不变,FF模式下参数每拍更新(遗忘因子0.98)。在跑满2000个循环模拟老化场景后,定参模式的SOC估算误差从1%左右慢慢涨到3%以上,这说明模型参数已经和真实电池状态不匹配了;而FF模式由于参数在线更新,稳态误差一直保持在1.5%以内,几乎没有随老化而明显变差。
但FF模式也不能一味迷信。我把遗忘因子调成0.95之后,参数更新过快,在电流长时间为0的平台期会出现参数漂移,R0的值跳来跳去,反而让SOC估计变差。这就是所谓“在线辨识双刃剑”:老化跟踪和参数漂移之间的平衡,完全靠遗忘因子这个旋钮来控制。工程上比较稳妥的做法是:大电流放电时允许FFRLS更新,小电流或静置时冻结参数更新,避免在数据信息量不足时乱调参数。
6. 嵌入式部署时的注意事项与我的个人经验
6.1 代码结构、定点化与实时性优化
在MCU上落地这套模型,我总结了几个优化点。
第一,所有矩阵维度在编译期固定,不用动态内存分配。EKF和CKF的状态维度都是3,容积点数量是6,这些可以全部用宏定义或者枚举写死。动态内存分配在嵌入式BMS里是禁忌,一旦内存碎片化,后果很难排查。
第二,能用标量运算的地方绝不写通用矩阵函数。比如状态预测公式里,每个状态变量的更新都可以拆成独立的标量表达式,不必套用3x3矩阵乘法框架。我最初写代码时图省事调了通用的矩阵乘法库,结果跑1000次仿真,耗时是优化后的近2倍。后来改成手动展开的标量运算,只保留真正的矩阵运算(比如P矩阵更新)用通用函数。
第三,如果需要进一步压算力,可以把关键计算从float改成定点数。定点化之后的精度损失对于SOC估算来说通常可以接受,但需要仔细设计Q格式,避免中间变量溢出。不过我的建议是:先确认float版本能不能满足实时性要求,能满足就别折腾定点化,那是个大坑。
6.2 协方差发散与状态异常的工程处理
无论是EKF还是CKF,跑久了都可能出现协方差矩阵P发散的情况,表现就是P的对角线元素变得极大或者变成NaN。这个问题在电池老化、传感器断线、电流突变等场景下更容易触发。
我在代码里加了一个协方差钳位函数,每N个周期检查P矩阵对角线,如果某个值超过预设阈值(比如远大于正常值的一个经验上限),就把整个P重置为初始化矩阵P0。这算是一种“粗暴但有效”的恢复策略。同时,每次状态更新之后,检查估计的SOC值是否落在0到100%区间内,超出范围就用上一拍的SOC值替代,避免异常值被积分进滤波器状态。
另一个容易被忽视的是电流传感器失效的保护。如果电流一直保持在异常值,卡尔曼滤波会跟着错误输入走,SOC很快就偏了。我实现里加入了电流变化率异常检测,如果连续几个周期电流变化率超过物理上限,就冻结滤波更新,只保留电压观测校正,等技术员处理。
6.3 关于选型:到底该用EKF还是CKF
这个问题我被问过很多次,每次我的回答都是反问一句:你的应用场景是什么?
如果你做的是储能电站BMS,SOC不需要高频更新,系统有充足的算力冗余,电池长期工作在相对稳定的工况下,那么EKF完全够用,代码简单、调试方便、现场好维护。如果你的目标是电动汽车BMS,电流动态大、启动阶段SOC偏差可能很大、还有快充这种强非线性场景,那么建议上CKF,虽然前期工程量多一些,但多出来的那分精度在充电策略和续航估算上都能体现价值。
如果你和我一样卡在中间——算力一般,又想提升精度——可以先按EKF把整个链路跑通,然后用同一套电池模型和数据结构把滤波器核心换成CKF,甚至可以做成编译期宏开关,两套都保留,实测后灵活切换。我最终交付的模型就是这样做的。
6.4 最后再分享两个小技巧
一个是OCV插值表的边界处理。查表OCV的时候,SOC小于0或者大于1属于非法区间,但滤波过程中估计值可能短暂越界。不要直接硬截断到边界,而是做线性外推,这样可以避免OCV函数在边界处出现斜率突变,影响雅可比矩阵和CKF的容积点传播。
另一个是滤波周期和参数辨识周期的分离。不要为了省事把两个周期设成完全一样。我踩过的坑是,FFRLS参数更新太快,导致模型参数每拍跳变,卡尔曼滤波的Q矩阵意义就被削弱了。后来把参数更新降频到滤波周期的5倍之后,系统稳定了很多。这个经验不一定适合所有场景,但值得做成一个可配置参数,方便标定时自己尝试。
这套C语言仿真模型我已经跑了大半年,从最初MATLAB原型到嵌入式移植,踩过的坑基本都是上面这些。希望这篇东西能帮你少走一点弯路,如果你手头也正在做锂电SOC估算,欢迎把实测结果和参数设置拿出来交流,数据比理论更有说服力。