# 储能 EMS 的功率策略：从一块电表到一次逆流的 200 毫秒

## 写在前面

我是薛定谔的悦，储能领域的工程师。最近系统里最让我有表达欲的，不是那些花哨的协议栈，而是一套看起来特别"土"的东西——几块电表、一个信号量、两个预保护值，拼出来的防逆流和防过载。

这套逻辑代码不多，核心就分布在 `ccu_sampler` 和 `kwhmeter_sampler` 两个模块里，加起来可能不到两千行。但它是整个储能系统"并网安全"的底线：充电的时候不能把变压器顶爆，放电的时候不能把电倒灌回电网。

这篇文章我把这套逻辑从头理一遍。

## 大纲

1.  为什么要盯着一块电表
    
2.  两个模块之间的"门铃"：System V 信号量
    
3.  快速通道：从轮询到事件驱动
    
4.  防逆流：别把电倒回电网
    
5.  防过载：别把变压器顶爆
    
6.  两个公式，一个思想
    
7.  工程里真正让人头疼的坑
    
8.  写在最后
    

* * *

## 1\. 为什么要盯着一块电表

一个工商业储能的典型拓扑是这样的：变压器出来，接一路电网侧关口表（Grid Meter），然后往下分两条支路，一条接普通负载，一条接储能机柜。储能机柜自己还挂着一块柜内表（Cab Meter）。

为什么要装两块表？因为 EMS 需要实时知道"负载到底吃了多少电"，而这个值没法直接测，只能靠两块表做减法：

```plaintext
负载功率 = 网侧表功率 - 柜内表功率
```

网侧表测的是变压器进线的总功率，柜内表测的是储能这条支路的功率。两个一减，剩下的就是纯负载消耗。

这个减法公式是整个功率控制的地基。充电和放电的"跟随负载"，全都是在这行减法之上算出来的。

我第一次读这块代码的时候，其实没觉得有什么。不就是两块表嘛。直到后来在一次现场联调里，客户现场只装了柜内表、没装网侧表，结果防逆流直接不干活了。我才意识到，这套逻辑对硬件是有前提的。这一点放到第七节细说。

## 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：

```plaintext
放电上限 = (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：

```plaintext
剩余 = 100 - 40 = 60
充电上限 = MAX(0, (60 - 5) × 0.9) = 49.5 KW
充电 49.5 + 负载 40 = 89.5，离 100 还有 10.5 的余量
```

这套逻辑就是"跟随负载"的核心。充电功率不是写死的，而是每一轮都根据当前的负载重新算，负载大就少充点，负载小就多充点，始终贴着变压器容量走，又留着一层安全垫。

## 6\. 两个公式，一个思想

把充电和放电摆在一起看，会发现它们是一对镜像：

充电（防过载）：

```plaintext
充电功率 = (变压器功率 - 负载功率 - 充电预保护值) × 充电跟随百分比
```

放电（防逆流）：

```plaintext
放电功率 = (负载功率 - 放电预保护值) × 放电跟随百分比 + 逆流允许值
```

一个减的是"变压器剩余容量"，一个减的是"负载需求"。方向相反，思想一样——都是在理论上能用的功率里，先扣掉一个预保护值当缓冲，再乘一个跟随百分比，让实际功率始终比理论极限小那么一截。

预保护值这个东西，我理解成"给系统留的反应时间"。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 跑得快，而是事件一发生，系统能在规定时间内响应。信号量那套"阻塞等待 + 内核唤醒"，就是为此设计的——平时零开销地睡着，关键时刻微秒级地醒来。

也让我理解了"跟随"的含义。跟随负载不是把公式写对就完了，而是要接受负载永远在变这个事实，然后用预保护值去对冲不确定性，用迟滞窗去对抗抖动。工程和理论的区别，大概就在这些"留一手"的细节里。

最后说一句有点感慨的话。储能这个行业，安全从来不是一句口号。防逆流和防过载背后，是电网对每一个并网点的硬约束。我们写的每一行减法、每一个信号量、每一个预保护值，最终都是为了让系统在无人值守的凌晨三点，负载突变的瞬间，还能稳稳地待在自己该待的位置上。
