El filtrado de señales digital es el proceso de atenuar el ruido de una señal muestreada para conservar la información útil que necesita nuestro algoritmo de control.
En los artículos anteriores hemos construido un PID digital y hemos tratado la saturación con anti-windup. Al llevarlo a un robot real, todavía podemos encontrarnos con motores que emiten un chirrido agudo, vibran o se calientan más de lo esperado.
¿El culpable? El ruido eléctrico y mecánico. ¿La víctima? La acción Derivativa (D) de nuestro PID.
Hoy, en el último artículo de esta mini-serie, vamos a entender por qué ocurre esto enganchando con lo que aprendimos en los diagramas de Bode, y vamos a programar una solución muy habitual: el filtro digital paso bajo.
El ruido de los sensores
En el mundo físico, los sensores no dibujan líneas perfectas.
- Un acelerómetro MPU6050 capta las microvibraciones del chasis.
- Un conversor analógico-digital (ADC) leyendo un potenciómetro sufre de jitter eléctrico.
- Un encoder óptico tiene un error de cuantización por sus “pasos” discretos.
Si graficamos esto, veremos una señal útil con pequeñas variaciones superpuestas. Una parte importante del ruido que molesta a la derivada tiene contenido de alta frecuencia, aunque también existen errores lentos, sesgos y derivas que un filtro paso bajo no corrige.
Por qué la derivada amplifica el ruido
Para entender por qué esta “pelusilla” inofensiva destroza un robot industrial de 10.000€, tenemos que mirar las matemáticas.
Si recuerdas el artículo sobre la Función de Transferencia, hacer una derivada en el dominio de Laplace equivale a multiplicar por s. En el artículo de los Diagramas de Bode, vimos que la frecuencia se representa como s=jω.
Por tanto, la ganancia de un derivador puro es directamente proporcional a la frecuencia ω. Cuanto más rápida sea la señal, más grande es la derivada. Un derivador es un amplificador infinito de altas frecuencias (en Bode, su gráfica sube eternamente a +20 dB/década).
El desastre matemático
Imagina que tenemos un ruido minúsculo en nuestro sensor, modelado como una onda senoidal de solo 0.01 unidades de amplitud, pero que vibra a 1000 rad/s:
Cuando nuestro término derivativo intenta calcular la pendiente de este ruido, matemáticamente hace esto:
¡La amplitud de la salida se ha multiplicado por mil! Un ruido imperceptible en la posición acaba de convertirse en picos de mando gigantescos en la señal de nuestro motor. Esto es lo que provoca el famoso “chattering” y calienta los drivers hasta quemarlos.
Simulador interactivo
Para experimentar esto de primera mano, prueba el siguiente simulador.
Deja la fuerza del filtro al mínimo y sube un poco la amplitud de ruido. Verás que la señal del sensor apenas parece cambiar, pero la gráfica de la derivada se convierte en un bloque sólido de ruido caótico.
Después aumenta la fuerza del filtro poco a poco. La derivada recupera su forma útil, aunque a cambio aparece algo de retardo.
Filtro de media móvil exponencial (EMA)
Necesitamos bloquear las altas frecuencias (el ruido) antes de calcular la derivada. Podríamos almacenar las últimas 10 lecturas en un array y calcular el promedio, pero eso gasta mucha memoria RAM y ciclos de CPU.
Una alternativa muy ligera es el filtro de media móvil exponencial (Exponential Moving Average, EMA). Es un filtro digital paso bajo de primer orden (IIR) que requiere una sola variable de memoria.
La fórmula es sencillísima:
Donde:
- x[k] es la lectura actual (cruda) del sensor.
- y[k] es el valor filtrado que vamos a usar en este ciclo.
- y[k−1] es el valor filtrado del ciclo anterior.
- α (Alpha) es el factor de suavizado, un número entre 0 y 1.
Cómo elegir el valor de Alpha (α):
- Si α=1, el filtro no hace nada. La salida es exactamente la lectura actual.
- Si α=0.1, confías un 10% en la lectura nueva y un 90% en la historia pasada. La señal será muy suave, pero tendrá retardo.
- Si α=0.01, el filtro es fuertísimo. Te quitará todo el ruido, pero tu sistema reaccionará tan tarde que el PID podría volverse inestable (recuerda que el retardo es el enemigo del margen de fase en Bode).
El efecto de α depende del periodo de muestreo. Si queremos mantener una constante de tiempo de filtro τ_f al cambiar T_s, podemos calcular α = T_s / (τ_f + T_s) como aproximación habitual.
Implementación del filtro derivativo en C++
Existen dos estrategias principales: filtrar el sensor completo (PV) o filtrar solo la señal usada por el término derivativo. Filtrar únicamente la derivada conserva una respuesta proporcional más rápida, aunque la decisión depende del ruido y del resto del sistema.
De esta forma, la acción proporcional sigue reaccionando a la medida sin ese filtro adicional, mientras la derivada atenúa los picos rápidos.
Vamos a ampliar el código anterior combinando el PID, el clamping condicional y el filtro derivativo. Sigue siendo un ejemplo didáctico: una implementación de producción también necesita validar parámetros, tratar fallos del sensor y definir una estrategia segura ante errores.
class PIDControlador {
private:
float Kp, Ki, Kd;
float integral_acumulada;
float pv_anterior;
float derivada_filtrada;
float salida_anterior;
bool inicializado;
// Parámetros físicos
float limite_max, limite_min;
float alpha_filtro; // Factor de suavizado (0.0 a 1.0)
public:
PIDControlador(float p, float i, float d, float min, float max, float alpha) {
Kp = p; Ki = i; Kd = d;
limite_min = min; limite_max = max;
alpha_filtro = alpha;
integral_acumulada = 0;
pv_anterior = 0;
derivada_filtrada = 0;
salida_anterior = 0;
inicializado = false;
}
float calcular(float setpoint, float pv, float dt) {
if (dt <= 0.0) {
return salida_anterior;
}
float error = setpoint - pv;
// --- Acción Proporcional ---
float P = Kp * error;
// --- Derivada sobre la medida con filtro EMA ---
// Evita el derivative kick cuando cambia el setpoint
float derivada_cruda = 0.0;
if (inicializado) {
derivada_cruda = -(pv - pv_anterior) / dt;
}
derivada_filtrada = (alpha_filtro * derivada_cruda)
+ ((1.0 - alpha_filtro) * derivada_filtrada);
float D = Kd * derivada_filtrada;
// --- Integral con anti-windup (clamping condicional) ---
float salida_actual = P + D + (Ki * integral_acumulada);
bool satura_arriba = salida_actual >= limite_max;
bool satura_abajo = salida_actual <= limite_min;
bool permite_integrar = (!satura_arriba && !satura_abajo)
|| (satura_arriba && error < 0.0)
|| (satura_abajo && error > 0.0);
if (permite_integrar) {
integral_acumulada += error * dt;
}
float salida_total = P + D + (Ki * integral_acumulada);
float salida_final = constrain(salida_total, limite_min, limite_max);
// --- Actualizar memorias para el próximo ciclo ---
pv_anterior = pv;
salida_anterior = salida_final;
inicializado = true;
return salida_final;
}
};