Ray Tracing in Cartesian Coordinates#

Colab note: This notebook is designed to run on Google Colab. The first code cell installs dependencies. Open In Colab

Learning Objectives:

  • Implement Snell’s Law in 1D layered media

  • Compute ray parameters and takeoff angles

  • Calculate travel times for direct and refracted waves

  • Visualize ray paths in flat-layered models

Prerequisites: Basic Python, Trigonometry

Reference: Shearer, Chapter 4 (Ray Theory)

Notebook Outline:


This notebook implements the II. practicum: computing travel-time fields and ray paths in 2-D cross sections using the fast marching method (FMM) solver in pykonal, then exploring how layering, curvature, and low-velocity zones affect:

Hide code cell source

# Install dependencies (for Google Colab or missing packages)
import sys

# Check if running in Colab
try:
    import google.colab
    IN_COLAB = True
    print("Running in Google Colab")
except:
    IN_COLAB = False
    print("Running in local environment")

# Install required packages if needed
required_packages = {
    'numpy': 'numpy',
    'matplotlib': 'matplotlib',
    'scipy': 'scipy',
    'obspy': 'obspy'
}

missing_packages = []
for package, pip_name in required_packages.items():
    try:
        __import__(package)
        print(f"✓ {package} is already installed")
    except ImportError:
        missing_packages.append(pip_name)
        print(f"✗ {package} not found")

if missing_packages:
    print(f"\nInstalling missing packages: {', '.join(missing_packages)}")
    import subprocess
    subprocess.check_call([sys.executable, "-m", "pip", "install", "-q"] + missing_packages)
    print("✓ Installation complete!")
else:
    print("\n✓ All required packages are installed!")
# Test pykonal installation
try:
    import h5py
    print(f"✓ h5py version: {h5py.__version__}")
except ImportError as e:
    print(f"✗ h5py not found: {e}")
    print("Run: pixi install")

try:
    import pykonal
    print(f"✓ pykonal version: {pykonal.__version__}")
    print("✓ pykonal successfully imported!")
except ImportError as e:
    print(f"✗ pykonal not found: {e}")
    print("Run: pixi install")
np.infty = np.inf

0. Install dependencies (Colab)#

We install pykonal plus the usual scientific stack.

If the PyPI wheel install fails on Colab (rare, but can happen if compilation is needed), the cell includes a fallback to install from GitHub.

# # Colab install
# !pip -q install --upgrade pip
# !pip -q install numpy scipy matplotlib

# # Try PyPI first (fastest)
# try:
#     import pykonal  # noqa: F401
#     print("pykonal already available")
# except Exception:
#     !pip -q install pykonal
# #
# # Fallback (if needed): install a specific tag from GitHub
# try:
#     import pykonal
#     print("pykonal version:", getattr(pykonal, "__version__", "unknown"))
# except Exception as e:
#     print("PyPI install failed, trying GitHub install. Error:", e)
#     !pip -q install cython
#     !pip -q install "git+https://github.com/malcolmw/pykonal@0.4.1"
#     import pykonal
#     print("pykonal version:", getattr(pykonal, "__version__", "unknown"))
# !pip install pykonal

1. Imports and plotting helpers#

import numpy as np
import matplotlib.pyplot as plt
import pykonal

from dataclasses import dataclass
from typing import Tuple, Dict, Optional

plt.rcParams.update({
    "figure.dpi": 140,
    "savefig.dpi": 200,
    "axes.spines.top": False,
    "axes.spines.right": False,
})

2. Model setup#

We work on a 2-D grid in ((x,z)) with depth (z) positive downward.
PyKonal is formally 3-D, so we represent 2-D by setting ny=1 and embedding ((x,z)) into ((x,y,z)) with (y=0).

Coordinate conventions#

  • We store velocity on a regular grid with spacing (\Delta x, \Delta z) (km).

  • The source is a point at ((x_s, z_s)).

  • Receivers lie on the surface (z=0) for travel-time curve experiments.

What the solver returns#

  • solver.traveltime.values: a 3-D array of (T(x,y,z)) on the grid

  • solver.trace_ray(end): ray path as an array of points ((x,y,z)), found by steepest descent on (T)

@dataclass
class Grid2D:
    x_min: float
    z_min: float
    dx: float
    dz: float
    nx: int
    nz: int

    @property
    def x(self) -> np.ndarray:
        return self.x_min + self.dx * np.arange(self.nx)

    @property
    def z(self) -> np.ndarray:
        return self.z_min + self.dz * np.arange(self.nz)

    def mesh(self) -> Tuple[np.ndarray, np.ndarray]:
        X, Z = np.meshgrid(self.x, self.z, indexing="ij")  # (nx,nz)
        return X, Z


def make_solver_from_velocity(grid: Grid2D, v_xz: np.ndarray) -> pykonal.EikonalSolver:
    """
    Create a PyKonal EikonalSolver for a 2-D (x,z) model embedded in 3-D (x,y,z) with ny=1.
    v_xz must be shape (nx, nz) in km/s.
    """
    assert v_xz.shape == (grid.nx, grid.nz)

    solver = pykonal.EikonalSolver(coord_sys="cartesian")

    # PyKonal uses (x, y, z) order for cartesian.
    solver.velocity.min_coords = (grid.x_min, 0.0, grid.z_min)
    solver.velocity.node_intervals = (grid.dx, 1.0, grid.dz)
    solver.velocity.npts = (grid.nx, 1, grid.nz)

    # Embed v(x,z) into v(x,y,z) with ny=1
    vv = np.zeros((grid.nx, 1, grid.nz), dtype=float)
    vv[:, 0, :] = v_xz
    solver.velocity.values = vv

    return solver


def initialize_point_source(solver: pykonal.EikonalSolver, grid: Grid2D, xs: float, zs: float) -> Tuple[int, int, int]:
    """
    Place a point source at the nearest grid node to (xs,zs).
    Returns source index (ix, iy, iz).
    """
    ix = int(np.round((xs - grid.x_min) / grid.dx))
    iz = int(np.round((zs - grid.z_min) / grid.dz))
    ix = np.clip(ix, 0, grid.nx - 1)
    iz = np.clip(iz, 0, grid.nz - 1)

    src_idx = (ix, 0, iz)

    solver.traveltime.values[src_idx] = 0.0
    solver.unknown[src_idx] = False
    solver.trial.push(*src_idx)

    return src_idx


def solve_traveltime(grid: Grid2D, v_xz: np.ndarray, xs: float, zs: float) -> Tuple[pykonal.EikonalSolver, np.ndarray, Tuple[int,int,int]]:
    """
    Solve T(x,z) for a given velocity model and point source.
    Returns solver, T_xz (nx,nz), and src_idx.
    """
    solver = make_solver_from_velocity(grid, v_xz)
    src_idx = initialize_point_source(solver, grid, xs, zs)
    ok = solver.solve()
    if not ok:
        raise RuntimeError("PyKonal solver did not return success.")
    T = solver.traveltime.values[:, 0, :]  # (nx,nz)
    return solver, T, src_idx


def trace_rays_to_surface_receivers(
    solver: pykonal.EikonalSolver,
    grid: Grid2D,
    receiver_x: np.ndarray,
    receiver_z: float = 0.0,
    y_fixed: float = 0.0,
) -> Dict[float, np.ndarray]:
    """
    Trace rays from each receiver location (x, z=receiver_z) back to the source using solver.trace_ray(end).
    Returns dict mapping receiver x -> ray path array (N,3) with columns (x,y,z).
    """
    rays = {}
    for xr in receiver_x:
        end = np.array([float(xr), float(y_fixed), float(receiver_z)], dtype=float)
        ray = solver.trace_ray(end)  # coords, not indices
        rays[float(xr)] = ray
    return rays


def plot_velocity_and_rays(grid: Grid2D, v_xz: np.ndarray, rays: Optional[Dict[float, np.ndarray]] = None,
                           xs: Optional[float]=None, zs: Optional[float]=None, title: str="") -> None:
    X, Z = grid.mesh()
    fig, ax = plt.subplots(figsize=(10, 4.3))
    pm = ax.pcolormesh(X, Z, v_xz, shading="auto")
    ax.invert_yaxis()
    cbar = fig.colorbar(pm, ax=ax)
    cbar.set_label("Velocity (km/s)")

    if rays:
        for _, ray in rays.items():
            ax.plot(ray[:, 0], ray[:, 2], lw=1.8)  # x vs z

    if xs is not None and zs is not None:
        ax.scatter([xs], [zs], marker="*", s=120, edgecolor="k", zorder=10, label="Source")
        ax.legend(loc="upper right")

    ax.set_xlabel("x (km)")
    ax.set_ylabel("z (km)")
    ax.set_title(title)
    plt.show()


def plot_traveltime_contours(grid: Grid2D, T_xz: np.ndarray, title: str="") -> None:
    X, Z = grid.mesh()
    fig, ax = plt.subplots(figsize=(10, 4.3))
    cs = ax.contour(X, Z, T_xz, levels=25)
    ax.clabel(cs, inline=True, fontsize=8, fmt="%.1f")
    ax.invert_yaxis()
    ax.set_xlabel("x (km)")
    ax.set_ylabel("z (km)")
    ax.set_title(title)
    plt.show()


def travel_time_curve(T_xz: np.ndarray, grid: Grid2D, receiver_x: np.ndarray, receiver_z: float = 0.0) -> np.ndarray:
    """
    Extract T(x, z=receiver_z) at receiver_x (nearest node).
    """
    iz = int(np.round((receiver_z - grid.z_min)/grid.dz))
    iz = np.clip(iz, 0, grid.nz-1)
    ix = np.clip(np.round((receiver_x - grid.x_min)/grid.dx).astype(int), 0, grid.nx-1)
    return T_xz[ix, iz]

2.1 Velocity model builders#

We will use simple, controllable models so students can form hypotheses:

  • Depth-dependent gradient: (v(z) = v_0 + g z)

  • Layered: piecewise constant or piecewise linear

  • Curved layers: warp the interface depth as a function of (x)

  • Low-velocity zone (LVZ): Gaussian slow anomaly centered at ((x_c, z_c))

In all experiments, keep the grid spacing coarse enough to run fast in Colab, but fine enough to see ray curvature.

def v_gradient(grid: Grid2D, v0: float=5.5, grad: float=0.02) -> np.ndarray:
    """
    Simple depth gradient: v(x,z) = v0 + grad*z
    """
    X, Z = grid.mesh()
    return v0 + grad * Z


def v_layered(grid: Grid2D, layers: Tuple[Tuple[float, float], ...]) -> np.ndarray:
    """
    Piecewise constant layers.
    layers: tuple of (z_top, velocity) sorted by increasing z_top
    Example: ((0, 5.5), (15, 6.3), (35, 7.0))
    """
    _, Z = grid.mesh()
    v = np.zeros((grid.nx, grid.nz), dtype=float)

    ztops = [lz[0] for lz in layers]
    vels = [lz[1] for lz in layers]

    # For each depth node, choose last layer whose z_top <= z
    for iz, z in enumerate(grid.z):
        idx = max([i for i, zt in enumerate(ztops) if z >= zt])
        v[:, iz] = vels[idx]
    return v


def warp_layers(v_xz: np.ndarray, grid: Grid2D, amp_km: float=5.0, wavelength_km: float=120.0) -> np.ndarray:
    """
    Warp a layered model by shifting depth index as a sinusoidal function of x.
    This creates curved/undulating interfaces while preserving layer velocities.
    """
    x = grid.x
    shift = amp_km * np.sin(2*np.pi*x / wavelength_km)  # km
    shift_idx = np.round(shift / grid.dz).astype(int)

    v_new = np.zeros_like(v_xz)
    for ix in range(grid.nx):
        v_new[ix, :] = np.roll(v_xz[ix, :], shift_idx[ix])
    return v_new


def add_lvz(v_xz: np.ndarray, grid: Grid2D, xc: float, zc: float, dv: float=-0.8, sigx: float=25.0, sigz: float=8.0) -> np.ndarray:
    """
    Add a Gaussian low-velocity zone: v -> v + dv * exp(...)
    dv should be negative for a LVZ.
    """
    X, Z = grid.mesh()
    anomaly = np.exp(-0.5*((X-xc)/sigx)**2 - 0.5*((Z-zc)/sigz)**2)
    v2 = v_xz + dv*anomaly
    # prevent non-physical values
    v2 = np.maximum(v2, 0.5)
    return v2

3. Experiments#

We will compute and compare travel-time fields, ray paths, and surface travel-time curves.

Shared configuration#

  • Domain: (x \in [0, 200]) km, (z \in [0, 60]) km

  • Source: shallow event at ((x_s, z_s) = (20, 8)) km

  • Receivers: line on the surface (z=0)

Tip for students: Before running each experiment, write down a prediction about how rays and (T(x)) will change.

# Shared grid + geometry
grid = Grid2D(x_min=0.0, z_min=0.0, dx=1.0, dz=1.0, nx=201, nz=61)

xs, zs = 20.0, 18.0
rx = np.linspace(0, 200, 41)  # receivers on surface

Experiment A — Flat depth-dependent velocity (baseline)#

Model: smooth increase with depth (v(z) = v_0 + g z)

What to look for

  • Travel-time contours should be roughly elliptical near the source and become distorted with depth gradient.

  • Rays should curve and turn more strongly for larger offsets.

Prediction prompt

  • If you increase the gradient (g), do rays turn shallower or deeper for the same offset?

vA = v_gradient(grid, v0=5.8, grad=0.03)

solverA, TA, _ = solve_traveltime(grid, vA, xs, zs)
raysA = trace_rays_to_surface_receivers(solverA, grid, rx, receiver_z=0.0)

plot_velocity_and_rays(grid, vA, raysA, xs, zs, title="Experiment A: v(z) gradient + traced rays")
plot_traveltime_contours(grid, TA, title="Experiment A: travel-time contours T(x,z)")

# Travel-time curve on surface
tA = travel_time_curve(TA, grid, rx, receiver_z=0.0)
plt.figure(figsize=(7.5,3.2))
plt.plot(rx, tA, marker="o")
plt.xlabel("x (km)")
plt.ylabel("T (s) at z=0")
plt.title("Experiment A: surface travel-time curve T(x, z=0)")
plt.show()

Experiment B — From flat to curved layers#

We now introduce layered structure and then warp it to make curved interfaces.

Model B1: flat layers
Model B2: same layers, but warped sinusoidally in (x)

What to look for

  • In B1, rays should bend only due to velocity contrasts and gradient-like behavior.

  • In B2, you should see lateral deflection and mild focusing/defocusing tied to interface curvature.

Prediction prompt

  • Where do you expect shorter travel times: beneath a syncline-like depression of a fast layer, or beneath an anticline-like uplift?

# Flat layered baseline
layers = (
    (0.0, 5.8),
    (12.0, 6.4),
    (28.0, 7.2),
    (45.0, 7.8),
)
vB1 = v_layered(grid, layers)

# Warped layers
vB2 = warp_layers(vB1, grid, amp_km=10.0, wavelength_km=40.0)

# Solve + trace for B1
solverB1, TB1, _ = solve_traveltime(grid, vB1, xs, zs)
raysB1 = trace_rays_to_surface_receivers(solverB1, grid, rx, receiver_z=0.0)

plot_velocity_and_rays(grid, vB1, raysB1, xs, zs, title="Experiment B1: flat layers + rays")
plot_traveltime_contours(grid, TB1, title="Experiment B1: travel-time contours")

tB1 = travel_time_curve(TB1, grid, rx, receiver_z=0.0)

# Solve + trace for B2
solverB2, TB2, _ = solve_traveltime(grid, vB2, xs, zs)
raysB2 = trace_rays_to_surface_receivers(solverB2, grid, rx, receiver_z=0.0)

plot_velocity_and_rays(grid, vB2, raysB2, xs, zs, title="Experiment B2: curved layers + rays")
plot_traveltime_contours(grid, TB2, title="Experiment B2: travel-time contours")

tB2 = travel_time_curve(TB2, grid, rx, receiver_z=0.0)

# Compare travel-time curves
plt.figure(figsize=(7.5,3.2))
plt.plot(rx, tB1, marker="o", label="Flat layers")
plt.plot(rx, tB2, marker="s", label="Curved layers")
plt.plot(rx, 10*(tB2-tB1), marker="+", label="Difference")
plt.xlabel("x (km)")
plt.ylabel("T (s) at z=0")
plt.title("Experiment B: surface travel-time curve comparison")
plt.legend()
plt.show()

Experiment C — Add a Low-Velocity Zone (LVZ)#

We add a mid-crustal LVZ (Gaussian slow anomaly) to the layered model.

What to look for

  • Rays can bend toward slower material.

  • Travel times can develop subtle changes in curvature; for strong LVZs you may see travel-time curve kinks or gaps in first arrivals (geometry-driven).

Prediction prompt

  • If the LVZ is strong enough, do you expect a shadow zone on the surface? Where?

# Start from the layered model and add LVZ
vC = add_lvz(vB1, grid, xc=110.0, zc=22.0, dv=-1, sigx=22.0, sigz=7.0)

solverC, TC, _ = solve_traveltime(grid, vC, xs, zs)
raysC = trace_rays_to_surface_receivers(solverC, grid, rx, receiver_z=0.0)

plot_velocity_and_rays(grid, vC, raysC, xs, zs, title="Experiment C: layered model + LVZ + rays")
plot_traveltime_contours(grid, TC, title="Experiment C: travel-time contours")

tC = travel_time_curve(TC, grid, rx, receiver_z=0.0)

plt.figure(figsize=(7.5,3.2))
plt.plot(rx, tB1, marker="o", label="No LVZ (B1)")
plt.plot(rx, tC, marker="s", label="With LVZ (C)")
plt.xlabel("x (km)")
plt.ylabel("T (s) at z=0")
plt.title("Experiment C: LVZ effect on surface travel-time curve")
plt.legend()
plt.show()

3.5 Residuals: source location vs. structure#

This notebook also illustrates two core research approaches in seismology:

  1. Image the subsurface from travel-time residuals.

  2. Locate sources (earthquakes) by minimizing residuals between predicted and observed arrivals.

What are travel-time residuals?#

We define residuals as:

\[ r_i = T_{\text{obs}}(\mathbf{x}_i) - T_{\text{pred}}(\mathbf{x}_i; \, \mathbf{m}) \]

where \(\mathbf{x}_i\) are receiver locations and \(\mathbf{m}\) denotes the model (velocity structure + source location).

Two causes of residual patterns#

1) Unknown velocity structure (model error)

  • If the Earth is slower than the model along a path, predicted times are too early, so \(r_i > 0\).

  • If the Earth is faster than the model, predicted times are too late, so \(r_i < 0\).

  • Spatially coherent residuals (e.g., a broad positive bulge in \(r(x)\)) often indicate missing structure.

2) Source location error

  • Moving the source changes travel times systematically with distance and azimuth.

  • A source that is too shallow or too close to the array often yields a monotonic trend in \(r(x)\).

  • Location errors tend to produce simple, smooth patterns tied to geometry.

Building intuition from \(r(x)\)#

  • Broad, localized residual highs/lows → likely structure (unknown velocity anomalies).

  • Smooth, near-linear residual trend with offset → likely source location (mislocated event).

  • Mixed patterns suggest both effects; this is why imaging and relocation are coupled problems.

In practice, we iteratively update both velocity and source parameters to reduce residuals across the whole network.

4. Toy problem (experiential learning)#

You introduced a mid-crustal LVZ and noticed the surface travel-time curve changed.

Task#

  1. Quantify the LVZ signature:

    • Compute (\Delta T(x) = T_{\mathrm{LVZ}}(x) - T_{\mathrm{base}}(x)).

  2. Diagnose whether the change is local or broad:

    • Where is (\Delta T(x)) maximal?

  3. Hypothesis test (write before running):

    • If you move the LVZ deeper (increase (z_c)), does (\Delta T) get smaller or larger at the surface? Why?

Stretch goal#

Try two LVZ strengths (dv=-0.5 and dv=-1.5) and see whether the ray geometry changes (e.g., paths bend toward the LVZ).

# 1) Quantify ΔT(x)
dT = tC - tB1

plt.figure(figsize=(7.5,3.2))
plt.plot(rx, dT, marker="o")
plt.axhline(0, lw=1)
plt.xlabel("x (km)")
plt.ylabel("ΔT (s) = T_LVZ - T_base")
plt.title("Toy problem: LVZ travel-time perturbation on the surface")
plt.show()

# 2) Where is ΔT maximal?
imax = np.argmax(dT)
print(f"Max ΔT ≈ {dT[imax]:.3f} s at x ≈ {rx[imax]:.1f} km")

# 3) Hypothesis test: move LVZ deeper and recompute quickly
vC_deeper = add_lvz(vB1, grid, xc=110.0, zc=30.0, dv=-1.0, sigx=22.0, sigz=7.0)
solverCd, TCd, _ = solve_traveltime(grid, vC_deeper, xs, zs)
tCd = travel_time_curve(TCd, grid, rx, receiver_z=0.0)
dT_deeper = tCd - tB1

plt.figure(figsize=(7.5,3.2))
plt.plot(rx, dT, marker="o", label="LVZ at zc=22 km")
plt.plot(rx, dT_deeper, marker="s", label="LVZ at zc=30 km")
plt.axhline(0, lw=1)
plt.xlabel("x (km)")
plt.ylabel("ΔT (s)")
plt.title("Toy problem: effect of LVZ depth on surface ΔT(x)")
plt.legend()
plt.show()