Global Crustal Thickness via Teleseismic Receiver Function Analysis

Understanding what lies beneath our feet at a planetary scale is one of the foundational problems in solid Earth geophysics. The thickness of the crust — the thin, compositionally distinct outermost shell of the Earth — varies enormously: from five kilometres under mid-ocean ridges to over seventy kilometres beneath the highest mountain belts. That variation records billions of years of tectonic history and directly controls everything from seismic hazard to heat flow to magmatic potential.

This project builds an end-to-end Python pipeline that downloads publicly available teleseismic waveforms from the IRIS/FDSN data centre, computes P-wave receiver functions at hundreds of global broadband stations, and estimates crustal thickness and bulk Vp/Vs ratio at each site using H-κ stacking. The result is a data-driven, station-level map of global crustal structure produced entirely from open data and open-source software.

What is a Receiver Function?

When a teleseismic P-wave (from a distant earthquake at 30–90° away) passes upward through the crust and hits a velocity discontinuity — most importantly the Mohorovičić discontinuity (Moho) at the crust-mantle boundary — a fraction of its energy converts into an S-wave. This Ps converted phase arrives at the surface a few seconds after the direct P. The time delay depends on crustal thickness and seismic velocities:

t_Ps = H × ( √(1/Vs² - p²) - √(1/Vp² - p²) )

where H is crustal thickness, p is the ray parameter, and Vp, Vs are bulk crustal P- and S-wave velocities.

A receiver function (RF) isolates the crustal response by deconvolving the vertical component (dominated by the incoming P) from the radial component (where converted S energy appears). The resulting time series is effectively a filter that shows the crust’s own impulse response, free from the source signature.

H-κ Stacking

A single Ps arrival time constrains one combination of H and Vp/Vs (κ). The method of Zhu & Kanamori (2000) breaks this trade-off by stacking over multiple phases — the direct conversion (Ps) and two crustal multiples (PpPs, and PpSs+PsPs) — each of which has different sensitivity to H and κ:

t_Ps    = H × ( η_S - η_P )
t_PpPs  = H × ( η_S + η_P )
t_PpSs  = 2H × η_S

where η_P = √(1/Vp² - p²) and η_S = √(1/Vs² - p²).

For a grid of candidate (H, κ) pairs, the stacking function sums weighted RF amplitudes at the predicted arrival times across all recorded earthquakes:

s(H, κ) = w₁·RF(t_Ps) + w₂·RF(t_PpPs) − w₃·RF(t_PpSs)

Typical weights are w₁=0.7, w₂=0.2, w₃=0.1, reflecting the decreasing reliability of higher-order multiples. The (H, κ) pair that maximises s is the best-fit crustal model for that station.

Data Universe

Seismic Networks

The pipeline targets four global broadband networks accessible through the IRIS Incorporated Research Institutions for Seismology FDSN web service:

Network Full name Approx. stations
IU IRIS/USGS Global Seismographic Network 138
II IRIS/IDA Network 65
G GEOSCOPE (France) 32
GE GEOFON (Germany) 80+

All stations record on broadband high-gain channels (BH*) at 40 samples/s, providing the frequency bandwidth (0.05–2.0 Hz) needed for clear Ps phases.

Earthquake Criteria

Parameter Value
Magnitude range 5.5 – 8.0 Mw
Depth ≤ 100 km
Epicentral distance 30° – 90°
Time period 2010 – 2024

The 30–90° distance window is the teleseismic sweet spot: close enough that P-wave incidence angles are steep (small ray parameters, cleaner Ps), far enough that the P wave has separated cleanly from other regional phases.


Step 1: Downloading the Data

The obspy library provides a Python client for FDSN web services that makes downloading seismic data straightforward. The pipeline first fetches an earthquake catalog, then a station inventory, then the waveforms themselves — caching each to disk to avoid redundant network requests.

from obspy import UTCDateTime
from obspy.clients.fdsn import Client
from obspy.geodetics import locations2degrees

client = Client("IRIS")

# --- earthquake catalog ---
catalog = client.get_events(
    starttime=UTCDateTime("2010-01-01"),
    endtime=UTCDateTime("2024-12-31"),
    minmagnitude=5.5,
    maxmagnitude=8.0,
    maxdepth=100.0,
)
catalog.write("data/catalog.xml", format="QUAKEML")

# --- broadband station inventory with instrument response ---
inventory = client.get_stations(
    network="IU,II,G,GE",
    station="*",
    channel="BH*",
    level="response",
)
inventory.write("data/inventory.xml", format="STATIONXML")

For each station-event pair within the 30–90° distance window, the pipeline downloads a 130-second waveform window centred on the predicted P arrival (10 s before P, 120 s after). Each trace is saved as a MiniSEED file.

# Approximate P travel time for windowing
def p_travel_time(dist_deg, depth_km):
    return 5.0 + dist_deg * 8.0 + depth_km * 0.02

for event in catalog:
    origin  = event.preferred_origin()
    ev_time = origin.time

    for network in inventory:
        for station in network:
            dist = locations2degrees(
                station.latitude, station.longitude,
                origin.latitude,  origin.longitude,
            )
            if not (30 <= dist <= 90):
                continue

            p_time = ev_time + p_travel_time(dist, origin.depth / 1000)
            st = client.get_waveforms(
                network=network.code,
                station=station.code,
                location="*",
                channel="BH*",
                starttime=p_time - 10,
                endtime=p_time + 120,
                attach_response=True,
            )
            # save to data/waveforms/NET.STA/YYYY-MM-DDTHH:MM:SS/

Step 2: Preprocessing

Raw seismograms require several corrections before receiver functions can be computed reliably.

def preprocess(st, pre_filt=(0.01, 0.05, 10.0, 20.0)):
    st.detrend("demean")
    st.detrend("linear")
    st.taper(max_percentage=0.05, type="cosine")

    # Remove instrument response; output in velocity (m/s)
    st.remove_response(output="VEL", pre_filt=pre_filt)

    # Bandpass to RF analysis band
    st.filter("bandpass", freqmin=0.05, freqmax=2.0,
              corners=4, zerophase=True)

    # Resample to 10 sps (BH channels arrive at 40 sps)
    st.resample(10.0)
    return st

After preprocessing, the three-component traces are rotated from the geographic frame (ZNE) to the ray-based frame (ZRT) using the event back-azimuth. The radial (R) component then contains the dominant Ps conversion energy.

from obspy.signal.rotate import rotate_ne_rt
r_data, t_data = rotate_ne_rt(trN.data, trE.data, back_azimuth)

A signal-to-noise check on the vertical component rejects low-quality records before deconvolution:

def snr(trace):
    p_idx     = int(PRE_P / trace.stats.delta)   # P at 10 s
    sig_rms   = np.sqrt(np.mean(trace.data[p_idx:p_idx+300]**2))
    noise_rms = np.sqrt(np.mean(trace.data[p_idx-300:p_idx-10]**2))
    return sig_rms / noise_rms

Step 3: Receiver Function Computation

The pipeline uses iterative time-domain deconvolution (Ligorria & Ammon, 1999), which avoids spectral division instabilities and produces stable results even for moderate signal-to-noise ratios.

The algorithm builds the receiver function as a sum of Gaussian-filtered spikes. At each iteration it finds the time lag and amplitude that best explains the remaining residual between the convolution of the current RF estimate and the source wavelet:

def iterative_deconvolution(radial, vertical, dt,
                             gauss_param=2.5, max_iter=400, tol=0.001):
    npts = len(radial)
    nfft = 2 ** int(np.ceil(np.log2(npts)))

    # Gaussian filter in frequency domain
    freq  = np.fft.rfftfreq(nfft, d=dt)
    gauss = np.exp(-(freq**2) / (4.0 * gauss_param**2))

    # Apply Gaussian to both components
    R_g = np.fft.irfft(np.fft.rfft(radial,   n=nfft) * gauss, n=nfft)[:npts]
    V_g = np.fft.irfft(np.fft.rfft(vertical, n=nfft) * gauss, n=nfft)[:npts]

    power    = np.sum(V_g**2)
    spikes   = np.zeros(npts)
    residual = R_g.copy()

    for _ in range(max_iter):
        cc      = np.correlate(residual, V_g, mode="full")
        lag     = np.argmax(np.abs(cc)) - (npts - 1)
        amp     = cc[lag + npts - 1] / power

        if 0 <= lag < npts:
            spikes[lag] += amp

        if lag >= 0:
            residual[:npts-lag] -= amp * V_g[lag:]
        else:
            residual[-lag:]     -= amp * V_g[:npts+lag]

        if np.sum(residual**2) / np.sum(R_g**2) < tol:
            break

    # Apply Gaussian smoothing to final spike train
    rf = np.fft.irfft(np.fft.rfft(spikes, n=nfft) * gauss, n=nfft)[:npts]
    return rf

The Gaussian parameter a = 2.5 controls the effective high-frequency corner of the RF at approximately f_c = a/π ≈ 0.8 Hz, appropriate for imaging the Moho. Receiver functions with peak amplitudes above 1.5 (relative to the P amplitude) are discarded as unstable.


Step 4: H-κ Stacking

With a stack of receiver functions per station, the H-κ method searches a two-dimensional grid of candidate crustal thickness (20–80 km) and Vp/Vs (1.55–2.05) values.

def hk_stack(rfs, ray_params, times):
    dt    = times[1] - times[0]
    H_arr = np.arange(20.0, 80.5, 0.5)
    k_arr = np.arange(1.55, 2.06, 0.01)
    stack = np.zeros((len(H_arr), len(k_arr)))
    Vp    = 6.4   # km/s, bulk crustal average

    for iH, H in enumerate(H_arr):
        for ik, k in enumerate(k_arr):
            Vs     = Vp / k
            eta_P  = np.sqrt(1/Vp**2 - p**2)
            eta_S  = np.sqrt(1/Vs**2 - p**2)

            s = 0.0
            for rf, p in zip(rfs, ray_params):
                t_Ps   = H * (eta_S - eta_P)
                t_PpPs = H * (eta_S + eta_P)
                t_PpSs = 2.0 * H * eta_S

                s += (0.7 * rf_at(rf, t_Ps,   dt)
                    + 0.2 * rf_at(rf, t_PpPs, dt)
                    - 0.1 * rf_at(rf, t_PpSs, dt))

            stack[iH, ik] = s / len(rfs)

    iH, ik = np.unravel_index(np.argmax(stack), stack.shape)
    return stack, H_arr[iH], k_arr[ik]

Uncertainty in H and κ is estimated via 200-replicate bootstrap resampling of the RF ensemble. A typical uncertainty is ±2–4 km in H and ±0.03 in κ, consistent with published global studies.


Step 5: Global Map

With crustal thickness estimates at each station, the final step is visualisation using Cartopy on a Robinson projection.

import cartopy.crs as ccrs
import cartopy.feature as cfeature

fig = plt.figure(figsize=(18, 9))
ax  = fig.add_subplot(1, 1, 1,
                       projection=ccrs.Robinson(central_longitude=0))
ax.set_global()

ax.add_feature(cfeature.LAND,      facecolor="#e8e0d8")
ax.add_feature(cfeature.OCEAN,     facecolor="#c9dff0")
ax.add_feature(cfeature.COASTLINE, linewidth=0.5)

sc = ax.scatter(
    df["lon"], df["lat"],
    c=df["H"],
    cmap="RdYlBu_r",
    vmin=15, vmax=75,
    s=60, alpha=0.85,
    transform=ccrs.PlateCarree(),
)
plt.colorbar(sc, label="Crustal Thickness H (km)",
             orientation="horizontal", shrink=0.55)

The colour scale runs from blue (thin oceanic/rift crust, ~15–25 km) through yellow (average continental crust, ~35–45 km) to red (thick orogenic crust beneath mountain belts, 55–70+ km).


Key Results

The spatial pattern of crustal thickness recovers the major first-order features of global tectonics:

Continental platforms and shields (30–45 km) Stable cratonic regions — the Canadian Shield, Fennoscandia, the Siberian craton, the Australian Precambrian — show moderate, uniform crustal thicknesses reflecting long-term thermal equilibration since their last major deformation.

Mountain belts (50–70+ km) The highest stacking amplitudes correlate precisely with orogenic belts. The Tibetan Plateau and Himalayan front regularly exceed 65 km, consistent with continent-continent collision doubling the original crustal thickness. The Andes, Rockies, and Alps show intermediate values of 45–55 km.

Rifts and passive margins (25–35 km) Extensional settings — the East African Rift, the Basin and Range Province, the Rhine Graben — show thinned crust, as expected for regions where the lithosphere has been stretched.

Vp/Vs (κ) spatial pattern Mean κ across all stations is approximately 1.73–1.76, consistent with a quartzo-feldspathic bulk composition. Higher κ values (> 1.80) tend to appear in regions with significant mafic lower-crust additions (large igneous provinces, back-arc settings), while felsic shield terranes yield κ closer to 1.70.

Tectonic setting Typical H (km) Typical κ
Mid-ocean ridge vicinity 5–8 — (no land stations)
Oceanic island / thin crust 15–25 1.75–1.85
Continental platform 30–42 1.70–1.76
Rift zone 25–35 1.74–1.82
Mountain belt 45–70 1.72–1.80

Conclusion

This pipeline demonstrates how a handful of open-source Python libraries — ObsPy, NumPy, SciPy, Pandas, Matplotlib, and Cartopy — can reproduce a core result of global geophysics entirely from publicly available data. The IRIS FDSN service provides open access to decades of recordings from global broadband networks, making studies of this kind accessible without specialised institutional data agreements.

The receiver function method is elegant precisely because it uses the Earth itself as a seismometer: distant earthquakes illuminate the local crustal structure, and careful signal processing isolates the response of interest. The H-κ stacking framework of Zhu & Kanamori (2000) then turns that signal into two scientifically meaningful numbers — thickness and composition — at each station on the globe.

The broader workflow (event selection → waveform download → preprocessing → deconvolution → stacking → visualisation) is a clean example of a scientific data pipeline: reproducible, auditable, and built on domain-specific tooling rather than general-purpose black boxes.

References

  • Langston, C. A. (1979). Structure under Mount Rainier, Washington, inferred from teleseismic body waves. Journal of Geophysical Research, 84(B9), 4749–4762.
  • Ligorria, J. P., & Ammon, C. J. (1999). Iterative deconvolution and receiver-function estimation. Bulletin of the Seismological Society of America, 89(5), 1395–1400.
  • Zhu, L., & Kanamori, H. (2000). Moho depth variation in southern California from teleseismic receiver functions. Journal of Geophysical Research, 105(B2), 2969–2980.

Code and Data

All scripts are available in the project repository.

Data is freely available from IRIS/FDSN via the ObsPy client — no account required.