一套储能 BMS 的充电电流限值,是怎么一步步算出来的

在储能系统里,有一个数字每天都在被追问:"现在最多能充多少电?"

这个数字不是拍脑袋定的,也不是简单地从参数表里读出来的。它是一个经过层层约束、反复取最小、还带了一堆防抖逻辑之后才诞生的结果。它决定了 PCS(储能变流器)此刻允许从电网吸取多少功率,也决定了电池能不能安全地、长久地活下去。

这篇文章我想讲清楚这件事。我会以一套真实的量产代码为底本一套基于S32K146 的储能 BMU 主控固件,电流限值的计算就落在 CurrLimCalc.cCurrLimFall.c这两个文件里。我会尽量用第一人称,把我读这段代码时真正想到的东西讲出来:它每一步在防什么、为什么这么设计、以及一个资深工程师看到这些代码时会会心一笑的地方。

先交代一个背景,免得后面看不懂。这套系统是磷酸铁锂(LFP)储能电池,标称容量 280Ah,16 串 × 15 个 PACK 组成一堆,总电压大概在 700V 上下。BMS 每 100ms 算一次限值,算完通过 CAN 报文(协议号 51)上报给 PCS。PCS 拿到这个数,就知道自己该出多少力了。

一、先看整个算法的骨架:一个「多约束取最小」的漏斗

我第一次读 CurrLimCalcTask() 的时候,印象最深的是它的结构------它不是一个复杂的算法,而是一个漏斗 。电流限值从最宽松的「硬件上限」出发,每过一个约束就被往下压一次,压到最后,输出的一定是所有约束里最严格的那个

充电这条链,代码是这样走的:

c 复制代码
chgHCurr   = GetChgHReqMaxCurr();         /* 1. 硬件上限 */
tempMulLim = CalcTempChgMulCurr();        /* 2. 温度倍率 */
chUpLim    = min(chgHCurr, tempMulLim);   /* 取小 */

if (CalcGetGroupNeedChgLimStateHook())  chUpLim = 0;   /* 3. 故障限流 → 直接 0 */

chUpLim = ChgLimDownByTempH(...);         /* 4. 高温降额 */
chUpLim = ChgLimDownBySubTemp(...);       /* 5. 温差降额 */
chUpLim = ChgLimCurrByPowerLim(...);      /* 6. 功率折算 */

tempMulLim = GetFallChgLimCurr();         /* 7. 末端恒压回落 */
chUpLim = min(chUpLim, tempMulLim);       /* 取小 */

gGLimCPInfo_51[ChgC] = chUpLim;           /* 8. 写进全局数组 */

放电那条链几乎是对称的,只是参数不同。这个「漏斗」结构是整个算法的灵魂,我会在文章末尾专门讲它为什么好。

但在讲漏斗之前,我得先讲清楚漏斗的最上游------那个「硬件上限」是怎么定的。

二、第一个约束:硬件上限,以及它为什么比告警值小 1A

GetChgHReqMaxCurr() 这个函数,名义上是「取硬件允许的最大充电电流」,但它里面藏了一个非常细腻的细节:

c 复制代码
u16 GetChgHReqMaxCurr(void)
{
    u16 curr = gGBmuGenPara_102[eBmuGenPara102_ChgCMaxLim];   /* 参数里的充电电流上限 */

    /* 如果上限 ≥ 告警值,就把上限压到告警值以下 */
    if (curr >= gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim])
    {
        if (gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim] > 20)
        {
            curr = gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim] - 10;   /* 比告警值小 1A */
        }
        else
        {
            curr = gGBmuGenPara_102[eBmuGenPara102_ChgCH2Lim];
        }
    }
    return curr;
}

(注意代码里所有电流的单位都是 0.1A,所以 -10 实际上是「减 1A」。)

这个逻辑我读了好几遍才明白它为什么要这么写。它防的是一个很隐蔽、但很致命的隐患:限值触顶告警值

设想一下:如果「允许的最大充电电流」和「过流告警阈值」是同一个值,会发生什么?当 PCS 真的按照这个限值去充电时,电流恰好顶在告警阈值上,任何一个采样毛刺、任何一点过冲,都会立刻触发「过流告警」。于是系统陷入一个尴尬的循环:BMS 让 PCS 充到 100A,PCS 充到 100A,BMS 一看「哎呀 100A 告警了」------但这个 100A 明明是 BMS 自己让 PCS 充的。

所以聪明的做法是:把限值永远压在告警值以下一点。你告警值是 101A,我限值就给你 100A,中间留 1A 的余量。这样限值和告警之间就有一道缓冲,不会自己跟自己打架。

这个「留余量」的思想,我在整个 BMS 代码里反复见到,它几乎成了一种工程本能。后面你会看到,它贯穿了所有环节。

三、第二个约束:温度倍率------磷酸铁锂的脾气

过了硬件上限,下一个约束是温度。电池不是在任何温度下都能满功率充放电的,尤其是磷酸铁锂,它对温度特别敏感。

代码里的做法是查表。CalcTempChgMulCurr() 先把当前平均温度,在一张温度表上定位:

c 复制代码
const s16 gTempCurLimitTable[TEMP_CURRLIMIT_NUM] =
{
    -10, -5, 0, 5, 10, 15, 20, 25, 35, 40, 45, 50, 55, 60, 70
};

然后去查对应温度档位的「倍率」,再乘上标称容量,得到这一温度下允许的电流:

c 复制代码
result = (u32)gGroupParaRO_115[index] * GetGroupStandCapAPI() / 10000;

gGroupParaRO_115 是一张「不同温度充电倍率表」,存在 EEPROM 里,可配置。比如 25℃ 时倍率是 100%(1C,即 280A),10℃ 时可能只有 60%(0.6C),而到了 0℃ 以下,磷酸铁锂的充电倍率会被压得很低------因为低温充电会析锂,对电池造成不可逆的伤害,甚至带来安全隐患。

这里其实藏着磷酸铁锂和三元锂电池的一个关键区别。三元锂在低温下的充电倍率可以稍微放宽一点,但磷酸铁锂在低温(尤其是 0℃ 以下)充电必须非常保守。所以这张温度倍率表,本质上就是把「电化学的安全边界」翻译成了「软件里的一个数字」。

我在读这段时还注意到一个细节:代码里有一段被 #if 0 注释掉的逻辑,是专门处理「低温 + 低 SOC」的极端情况的------温度低于 0℃ 且 SOC 低于 5% 时,直接禁止充电。虽然这段在当前版本被禁用了,但它透露出一个信息:写这套代码的人,对磷酸铁锂的低温析锂风险是有清晰认知的

四、第三个约束:高温降额,以及无处不在的「滞回」

温度倍率表解决的是「低温」和「常温」,但「高温」这一侧,代码用了另一套更精细的机制------线性降额

ChgLimDownByTempH() 的核心是一行线性插值:

c 复制代码
dowmCur = ((u16)(endTemp - nowTemp) * currLim) / (u16)(endTemp - startTemp + 1);

意思是:当温度超过 startTemp(开始降额的起始温度)之后,每升高一度,电流限值就线性地往下砍一截;等温度升到 endTemp(降额到零的温度),限值正好砍到 0。这是一条从 startTempendTemp直线,斜率由这两个温度决定。

这个「线性降额」本身不稀奇,稀奇的是它前后那一堆滞回(hysteresis)逻辑。看这段:

c 复制代码
if ((nowTemp < sHisTemp) && ((nowTemp + 2) >= sHisTemp))    /* 温度回降 2℃ 以内 */
{
    nowTemp = sHisTemp;                                     /* 视为温度没变 */
}
else if ((nowTemp < sHisTemp) && ((nowTemp + 3) >= sHisTemp)) /* 回降 3℃ */
{
    nowTemp += 1;                                           /* 只回退 1℃ */
    sHisTemp = nowTemp;
}
else
{
    sHisTemp = nowTemp;                                     /* 正常更新 */
}

这段代码,我愿称之为整个限流算法里最见功力的一处。它解决的是这样一个问题:

假设温度正好在降额起始点附近抖动 ------比如 startTemp 是 45℃,而实际温度在 44.9℃ 和 45.1℃ 之间来回跳。如果没有滞回,那么电流限值就会「进入降额 → 退出降额 → 进入降额 → 退出降额」地疯狂抖动,PCS 收到的指令一会儿高一会儿低,整个系统都在跟着抽搐。

滞回的逻辑就是:进来的时候痛快地进,出去的时候要拖一拖。温度往上走,超过 45℃ 就立刻开始降额,不犹豫;但温度往回落的时候,从 45℃ 回落到 44℃,我不急着把限值涨回去,而是再观察一下,确认温度真的稳定回落了,才慢慢恢复。

这个「进来快、出去慢」的滞回,用两三个静态变量就实现了,成本极低,但它避免了系统在边界点上反复横跳。这几乎是所有做实时控制的工程师刻在骨子里的东西------任何带阈值的控制,都必须想清楚「边界抖动」这个问题。

五、第四个约束:温差降额------保护的是「一致性」

高温降额看的是「绝对温度」,温差降额看的则是「电芯之间的差异」。

ChgLimDownBySubTemp() 的输入是 GetGCellMaxTempAPI() - GetGCellMinTempAPI(),也就是整堆电池里最高温度和最低温度之间的差值。这个差值大,说明电池堆内部温度分布不均匀,有些电芯热、有些电芯冷。

为什么不均匀就要降额?因为温度不均匀意味着电芯之间的一致性在变差。同一堆电池里,热的那几节衰减更快、内阻更高、电压更容易冲到上限;而冷的那几节还没充饱。如果还维持大电流充电,热的那几节会被逼得更紧,加速劣化,甚至触发单体过压。

所以代码的逻辑是:温差超过某个阈值(ChgHTDnDifT)之后,就开始按百分比降额:

c 复制代码
if (subTemp >= gGBmuHigLevPara_103[eBmuHigLevPara103_ChgHTDnDifT])
{
    if (nowTemp >= (gGBmuHigLevPara_103[eBmuHigLevPara103_ChgHTDnFstT] + 3))
    {
        /* 温差大 + 温度高 → 降 2 档 */
        dowmCur = currLim - ((u32)currLim * ChgHTDnRate * 2 / 1000);
    }
    else if (nowTemp >= (CalcGetChgCDnFstHTempHook() - 1))
    {
        /* 温差大 + 温度中等 → 降 1 档 */
        dowmCur = currLim - ((u32)currLim * ChgHTDnRate / 1000);
    }
    ...
}

注意这里是分档降额:温差大但温度不算特别高,降 1 档;温差大而且温度还高,降 2 档。而且同样的滞回逻辑也在这里重演了一遍------温度滞回、温差滞回,都做了。

这个「温差 + 温度」双条件的分档设计,反映的是一个很成熟的判断:单一的温差或单一的温度都说明不了全部问题,但两者叠加就是危险的信号。温差大说明一致性差,温度高说明已经接近热边界,两者同时出现,就必须更狠地降额。

六、第五个约束:功率折算------把「电流上限」翻译成「电流上限」

过了温度这一关,还有一个看似绕、实则必要的步骤:功率折算。

ChgLimCurrByPowerLim() 做的事很简单:

c 复制代码
sumVolt = GetGCellSumVoltAPI();          /* 总电压 */
if (sumVolt > 0)
{
    chgPowerCur = ChgPMaxLim * 10000 / sumVolt;   /* I = P / U */
}

就是欧姆定律的一个变形:I = P / U。给定一个「最大充电功率」ChgPMaxLim,除以当前总电压,就得到一个「功率约束下的最大电流」。

为什么要这一步?因为电流和功率是两套不同的约束,都要满足。有时候电流还没到上限,但电压已经很高了,P = U × I 会先触到功率上限。这时候真正限制你的不是电流,而是功率。所以要把功率上限也折算成一个电流值,然后和前面的电流限值取小。

这里面其实还藏着一个物理直觉:电池电压是随 SOC 变化的 。充满时电压高,同样功率对应的电流就小;放空时电压低,同样功率对应的电流就大。所以「功率折算成电流」这一步,必须用实时电压 去算,不能用固定电压。代码里 GetGCellSumVoltAPI() 取的就是实时总电压,这个细节处理对了,限值才准。

七、第六个约束:末端恒压回落------CC-CV 的软件实现

这是整个限流算法里,我最想讲的一部分,因为它触及了锂电池充电最本质的东西:CC-CV 充电特性

锂电池的标准充电曲线是这样的:先恒流(CC) ,电流保持不变,电压慢慢升高;当单体电压升到充电截止电压(对磷酸铁锂,大概 3.65V)附近时,转入恒压(CV),电压保持不变,电流开始逐渐减小,直到电流小到某个阈值(比如 0.05C),充电才算真正结束。

为什么不能一直恒流充到满?因为电池在接近充满时,内部极化加剧,如果继续用大电流硬充,电压会瞬间冲过头,轻则加速衰减,重则析锂、鼓包、热失控。所以临近充满时,必须让电流降下来,让电压稳稳地停在截止电压上。

这套「末端恒压」在代码里,是由 CurrLimFall.c 实现的。它的核心是一个台阶式的查表回落,而不是一个真正的 PID 闭环(PID 部分被注释掉了,作为预留)。

充电侧的入口是 CVPIDCtrlChgCLim()。它的逻辑是:实时盯着最高单体电压,一旦发现最高电压进入了末端区间(maxVolt >= CVCalcChgPIDAimVolt(0)),就标记「进入恒压阶段」,并记录下这一刻的实际电流作为基准:

c 复制代码
/* 进入恒压,记录基准电流 */
if (GetGSampOutCurrAPI() < 0)          /* 正在充电,电流为负 */
{
    if ((0 - GetGSampOutCurrAPI()) <= (GetGBattAllCapAPI() * 7 / 10))
    {
        sFallChgMaxCurr = GetGBattAllCapAPI() * 7 / 10;   /* 基准取 0.7C */
    }
    else
    {
        sFallChgMaxCurr = 0 - GetGSampOutCurrAPI();       /* 基准取当前实际电流 */
    }
}

注意这个基准电流的上限是 0.7C。这是刻意设计的------如果你进入恒压时电流还高达 1C,那说明之前的恒流阶段充得太猛了,末端回落必须更果断;反过来,如果进入恒压时电流已经很小了,那就以实际电流为基准,慢慢收尾。

然后,随着电压一步步逼近截止电压,目标电流就按一张分段表逐档往下掉:

c 复制代码
/* 目标电流 = 基准电流 × 分段表百分比 */
curr = (u16)((u32)sFallChgMaxCurr * gGroupParaRO_119[tabNum] / 100);

gGroupParaRO_119 是一张存在 EEPROM 里的「电压/SOC 平滑回落分段表」,它把末端电压区间分成若干段(SLOW_CURRLIMIT_NUM 段),每段对应一个电流百分比。电压每抬升一个档位,电流目标就降到对应的百分比,直到最后降到截止电流(ChgCFinLim,比如 0.05C)。

放电侧的末端回落是对称的------盯着最低单体电压,电压越逼近放电截止电压(磷酸铁锂约 2.5V),放电电流限值就越低。

这个「台阶式回落」的好处是简单、稳定、可配置。它不需要调 PID 参数,不需要担心闭环震荡,只要把分段表配好了,末端曲线就固定了。对一个以「安全、可靠」为第一优先级的大储能系统来说,这种「宁可简单、不可复杂」的选择,是很务实的。

八、最后一步:电流乘电压,得到功率限值

电流限值算完之后,还要顺手把「功率限值」也算出来。代码在 CurrLimCalcTask 的末尾:

c 复制代码
gGLimCPInfo_51[eLimCPInfo51_ChgC] = chUpLim;   /* 充电电流限值 */
gGLimCPInfo_51[eLimCPInfo51_DhgC] = dhUpLim;   /* 放电电流限值 */

if (eWORK_RUN == GetGWorkStateAPI())
{
    /* 功率 = 电流 × 总电压 */
    gGLimCPInfo_51[ChgP] = (chUpLim * GetGSampSumVoltAPI() + 5000) / 10000;
    gGLimCPInfo_51[DhgP] = (dhUpLim * GetGSampSumVoltAPI() + 5000) / 10000;
}
else
{
    gGLimCPInfo_51[ChgP] = 0;
    gGLimCPInfo_51[DhgP] = 0;
}

+ 5000 是四舍五入(因为整数除法会截断,加半个单位再除就是四舍五入)。这个细节不大,但能看出写代码的人对数值精度是在意的。

算出来的这四个值------充电电流、放电电流、充电功率、放电功率------就是协议 51 报文的全部内容,通过 CAN 发给 PCS。PCS 拿到之后,就把自己的充放电功率钳在这个范围内。

九、回到那个「漏斗」:为什么这个设计是好的

现在回头看第一节的漏斗结构,我想说说它为什么好。

第一,它把「复杂」藏在了「简单」的骨架里。 每个约束都是一个独立的函数,各管各的,互不干扰。要加一个新约束(比如将来要加一个「内阻异常的降额」),只需要在漏斗里再插一级取小,不影响其他约束。这是典型的可扩展性设计

第二,「取最小」本身就是安全的设计哲学。 你永远输出所有约束里最严格的那个,这就从结构上保证了------只要有一个约束觉得「危险」,系统就会保守。这个「宁紧勿松」的倾向,在安全攸关的系统里是正确到不能再正确的选择。

第三,每个约束都有自己的防抖。 硬件上限有「比告警值小 1A」的余量,高温降额有温度滞回,温差降额有温差滞回,末端回落有台阶式的平滑。这些防抖让限值曲线变得平滑、稳定,而不是在边界点上疯狂抖动。对一个下游是 PCS 这种大功率设备的系统来说,限值的平滑,直接决定了整个系统的稳定。

十、写在最后

我读这套限流代码,最大的感受是:它没有一行是「炫技」的,但每一行都踩在点上。

它没有用复杂的卡尔曼滤波、没有用神经网络、甚至连 PID 都注释掉了,就用「查表 + 线性插值 + 分档 + 滞回」这些最朴素的手段,把一个对安全至关重要的功能做到了稳、准、可配置。

这其实是一个很深刻的道理:在安全攸关的工业系统里,「简单可靠」永远比「高级」值钱。 你能用一个别人一眼就能看懂、三年后还能维护、现场出了故障能快速定位的算法解决问题,这本身就是一种能力,而且是一种比「会用高级算法」更稀缺的能力。

如果你也在读这套代码,我建议你带着这三个问题去读:为什么限值要永远比告警值小一点?为什么高温降额要有滞回?为什么末端要用台阶式而不是 PID?这三个问题想通了,你就不仅读懂了代码,还读懂了写代码的人------读懂了一个在高压、大电流、安全责任面前,选择用朴素手段守住底线的工程师。

相关推荐
逐米时代1 小时前
AR加知识库提升一次修复率
后端·restful
王码码20351 小时前
Go语言CGO:Go与C交互
后端·golang·go·接口
PC2005-cloud2 小时前
DSH 白嫖指南:接入 Command Code Go、WorkBuddy 与 Trae 的免费额度
开发语言·后端·golang
vx-程序开发2 小时前
【计算机毕设】基于Spring Boot的古城景区管理系统88564
java·数据库·spring boot·后端·spring·elasticsearch·课程设计
妙码生花3 小时前
两个月 59 篇 AI 开发日志 + Golang 商业级实战项目收工后,得来的 AI 使用心法-上
前端·后端·gin
vx-Biye_Design3 小时前
springboot中国传统节日宣传平台49078-计算机课程设计、毕业设计
java·前端·vue.js·spring boot·后端·课程设计·idea
妙码生花3 小时前
两个月 59 篇 AI 开发日志 + Golang 商业级实战项目收工后,得来的 AI 使用心法-下
前端·后端·go
qq_452396233 小时前
第三篇:《变量、类型与函数:Rust 的“基本盘”》
开发语言·后端·rust
江华森4 小时前
02 Ubuntu 24.04 手把手部署 K8s 集群(K3s 选型 + 真实踩坑记录)
后端