STM32 BMS SOC/SOH 估算核心算法深度解析:soc_estimator.c

STM32 BMS SOC/SOH 估算核心算法深度解析:soc_estimator.c

本篇文章对嵌入式 BMS(电池管理系统)固件中 Core/Src/soc_estimator.c 源码进行逐段深度解析。该模块基于 SH367601 采样 AFE 与 STM32F103,是整块 BMS 中「最见功力」的核心算法单元——它回答了三个问题:电池还剩多少电(SOC)、电池用旧了没(SOH)、以及当前可用容量是多少(soh_capacity_mAh)。我们不仅讲「它做了什么」,更要讲「为什么这么做」以及「哪些工程细节容易踩坑」。

1. 文件总览与设计哲学

源码开头的注释几乎是整个模块的纲领:

/**
 * SOC 策略:安时积分(主干) + OCV 查表校准(定期修正)
 * SOH 策略:容量衰减 + 直流内阻增长 + 温升异常 三指标加权融合
 */

读懂了这一句,就抓住了全篇的脉络:

  • SOC(荷电状态):主算法是安时积分(库仑计数),它实时准确但会随时间漂移;因此用OCV(开路电压)查表在特定时机去「纠偏」。
  • SOH(健康状态):单一指标不可靠,采用三个维度加权——容量衰减、直流内阻(DCR)增长、温升异常。

这是工业界「保守但稳健」的经典组合,尤其适合 磷酸铁锂(LFP) 电芯——这类电芯的电压平台极平,光靠电压根本算不出 20%~80% 区间的 SOC,必须依赖安时积分 + 端点校准。

2. 关键宏与数据类型基础

#define CLAMP(val, lo, hi)  ((val) < (lo) ? (lo) : (val) > (hi) ? (hi) : (val))
#define ARRAY_SIZE(arr)     (sizeof(arr) / sizeof((arr)[0]))
  • CLAMP:数值限幅宏,保证任何计算结果都在 [lo, hi] 区间内。全程几乎每个输出都套了它,避免积分/除法溢出产生荒谬值。
  • ARRAY_SIZE:静态数组元素个数。注意:只对「同一数组」安全,若传入指针会得到错误的 sizeof 值——这也是本文后面要提醒的隐患。

3. OCV-SOC 查表:LFP 与 NCM 两套曲线

模块内置两张静态查表:ocv_table_lfp[](磷酸铁锂,默认)与 ocv_table_ncm[](三元锂,备用)。以 LFP 为例,它的电压-OCV 关系具有强非线性与平台区的特点:

/* 充电末端:电压爬升明显,OCV 可区分 */
{3650.0f, 100.0f},  /* 3.65V — 满充 */
{3400.0f, 98.0f },
{3360.0f, 95.0f },  /* 3.36V — 进入平台区前 */
...
/* 平台区:电压变化极小,OCV 精度差,主要靠安时积分 */
{3330.0f, 80.0f },  /* 3.330V — 平台区上限 */
{3315.0f, 60.0f },
{3300.0f, 30.0f },  /* 3.300V — 平台区下限 */
/* 放电末端:电压快速下降,OCV 再次可用 */
{2900.0f, 3.0f  },
{2500.0f, 0.0f  },  /* 2.50V — 放电截止 */

工程要点

  • LFP 在 3.30V~3.33V 之间,电压只变化 30mV 却覆盖了 30%~80% 的 SOC——这就是「平台区」。
  • 数据来源注释写得很诚实:「典型 100Ah LFP 电芯,25℃,0.1C 放电 + 每 5% SOC 静置 2h 实测」。实际量产必须用自家电芯重新标定,这是此类代码最容易犯的错误——直接拿别人的曲线。
  • OCV 测量的前提是充分静置(极化消失),否则测到的是端电压而非开路电压。

4. OCV → SOC 线性插值

static float ocv_to_soc(float voltage_mV) {
    if (g_ocv_table == NULL || g_ocv_table_size < 2) return 50.0f;
    /* 边界检查 */
    if (voltage_mV >= g_ocv_table[0].voltage_mV) return 100.0f;
    if (voltage_mV <= g_ocv_table[g_ocv_table_size - 1].voltage_mV) return 0.0f;
    /* 线性插值 */
    for (uint16_t i = 0; i < g_ocv_table_size - 1; i++) {
        float v_high = g_ocv_table[i].voltage_mV;
        float v_low  = g_ocv_table[i + 1].voltage_mV;
        if (voltage_mV <= v_high && voltage_mV > v_low) {
            float ratio = (voltage_mV - v_low) / (v_high - v_low);
            return g_ocv_table[i + 1].soc + ratio * (g_ocv_table[i].soc - g_ocv_table[i + 1].soc);
        }
    }
    return 0.0f;
}

设计要点

  • 表按「电压从高到低」排列,插值区间用 voltage_mV <= v_high && voltage_mV > v_low 划分,左闭右开避免边界重叠。
  • 线性插值假设 OCV-SOC 在小区间内近似线性,这在平台区误差极大——所以调用它的 OCV 校准逻辑必须「避开平台区」,稍后看到。
  • 备用表未加载(g_ocv_table == NULL)时返回 50% 兜底,避免野指针。

5. 电池组统计量计算

static void update_cell_statistics(void) {
    const afe_data_t *afe = sh36760x_get_data();
    if (!afe->data_valid) return;
    /*** 求平均/最大/最小电压,以及最大/最小温度 ***/
}
  • 从 AFE 驱动取数据,数据无效直接返回,保持上次统计。
  • 计算 avg/max/min_cell_voltage_mVmax/min_temperature_C(温度原始值 ×0.1 转换,因为 AFE 上报的是 0.1°C 分辨率)。
  • 此函数每次 bms_get_state() 调用时刷新,供上层显示与保护判断使用。

6. SOC 主干算法:安时积分(库仑计数)

void soc_coulomb_update(void) {
    float current_A  = (float)afe->pack_current * 0.001f;    /* mA → A */
    float dt_h       = (float)SAMPLE_INTERVAL_MS / 3600000.0f; /* ms → h */
    float eta = (current_A >= 0) ? COULOMB_EFF_DISCHARGE : COULOMB_EFF_CHARGE;
    g_bms.soc_est.coulomb_counter_mAh += current_A * 1000.0f * dt_h * eta;
    float cap = g_bms.soc_est.soh_capacity_mAh;
    if (cap < 100.0f) cap = NOMINAL_CAPACITY_mAh;  /* 未标定时用标称值 */
    float remaining = cap - g_bms.soc_est.coulomb_counter_mAh;
    g_bms.soc_est.soc = (remaining / cap) * 100.0f;
    g_bms.soc_est.soc = CLAMP(g_bms.soc_est.soc, 0.0f, 100.0f);
}

逐行解读

  • 电流方向约定:从代码上下文(pack_current > 0 时是放电、< 0 是充电),本模块采用「放电为正、充电为负」的约定。注释里的「放电≈1.0,充电≈0.98」的 eta库仑效率(充进去的电不可能 100% 放出来,大电流还有热量损耗)。
  • dt 换算:采样周期 SAMPLE_INTERVAL_MS(毫秒)除以 3600000 变成小时,因为安时积分单位是 Ah、要用「A × 小时」。
  • 积分方向:库仑计数器以「已无法放出/需要充入的电量」累计。容量 cap 减掉累计值得到 remaining,再除以 cap 得百分比。充电时 current 为负,coulomb_counter 反而减小,SOC 上升——逻辑自洽。
  • 容量来源:优先用 soh_capacity_mAh(来自 SOH 估算,随老化递减);若 < 100mAh 视为未标定,退回标称容量。这样 SOC 计算会自动响应电池老化。

隐患提示:纯安时积分没有「自我修正能力」。电流采样误差、AFE 偏移、长时间工作,都会让 SOC 缓慢漂移——这正是下面 OCV 校准存在的意义。

7. SOC 校准:三层策略(本模块精华)

鉴于 LFP 平台区 OCV 失效,作者设计了 三层校准策略,按可靠性递减:

7.1 策略一:满充端点自动校准(最可靠)

if (avg_v > (FULL_CHARGE_VOLTAGE_mV - 20)
    && afe->pack_current < 0        /* 正在充电 */
    && abs_current_mA < (NOMINAL_CAPACITY_mAh / 30))  /* 电流降到 C/30 以下 */
{
    g_bms.soc_est.soc = 100.0f;
    g_bms.soc_est.coulomb_counter_mAh = 0.0f;
    g_bms.soc_est.soc_valid = 1;
    ... 
}
  • 条件:电压逼近满充限值(-20mV 容差)+ 正在充电 + 电流降到 C/30 以下(近似恒压涓流尾声)。此时可以确定「满充」了,SOC 强制 100%,库仑计清零。
  • C/30:即 100Ah / 30 ≈ 3.3A。这是识别「连续充电结束、进入浮充」的行业常用判据。

7.2 策略二:满放端点自动校准(较可靠)

if (avg_v < (CUTOFF_VOLTAGE_mV + 100)   /* 逼近放电截止电压 */
    && afe->pack_current > 0
    && abs_current_mA < (NOMINAL_CAPACITY_mAh / 10))  /* 放电电流 < C/10 */
{
    g_bms.soc_est.soc = 0.0f;
    g_bms.soc_est.coulomb_counter_mAh = g_bms.soc_est.soh_capacity_mAh;  /* = cap,即放空了 */
    ...
}
  • 条件:电压逼近截止值(+100mV)+ 放电 + 电流降到 C/10 以下(接近空载末端)。SOC 置 0,库仑计设为当前总容量——表示「还剩 0,已全部消耗」。

7.3 策略三:静置 OCV 查表(仅两端有效)

/* 条件 1:电流足够小 */
if (abs_current_mA > OCV_CALIB_CURRENT_mA) { g_bms.soc_est.rest_start_tick = 0; return; }
/* 条件 2:静置计时 ≥ OCV_CALIB_REST_TIME_MS(如 30min)*/
...
/* 条件 3:查表后判断是否处于平台区 */
float ocv_soc = ocv_to_soc(avg_v);
if (ocv_soc > 20.0f && ocv_soc < 80.0f) {
    /* 平台区:仅当偏差 > 30% 才干预,否则置 0 计时器继续等 */
    if (fabsf(ocv_soc - g_bms.soc_est.soc) > 30.0f) { /* 温和修正 */ }
} else {
    /* 平台区外:正常阈值 OCV_CALIB_SOC_DELTA 校准 */
}
  • 门槛:无负载静置足够久(让极化消散)后,才敢用 OCV。
  • 平台区内(20%~80%):OCV 算出的值置信度低,仅当库仑计漂移超过 30% 时才「大剂量」修正,其余情况宁可不动——避免把误差换来换去。
  • 平台区外(>80% 或 <20%):两端 OCV 可靠,用较小阈值 OCV_CALIB_SOC_DELTA 正常校准,并把库仑计反算回与 SOC 一致,实现「纠偏后立即清零历史漂移」。
  • 每个校准点都会记录 last_ocv_calib_tick,便于上层判断「多久没校准过」。

这套「平台区严格、区外宽松」的分段策略,是专门为 LFP 度身打造的,体现了作者对电芯特性的深刻理解。

8. SOH 维度一:容量追踪

void soh_capacity_tracking(void) {
    /* 电流绝对值 > 100mA 才累计,区分充/放电 */
    if (current_mA < -100.0f)  /* 充电 */  charge_accumulated_mAh += |I|·dt;
    if (current_mA >  100.0f)  /* 放电 */  discharge_accumulated_mAh += I·dt;
    /* 满充检测:电压≥满充-50mV 且 电流从未超过 C/25 */
    ...
    /* 满放:从满充状态一路放到截止,记录本次循环测得的容量 */
    if (charge_full_detected && avg_v < CUTOFF+100) {
        measured_capacity_mAh = discharge_accumulated_mAh;
        soh_capacity = 100 × measured / NOMINAL_CAPACITY;
    }
}
  • 门槛 100mA:小于此电流视为噪声或静态,不累计,避免误差滚雪球。
  • 完整循环法测容量:先记录「满充」到「满放截止」之间的放电电荷量 discharge_accumulated_mAh,即为当前实际容量,除以标称得到 soh_capacity(0~100)。
  • 防溢出:当累计放电超过 2×标称(可能是长期没用满循环),强制钳到 1.5×标称,防止数值爆炸。

9. SOH 维度二:直流内阻(DCR)测量

void soh_measure_dcr(voltage_before_mV, voltage_after_mV,
                     current_before_mA,  current_after_mA) {
    float dv = |V_after - V_before|;  /* mV */
    float di = |I_after - I_before|;  /* mA */
    if (di < 200.0f) return;          /* 电流变化太小,无效 */
    current_dcr_mOhm = dv / di * 1000.0f;  /* mΩ */
    soh_resistance = 100 × (1 - (current_dcr - NOMINAL_DCR) / NOMINAL_DCR);
}
  • 利用「负载阶跃」瞬间的电压跳变 ΔV 除以电流跳变 ΔI,估算直流内阻(欧姆定律)。
  • di < 200mA 直接拒绝,避免在电流噪声里算出假内阻。
  • soh_resistance 以内阻相对出厂值 NOMINAL_DCR_mOhm 的增大程度反推健康度:内阻越大 → SOH 越低。当内阻翻倍时该项归零。
  • 调用者需在合适的时机(如充放电切换瞬间)采集前后两帧电压电流,本函数只负责计算。

10. SOH 维度三:温升评估

void soh_evaluate_temp_rise(void) {
    float current_A = |I|;               /* 放电电流 */
    float temp_now   = TS1 × 0.1;        /* 最靠近电芯的温度点 */
    if (current_A > 0.5A && start_tick==0) { start_C=temp_now; start_tick=Tick; return; }
    if (Tick - start_tick < 600000) return;   /* 10 分钟后评估 */
    actual_rise = temp_now - start_C;
    i_normalized = current_A / (NOMINAL_CAPACITY/1000);  /* 归一化到 1C */
    normalized_rise = actual_rise / (i_normalized + 0.01);
    soh_temperature = 100 × (1 - (normalized_rise - NOMINAL_TEMP_RISE)/NOMINAL_TEMP_RISE);
}
  • 核心思想:内阻越大 → 同样电流下发热越多 → 温升越大 → SOH 越低
  • 在放电电流 > 0.5A 时记录起始温度,10 分钟后对比温升。
  • 归一化到 1C 电流:把实际温升按电流折算成「1C 放电下的等效温升」,消除电流大小差异对温升的影响,才能跨工况比较。
  • 分母 +0.01 防除零。

11. SOH 加权融合

void soh_calculate_final(void) {
    float soh = soh_capacity×W_cap + soh_resistance×W_res + soh_temperature×W_temp;
    /* 无有效容量数据时,退化为仅用内阻+温升 */
    if (measured_capacity_mAh < 100) soh = soh_resistance×0.6 + soh_temperature×0.4;
    soc_est.soh = CLAMP(soh, 0, 100);
    soc_est.soh_capacity_mAh = NOMINAL_CAPACITY × soh / 100;  /* 反馈给 SOC 用 */
}
  • 三项加权(权重由配置宏定义),默认以容量为主。
  • 智能降级:若还没成功测过一次完整循环容量(measured < 100mAh),容量项不可信,自动改用「内阻×0.6 + 温升×0.4」的临时权重——避免用一个假容量把 SOH 带偏。
  • 最终输出的 soh_capacity_mAh 回传给安时积分使用,形成「SOH → SOC」的闭环联动:电池越老,SOC 计算用的容量越小,百分比更真实。

12. 初始化与状态读取

void soc_estimator_init(void) {
    memset(&g_bms, 0, sizeof(g_bms));
    ocv_table_load(CELL_CHEMISTRY);      /* 按配置选 LFP 或 NCM 表 */
    g_bms.soc_est.soc = 50.0f;           /* 上电默认 50% */
    g_bms.soc_est.soh = 100.0f;          /* 默认全新 */
    g_bms.soh_est.current_dcr_mOhm = NOMINAL_DCR_mOhm;
    /* TODO: 从 Flash 加载上次保存的 SOC/SOH 值 */
}

bms_system_t* bms_get_state(void) {
    update_cell_statistics();
    memcpy(&g_bms.afe, sh36760x_get_data(), sizeof(afe_data_t));
    return &g_bms;
}
  • 上电默认 SOC=50%、SOH=100%,内阻取出厂值——保守起步,等校准后收敛。
  • bms_get_state() 是给上层/通信用的统一入口:每次调用刷新统计并同步 AFE 原始数据,返回全局结构体指针。

13. 遗留 TODO:Flash 持久化

void soc_save_to_flash(void) {
    /* TODO: 将 SOC/SOH 保存到 STM32F103 内部 Flash 的最后一页
     * 使用 HAL_FLASH_Program + HAL_FLASHEx_Erase
     *   - soc (float, 4B)
     *   - soh (float, 4B)
     *   - coulomb_counter_mAh (float, 4B)
     *   - soh_capacity_mAh (float, 4B)
     *   - CRC16 校验 (2B)
     * Flash 地址:推荐 0x0800FC00 (最后一页起始) */
}

这是本文件的明面缺口。注释给出了完整的落盘方案:18 字节数据 + CRC16,写 STM32F103 最后一页(0x0800FC00,1KB 页)。为什么必须有它?因为 SOC 是「带电记忆」——若每次断电都从 50% 重新积分,长期累积的校准成果全部丢失。量产固件强烈建议实现:soc_loader_from_flash()init 中调用,并在掉电/定时/满充满放校准后调用 save,写入前先擦页、写完回读校验。

14. 潜在缺陷与改进建议(评审视角)

问题说明改进建议
无 Flash 持久化掉电丢失 SOC/SOH、库仑计实现闪存储存,init 时加载,校准后保存
OCV 校准依赖固定阈值30% / OCV_CALIB_SOC_DELTA 为经验值参数配置化,按电芯实测标定
安时积分无电流偏差校正长时间累加漂移,靠 OCV 端点拉回可引入电流传感器零点校准(零流时采样归零)
温度采样只取 TS1温升评估只用第一个温度点,代表性不足取多点最高/平均,区分充电/放电温升
OCV 需静置 30min 才校准现实工况难得长时间无负载考虑「部分静置」或带载 OCV 修正模型
ARRAY_SIZE 用于指针有风险若他人误改传指针,sizeof 出错函数内显式传表大小,或加编译期断言
满放检测门槛较松C/10 + 100mV 可能提前误判结合 SOC 趋势与计时器双重确认
临界电流符号电流恰为 0 时按放电处理(eta 选错)用 >=/-=< 明确零流分支

15. 总结

soc_estimator.c 是一份注释详实、策略清晰、针对 LFP 高度定制的 BMS 核心算法实现。它的价值不在于代码量,而在于把「安时积分 + OCV 分段校准 + SOH 三维融合」这套工业标准做法落地得清楚明白,且处处体现了对 LFP 电压平台特性的敬畏。对于做 BMS 固件的工程师,这份代码是很好的学习范本——尤其是「平台区内轻干预、区外重校正」的 OCV 校准策略,以及「SOH 反哺 SOC 容量」的联动设计。

如果说有什么必须补的,那就是Flash 持久化针对自家电芯的参数标定——前者让算法「记得住」,后者让算法「测得准」。补上这两块,这套 SOC/SOH 估算就能真正上量产。

本文由 BMS 固件源码分析撰写,源码位置 Core/Src/soc_estimator.c(STM32F103 + SH367601)。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Navigation

About

Writing on the Wall is a newsletter for freelance writers seeking inspiration, advice, and support on their creative journey.