跳转至

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 设太大,峰值几拍就回到当前值,失去"保持"意义。

事件检测:变化率门限

变化率门限关注的是"变化速度"而不是数值本身。适合检测突变事件:断线(电阻突变)、堵转(电流突增)、泄漏(压力突降)、突加负载。

变化率公式

\[ \text{rate} = \frac{x[n] - x[n-1]}{dt} \]

当变化率绝对值超过阈值,就认为发生了突变事件。

前提和边界

  • 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 会把目标信号也当作干扰抵消掉。例如噪声抵消中,参考麦克风应尽量只拾取噪声,不拾取人声。

更新式

\[ w_i[n+1] = w_i[n] + 2\mu \cdot e \cdot x_i[n] \]

其中 \(\mu\) 是步长,控制收敛速度。

mu 的稳定性边界

步长 \(\mu\) 太小,收敛慢;\(\mu\) 太大,系数会发散。一个常用的保守上限是:

\[ 0 < \mu < \frac{1}{\text{输入功率} \cdot N} \]

其中 \(N\) 是滤波器阶数,输入功率是参考信号的平均功率。这个上限是充分条件而非充要条件——实际系统中信号特性、滤波器阶数和数值精度都会影响稳定范围。工程上通常先从这个上限的 1/10 开始试,观察误差是否收敛,再逐步增大。

NLMS:归一化 LMS

当输入幅度变化大时,固定的 \(\mu\) 不够用——输入大时步长相对太小,输入小时步长相对太大。NLMS 把步长除以输入功率,自动适应幅度变化:

\[ \mu_{\text{eff}} = \frac{\mu}{\sum x_i^2 + \epsilon} \]

\(\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 偶发的大幅尖峰,不让它进入后续判断。
  • 一阶滞后:压掉小幅随机抖动,让送入判断的值更平滑。
  • 迟滞判断:用高低两个阈值防止在阈值附近反复开关。
  • 消抖确认:要求状态连续稳定一段时间才确认,防止短暂干扰触发报警。

这四步不需要每次都用全。如果 ADC 尖峰不多,限幅可以省;如果抖动不大,一阶滞后可以省。根据实际问题选择需要的环节。

这种组合和第 08 章的工程配方互相呼应——第 08 章会更系统地讲"怎么根据问题类型组合工具"。

统计异常剔除:3σ、Grubbs 与 Hampel 的边界

3σ 方法

3σ 假设数据近似正态分布。若某点距离均值超过 3 倍标准差,就认为可疑:

\[ |x - \bar{x}| > 3\sigma \]
#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 的对比

方法 抗污染能力 计算量 适用场景
弱——均值和标准差会被异常值拉偏 最低 数据干净、异常值少
Grubbs 弱到中——比 3σ 多考虑了样本量,但仍依赖均值/标准差 小样本、单个异常值
Hampel 强——用中位数和 MAD,对异常值不敏感 较高(需要排序) 异常值可能较多、波动范围会变化

一句话: 数据干净时 3σ 够用;数据可能被污染时,Hampel 更稳。

小结:什么时候用专用算法

问题 通用工具为什么不够 专用工具
按宽度过滤形状特征 中值去孤立尖峰好,但无法按宽度选择性去除 形态学开/闭运算
需要记录极值 普通滤波只给当前平滑值 峰值/谷值保持
检测"变化太快" 普通阈值只看数值,不看速度 变化率门限
跟踪周期信号相位 普通滤波不跟踪频率 SPLL
有参考信号要抵消干扰 固定滤波器不能自适应 LMS / NLMS
报警链路需要多级保护 单一算法解决不了 限幅 + 滞后 + 消抖组合
数据可能被污染 3σ 的均值/标准差会被拉偏 Hampel

专用算法的核心不是"更高级",而是"更针对"。遇到问题时先问:通用工具(平均、中值、一阶滞后、卡尔曼、FIR、IIR)能不能解决?如果能,不需要上专用工具。如果通用工具差一点但不够,看看是不是参数没调好。确认通用工具确实解决不了时,再找专用工具。