Spectral Analysis

Spectral Analysis#

We will demonstrate how to use Python to calculate power spectrum as documented in NCL website. The code is generated by Google Gemini.

First, build the pre-defined functions as follows:

import xarray as xr
import numpy as np
import matplotlib.pyplot as plt
from scipy import signal
from scipy.stats import chi2

def specx_anal(data, d, sm, pct):
    """
    Python version of the NCL specx_anal function.
    Computes a smoothed one-dimensional power spectrum.
    
    Parameters:
    - data: Input one-dimensional time series (numpy array)
    - d: Detrend option (0: subtract mean, 1: linear detrend)
    - sm: Smoothing window size (odd number)
    - pct: Tapering percentage at both ends of the data
    
    Returns:
    - spec_info: A dictionary containing the spectral analysis results
    """
    n = len(data)
    
    # Linear detrend
    detrend_type = 'constant' if d == 0 else 'linear'
    
    # Tapering 
    window = signal.windows.tukey(n, alpha=2*pct)
    
    # Calculate Periodogram
    # fs=1: sampling frequency is once per time unit (SOI for example, once per month.)
    # scaling='density' obtains Power Spectral Density (PSD)
    # This step integrates windowing, detrending, FFT, and computing the one-sided spectrum
    freqs, pxx_raw = signal.periodogram(
        data, fs=1, window=window, detrend=detrend_type, scaling='density'
    )

    # Smooth the spectrum: use convolution for moving average
    smoother = np.ones(sm) / sm
    pxx_smooth = np.convolve(pxx_raw, smoother, mode='same')

    # Calculate Degrees of Freedom and bandwidth. Please refer to NCL documentation.
    dof = 2 * sm
    bw = dof / n  # 假設 dt=1
    
    spec_info = {
        'spcx': pxx_smooth,
        'frq': freqs,
        'dof': dof,
        'bw': bw
    }
    return spec_info

def specx_ci(original_data, spec_info, clevel_lower, clevel_upper):
    """
    Python version of the NCL specx_ci function.
    Computes confidence intervals based on red noise (AR1 process).
    
    Returns:
    - splt: A (4, n_freqs) array containing:
            [0]: Smoothed data spectrum
            [1]: Theoretical red noise spectrum
            [2]: Lower bound of the red noise confidence interval
            [3]: Upper bound of the red noise confidence interval
    """
    # Extract required variables from spec_info dictionary
    spcx = spec_info['spcx']
    freqs = spec_info['frq']
    dof = spec_info['dof']
    
    n_freqs = len(freqs)

    # Use original data to calculate lag-1 autocorrelation coefficient (r1)
    mean_data = original_data.mean()
    anom_data = original_data - mean_data
    r1 = np.corrcoef(anom_data[:-1], anom_data[1:])[0, 1]

    # Calculate theoretical red noise spectrum using the formula from Gilman et al. (1963)
    red_noise_spec = (1 - r1**2) / (1 - 2 * r1 * np.cos(2 * np.pi * freqs) + r1**2)

    # Normalize the total variance of the red noise spectrum to match the data spectrum
    red_noise_spec_normalized = red_noise_spec * (np.sum(spcx) / np.sum(red_noise_spec))

    # Calculate confidence intervals based on Chi-squared distribution
    chi2_lower = chi2.ppf(clevel_lower, dof)
    chi2_upper = chi2.ppf(clevel_upper, dof)
    
    lower_bound = red_noise_spec_normalized * (chi2_lower / dof)
    upper_bound = red_noise_spec_normalized * (chi2_upper / dof)

    # Return results
    splt = np.vstack([spcx, red_noise_spec_normalized, lower_bound, upper_bound])
    
    return splt

Using the SOI (Southern Oscillation Index) as an example, we will demonstrate the power spectrum calculated by the above functions.

ds = xr.open_dataset("./data/SOI_Darwin.nc")
soi_data = ds['DSOI'].values

# Setting parameters
d = 0      # Detrend option: 0=>remove mean
sm = 21    # Smoothing window: odd number at least 3
pct = 0.10 # Tapering percentage

# Compute spectrum
sdof = specx_anal(soi_data, d, sm, pct)

# Calculate confidence intervals (5% and 95%)
splt = specx_ci(soi_data,sdof, 0.05, 0.95)

Plotting

fig, axes = plt.subplots(1,2,figsize=(12, 6))
ax=axes.flatten()

# (a) x-axis: frequency
ax[0].plot(sdof['frq'], splt[0,:], label='SOI Spectrum', color='black', lw=1.5)
ax[0].plot(sdof['frq'], splt[1,:], label='Red Noise', color='red', lw=1.0, linestyle='--')
ax[0].plot(sdof['frq'], splt[2,:], label='95% Confidence Lower Bound', color='gray', lw=1.0, linestyle=':')
ax[0].plot(sdof['frq'], splt[3,:], label='95% Confidence Upper Bound', color='gray', lw=1.0, linestyle=':')

ax[0].set_title("SOI: Darwin (Spectrum vs Frequency)", loc='left')
ax[0].set_xlabel("Frequency (cycles/month)", fontsize=12)
ax[0].set_ylabel("Variance / Frequency Interval", fontsize=12)
ax[0].set_ylim(0.0, 20.0) 
ax[0].grid(True, which="major", ls="--", linewidth=0.5)
ax[0].legend()

# Plot bandwidth (BW)
bw_x = [0.40, 0.40 + sdof['bw']]
bw_y_val = 0.75 * np.max(sdof['spcx'])
bw_y = [bw_y_val, bw_y_val]
ax[0].plot(bw_x, bw_y, color='blue', lw=2)
ax[0].text(0.41 + sdof['bw'], bw_y_val, "BW", color='blue', va='center', ha='left', fontsize=12)

# (b) x-axis: period
# Convert frequency to period
freqs_nonzero = sdof['frq'][1:]
splt_nonzero = splt[:, 1:]
period = 1 / freqs_nonzero

ip = period <= 240 # Only plot the part where period is less than or equal to 240 months

ax[1].plot(period[ip], splt_nonzero[0, ip], label='SOI Spectrum', color='black', lw=1.5)
ax[1].plot(period[ip], splt_nonzero[1, ip], label='Red Noise', color='red', lw=1.0, linestyle='--')
ax[1].plot(period[ip], splt_nonzero[2, ip], label='95% Confidence Lower Bound', color='gray', lw=1.0, linestyle=':')
ax[1].plot(period[ip], splt_nonzero[3, ip], label='95% Confidence Upper Bound', color='gray', lw=1.0, linestyle=':')

ax[1].set_title("SOI: Darwin (Spectrum vs Period)", loc='left')
ax[1].set_xlabel("Period (month)", fontsize=12)
ax[1].set_ylabel("Variance / Frequency", fontsize=12)
ax[1].set_ylim(0.0, 20.0)
ax[1].grid(True, which="major", ls="--", linewidth=0.5)
ax[1].legend()

ax[1].invert_xaxis()

print(f"Lag-1 autocorrelation (r1): {np.corrcoef(soi_data[:-1], soi_data[1:])[0, 1]:.4f}")
Lag-1 autocorrelation (r1): 0.5348
_images/c615e04ecf8e17053b8622e23fa156817bfc3758b3ada560c914ef0a3e3e6061.png