一、为什么储能系统必须要有状态机

        我在做储能项目的早期,曾经见过一段"面条式"的充放电控制代码。逻辑大概是这样的:

  // 反面教材,请勿模仿
  void BatteryControl(void)

        {
      if (bms_ok && soc < 90 && !charge_forbidden && power > 0)

        {
          start_charging();
      } else if (soc >= 90 || charge_forbidden)

        {
          stop_charging();
      }
      if (!charge_ok && soc > 20 && discharge_power > 0)

        {
          start_discharging();
      }
      // ... 还有几百行类似的逻辑
  }

        这种写法在实验室能跑通,但一到现场就开始出问题:充放电同时触发、BMS告警时继续充电没有停、停充之后立刻又重启……所有问题的根源只有一个——代码没有"现在系统处于什么阶段"的概念,所有判断都是无状态的瞬时快照,逻辑之间相互踩踏。

        状态机解决的正是这个问题。它给系统一个"自我认知":我现在在充电,所以放电的条件我根本不去看;我现在在等待PCS响应,所以不要重复发指令……这种显式的状态管理,是工业控制软件从玩具走向生产的分水岭。

        我们项目的状态机代码在 st_batt_statemachine.c 里,它管理的是储能系统中每一组电池单元的运行状态。让我从头到尾把它拆开来讲。


  二、六状态全景图:比教科书更复杂的现实

        标准的充放电状态机教科书只给三个状态:空闲、充电、放电。而我们的实际工程里跑的是六个:

  STBATT_STATE_IDLE          → 空闲
  STBATT_STATE_WAIT_CHARGE   → 等待充电确认
  STBATT_STATE_IN_CHARGING   → 充电中
  STBATT_STATE_IN_DISCHARGING→ 放电中
  STBATT_STATE_FAULTED       → 故障
  STBATT_STATE_OFFGRID       → 离网(孤岛运行)

        为什么要多出 WAIT_CHARGE 和 OFFGRID 这两个状态?这背后是两个非常具体的工程问题。

        关于 WAIT_CHARGE: 当EMS发出充电启动指令(GRP_ACDC_RECTIFY_START)给PCS变流器后,PCS需要时间完成母线预充、继电器吸合、PWM调制启动等一系列硬件动作,这个过程在我们的现场大约需要5到30秒不等。在这段时间内,电池电流还没有真正流动,但如果系统还待在IDLE状态,它可能会
        因为"充电还没开始"而再次触发启动逻辑,造成重复发指令、PCS报通信异常。WAIT_CHARGE 就是这个"等待PCS响应"的中间缓冲状态,系统进入它之后,对外屏蔽所有新的启动触发,只等一件事:PCS电流是否真的上来了。

        关于 OFFGRID: 这是电网侧断开、系统转为孤岛运行时的专属状态,下面会有专门一节详细讲。

六个状态的完整转换关系可以用这张图来理解:

                                                 ┌────────────────────────┐
                                                  │                                                             │
                                       故障告警/通信断开                                        故障消除
                                                  │                                                             │
      ┌──── IDLE ────►FAULTED◄───────────────────┘
      │              │
      │  [充电条件满足]──►WAIT_CHARGE──►IN_CHARGING
      │                                                │                                    │
      │                                         [90s超时/故障]              [停充条件]
      │                                                │                                    │
      │                                                └──────────────┘
      │                                                                  │
      │                                                               IDLE
      │
      │                  [放电条件满足]──────────►IN_DISCHARGING
      │                                                                               │
      │                                                                [停放条件/SOC低]
      │                                                                               │
      └───────────────────────────────┘

      任意状态 + [电网断开] ──────────────►OFFGRID
      OFFGRID  + [电网恢复] ──────────────►IDLE


  三、为何需要分层状态机(HSM),而不是扁平枚举

        刚才展示的六状态图,如果直接用一个 switch-case 实现,你会发现一个让人头疼的问题:有些条件检查在每个状态下都要做。

        比如,无论系统处于IDLE、WAIT_CHARGE、IN_CHARGING还是IN_DISCHARGING,只要检测到电网断开,就必须立刻跳转到OFFGRID。如果是扁平状态机,你得在每个状态函数里各写一遍:

  // 扁平状态机的噩梦:每个状态都要重复写
  STBATT_STATE OnIdle(...) {
      if (JudgeIfNeedEnterOffGrid(...)) return STBATT_STATE_OFFGRID;
      // ...
  }
  STBATT_STATE OnWaitCharge(...) {
      if (JudgeIfNeedEnterOffGrid(...)) return STBATT_STATE_OFFGRID;
      // ...
  }
  STBATT_STATE OnCharging(...) {
      if (JudgeIfNeedEnterOffGrid(...)) return STBATT_STATE_OFFGRID;
      // ...
  }
  // 以此类推...

        看看真实代码,确实是这么写的——JudgeIfNeedEnterOffGrid 出现在第516、716、911、1032行,每个正常运行状态里各一次。这是分层状态机(Hierarchical State Machine,HSM)思想的一种手工实现。

        HSM的核心思想是:把共享的转换逻辑提升到父状态层,子状态只处理自己特有的逻辑。 在我们这个工程里,IDLE/WAIT_CHARGE/CHARGING/DISCHARGING可以看作一个"正常运行"父状态的子状态,而"电网断开→OFFGRID"这条转换属于父状态层。

        真正的HSM框架(如Miro Samek的QP框架)会通过事件冒泡机制自动处理这种层次结构。但在嵌入式C工程里,受限于工具链和代码习惯,我们选择了更直白的方式:在每个子状态函数里显式调用共享检查函数。这虽然有重复,但代码的可读性和可调试性反而更好——每个状态函数打开来就是一张清单,任何人都能读懂它的行为,不需要理解框架的分发机制。这是一个典型的"工程实用主义"选择:完美的架构 vs 可维护的实现,在工业软件里,后者往往胜出。


  四、OnCharging() 的多重守卫:一道精密的安全过滤器

        OnCharging 是整个状态机里逻辑最复杂、安全等级最高的函数。它做的最核心的一件事,是决定"充电状态下,什么时候应该停止充电"。

        这个停止条件不是一个简单的 if,而是一个由七个条件组成的 OR 门,只要其中任何一个成立,就立刻停充:

  if (pSingleRunInfo->bIsBatt_ChargeForbidden ||          // ① BMS下发禁充标志
      IsBatt_Plan2_StopCharge(pSTBatt_Rough, nBattIdx) || // ② EMS计划停充(SOC到达上限)
      (pSingleRunInfo->emBattStatus_Alm != STBATT_ALMSTAT_NOMAL) ||  // ③ BMS告警
      (pSingleRunInfo->emBattStatus_Run <= STBATT_RUNSTAT_COMM_FAIL) || // ④ BMS通信故障
      (pSTBatt_Rough->ccuStatu != CCU_STATE_IDLE) ||      // ⑤ PCS控制器状态异常
      (pSingleRunInfo->fAllow_MaxChargeCurr < 5.0) ||     // ⑥ BMS允许最大充电电流过低
      ((pSingleRunInfo->fPackTotalVolt - pSingleRunInfo->fAllow_LmtChargeVolt) > 10) || // ⑦ 电压超限
      (pSTBatt_Rough->calcNodeData[nBattIdx].emBatt_WorkMode != BATT_WORKMODE_FIRST_CHRGING) // ⑧ 工作模式切换
  )

        我把这八个条件逐一拆解,每一个背后都有具体的工程故事:

  ① bIsBatt_ChargeForbidden:BMS的最高裁决权

        这个标志来自BMS(电池管理系统)通过CAN总线下发的实时指令。当电池出现单体过温、单体过压、绝缘阻值过低等危险情况时,BMS会主动置位这个标志。它是整个充放电系统里优先级最高的信号,没有任何其他逻辑可以覆盖它。

        在代码层面,bIsBatt_ChargeForbidden 由CAN报文解析层(can_pl_batt.c、can_infy_batt.c等)实时更新,OnCharging 每个控制周期都读取最新值。这是一个典型的"安全第一"设计:硬件层面的保护信号,永远优先于软件逻辑。

  ② IsBatt_Plan2_StopCharge:EMS计划层的SOC上限控制

  static BOOL IsBatt_Plan2_StopCharge(STBATT_SAMPLER_ROUGH *pSTBatt_Rough, int nBattIdx)
  {
      float fJudgeSOC = (float)pSingleRunInfo->nSOC + 0.05; // 留0.05%余量
      if (fJudgeSOC >= ((float)pEmsCfgPlan->emsLmtSectors[n].nStopSOC))
      {
          return TRUE;
      }
      return FALSE;
  }

        注意这里有一个细节:SOC判断值加了0.05的偏置(fJudgeSOC = nSOC + 0.05)。这是为了处理SOC传感器的测量抖动——如果SOC恰好在停充阈值附近来回跳变,没有这个偏置的话,充电会频繁地启停,对PCS和电池都是伤害。加了0.05之后,一旦判断停充,SOC至少要降到 nStopSOC - 0.05      
        以下才有可能再充,形成了一个微小的滞回区间。

  ③④ BMS告警与通信故障:分开处理的用意

        告警(emBattStatus_Alm)和通信故障(emBattStatus_Run <= STBATT_RUNSTAT_COMM_FAIL)是两类不同性质的异常:

  - 告警意味着BMS还在运行,只是检测到了电池层面的问题(如温度高、电压偏差大)
  - 通信故障意味着EMS和BMS之间的CAN链路断开,EMS看不到电池任何信息

  这两种情况都必须停充,但原因完全不同。如果通信断开时还继续充电,EMS将失去对电池状态的感知,一旦电池真的出问题,系统将毫无反应。

  ⑥ fAllow_MaxChargeCurr < 5.0:BMS动态电流限制

  BMS会根据当前电池的SOC、温度、老化状态,实时更新允许充电的最大电流。当这个值低于5A时,意味着电池目前只接受极小的电流,已经接近充电截止状态(通常是CV阶段末期)。继续保持PCS运行的意义不大,还会浪费设备待机功耗,不如直接停机等下一个调度周期。

  ⑦ 电压超限保护:最后一道硬件防线

  ((pSingleRunInfo->fPackTotalVolt - pSingleRunInfo->fAllow_LmtChargeVolt) > 10)

        当电池包总电压超过BMS设置的限压上限10V时强制停充。这个10V的裕量不是随意取的:BMS的限压值本身已经留有安全余量,超过10V意味着BMS的软件保护可能已经失效或数据异常,需要EMS在硬件层面兜底。

  ⑧ 工作模式检查:防充放并行

        这条守卫是2024年11月新加的(注释里有 LJH241120 fixChrgAndDisTogether):

  (pSTBatt_Rough->calcNodeData[nBattIdx].emBatt_WorkMode != BATT_WORKMODE_FIRST_CHRGING)

        当EMS上层决策从"充电优先"切换到"放电优先"时,这个工作模式标志会更新。通过这条检查,充电状态函数能够感知到上层决策的变化,在下一个控制周期主动退出充电——而不是等到BMS或SOC触发停止。这修复了一个真实的现场问题:在充放电交替的调度场景下,工作模式切换后PCS会短暂地同时保持充电和放电的指令,造成功率抵消和异常发热。


  五、状态转换的原子性:一个容易被忽视的数据一致性问题

        在多线程嵌入式系统里,状态机的状态转换不是瞬间完成的。从"当前状态函数返回新状态值"到"状态变量被更新",中间存在一个窗口期。如果在这个窗口期内,另一个线程读取了状态变量,它看到的是旧状态,但系统实际上已经在向新状态迁移了。

  我们的状态机通过一种简单但有效的机制来保障原子性:

  // 来自 st_batt_sampler.c 的状态机调度循环(概念简化版)
  STBATT_STATEMACHINE_STATE emNextState = STBATT_STATE_IDLE;

  switch (pSTBatt_Rough->emCurState[nBattIdx])
  {
      case STBATT_STATE_IDLE:
          emNextState = OnIdle(pSTBatt_Rough, nBattIdx);
          break;
      case STBATT_STATE_WAIT_CHARGE:
          emNextState = OnWaitCharge(pSTBatt_Rough, nBattIdx);
          break;
      case STBATT_STATE_IN_CHARGING:
          emNextState = OnCharging(pSTBatt_Rough, nBattIdx);
          break;
      // ...
  }

  // 状态转换发生在这一行,且只有这一行
  pSTBatt_Rough->emCurState[nBattIdx] = emNextState;

        状态转换被压缩到一次赋值操作。在C语言里,对一个枚举变量的赋值(通常是4字节整数赋值)在绝大多数平台上是原子的。只要确保没有其他线程在这条赋值的同时读取 emCurState,就不会出现状态撕裂(State Tearing)。

        另一个保障来自于 Log_CtrlPcsEventStatus 函数——它在每次状态相关的PCS指令下发时,同步记录三元组(状态、指令、原因):

  void Log_CtrlPcsEventStatus(...,
      IN STAT_BMSMGMT_STATUS emMgmtStatus,
      IN CMD_BMSMGMT_PCSCTRL emMgmtPcsCtrlCmd,
      IN REASON_BMSMGMT_EXEC emMgmtExecReason)
  {
      pSingleRunInfo->emMgmtStatus     = emMgmtStatus;
      pSingleRunInfo->emMgmtPcsCtrlCmd = emMgmtPcsCtrlCmd;
      pSingleRunInfo->emMgmtExecReason = emMgmtExecReason;
  }

        这个三元组是整个状态转换的"事务记录"——发了什么指令、为什么发、发的时候系统是什么状态,三者绑定存储。任何时刻调试或事后追溯,都能从这个三元组还原出转换现场,极大降低了问题定位的难度。


  六、OFFGRID:最特殊的状态,也是最考验系统设计的状态

        STBATT_STATE_OFFGRID 是整个状态机里最特殊的一个,它对应的场景是:电网侧断电或主动解列,储能系统转为孤岛模式,独立为本地负荷供电。

  先看 OnOffGrid 的代码结构:

  STBATT_STATEMACHINE_STATE OnOffGrid(STBATT_SAMPLER_ROUGH* pSTBatt_Rough, int nBattIdx)
  {
      // 第一优先:检查安全条件,不满足就紧急停机
      if ((pSingleRunInfo->bIsBatt_DisChargeForbidden == TRUE) ||
          (pSingleRunInfo->bIsBatt_ChargeForbidden == TRUE) ||
          (pSingleRunInfo->emBattStatus_Alm != STBATT_ALMSTAT_NOMAL) ||
          (pSingleRunInfo->emBattStatus_Run <= STBATT_RUNSTAT_COMM_FAIL) ||
          (pSTBatt_Rough->ccuStatu != CCU_STATE_IDLE))
      {
          Send_Ctrl_CCU(pSTBatt_Rough, GRP_ACDC_STOP, nBattIdx);
      }

      // 第二优先:检查是否已恢复并网
      if (JudgeIfNeedEnterOffGrid(pSTBatt_Rough, nBattIdx) == FALSE)
      {
          return STBATT_STATE_IDLE; // 电网恢复,退出离网状态
      }

      // 第三:SOC保护——电量耗尽时停止逆变,防止电池过放
      if ((pSTBatt_Rough->blobSingleBattInfo[nBattIdx].nSOC <= pSTBatt_Rough->nProtectSOC)
          && (pSTBatt_Rough->branchGrpData[nBattIdx].emACDC_WorkStatu != STATU_ACDC_IDLE))
      {
          Send_Ctrl_CCU(pSTBatt_Rough, GRP_ACDC_STOP, nBattIdx);
      }
      else
      {
          // 正常离网运行:维持PCS热备待机状态
          OnHandle_PCSHotWaiting(pSTBatt_Rough, nBattIdx);
      }

      return STBATT_STATE_OFFGRID;
  }

        OnOffGrid 的逻辑层次非常清晰,三个优先级依次递进:

        第一层:安全兜底。 即使在离网模式下,BMS的禁充禁放和告警信号依然有效,一旦触发就立刻停机。这保证了"孤岛运行"不会成为安全保护的盲区。

        第二层:并网恢复检测。 JudgeIfNeedEnterOffGrid 持续检测STS(静态开关)的并离网状态位。一旦电网恢复,立刻返回IDLE,重新参与EMS的并网调度。这里有一个工程细节值得注意:离网恢复后直接回到IDLE而不是回到充电或放电状态,由IDLE的逻辑重新评估条件,做一次干净的"冷启动判断"——这避免了并网瞬间因历史状态残留而造成的功率冲击。

        第三层:离网SOC保护。 当电量耗尽(SOC降至保护阈值)时,停止逆变输出。注意这里的判断条件同时要求 emACDC_WorkStatu != STATU_ACDC_IDLE——如果PCS已经停了,就不需要再发停机指令,避免无效的CAN通信。

关于黑启动(Black Start)

  OnHandle_PCSHotWaiting 函数是离网状态下"热备待机"的核心实现:

  static void OnHandle_PCSHotWaiting(STBATT_SAMPLER_ROUGH* pSTBatt_Rough, int nBattIdx)
  {
      if (JudgeIfPCS_OnHotWaiting(pSTBatt_Rough, nBattIdx) == TRUE)
      {
          BOOL bPCSAvaliable = ((SysStatu.Byte0_Bit0to2 == 0) ||
                                (SysStatu.Byte0_Bit0to2 == 4)) ? TRUE : FALSE; // 0=正常, 4=告警

          if ((FaultState == 0) &&                        // PCS无故障
              (emACDC_WorkStatu == STATU_ACDC_IDLE) &&    // PCS当前空闲
              (bPCSAvaliable == TRUE) &&
              (pSTBatt_Rough->bBattProt == FALSE))        // 无电池保护
          {
              // 发送逆变器热备指令
              Send_Ctrl_CCU(pSTBatt_Rough, GRP_ACDC_INVERT_PENDING, nBattIdx);
          }
      }
  }

        GRP_ACDC_INVERT_PENDING 是一个"热备"指令——让PCS进入逆变器就绪状态但不立刻输出功率,相当于"引擎已经点火但还没挂档"。当本地负荷有需求时,PCS可以在毫秒级内从热备状态转为全功率输出,实现对负荷的无感切换。

        这就是工业现场黑启动能力的软件实现基础:系统不是等电网断了再慢慢启动,而是在检测到离网信号的瞬间,就开始准备好随时承担本地供电的能力。


  七、状态机测试策略:状态覆盖 vs 转换覆盖

        在工程实践中,我见过两种截然不同的状态机测试思路,它们各有侧重,需要结合使用。

        状态覆盖(State Coverage): 确保每个状态都被执行到。这是最基本的要求,相当于行覆盖。但它有一个严重的盲点:一个状态可以通过不同的转换路径进入,覆盖了这个状态,不代表覆盖了所有到达它的转换。

        转换覆盖(Transition Coverage): 确保每条状态转换边都被执行到。这比状态覆盖更强,但在实际工程中还不够——因为一条转换可能有多个触发条件(OR关系),需要单独测试每个条件的触发。

        在我们的项目里,针对 OnCharging 的八个停止条件,真正完备的测试策略应该是条件组合覆盖:

  测试矩阵(部分):

  另一个在工程现场特别有价值的测试手段是时间序列录制回放:

        在正常运行时,把每个控制周期的状态、BMS数据、PCS状态等关键变量序列化到CSV文件(这正是 st_batt_testmode.c里的功能)。然后在测试环境里用录制的数据驱动状态机,不依赖真实硬件就能复现任意历史场景。这个方法救过我们团队好几次——客户现场报了一个偶发的异常状态转换,把他们的数据日志拿回来一回放,问题10分钟内定位。


  八、AI结合:用状态转换序列预测电池健康退化

  讲完工程实现,我想把视角往前延伸一步——如果用AI来观察这台状态机,我们能得到什么?

  核心观点:状态转换序列本身就是电池健康状态的时序特征。

  一块健康的电池,它的状态转换序列应该呈现规律的节律:

  IDLE → WAIT_CHARGE → IN_CHARGING → IDLE → IN_DISCHARGING → IDLE → ...

        整个充电过程持续时间稳定,充电电流调整次数少,BMS告警出现频率极低。但随着电池老化或出现潜在问题,状态转换序列会开始出现异常特征:

        将这些观察系统化,可以构建一个基于LSTM的状态序列异常检测模型:

  # 特征工程:把状态转换序列编码为时序特征向量
  def encode_state_sequence(df):
      features = []
      for cycle in df.groupby('cycle_id'):
          features.append({
              'wait_charge_duration': cycle['WAIT_CHARGE'].sum(),  # 等待充电时长
              'charge_duration': cycle['IN_CHARGING'].sum(),        # 充电时长
              'discharge_duration': cycle['IN_DISCHARGING'].sum(),  # 放电时长
              'fault_count': cycle['FAULTED'].count(),              # 故障次数
              'charge_stop_reason_distribution':                    # 停充原因分布
                  cycle['stop_reason'].value_counts().to_dict(),
              'bms_forbidden_ratio':                                # BMS禁充触发比例
                  cycle['bms_forbidden'].mean()
          })
      return pd.DataFrame(features)

  # LSTM模型:输入30天的循环特征,预测未来7天的健康状态
  model = Sequential([
      LSTM(64, return_sequences=True, input_shape=(30, n_features)),
      LSTM(32),
      Dense(1, activation='sigmoid')  # 输出:异常概率
  ])

        这个方法在理论上有几个很好的特性:

        一、不依赖传感器绝对精度。 SOC估算可能有±5%的误差,但状态转换的时序特征(充电持续多少分钟、BMS触发了几次限流)是相对稳健的,误差不会累积。

        二、能捕捉多变量交互的模式。 人工看日志可以发现"BMS禁充越来越频繁",但很难同时发现"BMS禁充频率与当天最高温度的相关性在升高"——这种多维度的隐含关联正是LSTM擅长的。

        三、天然适合增量学习。 每个充放电循环产生新数据,模型可以定期(如每周)用新数据fine-tune,无需重新训练,适合部署在边缘端的控制器上。

        当然,这套方案目前还在理论和小规模验证阶段。把它部署到实际储能系统面临的最大挑战是:标注数据稀缺。电池真正失效的案例在现场相对罕见,做监督学习的正负样本极度不均衡,需要结合无监督的异常检测(如Isolation Forest)来弥补。


  九、回头看:状态机设计的本质

        写完这篇文章,我意识到 st_batt_statemachine.c 这个文件教给我最重要的一件事,不是某个具体的技术细节,而是一种把不确定的世界变得可预测的思维方式。

        电池会有各种奇怪的状态,PCS会在意想不到的时候抖一下,BMS的CAN帧可能迟来或乱序——这些不确定性是工业现场的常态。状态机的价值不是消灭这些不确定性,而是把系统对不确定性的响应,收敛到一个有限的、可枚举的、可测试的状态转换集合里。

        任何时刻,如果系统出现异常,你只需要知道两件事:当前在哪个状态、触发了哪个转换。定位到这两件事,问题就解决了一半。

这,是状态机最本质的工程价值。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐