An Intensive Tutorial on FMCW Radar Fundamentals
From the chirp equation to range–Doppler–angle maps, with derivations and runnable Python
How to use this tutorial
This tutorial builds a complete picture of a basic
Frequency-Modulated Continuous-Wave (FMCW) radar, one layer at a time.
Each part follows the same rhythm: physical intuition first, then the
mathematics derived step by step, then Python code that simulates the
effect so you can see the equation working. The code cells are written
for a Jupyter notebook and only need numpy,
scipy and matplotlib. Run them in order,
because later cells reuse parameters and functions defined earlier.
The roadmap is:
- Radar basics and why FMCW
- The chirp signal
- Mixing and the intermediate-frequency (IF) signal
- Range measurement, resolution and maximum range
- Velocity measurement with chirp-to-chirp phase
- The range–Doppler map (2-D FFT)
- Angle of arrival and MIMO virtual arrays
- Windowing and sidelobes
- Detection with CFAR
- Noise, SNR and processing gain
- A complete design example
- Exercises with answers
Notation used throughout: is the speed of light ( m/s), the start (carrier) frequency, the wavelength, the sweep bandwidth, the chirp duration, the chirp slope, the ADC sample rate, the samples per chirp, the chirps per frame, and the number of receive channels.
# Setup: imports, constants and plotting style used by every later cell
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import windows
c = 3e8 # speed of light [m/s]
rng = np.random.default_rng(seed=7)
plt.rcParams.update({
"figure.figsize": (9, 4),
"figure.dpi": 110,
"axes.grid": True,
"grid.alpha": 0.3,
"axes.spines.top": False,
"axes.spines.right": False,
"font.size": 10,
})1. Radar basics and why FMCW
A radar transmits an electromagnetic wave and listens for echoes. Everything it measures comes from comparing the echo to what was sent:
- Range comes from the round-trip delay .
- Radial velocity comes from the Doppler shift .
- Angle comes from the path-length difference across several receive antennas.
1.1 The pulsed-radar problem
A simple pulsed radar measures directly with a short pulse of width . Its range resolution is
For 15 cm resolution the pulse must be 1 ns long. A short pulse carries very little energy unless its peak power is enormous, and timing nanosecond events needs very fast electronics. That is expensive.
1.2 The FMCW solution
FMCW radar transmits continuously and sweeps its frequency linearly over a bandwidth . Resolution is set by the bandwidth, not the pulse length:
So a 4 GHz sweep gives 3.75 cm resolution, while the radar runs at low peak power for the whole sweep. Better still, the receiver mixes the echo with the transmitted signal, turning a tiny time delay into a low frequency (kHz to a few MHz) that a cheap ADC can sample. That combination is why FMCW dominates automotive radar (76–81 GHz), industrial level sensing, gesture sensing (60 GHz) and drone altimeters.
1.3 The radar range equation
The received power from a target of radar cross-section (RCS) is
where is transmit power, and are antenna gains and lumps the system losses. The term is the key fact: doubling the range costs 12 dB of signal. Section 10 shows how FMCW processing gain wins that back.
1.4 Block diagram
A basic FMCW radar has five stages:
- Synthesizer generates the chirp (a linear frequency ramp).
- Transmit antenna radiates it.
- Receive antenna collects the delayed echo.
- Mixer multiplies the received signal with a copy of the transmitted one, producing the IF (beat) signal.
- Low-pass filter and ADC sample the IF signal for digital processing (FFTs, detection, tracking).
# Radar parameters used throughout this tutorial (a 77 GHz automotive-style radar)
fc = 77e9 # start frequency [Hz]
B = 1e9 # sweep bandwidth [Hz] -> 15 cm resolution
Tc = 40e-6 # chirp duration (ramp time) [s]
S = B / Tc # chirp slope [Hz/s]
Fs = 10e6 # ADC sample rate [Hz] (complex / IQ sampling)
Ns = 256 # samples per chirp
Nc = 128 # chirps per frame
lam = c / fc # wavelength [m]
print(f"Wavelength λ = {lam*1e3:.3f} mm")
print(f"Chirp slope S = {S/1e12:.1f} MHz/µs")
print(f"Range resolution ΔR = {c/(2*B)*100:.1f} cm")
print(f"Max range (IQ) Rmax= {Fs*c/(2*S):.1f} m")
print(f"Max velocity vmax= ±{lam/(4*Tc):.2f} m/s")
print(f"Velocity res. Δv = {lam/(2*Nc*Tc):.3f} m/s")Wavelength λ = 3.896 mm
Chirp slope S = 25.0 MHz/µs
Range resolution ΔR = 15.0 cm
Max range (IQ) Rmax= 60.0 m
Max velocity vmax= ±24.35 m/s
Velocity res. Δv = 0.380 m/s
2. The chirp signal
2.1 Instantaneous frequency
During one chirp, , the transmitted frequency rises linearly:
2.2 Phase and the transmitted waveform
Phase is the integral of angular frequency:
The transmitted signal, with amplitude , is therefore
The quadratic term is what makes it a chirp: the phase accelerates, so the frequency climbs.
2.3 Frames
Chirps are repeated times to form a frame. Within a frame there are two time axes, and keeping them separate is the most useful mental model in FMCW processing:
- Fast time (or sample index ): time within one chirp, sampled at . It carries range information.
- Slow time (chirp index): which chirp in the frame, spaced by . It carries velocity information.
Real systems add idle time between chirps, so the chirp repetition period is slightly longer than the ramp. This tutorial takes for clarity; in real designs replace with in every velocity formula.
# Visualise a chirp: (a) frequency vs time for several chirps, (b) a slow toy chirp waveform
fig, ax = plt.subplots(1, 2, figsize=(11, 3.8))
t_frame = np.linspace(0, 4*Tc, 2000)
f_inst = fc + S * (t_frame % Tc)
ax[0].plot(t_frame*1e6, f_inst/1e9, lw=2)
ax[0].set(xlabel="Time [µs]", ylabel="Frequency [GHz]",
title="Instantaneous frequency of a chirp sequence")
# Real 77 GHz chirps oscillate too fast to plot, so use a toy chirp: 1 → 20 Hz in 1 s
t_toy = np.linspace(0, 1, 4000)
f0, S_toy = 1.0, 19.0
ax[1].plot(t_toy, np.cos(2*np.pi*(f0*t_toy + 0.5*S_toy*t_toy**2)), lw=1)
ax[1].set(xlabel="Time [s]", ylabel="Amplitude",
title="Toy chirp waveform: cos(2π(f₀t + St²/2))")
plt.tight_layout(); plt.show()3. Mixing and the IF signal
This is the heart of FMCW. Read the derivation slowly; every later result follows from it.
3.1 The received signal
A target at range returns a delayed, attenuated copy of the chirp, with round-trip delay
3.2 The mixer
A mixer multiplies its two inputs. Using , the product contains a difference-frequency term and a sum term near . The low-pass filter removes the sum, leaving the IF signal:
3.3 Deriving the IF phase
Expand the phase difference term by term:
The and terms cancel, leaving
So the IF signal is a pure tone:
with beat frequency and initial phase
The term is the residual video phase. For m, MHz/µs it is about 0.035 rad, negligible for most purposes.
3.4 Two numbers, two jobs
These two results drive everything else:
- The IF frequency is proportional to range. A frequency analysis (FFT) of one chirp gives a range profile.
- The IF phase is extremely sensitive. A change of just mm in range rotates the phase by a full , which is the basis of velocity measurement.
3.5 Complex (IQ) IF signal
Most modern radar chips use an IQ (quadrature) mixer that produces the complex signal
A complex tone has a single spectral line at , rather than the pair a real cosine has, so the usable unambiguous bandwidth doubles. The simulations below use complex IF signals.
3.6 The sampled IF signal for a moving target
Allow the target to move, , and evaluate the IF signal at fast-time sample () of chirp . Dropping the small terms, the IF signal becomes the fundamental FMCW data model:
Read it as a 2-D complex sinusoid: its frequency along encodes range, and its frequency along encodes velocity. Sections 4 to 6 simply estimate those two frequencies.
def simulate_if(targets, n_rx=1, d_over_lam=0.5, noise_std=0.0, exact=True):
"""
Simulate the complex (IQ) IF data cube x[n, m, k] for point targets.
n: fast-time sample, m: chirp index, k: receive (virtual) channel.
targets: list of dicts with keys R [m], v [m/s], theta [deg], amp.
exact=True uses the full round-trip delay per sample (includes range
migration and range-Doppler coupling); False uses the simplified model.
"""
n = np.arange(Ns)[:, None, None]
m = np.arange(Nc)[None, :, None]
k = np.arange(n_rx)[None, None, :]
t_fast = n / Fs
cube = np.zeros((Ns, Nc, n_rx), dtype=complex)
for tg in targets:
R0, v = tg["R"], tg["v"]
th = np.deg2rad(tg.get("theta", 0.0))
amp = tg.get("amp", 1.0)
if exact:
t_abs = m*Tc + t_fast # absolute time
tau = 2*(R0 + v*t_abs) / c # round-trip delay
phase = 2*np.pi*(fc*tau + S*tau*t_fast - 0.5*S*tau**2)
else:
phase = 2*np.pi*(2*S*R0/c*t_fast + 2*v/lam*m*Tc + 2*R0/lam)
phase = phase + 2*np.pi*d_over_lam*k*np.sin(th) # spatial (angle) phase
cube += amp*np.exp(1j*phase)
noise = noise_std/np.sqrt(2)*(rng.standard_normal(cube.shape)
+ 1j*rng.standard_normal(cube.shape))
return cube + noise
# One stationary target at 20 m: IF should be a tone at f_b = 2SR/c
R_demo = 20.0
fb_theory = 2*S*R_demo/c
x = simulate_if([{"R": R_demo, "v": 0.0}])[:, 0, 0]
t_us = np.arange(Ns)/Fs*1e6
plt.plot(t_us, x.real, label="I (real)")
plt.plot(t_us, x.imag, label="Q (imag)", alpha=0.7)
plt.xlabel("Fast time [µs]"); plt.ylabel("Amplitude")
plt.title(f"IF signal of a target at {R_demo} m (theory f_b = {fb_theory/1e6:.3f} MHz)")
plt.legend(); plt.show()4. Range measurement
4.1 Range from beat frequency
Inverting gives
Several targets produce a sum of tones, one per target. Taking the FFT of the samples of one chirp, the range FFT, gives a peak for each target. FFT bin corresponds to frequency , so its range is
4.2 Range resolution
Two tones observed for a time can be separated only if their frequencies differ by at least (the Fourier resolution limit):
Converting to range with and :
Range resolution depends only on the swept bandwidth. Strictly, here is the bandwidth swept while the ADC is sampling, , which is often slightly less than the full ramp.
4.3 Maximum range
The largest beat frequency the receiver can capture is limited by the ADC (and the IF filter):
For complex sampling, covers ; with only positive (approaching-range) beat frequencies in practice, designs typically quote for IQ and for real ADCs. Note the trade-off: a steeper slope improves resolution at fixed but shrinks for a given ADC.
4.4 Range accuracy versus resolution
Resolution is the ability to separate two targets. Accuracy is how precisely a single target’s range is measured, which can be much finer by interpolating between FFT bins or by using the phase. Do not confuse them.
# Range FFT with three targets, two of them closer than one resolution cell
dR = c/(2*B)
targets = [
{"R": 12.0, "v": 0, "amp": 1.0},
{"R": 30.0, "v": 0, "amp": 0.5},
{"R": 30.0 + 0.6*dR, "v": 0, "amp": 0.5}, # only 0.6 ΔR apart -> unresolved
]
x = simulate_if(targets, noise_std=0.05)[:, 0, 0]
N_fft = 1024 # zero-padding for a smoother plot
X = np.fft.fft(x, n=N_fft) / Ns
f_axis = np.arange(N_fft) * Fs / N_fft
r_axis = c * f_axis / (2*S)
plt.plot(r_axis, 20*np.log10(np.abs(X) + 1e-12))
for tg in targets:
plt.axvline(tg["R"], color="k", ls=":", lw=0.8)
plt.xlim(0, 60); plt.ylim(-60, 5)
plt.xlabel("Range [m]"); plt.ylabel("Magnitude [dB]")
plt.title(f"Range profile (ΔR = {dR*100:.0f} cm): the two targets near 30 m merge into one peak")
plt.show()# Resolution depends only on bandwidth: same two targets (30 cm apart), different B
fig, ax = plt.subplots(figsize=(9, 4))
for B_try in [0.5e9, 1e9, 2e9]:
S_try = B_try / Tc
tt = np.arange(Ns)/Fs
sig = sum(np.exp(1j*2*np.pi*2*S_try*R/c*tt) for R in [5.0, 5.3])
spec = np.abs(np.fft.fft(sig, 4096))/Ns
r = c*np.arange(4096)*Fs/4096/(2*S_try)
ax.plot(r, spec, label=f"B = {B_try/1e9:.1f} GHz → ΔR = {c/(2*B_try)*100:.1f} cm")
ax.set_xlim(4, 6.5); ax.set_xlabel("Range [m]"); ax.set_ylabel("|X| (linear)")
ax.set_title("Two targets 30 cm apart: only wider sweeps resolve them")
ax.legend(); plt.show()5. Velocity measurement
5.1 Why not just use the beat frequency?
A moving target shifts the beat frequency by the Doppler frequency . At 77 GHz and 10 m/s, kHz, while one range bin spans kHz. The Doppler shift is far below the range resolution of a single chirp, so frequency alone cannot measure velocity. The phase can.
5.2 Phase difference between consecutive chirps
From Section 3, the IF phase of chirp is . Between consecutive chirps the target moves , so
At 77 GHz, a target moving only 0.1 mm between chirps rotates the phase by about 18°: easily measurable.
5.3 Maximum unambiguous velocity
Phase is only known modulo , so the measurement is unambiguous for :
Faster targets alias (wrap around) to a wrong velocity. Short chirps raise .
5.4 The Doppler FFT and velocity resolution
With chirps, the phase rotates at a constant rate across the frame: the slow-time signal is itself a complex sinusoid with frequency , sampled every . An FFT across chirps (the Doppler FFT) resolves two velocities if their phase rates differ by at least per chirp:
where is the frame (observation) time. Longer frames give finer velocity resolution, just as wider bandwidth gives finer range resolution.
5.5 Relation to the familiar Doppler formula
Equivalently, the Doppler FFT measures with frequency resolution , and follows directly from .
# Phase across chirps for a single target: it rotates linearly at Δφ = 4π v Tc / λ
v_demo = 6.0
cube = simulate_if([{"R": 20.0, "v": v_demo}], exact=False)
k_peak = int(round(2*S*20.0/c / (Fs/Ns))) # range bin of the target
range_fft = np.fft.fft(cube[:, :, 0], axis=0)
phase = np.unwrap(np.angle(range_fft[k_peak, :]))
dphi_theory = 4*np.pi*v_demo*Tc/lam
dphi_meas = np.mean(np.diff(phase))
print(f"Δφ theory = {np.degrees(dphi_theory):.2f}° per chirp")
print(f"Δφ measured = {np.degrees(dphi_meas):.2f}° per chirp")
print(f"v estimate = {lam*dphi_meas/(4*np.pi*Tc):.3f} m/s")
plt.plot(np.degrees(phase), ".-", ms=3)
plt.xlabel("Chirp index m"); plt.ylabel("Unwrapped phase [deg]")
plt.title("Phase at the target's range bin grows linearly with chirp index")
plt.show()Δφ theory = 44.35° per chirp
Δφ measured = 44.35° per chirp
v estimate = 6.000 m/s
# Velocity aliasing: a target faster than v_max appears at the wrong velocity
vmax = lam/(4*Tc)
win = windows.hann(Nc)
fig, ax = plt.subplots(figsize=(9, 4))
for v_true in [10.0, 20.0, 30.0]:
slow = np.exp(1j*4*np.pi*v_true*Tc/lam*np.arange(Nc))
spec = np.fft.fftshift(np.fft.fft(slow*win))
v_axis = np.fft.fftshift(np.fft.fftfreq(Nc, d=Tc)) * lam/2
ax.plot(v_axis, 20*np.log10(np.abs(spec)/np.abs(spec).max() + 1e-9),
label=f"true v = {v_true} m/s → peak at {v_axis[np.argmax(np.abs(spec))]:.1f} m/s")
ax.axvline(vmax, color="k", ls="--", lw=0.8); ax.axvline(-vmax, color="k", ls="--", lw=0.8)
ax.set_ylim(-60, 3); ax.set_xlabel("Velocity [m/s]"); ax.set_ylabel("dB")
ax.set_title(f"Doppler FFT, v_max = ±{vmax:.1f} m/s (dashed): 30 m/s wraps around")
ax.legend(fontsize=8); plt.show()6. The range–Doppler map
6.1 The 2-D FFT
Arrange the samples of one frame in a matrix with fast time down the rows and slow time across the columns. Using the data model of Section 3.6, a 2-D DFT gives
where and are window functions (Section 8). Each target becomes a peak at range bin and Doppler bin :
In practice this is done as range FFTs (one per chirp, often computed on the fly as chirps arrive) followed by Doppler FFTs (one per range bin).
6.2 Range–Doppler coupling and range migration
The simplified model ignores two effects that the exact simulation includes:
- Range–Doppler coupling. The Doppler shift adds to the beat frequency, , so the apparent range is offset by ( cm for 10 m/s with our parameters, well under one bin).
- Range migration. During a frame the target moves . If that exceeds , its energy smears across range bins. With ms and 10 m/s the target moves 5 cm, well inside one 15 cm bin.
A useful design check is therefore .
# Full range–Doppler map with four targets (exact signal model + noise)
targets = [
{"R": 10.0, "v": 0.0, "amp": 1.0}, # stationary
{"R": 18.0, "v": 5.0, "amp": 0.6}, # receding
{"R": 25.0, "v": -8.0, "amp": 0.4}, # approaching
{"R": 32.0, "v": 3.0, "amp": 0.3},
]
cube = simulate_if(targets, noise_std=0.3)[:, :, 0]
def range_doppler(data):
w_r = windows.hann(Ns)[:, None]
w_d = windows.hann(Nc)[None, :]
rfft = np.fft.fft(data*w_r, axis=0) # range FFT
rd = np.fft.fftshift(np.fft.fft(rfft*w_d, axis=1), axes=1) # Doppler FFT
return rd
rd = range_doppler(cube)
rd_db = 20*np.log10(np.abs(rd) + 1e-12)
rd_db -= rd_db.max()
range_axis = c*np.arange(Ns)*Fs/Ns/(2*S)
vel_axis = np.fft.fftshift(np.fft.fftfreq(Nc, d=Tc))*lam/2
plt.figure(figsize=(9, 5))
plt.imshow(rd_db, aspect="auto", origin="lower", cmap="viridis", vmin=-60, vmax=0,
extent=[vel_axis[0], vel_axis[-1], range_axis[0], range_axis[-1]])
plt.colorbar(label="dB")
for tg in targets:
plt.plot(tg["v"], tg["R"], "r+", ms=12, mew=1.5)
plt.ylim(0, 45); plt.xlabel("Velocity [m/s]"); plt.ylabel("Range [m]")
plt.title("Range–Doppler map (red + = true target positions)")
plt.show()7. Angle of arrival
7.1 Phase across receive antennas
Place receive antennas in a line with spacing . A plane wave arriving from angle (measured from broadside) travels an extra distance to each successive antenna, giving a phase step
The signal across antennas, , is again a complex sinusoid, so a third FFT, the angle FFT, across the antenna dimension estimates .
7.2 Field of view
Unambiguous measurement requires :
With the field of view is the full . Larger spacing creates grating lobes (angle aliasing).
7.3 Angular resolution
An -point FFT resolves phase steps that differ by . Differentiating with respect to , , gives
Resolution is best at broadside and degrades toward the edges as . Eight elements give about at boresight.
7.4 MIMO and the virtual array
Adding receivers is costly. MIMO radar uses transmitters and receivers, with the transmitters separated in time (time-division multiplexing, TDM) or by code. If transmitter sits at position and receiver at , the round-trip phase depends on , so the pair behaves like a single virtual element at that sum. With receivers at and two transmitters spaced apart:
So virtual channels come from only physical antennas. One caveat with TDM: a moving target’s phase advances between transmitter slots, adding a velocity-dependent error to the angle that must be compensated using the Doppler estimate.
7.5 The radar data cube
Combining all three dimensions, the full data model is
and a 3-D FFT (range, Doppler, angle) maps each target to a point in range–velocity–angle space.
# Angle FFT on an 8-element virtual array (2 TX × 4 RX), two targets at the same range and speed
N_virt = 8
targets = [
{"R": 15.0, "v": 2.0, "theta": -20.0, "amp": 1.0},
{"R": 15.0, "v": 2.0, "theta": 15.0, "amp": 0.7},
]
cube = simulate_if(targets, n_rx=N_virt, noise_std=0.3)
# Range–Doppler per channel, then pick the strongest cell
rd_all = np.stack([range_doppler(cube[:, :, ch]) for ch in range(N_virt)], axis=-1)
power = np.sum(np.abs(rd_all)**2, axis=-1)
k_r, k_d = np.unravel_index(np.argmax(power), power.shape)
snapshot = rd_all[k_r, k_d, :] # 8 complex values across the array
N_ang = 256
ang_spec = np.fft.fftshift(np.fft.fft(snapshot*windows.taylor(N_virt, nbar=3, sll=25), N_ang))
omega = np.fft.fftshift(np.fft.fftfreq(N_ang))*2*np.pi
theta_axis = np.degrees(np.arcsin(omega/np.pi)) # d = λ/2
spec_db = 20*np.log10(np.abs(ang_spec)/np.abs(ang_spec).max())
plt.plot(theta_axis, spec_db)
for tg in targets:
plt.axvline(tg["theta"], color="k", ls=":", lw=0.8)
plt.ylim(-40, 2); plt.xlabel("Angle θ [deg]"); plt.ylabel("dB")
plt.title(f"Angle spectrum at R={range_axis[k_r]:.1f} m, 8 virtual channels "
f"(Δθ ≈ {np.degrees(2/N_virt):.0f}°)")
plt.show()# Range–angle map (birds-eye view): sum over Doppler after the angle FFT
targets = [
{"R": 10.0, "v": 1.0, "theta": -30.0, "amp": 1.0},
{"R": 20.0, "v": -3.0, "theta": 0.0, "amp": 0.8},
{"R": 28.0, "v": 4.0, "theta": 25.0, "amp": 0.7},
]
cube = simulate_if(targets, n_rx=N_virt, noise_std=0.3)
rd_all = np.stack([range_doppler(cube[:, :, ch]) for ch in range(N_virt)], axis=-1)
ra = np.fft.fftshift(np.fft.fft(rd_all*windows.hann(N_virt), n=64, axis=2), axes=2)
ra_map = np.sum(np.abs(ra)**2, axis=1) # non-coherent sum over Doppler
ra_db = 10*np.log10(ra_map/ra_map.max())
omega = np.fft.fftshift(np.fft.fftfreq(64))*2*np.pi
th = np.arcsin(omega/np.pi)
Rg, Th = np.meshgrid(range_axis, th, indexing="ij")
X, Y = Rg*np.sin(Th), Rg*np.cos(Th)
plt.figure(figsize=(7, 6))
plt.pcolormesh(X, Y, ra_db, cmap="viridis", vmin=-35, vmax=0, shading="gouraud")
plt.colorbar(label="dB")
for tg in targets:
t = np.deg2rad(tg["theta"])
plt.plot(tg["R"]*np.sin(t), tg["R"]*np.cos(t), "r+", ms=12, mew=1.5)
plt.gca().set_facecolor(plt.cm.viridis(0.0))
plt.xlim(-25, 25); plt.ylim(0, 40); plt.gca().set_aspect("equal")
plt.xlabel("Cross-range x [m]"); plt.ylabel("Down-range y [m]")
plt.title("Range–angle map in Cartesian coordinates")
plt.show()8. Windowing and sidelobes
8.1 Spectral leakage
An FFT of samples implicitly multiplies the signal by a rectangular window. The transform of a rectangle is the Dirichlet (periodic sinc) kernel
whose first sidelobe is only dB below the peak. A strong target’s sidelobes can therefore hide a weak target nearby, which is a serious problem when a truck and a pedestrian share a scene.
8.2 Window trade-offs
Tapering the data with a window lowers sidelobes at the cost of a wider main lobe (coarser effective resolution) and a small SNR loss:
| Window | Peak sidelobe | Main-lobe width (−3 dB, bins) | SNR loss |
|---|---|---|---|
| Rectangular | −13.3 dB | 0.89 | 0 dB |
| Hann | −31.5 dB | 1.44 | 1.76 dB |
| Hamming | −42.7 dB | 1.30 | 1.34 dB |
| Blackman | −58.1 dB | 1.68 | 2.37 dB |
| Chebyshev (−80 dB) | −80 dB | ~1.9 | ~2.5 dB |
The Hann window is a common default for range and Doppler; Chebyshev or Taylor windows are used when a specific sidelobe level is required.
# Strong target + weak target 3 bins away (40 dB weaker): rectangular vs Hann vs Blackman
tt = np.arange(Ns)/Fs
bin_hz = Fs/Ns
sig = np.exp(1j*2*np.pi*40.3*bin_hz*tt) + 0.01*np.exp(1j*2*np.pi*46.3*bin_hz*tt)
fig, ax = plt.subplots(figsize=(9, 4))
for name, w in [("Rectangular", np.ones(Ns)), ("Hann", windows.hann(Ns)),
("Blackman", windows.blackman(Ns))]:
spec = np.abs(np.fft.fft(sig*w, 4096))
ax.plot(np.arange(4096)*Ns/4096, 20*np.log10(spec/spec.max() + 1e-12), label=name)
ax.axvline(46.3, color="k", ls=":", lw=0.8)
ax.set_xlim(25, 60); ax.set_ylim(-90, 3)
ax.set_xlabel("FFT bin"); ax.set_ylabel("dB")
ax.set_title("A target 40 dB weaker (dotted line) is hidden by rectangular-window sidelobes")
ax.legend(); plt.show()9. Detection with CFAR
9.1 Why a fixed threshold fails
After the FFTs, each cell holds signal plus noise and clutter. A fixed threshold either misses weak targets or floods the output with false alarms when the noise floor changes (rain, ground clutter, temperature drift). Constant False Alarm Rate (CFAR) detection estimates the local noise level around each cell and sets the threshold relative to it.
9.2 Cell-Averaging CFAR
For each cell under test (CUT), average the power of training cells on either side, skipping a few guard cells next to the CUT so target energy does not leak into the estimate:
For exponentially distributed noise power (complex Gaussian noise), the threshold factor that gives a desired false-alarm probability is
For and , (11 dB). Variants such as OS-CFAR (ordered statistic) handle closely spaced targets and clutter edges better.
def ca_cfar_1d(power, n_train=12, n_guard=3, pfa=1e-4):
"""Cell-averaging CFAR on a 1-D power profile. Returns threshold and detection mask."""
N = 2*n_train
alpha = N*(pfa**(-1/N) - 1)
L = len(power)
thr = np.full(L, np.nan)
for i in range(n_train + n_guard, L - n_train - n_guard):
lead = power[i - n_guard - n_train : i - n_guard]
lag = power[i + n_guard + 1 : i + n_guard + 1 + n_train]
thr[i] = alpha*np.mean(np.concatenate([lead, lag]))
return thr, power > thr
# Range profile with a noise floor that rises with range (e.g. clutter), plus 4 targets
targets = [{"R": R, "v": 0, "amp": a} for R, a in [(8, 0.2), (15, 0.08), (27, 0.12), (40, 0.25)]]
x = simulate_if(targets, noise_std=0.15)[:, 0, 0]
x = x + np.linspace(0, 0.25, Ns)*(rng.standard_normal(Ns) + 1j*rng.standard_normal(Ns))
P = np.abs(np.fft.fft(x*windows.hann(Ns)))**2
thr, det = ca_cfar_1d(P)
plt.plot(range_axis, 10*np.log10(P), label="Range profile")
plt.plot(range_axis, 10*np.log10(thr), label="CA-CFAR threshold")
plt.plot(range_axis[det], 10*np.log10(P[det]), "rv", label="Detections")
plt.xlim(0, 50); plt.xlabel("Range [m]"); plt.ylabel("Power [dB]")
plt.title("CA-CFAR adapts its threshold to the rising noise floor")
plt.legend(); plt.show()
print("Detected ranges [m]:", np.round(range_axis[det], 2))Detected ranges [m]: [ 7.73 7.97 8.2 15. 15.23 26.72 26.95 27.19 39.61 39.84 40.08]
Notice that each target triggers two or three adjacent cells, because the Hann window widens the main lobe. Real pipelines follow CFAR with peak grouping (keep only local maxima) or clustering, so that each physical target produces one detection.
10. Noise, SNR and processing gain
10.1 Thermal noise
Receiver noise power in bandwidth is
where J/K, K and is the receiver noise figure. At room temperature dBm/Hz.
10.2 Coherent integration gain
An -point FFT adds a sinusoid’s samples coherently (amplitude grows as , power as ) while noise adds incoherently (power grows as ). The SNR gain is therefore , or dB. Across range, Doppler and angle FFTs:
For samples that is 54.2 dB. Equivalently, the effective noise bandwidth after processing is per channel, so the SNR for one target is
Solving for with a required detection SNR gives the maximum detection range:
# Verify the coherent integration gain numerically for the range FFT
N = Ns
snr_in_db = -10
amp = 1.0
sigma = amp/np.sqrt(10**(snr_in_db/10)) # noise std for the chosen input SNR
trials = 200
gains = []
for _ in range(trials):
tone = amp*np.exp(1j*2*np.pi*40*np.arange(N)/N)
noise = sigma/np.sqrt(2)*(rng.standard_normal(N) + 1j*rng.standard_normal(N))
Xs, Xn = np.fft.fft(tone), np.fft.fft(noise)
gains.append(np.abs(Xs[40])**2 / np.mean(np.abs(Xn)**2))
snr_out = 10*np.log10(np.mean(gains))
print(f"Input SNR = {snr_in_db} dB per sample")
print(f"Output SNR = {snr_out:.2f} dB → gain {snr_out - snr_in_db:.2f} dB "
f"(theory 10·log10({N}) = {10*np.log10(N):.2f} dB)")
# Detection range from the radar equation
def detection_range(Pt_dbm=12, Gt_db=10, Gr_db=10, rcs=10, NF_db=12, L_db=6, snr_min_db=15,
n_rx=8, Tf=Nc*Tc, f=fc):
kT = 1.38e-23*290
lin = lambda db: 10**(db/10)
Pt = lin(Pt_dbm)*1e-3
lam_ = c/f
num = Pt*lin(Gt_db)*lin(Gr_db)*lam_**2*rcs*n_rx*Tf
den = (4*np.pi)**3*kT*lin(NF_db)*lin(L_db)*lin(snr_min_db)
return (num/den)**0.25
for name, rcs in [("Car (10 m²)", 10), ("Pedestrian (0.5 m²)", 0.5), ("Bicycle (2 m²)", 2)]:
print(f"{name:22s} detection range ≈ {detection_range(rcs=rcs):6.1f} m")Input SNR = -10 dB per sample
Output SNR = 14.09 dB → gain 24.09 dB (theory 10·log10(256) = 24.08 dB)
Car (10 m²) detection range ≈ 157.9 m
Pedestrian (0.5 m²) detection range ≈ 74.7 m
Bicycle (2 m²) detection range ≈ 105.6 m
11. A complete design example
11.1 Requirements
Design the chirp for a short-range automotive radar:
- Maximum range m
- Range resolution cm
- Maximum velocity m/s (90 km/h relative)
- Velocity resolution m/s
- Carrier 77 GHz, IQ ADC available up to 20 MSps
11.2 Step-by-step design
Step 1, bandwidth from resolution.
Step 2, chirp time from maximum velocity. With mm, Choose s (this is the repetition period, including idle time).
Step 3, slope and ADC rate from maximum range. If roughly 30 µs of each chirp is sampled, the slope is MHz/µs. The beat frequency at 50 m is so an IQ ADC at MSps works. Samples per chirp: , rounded up to a 1024-point FFT with zero padding (or choose 512 samples and a slightly shorter sampling window).
Step 4, number of chirps from velocity resolution. With the resolution is 0.200 m/s, right at the limit; choose 512 if margin is needed.
Step 5, sanity checks. The frame time is ms. Range migration at : m, which spans about 2.4 range bins, so a fast target will smear. Options are to accept it, to shorten the frame, or to apply range-migration compensation (for example keystone processing).
The calculator below automates these trade-offs.
def fmcw_design(fc, B, Tc, Fs, Ns, Nc, n_rx=1, iq=True):
"""Return the key performance numbers for an FMCW chirp configuration."""
lam_ = c/fc
t_samp = Ns/Fs
S_ = B/t_samp # slope over the sampled portion
out = {
"Wavelength [mm]": lam_*1e3,
"Slope [MHz/µs]": S_/1e12,
"Range resolution [cm]": c/(2*B)*100,
"Max range [m]": (Fs if iq else Fs/2)*c/(2*S_),
"Max velocity [m/s]": lam_/(4*Tc),
"Velocity resolution [m/s]": lam_/(2*Nc*Tc),
"Frame time [ms]": Nc*Tc*1e3,
"Range migration at vmax [bins]": (lam_/(4*Tc))*Nc*Tc/(c/(2*B)),
"Angular resolution [deg]": np.degrees(2/n_rx) if n_rx > 1 else np.nan,
"Processing gain [dB]": 10*np.log10(Ns*Nc*n_rx),
}
return out
design = fmcw_design(fc=77e9, B=1.5e9, Tc=38e-6, Fs=20e6, Ns=600, Nc=256, n_rx=8)
for k_, v_ in design.items():
print(f"{k_:34s} {v_:10.3f}")Wavelength [mm] 3.896
Slope [MHz/µs] 50.000
Range resolution [cm] 10.000
Max range [m] 60.000
Max velocity [m/s] 25.632
Velocity resolution [m/s] 0.200
Frame time [ms] 9.728
Range migration at vmax [bins] 2.494
Angular resolution [deg] 14.324
Processing gain [dB] 60.895
12. Exercises
Try these before reading the answers. Use m/s.
- A radar sweeps 4 GHz in 50 µs. What is its range resolution and slope?
- With the radar in Q1 and an IQ ADC at 12.5 MSps, what is the maximum range?
- A target at 10 m is measured with the radar in Q1. What is the beat frequency?
- At 60 GHz with µs, what is the maximum unambiguous velocity?
- The same 60 GHz radar uses 64 chirps per frame. What is the velocity resolution?
- Between two chirps 40 µs apart, the phase at a 77 GHz target’s range bin changes by 30°. What is the target’s velocity?
- A 3 TX × 4 RX MIMO radar has virtual spacing. What is its angular resolution at boresight and at 45°?
- Why does doubling the transmit power only increase detection range by about 19%?
- Modify the range–Doppler simulation so that one target moves at 30 m/s. Where does it appear, and why?
- In the CFAR code, set
n_guard=0and place two targets 2 bins apart. What happens to the weaker one?
Answers
- cm. MHz/µs.
- m.
- MHz.
- mm, so m/s.
- m/s.
- with mm and : m/s.
- 12 virtual elements: rad at boresight; at 45°.
- Detection range scales as , and .
- m/s, so 30 m/s wraps to m/s. It appears as an approaching target. Remedies include shorter chirps, staggered chirp periods, or tracking.
- Without guard cells, the strong target’s main lobe falls in the training window of the weak target’s CUT, inflating the noise estimate so the weaker target may be missed (target masking). Guard cells or OS-CFAR fix this.
Summary of key equations
| Quantity | Equation | Depends on |
|---|---|---|
| Chirp slope | Bandwidth, chirp time | |
| Beat frequency | Slope, range | |
| IF phase | Range (sub-mm sensitivity) | |
| Range resolution | Bandwidth only | |
| Maximum range (IQ) | ADC rate, slope | |
| Velocity | Chirp-to-chirp phase | |
| Maximum velocity | Chirp period | |
| Velocity resolution | Frame time | |
| Angle | Inter-antenna phase | |
| Field of view | Antenna spacing | |
| Angular resolution | Number of (virtual) antennas | |
| Processing gain | Total samples per frame |
Further reading
- Texas Instruments, Introduction to mmWave Sensing: FMCW Radars (training series and application notes).
- M. A. Richards, Fundamentals of Radar Signal Processing, 2nd ed., McGraw-Hill.
- M. I. Skolnik, Introduction to Radar Systems, 3rd ed., McGraw-Hill.
- S. M. Patole, M. Torlak, D. Wang and M. Ali, “Automotive radars: A review of signal processing techniques,” IEEE Signal Processing Magazine, 2017.