Correlation and Detection¶
What you're seeing¶
Left — Corr coherent integration. BPSK PN reference, lag=17,
SNR ≈ −6 dB. With a single frame the peak/mean ratio is ~4.0 —
barely distinguishable from noise. After 8 coherent dwells
(dwell=8) it rises to ~7.0, pulling the lag-17 peak cleanly above
the noise floor. Coherent integration improves SNR by 10 log₁₀(M)
dB.
Centre — Corr2D 2-D template match. An 8×8 complex template
shifted by (row=3, col=5) is recovered in a single FFT2 call. The
surface peak lands exactly on the injected shift.
Right — CorrDetector.push() stream. Four signal dwells fire above
threshold=5; noise-only dwells stay below it. Each dot is one
dwell's test statistic: peak magnitude divided by local noise
estimate.
How it works¶
execute() accumulates frames and returns output only on the
dwell-th call; all other calls return None.
import numpy as np
from doppler.spectral import Corr, Corr2D, CorrDetector, CorrDetector2D
N, LAG = 64, 17 # 1-D frame length and injected lag
NY, NX = 8, 8 # 2-D frame dimensions
ROW, COL = 3, 5 # 2-D shift (row, col)
SIGMA = 2.0 # noise amplitude; power = SIGMA² = 4 → SNR ≈ −6 dB
DWELL = 8 # coherent integration depth
THRESHOLD = 5.0 # detection gate
rng = np.random.default_rng(42)
# Unit-magnitude BPSK PN references — fully deterministic.
ref1d = rng.choice(np.array([-1.0, 1.0], dtype=np.float32), size=N).astype(
np.complex64
)
ref2d = rng.choice(
np.array([-1.0, 1.0], dtype=np.float32), size=(NY, NX)
).astype(np.complex64)
noise_scale = np.float32(SIGMA / np.sqrt(2))
def noisy_frame() -> np.ndarray:
"""Return one N-sample CF32 frame: ref1d shifted by LAG, plus noise."""
signal = np.roll(ref1d, LAG)
noise = (rng.standard_normal(N) + 1j * rng.standard_normal(N)).astype(
np.complex64
) * noise_scale
return signal + noise
def noise_block(n_frames: int) -> np.ndarray:
"""Return n_frames*N samples of CF32 noise (no signal)."""
total = N * n_frames
return (
rng.standard_normal(total) + 1j * rng.standard_normal(total)
).astype(np.complex64) * noise_scale
# Corr: dwell=1 vs dwell=8 — execute() accumulates frames and returns
# output only on the dwell-th call; all other calls return None.
with Corr(ref1d, dwell=1) as c:
mag_d1 = np.abs(c.execute(noisy_frame()))
with Corr(ref1d, dwell=DWELL) as c:
for _ in range(DWELL - 1):
c.execute(noisy_frame())
mag_d8 = np.abs(c.execute(noisy_frame()))
snr_d1 = mag_d1[LAG] / np.mean(np.delete(mag_d1, LAG))
snr_d8 = mag_d8[LAG] / np.mean(np.delete(mag_d8, LAG))
print(
f"[Corr] dwell=1 peak/mean={snr_d1:.1f}"
f" | dwell={DWELL} peak/mean={snr_d8:.1f}"
)
# The dwell=8 coherent sum must place the global peak exactly at the
# injected lag, and integrating 8 frames must sharpen the peak/mean
# ratio over a single frame (coherent integration gain).
assert int(np.argmax(mag_d8)) == LAG, "dwell=8 peak not at injected lag"
assert snr_d8 > snr_d1, "no coherent integration gain at dwell=8"
CorrDetector wraps this loop and applies a CFAR threshold so you get
(lag, peak_mag, noise_est, test_stat) tuples directly from
det.push(block) without managing the dwell counter yourself.
Corr2D accumulates the dwell integration in the frequency domain — it
sums the per-frame cross-spectra and inverts once on the dump rather than once
per frame. The result is identical by linearity of the inverse FFT, but the
single inverse amortizes over the dwell (~1.7× cheaper per frame at dwell=8,
bench_corr2d.py). This holds only for coherent (complex-sum) integration;
see the 2-D Acquisition gallery for the details.
CorrDetector — streaming CFAR¶
push(block) accepts arbitrary-length blocks and yields
(lag, peak_mag, noise_est, test_stat) for each dwell that fires above the
threshold; the noise estimate is taken from lags [noise_lo, noise_hi]:
# One coherent-dwell block of the same shifted-PN + noise frames.
block = np.concatenate([noisy_frame() for _ in range(DWELL)])
det = CorrDetector(ref1d, dwell=DWELL, noise_lo=LAG + 4, noise_hi=N - 1,
threshold=THRESHOLD)
for lag, peak_mag, noise_est, test_stat in det.push(block):
print(f"detection lag={lag} stat={test_stat:.2f}")
# detection lag=17 stat=7.34 (stat varies with noise; lag is
# deterministic)
Corr2D — standalone 2-D match¶
# Corr2D: recover a (row, col) circular shift in one FFT2 call.
x2d = np.roll(np.roll(ref2d, ROW, axis=0), COL, axis=1)
with Corr2D(ref2d, dwell=1) as c:
surf2d = np.abs(c.execute(x2d)).reshape(NY, NX)
peak_row, peak_col = np.unravel_index(surf2d.argmax(), (NY, NX))
print(
f"[Corr2D] peak at (row={peak_row}, col={peak_col})"
f" (expected ({ROW}, {COL}))"
)
# 2-D template match: the correlation surface must peak exactly at the
# injected (row, col) circular shift.
assert (peak_row, peak_col) == (ROW, COL), "2-D peak not at true shift"
CorrDetector2D wraps Corr2D with the same CFAR gating for a full 2-D
acquisition search — see the 2-D Acquisition gallery.
