03 组合滤波与工程改进
第二章讲的是每个基础工具怎么用。这一章讲的是:真实项目里怎么把工具组合起来,处理混合噪声、资源限制和响应速度问题。
本章定位:工程组合,不是非线性系统理论
这一章的算法有些是线性时不变(LTI)的,有些不是。先把这条线划清楚:
| 类型 | 本章中的算法 | 能否用截止频率 / 频率响应描述 |
|---|---|---|
| LTI | 加权平均、一阶滞后 | 能。可以用传递函数、截止频率、群延迟等工具分析 |
| 非线性 | 限幅、去极值平均、加权中值、死区 | 不能。输出不满足叠加原理,频率响应依赖输入信号 |
| 非线性有状态 | 速率限制 | 不能。参数固定,但输出受"上一步输出"约束,行为取决于输入历史 |
非 LTI 不等于不能用。大多数工程滤波器都不是纯粹的 LTI 系统——限幅、中值、死区在实际项目中大量使用,效果也很好。只是不能用单一截止频率来描述它们的行为,需要用仿真或实测来验证效果。
本章的组织逻辑是工程组合,不是数学分类。三个基本判断先摆出来:
- 现实噪声通常是混合的。 一个温度采样可能既有小幅随机抖动,又偶尔被电磁干扰打出尖峰,还可能有慢漂移。只用一种滤波器很难同时应对。
- 组合的基本顺序是:先处理异常值,再做平滑。 如果先平滑再限幅,尖峰已经被平均进去了,限幅就晚了。
- 改进算法不是越复杂越好,而是为了修正单一算法的短板。 加一个环节就多一重参数、多一重延迟、多一重失真风险。
下表列出常见的工程现象和对应的组合思路:
| 工程现象 | 单一算法短板 | 组合 / 改进方法 |
|---|---|---|
| 采样中偶尔出现尖峰,同时还有随机抖动 | 直接平均会被尖峰拉偏;单独限幅后仍可能有抖动 | 去极值平均、限幅后一阶滞后 |
| 信号有随机噪声,但又希望阶跃变化不要太慢 | 等权平均对新样本不敏感,窗口越大延迟越明显 | 加权平均、一阶滞后并重新选择参数 |
| 显示值在最后几位来回跳动 | 单纯平滑后仍可能频繁刷新,读数不稳定 | 平滑后加显示死区 |
| 控制输出或设定值变化太突兀 | 滤波只能平滑测量值,不能限制输出变化速度 | 速率限制 |
| 既有异常点,又不能让响应太慢 | 单个滤波器很难同时兼顾抗尖峰和响应速度 | 限幅 / 中值 / 去极值 + 一阶滞后或加权平均 |
下面按这几条主线展开:先去异常,再加权,最后约束输出。
去异常后再平均
去极值平均:先丢掉最可疑的两端
一组数据里混入了尖峰,普通平均会被拉偏。一个直觉的做法是:先把最大值和最小值扔掉,再对剩下的求平均。统计学里这叫 trimmed mean(截尾均值)。
去掉两端后,公式变成:
其中 \(x_{\max}\) 和 \(x_{\min}\) 是窗口内的最大值和最小值。去掉两个极端值后,用剩余 \(N-2\) 个样本的均值作为输出。
什么时候有效。 窗口内只有少量尖峰(1~2 个)时,去极值平均能把它们剔除。这里"少量"的上限取决于去掉了几个极端值——只去掉最大最小各一个时,最多可靠处理 1~2 个孤立极端值;再多一个就可能残留在剩余样本中,平均值照样偏。
什么时候失效。 异常值数量超过被剔除的数量时就可能失效,不必等到过半。另外,如果真实信号本身就有陡峭边沿(比如方波上升沿),去极值会把正常跳变也当成极端值丢掉。
一次性去极值平均
采集 \(N\) 个样本后,去掉最大最小,对剩余求平均。适合不需要实时输出、可以等一批数据的场景(比如 ADC 多次采样后算一个代表值)。
用一组小数据走一遍。 假设窗口长度 \(N = 5\),采样值为:
其中 12.0 是一个尖峰。步骤:
- 找最大值 \(x_{\max} = 12.0\),最小值 \(x_{\min} = 3.1\)
- 求总和:\(3.1 + 3.3 + 12.0 + 3.5 + 3.2 = 25.1\)
- 去掉两端:\((25.1 - 12.0 - 3.1) / 3 = 10.0 / 3 = 3.33\)
普通平均是 \(25.1 / 5 = 5.02\),被尖峰拉偏了 1.7。去极值平均输出 3.33,接近真实值。
窗口长度 \(N\)(代码变量 n)。 物理含义:一次参与计算的采样点数。\(N\) 越大越平滑,但采集等待时间越长;\(N\) 越小响应越快,但去掉两个极端值后剩余样本太少,统计稳定性下降。实际常用 \(N = 5, 7, 9\)。
去掉的个数。 这里只去掉 1 个最大和 1 个最小,共 2 个。如果尖峰更多,可以去掉更多(比如去掉最大最小各 2 个),但剩余样本更少,需要相应增大 \(N\)。
#include <stdint.h>
/**
* 一次性去极值平均。
* buf: 样本数组
* n: 样本数量,必须大于 2
* 返回: 去掉最大最小值后的平均
*/
float trimmed_mean(const float *buf, uint16_t n)
{
if (n == 0) return 0.0f;
if (n <= 2) {
/* 去掉两端后无剩余,退化为普通平均 */
float sum = 0.0f;
for (uint16_t i = 0; i < n; i++) sum += buf[i];
return sum / (float)n;
}
float min_v = buf[0];
float max_v = buf[0];
float sum = 0.0f;
for (uint16_t i = 0; i < n; i++) {
if (buf[i] < min_v) min_v = buf[i];
if (buf[i] > max_v) max_v = buf[i];
sum += buf[i];
}
return (sum - min_v - max_v) / (float)(n - 2);
}
实现细节。 n 必须大于 2,否则去掉两端后没有剩余样本,代码做了退化处理。一次遍历同时找最大最小和求总和,时间复杂度 \(O(N)\),没有额外的排序开销。
滑动去极值平均
在滑动窗口中去掉最大最小再平均。它比普通滑动平均更抗尖峰,同时保留滑动更新的实时性。
用一组小数据走一遍。 窗口长度 \(N = 5\),当前窗口内的值为:
没有尖峰时,输出 \((3.1+3.3+3.5+3.2+3.4 - 3.1 - 3.5) / 3 = 9.9 / 3 = 3.30\)。与普通平均 \(16.5 / 5 = 3.30\) 相同——这组数据刚好如此。一般情况下去掉最大最小后均值会略不同于普通平均,但差距很小(因为去掉的是两端的值,剩余样本的均值通常接近全样本均值)。
如果下一笔采样是 11.0(尖峰),窗口变为:
总和 \(3.3+3.5+11.0+3.2+3.4 = 24.4\),去掉最小值 3.2 和最大值 11.0,\((24.4-3.2-11.0)/3 = 10.2/3 = 3.40\)。普通平均是 \(24.4/5 = 4.88\),去极值版本把尖峰的影响压下去了。
#include <stdint.h>
/**
* 滑动去极值平均。
* buf: 滑动窗口缓冲区(长度为 n)
* n: 窗口长度,必须大于 2
* 返回: 去掉最大最小值后的平均
*/
float moving_trimmed_mean(const float *buf, uint16_t n)
{
if (n == 0) return 0.0f;
if (n <= 2) {
/* 去掉两端后无剩余,退化为普通平均 */
float sum = 0.0f;
for (uint16_t i = 0; i < n; i++) sum += buf[i];
return sum / (float)n;
}
float min_v = buf[0];
float max_v = buf[0];
float sum = 0.0f;
for (uint16_t i = 0; i < n; i++) {
if (buf[i] < min_v) min_v = buf[i];
if (buf[i] > max_v) max_v = buf[i];
sum += buf[i];
}
return (sum - min_v - max_v) / (float)(n - 2);
}
实现细节。 每次更新需要遍历整个窗口找最大最小,时间复杂度 \(O(N)\)。如果窗口比较大,可以维护最大最小队列来降到 \(O(1)\),但实现复杂度显著增加。初学阶段先把逻辑写清楚,性能优化是后面的事。
验证方法。 用包含已知尖峰的原始数据,分别经过普通滑动平均和去极值滑动平均,对比输出。去极值版本在尖峰处应该明显更稳。
限幅后一阶滞后:先挡尖峰,再平滑抖动
单独用一阶滞后,尖峰会通过 \(\alpha\) 的比例进入输出,恢复慢。单独用限幅,只能挡大跳变,小抖动挡不住。把两者组合起来:先限幅挡住大跳变,再一阶滞后平滑小抖动。
信号流:
限幅阈值 \(D\)(代码变量 max_step)。 物理含义:允许相邻采样之间的最大变化量。如果传感器在正常工况下每秒最多变化 10 个单位,采样率是 100 Hz,那么每步最大变化是 0.1 个单位。\(D\) 应该设在 0.1 附近或略大,留一定余量。设太小会把真实变化也挡住,输出变得迟钝。
平滑系数 \(\alpha\)(代码变量 alpha)。 物理含义:新输入在输出中的占比。取值 0~1,越大跟踪越快、抗噪越差,越小越平滑、延迟越大。
限幅的作用是约束相邻采样之间的最大变化量:
然后对限幅后的值做一阶滞后:
用一组小数据走一遍。 设 \(D = 1.0\),\(\alpha = 0.3\),初始输出 \(y = 10.0\)。输入序列为:
其中 15.0 是尖峰。逐笔计算:
| 步骤 | 输入 \(x\) | 限幅后 \(x_{\text{clipped}}\) | 输出 \(y\) | 说明 |
|---|---|---|---|---|
| 1 | 10.1 | 10.1 | 10.03 | 变化 0.1 < D,不限幅 |
| 2 | 10.2 | 10.2 | 10.08 | 变化 0.17 < D,不限幅 |
| 3 | 15.0 | 11.08 | 10.37 | 变化 4.92 > D,限幅到 y+D = 11.08 |
| 4 | 10.3 | 10.3 | 10.35 | 变化 0.07 < D,不限幅 |
| 5 | 10.4 | 10.4 | 10.37 | 变化 0.05 < D,不限幅 |
尖峰 15.0 被限幅夹住,只让输出从 10.08 跳到 10.37。如果没有限幅,一阶滞后会输出 \(10.08 + 0.3 \times (15.0 - 10.08) = 11.56\),恢复需要很多步。
顺序反过来会怎样。 同样的输入,先一阶滞后再限幅。这里两个模块各自有状态:滞后模块的内部状态是上一步的系统输出(即限幅后的值),不是它自己的滞后中间值——因为实际输出到系统的是限幅后的结果。
| 步骤 | 输入 \(x\) | 滞后内部状态 | 滞后后(未限幅) | 限幅后输出 | 说明 |
|---|---|---|---|---|---|
| 1 | 10.1 | 10.0 | 10.03 | 10.03 | |
| 2 | 10.2 | 10.03 | 10.08 | 10.08 | |
| 3 | 15.0 | 10.08 | 11.56 | 11.08 | 尖峰先通过 α 进入滞后;限幅夹到 11.08 |
| 4 | 10.3 | 11.08 | 11.05 | 11.05 | 滞后状态是 11.08(限幅后),不是 11.56 |
| 5 | 10.4 | 11.05 | 10.86 | 10.86 | 在恢复中,输出已接近正序的 10.37 |
第 3 步输出 11.08(反序)vs 10.37(正序),反序的偏差是正序的约 4 倍。关键区别:反序的滞后内部状态在第 3 步之后被限幅"拉回"到 11.08,而不是留在 11.56——限幅确实约束了后续恢复速度。但 11.08 仍然比正序的 10.37 高出不少,尖峰的 \(\alpha\) 比例已经污染了输出,限幅只能限制后续偏差的传播,无法消除已经发生的偏移。
#include <stdint.h>
typedef struct {
float y; /* 上一次输出 */
uint8_t init; /* 是否完成初始化 */
} LimitLag;
/* 必须在首次调用前执行。结构体不是静态存储时,init 可能是随机值。 */
void limit_lag_init(LimitLag *f)
{
f->y = 0.0f;
f->init = 0; /* 令首次 update 走初始化分支 */
}
/**
* 限幅后一阶滞后。
* x: 当前输入
* max_step: 限幅阈值 D
* alpha: 一阶滞后平滑系数
*/
float limit_then_lag(LimitLag *f, float x,
float max_step, float alpha)
{
if (!f->init) {
f->y = x;
f->init = 1;
return x;
}
/* 第一步:限幅,约束输入相对上次输出的最大变化 */
float clipped = x;
if (x > f->y + max_step) clipped = f->y + max_step;
else if (x < f->y - max_step) clipped = f->y - max_step;
/* 第二步:一阶滞后,平滑限幅后的值 */
f->y = f->y + alpha * (clipped - f->y);
return f->y;
}
实现细节。 状态变量 y 保存上一次输出,init 标志确保首次调用时输出等于第一笔输入,避免从零开始的启动瞬态。限幅是非线性操作,整个组合不再是 LTI 系统,不能用单一截止频率描述频率响应。
什么时候不该用。 真实信号本身有快速阶跃变化时(比如开关量输入),限幅会把正常跳变也挡住。这种情况应该用消抖或迟滞,而不是模拟量滤波。
验证方法。 在原始数据中人为注入尖峰,观察限幅后一阶滞后的输出是否比单独一阶滞后恢复更快。同时检查正常阶跃响应是否被限幅误伤。
让新数据更有影响
加权平均:让新样本更有影响
普通滑动平均认为窗口内每个样本同等重要。但很多场景下,新样本比旧样本更能反映当前状态。加权平均给不同样本分配不同权重,让新数据影响更大。
普通平均可以看作所有权重都等于 1:
加权平均把它推广成:
其中 \(w_i\) 是第 \(i\) 个样本的权重。权重越大,该样本对输出的影响越大。
典型权重设置。 4 点窗口用权重 \(\{1, 2, 3, 4\}\),最新样本权重最大。也可以用几何递增权重,让新数据的优势更明显。
用一组小数据走一遍。 窗口长度 \(N = 4\),权重 \(w = \{1, 2, 3, 4\}\)(最旧到最新),当前窗口值为:
普通平均:\((10.0+10.2+10.1+10.5)/4 = 10.20\)。
加权平均:
加权平均 10.27 比普通平均 10.20 更偏向最新的 10.5。如果下一笔采样跳到 11.0,加权平均会比普通平均更快跟上。
权重 \(w_i\)(代码变量 w)。 物理含义:每个样本的"可信度"或"时效性"。权重越大,该样本对输出的影响越大。权重应为非负值;负权重会让输出失去物理意义。
#include <stdint.h>
/**
* 加权平均。
* x: 样本数组(x[0] 最旧,x[n-1] 最新)
* w: 权重数组(与 x 一一对应)
* n: 样本数量
* 返回: 加权平均值
*
* 前提:n >= 1,权重总和不能为 0
*/
float weighted_mean(const float *x, const float *w, uint16_t n)
{
float sum = 0.0f;
float ws = 0.0f;
for (uint16_t i = 0; i < n; i++) {
sum += x[i] * w[i];
ws += w[i];
}
if (ws == 0.0f) return 0.0f;
return sum / ws;
}
实现细节。 权重总和 ws 不能为 0;代码做了防零保护,但权重全为 0 时返回 0 没有物理意义,调用方应避免。权重数组需要额外的 RAM 存储。如果权重固定,可以在编译时用 const 数组;如果权重需要动态调整,计算量会增加。
和一阶滞后的关系。 思想接近,但不完全等价。有限窗口的指数加权平均是 FIR(冲激响应有限长),一阶滞后是 IIR(冲激响应无限长)。当指数权重延伸为无限历史并用递推实现时,才对应一阶滞后。工程上,如果权重本来就是指数递减的,直接用一阶滞后更划算——不需要缓冲区,计算量更小。
什么时候不该用。 所有样本质量相同时(比如 ADC 多次采样取平均),用加权反而引入不必要的偏向。尖峰干扰严重时,加权平均没有抗尖峰能力,应先去异常再加权。
验证方法。 对比普通平均和加权平均的阶跃响应:加权平均应该更快达到新稳态。对比稳态噪声:加权平均的输出抖动应该稍大。
加权中值
加权中值不是对数值加权求和,而是让某些样本在排序中出现多次。
例如三个样本 \(a, b, c\),权重为 \(1, 3, 1\),等价于排序:
然后取中间值。总权重为奇数时有唯一中位值;为偶数时取两个中间值中的较小者(下中位值)。它适合希望保留中值抗尖峰能力,同时更信任某些样本的场景。
用一组小数据走一遍。 窗口长度 \(N = 3\),样本值 \(v = \{3.0,\; 3.2,\; 12.0\}\),权重 \(w = \{1,\; 3,\; 1\}\)。其中 12.0 是尖峰。
展开后排序:\(\{3.0,\; 3.2,\; 3.2,\; 3.2,\; 12.0\}\)。总权重 = 5,中位值位置 = 3(从 1 计数),第 3 小的值是 3.2。
如果用普通中值(不加权):\(\{3.0,\; 3.2,\; 12.0\}\),中位值也是 3.2。这个例子里结果相同,但权重差异更大时会有区别——如果权重是 \(\{1,\; 1,\; 1\}\),普通中值仍然是 3.2;如果权重是 \(\{1,\; 1,\; 3\}\),展开后 \(\{3.0,\; 3.2,\; 12.0,\; 12.0,\; 12.0\}\),中位值变成 12.0——高权重的尖峰会主导结果。所以权重分配要谨慎,不要给可能含尖峰的样本高权重。
权重 \(w_i\)(代码变量 w)。 物理含义:每个样本在排序中出现的次数。权重越大,中位值越可能偏向该样本。权重应为非负整数。
#include <stdint.h>
/**
* 加权中值(选择算法实现,避免展开数组的栈开销)。
* v: 样本数组
* w: 权重数组(非负整数)
* n: 样本数量(应 <= 16,超出需调整 used 数组大小)
* 返回: 加权中值
*
* 总权重为奇数时取唯一中位值;为偶数时取下中位值(两个中间值中的较小者)。
* 异常情况(总权重为 0)返回 v[0];工程项目中更推荐返回状态码或 NaN。
*/
float weighted_median(const float *v, const uint8_t *w, uint16_t n)
{
if (n == 0u || n > 16u) return 0.0f; /* 越界保护 */
/* 权重总和 */
uint16_t total = 0;
for (uint16_t i = 0; i < n; i++) total += w[i];
if (total == 0) return v[0];
uint16_t half = (total + 1) / 2; /* 中位值位置 */
/* 选择算法:找第 half 小的加权样本,不需要展开和排序。
* used[i] 记录样本 i 已被选中几次。 */
uint8_t used[16] = {0}; /* 假设 n <= 16 */
float best = v[0];
for (uint16_t sel = 0; sel < half; sel++) {
float min_val = 1e30f;
uint16_t min_idx = 0;
for (uint16_t i = 0; i < n; i++) {
if (used[i] < w[i] && v[i] < min_val) {
min_val = v[i];
min_idx = i;
}
}
used[min_idx]++;
best = min_val;
}
return best;
}
实现细节。 用选择算法代替展开+排序,RAM 用量 O(1)(只需一个 used 数组),避免了展开数组的栈开销。时间复杂度 O(half × n),当总权重不大时可以接受。used 数组大小固定为 16,样本数超过 16 时需要调整。
什么时候不该用。 权重差异不大时,加权中值和普通中值差别很小,不值得增加复杂度。样本数量很多时,排序或累计权重的计算量会成为问题。
控制输出行为
下面两个算法不太像传统滤波器,它们更像控制逻辑。传统滤波器的目标是还原真实信号;这两个的目标是让控制或显示更稳。它们不关心信号的频率特性,只关心输出行为是否符合预期。
死区滤波
死区滤波忽略很小的变化。输入在死区范围内时,输出不响应;只有变化超过死区宽度时,才跟踪真实输入。
死区半宽 \(W\)(代码变量 width)。 物理含义:输入在中心值附近多大范围内被视为"没变化"。例如摇杆在中心位置抖动 ±5,死区半宽可以设 5,表示 ±5 以内的抖动都被忽略。设太小起不到消抖作用,设太大则真实的小幅操作被忽略。
中心值 \(c\)(代码变量 center)。 物理含义:死区的参考点,通常是零点或上一次的有效输出。
用一组小数据走一遍。 设 \(W = 2\),初始中心值 \(c = 25\)。输入序列为:
| 步骤 | 输入 \(x\) | \(|x - c|\) | 与 \(W\) 比较 | 输出 | 说明 | |---|---|---|---|---|---| | 1 | 25.5 | 0.5 | < 2 | 25.0 | 在死区内,不响应 | | 2 | 26.8 | 1.8 | < 2 | 25.0 | 仍在死区内 | | 3 | 24.3 | 0.7 | < 2 | 25.0 | 仍在死区内 | | 4 | 23.0 | 2.0 | = 2 | 23.0 | 恰好在边界上,代码用严格不等式,不算"在死区内",输出跳到真实输入 | | 5 | 28.0 | 3.0 | > 2 | 28.0 | 超出死区,输出跟踪真实输入 |
前 3 步的抖动被完全忽略,显示值稳定在 25.0。第 4 步恰好踩在边界上,代码判定为"不在死区内",输出跳到 23.0。第 5 步继续跟踪。如果第 4 步输入是 23.1(差值 1.9 < 2),输出仍然是 25.0——死区边界的判定取决于严格不等式还是非严格不等式,工程中两种写法都有,这里代码用的是严格不等式。
#include <stdint.h>
/**
* 死区滤波。
* x: 当前输入
* center: 死区中心值
* width: 死区半宽(应为正数)
* 返回: 在死区内返回 center,否则返回 x
*/
float deadband(float x, float center, float width)
{
if (width < 0.0f) width = 0.0f; /* 负宽度无意义,退化为无死区 */
float e = x - center;
if (e > -width && e < width) return center;
return x;
}
实现细节。 代码用严格不等式(> 和 <),输入恰好等于边界值时不触发死区。负宽度被钳位到 0,退化为无死区(直接透传输入)。
代价和权衡。 死区会让小变化完全消失。如果需要测量微小变化(比如精密称重),死区不合适。死区边界处存在不连续——输入从中心值逐渐增大,在越过死区边界的瞬间,输出会从 center 跳到 x,这个跳变可能触发后续控制动作。
什么时候不该用。 需要高分辨率微小变化的测量场景。输入噪声本身就是需要被观测的信号时。
验证方法。 让输入在死区边界附近缓慢变化,观察输出是否在预期的边界点跳变。检查死区内输出是否稳定在中心值。
速率限制
速率限制规定输出每次最多变化多少。输入变化在允许范围内,输出直接跟踪;超出范围时,输出按最大速率追赶。
最大上升步长 \(D_{\uparrow}\)(代码变量 step_up)。 物理含义:输出每次采样最多能增加多少。如果 PWM 占空比 0~1000,采样率 100 Hz,希望占空比在 1 秒内最多变化 200,那么每步最大上升是 2。设太小输出追不上目标;设太大速率限制形同虚设。
最大下降步长 \(D_{\downarrow}\)(代码变量 step_down)。 物理含义:输出每次采样最多能减少多少。可以和上升步长不同,实现非对称约束(比如允许快速减速但限制快速加速)。
用一组小数据走一遍。 设 \(D_{\uparrow} = 2\),\(D_{\downarrow} = 2\),初始输出 \(y = 0\)。目标值序列为:
| 步骤 | 目标 \(x\) | 变化量 | 与步长比较 | 输出 \(y\) | 说明 |
|---|---|---|---|---|---|
| 1 | 0 | — | — | 0 | 初始化 |
| 2 | 5 | +5 | > 2 | 2 | 按最大上升速率追赶 |
| 3 | 3 | +1 | < 2 | 3 | 在步长范围内,直接跟踪 |
| 4 | 10 | +7 | > 2 | 5 | 按最大上升速率追赶 |
| 5 | 8 | +3 | > 2 | 7 | 仍在追赶,但变化 3 > 2,限速 |
第 2 步目标跳到 5,但输出只能到 2。第 3 步目标降到 3,输出从 2 到 3(变化 1 < 2),直接跟上。第 4 步目标跳到 10,输出从 3 到 5。第 5 步目标降到 8,输出从 5 到 7。输出始终在追赶目标,但不会一步到位。
#include <stdint.h>
typedef struct {
float y; /* 上一次输出 */
uint8_t init; /* 是否完成初始化 */
} SlewLimit;
/* 必须在首次调用前执行。结构体不是静态存储时,init 可能是随机值。 */
void slew_limit_init(SlewLimit *f)
{
f->y = 0.0f;
f->init = 0; /* 令首次 update 走初始化分支 */
}
/**
* 速率限制。
* target: 目标值
* step_up: 允许的最大上升步长(应为非负)
* step_down:允许的最大下降步长(应为非负)
*/
float slew_limit(SlewLimit *f, float target,
float step_up, float step_down)
{
if (!f->init) {
f->y = target;
f->init = 1;
return target;
}
/* 负步长无意义,钳位到 0 */
if (step_up < 0.0f) step_up = 0.0f;
if (step_down < 0.0f) step_down = 0.0f;
if (target > f->y + step_up) {
f->y = f->y + step_up;
} else if (target < f->y - step_down) {
f->y = f->y - step_down;
} else {
f->y = target;
}
return f->y;
}
实现细节。 状态变量 y 保存上一次输出,init 标志确保首次调用时直接输出目标值。负步长被钳位到 0,此时输出永远不变(除了首次初始化)。上升和下降步长分开存储,支持非对称约束。
代价和权衡。 速率限制引入延迟——目标值已经到了,输出还在追赶。如果目标值持续快速变化,输出会一直落后。
什么时候不该用。 需要输出快速响应目标变化时。目标值本身变化很慢、不需要额外约束时。
验证方法。 输入一个阶跃目标,观察输出是否按预期速率追赶。对比输出和目标值的差距随时间变化,确认收敛速度符合预期。
组合滤波的顺序
单个算法讲完了,但真正决定效果的往往是组合顺序。不同的噪声类型需要不同的组合策略。
场景一:尖峰 + 白噪声
这是最常见的混合噪声。比如温度采样,大部分时间在 ±0.3°C 内随机抖动,偶尔被电磁干扰打出 ±5°C 的尖峰。
推荐顺序:
先处理异常值,再做平滑。如果反过来,尖峰已经被平均进了输出,后续的异常值处理就晚了。
顺序反过来会怎样。 用一组数据对比。设输入为 \(\{10.0,\; 10.1,\; 15.0,\; 10.2,\; 10.3\}\),其中 15.0 是尖峰。用去极值平均(\(N=5\))+ 一阶滞后(\(\alpha = 0.5\))组合。
正序:先去极值,再滞后。
去极值平均输出:\((10.0+10.1+15.0+10.2+10.3 - 10.0 - 15.0)/3 = 30.6/3 = 10.2\)。尖峰被剔除。
一阶滞后(假设前一输出为 10.1):\(10.1 + 0.5 \times (10.2 - 10.1) = 10.15\)。
反序:先滞后,再去极值。
一阶滞后处理每个输入(假设前一输出为 10.1):输出序列为 \(\{10.05,\; 10.075,\; 12.54,\; 11.37,\; 10.83\}\)。尖峰 15.0 通过 \(\alpha\) 进入输出,产生 12.54。
去极值平均:\((10.05+10.075+12.54+11.37+10.83 - 10.05 - 12.54)/3 = 32.265/3 = 10.755\)。
正序输出 10.15,反序输出 10.755。反序的偏差是正序的约 12 倍——尖峰在一阶滞后环节就已经污染了输出,后面的去极值只能部分补救。
限幅适合尖峰幅度远超正常波动的场景。去极值平均适合尖峰数量少但幅度不确定的场景。两者可以单独用,也可以组合用,但不要重复做同样的事——限幅后再去极值,可能过度剔除数据。
场景二:显示值
传感器读数显示在屏幕上,希望数值稳定、不乱跳,但也不能太慢。
推荐顺序:
先平滑去掉高频抖动,再用死区让小变化不刷新显示。
顺序反过来会怎样。 设输入为 \(\{25.0,\; 25.7,\; 25.3,\; 25.8,\; 25.2\}\),这是正常的随机抖动,没有尖峰。死区半宽 \(W = 0.5\),中心值 \(c = 25\),一阶滞后 \(\alpha = 0.5\)。
正序:先滞后,再死区。
一阶滞后输出(初始 25.0):\(\{25.0,\; 25.35,\; 25.33,\; 25.54,\; 25.37\}\)。
死区检查(中心 25,宽度 0.5):25.0 在死区内 → 输出 25.0。25.35 在死区内 → 输出 25.0。25.33 在死区内 → 输出 25.0。25.54 超出死区 → 输出 25.54,中心更新为 25.54。25.37 在新死区 [25.04, 26.04] 内 → 输出 25.54。
显示值变化 1 次:25.0 → 25.54。
反序:先死区,再滞后。
死区处理(中心 25,宽度 0.5):25.0 在死区内 → 25.0。25.7 超出 → 25.7,中心更新为 25.7。25.3 在 [25.2, 26.2] 内 → 25.7。25.8 在 [25.2, 26.2] 内 → 25.7。25.2 恰好在边界上(严格不等式)→ 25.2,中心更新为 25.2。
一阶滞后输出(初始 25.0):\(\{25.0,\; 25.35,\; 25.53,\; 25.61,\; 25.41\}\)。
显示值变化 4 次:25.0 → 25.35 → 25.53 → 25.61 → 25.41。
同样的输入,正序显示刷新 1 次,反序刷新 4 次。原因:一阶滞后先压低了抖动幅度,滞后后的值更容易落在死区内;直接对原始输入做死区判定,抖动幅度稍大就会频繁跳出死区。
显示场景通常把死区放在平滑之后;控制输入场景有时会把死区放在前面——取决于你想抑制的是原始输入的小抖动,还是想减少显示刷新。
场景三:执行器命令
目标值是控制算法的输出,需要送到执行器(电机、阀门、PWM)。目标值可能突变,但执行器不能突变。
推荐顺序:
速率限制放在最后一步,确保输出到执行器的变化速度在安全范围内。如果在速率限制之前还有其他滤波环节(比如一阶滞后),要注意总延迟是否可接受。
通用原则
- 先去异常,再做平滑。 限幅、中值、去极值这些处理异常值的操作放在前面。
- 平滑环节放在中间。 一阶滞后、滑动平均、加权平均负责降低随机噪声。
- 输出约束放在最后。 死区、速率限制、迟滞这些约束输出行为的规则放在最后。
- 每加一个环节,就多一重参数、延迟和失真风险。 不要为了"更保险"而堆叠算法。能用两个环节解决的问题,不要用三个。
小结:组合不是堆算法
| 算法 | 作用 | 主要代价 |
|---|---|---|
| 去极值平均 | 剔除少量尖峰后求平均 | 窗口内尖峰过多时失效;滑动版本计算量 \(O(N)\) |
| 限幅后一阶滞后 | 先挡大跳变,再平滑小抖动 | 多一个限幅阈值参数;非线性,不能用截止频率描述 |
| 加权平均 | 让新数据影响更大 | 抗噪声稍弱;需要额外权重存储 |
| 加权中值 | 中值抗尖峰 + 权重偏向 | 权重膨胀时 RAM 占用大 |
| 死区 | 忽略小幅变化 | 死区内信息完全丢失;边界不连续 |
| 速率限制 | 约束输出变化速度 | 输出追赶目标有延迟 |
组合滤波的核心不是"用了多少种算法",而是"每一步解决什么问题"。画出信号流图,标出每一步的作用和代价,比堆叠三个滤波器更有价值。
验证方法始终是:保留原始数据,对比滤波前后的波形。看尖峰是否被剔除、抖动是否降低、响应速度是否可接受、真实信号有没有被误伤。如果四个条件都满足,组合就是有效的;如果某个条件不满足,调整参数或改变顺序,而不是再加一个环节。