07 专用算法与特殊场景
前面几章的算法覆盖了大部分测量问题:平滑、去异常、趋势跟踪、状态估计、频域滤波。但工程中还有一些问题,用通用工具解决不了或者不划算。这一章的算法不一定天天用,但遇到特定问题时很省力。
先看问题类型
| 问题类型 | 典型场景 | 优先考虑 |
|---|---|---|
| 信号上有窄毛刺或窄凹陷 | ADC 偶发脉冲、接触不良 | 一维形态学滤波 |
| 需要记录一段时间内的极值 | 冲击检测、振动峰值、电平表 | 峰值/谷值保持 |
| 需要检测"变化太快"的事件 | 断线、堵转、泄漏、突加负载 | 变化率门限 |
| 需要跟踪周期信号的频率和相位 | 电网同步、逆变器锁相、转速估计 | 软件锁相环 SPLL |
| 有参考信号,想自适应抵消干扰 | 噪声抵消、回声消除、系统辨识 | LMS 自适应滤波 |
| 需要把多个规则串成报警链路 | 液位开关、电压保护、压力报警 | 限幅 + 滞后 + 消抖组合 |
下面按问题类型逐个展开。
形状类处理:一维形态学滤波
形态学滤波来自图像处理,也可以用于一维信号。它处理的不是随机噪声,而是形状问题:窄正脉冲(毛刺)、窄负凹陷(丢数据)、包络线提取。
四个基本操作
- 腐蚀:窗口内取最小值。去掉窄正脉冲,但也会让窄凹陷变得更深。
- 膨胀:窗口内取最大值。填补窄负凹陷,但也会让窄正脉冲变得更高。
- 开运算:先腐蚀再膨胀。去掉窄正脉冲,保留信号整体形状。
- 闭运算:先膨胀再腐蚀。填补窄负凹陷,保留信号整体形状。
窗口长度和脉冲宽度的关系
窗口长度决定了能去掉多宽的毛刺。窗口宽度 \(W\) 能去掉宽度不超过 \(W\) 的正脉冲(开运算)或负凹陷(闭运算)。但窗口太大会改变真实的窄脉冲信号——如果信号本身有宽度接近窗口的特征,也会被当作毛刺去掉。
MCU C 实现
#include <stdint.h>
/**
* 窗口内取最小值(腐蚀)。
* buf: 窗口缓冲区,不能为空
* n: 窗口长度,必须大于 0
*/
float erode_min(const float *buf, uint16_t n)
{
float min_v = buf[0];
for (uint16_t i = 1; i < n; i++) {
if (buf[i] < min_v) min_v = buf[i];
}
return min_v;
}
/**
* 窗口内取最大值(膨胀)。
* buf: 窗口缓冲区,不能为空
* n: 窗口长度,必须大于 0
*/
float dilate_max(const float *buf, uint16_t n)
{
float max_v = buf[0];
for (uint16_t i = 1; i < n; i++) {
if (buf[i] > max_v) max_v = buf[i];
}
return max_v;
}
上面只给出了腐蚀和膨胀的代码。开运算和闭运算需要把两者串联:开运算 = 先腐蚀再膨胀,闭运算 = 先膨胀再腐蚀。实际实现时维护一个滑动窗口缓冲区,先算第一步的输出存入临时缓冲区,再对临时缓冲区做第二步。不要把 dilate_max(erode_min(buf, n), n) 当作直接可用的代码——中间结果需要正确传递。
什么时候失效。 形态学滤波不区分"噪声毛刺"和"真实窄脉冲"——它只看宽度。如果信号本身有窄特征(比如尖峰检测),窗口长度要小于特征宽度。
幅值记录:峰值/谷值保持
峰值保持用于记录一段时间内的最大值,谷值保持记录最小值。典型场景:冲击检测(记录最大加速度)、振动峰值(记录最大振幅)、电平表(显示信号包络)。
decay 的含义
加入 decay 后,峰值会按每个采样周期下降 decay 个单位慢慢回落。这样峰值不会永远停在历史最高点,而是"记住最近的峰值,但慢慢忘掉"。
decay 的大小和采样率有关。如果采样率 100 Hz,希望峰值在 1 秒内回落 50 个单位,那么每步 decay = 50/100 = 0.5。
MCU C 实现
#include <stdint.h>
/**
* 峰值保持(带回落)。
* peak: 当前峰值状态
* x: 当前输入
* decay: 每步回落量(应为非负)
* 返回: 更新后的峰值
*/
float peak_hold(float peak, float x, float decay)
{
if (decay < 0.0f) decay = 0.0f;
/* 新输入超过当前峰值时,立即更新 */
float candidate = peak - decay;
if (candidate < x) candidate = x;
return candidate;
}
/**
* 谷值保持(带回落)。
* valley: 当前谷值状态
* x: 当前输入
* decay: 每步回升量(应为非负)
* 返回: 更新后的谷值
*/
float valley_hold(float valley, float x, float decay)
{
if (decay < 0.0f) decay = 0.0f;
/* 新输入低于当前谷值时,立即更新 */
float candidate = valley + decay;
if (candidate > x) candidate = x;
return candidate;
}
注意:peak_hold 的返回值是 max(x, peak - decay),不是 peak > x ? peak - decay : x。前者在输入持续上升时能正确跟踪;后者在输入接近峰值时会反复跳动。
什么时候失效。 decay 设太小,峰值几乎不回落,失去时效性;decay 设太大,峰值几拍就回到当前值,失去"保持"意义。
事件检测:变化率门限
变化率门限关注的是"变化速度"而不是数值本身。适合检测突变事件:断线(电阻突变)、堵转(电流突增)、泄漏(压力突降)、突加负载。
变化率公式
当变化率绝对值超过阈值,就认为发生了突变事件。
前提和边界
dt必须为正,否则除零或符号反转。- 变化率的单位是"物理量/秒"。阈值要根据物理意义设定——比如温度每秒变化不超过 2°C,阈值可以设 3°C/s。
- ADC 噪声会让相邻采样有 ±1 LSB 抖动,除以小 dt 后变化率会很大。变化率检测最好先做轻微低通或连续确认,否则 ADC 抖一下就会误报。
MCU C 实现
#include <stdint.h>
typedef struct {
float last; /* 上一次输入 */
uint8_t init; /* 是否完成初始化 */
} RateDetect;
void rate_detect_init(RateDetect *f)
{
f->last = 0.0f;
f->init = 0;
}
/**
* 变化率事件检测。
* x: 当前输入
* dt: 采样周期(秒),必须为正
* th: 变化率阈值(物理量/秒),应为非负
* 返回: 1 表示检测到突变事件,0 表示正常
*/
uint8_t rate_event(RateDetect *f, float x,
float dt, float th)
{
if (!f->init) {
f->last = x;
f->init = 1;
return 0;
}
if (dt <= 0.0f) {
f->last = x; /* dt 无效时也更新 last,避免下次正常 dt 时累积两段变化 */
return 0u;
}
if (th < 0.0f) th = 0.0f;
float rate = (x - f->last) / dt;
if (rate < 0.0f) rate = -rate;
f->last = x;
return rate > th ? 1u : 0u;
}
工程建议。 单次变化率检测容易误报。更稳健的做法是:连续 N 次变化率都超阈值才确认事件,或者先对输入做一阶低通再算变化率。
周期跟踪:软件锁相环 SPLL
软件锁相环用于跟踪周期信号的频率和相位。典型场景:电网同步(跟踪 50/60 Hz 的相位)、逆变器控制(锁相到电网)、转速估计(跟踪旋转频率)。
SPLL 的三个组成部分
输入信号 → 相位误差检测器 → 环路滤波器 → 内部振荡器(NCO)→ 输出相位
↑ |
└────────────────────────────────────────────────────┘
- 相位误差检测器:比较输入信号和内部振荡器的相位差,输出误差信号。
- 环路滤波器:平滑误差信号,决定环路的动态特性。通常是一阶或二阶 IIR。
- 内部振荡器(NCO):根据滤波后的误差调整自身的频率和相位,产生输出。
环路带宽
环路带宽是 SPLL 最关键的参数:
- 带宽大:跟踪快,能跟上频率变化,但容易受噪声影响,输出相位抖动大。
- 带宽小:抗噪好,输出相位稳,但频率变化时跟踪慢,可能暂时失锁。
锁定和失锁
当输入频率在环路的跟踪范围内时,SPLL 处于"锁定"状态,输出相位和输入同步。当输入频率突然跳变超出跟踪范围,或噪声太大,SPLL 会"失锁"——输出相位和输入不再同步,需要重新捕获。
工程上通常需要检测锁定状态:比如比较相位误差是否在一定范围内持续稳定。
边界和建议
SPLL 的完整实现涉及相位检测器类型(乘法型、过零型、dq 变换型)、环路滤波器阶数、NCO 结构等,超出本章范围。对初学者来说,建议先理解三个组成部分和环路带宽的含义,再根据具体应用查找参考实现。不要一开始就复制完整 SPLL 代码。
自适应抵消:LMS
LMS(Least Mean Squares)用误差来自动调整滤波器系数。它常用于噪声抵消、系统辨识、回声消除。
LMS 不是"没有目标也能自己变好"的魔法
LMS 需要两个输入:
- 参考输入 \(x[n]\):和干扰相关的信号。
- 期望信号 \(d[n]\):包含信号加干扰的混合信号。
LMS 的目标是:调整滤波器系数,让滤波器输出 \(y[n]\) 尽量接近 \(d[n]\) 中的干扰成分。误差 \(e = d - y\) 用来驱动系数更新。
如果没有参考输入,LMS 无法工作。 它不是"没有目标也能自动优化"的滤波器。
还有一个容易忽略的前提:参考输入必须和干扰相关,不能和信号本身相关。 如果参考信号里包含你想保留的目标信号,LMS 会把目标信号也当作干扰抵消掉。例如噪声抵消中,参考麦克风应尽量只拾取噪声,不拾取人声。
更新式
其中 \(\mu\) 是步长,控制收敛速度。
mu 的稳定性边界
步长 \(\mu\) 太小,收敛慢;\(\mu\) 太大,系数会发散。一个常用的保守上限是:
其中 \(N\) 是滤波器阶数,输入功率是参考信号的平均功率。这个上限是充分条件而非充要条件——实际系统中信号特性、滤波器阶数和数值精度都会影响稳定范围。工程上通常先从这个上限的 1/10 开始试,观察误差是否收敛,再逐步增大。
NLMS:归一化 LMS
当输入幅度变化大时,固定的 \(\mu\) 不够用——输入大时步长相对太小,输入小时步长相对太大。NLMS 把步长除以输入功率,自动适应幅度变化:
\(\epsilon\) 是防止除零的小常数。NLMS 比 LMS 更稳健,工程上更常用。
MCU C 实现
#include <stdint.h>
/**
* LMS 自适应滤波更新。
* w: 滤波器系数数组(会被原地修改)
* x: 参考输入缓冲区(长度 n)
* n: 滤波器阶数
* d: 期望信号
* mu: 步长
* 返回: 误差 e = d - y
*/
float lms_update(float *w, const float *x, uint16_t n,
float d, float mu)
{
float y = 0.0f;
/* 计算滤波器输出 y = w · x */
for (uint16_t i = 0; i < n; i++) {
y += w[i] * x[i];
}
float e = d - y;
/* 用误差修正每一个系数 */
for (uint16_t i = 0; i < n; i++) {
w[i] += 2.0f * mu * e * x[i];
}
return e;
}
什么时候失效。 参考输入和干扰不相关时,LMS 无法收敛。步长过大时系数发散。环境特性变化太快时,LMS 的收敛速度跟不上。
工程组合:限幅消抖报警链
这不是一个单独算法,而是一套常见的工程组合。液位开关、电压保护、压力报警都可以这样做。
典型链路
每一步的作用:
- 限幅:挡住 ADC 偶发的大幅尖峰,不让它进入后续判断。
- 一阶滞后:压掉小幅随机抖动,让送入判断的值更平滑。
- 迟滞判断:用高低两个阈值防止在阈值附近反复开关。
- 消抖确认:要求状态连续稳定一段时间才确认,防止短暂干扰触发报警。
这四步不需要每次都用全。如果 ADC 尖峰不多,限幅可以省;如果抖动不大,一阶滞后可以省。根据实际问题选择需要的环节。
这种组合和第 08 章的工程配方互相呼应——第 08 章会更系统地讲"怎么根据问题类型组合工具"。
统计异常剔除:3σ、Grubbs 与 Hampel 的边界
3σ 方法
3σ 假设数据近似正态分布。若某点距离均值超过 3 倍标准差,就认为可疑:
#include <stdint.h>
uint8_t is_outlier_3sigma(float x, float mean, float sigma)
{
if (sigma <= 0.0f) return 0u; /* sigma 非正时无法判断,保守返回正常 */
float d = x - mean;
if (d < 0.0f) d = -d;
return d > 3.0f * sigma ? 1u : 0u;
}
前提条件: 数据近似正态、样本相对干净、异常值数量少。如果样本里异常值很多,均值和标准差本身会被污染——一个极端值就能把均值拉偏,进而让更多正常点被判为异常。
Grubbs 准则
Grubbs 比 3σ 更正式,用于小样本中判断单个异常值。它用 t 分布计算临界值,考虑了样本量的影响。但需要查表或计算临界值,实现比 3σ 复杂。
和 Hampel 的对比
| 方法 | 抗污染能力 | 计算量 | 适用场景 |
|---|---|---|---|
| 3σ | 弱——均值和标准差会被异常值拉偏 | 最低 | 数据干净、异常值少 |
| Grubbs | 弱到中——比 3σ 多考虑了样本量,但仍依赖均值/标准差 | 低 | 小样本、单个异常值 |
| Hampel | 强——用中位数和 MAD,对异常值不敏感 | 较高(需要排序) | 异常值可能较多、波动范围会变化 |
一句话: 数据干净时 3σ 够用;数据可能被污染时,Hampel 更稳。
小结:什么时候用专用算法
| 问题 | 通用工具为什么不够 | 专用工具 |
|---|---|---|
| 按宽度过滤形状特征 | 中值去孤立尖峰好,但无法按宽度选择性去除 | 形态学开/闭运算 |
| 需要记录极值 | 普通滤波只给当前平滑值 | 峰值/谷值保持 |
| 检测"变化太快" | 普通阈值只看数值,不看速度 | 变化率门限 |
| 跟踪周期信号相位 | 普通滤波不跟踪频率 | SPLL |
| 有参考信号要抵消干扰 | 固定滤波器不能自适应 | LMS / NLMS |
| 报警链路需要多级保护 | 单一算法解决不了 | 限幅 + 滞后 + 消抖组合 |
| 数据可能被污染 | 3σ 的均值/标准差会被拉偏 | Hampel |
专用算法的核心不是"更高级",而是"更针对"。遇到问题时先问:通用工具(平均、中值、一阶滞后、卡尔曼、FIR、IIR)能不能解决?如果能,不需要上专用工具。如果通用工具差一点但不够,看看是不是参数没调好。确认通用工具确实解决不了时,再找专用工具。