Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Introducción

El filtrado es una técnica esencial en el análisis de series de tiempo, en especial en las geociencias, donde las señales suelen estar contaminadas por ruido o contener componentes tanto de corta como de larga duración. Los filtros ayudan a aislar la información significativa de los datos crudos atenuando las frecuencias no deseadas o realzando ciertos rasgos. En esta lección filtraremos series de tiempo, con foco en una variable climática que tiene estacionalidad y una tendencia positiva. Los datos climáticos suelen exhibir tanto variaciones de corto plazo (como los ciclos diarios o estacionales) como tendencias de largo plazo (como el calentamiento oceánico). Al aplicar filtros, podemos concentrarnos en componentes específicas de una señal climática, ya sea que nos interesen las tendencias climáticas de largo plazo o los patrones meteorológicos de corto plazo.

Por qué importa el filtrado

  • Reducción de ruido: los datos geofísicos suelen contener ruido de los instrumentos de medición, de las condiciones ambientales o de señales no relacionadas. El filtrado ayuda a remover ese ruido y realza la señal de interés.
  • Aislamiento de rasgos: al concentrarse en bandas de frecuencia específicas, el filtrado permite aislar fenómenos de corto plazo (como las tormentas) o procesos de largo plazo (como las tendencias climáticas).
  • Suavizado de los datos: en las geociencias, suavizar series de tiempo ruidosas hace más evidentes los patrones y mejora la claridad de las visualizaciones.
  • Detección de tendencias: el filtrado de largo plazo puede revelar tendencias subyacentes en los datos, que importan para los estudios del cambio climático, la circulación oceánica y el calentamiento global.

Tipos de filtros

  • Filtro pasabajas: deja pasar las componentes de baja frecuencia mientras atenúa las de alta frecuencia. Útil para aislar tendencias de largo plazo.
  • Filtro pasaaltas: deja pasar las componentes de alta frecuencia mientras atenúa las de baja frecuencia. Útil para concentrarse en las fluctuaciones de corto plazo.
  • Filtro pasabanda: deja pasar un rango específico de frecuencias, bloqueando tanto las más altas como las más bajas. Útil para analizar fenómenos dentro de un rango de frecuencia particular.
  • Filtros de suavizado: como las medias móviles o los filtros gaussianos, suavizan los datos para remover las fluctuaciones de corto plazo.

Los datos pueden superponer múltiples señales de frecuencias diversas. Para remover o extraer señales específicas que no se traslapan en frecuencia, podemos filtrar los datos.

El filtro puede ser:

  • pasaaltas (high pass): reduce las señales de frecuencias menores que una frecuencia de esquina fcf_c y solo deja pasar las señales por encima de fcf_c. Suele parametrizarse en las funciones como hp o highpass.
  • pasabajas (low pass): reduce las señales de frecuencias mayores que una frecuencia de corte fcf_c y solo deja pasar las señales por debajo de ese fcf_c. Suele parametrizarse en las funciones como lp o lowpass.
  • pasabanda (band pass): reduce las señales de frecuencias menores que una frecuencia de esquina baja fc1f_{c1} y de frecuencias mayores que una frecuencia de esquina alta fc2>fc1f_{c2}>f_{c1}. Suele parametrizarse como bp o bandpass.

Existen distintos tipos de filtros. Los más comunes son butterworth y chebyshev, pero existen otros.

Filters

Figura: ejemplos de filtros ilustrados aquí.

Ejemplo 1: filtrar una serie de tiempo climática sintética

Construimos una serie climática diaria sintética con tres componentes conocidas: una tendencia lineal de calentamiento, un ciclo estacional y ruido. Como la construimos nosotros mismos, conocemos las componentes verdaderas con exactitud, así que podemos comprobar qué tan bien el filtrado recupera cada una.

Hacemos el ruido coloreado (rojo) en lugar de blanco: el ruido climático real tiene más potencia en las frecuencias bajas que en las altas. Lo generamos moldeando el espectro de ruido blanco en el dominio de Fourier — escalamos el espectro de amplitud por 1/f1/f, conservamos fases aleatorias y aplicamos la transformada inversa. Esta elección hace honesto el ejercicio: el ruido rojo tiene potencia en todas las frecuencias, incluidas las frecuencias bajas donde viven la tendencia y el ciclo estacional, así que ningún filtro puede separar las componentes a la perfección.

🖥️ Diapositivas — Sesión 08 (vie 16 oct)

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import butter, sosfiltfilt, sosfilt
from scipy.fft import rfft, irfft, rfftfreq

rng = np.random.default_rng(42)

# 10 years of daily data; build t from the sample count so lengths always match
fs = 1.0                     # sampling frequency: 1 sample per day
n = 365 * 10                 # number of samples
time = np.arange(n) / fs     # time in days

# true components
trend = 0.01 * time                          # long-term warming trend, deg C
seasonal = 10 * np.sin(2 * np.pi * time / 365)  # seasonal cycle, period 365 days

# red (colored) noise: shape a white spectrum by 1/f, random phases, inverse FFT
white = rng.standard_normal(n)
freqs = rfftfreq(n, d=1/fs)
shaping = np.zeros_like(freqs)
shaping[1:] = 1 / freqs[1:]      # amplitude ~ 1/f; zero out the mean
noise = irfft(rfft(white) * shaping, n=n)
noise *= 2.5 / np.std(noise)     # scale to a standard deviation of 2.5 deg C

clima = trend + seasonal + noise

# Plot the raw data and the true components
fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
ax[0].plot(time, clima, label='Raw data')
ax[0].set_ylabel('Temperature (°C)')
ax[0].set_title('Synthetic climate time series')
ax[0].legend(); ax[0].grid(True)
ax[1].plot(time, trend, label='True trend')
ax[1].plot(time, seasonal, label='True seasonal')
ax[1].plot(time, noise, label='True (red) noise', alpha=0.5)
ax[1].set_xlabel('Time (days)'); ax[1].set_ylabel('Temperature (°C)')
ax[1].legend(); ax[1].grid(True)
plt.show()
<Figure size 1000x600 with 2 Axes>

Recuperar cada componente con filtros

Las tres componentes ocupan bandas de frecuencia distintas (las frecuencias aquí están en ciclos por día):

  • la tendencia vive en frecuencias muy bajas, cerca de 0;
  • el ciclo estacional es una línea angosta en f=1/3650.0027f = 1/365 \approx 0.0027 ciclos por día;
  • el ruido se extiende por todas las frecuencias.

Diseñamos entonces tres filtros de Butterworth:

  • un pasabajas con corte por debajo de la frecuencia estacional para recuperar la tendencia;
  • un pasabanda que encierra 1/3651/365 ciclos por día para aislar el ciclo estacional;
  • un pasaaltas con corte por encima de la frecuencia estacional para aislar el ruido.

Usamos butter(..., output='sos'), que devuelve el filtro como secciones de segundo orden (second-order sections): un producto de polinomios de segundo orden que representa el mismo filtro pero es numéricamente más estable que la forma polinómica (b, a), sobre todo en órdenes de filtro altos. Aplicamos el filtro con sosfiltfilt, que corre el filtro hacia adelante y hacia atrás para que el resultado tenga desfase cero (más sobre esto abajo).

# Low-pass to recover the trend: cutoff at 1/700 cycles/day, below the seasonal line
sos_lp = butter(4, 1/700, btype='lowpass', fs=fs, output='sos')
clima_trend = sosfiltfilt(sos_lp, clima)

# Band-pass to isolate the seasonal cycle: 1/500 to 1/250 cycles/day brackets 1/365
sos_bp = butter(2, [1/500, 1/250], btype='bandpass', fs=fs, output='sos')
clima_seasonal = sosfiltfilt(sos_bp, clima)

# High-pass to isolate the noise: cutoff at 1/100 cycles/day, above the seasonal line
sos_hp = butter(4, 1/100, btype='highpass', fs=fs, output='sos')
clima_noise = sosfiltfilt(sos_hp, clima)

fig, ax = plt.subplots(3, 1, figsize=(10, 9), sharex=True)
ax[0].plot(time, trend, 'k', label='True trend')
ax[0].plot(time, clima_trend, 'r', label='Low-pass recovered')
ax[0].set_ylabel('°C'); ax[0].set_title('Trend: true vs low-pass filtered')
ax[0].legend(); ax[0].grid(True)

ax[1].plot(time, seasonal, 'k', label='True seasonal')
ax[1].plot(time, clima_seasonal, 'g', label='Band-pass recovered')
ax[1].set_ylabel('°C'); ax[1].set_title('Seasonal cycle: true vs band-pass filtered')
ax[1].legend(); ax[1].grid(True)

ax[2].plot(time, noise, 'k', label='True noise', alpha=0.6)
ax[2].plot(time, clima_noise, 'b', label='High-pass recovered', alpha=0.6)
ax[2].set_xlabel('Time (days)'); ax[2].set_ylabel('°C')
ax[2].set_title('Noise: true vs high-pass filtered')
ax[2].legend(); ax[2].grid(True)
plt.tight_layout()
plt.show()

# quantify the recovery errors
for name, true_c, rec in [('trend', trend, clima_trend),
                          ('seasonal', seasonal, clima_seasonal),
                          ('noise', noise, clima_noise)]:
    rms = np.sqrt(np.mean((true_c - rec)**2))
    print(f"RMS error of recovered {name}: {rms:.2f} °C")
<Figure size 1000x900 with 3 Axes>
RMS error of recovered trend: 2.45 °C
RMS error of recovered seasonal: 1.55 °C
RMS error of recovered noise: 2.49 °C

Por qué la recuperación es imperfecta: fuga espectral entre componentes

La tendencia recuperada no es una línea recta: deambula alrededor de la tendencia verdadera. Al ruido recuperado le falta parte del ruido verdadero. Esto no es un defecto de los filtros — es una propiedad de los datos.

Un filtro separa las señales por banda de frecuencia. Solo puede separar componentes limpiamente si ocupan bandas disjuntas. Aquí el ruido rojo tiene potencia en todas las frecuencias, incluso por debajo del corte del pasabajas. Esa parte de baja frecuencia del ruido atraviesa el filtro pasabajas junto con la tendencia, y el filtro no tiene manera de distinguirlas. La misma fuga contamina la salida del pasabanda: la serie «estacional» recuperada contiene la potencia de ruido que cae dentro de la banda de paso, así que su amplitud fluctúa de un año a otro aunque el ciclo estacional verdadero sea perfectamente regular. Mientras tanto, a la salida del pasaaltas le falta la parte de baja frecuencia del ruido — y el ruido rojo concentra la mayor parte de su potencia en las frecuencias bajas, así que el filtro pasaaltas recupera solo una fracción pequeña del ruido verdadero y su error RMS es casi tan grande como el ruido mismo.

Observe también los bordes del filtro: cerca del inicio y del final de la serie, el filtro tiene datos incompletos y la salida queda distorsionada. Los efectos de borde son un artefacto estándar del filtrado; aplique un taper o recorte los bordes antes de interpretarlos.

La lección: el filtrado recupera una banda de frecuencia, no una componente física. Ambas coinciden solo cuando las componentes están separadas espectralmente. Con ruido coloreado, cierta fuga es inevitable, y usted debería reportarla en lugar de ignorarla.

Filtrado de fase cero frente a filtrado causal

scipy.signal ofrece dos maneras de aplicar un filtro SOS:

  • sosfilt corre el filtro solo hacia adelante en el tiempo. Es un filtro causal: la salida en el tiempo tt depende solo de las muestras hasta tt. Es la única opción en los sistemas de tiempo real, y preserva el inicio de una señal — nada aparece en la salida antes de aparecer en la entrada. El costo es un retardo de fase dependiente de la frecuencia: los rasgos de la salida llegan tarde.
  • sosfiltfilt corre el filtro hacia adelante y luego hacia atrás. Las dos pasadas cancelan mutuamente su retardo de fase, así que la salida tiene fase cero: los rasgos filtrados quedan alineados en el tiempo con los datos crudos. El costo es que el filtro deja de ser causal — la energía se fuga hacia atrás en el tiempo, así que un inicio abrupto adquiere un pequeño precursor. Úselo para el análisis fuera de línea cuando importa la alineación temporal, nunca para el procesamiento en tiempo real, y tenga cuidado al medir tiempos de llegada cerca de inicios abruptos.

Demostramos la diferencia con los datos sísmicos de abajo, donde el inicio abrupto de la onda P hace fácil de ver el retardo de fase.

Casos de uso en las geociencias:

  • Estudios del cambio climático: filtrar datos de temperatura para remover el ruido y concentrarse en las tendencias de largo plazo.
  • Detección de El Niño y La Niña: el filtrado ayuda a identificar las oscilaciones periódicas en los datos de temperatura superficial del mar — el corazón de los pronósticos estacionales en toda América Latina.
  • Pronóstico del tiempo: los filtros pasaaltas aíslan las variaciones de corto plazo para su análisis.

Ejemplo 2: aplicación a la sismología

Descargamos los mismos datos que en la lección 2.8: sismogramas registrados en el Puget Sound (estación UW.RATT) para el sismo M8.2 de Chignik, Alaska, del 29 de julio de 2021, más una ventana de ruido antes del evento. Consultamos el centro de datos FDSN con el cliente IRIS; note que los servicios de datos de IRIS ahora los opera EarthScope, pero el nombre de cliente IRIS sigue funcionando.

El código de canal HHZ denota un sismómetro de banda ancha de alta ganancia, muestreado a 100 muestras por segundo, componente vertical. Aquí mantenemos los datos en cuentas crudas del digitalizador (no removemos la respuesta instrumental), así que los ejes de amplitud están etiquetados en cuentas.

# Import modules for seismic data
import os

import scipy.signal as signal

# seismic python toolbox
import obspy
import obspy.clients.fdsn.client as fdsn
from obspy import UTCDateTime

os.makedirs('data', exist_ok=True)
# Download seismic data
network = 'UW'
station = 'RATT'
channel = 'HHZ'  # broadband high-gain vertical channel, 100 samples per second
Tstart = UTCDateTime(2021, 7, 29, 6, 15)
Tend = Tstart + 7200
fdsn_client = fdsn.Client('IRIS')  # client to query the EarthScope (formerly IRIS) DMC server

# call to download the specific data: earthquake waveforms
Z = fdsn_client.get_waveforms(network=network, station=station, location='--', channel=channel,
                              starttime=Tstart, endtime=Tend)
# basic pre-processing: merge if there are gaps, detrend, taper
Z.merge(); Z.detrend(type='linear'); Z[0].taper(max_percentage=0.05)

# call to download the specific data: noise waveforms (the two hours before the earthquake)
N = fdsn_client.get_waveforms(network=network, station=station, location='--', channel=channel,
                              starttime=Tstart - 7200, endtime=Tstart)
N.merge(); N.detrend(type='linear'); N[0].taper(max_percentage=0.05)
/Users/marinedenolle/Dropbox/CLASSES/ESS490/curriculum-book/.pixi/envs/default/lib/python3.12/site-packages/obspy/clients/fdsn/client.py:251: ObsPyDeprecationWarning: IRIS is now EarthScope, please consider changing the FDSN client short URL to 'EARTHSCOPE'.
  warnings.warn(msg, ObsPyDeprecationWarning)
UW.RATT..HHZ | 2021-07-29T04:15:00.000000Z - 2021-07-29T06:14:59.990000Z | 100.0 Hz, 720000 samples
fig, ax = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
ax[0].plot(Z[0].data); ax[0].grid(True); ax[0].set_ylabel('Counts'); ax[0].set_title('Earthquake window')
ax[1].plot(N[0].data); ax[1].grid(True); ax[1].set_ylabel('Counts'); ax[1].set_title('Noise window')
ax[1].set_xlabel('Sample index')
<Figure size 1000x600 with 2 Axes>

Usaremos el módulo scipy.signal para filtrar las series de tiempo.

# sampling rate of the data:
fs = Z[0].stats.sampling_rate
z = np.asarray(Z[0].data)
n_ = np.asarray(N[0].data)

# build the time vector from the number of samples so lengths always match
t = np.arange(len(z)) / fs

Usamos un filtro butterworth de segundo orden, pasabanda entre las frecuencias de 1 Hz y 10 Hz. La salida sos es una representación en secciones de segundo orden: el filtro se expresa como un producto de polinomios de segundo orden, que es numéricamente más estable que la forma de un solo polinomio de orden alto. Aplicamos el filtro con sosfiltfilt para obtener un resultado de fase cero.

sos = signal.butter(2, [1, 10], 'bandpass', fs=fs, output='sos')
zf = signal.sosfiltfilt(sos, z)
nf = signal.sosfiltfilt(sos, n_)
fig, axis = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
axis[0].plot(t, zf); axis[0].set_ylabel('Counts'); axis[0].set_title('Earthquake, band-passed 1-10 Hz')
axis[1].plot(t[:len(nf)], nf); axis[1].set_ylabel('Counts'); axis[1].set_title('Noise, band-passed 1-10 Hz')
axis[0].set_xlim([0, 1000]); axis[1].set_xlim([0, 1000])
axis[0].grid(True); axis[1].grid(True)
axis[1].set_xlabel('Time in seconds')
<Figure size 1000x600 with 2 Axes>

Ahora filtre en una banda de frecuencia más alta (10-40 Hz) y compare las señales del sismo y del ruido.

sos_hf = signal.butter(2, [10, 40.], 'bandpass', fs=fs, output='sos')
zf_hf = signal.sosfiltfilt(sos_hf, z)
nf_hf = signal.sosfiltfilt(sos_hf, n_)

fig, ax = plt.subplots(2, 1, figsize=(11, 8), sharex=True)
ax[0].plot(t, z); ax[0].plot(t[:len(n_)], n_); ax[0].grid(True)
ax[0].set_title('Raw data'); ax[0].legend(['Earthquake', 'Noise']); ax[0].set_ylabel('Counts')
ax[1].plot(t, zf_hf); ax[1].plot(t[:len(nf_hf)], nf_hf); ax[1].grid(True)
ax[1].set_title('Filtered data, 10-40 Hz'); ax[1].legend(['Earthquake', 'Noise']); ax[1].set_ylabel('Counts')
ax[1].set_xlabel('Time in seconds')
ax[0].set_xlim([700, 1000])
ax[1].set_xlim([700, 1000])
(700.0, 1000.0)
<Figure size 1100x800 with 2 Axes>

El sismo se destaca del ruido en ambas bandas de frecuencia. En la banda de 10-40 Hz, la onda P es la llegada dominante: las altas frecuencias se atenúan con la distancia, así que las fases posteriores, más lentas, están empobrecidas en energía de alta frecuencia.

Filtrado de fase cero (sosfiltfilt) frente a causal (sosfilt)

Ahora aplicamos el mismo filtro pasabanda de 1-10 Hz de las dos maneras y hacemos acercamiento sobre el inicio de la onda P.

zf_zerophase = signal.sosfiltfilt(sos, z)  # forward-backward: zero phase
zf_causal = signal.sosfilt(sos, z)         # forward only: causal, phase-delayed

fig, ax = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
ax[0].plot(t, zf_zerophase, label='sosfiltfilt (zero-phase)')
ax[0].plot(t, zf_causal, label='sosfilt (causal)', alpha=0.8)
ax[0].set_xlim([700, 1000]); ax[0].grid(True); ax[0].legend()
ax[0].set_ylabel('Counts'); ax[0].set_title('Band-passed 1-10 Hz: zero-phase vs causal')

ax[1].plot(t, zf_zerophase, label='sosfiltfilt (zero-phase)')
ax[1].plot(t, zf_causal, label='sosfilt (causal)', alpha=0.8)
ax[1].set_xlim([750, 762]); ax[1].grid(True); ax[1].legend()
ax[1].set_ylabel('Counts'); ax[1].set_xlabel('Time in seconds')
ax[1].set_title('Zoom on the P-wave onset')
<Figure size 1100x700 with 2 Axes>

En el panel ampliado, la salida causal de sosfilt está desplazada hacia tiempos posteriores respecto de la salida de fase cero de sosfiltfilt: el retardo de fase del filtro mueve el inicio aparente. La versión de fase cero queda alineada con los datos crudos, pero lo logra filtrando hacia atrás en el tiempo, lo que esparce una pequeña cantidad de energía antes del inicio.

Guía práctica:

  • Use el filtrado causal (sosfilt) para las aplicaciones en tiempo real (la alerta sísmica temprana, como la del SASMEX mexicano) y cuando importa la presencia o ausencia de energía antes de un inicio. Si mide tiempos de llegada sobre datos filtrados causalmente, corrija el retardo del filtro o acepte un sesgo.
  • Use el filtrado de fase cero (sosfiltfilt) para el análisis fuera de línea donde importa la alineación temporal entre los datos crudos y los filtrados. No interprete las pequeñas ondulaciones precursoras cerca de inicios abruptos: pueden ser artefactos del filtro.

Ejemplo 3: filtrar registros imperfectos — un hueco y un error de reloj

Todo lo anterior supuso un registro continuo y correctamente fechado. Los archivos reales no cooperan: la telemetría se cae, los discos se llenan, los relojes GPS pierden la señal. El registro de RATT resulta estar completo y bien fechado — así que rompemos una copia a propósito. De ese modo el registro intacto es la verdad de referencia, y cada reparación recibe calificación.

Un hueco en el registro

Eliminamos 20 segundos de la coda y los rellenamos con ceros, que es como muchos archivos (y un merge descuidado) entregan las interrupciones. Luego filtramos derecho a través del hueco, como si nada hubiera pasado.

# Inject a 20 s dropout into the coda, zero-filled
gap_start, gap_end = 850.0, 870.0
i0, i1 = int(gap_start * fs), int(gap_end * fs)
z_gap = z.astype(float).copy()
z_gap[i0:i1] = 0.0

zf_gap = signal.sosfiltfilt(sos, z_gap)  # naive: filter straight across the gap

fig, ax = plt.subplots(2, 1, figsize=(11, 6), sharex=True)
ax[0].plot(t, z_gap, lw=0.8)
ax[0].set_ylabel('Counts'); ax[0].set_title('Raw record with a zero-filled 20 s dropout')
ax[1].plot(t, zf, color='gray', lw=0.8, label='filtered complete record (truth)')
ax[1].plot(t, zf_gap, color='tab:red', lw=0.8, alpha=0.8, label='filtered across the gap')
ax[1].set_ylabel('Counts'); ax[1].set_xlabel('Time in seconds'); ax[1].legend()
for a in ax:
    a.axvspan(gap_start, gap_end, color='k', alpha=0.08)
    a.set_xlim([830, 890]); a.grid(True)
plt.tight_layout()
plt.show()

# Grade against the complete-record reference, by distance from the gap edges
print('distance from gap   local signal RMS   naive filtering error')
for lo, hi in [(0, 2), (2, 5), (5, 10)]:
    w = (((t >= gap_start - hi) & (t < gap_start - lo))
         | ((t >= gap_end + lo) & (t < gap_end + hi)))
    srms = np.sqrt(np.mean(zf[w] ** 2))
    err = np.sqrt(np.mean((zf_gap[w] - zf[w]) ** 2))
    print(f'  {lo}-{hi} s           {srms:7.0f} counts     {err:8.0f} counts '
          f'({err / srms:7.2f}x the signal)')
<Figure size 1100x600 with 2 Axes>
distance from gap   local signal RMS   naive filtering error
  0-2 s                70 counts        12998 counts ( 185.13x the signal)
  2-5 s                74 counts            1 counts (   0.02x the signal)
  5-10 s                62 counts            0 counts (   0.00x the signal)

El daño está fuera de toda proporción con el hueco. El relleno con ceros crea dos discontinuidades de escalón cuya altura es la amplitud cruda — dominada aquí por las ondas superficiales de periodo largo, decenas de miles de cuentas —, mientras que la señal verdadera de 1–10 Hz en esta parte de la coda está por debajo de las cien cuentas. Un escalón es de banda ancha, así que el filtro responde con su propio repique en las frecuencias de esquina: a menos de dos segundos de cada borde, la salida es más de cien veces la señal, y dentro del hueco la salida ingenua muestra oscilaciones de aspecto plausible que son puro artefacto del filtro. Como en la lección 2.6, las muestras fabricadas son las peligrosas — parecen datos.

Ningún filtro puede resucitar los 20 segundos que nunca se registraron; reparar significa confinar el daño. La corrección es el filtrado por segmentos: filtrar por separado cada tramo contiguo de datos reales y dejar el hueco como NaN. sosfiltfilt rellena cada segmento internamente (reflexión impar alrededor de los extremos), así que ningún segmento ve nunca un escalón.

def filter_with_gaps(x, bad, sos):
    """Zero-phase filter each contiguous valid segment separately.

    The gap itself stays NaN: declared missing, not repaired."""
    out = np.full(len(x), np.nan)
    good_idx = np.flatnonzero(~bad)
    segments = np.split(good_idx, np.flatnonzero(np.diff(good_idx) > 1) + 1)
    for seg in segments:
        out[seg] = signal.sosfiltfilt(sos, x[seg].astype(float))
    return out

bad = np.zeros(len(z), dtype=bool)
bad[i0:i1] = True
zf_seg = filter_with_gaps(z, bad, sos)

fig, ax = plt.subplots(figsize=(11, 4))
ax.plot(t, zf, color='gray', lw=0.8, label='filtered complete record (truth)')
ax.plot(t, zf_seg, color='tab:blue', lw=0.8, alpha=0.8, label='segment-wise filtered')
ax.axvspan(gap_start, gap_end, color='k', alpha=0.08)
ax.set_xlim([830, 890]); ax.grid(True); ax.legend()
ax.set_xlabel('Time in seconds'); ax.set_ylabel('Counts')
ax.set_title('Segment-wise filtering: the gap stays a gap')
plt.tight_layout()
plt.show()

print('distance from gap   naive error         segment-wise error')
for lo, hi in [(0, 2), (2, 5), (5, 10)]:
    w = (((t >= gap_start - hi) & (t < gap_start - lo))
         | ((t >= gap_end + lo) & (t < gap_end + hi)))
    srms = np.sqrt(np.mean(zf[w] ** 2))
    en = np.sqrt(np.mean((zf_gap[w] - zf[w]) ** 2))
    es = np.sqrt(np.nanmean((zf_seg[w] - zf[w]) ** 2))
    print(f'  {lo}-{hi} s          {en / srms:8.2f}x signal    {es / srms:8.4f}x signal')
<Figure size 1100x400 with 1 Axes>
distance from gap   naive error         segment-wise error
  0-2 s            185.13x signal      1.7491x signal
  2-5 s              0.02x signal      0.0005x signal
  5-10 s              0.00x signal      0.0000x signal

Calificado contra el registro completo: el filtrado por segmentos reduce el error cerca de los bordes en dos órdenes de magnitud, y más allá de dos segundos del hueco coincide con la verdad a mejor que una fracción de porcentaje. La contaminación residual abarca aproximadamente dos segundos — unos pocos periodos de la frecuencia más baja de la banda de paso (1 Hz), que es la memoria del filtro. La receta práctica: filtre por segmentos, mantenga los huecos como faltantes y marque en sus metadatos un intervalo de resguardo de unos pocos periodos del filtro en cada borde de segmento, para que los usuarios posteriores (o su propio código de extracción de características en 2.11) sepan en qué muestras confiar.

Un error de reloj

La segunda patología deja la forma de onda perfecta y corrompe solo las marcas de tiempo. La deriva de los relojes GPS, los errores de segundo intercalar y los reinicios del digitalizador producen rutinariamente errores de tiempo por debajo del segundo — invisibles a simple vista en cualquier gráfico, y fatales para las lecturas de fases, la tomografía por correlación cruzada y cualquier etiqueta de ML derivada de tiempos de llegada. Simulamos un instrumento cuyo reloj atrasa medio segundo y recuperamos el desfase de la manera estándar: correlación cruzada contra una referencia y lectura del retardo del pico.

offset_true = 0.5  # seconds = 50 samples at 100 Hz
z_late = np.roll(z, int(offset_true * fs))  # same ground motion, stamped 0.5 s late
zf_late = signal.sosfiltfilt(sos, z_late.astype(float))

# Cross-correlate a 60 s window spanning the P arrival
w0, w1 = int(740 * fs), int(800 * fs)
a, b = zf[w0:w1], zf_late[w0:w1]
cc = signal.correlate(b, a, mode='full')
lags = signal.correlation_lags(len(b), len(a), mode='full') / fs
lag_best = lags[np.argmax(cc)]

fig, ax = plt.subplots(2, 1, figsize=(11, 6))
ax[0].plot(t, zf, label='reference clock')
ax[0].plot(t, zf_late, label='faulty clock (+0.5 s)', alpha=0.8)
ax[0].set_xlim([754, 760]); ax[0].grid(True); ax[0].legend()
ax[0].set_ylabel('Counts'); ax[0].set_xlabel('Time in seconds')
ax[0].set_title('The same P wave under two clocks')
ax[1].plot(lags, cc / cc.max())
ax[1].axvline(lag_best, color='r', ls='--', label=f'peak at {lag_best:+.2f} s')
ax[1].set_xlim([-2, 2]); ax[1].grid(True); ax[1].legend()
ax[1].set_xlabel('Lag (s)'); ax[1].set_ylabel('Normalized cross-correlation')
plt.tight_layout()
plt.show()

print(f'injected clock offset:  {offset_true:+.2f} s')
print(f'recovered from the cross-correlation peak: {lag_best:+.2f} s')
<Figure size 1100x600 with 2 Axes>
injected clock offset:  +0.50 s
recovered from the cross-correlation peak: +0.50 s

El pico de la correlación cruzada queda exactamente en el desfase inyectado: los errores de tiempo que ninguna inspección visual detectaría son recuperables a la muestra, y a una fracción de muestra si interpola el pico de correlación (ajuste una parábola por los tres puntos alrededor del máximo). Con datos reales, la referencia es un sensor colocalizado, una estación vecina corregida por la diferencia de tiempo de viaje predicha o — para los métodos de ruido ambiental — el promedio de largo plazo de la propia correlación del ruido, que es como las redes modernas monitorean continuamente la deriva de los relojes. La versión de cadencia diaria de esta patología (una serie GNSS que reporta el campo en el día equivocado) es uno de los defectos inyectables de mlgeo_synth.degrade_series, y la lógica de reparación es la misma: detectar por correlación contra una referencia y luego desplazar.

Conclusión

Filtrar series de tiempo es un paso rutinario del análisis geocientífico. Aísla bandas de frecuencia, remueve ruido y revela tendencias — pero separa bandas, no componentes físicas, y cada elección del filtro (frecuencias de esquina, orden, causal frente a fase cero) deja una firma en la salida. Conozca esa firma antes de interpretar el resultado. Y antes de filtrar siquiera, revise el registro mismo: filtre a través de un hueco relleno de ceros y el artefacto pesará más que la señal; confíe en una marca de tiempo sin una referencia y un error de medio segundo viajará en silencio hacia cada producto posterior.