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
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()