4. 数据采集与预处理:采样频率选择、噪声滤波、异常值剔除、时间戳对齐

各位工程师朋友,这一章我们聊聊数据采集与预处理。说实话,这块内容看起来基础,但往往是整个电池安全预警系统的“地基”。地基没打好,后面算法再花哨也是白搭。我在多个量产项目中吃过这方面的亏,今天把这些经验掰开了揉碎了讲给你听。

核心观点:数据预处理不是“洗数据”那么简单,它直接决定了阈值标定的可信度和算法响应的实时性。预处理做得好,后续工作事半功倍。

4.1 采样频率选择:不是越高越好

采样频率怎么选?很多人第一反应是“越高越好”。其实不然。我见过一个项目,工程师把采样频率设到了100Hz,结果数据量巨大,存储和通信都扛不住,而且很多高频噪声也被采了进来。

我个人习惯遵循奈奎斯特采样定理,但实际工程中会留2~5倍的余量。对于电池安全预警,关键信号的频率范围大致如下:

信号类型 有效频率范围 推荐采样频率
电压(单体/总压) 0~10 Hz 10~20 Hz
电流 0~50 Hz(含纹波) 50~100 Hz
温度(NTC) 0~1 Hz 1~5 Hz
绝缘电阻 0~0.5 Hz 0.5~2 Hz

为什么会这样?你想想看,电池的热惯性很大,温度变化很慢,你用10Hz去采温度,除了浪费资源,没有任何好处。而电流信号因为包含负载突变和纹波,需要相对高一些的采样率。

我的经验:对于BMS主控芯片资源有限的情况,我建议采用“多速率采样”策略——电压电流用较高频率,温度用较低频率。这样既保证了关键信号的实时性,又不会把MCU跑死。

4.2 噪声滤波:中值滤波 vs 卡尔曼滤波

原始信号里一定有噪声,这是物理世界的常态。滤波的目的不是把噪声完全消除(那不可能),而是把信噪比提升到可接受的水平。

4.2.1 中值滤波:简单粗暴但有效

中值滤波的原理很简单——取一个滑动窗口内的中间值。它对脉冲噪声(比如传感器偶尔跳变)效果特别好。

我在项目中遇到过一个问题:某个电芯的电压采集偶尔会跳变0.5V,用均值滤波根本压不住,但中值滤波一下就搞定了。代码实现也很简单:

// 中值滤波示例(窗口大小=5)
#define WINDOW_SIZE 5

float median_filter(float new_value) {
    static float buffer[WINDOW_SIZE] = {0};
    static uint8_t index = 0;
    float temp[WINDOW_SIZE];
    
    // 更新环形缓冲区
    buffer[index] = new_value;
    index = (index + 1) % WINDOW_SIZE;
    
    // 拷贝并排序
    memcpy(temp, buffer, sizeof(temp));
    // 冒泡排序(实际工程中建议用快速选择算法)
    for (int i = 0; i < WINDOW_SIZE - 1; i++) {
        for (int j = 0; j < WINDOW_SIZE - i - 1; j++) {
            if (temp[j] > temp[j + 1]) {
                float t = temp[j];
                temp[j] = temp[j + 1];
                temp[j + 1] = t;
            }
        }
    }
    return temp[WINDOW_SIZE / 2];
}

注意:中值滤波的窗口大小很关键。窗口太小,滤波效果差;窗口太大,信号延迟严重。我一般取5~9个点,具体要看你的采样频率和信号变化速度。

4.2.2 卡尔曼滤波:状态估计的利器

卡尔曼滤波听起来高大上,其实说白了就是“用模型预测+测量修正”的方式,不断逼近真实值。它特别适合处理高斯噪声,而且能给出状态的不确定性估计。

对于电池电压和SOC估计,卡尔曼滤波几乎是标配。但要注意,它需要你建立相对准确的系统模型。模型不准,滤波效果反而更差。

// 一维卡尔曼滤波简化实现
typedef struct {
    float x;      // 状态估计值
    float p;      // 估计误差协方差
    float q;      // 过程噪声协方差
    float r;      // 测量噪声协方差
} kalman_1d_t;

float kalman_update(kalman_1d_t *kf, float measurement) {
    // 预测
    float p_pred = kf->p + kf->q;
    
    // 卡尔曼增益
    float k = p_pred / (p_pred + kf->r);
    
    // 更新
    kf->x = kf->x + k * (measurement - kf->x);
    kf->p = (1 - k) * p_pred;
    
    return kf->x;
}

我的建议:如果信号噪声类型比较单一(比如只有脉冲噪声),用中值滤波就够了,别折腾卡尔曼。如果信号变化规律且噪声复杂(比如电流信号),卡尔曼滤波值得投入。我曾经在一个项目中,用卡尔曼滤波把电流信号的SNR从15dB提升到了35dB以上。

4.3 异常值剔除:别让一个坏点毁了整个算法

异常值,也叫野点或离群点。它们可能来自传感器故障、通信干扰、或者电池本身真的出了异常。我们的目标是:剔除明显错误的测量值,但保留真实的异常事件

常用的方法有三种:

  • 3σ准则:假设数据服从正态分布,超出均值±3倍标准差的值视为异常。简单,但对非正态分布效果差。
  • 箱线图法(IQR):基于四分位数,不受极端值影响。适合非对称分布。
  • 滑动窗口法:实时计算窗口内的均值和标准差,判断当前值是否合理。

我个人最常用的是滑动窗口法,因为它适合实时嵌入式系统。代码示例如下:

// 滑动窗口异常值检测
#define WINDOW_SIZE 10

bool is_outlier(float new_value) {
    static float buffer[WINDOW_SIZE] = {0};
    static uint8_t count = 0;
    float sum = 0, mean = 0, std = 0;
    
    // 更新缓冲区
    if (count < WINDOW_SIZE) {
        buffer[count++] = new_value;
        return false;  // 数据不足,暂不判断
    }
    
    // 滑动更新(先进先出)
    memmove(buffer, buffer + 1, (WINDOW_SIZE - 1) * sizeof(float));
    buffer[WINDOW_SIZE - 1] = new_value;
    
    // 计算均值和标准差
    for (int i = 0; i < WINDOW_SIZE; i++) {
        sum += buffer[i];
    }
    mean = sum / WINDOW_SIZE;
    
    for (int i = 0; i < WINDOW_SIZE; i++) {
        std += (buffer[i] - mean) * (buffer[i] - mean);
    }
    std = sqrt(std / WINDOW_SIZE);
    
    // 判断:超出均值±3σ视为异常
    return (fabs(new_value - mean) > 3 * std);
}

避坑指南:我曾经犯过一个错误——把电池真实的热失控信号当成了异常值剔除掉。后来我加了一个规则:如果连续3个点都超出阈值,就不再剔除,而是触发预警。这样既滤掉了传感器偶发故障,又不会漏掉真实危险。

4.4 时间戳对齐:多源数据的“统一时钟”

BMS系统里,电压、电流、温度来自不同的采集芯片,它们的时间基准可能不同。如果不做时间戳对齐,你算出来的内阻、SOC、SOP全都是错的。

时间戳对齐的核心思路是:选择一个主时钟源,其他所有数据都向它对齐

具体做法:

  • 每个数据帧都带上硬件时间戳(最好是微秒级)
  • 在软件层面,用线性插值或最近邻插值,把不同频率的数据映射到统一的时间轴上
  • 对于电流和电压,必须严格对齐,因为内阻计算依赖两者的同步性
// 时间戳对齐示例:将电流数据对齐到电压时间轴
typedef struct {
    uint32_t timestamp_us;
    float value;
} data_point_t;

// 线性插值对齐
float align_to_timestamp(data_point_t *current_data, int len, uint32_t target_ts) {
    // 找到目标时间戳前后的两个点
    for (int i = 0; i < len - 1; i++) {
        if (current_data[i].timestamp_us <= target_ts && 
            current_data[i + 1].timestamp_us >= target_ts) {
            // 线性插值
            float ratio = (float)(target_ts - current_data[i].timestamp_us) / 
                          (current_data[i + 1].timestamp_us - current_data[i].timestamp_us);
            return current_data[i].value + ratio * 
                   (current_data[i + 1].value - current_data[i].value);
        }
    }
    return current_data[len - 1].value;  // 边界情况
}

我的经验:如果硬件不支持高精度时间戳,可以在软件里用定时器中断打时间戳。精度能做到毫秒级,对于大部分BMS应用已经够用了。但要注意中断响应延迟的补偿。

4.5 本章知识体系总览

下面这张图总结了数据采集与预处理的完整流程和关键决策点。你可以把它当作一个检查清单,做项目时对照着来。

数据采集与预处理流程 原始信号(电压/电流/温度) 采样频率选择(奈奎斯特+工程余量) 噪声滤波(中值/卡尔曼/其他) 异常值剔除(3σ/IQR/滑动窗口) 时间戳对齐(主时钟+插值) 干净、对齐、可用的数据 关键决策点: • 信号带宽分析 • MCU资源评估 • 噪声类型识别 • 实时性要求 • 误报率 vs 漏报率 • 连续异常判断 • 硬件时间戳精度 • 插值方法选择

嗯,这一章的内容就到这里。数据预处理看起来琐碎,但每一步都关系到后续算法的成败。我个人觉得,花30%的时间在预处理上,一点都不为过。下一章我们会进入核心算法部分,但前提是——你的数据已经准备好了。

本章要点回顾:

  • 采样频率根据信号带宽选择,不是越高越好
  • 中值滤波适合脉冲噪声,卡尔曼滤波适合高斯噪声
  • 异常值剔除要平衡“滤除坏点”和“保留真实异常”
  • 时间戳对齐是多源数据融合的前提,必须做

专注资料整理