| name | obspy-seismology |
| description | Use this Skill for seismological analysis with ObsPy: waveform download, filtering, P/S phase picking, moment magnitude, and spectral analysis.
|
| tags | ["earth-science","seismology","obspy","waveform","earthquake"] |
| version | 1.0.0 |
| authors | [{"name":"Rosetta Skills Contributors","github":"@xjtulyc"}] |
| license | MIT |
| platforms | ["claude-code","codex","gemini-cli","cursor"] |
| dependencies | {"python":["obspy>=1.4","numpy>=1.24","scipy>=1.11","matplotlib>=3.7"]} |
| last_updated | 2026-03-17 |
| status | stable |
Seismological Analysis with ObsPy
One-line summary: Download, process, and analyze seismic waveforms with ObsPy: bandpass filtering, P/S phase picking, spectral analysis, focal mechanisms, and moment magnitude estimation.
When to Use This Skill
- When downloading seismic waveforms from IRIS/FDSN data centers
- When filtering and deconvolving instrument response from seismograms
- When picking P and S wave arrivals automatically or manually
- When computing seismic spectra and corner frequencies
- When estimating moment magnitude from seismic records
- When computing traveltimes using 1D Earth models (iasp91, PREM)
Trigger keywords: ObsPy, seismology, seismogram, waveform, earthquake, P-wave, S-wave, FDSN, IRIS, bandpass filter, instrument response, moment magnitude, seismic, travel time
Background & Key Concepts
Seismic Wave Types
- P-waves: Compressional (primary) waves, fastest, arrive first
- S-waves: Shear waves, ~60% of P velocity, arrive second
- Surface waves: Rayleigh and Love waves, slowest, largest amplitude
Instrument Response Removal
Raw data in counts → ground motion (m, m/s, or m/s²) by deconvolving the instrument response:
$$
U(\omega) = \frac{X(\omega)}{I(\omega)}
$$
where $X(\omega)$ is the raw spectrum and $I(\omega)$ is the instrument response (poles/zeros + sensitivity).
Moment Magnitude
$$
M_w = \frac{2}{3}\log_{10}(M_0) - 10.7
$$
where $M_0$ is the seismic moment (N·m), estimated from the plateau of the displacement spectrum.
Environment Setup
Install Dependencies
pip install obspy>=1.4 numpy>=1.24 scipy>=1.11 matplotlib>=3.7
Verify Installation
import obspy
from obspy import read_events
print(f"ObsPy version: {obspy.__version__}")
from obspy import Trace, Stream
import numpy as np
t = np.linspace(0, 10, 1000)
data = np.sin(2 * np.pi * 5 * t) * np.exp(-t/3)
tr = Trace(data=data)
tr.stats.sampling_rate = 100.0
tr.stats.network = "XX"; tr.stats.station = "TEST"
print(f"Test trace: {tr}")
Core Workflow
Step 1: Download Waveforms and Remove Instrument Response
from obspy import UTCDateTime
from obspy.clients.fdsn import Client
import matplotlib.pyplot as plt
import numpy as np
client = Client("IRIS")
event_time = UTCDateTime("2011-03-11T05:46:24")
origin_lat, origin_lon = 38.297, 142.373
network, station = "IU", "MAJO"
starttime = event_time - 60
endtime = event_time + 600
print(f"Downloading waveforms for {network}.{station}...")
try:
st = client.get_waveforms(
network=network, station=station, location="00",
channel="BH?",
starttime=starttime, endtime=endtime,
)
print(f"Downloaded: {st}")
inv = client.get_stations(
network=network, station=station,
starttime=starttime, endtime=endtime,
level="response",
)
print(f"Inventory: {inv}")
except Exception as e:
print(f"Download failed: {e}")
print("Creating synthetic data for demonstration...")
from obspy import Trace, Stream, Inventory
import numpy as np
dt = 0.01; N = 66000
t = np.arange(N) * dt
p_wave = np.zeros(N); s_wave = np.zeros(N)
p_idx = int(100/dt)
s_idx = int(180/dt)
p_wave[p_idx:p_idx+500] = np.sin(2*np.pi*1.5*t[:500]) * np.exp(-t[:500]/10)
s_wave[s_idx:s_idx+800] = np.sin(2*np.pi*0.8*t[:800]) * np.exp(-t[:800]/15)
noise = np.random.randn(N) * 0.02
st = Stream()
for ch, amp in [("BHZ", 1.0), ("BHN", 0.7), ("BHE", 0.5)]:
tr = Trace(data=(p_wave + s_wave + noise) * amp)
tr.stats.network = network; tr.stats.station = station
tr.stats.channel = ch; tr.stats.sampling_rate = 100.0
tr.stats.starttime = starttime
st.append(tr)
inv = None
st_raw = st.copy()
st.detrend('demean')
st.detrend('linear')
st.taper(max_percentage=0.05, type='cosine')
if inv is not None:
st.remove_response(inventory=inv, output='VEL',
pre_filt=(0.005, 0.01, 40, 45))
y_unit = "Velocity (m/s)"
else:
st.filter('bandpass', freqmin=0.01, freqmax=10.0)
y_unit = "Counts (filtered)"
fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)
t_axis = np.arange(len(st[0].data)) * st[0].stats.delta
t0 = float(event_time - starttime)
for ax, tr in zip(axes, st):
ax.plot(t_axis, tr.data, 'k-', linewidth=0.6)
ax.axvline(t0, color='red', linestyle='--', linewidth=1.5, label='Origin time')
ax.set_ylabel(f"{tr.stats.channel}\n{y_unit}", fontsize=9)
ax.grid(True, alpha=0.3)
ax.legend(loc='upper right', fontsize=8)
axes[-1].set_xlabel("Time after start (s)")
plt.suptitle(f"Seismogram: {network}.{station} | 2011 Tohoku M9.0")
plt.tight_layout()
plt.savefig("seismogram.png", dpi=150)
plt.show()
Step 2: Phase Picking and Travel Time Calculation
from obspy import UTCDateTime, Stream
from obspy.taup import TauPyModel
import numpy as np
import matplotlib.pyplot as plt
model = TauPyModel(model="iasp91")
source_depth_km = 25.0
dist_deg = 15.0
arrivals = model.get_travel_times(
source_depth_in_km=source_depth_km,
distance_in_degree=dist_deg,
phase_list=["P", "pP", "PP", "S", "SS", "Rayleigh", "Love"],
)
print(f"Theoretical travel times (Δ={dist_deg}°, h={source_depth_km} km):")
print(f"{'Phase':12s} {'Time (s)':12s} {'Slowness':12s}")
print("-" * 40)
for arr in arrivals:
print(f"{arr.name:12s} {arr.time:12.2f} {arr.ray_param_sec_degree:12.4f}")
from obspy.signal.trigger import classic_sta_lta, trigger_onset
np.random.seed(42)
sps = 100.0
N = int(300 * sps)
t_arr = np.arange(N) / sps
data = np.random.randn(N) * 0.5
p_onset = int(100 * sps)
data[p_onset:p_onset+int(50*sps)] += (
np.sin(2*np.pi*3*t_arr[:int(50*sps)]) * np.exp(-t_arr[:int(50*sps)]/8) * 10
)
s_onset = int(180 * sps)
data[s_onset:s_onset+int(80*sps)] += (
np.sin(2*np.pi*1.5*t_arr[:int(80*sps)]) * np.exp(-t_arr[:int(80*sps)]/15) * 15
)
cft = classic_sta_lta(data, int(0.5*sps), int(10*sps))
on_off = trigger_onset(cft, thres1=3.5, thres2=0.5)
print(f"\nSTA/LTA detected {len(on_off)} trigger(s):")
for on, off in on_off:
print(f" ON at t={on/sps:.2f}s, OFF at t={off/sps:.2f}s")
fig, axes = plt.subplots(2, 1, figsize=(14, 6), sharex=True)
axes[0].plot(t_arr, data, 'k-', linewidth=0.5)
for on, _ in on_off:
axes[0].axvline(on/sps, color='red', linewidth=1.5, linestyle='--')
axes[0].set_ylabel("Amplitude"); axes[0].set_title("Seismogram + Phase Picks")
axes[0].grid(True, alpha=0.3)
axes[1].plot(t_arr, cft, 'b-', linewidth=1)
axes[1].axhline(3.5, color='red', linestyle='--', label='ON threshold')
axes[1].axhline(0.5, color='orange', linestyle='--', label='OFF threshold')
axes[1].set_ylabel("STA/LTA ratio"); axes[1].set_xlabel("Time (s)")
axes[1].set_title("Characteristic Function (STA/LTA)")
axes[1].legend(); axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("phase_picking.png", dpi=150)
plt.show()
Step 3: Spectral Analysis and Magnitude Estimation
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import welch
from scipy.optimize import curve_fit
np.random.seed(0)
sps = 100.0
N = int(60 * sps)
t = np.arange(N) / sps
t_r = 2.0
signal = np.zeros(N)
onset = int(5 * sps)
pulse_t = t[:N-onset]
signal[onset:] = pulse_t * np.exp(-pulse_t / t_r)
signal += np.random.randn(N) * 1e-8
freq = np.fft.rfftfreq(N, d=1/sps)
spec = np.abs(np.fft.rfft(signal)) / sps
freq_fit = freq[(freq > 0.05) & (freq < 20)]
spec_fit = spec[(freq > 0.05) & (freq < 20)]
def brune_model(f, omega0, fc):
return omega0 / (1 + (f/fc)**2)
try:
popt, _ = curve_fit(brune_model, freq_fit, spec_fit,
p0=[spec_fit.max(), 1.0], maxfev=5000)
omega0_hat, fc_hat = popt
print(f"Brune fit: Ω₀ = {omega0_hat:.4e}, fc = {fc_hat:.3f} Hz")
rho = 2700.0
v_p = 6000.0
R = 100e3
F_p = 0.52
M0 = 4 * np.pi * rho * v_p**3 * R * omega0_hat / (2 * F_p)
Mw = (2/3) * np.log10(M0) - 10.7
print(f"Seismic moment M₀ = {M0:.3e} N·m")
print(f"Moment magnitude Mw = {Mw:.2f}")
except Exception as e:
print(f"Fit failed: {e}")
omega0_hat, fc_hat = spec_fit.max(), 1.0
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
axes[0].plot(t, signal * 1e6, 'k-', linewidth=0.8)
axes[0].set_xlabel("Time (s)"); axes[0].set_ylabel("Displacement (μm)")
axes[0].set_title("Ground Displacement Seismogram"); axes[0].grid(True, alpha=0.3)
f_smooth = np.logspace(np.log10(0.05), np.log10(20), 200)
axes[1].loglog(freq_fit, spec_fit, 'b.', markersize=3, alpha=0.5, label='Data')
axes[1].loglog(f_smooth, brune_model(f_smooth, omega0_hat, fc_hat),
'r-', linewidth=2.5, label=f'Brune fit (fc={fc_hat:.2f} Hz)')
axes[1].axvline(fc_hat, color='gray', linestyle='--', linewidth=1, alpha=0.7)
axes[1].set_xlabel("Frequency (Hz)"); axes[1].set_ylabel("Spectral amplitude")
axes[1].set_title("Displacement Spectrum — Brune Source Model")
axes[1].legend(); axes[1].grid(True, which='both', alpha=0.3)
plt.tight_layout()
plt.savefig("seismic_spectrum.png", dpi=150)
plt.show()
Advanced Usage
Receiver Function Computation
from obspy import Stream, Trace
import numpy as np
from scipy.signal import correlate, fftconvolve
def compute_receiver_function(Z_data, R_data, dt, water_level=0.01, f_gauss=2.5):
"""
Estimate P-to-S receiver function via iterative time-domain deconvolution.
Parameters
----------
Z_data : array — vertical component (reference)
R_data : array — radial component (response)
dt : float — sample interval (s)
water_level : float — damping for spectral division
f_gauss : float — Gaussian filter width (Hz)
Returns
-------
rf : array — receiver function time series
t : array — time axis (s)
"""
N = len(Z_data)
freq = np.fft.rfftfreq(N, d=dt)
gauss = np.exp(-(freq/(2*f_gauss))**2)
Z_f = np.fft.rfft(Z_data)
R_f = np.fft.rfft(R_data)
denom = np.abs(Z_f)**2 + water_level * np.max(np.abs(Z_f)**2)
rf_f = R_f * np.conj(Z_f) / denom * gauss
rf = np.fft.irfft(rf_f, n=N)
t = np.arange(N) * dt - N*dt/2
return np.roll(rf, N//2), t
np.random.seed(42)
dt = 0.025
N = int(100/dt)
t_syn = np.arange(N) * dt - 50
from scipy.signal import ricker
Z = ricker(N, 10) + np.random.randn(N) * 0.01
ps_delay = int(5 / dt)
R = np.roll(ricker(N, 10), ps_delay) * 0.4 + np.random.randn(N) * 0.01
rf, t_rf = compute_receiver_function(Z, R, dt)
import matplotlib.pyplot as plt
fig, axes = plt.subplots(3, 1, figsize=(10, 7), sharex=False)
axes[0].plot(t_syn, Z, 'k-'); axes[0].set_title("Vertical (Z)"); axes[0].grid(alpha=0.3)
axes[1].plot(t_syn, R, 'b-'); axes[1].set_title("Radial (R)"); axes[1].grid(alpha=0.3)
mask = (t_rf > -5) & (t_rf < 20)
axes[2].plot(t_rf[mask], rf[mask], 'r-', linewidth=1.5)
axes[2].axvline(0, color='gray', linestyle='--'); axes[2].axhline(0, color='gray', linestyle='-', linewidth=0.5)
axes[2].set_title("Receiver Function"); axes[2].set_xlabel("Time (s)"); axes[2].grid(alpha=0.3)
plt.tight_layout(); plt.savefig("receiver_function.png", dpi=150); plt.show()
Troubleshooting
Error: No data available from FDSN client
Cause: Station or channel not available for the requested time window.
Fix:
from obspy.clients.fdsn import Client
client = Client("IRIS")
inv = client.get_stations(network="IU", station="MAJO", level="channel",
starttime=UTCDateTime("2011-01-01"),
endtime=UTCDateTime("2012-01-01"))
print(inv)
Error: NonLinearLSQError in Brune fit
Cause: Poor initial guess for curve_fit.
Fix:
p0 = [spec_fit[0], freq_fit[np.argmax(np.diff(np.log(spec_fit+1e-30)) < -0.1)]]
popt, _ = curve_fit(brune_model, freq_fit, spec_fit, p0=p0, maxfev=10000)
Version Compatibility
| Package | Tested versions | Notes |
|---|
| obspy | 1.4.x | Stable API; 1.4 added FDSN mass downloader |
| scipy | 1.11, 1.12 | curve_fit behavior stable |
| numpy | 1.24, 1.26 | No issues |
External Resources
Official Documentation
Key Papers
- Beyreuther, M. et al. (2010). ObsPy: A Python Toolbox for Seismology. Seismological Research Letters, 81(3), 530–533.
Examples
Example 1: Earthquake Catalog Download
from obspy import UTCDateTime
from obspy.clients.fdsn import Client
client = Client("USGS")
start = UTCDateTime("2023-01-01")
end = UTCDateTime("2023-12-31")
catalog = client.get_events(
starttime=start, endtime=end,
minmagnitude=6.0,
orderby="magnitude",
)
print(f"Found {len(catalog)} events (M≥6) in 2023:")
for event in catalog[:5]:
mag = event.magnitudes[0].mag
orig = event.origins[0]
print(f" M{mag:.1f} {orig.time.date} "
f"lat={orig.latitude:.2f}, lon={orig.longitude:.2f}, "
f"depth={orig.depth/1000:.1f} km")
Example 2: Waveform Filtering and Spectrogram
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import spectrogram
np.random.seed(1)
sps = 100.0
N = int(120 * sps)
t = np.arange(N) / sps
data = np.random.randn(N) * 0.1
p_idx = int(30 * sps)
data[p_idx:p_idx+int(20*sps)] += np.sin(2*np.pi*5*(t[:int(20*sps)])) * np.exp(-t[:int(20*sps)]/5)
s_idx = int(60 * sps)
data[s_idx:s_idx+int(40*sps)] += np.sin(2*np.pi*2*(t[:int(40*sps)])) * np.exp(-t[:int(40*sps)]/8) * 2
f_spec, t_spec, Sxx = spectrogram(data, fs=sps, nperseg=256, noverlap=200)
fig, axes = plt.subplots(2, 1, figsize=(12, 6), sharex=False)
axes[0].plot(t, data, 'k-', linewidth=0.5)
axes[0].axvline(30, color='r', linestyle='--', label='P arrival'); axes[0].axvline(60, color='b', linestyle='--', label='S arrival')
axes[0].set_ylabel("Amplitude"); axes[0].set_title("Seismogram"); axes[0].legend(); axes[0].grid(alpha=0.3)
axes[1].pcolormesh(t_spec, f_spec[f_spec<20], 10*np.log10(Sxx[f_spec<20]+1e-20), shading='gouraud', cmap='viridis')
axes[1].set_ylabel("Frequency (Hz)"); axes[1].set_xlabel("Time (s)"); axes[1].set_title("Spectrogram")
plt.tight_layout(); plt.savefig("spectrogram_seismic.png", dpi=150); plt.show()
Last updated: 2026-03-17 | Maintainer: @xjtulyc
Issues: GitHub Issues