在储能系统里,有一个数字每天都在被追问:"现在最多能充多少电?"
这个数字不是拍脑袋定的,也不是简单地从参数表里读出来的。它是一个经过层层约束、反复取最小、还带了一堆防抖逻辑之后才诞生的结果。它决定了 PCS(储能变流器)此刻允许从电网吸取多少功率,也决定了电池能不能安全地、长久地活下去。
这篇文章我想讲清楚这件事。我会以一套真实的量产代码为底本一套基于S32K146 的储能 BMU 主控固件,电流限值的计算就落在 CurrLimCalc.c和 CurrLimFall.c这两个文件里。我会尽量用第一人称,把我读这段代码时真正想到的东西讲出来:它每一步在防什么、为什么这么设计、以及一个资深工程师看到这些代码时会会心一笑的地方。
先交代一个背景,免得后面看不懂。这套系统是磷酸铁锂(LFP)储能电池,标称容量 280Ah,16 串 × 15 个 PACK 组成一堆,总电压大概在 700V 上下。BMS 每 100ms 算一次限值,算完通过 CAN 报文(协议号 51)上报给 PCS。PCS 拿到这个数,就知道自己该出多少力了。
一、先看整个算法的骨架:一个「多约束取最小」的漏斗
我第一次读 CurrLimCalcTask() 的时候,印象最深的是它的结构——它不是一个复杂的算法,而是一个漏斗。电流限值从最宽松的「硬件上限」出发,每过一个约束就被往下压一次,压到最后,输出的一定是所有约束里最严格的那个。
充电这条链,代码是这样走的:
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() 这个函数,名义上是「取硬件允许的最大充电电流」,但它里面藏了一个非常细腻的细节:
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() 先把当前平均温度,在一张温度表上定位:
const s16 gTempCurLimitTable[TEMP_CURRLIMIT_NUM] =
{
-10, -5, 0, 5, 10, 15, 20, 25, 35, 40, 45, 50, 55, 60, 70
};
然后去查对应温度档位的「倍率」,再乘上标称容量,得到这一温度下允许的电流:
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() 的核心是一行线性插值:
dowmCur = ((u16)(endTemp - nowTemp) * currLim) / (u16)(endTemp - startTemp + 1);
意思是:当温度超过 startTemp(开始降额的起始温度)之后,每升高一度,电流限值就线性地往下砍一截;等温度升到 endTemp(降额到零的温度),限值正好砍到 0。这是一条从 startTemp 到 endTemp 的直线,斜率由这两个温度决定。
这个「线性降额」本身不稀奇,稀奇的是它前后那一堆滞回(hysteresis)逻辑。看这段:
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)之后,就开始按百分比降额:
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() 做的事很简单:
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)),就标记「进入恒压阶段」,并记录下这一刻的实际电流作为基准:
/* 进入恒压,记录基准电流 */
if (GetGSampOutCurrAPI() < 0) /* 正在充电,电流为负 */
{
if ((0 - GetGSampOutCurrAPI()) <= (GetGBattAllCapAPI() * 7 / 10))
{
sFallChgMaxCurr = GetGBattAllCapAPI() * 7 / 10; /* 基准取 0.7C */
}
else
{
sFallChgMaxCurr = 0 - GetGSampOutCurrAPI(); /* 基准取当前实际电流 */
}
}
注意这个基准电流的上限是 0.7C。这是刻意设计的——如果你进入恒压时电流还高达 1C,那说明之前的恒流阶段充得太猛了,末端回落必须更果断;反过来,如果进入恒压时电流已经很小了,那就以实际电流为基准,慢慢收尾。
然后,随着电压一步步逼近截止电压,目标电流就按一张分段表逐档往下掉:
/* 目标电流 = 基准电流 × 分段表百分比 */
curr = (u16)((u32)sFallChgMaxCurr * gGroupParaRO_119[tabNum] / 100);
gGroupParaRO_119 是一张存在 EEPROM 里的「电压/SOC 平滑回落分段表」,它把末端电压区间分成若干段(SLOW_CURRLIMIT_NUM 段),每段对应一个电流百分比。电压每抬升一个档位,电流目标就降到对应的百分比,直到最后降到截止电流(ChgCFinLim,比如 0.05C)。
放电侧的末端回落是对称的——盯着最低单体电压,电压越逼近放电截止电压(磷酸铁锂约 2.5V),放电电流限值就越低。
这个「台阶式回落」的好处是简单、稳定、可配置。它不需要调 PID 参数,不需要担心闭环震荡,只要把分段表配好了,末端曲线就固定了。对一个以「安全、可靠」为第一优先级的大储能系统来说,这种「宁可简单、不可复杂」的选择,是很务实的。
八、最后一步:电流乘电压,得到功率限值
电流限值算完之后,还要顺手把「功率限值」也算出来。代码在 CurrLimCalcTask 的末尾:
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?这三个问题想通了,你就不仅读懂了代码,还读懂了写代码的人——读懂了一个在高压、大电流、安全责任面前,选择用朴素手段守住底线的工程师。