时域滤波基础:移动平均滤波(MAF)

各位同学,今天咱们来聊聊电机控制里最基础、也最实用的滤波方法——移动平均滤波。说实话,我入行那会儿,第一个接触的滤波算法就是它。那时候带我的老工程师跟我说:「别小看这玩意儿,用好了能解决一半的噪音问题。」我当时还不信,后来踩了不少坑才明白,这话一点不夸张。

移动平均滤波的原理

移动平均滤波,说白了就是「取平均值」。你连续采N个点,把这N个点加起来除以N,得到的结果作为当前时刻的输出。每来一个新数据,就把最老的那个数据丢掉,保持窗口长度不变。

数学表达式很简单:

y[k] = (x[k] + x[k-1] + ... + x[k-N+1]) / N

其中N就是窗口长度。N越大,滤波效果越平滑,但延迟也越大。我习惯把N叫做「平滑力度」——你想想看,N=3就像轻轻抹了一下,N=50就像拿铲子把信号铲平了。

核心要点:移动平均滤波的本质是一个低通滤波器。它的截止频率与窗口长度N成反比。N越大,能通过的频率越低。

滑动窗口设计

滑动窗口的实现方式,我见过三种。咱们一个一个说。

1. 数组循环队列法

这是我最推荐的方式。维护一个固定长度的数组,用头尾指针来管理数据。每来一个新数据,覆盖最老的数据,然后移动指针。这样做的好处是——不需要频繁搬移数据,效率高。

// 循环队列实现移动平均
typedef struct {
    float buffer[WINDOW_SIZE];
    int head;
    int tail;
    int count;
    float sum;
} MAF_Handle;

float MAF_Update(MAF_Handle *maf, float new_sample) {
    // 如果窗口已满,先减去最老的数据
    if (maf->count == WINDOW_SIZE) {
        maf->sum -= maf->buffer[maf->head];
        maf->head = (maf->head + 1) % WINDOW_SIZE;
        maf->count--;
    }
    // 加入新数据
    maf->buffer[maf->tail] = new_sample;
    maf->sum += new_sample;
    maf->tail = (maf->tail + 1) % WINDOW_SIZE;
    maf->count++;
    
    return maf->sum / maf->count;
}

2. 直接累加滑动法

有些初学者喜欢用这个——每次把整个数组往前挪一位。嗯,我建议你别这么干。N=10还好,N=100的时候,每次更新要搬移100个数据,CPU时间全浪费在搬数据上了。

3. 指数加权移动平均(EWMA)

这个严格来说不算标准的MAF,但它不需要维护窗口数组,内存占用极小。公式是:

y[k] = α * x[k] + (1-α) * y[k-1]

α在0到1之间。α越大,对新数据越敏感。我在项目中遇到过内存紧张的情况,那时候就用EWMA代替标准MAF,效果还不错。

我的建议:如果内存够用,优先用循环队列法。如果MCU资源紧张,用EWMA。千万别用数组搬移法,那是给自己找麻烦。

边界效应处理

这个问题很多人会忽略,但实际项目中特别坑。什么叫边界效应?就是滤波刚开始的时候,窗口还没填满,你拿什么算平均值?

我遇到过三种处理方式:

处理方法 做法 优缺点
零填充 窗口未满时,缺失的数据用0补 简单,但初始阶段输出会偏低
首值填充 用第一个有效数据填充剩余窗口 输出平滑,但初始阶段有偏差
变窗口长度 窗口未满时,用实际数据个数做分母 最准确,但输出长度不固定

我个人习惯用第三种——变窗口长度。虽然输出在初始阶段会波动一下,但胜在真实。等窗口填满后,一切恢复正常。

注意:我曾经在一个电机电流采样项目里用了零填充,结果启动阶段电流波形看起来像被「拽」下去了,害我排查了半天。后来换成变窗口长度,问题立刻解决。

代码实现与性能分析

咱们来看一个完整的实现。这个代码我用了很多年,基本没出过问题。

#define WINDOW_SIZE 16

typedef struct {
    float buffer[WINDOW_SIZE];
    uint8_t index;
    uint8_t count;
    float sum;
} MovingAverage;

void MA_Init(MovingAverage *ma) {
    ma->index = 0;
    ma->count = 0;
    ma->sum = 0.0f;
    memset(ma->buffer, 0, sizeof(ma->buffer));
}

float MA_Process(MovingAverage *ma, float input) {
    // 减去将被覆盖的旧数据
    ma->sum -= ma->buffer[ma->index];
    // 存入新数据
    ma->buffer[ma->index] = input;
    ma->sum += input;
    // 移动指针
    ma->index = (ma->index + 1) % WINDOW_SIZE;
    // 计数,用于边界处理
    if (ma->count < WINDOW_SIZE) {
        ma->count++;
    }
    // 返回平均值
    return ma->sum / ma->count;
}

性能方面,咱们做个简单分析:

  • 时间复杂度:O(1)。每次更新只需要一次加减法和一次除法,非常快。
  • 空间复杂度:O(N)。需要N个float的存储空间,外加几个变量。
  • 延迟:N/2个采样周期。这是移动平均的固有延迟,无法避免。

你想想看,为什么延迟是N/2?因为移动平均相当于对信号做了卷积,卷积核是一个长度为N的矩形窗。这个窗的中心点在第N/2个位置,所以延迟就是N/2。

性能关键点:移动平均滤波的延迟和噪声抑制能力是矛盾的。N越大,噪声抑制越好,但延迟也越大。在电机控制中,我一般把N控制在4到32之间。太小了没效果,太大了影响系统响应。

最后说一个我踩过的坑。有一次我在一个项目中用了N=64的移动平均,结果电机启动时响应特别慢,转速超调了30%。后来我把N改成了16,超调降到了5%以内。嗯,这里要注意——滤波不是越平滑越好,要兼顾系统的动态响应。

移动平均滤波知识体系 移动平均滤波 原理:N点平均 滑动窗口设计 边界效应处理 低通滤波特性 截止频率 = fs/(2πN) 循环队列法(推荐) EWMA(内存紧张时) 零填充 / 首值填充 变窗口长度(推荐) 性能权衡:N越大 → 噪声抑制越好 → 延迟越大 推荐N范围:4 ~ 32(电机控制场景)

好了,移动平均滤波就讲到这里。记住一句话:简单的东西往往最可靠。下次遇到电机噪音问题,先试试移动平均,说不定就解决了。

专注资料整理