Spectral diagnostics comparison and tuning

Tutorial goal

Compare periodogram, AR, Burg, and Capon spectra, then vary model order/aperture.

Note

New to the terminology? See the lattice DSP concept map and the causality/data-use guide for how online, offline, block, and MIMO examples should be read.

Context

This is the tutorial page to read after the two focused spectral examples. It keeps the signal fixed and changes the diagnostic method or its tuning parameter so the reader can see how visual conclusions depend on modeling choices.

Key idea and equations

The tunable quantities are model complexity parameters: AR order p and Capon aperture M. Larger values can increase resolution, but they can also amplify finite-sample noise.

How to read the result

Use the second figure as a tuning guide: peaks should align with true tones, but excessive sharpness or spurious peaks are a warning sign.

Run command

python examples/spectral_diagnostics_comparison.py

Run status

Return code: 0

Captured stdout

true tone frequencies: [0.135, 0.215, 0.242]
main AR order: 24
main Capon aperture: 36

Figures

spectral diagnostics comparison

spectral_diagnostics_comparison.png

spectral diagnostics tuning

spectral_diagnostics_tuning.png

Generated data files

Source code

  1"""Tutorial: compare spectral diagnostics as the model complexity changes.
  2
  3This example places periodogram, AR, and Capon estimates on the same synthetic
  4signal, then adds a second figure showing how AR model order and Capon aperture
  5change the diagnostic.  It is meant as a visual tuning guide rather than a new
  6API surface.
  7"""
  8
  9from __future__ import annotations
 10
 11import csv
 12import os
 13from pathlib import Path
 14
 15import numpy as np
 16
 17from lattice_dsp import autocorrelation, burg_denominator, levinson_durbin_denominator
 18
 19
 20def artifact_dir() -> Path:
 21    path = Path(os.environ.get("LATTICE_DSP_ARTIFACT_DIR", "reports/example-artifacts"))
 22    path.mkdir(parents=True, exist_ok=True)
 23    return path
 24
 25
 26def db_normalized(power: np.ndarray) -> np.ndarray:
 27    power = np.maximum(np.asarray(power, dtype=float), 1e-18)
 28    return 10.0 * np.log10(power / np.max(power))
 29
 30
 31def periodogram(x: np.ndarray, n_fft: int) -> tuple[np.ndarray, np.ndarray]:
 32    window = np.hanning(x.size)
 33    spectrum = np.fft.rfft(window * x, n=n_fft)
 34    return np.fft.rfftfreq(n_fft), np.abs(spectrum) ** 2 / max(np.sum(window**2), 1e-12)
 35
 36
 37def ar_spectrum(denominator: np.ndarray, freq: np.ndarray) -> np.ndarray:
 38    z = np.exp(-2j * np.pi * freq)
 39    a = np.zeros_like(z, dtype=complex)
 40    for i, coef in enumerate(denominator):
 41        a += float(coef) * z**i
 42    return 1.0 / np.maximum(np.abs(a) ** 2, 1e-18)
 43
 44
 45def capon_spectrum(x: np.ndarray, aperture: int, freq: np.ndarray) -> np.ndarray:
 46    windows = np.lib.stride_tricks.sliding_window_view(x, aperture).astype(complex)
 47    R = (windows.conj().T @ windows) / windows.shape[0]
 48    loading = 1e-3 * float(np.trace(R).real) / aperture
 49    Rinv = np.linalg.pinv(R + loading * np.eye(aperture))
 50    n = np.arange(aperture)
 51    out = np.empty_like(freq, dtype=float)
 52    for i, f in enumerate(freq):
 53        steering = np.exp(-2j * np.pi * f * n)
 54        out[i] = 1.0 / max(np.vdot(steering, Rinv @ steering).real, 1e-18)
 55    return out
 56
 57
 58def write_csv(path: Path, freq: np.ndarray, columns: dict[str, np.ndarray]) -> None:
 59    with path.open("w", newline="", encoding="utf-8") as f:
 60        writer = csv.writer(f)
 61        writer.writerow(["frequency_cycles_per_sample", *columns])
 62        for i, value in enumerate(freq):
 63            writer.writerow([value, *(columns[name][i] for name in columns)])
 64
 65
 66def main() -> None:
 67    rng = np.random.default_rng(909)
 68    samples = 640
 69    n = np.arange(samples)
 70    tones = [0.135, 0.215, 0.242]
 71    x = (
 72        1.0 * np.sin(2 * np.pi * tones[0] * n + 0.2)
 73        + 0.75 * np.sin(2 * np.pi * tones[1] * n)
 74        + 0.65 * np.sin(2 * np.pi * tones[2] * n + 1.2)
 75        + 0.50 * rng.normal(size=samples)
 76    )
 77    x -= np.mean(x)
 78
 79    n_fft = 4096
 80    freq, p_per = periodogram(x, n_fft)
 81
 82    order = 24
 83    den_ld = np.asarray(levinson_durbin_denominator(autocorrelation(x, order), order), dtype=float)
 84    den_burg = np.asarray(burg_denominator(x, order), dtype=float)
 85    p_ld = ar_spectrum(den_ld, freq)
 86    p_burg = ar_spectrum(den_burg, freq)
 87    p_capon = capon_spectrum(x, 36, freq)
 88
 89    out_dir = artifact_dir()
 90    columns = {
 91        "periodogram_db": db_normalized(p_per),
 92        "levinson_ar_db": db_normalized(p_ld),
 93        "burg_ar_db": db_normalized(p_burg),
 94        "capon_db": db_normalized(p_capon),
 95    }
 96    csv_path = out_dir / "spectral_diagnostics_comparison.csv"
 97    write_csv(csv_path, freq, columns)
 98
 99    print("true tone frequencies:", tones)
100    print("main AR order:", order)
101    print("main Capon aperture:", 36)
102    print(f"wrote {csv_path}")
103
104    try:
105        import matplotlib.pyplot as plt
106    except Exception:
107        print("matplotlib is not installed; skipped figures")
108        return
109
110    fig, ax = plt.subplots(figsize=(9, 4.8))
111    for name, values in columns.items():
112        label = name.replace("_db", "").replace("_", " ")
113        ax.plot(freq, values, label=label)
114    for tone in tones:
115        ax.axvline(tone, linestyle=":", linewidth=1.0)
116    ax.set_xlim(0.08, 0.29)
117    ax.set_ylim(-55, 4)
118    ax.set_title("Spectral diagnostics on the same noisy signal")
119    ax.set_xlabel("frequency (cycles/sample)")
120    ax.set_ylabel("normalized power (dB)")
121    ax.legend()
122    fig.tight_layout()
123    fig_path = out_dir / "spectral_diagnostics_comparison.png"
124    fig.savefig(fig_path, dpi=160)
125    print(f"wrote {fig_path}")
126
127    fig2, ax2 = plt.subplots(figsize=(9, 4.8))
128    for test_order in [8, 16, 32]:
129        den = np.asarray(
130            levinson_durbin_denominator(autocorrelation(x, test_order), test_order), dtype=float
131        )
132        ax2.plot(freq, db_normalized(ar_spectrum(den, freq)), label=f"AR order {test_order}")
133    for aperture in [20, 44]:
134        ax2.plot(
135            freq,
136            db_normalized(capon_spectrum(x, aperture, freq)),
137            linestyle="--",
138            label=f"Capon aperture {aperture}",
139        )
140    for tone in tones:
141        ax2.axvline(tone, linestyle=":", linewidth=1.0)
142    ax2.set_xlim(0.08, 0.29)
143    ax2.set_ylim(-55, 4)
144    ax2.set_title("Changing AR order and Capon aperture changes the diagnostic")
145    ax2.set_xlabel("frequency (cycles/sample)")
146    ax2.set_ylabel("normalized power (dB)")
147    ax2.legend(ncol=2)
148    fig2.tight_layout()
149    fig2_path = out_dir / "spectral_diagnostics_tuning.png"
150    fig2.savefig(fig2_path, dpi=160)
151    print(f"wrote {fig2_path}")
152
153
154if __name__ == "__main__":
155    main()