一、先说背景
在做这个光储一体化项目的过程中,光伏逆变器的控制策略是反复改动最多的模块之一。不是因为逻辑复杂,而是因为现场情况千变万化------有时候储能满了,有时候储能在放电,有时候电网侧功率已经开始往外倒送,什么情况下该让光伏全力输出,什么情况下该收一收,不同的时间节点答案是不一样的。
最终我们沉淀出了 ctrlPVMode 这个核心控制模式枚举,用它来统一调度光伏逆变器的出力方式。今天就以这段代码为主线,把整个控制链路的思路说清楚。
二、三种模式的设计动机
代码里枚举的定义非常简洁:
c
typedef enum tagCtrlPVMode {
CtrlPVMode_Auto, // 自动
CtrlPVMode_DAQ, // 数采控制
CtrlPVMode_AEMS, // AEMS控制
CtrlPVMode_MAX
} CtrlPVMode;
三个值,但背后对应的是两种截然不同的控制哲学。
**DAQ(数采控制)**的本质是"放权"。把逆变器交还给本地数采系统,让它自己维持当前的实际输出功率。这在什么时候有意义?------当储能完全不参与、电网也没有明显的倒送风险时,集中式AEMS的介入反而是多余的。给逆变器下一个和它当前输出一模一样的功率指令,等于告诉它"你现在做的就是对的,继续"。
AEMS 控制则是另一个极端。AEMS 要全程接管:根据负载功率、储能SOC、电网表计、逆变器额定功率,实时计算出一个最优目标出力值,通过Modbus/内部总线写给逆变器。优点是精确,代价是逻辑复杂、参数多。
**Auto(自动)**是工程实践里的折中------让DAQ 先判断是否需要接管,不需要的时候交给 AEMS精细控制。这个思路在代码注释里有一句话可以印证:
▎ 光伏自动控制模式:储能充满了且没有放电计划。上级控制模式:其他:储能未充满或者有放电计划。
三、初始化与热更新
ctrlPVMode 的初始化发生在 Initialize() 函数里:
c
err = pDPR->Get(pAEMS->hSampler, GETTYPE_ST_VAR, 991, 990, &varV,4);
pAEMS->ctrlPVMode = (CtrlPVMode)((err==ERR_DPR_OK) ? varV.emValue : CtrlPVMode_Auto);
设备节点 991/990 存储的是配置值,读不到就默认Auto。这个设计体现了一个原则:控制策略在运行时可以被上层系统写入覆盖,也就是"热更新"。
在 Control() 函数里,case990 对应这个参数的实时更新:
c
case 990:
pAEMS->ctrlPVMode = (CtrlPVMode)pVarVal->emValue;
break;
同一批热更新的参数还包括:
- 991/992 → fPStep_PV(每次步进功率)
- 991/993 → uMsStep_PV(步进时间间隔)
- 991/994 → uMaxPBackflow(允许最大逆流功率)
- 991/995 → uMaxMsBackflow(允许最长逆流时长)
参数全部通过 DPR(数据处理器)下发,前端 Web 界面改一次配置,运行中的 AEMS进程马上生效,不需要重启。这是工程项目里很实用的一点,现场调试时能省不少事。
四、调度入口:Sample() 里的 switch
每次采样周期,Sample() 函数会先做一批关键变量的预计算:
c
// BESS 最大可充电能力
pAEMS->fMax_Chrg_PwrAbility_AEMS = (pAEMS->bAutoChrgEnable? pBessInfo->fMax_Chrg_PwrAbilitySumA
: (pBessInfo->emACDC_WorkStatuSum == STATU_ACDC_RECTIFY ? pBessInfo->fMax_Chrg_PwrAbilitySum : 0));
// BESS 还能消纳多少
pAEMS->fRemainChrgAbl_Bess = pAEMS->fMax_Chrg_PwrAbility_AEMS - pBessInfo->f_PcabMeterSum;
// 负载功率(光伏出力 + 放电侧交流负载)
pAEMS->fPLoad_AC = pPvInfo->fTotalPVOutputActPower + pBessInfo->fPacload4DischgSum;
// 总消纳能力(负载 + BESS 充电能力)
pAEMS->fPLoad_AndBess = pAEMS->fPLoad_AC + pAEMS->fMax_Chrg_PwrAbility_AEMS;
// 跟随负载的基准控制功率
pAEMS->fCtrlPV_4PLoad_AC = pAEMS->fPLoad_AC - pAEMS->fProtBackflow_PV_AEMS;
这几个中间量是后续所有判断的数据基础。注意 fPLoad_AC 的计算方式------它不是直接读电表,而是用"光伏实际出力 + 放电侧负载"来估算,因为这样更能反映系统当前真实的消纳能力。
预计算完成后,才进入模式调度:
c
switch (pAEMS->ctrlPVMode)
{
case CtrlPVMode_DAQ:
CtrlByDAQ(pAEMS);
break;
case CtrlPVMode_AEMS:
CtrlByAEMS(pAEMS);
break;
default: // Auto
if (!CtrlByDAQ(pAEMS))
{
CtrlByAEMS(pAEMS);
}
break;
}
Auto 模式下的这两行是整个设计里我认为最有意思的地方:DAQ 作为安全网,AEMS 作为主控。CtrlByDAQ 返回 TRUE 时,代表 DAQ认为当前情况它来接管更合适,AEMS 就不再介入。只有 DAQ 判断"这不是我该管的场景"(返回 FALSE),AEMS 才出手。
│
┌───────────┴───────────┐
ctrlPVMode = DAQ
= AEMS │ CtrlByDAQ()CtrlByAEMS()Auto
(default)
│
CtrlByDAQ() 返回?
┌─────┴─────┐
TRUEFALSE
(DAQ接管) CtrlByAEMS()
五、CtrlByDAQ:什么情况下 DAQ 要接管?
函数定义,进入条件有四个,必须同时成立:
c
if (pAEMS->bAntiBackflow_PV // [A] PV 配置了防逆流功能
&& pBessInfo->emACDC_WorkStatuSum != STATU_ACDC_INVERTER // [B] 储能没在放电
&& pBessInfo->emACDC_WorkStatuSum != STATU_ACDC_RECTIFY // [C] 储能没在充电
&& pBessInfo->fACDC_RealTime_CurrSum < 2 // [D] 储能实时电流 < 2A(近似空转)
&& pAEMS->fMax_Chrg_PwrAbility_AEMS < MIN_PWR_CHRG_4START // [E] 储能没有消纳能力
)
翻译成人话:储能完全静止、没有任何充放电行为、且没有消纳逆流功率的能力时,DAQ 接管光伏。
为什么要这样设计?因为在储能空转的场景下,如果 AEMS 主动推送一个较大的光伏功率指令,多余的功率无处可去,会直接往电网倒送,触发防逆流保护甚至导致并网违规。这时最安全的做法是让逆变器维持当前状态------DAQ读取当前实际输出功率并原样写回:
c
pPvCtrl->fActivePower = pPvInfo->fTotalPVOutputActPower;
pPvCtrl->CtrlMode = CTRL_BY_DATA_ACQUISITION;
下发之前还有一个死区过滤,只有以下任一条件成立才真正发送 Modbus 帧:
c
if (fCtrlPwr_Diff > MAX_CtrlPwr_Diff || ElapseS(pPV->tmCtrlPwr_Last) > 1)
即功率偏差足够大,或者距离上次下发超过 1 秒,才触发写操作。这个防抖逻辑很关键,能有效降低 Modbus 总线的写频率,在多台逆变器并联的场景下尤其重要。
六、CtrlByAEMS:五个阶段的控制链路
这是整个模块里逻辑最密集的部分,我把它分成五个阶段逐一分析。
阶段 2.1:关机保护
c
if (pAEMS->fPLoad_AndBess < pAEMS->fProtBackflow_PV_AEMS)
{
if (pPvInfo->fRecvActivePower >0 || pPvInfo->fTotalPVOutputActPower > 1)
{
pPvCtrl->fActivePower = 0;
CtrlPV_AEMS(pAEMS, FALSE);
}
return TRUE;
}
fPLoad_AndBess(负载 + BESS充电能力之和)如果连防逆流保护值都不够,说明光伏出任何功率都会往电网倒送。这时候最正确的选择是关机。
注意有个小细节:只有在逆变器当前仍有出力(fRecvActivePower > 0 || fTotalPVOutputActPower > 1)时才下指令,避免重复向已经关机的逆变器写 0 功率。
阶段 2.2:防逆流降功率
这是代码里最复杂的一段,处理的是电网已经出现逆流但还不需要关机的情况。
c
// 逆流判断
pAEMS->fPBackflow = (pBessInfo->f_PgridMeterSum < 0
? ABS(pBessInfo->f_PgridMeterSum) : 0);
pAEMS->bPwrDown_PV = (
(pAEMS->msBackflow > MAX_MS_Backflow && (
pBessInfo->emACDC_WorkStatuSum == STATU_ACDC_IDLE// BESS停止
|| !pAEMS->bChrgAbl_Bess // BESS无法消纳
|| (pBessInfo->f_PcabMeterSum < 0 &&
(pAEMS->fPBackflow + pBessInfo->f_PcabMeterSum > pAEMS->uMaxPBackflow))
))
|| (pAEMS->fPBackflow > 0 && pAEMS->msBackflow > pAEMS->uMaxMsBackflow)
);
bPwrDown_PV 的触发条件体现了一个分层容忍逻辑:
- 短暂逆流(< MAX_MS_Backflow):允许存在,BESS 有消纳能力时不干预,因为 BESS 本身就能把多余的光伏功率吃掉
- 逆流持续超时且BESS 消纳不了:触发降功率
- 逆流量超过阈值 uMaxPBackflow 或持续超过 uMaxMsBackflow:无论原因,必须降
触发降功率后,功率目标的计算方式:
// 逆流太大 || 逆流太久:降到"跟随负载+BESS功率"的水平pPvCtrl->fActivePower = pAEMS->fCtrlPV_4PLoad_AC + pBessInfo->f_PcabMeterSum- pAEMS->fProtBackflow_PV_AEMS;
这里有个值得关注的设计演进。旧版本(被注释掉的old 代码)直接将功率降到 0,后来改成了"降到负载功率+BESS 当前功率 - 防逆流保护值"。这个改法更合理:与其激进地完全停光伏,不如降到恰好不产生逆流的水平,减少发电量损失。从注释 // old pPvCtrl->fActivePower = 0; 可以看出这是一次迭代优化,现场测试后发现直接归零太激进了。
阶段 3.1 / 3.2:满功率输出
通过了防逆流检查之后,进入正向控制阶段。
c
// 3.1 负载功率超过 PV 额定,直接拉满
if ((float)pPvInfo->iTotalRatedPower_Set * CTRL_ALLPOWER_ADDED < pAEMS->fPLoad_AC)
{
pPvCtrl->fActivePower = (float)pPvInfo->iTotalRatedPower_Set * CTRL_ALLPOWER_ADDED;
// bRampup = FALSE,不需要斜坡,负载能全消纳
}
// 3.2 BESS 在运行且负载+BESS总消纳 > PV 额定
else if (pBessInfo->emACDC_WorkStatuSum != STATU_ACDC_IDLE
&& (float)pPvInfo->iTotalRatedPower_Set * CTRL_ALLPOWER_ADDED < pAEMS->fPLoad_AndBess)
{
bRampup = pAEMS->bRampup_PV;
pPvCtrl->fActivePower = (float)pPvInfo->iTotalRatedPower_Set * CTRL_ALLPOWER_ADDED;
}
3.1 和 3.2 的区别:3.1 里负载单独就能消纳全部额定功率,不需要 BESS 协助,所以不开bRampup,可以一步到位。3.2 里需要BESS 来帮忙消纳,因此是否开爬坡由配置项bRampup_PV(参数991/987)决定------毕竟 BESS的充电能力也有响应时间,激进爬坡会导致短暂的逆流。
阶段 4.1:步进式升功率
到了else 分支,说明当前的光伏额定功率超过了负载+BESS 的总消纳能力,需要谨慎一步步往上试:
c
pAEMS->bPwrUpStep_PV = (
ElapseMS(pPV->dtmRetry_Last) > pAEMS->uMsStep_PV // 调节时间步长
&& (
pBessInfo->f_PgridMeterSum > (pAEMS->fProtBackflow_PV_AEMS + pAEMS->fPStep_PV / 2) // 电网还有裕量
|| pAEMS->fRemainChrgAbl_Bess > (pAEMS->fProtBackflow_PV_AEMS + pAEMS->fPStep_PV / 2) // 储能还能充
)
);
时间条件和功率条件都满足,才往上步进一格(fPStep_PV,通过参数 991/992 配置);如果逆变器当前出力已接近额定(超过iTotalRatedPower_Set * MAX_RATIO_RATEDPWR),就直接跳到额定值,不再一步一步爬。
这个设计思路的核心是时间节流。每次上调之间必须等待 uMsStep_PV 毫秒(配置参数991/993),这样既能防止调节频率太高导致逆变器振荡,也能给电网表计和储能系统留出响应时间。
阶段 5.1:SOC 近满限功率
在最终调用 CtrlPV_AEMS 下发之前,还有一个特殊处理:
c
if (pBessInfo->fSOCSum >= (float)pAEMS->uLmtPwrIfSOCGe_PV)
{
float fPbess_Chrg =0;
if (pBessInfo->emACDC_WorkStatuSum == STATU_ACDC_RECTIFY
|| pBessInfo->emACDC_WorkStatuSum == STATU_ACDC_STANDBY)
{
fPbess_Chrg = MIN((float)pAEMS->uMaxChrgAbl4BESS_PV, pAEMS->fMax_Chrg_PwrAbility_AEMS);
}
pPvCtrl->fActivePower = pAEMS->fPLoad_AC + fPbess_Chrg;
}
储能 SOC 到达 uLmtPwrIfSOCGe_PV(配置参数 991/988,单位 %)时,光伏的目标功率被限制为"负载 +少量充电功率"。如果储能处于充电或待机状态,还会留一点充电余量(上限 uMaxChrgAbl4BESS_PV),否则就只跟随负载。
这段代码的注释里有一行储能快充满,跟随负载+Bess,和注释掉的旧逻辑 降光伏功率对比,可以看出旧版本是"强制限功率",新版本是"智能跟随"。前者会导致储能充满后光伏大幅削减,发电量损失较大;后者则是动态跟随实时负载,最大化利用光伏出力。
完整的 CtrlByAEMS 执行流程
┌────────────┴────────────┐
│ 计算 fPBackflow, │
│ bChrgAbl_Bess │
└────────────┬────────────┘
│
fPLoad_AndBess < fProtBackflow_PV_AEMS?
YES → 关机,返回│ NOf_PgridMeterSum < fProtBackflow_PV_AEMS (逆流)?
YES → 计算 msBackflow,判断 bPwrDown_PV│ bPwrDown_PV=TRUE├→
功率=fCtrlPV_4PLoad_AC+f_PcabMeterSum-保护值
└→CtrlPV_AEMS(FALSE), 返回
│ NO(无逆流)
┌───────┴──────────────────────┐
│ 额定 < fPLoad_AC? │
YES NO3.1 满功率,无斜坡 额定 < fPLoad_AndBess?
YES NO3.2 满功率 4.1 步进升功率
有斜坡 (条件允许时+fPStep)│┌───────┴──────────────────────┐
│ fSOCSum >= uLmtPwrIfSOCGe?│
YES NO
5.1 功率=fPLoad_AC+fPbess_Chrg 维持上面的目标值
│
CtrlPV_AEMS(bRampup)
七、CtrlPV_AEMS:最后一公里
前面所有的计算都是在确定目标功率 pPvCtrl->fActivePower,真正把指令发出去的是 CtrlPV_AEMS()。
这个函数做了三件事:
1. 斜坡限制(Rampup)
c
if (bRampup)
{
pPvCtrl->fActivePower = MIN(
pPvInfo->fRecvActivePower + pAEMS->fPStep_PV,
pPvCtrl->fActivePower
);
}
bRampup=TRUE 时,本次实际下发的功率不能超过"当前反馈功率 + 一个步进量"。即使目标功率是 500kW,当前只有100kW,也只能先发 100+ fPStep_PV。这样做是为了避免逆变器功率突变,特别是 BESS协助消纳的场景里,储能的响应比光伏慢,激进爬坡会短暂超出消纳能力。
2. 负载跟随下界(fCtrlPV_4PLoad_AC)
c
if (pPvCtrl->fActivePower < pAEMS->fCtrlPV_4PLoad_AC)
{
pPvCtrl->fActivePower = MAX(pAEMS->fCtrlPV_4PLoad_AC, pPvCtrl->fActivePower);
}
fCtrlPV_4PLoad_AC = fPLoad_AC - fProtBackflow_PV_AEMS,是"跟随当前负载的最低出力基准"。如果经过斜坡限制后目标功率低于这个值,说明光伏出力不足以覆盖负载,这时候不应该被斜坡拖着往下走,而是应该立刻拉到基准值------注释写得直白:负载马上到位。注意这个逻辑,旧版本没有这个下界保护,会导致在负载较大时光伏恢复输出的速度过慢,储能要额外放电补缺口。
3. 死区过滤与实际下发
c
pPvCtrl->fActivePower = MIN(
(float)pPvInfo->iTotalRatedPower_Set * CTRL_ALLPOWER_ADDED,
pPvCtrl->fActivePower
);
pPvCtrl->fActivePower = MAX(0, pPvCtrl->fActivePower);
float fCtrlPwr_Diff = ABS(pPvCtrl->fActivePower - pPvInfo->fRecvActivePower);
if (pPvCtrl->CtrlMode != CTRL_BY_AEMS
|| pPvInfo->RecvCtrlMode != CTRL_BY_AEMS
|| fCtrlPwr_Diff > MAX_CtrlPwr_Diff
|| ElapseS(pPV->tmCtrlPwr_Last) > 1)
{
pPvCtrl->CtrlMode = CTRL_BY_AEMS;
pPV->tmCtrlPwr_Last = _NOW_;Push_BlobValue(SETTYPE_RAW_SP, pAEMS->hSampler, 1050, 3752,(void*)pPvCtrl,
sizeof(A_BLOB_PV_CTRL_BY_AEMS), BLOB_TYPE_PV_SRTTING_BY_AEMS);
}
先把功率值夹紧在0, 额定 × CTRL_ALLPOWER_ADDED 区间内,再判断是否有必要真的发Modbus。有四种情况必须发:控制模式不对、逆变器反馈的控制模式不对、功率偏差超过死区、或者距离上次下发超过 1 秒(心跳保活)。
这个1 秒心跳的意义:逆变器本地控制器如果长时间收不到 AEMS 指令,可能会因为通信超时切换回本地模式。每隔 1秒强制重发一次,确保控制权不丢。
八、Auto模式里的 DAQ 优先级究竟意味着什么
回过头来看这段代码,DAQ 模式的触发条件再仔细看一遍:
c
if (pAEMS->bAntiBackflow_PV
&& pBessInfo->emACDC_WorkStatuSum != STATU_ACDC_INVERTER
&& pBessInfo->emACDC_WorkStatuSum != STATU_ACDC_RECTIFY
&& pBessInfo->fACDC_RealTime_CurrSum< 2
&& pAEMS->fMax_Chrg_PwrAbility_AEMS < MIN_PWR_CHRG_4START)
条件 C、D 是判断储能是否真的空转的双重保险------光靠状态机(emACDC_WorkStatuSum)不够可靠,状态切换有滞后,所以再加一个实时电流判断(fACDC_RealTime_CurrSum < 2A)。这是从现场踩坑里来的经验:储能状态刚从放电切到待机,CCU的状态字还没来得及更新,但电流表计已经到0 了,用电流来兜底更实时。
在 Auto 模式下,整个逻辑链可以理解成:
储能空转 + PV配了防逆流?YES → DAQ接管(维持现状,防止乱发功率)
NO → AEMS接管(主动优化出力)
本质上是把"是否有能力消纳额外的光伏功率"作为模式切换的核心判据。有能力消纳,AEMS来帮你用好每一度电;没能力消纳,让逆变器自己维持,别添乱。
九、待机控制的联动
ctrlPVMode 调度完成后,Sample() 还有一段待机逻辑:
c
bNeedStandby = (
(pPvInfo->fTotalPVPowerAbility > MIN_PWR_LMT|| pPvInfo->fTotalPVOutputActPower > MIN_PWR_LMT)
&& pAEMS->fMax_Chrg_PwrAbility_AEMS > MIN_PWR_CHRG_4START
&& (
pPvCtrl->fActivePower < pPvInfo->iTotalRatedPower_Set
|| pPvInfo->fTotalPVOutputActPower >= pAEMS->fPLoad_AC
|| pBessInfo->fPacload4DischgSum< pAEMS->fLtPwr2Standby_PV
|| pBessInfo->emACDC_WorkStatuSum == STATU_ACDC_INVERTER
)
);
翻译:光伏有出力能力 + BESS 有消纳能力 + (光伏出力未满额定,或光伏出力已超过负载,或负载偏低,或储能在放电)→ 让BESS进入待机模式。
待机不是关机,是让 CCU降低自放电损耗、进入浅休眠。这个逻辑是光伏控制策略的"下游",只有在光伏功率分配确定之后,才能判断是否需要 BESS待机。这也是为什么待机逻辑放在 ctrlPVMode switch 后面而不是前面。
停止待机有一个延时保护 uDelayStandby_PV(参数 1001/3318),防止待机状态在临界条件附近反复振荡。
十、整体设计回顾
把整个 ctrlPVMode 的链路重新串一遍:
配置读取(991/990)
│Initialize()
│
┌──────┴────────────────────────────────────────────┐
│ Sample() 周期调用 │
│ │ │
│ 1. 读BESS 状态 (1001/3900) │
│ 2. 读 PV 状态 (1050/3697) │
│ 3. 预计算 fPLoad_AC / fPLoad_AndBess 等 │
│ 4. ctrlPVMode switch─────────────────────────── │
│ ├── DAQ →CtrlByDAQ() │
│ ├── AEMS → CtrlByAEMS() │
│ └── Auto → CtrlByDAQ() || CtrlByAEMS() │
│ │ │
│ CtrlPV_AEMS()死区过滤后下发 │
│ │ │
│ 5. BESS 待机联动判断 │
│ 6. Do_Charger_EM() 充电桩能量管理 │
└────────────────────────────────────────────────────┘
│
Control() 热更新(任意时刻)
│
参数写入 pAEMS->ctrlPVMode 等
几个我认为设计得比较扎实的地方:
**分层容忍,而不是硬阈值关机。**逆流从出现到触发降功率,中间有MAX_MS_Backflow 的宽容窗口,BESS有时候能在这个窗口里把逆流吃掉,完全不需要降光伏。只有 BESS真的没能力、或者逆流时间过长,才强制干预。旧版本直接降到0,新版本改成"降到跟随负载的水平",保留了更多发电量。
斜坡与跟随的平衡。 bRampup 只在特定场景开启(3.2 满功率+BESS 协助时),防止爬坡时逆变器和储能之间的响应时差产生逆流。但在纯负载消纳场景(3.1)或主动降功率场景(2.2),不开斜坡,响应更快。同时下界保护 fCtrlPV_4PLoad_AC确保恢复时不会被斜坡拖累太久。
热更新全覆盖。 从控制模式到步进量、时间间隔、最大逆流值,所有关键参数都可以在不重启的情况下通过 DPR 下发修改。这对现场调试非常实用------不同项目的变压器容量、储能容量差异很大,参数需要反复调整,热更新能省掉很多重启等待时间。
双重防抖。 CtrlByDAQ 里有 5秒的条件防抖,CtrlPV_AEMS 里有 1秒的心跳+死区过滤。两层保护确保既不会因状态短暂抖动频繁切换控制模式,也不会因控制模式频繁切换产生功率振荡。
唯一觉得有点绕的地方是 fPLoad_AC 的计算方式------用"光伏实际出力 +放电侧负载"来估算系统负载,而不是直接读一个独立的负载电表。这是受限于硬件配置(部分项目没有单独的负载表),但在光伏出力变化剧烈时,这个估算值会跟着抖,进而影响fPLoad_AndBess 和 fCtrlPV_4PLoad_AC 的稳定性。这是后续可以优化的地方:如果有独立的负载电表数据,优先使用。
代码里标注的那些历史改动这套逻辑经历了相当多的现场迭代。能看到它从"粗暴降0"演化到"智能跟随",从"无斜坡"到"有条件斜坡",从"固定阈值"到"带滞后的容忍窗口",每一步改动背后都对应着一类实际踩到的坑。