时域滤波基础:移动平均滤波(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%以内。嗯,这里要注意——滤波不是越平滑越好,要兼顾系统的动态响应。
好了,移动平均滤波就讲到这里。记住一句话:简单的东西往往最可靠。下次遇到电机噪音问题,先试试移动平均,说不定就解决了。