# Fast Fourier Transform: From Theory to Frequency Discovery
Related notes:
- [[MATH310S26-Day7-Notes]] - Introduction to Fourier series
- [[MATH310S26-Day10-Notes]] - Complex Fourier series
- [[MATH310S26-Day11-Notes]] - Fourier regression theory
- [[MATH310S26-Day13-WorkdayMaterials (Fourier regression and seasonal decomposition)]] - Pre-specified frequency approach
- [[MATH310S26-Day14-Notes]] - Delta functions and Fourier transforms
- [[MATH310S26-Day15-Notes]] - Fourier transform examples
## Recent Lectures Recap
- Developed Fourier series for periodic functions on $[-L, L]$
- Extended to Fourier transforms by taking $L \to \infty$ limit
- Connected continuous and discrete via delta function sampling
- Note: Just as uniform discrete time sampling in frequency led to periodicity in time, the reverse is also true, i.e., uniform sampling in time leads to periodicity in frequency.
- In [[MATH310S26-Day13-WorkdayMaterials (Fourier regression and seasonal decomposition)|Day 13]], we pre-specified frequencies based on physical reasoning
## Day 16 Goals
- Understand the [Fast Fourier Transform (FFT)](https://en.wikipedia.org/wiki/Fast_Fourier_transform) as a computational tool
- Use FFT to **discover** dominant frequencies in GMSL data
- Compare data-driven frequency selection with our Day 13 physical intuition
- Visualize and interpret [power spectra](https://en.wikipedia.org/wiki/Spectral_density)
- Reconstruct signals using FFT-discovered frequencies
- Preview: How frequency analysis connects to filtering (upcoming topic)
# From Fourier Series to FFT: The Mathematical Journey
For a more detailed overview, see: [[FourierTheoryAndFFT.pdf]]
## The Conceptual Path: FS → FT → Sampling → FFT
### 1. [Fourier Series](https://en.wikipedia.org/wiki/Fourier_series) (Periodic Functions)
From [[MATH310S26-Day10-Notes|Day 10]], for a periodic function with period $2L$:
$f(t) = \sum_{n=-\infty}^{\infty} c_n e^{i\omega_n t}, \quad \omega_n = \frac{n\pi}{L}$
The coefficients are:
$c_n = \frac{1}{2L} \int_{-L}^{L} f(t) e^{-i\omega_n t} dt$
**Key insight**: [Discrete frequencies](https://en.wikipedia.org/wiki/Discrete_frequency_domain) $\omega_n$ because of finite period.
### 2. [Fourier Transform](https://en.wikipedia.org/wiki/Fourier_transform) (Non-periodic Functions)
From [[MATH310S26-Day12-Notes|Day 12]] and [[MATH310S26-Day14-Notes|Day 14]], taking $L \to \infty$:
**Forward Transform**:
$\hat{f}(\omega) = \mathcal{F}\left\{f\right\}=\frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} f(t) e^{-i\omega t} dt$
**Inverse Transform**:
$f(t)= \mathcal{F}^{-1}\left\{\hat{f}\right\} = \frac{1}{\sqrt{2\pi}} \int_{-\infty}^{\infty} \hat{f}(\omega) e^{i\omega t} d\omega$
**Key insight**: [Continuous frequency spectrum](https://en.wikipedia.org/wiki/Frequency_spectrum) $\omega \in \mathbb{R}$ for non-periodic signals.
### 3. The Bridge: Delta Functions and Sampling
From [[MATH310S26-Day14-Notes|Day 14]], the [Dirac delta function](https://en.wikipedia.org/wiki/Dirac_delta_function) connects discrete and continuous:
**For sinusoids** (which don't have classical Fourier transforms):
$\mathcal{F}\{\cos(\omega_0 t)\} = \sqrt{\frac{\pi}{2}}[\delta(\omega - \omega_0) + \delta(\omega + \omega_0)]$
The delta functions "pick out" specific frequencies, just like Fourier series!
**For sampled data**: Real measurements happen at discrete times $t_k = k\Delta t$:
$f_{\text{sampled}}(t) = f(t) \cdot \sum_{k=-\infty}^{\infty} \delta(t - k\Delta t)$
This sampling creates periodicity in the frequency domain (we'll see this as [aliasing](https://en.wikipedia.org/wiki/Aliasing)).
### 4. The FFT: A Computational Tool
For finite samples $\{f_0, f_1, \ldots, f_{N-1}\}$, the **Fast Fourier Transform**:
- Efficiently computes frequency content
- Uses clever factorization ([Cooley-Tukey algorithm](https://en.wikipedia.org/wiki/Cooley%E2%80%93Tukey_FFT_algorithm))
- The Most Important Algorithm Of All Time, Veritasium, Nov 3, 2022, has an interesting historical perspective. Jump to 10:20 to see the origin story!

- Reduces computation from $O(N^2)$ to $O(N \log N)$
- For our GMSL data (N≈1200): ~100× faster!
**What FFT gives us**:
- Complex numbers at discrete frequencies
- Magnitude = strength of oscillation
- Phase = timing/shift of oscillation
- We'll interpret these physically for our sea level data
## Physical Interpretation for Our Data
For GMSL data sampled monthly over ~31 years:
- **[Sampling rate](https://en.wikipedia.org/wiki/Sampling_(signal_processing))**: 12 samples/year → $f_s = 12$ year⁻¹
- **[Nyquist frequency](https://en.wikipedia.org/wiki/Nyquist_frequency)**: $f_{Nyquist} = f_s/2 = 6$ cycles/year
- **Frequency resolution**: $\Delta f = 1/(N\Delta t) \approx 1/31$ cycles/year
- **Interpretable range**: 0 to 6 cycles/year (beyond that is aliased)
## What the FFT Gives Us
The FFT output $F_n$ contains:
1. **Magnitude**: $|F_n|$ - How strong is frequency $n$?
2. **Phase**: $\arg(F_n)$ - What's the phase shift?
3. **[Power](https://en.wikipedia.org/wiki/Spectral_density#Power_spectral_density)**: $|F_n|^2$ - Energy at frequency $n$
4. **[Power Spectral Density](https://en.wikipedia.org/wiki/Spectral_density)**: $|F_n|^2/(N \cdot \Delta f)$ - Power per unit frequency
# Applying FFT to Global Mean Sea Level Data
We'll use the same GMSL data from Day 13, but now let the FFT tell us which frequencies matter!
* GMSL data from 1993-2024: [NASA Sea Level Portal](https://sealevel.nasa.gov/), or [global_mean_sea_level_1993-2024.csv (dropbox)](https://www.dropbox.com/scl/fi/9uqs42k9k3tgqwl45gdn9/global_mean_sea_level_1993-2024.csv?rlkey=d62q93mjrso72ql76dhzpolg4&dl=0), or Canvas for these data
## Python Implementation
Step 1. Load data and compute time parameters
```python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# Load data
df = pd.read_csv("global_mean_sea_level_1993-2024.csv")
df = df.dropna(subset=['GMSLWithGIA'])
t = df['YearPlusFraction'].values
S = df['GMSLWithGIA'].values
# Time parameters
dt = np.mean(np.diff(t)) # Average time step (years)
fs = 1/dt # Sampling frequency (samples/year)
N = len(S)
print(f"Data: {N} points, dt = {dt:.4f} years, fs = {fs:.2f} samples/year")
```
Purpose: Load GMSL data and establish time sampling parameters for FFT.
Step 2. Compute FFT and one-sided PSD
```python
# Compute FFT
S_fft = np.fft.fft(S)
# Create frequency array (one-sided)
freqs = np.fft.fftfreq(N, dt)[:N//2] # Positive frequencies only
# Compute one-sided PSD
# Factor of 2 accounts for negative frequencies (except [DC component](https://en.wikipedia.org/wiki/DC_bias))
psd = np.abs(S_fft[:N//2])**2 / N
psd[1:] *= 2 # Double non-DC components
psd /= fs # Normalize by sampling frequency
print(f"Frequency range: 0 to {freqs[-1]:.2f} cycles/year")
print(f"Frequency resolution: {freqs[1]:.4f} cycles/year")
print(f"Number of frequency bins: {len(freqs)}")
```
Purpose: Transform to frequency domain and compute power spectral density.
> **Note:** The FFT does **not** produce a continuous spectrum. It returns exactly $N/2$ discrete numbers — one per frequency bin. The "smooth curve" you will see in plots is just the plotting library connecting these discrete points with straight lines. There is no signal information *between* them. For this dataset, $N = 1168$, so you have $N/2 - 1 = 583$ non-DC frequency bins, spaced $\Delta f = 1/(N \cdot \Delta t) \approx 0.024$ cycles/year apart.
Step 3. Visualize PSD in both linear and log (dB) scales
```python
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
# Linear scale
ax1.plot(freqs, psd, 'b-', linewidth=0.8)
ax1.set_xlabel('Frequency (cycles/year)')
ax1.set_ylabel('PSD (mm²·year)')
ax1.set_title('Power Spectral Density - Linear Scale')
ax1.set_xlim(0, 5)
ax1.grid(True, alpha=0.3)
# Decibel scale (10*log10 for power)
psd_dB = 10 * np.log10(psd / np.max(psd)) # Normalize to max = 0 dB
ax2.plot(freqs, psd_dB, 'b-', linewidth=0.8)
ax2.set_xlabel('Frequency (cycles/year)')
ax2.set_ylabel('PSD (dB relative to peak)')
ax2.set_title('Power Spectral Density - Logarithmic Scale')
ax2.set_xlim(0, 5)
ax2.set_ylim(-60, 5)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
```
Purpose: Linear scale shows dominant frequencies clearly. [dB scale](https://en.wikipedia.org/wiki/Decibel) reveals weaker signals hidden by strong ones. The dB value is defined as $10 \log_{10}(\text{PSD}/\text{PSD}_{\max})$, so the strongest peak is always at 0 dB and everything else is negative. This is identical to how audio engineers represent the [dynamic range](https://en.wikipedia.org/wiki/Dynamic_range) of a recording — it compresses a factor-of-10,000 variation down to a readable 40 dB span.
Step 4. Identify prominent frequencies using threshold
```python
# Use threshold: peaks > 10× median power (excluding DC)
psd_no_dc = psd[1:] # Exclude DC component
threshold = 10 * np.median(psd_no_dc)
# Find frequencies above threshold
prominent_mask = psd > threshold
prominent_freqs = freqs[prominent_mask]
prominent_psd = psd[prominent_mask]
# Sort by power
sort_idx = np.argsort(prominent_psd)[::-1]
prominent_freqs = prominent_freqs[sort_idx]
prominent_psd = prominent_psd[sort_idx]
print(f"Threshold value: {threshold:.1f} mm²·year")
print(f"Number of bins above threshold: {np.sum(prominent_mask)}")
print("Prominent frequencies (cycles/year):")
for f, p in zip(prominent_freqs, prominent_psd):
if f > 0:
period = 1/f
print(f" f = {f:.3f} (period = {period:.2f} years), Power = {p:.1f}")
```
Purpose: Use a data-driven threshold to flag frequency bins with unusually high power.
> **Note:** This threshold is a **value cutoff**, not a rank-based filter. The code keeps every bin whose PSD exceeds $10 \times \text{median}$, regardless of how many that turns out to be — for our data this is roughly 68 bins. This is fundamentally different from selecting the "top 68 bins by power." In a rank-based approach, you choose a count first and let the data determine the cutoff value. Here you choose a cutoff value first and let the data determine the count. Critically, entire **high-power regions** of the spectrum will be flagged, including the slopes and saddles *between* peaks. A bin sitting in a "valley" between two large peaks can still be far above the global median. We will diagnose this problem in Step 9.
Step 5. Visualize prominent frequencies
```python
plt.figure(figsize=(10, 5))
plt.semilogy(freqs, psd, 'b-', linewidth=0.8, alpha=0.5, label='Full spectrum')
plt.semilogy(prominent_freqs[prominent_freqs > 0],
prominent_psd[prominent_freqs > 0],
'ro', markersize=8, label='Above threshold')
# Annotate expected frequencies
plt.axvline(1.0, color='orange', linestyle='--', alpha=0.5, label='Annual')
plt.axvline(2.0, color='green', linestyle='--', alpha=0.5, label='Semi-annual')
# Add threshold line
plt.axhline(threshold, color='red', linestyle=':', alpha=0.5,
label=f'10×median threshold = {threshold:.1f}')
plt.xlabel('Frequency (cycles/year)')
plt.ylabel('PSD (mm²·year, log scale)')
plt.title('Prominent Frequencies in GMSL (Day 16: FFT Discovery)')
plt.xlim(0, 3)
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
```
Purpose: Visual confirmation of the threshold selection.
> **Note:** You will likely see red dots appearing at what look like valleys or downward slopes in the spectrum. This is **expected and correct** — those are FFT bins that sit within a broad high-power region but happen to be lower than their immediate neighbors. They are above the global threshold even though they are not local maxima. Think of it like flood water: once the water level (the threshold) is high enough, it submerges entire mountain ranges, not just the peaks — including the passes and saddles between summits. This is the fundamental limitation of the threshold approach, and it is precisely what Steps 9–10 are designed to fix.
Step 6. Fourier regression on discovered frequencies
```python
# Select non-DC prominent frequencies
regression_freqs = prominent_freqs[(prominent_freqs > 0.1) & (prominent_freqs < 10)]
print(f"Regression frequencies: {regression_freqs} cycles/year")
# Build design matrix
X = np.ones((N, 1 + 2*len(regression_freqs)))
for i, freq in enumerate(regression_freqs):
X[:, 1 + 2*i] = np.cos(2*np.pi*freq*t)
X[:, 2 + 2*i] = np.sin(2*np.pi*freq*t)
# Solve normal equations: beta = (X^T X)^{-1} X^T S
beta = np.linalg.solve(X.T @ X, X.T @ S)
S_seasonal = X @ beta
# Extract just the oscillatory part (no mean)
X_no_mean = X[:, 1:]
beta_no_mean = beta[1:]
S_oscillations = X_no_mean @ beta_no_mean
print(f"Mean sea level: {beta[0]:.1f} mm")
```
Purpose: Use FFT-discovered frequencies for regression, separating mean from oscillations. Each frequency $f_k$ contributes two columns to the [design matrix](https://en.wikipedia.org/wiki/Design_matrix) — $\cos(2\pi f_k t)$ and $\sin(2\pi f_k t)$ — which together can represent an oscillation of any [amplitude](https://en.wikipedia.org/wiki/Amplitude) and [phase](https://en.wikipedia.org/wiki/Phase_(waves)) at that frequency.
Step 7. Remove seasonal and fit quadratic trend
```python
# Remove seasonal oscillations
S_deseasonalized = S - S_oscillations
# Center time for numerical stability (but interpret carefully)
t_mean = np.mean(t)
t_centered = t - t_mean
# Fit quadratic trend: S = a + b*t_c + c*t_c^2
A = np.column_stack([np.ones(N), t_centered, t_centered**2])
coeffs = np.linalg.solve(A.T @ A, A.T @ S_deseasonalized)
S_trend = A @ coeffs
# Extract coefficients
a, b, c = coeffs
print(f"Centered quadratic fit: S = {a:.1f} + {b:.2f}*t_c + {c:.4f}*t_c²")
print(f"Acceleration: {2*c:.4f} mm/year²")
# For centered model: rate = b + 2*c*(t - t_mean)
rate_1993 = b + 2*c*(t[0] - t_mean)
rate_2024 = b + 2*c*(t[-1] - t_mean)
print(f"Rate at 1993: {rate_1993:.2f} mm/year")
print(f"Rate at 2024: {rate_2024:.2f} mm/year")
```
Purpose: Quadratic trend captures the acceleration of sea level rise. The [instantaneous rate](https://en.wikipedia.org/wiki/Derivative) of rise is the derivative of the quadratic: $\frac{dS}{dt} = b + 2c \cdot t_c$. Because $c > 0$, the rate increases over time — sea level is not just rising, it is accelerating.
Step 8. Compare with Intuitive Fourier Regression (Day 13)
```python
# Day 13 - Intuitive Fourier Regression: Pre-specified k=1,2 with quadratic trend
X_intuitive = np.column_stack([np.ones(N), t_centered, t_centered**2,
np.cos(2*np.pi*1*t), np.sin(2*np.pi*1*t),
np.cos(2*np.pi*2*t), np.sin(2*np.pi*2*t)])
beta_intuitive = np.linalg.solve(X_intuitive.T @ X_intuitive, X_intuitive.T @ S)
S_intuitive = X_intuitive @ beta_intuitive
# Day 16 - Fourier Regression on Prominent Frequencies: FFT-discovered with quadratic trend
S_prominent = S_trend + S_oscillations
# Compute R²
SS_tot = np.sum((S - np.mean(S))**2)
SS_res_intuitive = np.sum((S - S_intuitive)**2)
SS_res_prominent = np.sum((S - S_prominent)**2)
R2_intuitive = 1 - SS_res_intuitive/SS_tot
R2_prominent = 1 - SS_res_prominent/SS_tot
print(f"\nModel Comparison:")
print(f"Day 13 - Intuitive Fourier Regression (2 freqs): R² = {R2_intuitive:.4f}")
print(f"Day 16 - Prominent Frequencies ({len(regression_freqs)} freqs): R² = {R2_prominent:.4f}")
print(f"Improvement: ΔR² = {R2_prominent - R2_intuitive:.4f}")
# Visualize first 5 years
idx = t < t[0] + 5
plt.figure(figsize=(10, 5))
plt.plot(t[idx], S[idx], 'ko', markersize=3, alpha=0.5, label='Data')
plt.plot(t[idx], S_intuitive[idx], 'b-', linewidth=1.5,
label=f'OG Fourier Regression (2 freqs, R²={R2_intuitive:.3f})')
plt.plot(t[idx], S_prominent[idx], 'r-', linewidth=1.5,
label=f'Prominent Frequencies ({len(regression_freqs)} freqs, R²={R2_prominent:.3f})')
plt.xlabel('Year')
plt.ylabel('Sea Level (mm)')
plt.title('Model Comparison: OG Fourier vs FFT-discovered Frequencies')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
```
Purpose: Compare FFT-discovered frequencies with our physics-based Day 13 approach.
Step 9. The problem with threshold approach
```python
# Our threshold approach found many frequencies
print(f"\nThreshold approach found {len(regression_freqs)} 'significant' frequencies")
print("But are they all real peaks? Let's investigate...")
# Plot to see the issue
plt.figure(figsize=(10, 5))
plt.semilogy(freqs, psd, 'b-', linewidth=0.8, alpha=0.5)
plt.semilogy(prominent_freqs[prominent_freqs > 0],
prominent_psd[prominent_freqs > 0],
'ro', markersize=4, alpha=0.5)
plt.axhline(threshold, color='red', linestyle=':',
label=f'Threshold = {threshold:.1f}')
plt.xlabel('Frequency (cycles/year)')
plt.ylabel('PSD (log scale)')
plt.title('Problem: Threshold selects high-power regions, not peaks!')
plt.text(2.5, threshold*2, f'{len(regression_freqs)} freqs\nabove threshold!',
color='red', fontsize=9)
plt.xlim(0, 3)
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
print("Notice: Many 'significant' points are just part of broad features, not peaks!")
```
Purpose: Visualize why the threshold approach picks up too many frequencies.
> **Note:** Look carefully at the subtitle — it says something like "Found 28 peaks among 68 points above threshold." Those are two different numbers. There are 68 **bins** above the threshold, but only 28 of those are actual local maxima. The other 40 lie on slopes and saddles within broad high-power spectral regions. They were flagged not because they are peaks but simply because the entire neighborhood around the annual and semi-annual cycles is energetic enough to clear the global threshold. This is the central issue: **a value cutoff cannot distinguish a peak from a shoulder**.
Step 10. Smart peak detection approach
```python
from scipy.signal import find_peaks
# Exclude very low frequencies (those are trend, not oscillations)
mask = freqs > 0.3
psd_for_peaks = psd.copy()
psd_for_peaks[~mask] = 0
# Find peaks with prominence (how much they stand out above local background)
peaks, properties = find_peaks(psd_for_peaks,
prominence=20, # Must stand out from neighbors
distance=int(0.2/freqs[1])) # Min separation
print(f"\nPeak detection found {len(peaks)} peaks!")
peak_freqs = freqs[peaks]
peak_psd = psd[peaks]
for f, p in zip(peak_freqs, peak_psd):
print(f" f = {f:.3f} (period = {1/f:.2f} years), Power = {p:.0f}")
# [Signal-to-noise ratio](https://en.wikipedia.org/wiki/Signal-to-noise_ratio)
window_size = int(0.5 / freqs[1])
background = np.zeros_like(psd)
for i in range(len(psd)):
start = max(0, i - window_size//2)
end = min(len(psd), i + window_size//2)
background[i] = np.percentile(psd[start:end], 25)
peak_ratio = psd / (background + 1e-10)
# Identify which detected peaks to actually use
retained_mask = np.zeros(len(peak_freqs), dtype=bool)
for i, (f, ratio_val) in enumerate(zip(peak_freqs, peak_ratio[peaks])):
if f > 0.5 and f < 4.0 and ratio_val > 5:
retained_mask[i] = True
retained_freqs = peak_freqs[retained_mask]
retained_psd = peak_psd[retained_mask]
notused_freqs = peak_freqs[~retained_mask]
notused_psd = peak_psd[~retained_mask]
print(f"\nUsing {np.sum(retained_mask)} peaks out of {len(peak_freqs)} detected")
print("Peaks to use in regression:", retained_freqs)
print("Detected but not used:", notused_freqs)
# Visualize
plt.figure(figsize=(12, 5))
plt.semilogy(freqs, psd, 'b-', linewidth=0.8, alpha=0.7)
threshold_mask = (psd > threshold) & (freqs > 0.5)
plt.semilogy(freqs[threshold_mask], psd[threshold_mask], 'o',
color='red', markersize=4, alpha=0.2,
label=f'Above threshold ({np.sum(threshold_mask)} points)')
trend_idx = np.where((freqs > 0) & (freqs < 0.3))[0]
if len(trend_idx) > 0:
max_trend_idx = trend_idx[np.argmax(psd[trend_idx])]
plt.semilogy(freqs[max_trend_idx], psd[max_trend_idx], 's',
color='grey', markersize=10,
label=f'Trend (~{1/freqs[max_trend_idx]:.0f} year "period")')
if len(notused_freqs) > 0:
plt.semilogy(notused_freqs, notused_psd, 'o', markersize=8,
color='green', alpha=0.3,
label=f'Detected, not used (n={len(notused_freqs)})')
if len(retained_freqs) > 0:
plt.semilogy(retained_freqs, retained_psd, 'o', markersize=8,
color='green', alpha=1.0,
label=f'Used in model (n={len(retained_freqs)})')
plt.axvline(1.0, color='orange', linestyle='--', alpha=0.5)
plt.axvline(2.0, color='darkgreen', linestyle='--', alpha=0.5)
plt.xlim(0, 3)
plt.xlabel('Frequency (cycles/year)')
plt.ylabel('PSD (log scale)')
plt.title('Peak Detection Process: From Threshold to Smart Selection')
plt.legend(loc='upper right', fontsize=9)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
```
Purpose: Proper peak detection requires a bin to be a **[local maximum](https://en.wikipedia.org/wiki/Maximum_and_minimum)** (greater than both neighbors) and to stand out significantly above its local background. This is fundamentally different from the global threshold — a bin can be globally high but locally unremarkable (a shoulder), and a bin can be globally modest but locally prominent (a sharp peak against a quiet background).
Step 11. Model comparison with detected peaks
```python
# Helper function for model fitting
def fit_and_evaluate(freq_list, label):
X = np.ones((N, 1 + 2*len(freq_list)))
for i, freq in enumerate(freq_list):
X[:, 1 + 2*i] = np.cos(2*np.pi*freq*t)
X[:, 2 + 2*i] = np.sin(2*np.pi*freq*t)
beta = np.linalg.solve(X.T @ X, X.T @ S)
S_fourier = X @ beta
S_oscillations = S_fourier - beta[0]
S_detrended = S - S_oscillations
A = np.column_stack([np.ones(N), t_centered, t_centered**2])
coeffs = np.linalg.solve(A.T @ A, A.T @ S_detrended)
S_trend = A @ coeffs
S_full = S_trend + S_oscillations
SS_tot = np.sum((S - np.mean(S))**2)
SS_res = np.sum((S - S_full)**2)
R2 = 1 - SS_res/SS_tot
print(f"{label:30s} | R² = {R2:.4f}")
return S_full, R2
print("Model | R²")
print("-" * 45)
if len(regression_freqs) > 20:
S_thresh, R2_thresh = fit_and_evaluate(regression_freqs[:20],
f"Thresholded ({len(regression_freqs)} freqs, cap 20)")
else:
S_thresh, R2_thresh = fit_and_evaluate(regression_freqs,
f"Thresholded ({len(regression_freqs)} freqs)")
S_allpeaks, R2_allpeaks = fit_and_evaluate(peak_freqs,
f"All Peaks ({len(peak_freqs)} freqs)")
# Snap to nearest FFT bin (not exactly 1.0 and 2.0 — the grid may not align)
intuitive_freqs = [freqs[np.argmin(np.abs(freqs - 1.0))],
freqs[np.argmin(np.abs(freqs - 2.0))]]
S_intuitive_fit, R2_intuitive_fit = fit_and_evaluate(intuitive_freqs,
"OG Fourier Regression (2 freqs)")
print("\nConclusion: Peak detection prevents overfitting!")
print("The OG Fourier Regression (intuitive approach) remains optimal.")
```
Purpose: Compare models using [$R^2$](https://en.wikipedia.org/wiki/Coefficient_of_determination) to evaluate how well each frequency selection fits the data.
> **Note:** When building the OG Fourier Regression we use `freqs[np.argmin(np.abs(freqs - 1.0))]` rather than simply `1.0`. This is because the FFT frequency grid does not necessarily include exactly 1.000 or 2.000 cycles/year — the bins are spaced $\Delta f \approx 0.024$ cycles/year apart and may land at 0.997 or 1.021. Snapping to the nearest available bin ensures our regression frequencies are consistent with the FFT output. This is related to the concept of [frequency resolution](https://en.wikipedia.org/wiki/Spectral_resolution).
---
## R Implementation
Step 1. Load data and compute time parameters
```r
library(ggplot2)
library(gridExtra)
if (exists("rstudioapi") && rstudioapi::isAvailable()) {
setwd(dirname(rstudioapi::getActiveDocumentContext()$path))
}
# Load data
df <- read.csv("global_mean_sea_level_1993-2024.csv")
df <- df[!is.na(df$GMSLWithGIA), ]
t <- df$YearPlusFraction
S <- df$GMSLWithGIA
# Time parameters
dt <- mean(diff(t))
fs <- 1/dt
N <- length(S)
cat(sprintf("Data: %d points, dt = %.4f years, fs = %.2f samples/year\n",
N, dt, fs))
```
Purpose: Load GMSL data and establish time sampling parameters for FFT.
Step 2. Compute FFT and one-sided PSD
```r
# Compute FFT
S_fft <- fft(S)
# Create frequency array (one-sided): k / (N * dt) for k = 0, 1, ..., N/2-1
freqs <- (0:(N/2-1)) / (N * dt)
# Compute one-sided PSD
psd <- Mod(S_fft[1:(N/2)])^2 / N
psd[2:length(psd)] <- 2 * psd[2:length(psd)] # Double non-DC components
psd <- psd / fs # Normalize by sampling frequency
cat(sprintf("Frequency range: 0 to %.2f cycles/year\n", tail(freqs, 1)))
cat(sprintf("Frequency resolution: %.4f cycles/year\n", freqs[2]))
cat(sprintf("Number of frequency bins: %d\n", length(freqs)))
```
Purpose: Transform to frequency domain and compute power spectral density.
> **Note:** The FFT does **not** produce a continuous spectrum. It returns exactly $N/2$ discrete numbers — one per frequency bin. The "smooth curve" you will see in plots is ggplot connecting these discrete points; there is no signal information *between* them. For this dataset, $N = 1168$, so $N/2 - 1 = 583$ non-DC frequency bins, spaced $\Delta f = 1/(N \cdot \Delta t) \approx 0.024$ cycles/year apart. The frequency array `freqs` is simply the index $k$ divided by the total time span: $f_k = k/(N \cdot \Delta t)$.
Step 3. Visualize PSD in both linear and log (dB) scales
```r
psd_df <- data.frame(freq = freqs, psd = psd)
# Linear scale plot
p1 <- ggplot(psd_df[psd_df$freq <= 5,], aes(x=freq, y=psd)) +
geom_line(color="blue", linewidth=0.8) +
labs(x="Frequency (cycles/year)", y="PSD (mm²·year)",
title="Power Spectral Density - Linear Scale") +
theme_minimal() +
theme(panel.grid.minor=element_blank())
# dB scale: 10*log10(psd/max) so that the dominant peak = 0 dB
psd_dB <- 10 * log10(psd / max(psd))
psd_df$psd_dB <- psd_dB
p2 <- ggplot(psd_df[psd_df$freq <= 5,], aes(x=freq, y=psd_dB)) +
geom_line(color="blue", linewidth=0.8) +
labs(x="Frequency (cycles/year)", y="PSD (dB relative to peak)",
title="Power Spectral Density - Logarithmic Scale") +
ylim(-60, 5) +
theme_minimal() +
theme(panel.grid.minor=element_blank())
psd_plot <- grid.arrange(p1, p2, ncol=2)
ggsave("r_fig0_psd_scales.png", psd_plot, width=12, height=4, dpi=150)
```
Purpose: Linear scale shows dominant frequencies clearly. dB scale reveals weaker signals hidden by strong ones. The dB value is $10 \log_{10}(\text{PSD}/\text{PSD}_{\max})$, so the strongest peak sits at 0 dB and everything else is negative. This compresses a factor-of-10,000 power variation into a readable 40 dB span — the same reason audio engineers use dB to describe dynamic range.
Step 4. Identify prominent frequencies using threshold
```r
# Use threshold: bins > 10× median power (excluding DC)
psd_no_dc <- psd[-1]
threshold <- 10 * median(psd_no_dc)
# Find ALL bins above threshold (not just peaks)
prominent_idx <- which(psd > threshold)
prominent_freqs <- freqs[prominent_idx]
prominent_psd <- psd[prominent_idx]
# Sort by power
sort_idx <- order(prominent_psd, decreasing=TRUE)
prominent_freqs <- prominent_freqs[sort_idx]
prominent_psd <- prominent_psd[sort_idx]
cat(sprintf("Threshold value: %.1f mm²·year\n", threshold))
cat(sprintf("Number of bins above threshold: %d\n", length(prominent_idx)))
cat("Prominent frequencies (cycles/year):\n")
for (i in 1:length(prominent_freqs)) {
f <- prominent_freqs[i]
p <- prominent_psd[i]
if (f > 0) {
period <- 1/f
cat(sprintf(" f = %.3f (period = %.2f years), Power = %.1f\n",
f, period, p))
}
}
```
Purpose: Use a data-driven threshold to flag frequency bins with unusually high power.
> **Note:** This threshold is a **value cutoff**, not a rank-based filter. The code flags every bin whose PSD exceeds $10 \times \text{median}$, regardless of how many that turns out to be. For our data this is roughly 68 bins — but that count is a consequence of the data, not a deliberate choice. Compare this to selecting the "top 68 bins by power": in that case you choose the count first and the threshold falls where it falls. Here you choose the threshold first and accept however many bins clear it. Crucially, entire **high-power spectral regions** will be flagged, including the slopes and saddles between actual peaks. A bin sitting in a "valley" between two large peaks can still be far above the global median. We will diagnose this in Step 9.
Step 5. Visualize prominent frequencies
```r
plot_df <- data.frame(freq = freqs, psd = psd)
prominent_df <- data.frame(
freq = prominent_freqs[prominent_freqs > 0],
psd = prominent_psd[prominent_freqs > 0]
)
p <- ggplot(plot_df[plot_df$freq <= 3,], aes(x=freq, y=psd)) +
geom_line(color="blue", linewidth=0.8, alpha=0.5) +
geom_point(data=prominent_df[prominent_df$freq <= 3,],
aes(x=freq, y=psd), color="red", size=3) +
geom_vline(xintercept=1.0, color="orange", linetype="dashed", alpha=0.5) +
geom_vline(xintercept=2.0, color="green", linetype="dashed", alpha=0.5) +
geom_hline(yintercept=threshold, color="red", linetype="dotted", alpha=0.5) +
scale_y_log10() +
labs(x="Frequency (cycles/year)", y="PSD (mm²·year, log scale)",
title="Prominent Frequencies in GMSL (Day 16: Fourier Regression on Prominent Frequencies)",
subtitle=sprintf("Red dotted line = 10×median threshold = %.1f", threshold)) +
theme_minimal() +
annotate("text", x=1.0, y=max(psd)/2, label="Annual",
color="orange", angle=90, vjust=-0.5) +
annotate("text", x=2.0, y=max(psd)/2, label="Semi-annual",
color="green", angle=90, vjust=-0.5)
print(p)
ggsave("r_fig1_prominent_frequencies.png", p, width=10, height=5, dpi=150)
```
Purpose: Visual confirmation of the threshold selection.
> **Note:** You will likely see red dots appearing at what look like valleys or downward slopes in the spectrum. This is **expected and correct**. Remember: the FFT spectrum is a set of discrete points connected by lines — there is no actual data between them. A red dot in an apparent "valley" is simply an FFT bin that (a) sits within a broad high-power region so it clears the global threshold, but (b) happens to be lower than its two immediate neighbors. The plotting library draws a connecting line between neighboring bins, making it look like a valley, but the bin itself is above the threshold. This is the problem Steps 9–10 are designed to solve.
Step 6. Fourier regression on discovered frequencies
```r
# Select non-DC prominent frequencies for regression
regression_freqs <- prominent_freqs[(prominent_freqs > 0.1) &
(prominent_freqs < 10)]
cat(sprintf("Using %d regression frequencies\n", length(regression_freqs)))
cat("Regression frequencies:", round(regression_freqs, 3), "cycles/year\n")
# Build design matrix: intercept + cos/sin pairs for each frequency
X <- matrix(1, nrow=N, ncol=1 + 2*length(regression_freqs))
for (i in 1:length(regression_freqs)) {
freq <- regression_freqs[i]
X[, 1 + 2*(i-1) + 1] <- cos(2*pi*freq*t)
X[, 1 + 2*(i-1) + 2] <- sin(2*pi*freq*t)
}
# Solve normal equations: beta = (X^T X)^{-1} X^T S
beta <- solve(t(X) %*% X) %*% (t(X) %*% S)
S_seasonal <- X %*% beta
# Extract just the oscillatory part (no mean)
X_no_mean <- X[, -1, drop=FALSE]
beta_no_mean <- beta[-1]
S_oscillations <- X_no_mean %*% beta_no_mean
cat(sprintf("Mean sea level: %.1f mm\n", beta[1]))
```
Purpose: Use FFT-discovered frequencies for regression, separating mean from oscillations. Each frequency $f_k$ contributes two columns — $\cos(2\pi f_k t)$ and $\sin(2\pi f_k t)$ — which together represent an oscillation of any amplitude and phase at that frequency.
Step 7. Remove seasonal and fit quadratic trend
```r
# Remove seasonal oscillations from signal
S_deseasonalized <- S - S_oscillations
# Center time for numerical stability
t_mean <- mean(t)
t_centered <- t - t_mean
# Fit quadratic trend: S = a + b*t_c + c*t_c^2
A <- cbind(1, t_centered, t_centered^2)
coeffs <- solve(t(A) %*% A) %*% (t(A) %*% S_deseasonalized)
S_trend <- A %*% coeffs
a <- coeffs[1]
b <- coeffs[2]
c <- coeffs[3]
cat(sprintf("Centered quadratic fit: S = %.1f + %.2f*t_c + %.4f*t_c²\n", a, b, c))
cat(sprintf("Acceleration: %.4f mm/year²\n", 2*c))
# Instantaneous rate = b + 2*c*(t - t_mean)
rate_1993 <- b + 2*c*(t[1] - t_mean)
rate_2024 <- b + 2*c*(tail(t, 1) - t_mean)
rate_center <- b
cat(sprintf("Rate at 1993: %.2f mm/year\n", rate_1993))
cat(sprintf("Rate at 2024: %.2f mm/year\n", rate_2024))
cat(sprintf("Rate at center (%.1f): %.2f mm/year\n", t_mean, rate_center))
```
Purpose: [Quadratic trend](https://en.wikipedia.org/wiki/Polynomial_regression) captures the acceleration of sea level rise. The instantaneous rate of rise is $dS/dt = b + 2c \cdot t_c$. Because $c > 0$, the rate increases over time. The coefficient $c$ itself is half the acceleration: acceleration $= 2c$ mm/year².
Step 8. Compare with Intuitive Fourier Regression (Day 13)
```r
# Day 13 - Intuitive Fourier Regression: Pre-specified k=1,2 with quadratic trend
X_intuitive <- cbind(1, t_centered, t_centered^2,
cos(2*pi*1*t), sin(2*pi*1*t),
cos(2*pi*2*t), sin(2*pi*2*t))
beta_intuitive <- solve(t(X_intuitive) %*% X_intuitive) %*% (t(X_intuitive) %*% S)
S_intuitive <- X_intuitive %*% beta_intuitive
# Day 16 - FFT-discovered: quadratic trend + oscillations
S_prominent <- S_trend + S_oscillations
# Compute R²
SS_tot <- sum((S - mean(S))^2)
SS_res_intuitive <- sum((S - S_intuitive)^2)
SS_res_prominent <- sum((S - S_prominent)^2)
R2_intuitive <- 1 - SS_res_intuitive/SS_tot
R2_prominent <- 1 - SS_res_prominent/SS_tot
cat("\nModel Comparison:\n")
cat(sprintf("Day 13 - OG Fourier Regression (2 freqs): R² = %.4f\n", R2_intuitive))
cat(sprintf("Day 16 - Prominent Frequencies (%d freqs): R² = %.4f\n",
length(regression_freqs), R2_prominent))
cat(sprintf("Improvement: ΔR² = %.4f\n", R2_prominent - R2_intuitive))
# Visualize first 5 years
idx <- which(t < t[1] + 5)
# Pre-compute label strings so factor levels and color keys match exactly
label_og <- sprintf("OG Fourier Regression (2 freqs, R2=%.3f)", R2_intuitive)
label_prom <- sprintf("Prominent Frequencies (%d freqs, R2=%.3f)",
length(regression_freqs), R2_prominent)
comparison_df <- data.frame(
t = rep(t[idx], 3),
S = c(S[idx], S_intuitive[idx], S_prominent[idx]),
Model = factor(rep(c("Data", label_og, label_prom), each=length(idx)),
levels=c("Data", label_og, label_prom))
)
p <- ggplot(comparison_df, aes(x=t, y=S, color=Model)) +
geom_point(data=comparison_df[comparison_df$Model=="Data",],
size=1, alpha=0.5) +
geom_line(data=comparison_df[comparison_df$Model!="Data",],
linewidth=1.2, alpha=0.8) +
scale_color_manual(values=setNames(c("black", "blue", "red"),
c("Data", label_og, label_prom))) +
labs(x="Year", y="Sea Level (mm)",
title="Model Comparison: OG Fourier vs FFT-discovered Frequencies") +
theme_minimal() +
theme(legend.position="top")
print(p)
ggsave("r_fig_step8_model_comparison.png", p, width=10, height=5, dpi=150)
```
Purpose: Compare FFT-discovered frequencies with our physics-based Day 13 approach.
Step 9. The problem with threshold approach
```r
cat(sprintf("\nThreshold approach found %d bins above threshold (not all are peaks!)\n",
length(prominent_freqs[prominent_freqs > 0.1])))
cat("But are they all real peaks? Let's investigate...\n")
psd_plot_df <- data.frame(freq = freqs, psd = psd)
prominent_df <- data.frame(freq = prominent_freqs[prominent_freqs > 0],
psd = prominent_psd[prominent_freqs > 0])
p <- ggplot(psd_plot_df[psd_plot_df$freq <= 3,], aes(x=freq, y=psd)) +
geom_line(color="blue", alpha=0.5, linewidth=0.8) +
geom_point(data=prominent_df[prominent_df$freq <= 3,],
color="red", size=2, alpha=0.5) +
geom_hline(yintercept=threshold, color="red", linetype="dashed", alpha=0.5) +
scale_y_log10() +
labs(x="Frequency (cycles/year)", y="PSD (log scale)",
title="Problem: Threshold selects high-power regions, not peaks!",
subtitle=sprintf("Red dashed line = threshold = %.1f; Found %d peaks among %d points above threshold",
threshold, length(regression_freqs), length(prominent_idx))) +
theme_minimal()
print(p)
ggsave("r_fig2_threshold_problem.png", p, width=10, height=5, dpi=150)
cat("Notice: Many 'significant' points are just part of broad features, not peaks!\n")
```
Purpose: Visualize why the threshold approach picks up too many frequencies.
> **Note:** The subtitle of this plot tells the story: there are many **bins** above the threshold but only a fraction are actual local maxima. The rest are on slopes and broad shoulders around the annual and semi-annual peaks. A value cutoff cannot distinguish the summit from the hillside — it just tells you everything above a certain elevation.
Step 10. Smart peak detection approach
```r
# Focus on oscillations — zero out the low-frequency trend region
mask <- freqs > 0.3
psd_for_peaks <- psd
psd_for_peaks[!mask] <- 0
# Enhanced peak detection: require local maximum AND prominence above local background
find_peaks <- function(x, min_distance=1, prominence_threshold=20) {
peaks <- c()
for (i in 2:(length(x)-1)) {
if (x[i] > x[i-1] & x[i] > x[i+1]) {
if (length(peaks) == 0 || (i - tail(peaks, 1)) >= min_distance) {
# Prominence check: peak must stand out from local neighborhood
window <- 10
start <- max(1, i - window)
end <- min(length(x), i + window)
local_background <- median(x[start:end])
prominence <- x[i] - local_background
if (prominence > prominence_threshold) {
peaks <- c(peaks, i)
}
}
}
}
return(peaks)
}
min_distance <- round(0.2 / freqs[2]) # 0.2 cycles/year minimum separation
peaks <- find_peaks(psd_for_peaks, min_distance, prominence_threshold=20)
cat(sprintf("\nFound %d distinct peaks:\n", length(peaks)))
cat("Freq (cy/yr) | Period (yr) | Power\n")
cat("-------------|-------------|-------\n")
peak_freqs <- c()
peak_powers <- c()
peak_indices <- c()
for (peak_idx in peaks) {
f <- freqs[peak_idx]
p <- psd[peak_idx]
peak_freqs <- c(peak_freqs, f)
peak_powers <- c(peak_powers, p)
peak_indices <- c(peak_indices, peak_idx)
cat(sprintf("%12.3f | %11.2f | %6.0f\n", f, 1/f, p))
}
# [Signal-to-noise ratio](https://en.wikipedia.org/wiki/Signal-to-noise_ratio) (kept for reference output)
window_size <- round(0.5 / freqs[2])
background <- numeric(length(psd))
for (i in 1:length(psd)) {
start <- max(1, i - window_size/2)
end <- min(length(psd), i + window_size/2)
background[i] <- quantile(psd[start:end], 0.25)
}
peak_ratio <- psd / (background + 1e-10)
cat(sprintf("\nUsing all %d detected peaks in regression\n", length(peak_freqs)))
# Find the dominant low-frequency peak (trend, not oscillation)
low_freq_idx <- which(freqs > 0 & freqs < 0.3)
if (length(low_freq_idx) > 0) {
max_low_freq_idx <- low_freq_idx[which.max(psd[low_freq_idx])]
trend_point_df <- data.frame(
freq = freqs[max_low_freq_idx],
psd = psd[max_low_freq_idx]
)
}
# Build visualization
threshold_mask <- (psd > threshold) & (freqs > 0.5)
threshold_df <- data.frame(freq=freqs[threshold_mask], psd=psd[threshold_mask])
# All detected oscillatory peaks
peak_df <- data.frame(freq=peak_freqs, psd=peak_powers)
psd_df <- data.frame(freq=freqs, psd=psd, ratio=peak_ratio)
p <- ggplot(psd_df[psd_df$freq <= 3,], aes(x=freq, y=psd)) +
geom_line(color="blue", alpha=0.7, linewidth=0.8)
if (nrow(threshold_df) > 0)
p <- p + geom_point(data=threshold_df[threshold_df$freq <= 3,],
aes(x=freq, y=psd), color="red", alpha=0.2, size=2)
if (nrow(peak_df) > 0) {
p <- p + geom_point(data=peak_df[peak_df$freq <= 3,],
aes(x=freq, y=psd), color="green", alpha=1.0, size=4)
for (i in 1:nrow(peak_df)) {
f <- peak_df$freq[i]; pd <- peak_df$psd[i]
if (abs(f - 1.0) < 0.1)
p <- p + annotate("text", x=f, y=pd*2, label="Annual", hjust=0.5, size=3)
else if (abs(f - 2.0) < 0.1)
p <- p + annotate("text", x=f, y=pd*2, label="Semi-annual", hjust=0.5, size=3)
}
}
p <- p +
geom_vline(xintercept=1.0, color="orange", linetype="dashed", alpha=0.5) +
geom_vline(xintercept=2.0, color="darkgreen", linetype="dashed", alpha=0.5) +
scale_y_log10() +
labs(x="Frequency (cycles/year)", y="PSD (log scale)",
title="Peak Detection Process: From Threshold to Smart Selection") +
theme_minimal() +
theme(panel.grid.minor=element_blank()) +
annotate("text", x=2.5, y=max(psd)*0.9, label="● Above threshold",
color="red", alpha=0.2, hjust=0, size=3) +
annotate("text", x=2.5, y=max(psd)*0.6, label="■ Trend (polynomial regression)",
color="grey", hjust=0, size=3) +
annotate("text", x=2.5, y=max(psd)*0.4, label="● Detected peaks (used in model)",
color="green", hjust=0, size=3)
# Grey box for trend peak — added last for visibility
if (exists("trend_point_df") && nrow(trend_point_df) > 0)
p <- p + geom_point(data=trend_point_df, aes(x=freq, y=psd),
color="grey", shape=15, size=3)
print(p)
ggsave("r_fig3_peak_detection.png", p, width=12, height=5, dpi=150)
```
Purpose: Proper peak detection requires a bin to be a local maximum **and** stand above its local background — this filters out the slopes and shoulders that the global threshold included, leaving only genuine spectral peaks to use in regression.
Step 11. Model comparison with peak-based selection
```r
fit_with_freqs <- function(freqs_to_use, t, S, label) {
N <- length(t)
X <- matrix(1, nrow=N, ncol=1 + 2*length(freqs_to_use))
for (i in seq_along(freqs_to_use)) {
freq <- freqs_to_use[i]
X[, 1 + 2*(i-1) + 1] <- cos(2*pi*freq*t)
X[, 1 + 2*(i-1) + 2] <- sin(2*pi*freq*t)
}
beta <- solve(t(X) %*% X) %*% (t(X) %*% S)
S_fourier <- X %*% beta
S_oscillations <- S_fourier - beta[1]
S_detrended <- S - S_oscillations
t_centered <- t - mean(t)
X_trend <- cbind(1, t_centered, t_centered^2)
beta_trend <- solve(t(X_trend) %*% X_trend) %*% (t(X_trend) %*% S_detrended)
S_trend <- X_trend %*% beta_trend
S_full <- S_trend + S_oscillations
SS_tot <- sum((S - mean(S))^2)
SS_res <- sum((S - S_full)^2)
R2 <- 1 - SS_res/SS_tot
return(list(R2=R2, S_fit=S_full))
}
cat("\nModel Selection Results:\n")
cat("Model | Frequencies | R²\n")
cat("-----------------------|-------------|--------\n")
# Threshold approach
nfreqs_thresh <- min(length(regression_freqs), 20)
result_thresh <- fit_with_freqs(regression_freqs[1:nfreqs_thresh], t, S, "Threshold")
cat(sprintf("Threshold Approach | %d (cap 20) | %.4f\n",
length(regression_freqs), result_thresh$R2))
# All detected peaks
if (length(peak_freqs) > 0) {
result_peaks <- fit_with_freqs(peak_freqs, t, S, "Detected Peaks")
cat(sprintf("Detected Peaks | %d | %.4f\n",
length(peak_freqs), result_peaks$R2))
}
# OG Fourier Regression: snap to nearest FFT bin
closest_1 <- freqs[which.min(abs(freqs - 1.0))]
closest_2 <- freqs[which.min(abs(freqs - 2.0))]
result_og <- fit_with_freqs(c(closest_1, closest_2), t, S, "OG Fourier")
cat(sprintf("OG Fourier Regression | 2 | %.4f\n",
result_og$R2))
cat("\nConclusion: The OG Fourier Regression (annual + semi-annual) remains optimal.\n")
cat("FFT validates our physical intuition from Day 13!\n")
# Visualization
idx_5yr <- t < t[1] + 5
plot_df_5yr <- data.frame(
t = rep(t[idx_5yr], 4),
S = c(S[idx_5yr],
result_thresh$S_fit[idx_5yr],
if(exists("result_peaks")) result_peaks$S_fit[idx_5yr] else S[idx_5yr],
result_og$S_fit[idx_5yr]),
Model = factor(rep(c("Data", "Threshold Approach", "Detected Peaks",
"OG Fourier Regression"), each=sum(idx_5yr)))
)
p1 <- ggplot(plot_df_5yr, aes(x=t, y=S, color=Model)) +
geom_point(data=plot_df_5yr[plot_df_5yr$Model=="Data",], size=1, alpha=0.5) +
geom_line(data=plot_df_5yr[plot_df_5yr$Model!="Data",], linewidth=1.2, alpha=0.8) +
scale_color_manual(values=c("Data"="black", "Threshold Approach"="red",
"Detected Peaks"="green",
"OG Fourier Regression"="blue")) +
labs(x="", y="Sea Level (mm)", title="Model Comparison: First 5 Years Detail") +
theme_minimal() + theme(legend.position="top", legend.text=element_text(size=8))
plot_df_full <- data.frame(
t = rep(t, 4),
S = c(S, result_thresh$S_fit,
if(exists("result_peaks")) result_peaks$S_fit else S,
result_og$S_fit),
Model = factor(rep(c("Data", "Threshold Approach", "Detected Peaks",
"OG Fourier Regression"), each=N))
)
p2 <- ggplot(plot_df_full, aes(x=t, y=S, color=Model)) +
geom_line(data=plot_df_full[plot_df_full$Model=="Data",],
linewidth=0.3, alpha=0.3) +
geom_line(data=plot_df_full[plot_df_full$Model!="Data",],
linewidth=0.8, alpha=0.6) +
scale_color_manual(values=c("Data"="black", "Threshold Approach"="red",
"Detected Peaks"="green",
"OG Fourier Regression"="blue")) +
labs(x="Year", y="Sea Level (mm)", title="Model Comparison: Full Time Series") +
theme_minimal() + theme(legend.position="top", legend.text=element_text(size=8))
combined_plot <- grid.arrange(p1, p2, ncol=1, heights=c(1, 1))
ggsave("r_fig4_model_comparison.png", combined_plot, width=12, height=8, dpi=150)
```
Purpose: Compare models using [$R^2$](https://en.wikipedia.org/wiki/Coefficient_of_determination) to evaluate how well each frequency selection fits the data.
> **Note:** When building the OG model, we use `which.min(abs(freqs - 1.0))` to find the FFT bin *nearest* to 1.0 cycles/year rather than assuming the grid lands there exactly. The FFT frequency bins are spaced $\Delta f \approx 0.024$ cycles/year apart and may not include exactly 1.000 or 2.000. Snapping to the nearest bin keeps the model consistent with the actual FFT output.
---
## MATLAB/Octave Implementation
Step 1. Load data and compute time parameters
```matlab
% Load data
data = readtable('global_mean_sea_level_1993-2024.csv');
valid = ~isnan(data.GMSLWithGIA);
t = data.YearPlusFraction(valid);
S = data.GMSLWithGIA(valid);
% Time parameters
dt = mean(diff(t));
fs = 1/dt;
N = length(S);
fprintf('Data: %d points, dt = %.4f years, fs = %.2f samples/year\n', ...
N, dt, fs);
```
Purpose: Load GMSL data and establish time sampling parameters for FFT.
Step 2. Compute FFT and one-sided PSD
```matlab
% Compute FFT
S_fft = fft(S);
% Create frequency array (one-sided): k / (N*dt) for k = 0, 1, ..., N/2-1
freqs = (0:N/2-1) / (N * dt);
% Compute one-sided PSD
psd = abs(S_fft(1:N/2)).^2 / N;
psd(2:end) = 2 * psd(2:end); % Double non-DC components
psd = psd / fs; % Normalize by sampling frequency
fprintf('Frequency range: 0 to %.2f cycles/year\n', freqs(end));
fprintf('Frequency resolution: %.4f cycles/year\n', freqs(2));
fprintf('Number of frequency bins: %d\n', length(freqs));
```
Purpose: Transform to frequency domain and compute power spectral density.
> **Note:** The FFT does **not** produce a continuous spectrum. It returns exactly $N/2$ discrete numbers — one per frequency bin. The smooth curve you see in plots is MATLAB connecting these discrete points with straight lines; there is no signal information *between* them. For this dataset, $N = 1168$, giving $N/2 - 1 = 583$ non-DC frequency bins spaced $\Delta f = 1/(N \cdot \Delta t) \approx 0.024$ cycles/year apart. The frequency array is constructed as $f_k = k / (N \cdot \Delta t)$ for $k = 0, 1, \ldots, N/2 - 1$.
Step 3. Visualize PSD in both linear and log (dB) scales
```matlab
figure('Position', [100, 100, 1200, 400]);
% Linear scale
subplot(1, 2, 1);
plot(freqs, psd, 'b-', 'LineWidth', 0.8);
xlabel('Frequency (cycles/year)');
ylabel('PSD (mm²·year)');
title('Power Spectral Density - Linear Scale');
xlim([0, 5]);
grid on;
% dB scale: 10*log10(psd/max) so the dominant peak = 0 dB
subplot(1, 2, 2);
psd_dB = 10 * log10(psd / max(psd));
plot(freqs, psd_dB, 'b-', 'LineWidth', 0.8);
xlabel('Frequency (cycles/year)');
ylabel('PSD (dB relative to peak)');
title('Power Spectral Density - Logarithmic Scale');
xlim([0, 5]);
ylim([-60, 5]);
grid on;
```
Purpose: Linear scale shows dominant frequencies clearly. dB scale reveals weaker signals hidden by strong ones. The dB value is $10 \log_{10}(\text{PSD}/\text{PSD}_{\max})$, so the dominant peak sits at 0 dB and everything else is negative. This compresses a factor-of-10,000 variation into a readable 40 dB span.
Step 4. Identify prominent frequencies using threshold
```matlab
% Use threshold: bins > 10x median power (excluding DC)
psd_no_dc = psd(2:end);
threshold = 10 * median(psd_no_dc);
% Find ALL bins above threshold (not just peaks — see Step 9 for the distinction)
prominent_idx = find(psd > threshold);
prominent_freqs = freqs(prominent_idx);
prominent_psd = psd(prominent_idx);
% Sort by power
[prominent_psd, sort_idx] = sort(prominent_psd, 'descend');
prominent_freqs = prominent_freqs(sort_idx);
fprintf('Threshold value: %.1f mm^2·year\n', threshold);
fprintf('Number of bins above threshold: %d\n', length(prominent_idx));
fprintf('Prominent frequencies (cycles/year):\n');
for i = 1:length(prominent_freqs)
f = prominent_freqs(i);
p = prominent_psd(i);
if f > 0
period = 1/f;
fprintf(' f = %.3f (period = %.2f years), Power = %.1f\n', ...
f, period, p);
end
end
```
Purpose: Use a data-driven threshold to flag frequency bins with unusually high power.
> **Note:** This threshold is a **value cutoff**, not a rank-based filter. `find(psd > threshold)` returns every bin above $10 \times \text{median}$, regardless of how many that turns out to be — for our data roughly 68 bins. This is different from selecting the "top 68 by power." In a rank-based approach you choose a count first; here you choose a cutoff value first and the count is determined by the data. Crucially, entire **high-power spectral regions** are flagged, including slopes and saddles between actual peaks. A bin sitting in a "valley" between two large peaks can still be far above the global median. We will diagnose this problem in Step 9.
Step 5. Visualize prominent frequencies
```matlab
figure('Position', [100, 100, 800, 400]);
semilogy(freqs, psd, 'b-', 'LineWidth', 1);
hold on;
prominent_freqs_pos = prominent_freqs(prominent_freqs > 0);
prominent_psd_pos = prominent_psd(prominent_freqs > 0);
semilogy(prominent_freqs_pos, prominent_psd_pos, 'ro', 'MarkerSize', 8, ...
'DisplayName', 'Above threshold');
yline(threshold, 'r:', 'LineWidth', 1.2);
xline(1.0, '--', 'Color', [1 0.5 0], 'Alpha', 0.5, 'Label', 'Annual');
xline(2.0, 'g--', 'Alpha', 0.5, 'Label', 'Semi-annual');
xlabel('Frequency (cycles/year)');
ylabel('PSD (mm^2·year, log scale)');
title('Prominent Frequencies in GMSL');
subtitle(sprintf('Red dotted line = 10×median threshold = %.1f', threshold));
xlim([0 3]);
grid on;
legend('PSD', 'Above threshold', 'Location', 'northeast');
```
Purpose: Visual confirmation of the threshold selection.
> **Note:** You will likely see red dots appearing at what look like valleys or slopes in the spectrum. This is **expected and correct**. The FFT spectrum is a discrete set of points connected by lines — there is no actual data between them. A red dot in an apparent "valley" is an FFT bin that (a) sits within a broad high-power region and clears the global threshold, but (b) is lower than its two immediate neighbors so the connecting lines make it look like a trough. This is the fundamental weakness of the threshold approach, and exactly what Steps 9–10 are designed to address.
Step 6. Fourier regression on discovered frequencies
```matlab
% Select non-DC prominent frequencies for regression
regression_freqs = prominent_freqs(prominent_freqs > 0.1 & prominent_freqs < 10);
fprintf('Using %d regression frequencies\n', length(regression_freqs));
fprintf('Regression frequencies: ');
fprintf('%.3f ', regression_freqs);
fprintf('cycles/year\n');
% Build design matrix: intercept + cos/sin pairs for each frequency
X = ones(N, 1 + 2*length(regression_freqs));
for i = 1:length(regression_freqs)
freq = regression_freqs(i);
X(:, 1 + 2*(i-1) + 1) = cos(2*pi*freq*t);
X(:, 1 + 2*(i-1) + 2) = sin(2*pi*freq*t);
end
% Solve normal equations: beta = (X'X)^{-1} X'S
beta = (X' * X) \ (X' * S);
S_seasonal = X * beta;
% Extract oscillatory part (remove mean)
S_oscillations = S_seasonal - beta(1);
fprintf('Mean sea level: %.1f mm\n', beta(1));
```
Purpose: Use FFT-discovered frequencies for regression, separating mean from oscillations. Each frequency $f_k$ contributes two columns — $\cos(2\pi f_k t)$ and $\sin(2\pi f_k t)$ — representing an oscillation of arbitrary amplitude and phase at that frequency.
Step 7. Remove seasonal and fit quadratic trend
```matlab
% Remove seasonal oscillations
S_deseasonalized = S - S_oscillations;
% Center time for numerical stability
t_mean = mean(t);
t_centered = t - t_mean;
% Fit quadratic trend: S = a + b*t_c + c*t_c^2
A = [ones(N, 1), t_centered, t_centered.^2];
coeffs = (A' * A) \ (A' * S_deseasonalized);
S_trend = A * coeffs;
a = coeffs(1);
b = coeffs(2);
c = coeffs(3);
fprintf('Centered quadratic fit: S = %.1f + %.2f*t_c + %.4f*t_c^2\n', a, b, c);
fprintf('Acceleration: %.4f mm/year^2\n', 2*c);
% Instantaneous rate = b + 2*c*(t - t_mean)
rate_1993 = b + 2*c*(t(1) - t_mean);
rate_2024 = b + 2*c*(t(end) - t_mean);
fprintf('Rate at 1993: %.2f mm/year\n', rate_1993);
fprintf('Rate at 2024: %.2f mm/year\n', rate_2024);
```
Purpose: Quadratic trend captures the acceleration of sea level rise. The instantaneous rate of rise is $dS/dt = b + 2c \cdot t_c$. Because $c > 0$, the rate increases over time — sea level is not just rising, it is accelerating. The coefficient $c$ is half the acceleration: acceleration $= 2c$ mm/year².
Step 8. Compare with Intuitive Fourier Regression (Day 13)
```matlab
% Day 13 - Intuitive Fourier Regression: Pre-specified k=1,2 with quadratic trend
X_intuitive = [ones(N,1), t_centered, t_centered.^2, ...
cos(2*pi*1*t), sin(2*pi*1*t), ...
cos(2*pi*2*t), sin(2*pi*2*t)];
beta_intuitive = (X_intuitive' * X_intuitive) \ (X_intuitive' * S);
S_intuitive = X_intuitive * beta_intuitive;
% Day 16 - FFT-discovered: quadratic trend + oscillations
S_prominent = S_trend + S_oscillations;
% Compute R²
SS_tot = sum((S - mean(S)).^2);
R2_intuitive = 1 - sum((S - S_intuitive).^2) / SS_tot;
R2_prominent = 1 - sum((S - S_prominent).^2) / SS_tot;
fprintf('\nModel Comparison:\n');
fprintf('Day 13 - OG Fourier Regression (2 freqs): R² = %.4f\n', R2_intuitive);
fprintf('Day 16 - Prominent Frequencies (%d freqs): R² = %.4f\n', ...
length(regression_freqs), R2_prominent);
fprintf('Improvement: dR² = %.4f\n', R2_prominent - R2_intuitive);
% Visualize first 5 years
idx = t < t(1) + 5;
figure('Position', [100, 100, 800, 400]);
plot(t(idx), S(idx), 'ko', 'MarkerSize', 3, 'MarkerFaceColor', 'k');
hold on;
plot(t(idx), S_intuitive(idx), 'b-', 'LineWidth', 1.5, ...
'DisplayName', sprintf('OG Fourier Regression (2 freqs, R²=%.3f)', R2_intuitive));
plot(t(idx), S_prominent(idx), 'r-', 'LineWidth', 1.5, ...
'DisplayName', sprintf('Prominent Frequencies (%d freqs, R²=%.3f)', ...
length(regression_freqs), R2_prominent));
xlabel('Year'); ylabel('Sea Level (mm)');
title('Model Comparison: OG Fourier vs FFT-discovered Frequencies');
legend('Location', 'northwest');
grid on;
```
Purpose: Compare FFT-discovered frequencies with our physics-based Day 13 approach.
Step 9. The problem with threshold approach
```matlab
fprintf('\nThreshold approach found %d bins above threshold — not all are peaks!\n', ...
length(prominent_freqs(prominent_freqs > 0.1)));
fprintf('But are they all real peaks? Let''s investigate...\n');
figure('Position', [100, 100, 800, 400]);
semilogy(freqs, psd, 'b-', 'LineWidth', 0.8, 'Alpha', 0.5);
hold on;
semilogy(prominent_freqs(prominent_freqs > 0), ...
prominent_psd(prominent_freqs > 0), 'ro', 'MarkerSize', 4, 'Alpha', 0.5);
yline(threshold, 'r:', 'LineWidth', 1.2);
xlabel('Frequency (cycles/year)');
ylabel('PSD (log scale)');
title('Problem: Threshold selects high-power regions, not peaks!');
subtitle(sprintf('Threshold = %.1f; %d bins above threshold', ...
threshold, length(prominent_idx)));
xlim([0 3]);
grid on;
fprintf('Notice: Many significant points are on slopes, not at peaks!\n');
```
Purpose: Visualize why the threshold approach picks up too many frequencies.
> **Note:** The subtitle says something like "68 bins above threshold." Of those, only a fraction are actual local maxima. The rest are on slopes and broad shoulders around the annual and semi-annual peaks. A value cutoff cannot distinguish the summit from the hillside — it just flags everything above a certain altitude. This is the central limitation we address in Step 10.
Step 10. Smart peak detection approach
```matlab
% Focus on oscillations — zero out the low-frequency trend region
mask = freqs > 0.3;
psd_for_peaks = psd;
psd_for_peaks(~mask) = 0;
% Find local maxima with minimum separation and prominence requirement
min_distance = round(0.2 / freqs(2)); % 0.2 cycles/year minimum separation
peaks = [];
for i = 2:(length(psd_for_peaks)-1)
% Must be a local maximum
if psd_for_peaks(i) > psd_for_peaks(i-1) && psd_for_peaks(i) > psd_for_peaks(i+1)
% Must have minimum separation from last detected peak
if isempty(peaks) || (i - peaks(end)) >= min_distance
% Prominence check: must stand out above local background
window = 10;
local_start = max(1, i - window);
local_end = min(length(psd_for_peaks), i + window);
local_bg = median(psd_for_peaks(local_start:local_end));
if (psd_for_peaks(i) - local_bg) > 20
peaks = [peaks, i];
end
end
end
end
% [Signal-to-noise ratio](https://en.wikipedia.org/wiki/Signal-to-noise_ratio) for all bins
window_size = round(0.5 / freqs(2));
background = zeros(size(psd));
for i = 1:length(psd)
start_idx = max(1, i - floor(window_size/2));
end_idx = min(length(psd), i + floor(window_size/2));
background(i) = prctile(psd(start_idx:end_idx), 25);
end
peak_ratio = psd ./ (background + 1e-10);
% Extract peak properties
peak_freqs = freqs(peaks);
peak_powers = psd(peaks);
peak_ratios = peak_ratio(peaks);
fprintf('\nFound %d distinct peaks:\n', length(peaks));
fprintf('Freq (cy/yr) | Period (yr) | Power\n');
fprintf('-------------|-------------|-------\n');
for i = 1:length(peak_freqs)
fprintf('%12.3f | %11.2f | %6.0f\n', peak_freqs(i), 1/peak_freqs(i), peak_powers(i));
end
% Filter: keep oscillatory peaks (f > 0.5), high signal/background, physically reasonable
retained_mask = (peak_freqs > 0.5) & (peak_freqs < 4.0) & (peak_ratios > 5);
trend_mask = peak_freqs < 0.5;
retained_freqs = peak_freqs(retained_mask);
retained_psd = peak_powers(retained_mask);
rejected_freqs = peak_freqs(~retained_mask & ~trend_mask);
rejected_psd = peak_powers(~retained_mask & ~trend_mask);
fprintf('\nRetained %d peaks out of %d detected\n', sum(retained_mask), length(peak_freqs));
if sum(retained_mask) > 0
fprintf('Retained peaks (cycles/year): ');
fprintf('%.3f ', retained_freqs);
fprintf('\n');
end
% Visualization
threshold_mask = (psd > threshold) & (freqs > 0.5)';
figure('Position', [100, 100, 900, 400]);
semilogy(freqs, psd, 'b-', 'LineWidth', 0.8, 'Alpha', 0.7, 'DisplayName', 'PSD');
hold on;
if any(threshold_mask)
h = semilogy(freqs(threshold_mask), psd(threshold_mask), 'ro', 'MarkerSize', 4);
h.Color(4) = 0.2;
h.DisplayName = sprintf('Above threshold (%d points)', sum(threshold_mask));
end
% Grey square for trend peak
low_freq_idx = freqs > 0 & freqs < 0.3;
if any(low_freq_idx)
[~, tmp] = max(psd(low_freq_idx));
trend_idx_local = find(low_freq_idx);
semilogy(freqs(trend_idx_local(tmp)), psd(trend_idx_local(tmp)), 'ks', ...
'MarkerSize', 10, 'MarkerFaceColor', [0.6 0.6 0.6], ...
'DisplayName', 'Trend (not oscillation)');
end
if ~isempty(rejected_freqs)
h2 = semilogy(rejected_freqs, rejected_psd, 'go', 'MarkerSize', 8);
h2.Color(4) = 0.3;
h2.DisplayName = sprintf('Detected, not used (%d)', length(rejected_freqs));
end
if ~isempty(retained_freqs)
semilogy(retained_freqs, retained_psd, 'go', 'MarkerSize', 8, ...
'MarkerFaceColor', 'g', 'DisplayName', sprintf('Used in model (%d)', ...
length(retained_freqs)));
end
xline(1.0, '--', 'Color', [1 0.5 0], 'Alpha', 0.5, 'Label', 'Annual');
xline(2.0, 'g--', 'Alpha', 0.5, 'Label', 'Semi-annual');
xlim([0 3]);
xlabel('Frequency (cycles/year)');
ylabel('PSD (log scale)');
title('Peak Detection Process: From Threshold to Smart Selection');
legend('Location', 'northeast', 'FontSize', 8);
grid on;
```
Purpose: Proper peak detection requires a bin to be a local maximum **and** stand above its local background. This combination eliminates slopes and shoulders that the global threshold included.
Step 11. Model comparison with detected peaks
```matlab
% Helper: fit Fourier + quadratic model and return metrics
function result = fit_model(freq_list, t, S, N, t_centered, label)
X = ones(N, 1 + 2*length(freq_list));
for i = 1:length(freq_list)
X(:, 1 + 2*(i-1) + 1) = cos(2*pi*freq_list(i)*t);
X(:, 1 + 2*(i-1) + 2) = sin(2*pi*freq_list(i)*t);
end
beta = (X' * X) \ (X' * S);
S_osc = X * beta - beta(1);
A = [ones(N,1), t_centered, t_centered.^2];
coeffs = (A' * A) \ (A' * (S - S_osc));
S_full = A * coeffs + S_osc;
SS_tot = sum((S - mean(S)).^2);
SS_res = sum((S - S_full).^2);
R2 = 1 - SS_res/SS_tot;
fprintf('%-30s | R² = %.4f\n', label, R2);
result = struct('R2', R2, 'S_fit', S_full);
end
fprintf('\nModel Comparison:\n');
fprintf('%s\n', repmat('-', 1, 45));
n_thresh = min(length(regression_freqs), 20);
result_thresh = fit_model(regression_freqs(1:n_thresh), t, S, N, t_centered, ...
sprintf('Threshold (%d freqs)', length(regression_freqs)));
if ~isempty(peak_freqs)
result_peaks = fit_model(peak_freqs, t, S, N, t_centered, ...
sprintf('Localized peaks (%d freqs)', length(peak_freqs)));
end
% Snap to nearest FFT bin — the grid may not include exactly 1.000 or 2.000
[~, idx1] = min(abs(freqs - 1.0));
[~, idx2] = min(abs(freqs - 2.0));
og_freqs = [freqs(idx1), freqs(idx2)];
result_og = fit_model(og_freqs, t, S, N, t_centered, 'OG Fourier (annual + semi-annual)');
fprintf('\nConclusion: OG Fourier Regression (2 frequencies) remains optimal.\n');
fprintf('FFT validates our physical intuition from Day 13!\n');
```
Purpose: Compare models using [$R^2$](https://en.wikipedia.org/wiki/Coefficient_of_determination) to evaluate how well each frequency selection fits the data.
> **Note:** `[~, idx1] = min(abs(freqs - 1.0))` snaps to the FFT bin nearest to 1.0 cycles/year rather than assuming the grid includes exactly 1.000. The bins are spaced $\Delta f \approx 0.024$ cycles/year apart and may land at 0.997 or 1.021. Using the nearest actual bin keeps the regression frequencies consistent with the FFT output.
---
# Results and Interpretation
## Discovered Frequencies
The FFT reveals dominant peaks at:
1. **~1.0 cycles/year**: Annual cycle from Earth's orbit
2. **~2.0 cycles/year**: Semi-annual from hemispheric asymmetry
3. **Near 0**: Long-term trend (DC component)
4. **No clear ENSO peak**: Because it's quasi-periodic (2-7 years), not a single frequency
## Comparison with Day 13
| Approach | Frequencies Used | R² | Physical Basis |
|----------|-----------------|-----|---------------|
| Day 13 | Pre-specified (1, 2 cy/yr) | ~0.92-0.94 | Domain knowledge |
| FFT-based | Discovered (~1.0, ~2.0 cy/yr) | ~0.92-0.94 | Data-driven |
**Key Finding**: The FFT confirms our physical intuition! The dominant frequencies match what we expected from Earth's orbital mechanics.
## [Power Spectrum](https://en.wikipedia.org/wiki/Spectral_density#Power_spectral_density) vs [Power Spectral Density](https://en.wikipedia.org/wiki/Spectral_density)
- **Power Spectrum** ($|FFT|^2$): Total power at each frequency bin
- **[PSD](https://en.wikipedia.org/wiki/Spectral_density)** (Power/Hz): Power per unit frequency [bandwidth](https://en.wikipedia.org/wiki/Bandwidth_(signal_processing))
- PSD is preferred for comparing spectra with different sampling rates
- Both show the same peaks, but PSD has consistent units
## Why No Clear ENSO Signal?
[El Niño-Southern Oscillation](https://www.climate.gov/enso) doesn't appear as a single peak because:
1. **Irregular period**: Varies between 2-7 years
2. **[Non-stationary](https://en.wikipedia.org/wiki/Stationary_process)**: Amplitude and frequency change over time
3. **[Broad-band signal](https://en.wikipedia.org/wiki/Broadband)**: Energy spread across multiple frequencies
4. Next class: Spectrograms will reveal time-varying frequencies!
# Check Your Understanding
The following was all AI-generated, with no prompting, after interacting with my R-codebase to produce these multi-language code blocks. It's not really what I was getting at with the heavy graphing of this code base, but I thought it was neat, and so I kept it.
## 1. Nyquist Frequency
What's the highest frequency we can detect with monthly sampling?
```python
# Sampling rate = 12 samples/year
fs = 12 # samples/year
f_nyquist = fs / 2
print(f"Nyquist frequency: {f_nyquist} cycles/year")
print(f"Period: {1/f_nyquist:.1f} months")
# Frequencies above 6 cycles/year are aliased!
```
## 2. Frequency Resolution
How precisely can we determine frequencies?
```python
# Resolution depends on total observation time
T_total = t[-1] - t[0] # years
df = 1 / T_total
print(f"Frequency resolution: {df:.4f} cycles/year")
print(f"Can distinguish periods differing by ~{365/T_total:.1f} days")
```
## 3. [Parseval's Theorem](https://en.wikipedia.org/wiki/Parseval%27s_theorem)
Verify that energy is conserved between time and frequency domains:
```python
# Time domain energy
E_time = np.sum(S_detrended**2)
# Frequency domain energy (need full FFT)
fft_full = np.fft.fft(S_detrended)
E_freq = np.sum(np.abs(fft_full)**2) / N
print(f"Time domain energy: {E_time:.1f}")
print(f"Frequency domain energy: {E_freq:.1f}")
print(f"Ratio: {E_freq/E_time:.6f}") # Should be ~1.0
```
## 4. Phase Information
The FFT gives both magnitude and phase. Extract the phase of the annual component:
```python
# Find annual frequency bin
annual_idx = np.argmin(np.abs(freqs - 1.0))
annual_fft = fft_vals[annual_idx]
magnitude = np.abs(annual_fft)
phase = np.angle(annual_fft)
print(f"Annual component: magnitude = {magnitude:.1f}")
print(f"Phase = {phase:.2f} radians = {phase*180/np.pi:.1f} degrees")
# Phase tells us when in the year the peak occurs
```
## 5. [Zero Padding](https://en.wikipedia.org/wiki/Zero_padding)
What happens if we add zeros to make the FFT longer?
```python
# Pad to next power of 2 for faster FFT
N_padded = 2**int(np.ceil(np.log2(N)))
S_padded = np.pad(S_detrended, (0, N_padded - N), mode='constant')
fft_padded = np.fft.fft(S_padded)
freqs_padded = np.fft.fftfreq(N_padded, dt)[:N_padded//2]
print(f"Original: {N} points, {len(freqs)} frequencies")
print(f"Padded: {N_padded} points, {len(freqs_padded)} frequencies")
print("Zero padding interpolates spectrum (smoother plot) but doesn't add information!")
```
# Key Takeaways
1. **FFT as Discovery Tool**: Let the data tell you which frequencies matter
2. **Validation of Physics**: FFT confirms annual/semi-annual cycles we expected
3. **[Spectrum is Discrete](https://en.wikipedia.org/wiki/Discrete_spectrum)**: FFT returns exactly $N/2$ numbers — plotting libraries connect the dots
4. **Threshold ≠ Top-N**: Value cutoff flags entire regions including slopes and shoulders
5. **[dB Scale](https://en.wikipedia.org/wiki/Decibel) Reveals Hidden Signals**: [Logarithmic scale](https://en.wikipedia.org/wiki/Logarithmic_scale) compresses [dynamic range](https://en.wikipedia.org/wiki/Dynamic_range)
6. **[Acceleration](https://en.wikipedia.org/wiki/Acceleration) Matters**: Sea level rise is accelerating at ~0.08 mm/year²
7. **Smart [Peak Detection](https://en.wikipedia.org/wiki/Peak_detection) Prevents [Overfitting](https://en.wikipedia.org/wiki/Overfitting)**: Local maxima + [prominence](https://en.wikipedia.org/wiki/Topographic_prominence) beats global threshold
8. **[Model Selection](https://en.wikipedia.org/wiki/Model_selection): Simpler is Better**: OG Fourier Regression (2 frequencies) remains optimal
# Mathematical Connections
- **From Day 13**: Pre-specified frequencies were physically motivated
- **From Day 14-15**: Delta functions connect continuous and discrete transforms
- **To [Spectrograms](https://en.wikipedia.org/wiki/Spectrogram)**: [Time-frequency analysis](https://en.wikipedia.org/wiki/Time%E2%80%93frequency_analysis) for non-stationary signals
- **To Filtering**: Zeroing frequencies = ideal filter (preview of convolution)
- **To Signal Processing**: [Nyquist theorem](https://en.wikipedia.org/wiki/Nyquist_frequency), [aliasing](https://en.wikipedia.org/wiki/Aliasing)
# Preview: Connection to Filtering
The FFT enables frequency-domain filtering:
1. **Transform** to frequency domain (FFT)
2. **Modify** frequencies (multiply by filter response)
3. **Transform back** to time domain (inverse FFT)
This is mathematically equivalent to [convolution](https://en.wikipedia.org/wiki/Convolution) in the time domain! Next class: How this relates to [RC circuits](https://en.wikipedia.org/wiki/RC_circuit) and physical [filters](https://en.wikipedia.org/wiki/Filter_(signal_processing)).
---
*Remember: The FFT is just a fast algorithm for the [DFT](https://en.wikipedia.org/wiki/Discrete_Fourier_transform). The mathematics are the same, but [O(N log N)](https://en.wikipedia.org/wiki/Time_complexity) beats O(N²) every time!*