写在前面
我是薛定谔的悦,储能领域的工程师。最近系统里最让我有表达欲的,不是那些花哨的协议栈,而是一套看起来特别"土"的东西------几块电表、一个信号量、两个预保护值,拼出来的防逆流和防过载。
这套逻辑代码不多,核心就分布在 ccu_sampler 和 kwhmeter_sampler 两个模块里,加起来可能不到两千行。但它是整个储能系统"并网安全"的底线:充电的时候不能把变压器顶爆,放电的时候不能把电倒灌回电网。
这篇文章我把这套逻辑从头理一遍。
大纲
- 为什么要盯着一块电表
- 两个模块之间的"门铃":System V 信号量
- 快速通道:从轮询到事件驱动
- 防逆流:别把电倒回电网
- 防过载:别把变压器顶爆
- 两个公式,一个思想
- 工程里真正让人头疼的坑
- 写在最后
1. 为什么要盯着一块电表
一个工商业储能的典型拓扑是这样的:变压器出来,接一路电网侧关口表(Grid Meter),然后往下分两条支路,一条接普通负载,一条接储能机柜。储能机柜自己还挂着一块柜内表(Cab Meter)。
为什么要装两块表?因为 EMS 需要实时知道"负载到底吃了多少电",而这个值没法直接测,只能靠两块表做减法:
负载功率 = 网侧表功率 - 柜内表功率
网侧表测的是变压器进线的总功率,柜内表测的是储能这条支路的功率。两个一减,剩下的就是纯负载消耗。
这个减法公式是整个功率控制的地基。充电和放电的"跟随负载",全都是在这行减法之上算出来的。
我第一次读这块代码的时候,其实没觉得有什么。不就是两块表嘛。直到后来在一次现场联调里,客户现场只装了柜内表、没装网侧表,结果防逆流直接不干活了。我才意识到,这套逻辑对硬件是有前提的。这一点放到第七节细说。
2. 两个模块之间的"门铃":System V 信号量
电表的数据是谁采的?kwhmeter_sampler,一个跑在 RS485 上的采样器。防逆流、防过载的策略是谁算的?ccu_sampler,另一个采样器。
问题来了:这两个是独立的模块,各自一个线程在跑。电表采样器发现"逆流了",怎么第一时间告诉 CCU 采样器?
答案是一个 System V 信号量。代码里就一行:
c
pCCU->nEssSemCCUWith485Meter = Creat_SemID(SEM_CCU_485_PATH, SEM_CCU_485_PROJ_ID, 1, IPC_CREAT | 0666);
展开来,Creat_SemID 就干两件事:
c
int Creat_SemID(const char *pathname, int proj_id, int nsems, int semflg)
{
key_t key = ftok(pathname, proj_id); // 用路径+字符生成一个 IPC key
int semid = semget(key, nsems, semflg); // 用 key 拿到信号量
return semid;
}
其中 SEM_CCU_485_PATH 是 /var,SEM_CCU_485_PROJ_ID 是字符 'S'。关键在于 ftok:只要两个进程用同一个路径加同一个字符,算出来的 key 就一定相同,于是它们拿到的是内核里同一个信号量。
我第一次接触这个,脑子里冒出来的问题是:这跟普通的锁有啥区别?
区别在于作用域。pthread 的互斥锁只能在一个进程内用,而 System V 信号量是内核对象,跨进程。CCU 采样器和电表采样器是两个 .so,各自独立,普通锁管不到彼此,只有这种内核级的东西能当桥梁。
然后就是两个操作,一个等、一个发:
c
void sem_wait_op(int semid) // P 操作,减一,减到 0 以下就阻塞
{
struct sembuf sop = {0, -1, 0};
semop(semid, &sop, 1);
}
void sem_post_op(int semid) // V 操作,加一,唤醒等待者
{
struct sembuf sop = {0, 1, 0};
semop(semid, &sop, 1);
}
我把它理解成一个门铃。电表采样器那边按一下 sem_post_op,CCU 采样器这边一直睡在 sem_wait_op 上,门铃一响就被内核叫醒。
这里有个容易误解的点:信号量不传数据。它只传"有事情发生了"这一个信号。真正的功率值、电压电流,是通过 DPR 数据模型共享的。信号量只是负责"敲门"。
ftok 这个函数也值得说两句。它是拿一个文件路径加一个字符,算出一个整数 key。这里有个隐蔽的坑:路径必须是真实存在的文件,不存在就会返回 -1,然后 semget 拿到的是个非法值,信号量静默失效。项目里用的是 /var,一个稳定的系统目录,而不是某个可能被清理的临时文件。另外,路径加字符这个组合,只要两个模块各写错一个字符,就会各拿到各的信号量,谁也等不到谁------这种 bug 不报错,就是"永远不触发",比崩溃还难查。
还有一点我一开始没想明白:为什么不用 POSIX 命名信号量,非要用 System V 这套 ftok + semget + semop 的老古董。后来看代码,发现这个项目里 System V 信号量已经在 syshand 守护进程和主进程之间用着了,是一套既有的 IPC 约定。新模块沿用同一套,比再引入一种信号量风格要省心。工程里很多"为什么这么写"的答案,其实是"因为这里已经这么写了"。
3. 快速通道:从轮询到事件驱动
为什么要费劲搞一个信号量?直接让 CCU 采样器定时去读电表数据不行吗?
行,但是慢,而且浪费 CPU。
定时轮询的问题在于响应延迟取决于轮询周期。周期设短了,CPU 忙等;周期设长了,逆流都发生几百毫秒了 CCU 还没反应过来。而逆流这件事,等不起。电网那边对逆流的容忍度是按"一次调频"级别来要求的,端到端要压到 200 毫秒以内。
信号量是阻塞的。CCU 采样器的线程平时就睡在 sem_wait_op 上,不占 CPU。电表那边一 sem_post_op,内核立刻把它唤醒,微秒级的延迟。这就是"快速通道"的由来,代码里对应一个 bQuickChannle 标志。
整条端到端的时序,我拆开来算过。电表采样器那边,RS485 采集一轮网侧表功率,大概几十毫秒;发现两个功率都为负,sem_post 一下,微秒级;CCU 采样器被唤醒,跑一遍能量管理策略算出发电功率上限,再通过 CAN 或 Modbus 把指令下给 PCS,又是几十毫秒。全部加起来,从"逆流发生"到"PCS 开始降功率",控制在 200 毫秒以内。这 200 毫秒就是电网给储能定的硬指标,也是"快速响应"这个说法的来源。
这里面的关键,是信号量那一段几乎不耗时。如果换成轮询,哪怕周期压到 100 毫秒,光是"等下一轮"就要吃掉一半的预算。事件驱动和轮询的差距,在这种硬实时场景里会被放大得特别明显。
触发条件在 kwhmeter_sampler 的 Judge_ActvPower_Reflux 里:
c
if ((fCabPwr_Sum < 0) && (fGridPwr_Sum < 0))
{
sem_post_op(pWhim_Rough->nSemCCUWith485Meter); // 发信号唤醒 CCU
}
柜内功率和网侧功率同时为负,说明功率在往电网倒灌,这时候按下门铃。
CCU 采样器被唤醒后,先检查一个前置条件:
c
if (pCCU->emPMCtrlMode != AUTO_CTRL_PM_MODE) // 非自动模式,直接跳过
continue;
必须是自动功率管理模式才会响应。手动模式下,即使逆流了,系统也不该自作主张去调 PCS。这是安全上的考虑------操作员在手动调试的时候,最怕设备自己乱动。
4. 防逆流:别把电倒回电网
放电的时候,储能给负载供电。理想情况是"负载吃多少,储能放多少"。但负载是实时波动的,万一负载突然掉下去,储能还在按原来的功率放,多出来的电就会倒灌回电网。这就是逆流。
判断逻辑在 JudgeIfEnterAnti_RefluxOrOverload 里:
c
BOOL bAntiReflux = (fAnti_RefluxMeter - pCCU->blobBackendCfg.fPreProt4RefluxAndOverLoad
< (pEMS->fAllowedReverseValue * -1.0f));
拆开看,三个量:
fAnti_RefluxMeter:防逆流表的实时有功功率,就是网侧表功率fPreProt4RefluxAndOverLoad:放电预保护值,一个提前量fAllowedReverseValue:逆流允许值,允许倒灌多少
翻译成人话:当网侧功率负到「低于逆流允许值 + 预保护值」的时候,判定进入防逆流。
为什么要有预保护值?注释里写得很直白:Pre-Sett Value, Due To EMS Adjust Power is Too Slow------EMS 调节功率太慢了,所以要提前动手。等真正越界了再去调,传输和计算延迟早就让逆流多倒灌了几十毫秒。
而 fAllowedReverseValue 是给奥地利认证这种场景留的。有些电网允许一点点逆流,这个值可以配置成 3KW、5KW 之类。
真正算放电功率上限的地方在 Calc_ESS_DischgLmtPower:
c
pEMS->fTotal_PessDischgLmt =
(pEMS->fPacload4Dischg - fProt) * pCCU->blobBackendCfg.fOutput2AC_PwrRate;
pEMS->fTotal_PessDischgLmt += pEMS->fAllowedReverseValue;
这里 fPacload4Dischg 就是前面说的负载功率(网侧表减柜内表),fProt 是放电预保护值,fOutput2AC_PwrRate 是放电跟随百分比。最后再加回逆流允许值。
举个例子。负载 60KW,放电预保护值 5KW,跟随百分比 0.9,逆流允许值 3KW:
放电上限 = (60 - 5) × 0.9 + 3 = 52.5 KW
储能最多放 52.5KW,负载吃 60KW,中间还差 7.5KW 的余量,不会倒灌。
上面说的是三相总量的防逆流。还有一个单相的场景,是奥地利认证项目带出来的。欧标对单相逆流管得比国内严,光看三相总量不够,得看每一相。代码里 fAtRflxMtr_kWPhMin 就是三相功率里的最小值,单相防逆流的触发条件是:
c
if (pEMS->fAtRflxMtr_kWPhMin < pCCU->blobBackendCfg.fPreProt4Reflux_Phase)
pEMS->bEnterAnti_Reflux_Phase = TRUE;
三相里只要有一相的功率负到超过单相预保护值,就触发。这个场景的坑在于,三相负载往往是不平衡的,某一相可能已经逆流了,三相总量还是正的。如果只做总量防逆流,单相逆流根本抓不住。奥地利认证要求"三相总量 + 单相最小值"双通道同时检测,缺一不可。
5. 防过载:别把变压器顶爆
充电的时候,问题反过来了。储能充电相当于一个大负载,充电功率加上原本的负载功率,不能超过变压器的容量。超了,变压器过载跳闸,整条线路都断。
判断逻辑同样在那个函数里:
c
BOOL bAntiOverLoad = ((pCCU->blobBackendCfg.fSysACInput_PwrLmt - fAnti_OverLoadMeter)
< pCCU->blobBackendCfg.fPreProt_Chrg);
fSysACInput_PwrLmt 就是变压器可用功率,注释直接写着"变压器可用功率(防过载)"。fAnti_OverLoadMeter 是网侧表功率,fPreProt_Chrg 是充电预保护值。
翻译:变压器功率减去网侧表功率,剩下的可用功率如果已经小于充电预保护值了,就判定进入防过载。
充电功率上限的完整计算在 Calc_ESS_ChgLmtPower:
c
// 剩余充电功率 = 变压器功率 - 负载功率
pEMS->fRemainPwr4Chrg = pEMS->fGridACInputPwr_Lmt - pEMS->fPacload4Chg;
// 再减预保护值,乘充电跟随百分比
pEMS->fGridRemainACPwr2Chrg =
MAX(0.0f, (pEMS->fGridRemainACPwr2Chrg - fProt) * pCCU->blobBackendCfg.PowerRate_Chrg);
举个例子,变压器 100KW,负载 40KW,充电预保护值 5KW,跟随百分比 0.9:
剩余 = 100 - 40 = 60
充电上限 = MAX(0, (60 - 5) × 0.9) = 49.5 KW
充电 49.5 + 负载 40 = 89.5,离 100 还有 10.5 的余量
这套逻辑就是"跟随负载"的核心。充电功率不是写死的,而是每一轮都根据当前的负载重新算,负载大就少充点,负载小就多充点,始终贴着变压器容量走,又留着一层安全垫。
6. 两个公式,一个思想
把充电和放电摆在一起看,会发现它们是一对镜像:
充电(防过载):
充电功率 = (变压器功率 - 负载功率 - 充电预保护值) × 充电跟随百分比
放电(防逆流):
放电功率 = (负载功率 - 放电预保护值) × 放电跟随百分比 + 逆流允许值
一个减的是"变压器剩余容量",一个减的是"负载需求"。方向相反,思想一样------都是在理论上能用的功率里,先扣掉一个预保护值当缓冲,再乘一个跟随百分比,让实际功率始终比理论极限小那么一截。
预保护值这个东西,我理解成"给系统留的反应时间"。EMS 从采样到计算到下发 PCS 指令,是有一个延迟的。如果贴着极限跑,这个延迟窗口里负载稍微动一下就越界了。预保护值就是把这个延迟窗口换算成功率,提前预留出来。
两个预保护值当初是共用的一个参数,后来拆开了(代码里能看到 LJH250703 的改动注释),因为充电和放电需要留的余量不一样,混用一个值两边都别扭。
7. 工程里真正让人头疼的坑
代码逻辑看懂了是一回事,现场跑起来又是另一回事。
第一个坑:电表没装齐。
前面说了,负载功率 = 网侧表 - 柜内表。这是硬前提。代码里 Judge_ActvPower_Reflux 开头就判断:
c
if ((meterData[CAB_FIRST].emMeterType == METER_TYPE_NotInstalled)
|| (meterData[GRID_FIRST].emMeterType == METER_TYPE_NotInstalled))
return 0; // 直接不处理
注释写得很清楚:站级 EMS 快速防逆流必须接柜内表和网侧表。但现场总有各种情况------有的站从设备只装了柜内表没装网侧表,有的干脆把表接错了位置。这种时候防逆流是静默失效的,不报错,只是"永远不触发"。最怕的就是这种安静的错误。
第二个坑:电表安装位置决定负载怎么算。
负载功率不是固定用"网侧表减柜内表"这一种算法。代码里 Calc_ACLoadPwrByDiffMeterPosition 按电表位置分了五六种场景,工业用电、民用电、多负载支路,每种减法的表不一样。配置里 emAuxMeterPos(参数 1,7)设错了,负载算出来就是错的,后面全错。
第三个坑:迟滞防振荡。
逆流和过载的进入、退出都不是瞬时的,中间隔了一个 MAX_RETDIFF_PERIOD 时间窗。为什么?因为功率在阈值附近抖动的时候,如果进出没有迟滞,PCS 会疯狂地一会儿开一会儿关,功率振荡。这段代码的注释我记得特别清楚,防逆流退出要"超过时间窗",就是怕抖。
第四个坑:预保护值怎么调。
预保护值设大了,系统太保守,功率上不去,白白浪费容量;设小了,余量不够,负载一抖就越界。这个值没有银弹,只能现场一点一点试。我自己的经验是先设成变压器容量的 5% 左右,然后看功率曲线,有振荡就加大,有余量就减小。
第五个坑:手动模式和自动模式的边界。
被信号量唤醒后,第一步就是检查 emPMCtrlMode 是不是自动模式。这个检查不能省。现场调试的时候,操作员会切到手动模式去单独控制某一台 PCS,如果这时候自动策略还在跑,两边指令冲突,后果比逆流严重得多。
除了这五个坑,我还想记一个真实的联调事故。有一次客户现场的防逆流怎么都触发不了,日志里明明能看到网侧表功率已经负了,但 PCS 就是不动。排查了一整天,最后发现是电表安装位置那个参数配错了。客户现场是"多负载支路"的接法,负载功率应该用 网侧表 - 柜内表 + 辅助表 来算,但配置里还停在默认的"无辅助表",算出来的负载比真实值小了一截,导致逆流判断的阈值整个偏移了。表接对了,位置参数配错了,等于白接。
那次之后我养成一个习惯:现场上电之前,先把这几块表"谁是谁"在配置里对一遍,拿一个已知负载去验证减法公式对不对。表读数的正负号、安装位置、参数 ID,任何一个对不上,后面所有策略都是空中楼阁。功率控制这种东西,地基错了,楼越高塌得越狠。
关于这几个参数到底藏在哪,也顺带说清楚。变压器可用功率 fSysACInput_PwrLmt、充电预保护值 fPreProt_Chrg、放电预保护值 fPreProt4RefluxAndOverLoad、充电跟随百分比 PowerRate_Chrg、放电跟随百分比 fOutput2AC_PwrRate,这些全在同一个后端配置 blob A_BLOB_ES_BACKEND_CFG 里,参数 ID 是 nDevId=1, nParamId=26。也就是说,你在 Web 配置里改一个"后端配置"的 blob,这几个字段一起生效。
变压器功率这个值,代码里还留了三种来源:可以直接用手填的 fSysACInput_PwrLmt,也可以设成"固定值模式"读 fFixed_GridInput_Limit,或者"动态模式"读管理计划里每个时段的 nGridInputPwrLmt。如果现场是分时电价、不同时段变压器额度不同,就用动态模式;如果变压器容量恒定,直接禁用动态限制、手填一个值最省事。
我自己的习惯是:变压器功率填实际容量的值,预保护值先设成容量的 5%,跟随百分比设 0.9,然后上电看功率曲线再微调。这三个值没有一个"正确"答案,只有"适合这个现场"的答案。
8. 写在最后
这套防逆流、防过载的逻辑,代码层面没有太多花哨的东西。两块表做减法算负载,一个信号量当门铃,两个预保护值留余量,再加一个迟滞窗防振荡。全部加起来,就是储能系统并网安全的底线。
但它让我重新理解了什么叫"实时"。实时不是 CPU 跑得快,而是事件一发生,系统能在规定时间内响应。信号量那套"阻塞等待 + 内核唤醒",就是为此设计的------平时零开销地睡着,关键时刻微秒级地醒来。
也让我理解了"跟随"的含义。跟随负载不是把公式写对就完了,而是要接受负载永远在变这个事实,然后用预保护值去对冲不确定性,用迟滞窗去对抗抖动。工程和理论的区别,大概就在这些"留一手"的细节里。
最后说一句有点感慨的话。储能这个行业,安全从来不是一句口号。防逆流和防过载背后,是电网对每一个并网点的硬约束。我们写的每一行减法、每一个信号量、每一个预保护值,最终都是为了让系统在无人值守的凌晨三点,负载突变的瞬间,还能稳稳地待在自己该待的位置上。