Body Waves: P and S Waves#
Learning Objectives:
Derive the wave equation for P and S waves
Understand particle motion polarization
Visualize wave propagation in elastic media
Prerequisites: Vector calculus (divergence, curl), Partial differential equations
Reference: Shearer, Chapter 3 (Seismic Waves)
Notebook Outline:
2. Toy problem: div/curl intuition (P ↔ div, S ↔ curl) — and its limits
2.2 Test 2 (concept): P is (approximately) irrotational, S is (approximately) divergence-free
2.3 Reflection: what you can/cannot do with a single station
3.4 Rotate to ZNE (if needed), then to RT (radial/transverse)
4.3 Test 3 (concept): P is “more rectilinear than S” (often, but not guaranteed)
5.1 Proxy A: Z/R vs T energy ratios (a practical separation heuristic)
5.2 Test 4 (concept): S window tends to be more transverse than P window
6.1 Quick symbolic/numeric verification of the key vector identity
This notebook explores the theoretical foundations of body waves (P and S waves) through computational exercises:
Toy problem: Plane-wave polarization to verify P vs S motion
Some functions
def central_diff(x, dt):
"""
Central difference derivative for equally spaced samples.
Returns derivative with same length, using one-sided at ends.
"""
dx = np.zeros_like(x)
dx[1:-1] = (x[2:] - x[:-2])/(2*dt)
dx[0] = (x[1]-x[0])/dt
dx[-1] = (x[-1]-x[-2])/dt
return dx
def moving_window(x, i0, i1):
"""Safe slicing."""
i0 = max(i0, 0)
i1 = min(i1, len(x))
return x[i0:i1]
def polarization_metrics(Z, N, E):
"""
Polarization via covariance eigendecomposition.
Returns eigenvalues (sorted desc), eigenvectors (columns), rectilinearity.
"""
X = np.vstack([Z, N, E]).T
X = X - X.mean(axis=0, keepdims=True)
C = (X.T @ X) / X.shape[0]
w, V = eig(C)
idx = np.argsort(w)[::-1]
w = np.real(w[idx])
V = np.real(V[:, idx])
# Common rectilinearity proxy: 1 - (λ2+λ3)/(2*λ1)
rect = 1.0 - (w[1] + w[2])/(2*w[0] + 1e-30)
return w, V, rect
def angle_from_vertical(v):
"""
Incidence angle from vertical (Z axis).
v is a 3-vector (Z,N,E) direction.
"""
v = v / (norm(v) + 1e-30)
# Z axis = [1,0,0]
return np.degrees(np.arccos(np.clip(v[0], -1, 1)))
def baz_from_NE(v):
"""
Back-azimuth estimate from horizontal projection of v in (N,E).
Returns degrees in [0,360).
"""
v = v / (norm(v) + 1e-30)
n, e = v[1], v[2]
baz = (np.degrees(np.arctan2(e, n)) + 360) % 360
return baz
1. Toy problem: plane-wave polarization (P vs S)#
1.1 Build a monochromatic plane wave (Chapter 3.4–3.5)#
We generate a plane wave in 1-D (propagating in +x), but we treat displacement as a 3-component vector.
# Sampling
dt = 0.01
t = np.arange(0, 6, dt)
# Wave parameters
f = 1.0
w = 2*np.pi*f
cP = 6.0 # km/s (toy)
cS = 3.5 # km/s (toy)
# Choose an "x" location for a synthetic seismogram at a receiver
x = 30.0 # km
phaseP = w*(t - x/cP)
phaseS = w*(t - x/cS)
A = 1.0
# P wave polarized along x (map x -> N for convenience later)
# We'll use components (Z, N, E). For toy: let propagation be "N".
Zp = np.zeros_like(t)
Np = A*np.cos(phaseP)
Ep = np.zeros_like(t)
# S wave polarized transverse (along E)
Zs = np.zeros_like(t)
Ns = np.zeros_like(t)
Es = A*np.cos(phaseS)
# Plot
plt.figure()
plt.plot(t, Np, label="P: N component")
plt.plot(t, Es, label="S: E component")
plt.xlim(0, 6)
plt.xlabel("Time (s)")
plt.ylabel("Displacement (arb.)")
plt.legend()
plt.title("Toy plane waves: longitudinal P vs transverse S")
plt.show()
1.2 Test 1 (concept): “P is longitudinal, S is transverse”#
Automated test: For a “pure” P, almost all energy should be in N; for a “pure” S, almost all energy should be in E.
def energy(x): return np.sum(x**2)
P_energy = np.array([energy(Zp), energy(Np), energy(Ep)])
S_energy = np.array([energy(Zs), energy(Ns), energy(Es)])
print("P energy fractions (Z,N,E):", P_energy/P_energy.sum())
print("S energy fractions (Z,N,E):", S_energy/S_energy.sum())
assert (P_energy[1] / P_energy.sum()) > 0.99, "P not sufficiently longitudinal"
assert (S_energy[2] / S_energy.sum()) > 0.99, "S not sufficiently transverse"
print("PASS: Toy polarization test.")
2. Toy problem: div/curl intuition (P ↔ div, S ↔ curl) — and its limits#
Chapter 3 shows that in a homogeneous medium the equation of motion can be written (Eq. 3.19–3.27) and decoupled by taking divergence and curl:
P: wave equation for ∇·u
S: wave equation for ∇×u
At a single receiver, you do not measure spatial derivatives directly. So we do two things:
Build a tiny synthetic spatial grid to compute numerical div/curl.
Explicitly note what cannot be done with a single seismogram.
2.1 Synthetic 3-D displacement field on a small grid#
We build a plane P wave with u = ∇φ and an S wave with u = ∇×ψ (Helmholtz decomposition, Eq. 3.28). Then compute numerical divergence and curl and verify:
P: curl ≈ 0, div ≠ 0
S: div ≈ 0, curl ≠ 0
# Small spatial grid (km)
dx = 0.5
x = np.arange(-10, 10+dx, dx)
y = np.arange(-10, 10+dx, dx)
z = np.arange(-10, 10+dx, dx)
X, Y, Z = np.meshgrid(x, y, z, indexing='ij')
# Snapshot time
t0 = 2.0
# P-wave scalar potential φ = cos(ω(t - x/cP))
phi = np.cos(w*(t0 - X/cP))
# uP = ∇φ (approx via finite differences)
uPx = np.gradient(phi, dx, axis=0)
uPy = np.gradient(phi, dx, axis=1)
uPz = np.gradient(phi, dx, axis=2)
# S-wave vector potential ψ with only z-component -> produces shear in x-y plane
# Let ψz = cos(ω(t - x/cS)); ψx=ψy=0
psiz = np.cos(w*(t0 - X/cS))
psix = np.zeros_like(psiz)
psiy = np.zeros_like(psiz)
# uS = ∇×ψ
# curl(ψ) = [∂y ψz - ∂z ψy, ∂z ψx - ∂x ψz, ∂x ψy - ∂y ψx]
dpsiz_dy = np.gradient(psiz, dx, axis=1)
dpsiz_dx = np.gradient(psiz, dx, axis=0)
uSx = dpsiz_dy - np.gradient(psiy, dx, axis=2)
uSy = np.gradient(psix, dx, axis=2) - dpsiz_dx
uSz = np.gradient(psiy, dx, axis=0) - np.gradient(psix, dx, axis=1)
# divergence
div_uP = (np.gradient(uPx, dx, axis=0) +
np.gradient(uPy, dx, axis=1) +
np.gradient(uPz, dx, axis=2))
div_uS = (np.gradient(uSx, dx, axis=0) +
np.gradient(uSy, dx, axis=1) +
np.gradient(uSz, dx, axis=2))
# curl magnitude
curl_uP_x = np.gradient(uPz, dx, axis=1) - np.gradient(uPy, dx, axis=2)
curl_uP_y = np.gradient(uPx, dx, axis=2) - np.gradient(uPz, dx, axis=0)
curl_uP_z = np.gradient(uPy, dx, axis=0) - np.gradient(uPx, dx, axis=1)
curlmag_uP = np.sqrt(curl_uP_x**2 + curl_uP_y**2 + curl_uP_z**2)
curl_uS_x = np.gradient(uSz, dx, axis=1) - np.gradient(uSy, dx, axis=2)
curl_uS_y = np.gradient(uSx, dx, axis=2) - np.gradient(uSz, dx, axis=0)
curl_uS_z = np.gradient(uSy, dx, axis=0) - np.gradient(uSx, dx, axis=1)
curlmag_uS = np.sqrt(curl_uS_x**2 + curl_uS_y**2 + curl_uS_z**2)
print("P field: mean |div| =", np.mean(np.abs(div_uP)), "mean |curl| =", np.mean(curlmag_uP))
print("S field: mean |div| =", np.mean(np.abs(div_uS)), "mean |curl| =", np.mean(curlmag_uS))
2.2 Test 2 (concept): P is (approximately) irrotational, S is (approximately) divergence-free#
# We compare ratios to avoid scale dependence
ratio_P = np.mean(curlmag_uP) / (np.mean(np.abs(div_uP)) + 1e-30)
ratio_S = np.mean(np.abs(div_uS)) / (np.mean(curlmag_uS) + 1e-30)
print("ratio_P = <|curl|>/<|div|> (should be small):", ratio_P)
print("ratio_S = <|div|>/<|curl|> (should be small):", ratio_S)
assert ratio_P < 0.05, "P not sufficiently irrotational in this discretization"
assert ratio_S < 0.05, "S not sufficiently divergence-free in this discretization"
print("PASS: div/curl separation on a synthetic grid.")
2.3 Reflection: what you can/cannot do with a single station#
You can test polarization (particle motion direction) very well from 3C recordings (Ch. 3.5).
You cannot compute true spatial divergence/curl of displacement from one station; that requires an array or a wavefield model.
3. Real data: download 3-component seismograms with ObsPy (FDSN)#
We now pull real waveform data and repeat the polarization analysis on P and S windows.
3.1 Fetch a catalog event + station#
You can choose any event/station pair. The default below is designed to be editable.
from obspy.clients.fdsn import Client
from obspy import UTCDateTime
from obspy.geodetics import gps2dist_azimuth
from obspy.taup import TauPyModel
client = Client("IRIS") # or "USGS", "RESIF", etc.
# --- Pick an event time and station (edit these freely) ---
origin_time = UTCDateTime("2021-11-28T10:52:14") # example; change as needed
oo = client.get_events(starttime=origin_time - 10, endtime=origin_time + 10,minmagnitude=7)[0]
print(oo)
net, sta, loc, cha = "C1", "VA04", "*", "BH?" # 3C broadband
sta = client.get_stations(network=net, station=sta, location=loc,
channel=cha, starttime=origin_time,
endtime=origin_time + 3600,
level="response")
ev_lat = oo.origins[0].latitude
ev_lon = oo.origins[0].longitude
ev_depth_km = oo.origins[0].depth / 1000.0 # m -> km
st_lat = sta[0].stations[0].latitude
st_lon = sta[0].stations[0].longitude
# Distance/azimuth
dist_m, az, baz = gps2dist_azimuth(ev_lat, ev_lon, st_lat, st_lon)
dist_deg = dist_m / (1000.0 * 111.19) # rough deg; TauP wants degrees. We'll compute precisely below too.
dist_deg = dist_m / 1000.0 / 111.19
print("Approx distance (deg):", dist_deg, "az:", az, "baz:", baz)
3.2 Predict P and S arrivals (TauP)#
model = TauPyModel(model="iasp91")
arrivals = model.get_travel_times(source_depth_in_km=ev_depth_km,
distance_in_degree=dist_deg)
for a in arrivals[:18]:
print(a.name, "t(s)=", a.time)
tP = [a.time for a in arrivals if a.name == "P"][0]
tS = [a.time for a in arrivals if a.name == "S"][0]
print("Predicted P:", tP, "s ; S:", tS, "s")
3.3 Download waveforms around the arrivals#
t0 = origin_time + (tP - 300) # start 5 min before P
st = client.get_waveforms(network=net, station=sta, location="*", channel=cha,
starttime=t0, endtime=t0 + 3600, attach_response=True)
st.merge(fill_value="interpolate")
st.detrend("linear")
st.detrend("demean")
st.filter("bandpass", freqmin=0.02, freqmax=1.0, corners=4, zerophase=True)
st.taper(0.02)
print(st)
st.plot()
3.4 Rotate to ZNE (if needed), then to RT (radial/transverse)#
For polarization and P/S separation, RT rotation is often helpful:
P energy tends to be strong on Z and R
SH tends to be strong on T
SV tends to mix Z and R
# Ensure we have Z, N, E traces
# Many networks already provide BHZ,BHN,BHE.
# If you have BH1/BH2, you'd need instrument azimuth metadata and rotate.
stZ = st.select(channel="*Z")[0]
stN = st.select(channel="*N")[0]
stE = st.select(channel="*E")[0]
# Rotate NE -> RT using back-azimuth
from obspy.signal.rotate import rotate_ne_rt
N = stN.data.astype(float)
E = stE.data.astype(float)
R, T = rotate_ne_rt(N, E, baz)
# Time axis
npts = stZ.stats.npts
dt = stZ.stats.delta
times = np.arange(npts)*dt
abs_times = stZ.stats.starttime + times
# Plot Z, R, T
plt.figure(figsize=(10,6))
plt.plot(times, stZ.data/np.max(np.abs(stZ.data)), label="Z (norm)")
plt.plot(times, R/np.max(np.abs(R)), label="R (norm)")
plt.plot(times, T/np.max(np.abs(T)), label="T (norm)")
plt.axvline(tP - 300, linestyle="--")
plt.axvline(tS - 300, linestyle="--")
plt.xlabel("Time since window start (s)")
plt.legend()
plt.title("3C components (normalized), with predicted P and S markers")
plt.show()
4. Real data polarization analysis (P window vs S window)#
We quantify polarization in two windows:
P window: around predicted P arrival
S window: around predicted S arrival
4.1 Define windows#
# Window definitions relative to our downloaded start (t0)
P_center = tP - 300
S_center = tS - 300
# Editable window lengths (seconds)
P_pre, P_post = 5, 25
S_pre, S_post = 5, 35
iP0 = int((P_center - P_pre)/dt)
iP1 = int((P_center + P_post)/dt)
iS0 = int((S_center - S_pre)/dt)
iS1 = int((S_center + S_post)/dt)
ZP = stZ.data[iP0:iP1]
NP = N[iP0:iP1]
EP = E[iP0:iP1]
ZS = stZ.data[iS0:iS1]
NS = N[iS0:iS1]
ES = E[iS0:iS1]
print("P window length (s):", (iP1-iP0)*dt, "S window length (s):", (iS1-iS0)*dt)
4.2 Compute polarization metrics (covariance eigenanalysis)#
wP, VP, rectP = polarization_metrics(ZP, NP, EP)
wS, VS, rectS = polarization_metrics(ZS, NS, ES)
vP = VP[:,0] # principal polarization direction (Z,N,E)
vS = VS[:,0]
print("P eigenvalues:", wP, "rectilinearity:", rectP)
print("S eigenvalues:", wS, "rectilinearity:", rectS)
print("P principal direction (Z,N,E):", vP, "incidence angle from vertical:", angle_from_vertical(vP))
print("S principal direction (Z,N,E):", vS, "incidence angle from vertical:", angle_from_vertical(vS))
Interpretation:
P should be relatively rectilinear (high rectilinearity), with principal axis often near the ray direction (mix of Z and radial).
S can be more complex (SV+SH mixing) but often less rectilinear in noisy/converted contexts.
4.3 Test 3 (concept): P is “more rectilinear than S” (often, but not guaranteed)#
This is a probabilistic test (data quality matters), so we set a mild expectation and make the student diagnose failures.
print("rectP:", rectP, "rectS:", rectS)
# Mild expectation; adjust if needed
if rectP <= rectS:
print("NOTE: P not more rectilinear than S for this record. Diagnose:")
print("- Is the P window too long/short?")
print("- Is bandpass appropriate?")
print("- Are you near a nodal direction or strongly scattered site?")
else:
print("PASS (typical): P rectilinearity exceeds S rectilinearity.")
5. P/S separation tests using component behavior (receiver-side)#
Chapter 3’s divergence/curl separation is a field statement in homogeneous media (Eq. 3.20–3.26), but with single-station data we use observable proxies:
5.1 Proxy A: Z/R vs T energy ratios (a practical separation heuristic)#
Compute energy fractions in the P and S windows:
P tends to have higher Z+R energy.
SH tends to show strongly on T around S.
# Build R,T windows too
RP = R[iP0:iP1]; TP = T[iP0:iP1]
RS = R[iS0:iS1]; TS = T[iS0:iS1]
def frac_T(Z, R, T):
Ez, Er, Et = np.sum(Z**2), np.sum(R**2), np.sum(T**2)
return Et/(Ez+Er+Et+1e-30), (Ez+Er)/(Ez+Er+Et+1e-30)
fTP, fZR_P = frac_T(ZP, RP, TP)
fTS, fZR_S = frac_T(ZS, RS, TS)
print("P window: T fraction =", fTP, "ZR fraction =", fZR_P)
print("S window: T fraction =", fTS, "ZR fraction =", fZR_S)
5.1.1 Visualizing Polarization Motion#
Particle motion plots show how the ground moves in different planes during the wave passage. This helps visualize:
P-wave: Should show rectilinear motion primarily in the Z-R plane (along the ray direction)
S-wave: Can show elliptical or rectilinear motion depending on SV/SH content
SV motion: appears in Z-R plane
SH motion: appears in the transverse (T) component
We’ll plot the particle motion in three planes: Z-R, Z-T, and R-T for both P and S windows.
# Create particle motion plots for P and S windows
fig, axes = plt.subplots(2, 3, figsize=(15, 10))
# Time vectors for P and S windows
tP_window = times[iP0:iP1]
tS_window = times[iS0:iS1]
# Color by time to show motion direction
norm_P = plt.Normalize(vmin=tP_window.min(), vmax=tP_window.max())
norm_S = plt.Normalize(vmin=tS_window.min(), vmax=tS_window.max())
cmap = plt.cm.viridis
# ===== P-wave particle motion =====
# Z-R plane (primary plane for P-waves)
sc1 = axes[0, 0].scatter(RP, ZP, c=tP_window, cmap=cmap, s=20, alpha=0.6)
axes[0, 0].plot(RP, ZP, 'k-', alpha=0.3, linewidth=0.5)
axes[0, 0].plot(RP[0], ZP[0], 'go', markersize=8, label='Start')
axes[0, 0].plot(RP[-1], ZP[-1], 'ro', markersize=8, label='End')
axes[0, 0].set_xlabel('Radial (R)', fontsize=10)
axes[0, 0].set_ylabel('Vertical (Z)', fontsize=10)
axes[0, 0].set_title(f'P-wave: Z-R plane\n(Rectilinearity={rectP:.3f})', fontsize=11)
axes[0, 0].grid(True, alpha=0.3)
axes[0, 0].legend(fontsize=8)
axes[0, 0].axis('equal')
# Z-T plane (should show minimal motion for pure P)
sc2 = axes[0, 1].scatter(TP, ZP, c=tP_window, cmap=cmap, s=20, alpha=0.6)
axes[0, 1].plot(TP, ZP, 'k-', alpha=0.3, linewidth=0.5)
axes[0, 1].plot(TP[0], ZP[0], 'go', markersize=8)
axes[0, 1].plot(TP[-1], ZP[-1], 'ro', markersize=8)
axes[0, 1].set_xlabel('Transverse (T)', fontsize=10)
axes[0, 1].set_ylabel('Vertical (Z)', fontsize=10)
axes[0, 1].set_title(f'P-wave: Z-T plane\n(T fraction={fTP:.3f})', fontsize=11)
axes[0, 1].grid(True, alpha=0.3)
axes[0, 1].axis('equal')
# R-T plane (horizontal plane)
sc3 = axes[0, 2].scatter(TP, RP, c=tP_window, cmap=cmap, s=20, alpha=0.6)
axes[0, 2].plot(TP, RP, 'k-', alpha=0.3, linewidth=0.5)
axes[0, 2].plot(TP[0], RP[0], 'go', markersize=8)
axes[0, 2].plot(TP[-1], RP[-1], 'ro', markersize=8)
axes[0, 2].set_xlabel('Transverse (T)', fontsize=10)
axes[0, 2].set_ylabel('Radial (R)', fontsize=10)
axes[0, 2].set_title('P-wave: R-T plane', fontsize=11)
axes[0, 2].grid(True, alpha=0.3)
axes[0, 2].axis('equal')
# ===== S-wave particle motion =====
# Z-R plane (SV motion appears here)
sc4 = axes[1, 0].scatter(RS, ZS, c=tS_window, cmap=cmap, s=20, alpha=0.6)
axes[1, 0].plot(RS, ZS, 'k-', alpha=0.3, linewidth=0.5)
axes[1, 0].plot(RS[0], ZS[0], 'go', markersize=8, label='Start')
axes[1, 0].plot(RS[-1], ZS[-1], 'ro', markersize=8, label='End')
axes[1, 0].set_xlabel('Radial (R)', fontsize=10)
axes[1, 0].set_ylabel('Vertical (Z)', fontsize=10)
axes[1, 0].set_title(f'S-wave: Z-R plane (SV)\n(Rectilinearity={rectS:.3f})', fontsize=11)
axes[1, 0].grid(True, alpha=0.3)
axes[1, 0].legend(fontsize=8)
axes[1, 0].axis('equal')
# Z-T plane (SH motion shows on T)
sc5 = axes[1, 1].scatter(TS, ZS, c=tS_window, cmap=cmap, s=20, alpha=0.6)
axes[1, 1].plot(TS, ZS, 'k-', alpha=0.3, linewidth=0.5)
axes[1, 1].plot(TS[0], ZS[0], 'go', markersize=8)
axes[1, 1].plot(TS[-1], ZS[-1], 'ro', markersize=8)
axes[1, 1].set_xlabel('Transverse (T)', fontsize=10)
axes[1, 1].set_ylabel('Vertical (Z)', fontsize=10)
axes[1, 1].set_title(f'S-wave: Z-T plane (SH)\n(T fraction={fTS:.3f})', fontsize=11)
axes[1, 1].grid(True, alpha=0.3)
axes[1, 1].axis('equal')
# R-T plane (horizontal plane, shows both SV and SH)
sc6 = axes[1, 2].scatter(TS, RS, c=tS_window, cmap=cmap, s=20, alpha=0.6)
axes[1, 2].plot(TS, RS, 'k-', alpha=0.3, linewidth=0.5)
axes[1, 2].plot(TS[0], RS[0], 'go', markersize=8)
axes[1, 2].plot(TS[-1], RS[-1], 'ro', markersize=8)
axes[1, 2].set_xlabel('Transverse (T)', fontsize=10)
axes[1, 2].set_ylabel('Radial (R)', fontsize=10)
axes[1, 2].set_title('S-wave: R-T plane', fontsize=11)
axes[1, 2].grid(True, alpha=0.3)
axes[1, 2].axis('equal')
# Add colorbars
plt.colorbar(sc3, ax=axes[0, :], label='Time (s)', orientation='horizontal', pad=0.1, shrink=0.8)
plt.colorbar(sc6, ax=axes[1, :], label='Time (s)', orientation='horizontal', pad=0.1, shrink=0.8)
plt.tight_layout()
plt.show()
Interpretation Guide:
Rectilinear motion: Points align along a straight line (high rectilinearity)
Elliptical motion: Points trace an ellipse (common for S-waves with mixed SV/SH)
Scattered motion: Random pattern (low rectilinearity, noisy data)
For P-waves:
Z-R plane should show strong rectilinear motion along the ray path
Z-T plane should show minimal motion (little transverse energy)
Color progression from green (start) to red (end) shows the time evolution
For S-waves:
Z-R plane shows SV component (vertical-radial motion)
Z-T plane shows SH component (transverse-vertical motion)
R-T plane shows horizontal particle motion
S-waves often show more complex, elliptical patterns due to SV+SH mixing
5.2 Test 4 (concept): S window tends to be more transverse than P window#
if fTS <= fTP:
print("NOTE: Transverse fraction not higher in S window. Diagnose:")
print("- Are we looking at SV-dominant S (less T)?")
print("- Is the back-azimuth correct? Station metadata correct?")
print("- Is S window contaminated by later phases/coda?")
else:
print("PASS (typical): S window is more transverse than P window.")
6. Linking back to Chapter 3 “core derivation” with a mini-derivation check#
This section is conceptual + computational and directly targets the multiple-choice learning outcomes:
Stress divergence as force density (Eq. 3.5–3.9)
Hooke’s law closure (Eq. 3.11–3.13)
Vector identity enabling P/S decoupling (Eq. 3.16–3.17)
Homogeneous vs source region (Eq. 3.9 vs 3.8)
6.1 Quick symbolic/numeric verification of the key vector identity#
Identity used in Chapter 3 (Eq. 3.16):
∇×∇×u=∇(∇⋅u)−∇2u.
We verify numerically on a random smooth vector field on a grid.
# Random smooth field on grid (reuse X,Y,Z from earlier, but smaller for speed)
dx = 1.0
x = np.arange(-8, 8+dx, dx)
y = np.arange(-8, 8+dx, dx)
z = np.arange(-8, 8+dx, dx)
X, Y, Z = np.meshgrid(x, y, z, indexing='ij')
u1 = np.sin(0.2*X)*np.cos(0.1*Y)
u2 = np.cos(0.15*Y)*np.sin(0.1*Z)
u3 = np.sin(0.1*X)*np.cos(0.12*Z)
# Compute div u
divu = np.gradient(u1, dx, axis=0) + np.gradient(u2, dx, axis=1) + np.gradient(u3, dx, axis=2)
# Compute grad(div u)
gdiv_x = np.gradient(divu, dx, axis=0)
gdiv_y = np.gradient(divu, dx, axis=1)
gdiv_z = np.gradient(divu, dx, axis=2)
# Compute Laplacian of u components
lap_u1 = (np.gradient(np.gradient(u1, dx, axis=0), dx, axis=0) +
np.gradient(np.gradient(u1, dx, axis=1), dx, axis=1) +
np.gradient(np.gradient(u1, dx, axis=2), dx, axis=2))
lap_u2 = (np.gradient(np.gradient(u2, dx, axis=0), dx, axis=0) +
np.gradient(np.gradient(u2, dx, axis=1), dx, axis=1) +
np.gradient(np.gradient(u2, dx, axis=2), dx, axis=2))
lap_u3 = (np.gradient(np.gradient(u3, dx, axis=0), dx, axis=0) +
np.gradient(np.gradient(u3, dx, axis=1), dx, axis=1) +
np.gradient(np.gradient(u3, dx, axis=2), dx, axis=2))
# Compute curl(curl(u))
# curl u:
curlu_x = np.gradient(u3, dx, axis=1) - np.gradient(u2, dx, axis=2)
curlu_y = np.gradient(u1, dx, axis=2) - np.gradient(u3, dx, axis=0)
curlu_z = np.gradient(u2, dx, axis=0) - np.gradient(u1, dx, axis=1)
# curl(curl u):
cc_x = np.gradient(curlu_z, dx, axis=1) - np.gradient(curlu_y, dx, axis=2)
cc_y = np.gradient(curlu_x, dx, axis=2) - np.gradient(curlu_z, dx, axis=0)
cc_z = np.gradient(curlu_y, dx, axis=0) - np.gradient(curlu_x, dx, axis=1)
# RHS = grad(div u) - Laplacian(u)
rhs_x = gdiv_x - lap_u1
rhs_y = gdiv_y - lap_u2
rhs_z = gdiv_z - lap_u3
# Compare norms
err = (np.mean(np.abs(cc_x - rhs_x)) +
np.mean(np.abs(cc_y - rhs_y)) +
np.mean(np.abs(cc_z - rhs_z)))
scale = (np.mean(np.abs(rhs_x)) + np.mean(np.abs(rhs_y)) + np.mean(np.abs(rhs_z)) + 1e-30)
rel_err = err/scale
print("Relative error (finite-diff check):", rel_err)
assert rel_err < 0.1, "Vector identity check failed beyond tolerance (try finer dx or smoother field)."
print("PASS: Numerical check of key vector identity (within FD tolerance).")