import radarsimpy
print("`RadarSimPy` used in this example is version: " + str(radarsimpy.__version__))
`RadarSimPy` used in this example is version: 15.2.0
FMCW Radar: Complete Range-Doppler Processing¶
FMCW radar transmits a linear frequency chirp and extracts range and velocity by mixing the TX and RX signals to produce a beat frequency.
Range from beat frequency: $$R = \frac{f_b \cdot c \cdot T_c}{2B}, \quad \Delta R = \frac{c}{2B}$$
Velocity from Doppler shift across chirps: $$f_d = \frac{2v_r f_c}{c}, \quad \Delta v = \frac{c}{2f_c T_{obs}}$$
Processing pipeline: Range FFT (fast-time) → Doppler FFT (slow-time) → 2D Range-Doppler map.
This Example¶
- Radar: 24.125 GHz center, 100 MHz BW → 1.5 m range resolution
- Waveform: 80 μs chirp, 100 μs PRP, 256 chirps → 0.24 m/s velocity resolution
- Targets: 3 point targets at 200 m / −5 m/s, 95 m / −50 m/s, 30 m / −22 m/s
- Processing: Chebyshev-windowed range and Doppler FFTs, 3D visualization
Radar System Configuration¶
Import Required Modules¶
# Import necessary modules for radar simulation
import numpy as np
from radarsimpy import Radar, Transmitter, Receiver
import plotly.graph_objs as go
from IPython.display import Image, display
# Set to True for interactive plots; False renders a static JPEG (e.g. for HTML export)
INTERACTIVE = False
def show(fig):
if INTERACTIVE:
fig.show()
else:
display(Image(fig.to_image(format="jpg", scale=2)))
Antenna Pattern Definition¶
Define realistic antenna radiation patterns for both transmitter and receiver.
Antenna Pattern Modeling:
Realistic antenna patterns are crucial for accurate radar simulation. Here we use idealized cosine patterns that approximate typical horn or patch antenna behavior.
Azimuth Pattern:
- Function: $G_{az}(\theta) = \cos^4(\theta) + 6$ dB
- Beamwidth: ~±40° (-3 dB points)
- Sidelobe Level: ~-20 dB
Elevation Pattern:
- Function: $G_{el}(\phi) = \cos^{20}(\phi) + 6$ dB
- Beamwidth: ~±20° (narrower, higher directivity)
- Sidelobe Level: ~-40 dB (lower sidelobes)
Why Patterns Matter:
- Target Detection: Off-boresight targets have reduced gain
- Interference: Sidelobe rejection affects interference immunity
- Clutter: Ground returns through elevation sidelobes
- Angular Coverage: Defines field of view
Users can replace these with measured patterns for specific antennas.
# Define azimuth angle array: -80° to +80° in 1° steps
az_angle = np.arange(-80, 81, 1) # 161 points
# Azimuth pattern: cos^4 taper with 6 dB gain
# Provides moderate beamwidth (~80° @ -3dB) with ~-20 dB sidelobes
az_pattern = 20 * np.log10(np.cos(az_angle / 180 * np.pi) ** 4) + 6 # dB
# Define elevation angle array: -80° to +80° in 1° steps
el_angle = np.arange(-80, 81, 1) # 161 points
# Elevation pattern: cos^20 taper with 6 dB gain
# Provides narrow beamwidth (~40° @ -3dB) with ~-40 dB sidelobes
el_pattern = 20 * np.log10((np.cos(el_angle / 180 * np.pi)) ** 20) + 6 # dB
Visualize Antenna Patterns¶
Display azimuth and elevation radiation patterns showing beamwidth and sidelobe characteristics.
# Create figure for antenna patterns
fig = go.Figure()
# Plot azimuth pattern
fig.add_trace(
go.Scatter(
x=az_angle,
y=az_pattern,
name="Azimuth",
line=dict(color="blue", width=2),
)
)
# Plot elevation pattern
fig.add_trace(
go.Scatter(
x=el_angle,
y=el_pattern,
name="Elevation",
line=dict(color="red", width=2),
)
)
fig.update_layout(
title="Antenna Radiation Patterns: Azimuth and Elevation",
yaxis=dict(title="Gain (dB)", range=[-60, 10], gridcolor="lightgray"),
xaxis=dict(title="Angle (degrees)", gridcolor="lightgray"),
height=500,
legend=dict(x=0.02, y=0.98),
)
show(fig)
Transmitter Channel Configuration¶
Define transmitter antenna location and radiation pattern.
Channel Parameters:
- Location: (0, 0, 0) → Origin (monostatic radar)
- Azimuth Pattern: Defined above (cos⁴ taper)
- Elevation Pattern: Defined above (cos²⁰ taper)
- Polarization: Default (linear, see documentation)
- Modulation: Default (unity, no pulse shaping)
The antenna patterns control how transmitted power is distributed in space, affecting target detection capability at different angles.
# Define transmitter channel with antenna patterns
tx_channel = dict(
location=(0, 0, 0), # Antenna position at origin
azimuth_angle=az_angle, # Azimuth angles array
azimuth_pattern=az_pattern, # Azimuth gain pattern (dB)
elevation_angle=el_angle, # Elevation angles array
elevation_pattern=el_pattern, # Elevation gain pattern (dB)
)
Transmitter Configuration¶
Configure the FMCW chirp waveform. The frequency sweep defines bandwidth B and center frequency f_c, while prp and pulses set the CPI length.
| Parameter | Value | Notes |
|---|---|---|
| Frequency sweep | 24.075–24.175 GHz | 100 MHz BW, 1.5 m range resolution |
| Chirp duration | 80 μs | Slope: 1.25 GHz/ms |
| TX power | 10 dBm | |
| PRP | 100 μs | PRF = 10 kHz |
| Pulses | 256 | CPI = 25.6 ms, Δv ≈ 0.24 m/s |
Maximum unambiguous velocity: $v_{max} = \frac{c}{4 f_c \cdot PRP} \approx 31$ m/s
# Configure FMCW transmitter
tx = Transmitter(
f=[24.075e9, 24.175e9], # Frequency sweep: 24.075-24.175 GHz (100 MHz BW)
t=80e-6, # Chirp duration: 80 μs
tx_power=10, # Transmit power: 10 dBm (~10 mW)
prp=100e-6, # Pulse repetition period: 100 μs (10 kHz PRF)
pulses=256, # Number of chirps: 256 (for Doppler processing)
channels=[tx_channel], # Transmitter antenna configuration
)
Receiver Channel Configuration¶
Define receiver antenna location and radiation pattern.
Channel Parameters:
Similar to the transmitter, the receiver channel specifies:
- Location: (0, 0, 0) → Co-located with TX (monostatic)
- Azimuth Pattern: Same as transmitter
- Elevation Pattern: Same as transmitter
For monostatic radar, TX and RX share the same antenna pattern, simplifying the radar equation and providing symmetric coverage.
# Define receiver channel with antenna patterns
rx_channel = dict(
location=(0, 0, 0), # Antenna position at origin (co-located with TX)
azimuth_angle=az_angle, # Azimuth angles array
azimuth_pattern=az_pattern, # Azimuth gain pattern (dB)
elevation_angle=el_angle, # Elevation angles array
elevation_pattern=el_pattern, # Elevation gain pattern (dB)
)
Receiver Configuration¶
The sampling rate determines maximum unambiguous range: $R_{max} = \frac{f_s c T_c}{2B} = 240$ m.
| Parameter | Value |
|---|---|
Sampling rate fs |
2 MHz |
| Noise figure | 12 dB |
| RF gain | 20 dB |
| Baseband gain | 30 dB |
| Load resistor | 500 Ω |
# Configure radar receiver
rx = Receiver(
fs=2e6, # Sampling rate: 2 MHz (determines max range ~240 m)
noise_figure=12, # Noise figure: 12 dB
rf_gain=20, # RF gain: 20 dB (LNA)
load_resistor=500, # Load resistance: 500 Ω
baseband_gain=30, # Baseband gain: 30 dB (total gain: 50 dB)
channels=[rx_channel], # Receiver antenna configuration
)
Create Radar System¶
Combine transmitter and receiver to form the complete FMCW radar.
# Create complete FMCW radar system
radar = Radar(transmitter=tx, receiver=rx)
Target Configuration¶
Three point targets covering different (range, velocity, RCS) combinations:
| Target | Location (m) | Speed (m/s) | RCS (dBsm) | Description |
|---|---|---|---|---|
| 1 | (200, 0, 0) | (−5, 0, 0) | 20 | Slow, large, far |
| 2 | (95, 20, 0) | (−50, 0, 0) | 15 | Fast, medium, mid-range |
| 3 | (30, −5, 0) | (−22, 0, 0) | 5 | Moderate, small, near |
# Configure Target 1: Large, slow, far vehicle
target_1 = dict(
location=(200, 0, 0), # Position: 200m range
speed=(-5, 0, 0), # Velocity: -5 m/s (18 km/h approaching)
rcs=20, # Radar cross section: 20 dBsm (100 m² - truck)
phase=0, # Initial phase: 0 degrees
)
# Configure Target 2: Medium vehicle, high speed
target_2 = dict(
location=(95, 20, 0), # Position: ~97m range, 20m Y-offset
speed=(-50, 0, 0), # Velocity: -50 m/s (180 km/h approaching)
rcs=15, # Radar cross section: 15 dBsm (31.6 m² - car)
phase=0, # Initial phase: 0 degrees
)
# Configure Target 3: Small vehicle, moderate speed, close
target_3 = dict(
location=(30, -5, 0), # Position: ~30m range, -5m Y-offset
speed=(-22, 0, 0), # Velocity: -22 m/s (79 km/h approaching)
rcs=5, # Radar cross section: 5 dBsm (3.16 m² - motorcycle)
phase=0, # Initial phase: 0 degrees
)
# Combine targets for simulation
targets = [target_1, target_2, target_3]
Simulate Baseband Signals¶
sim_radar computes the complex I/Q beat signal for each chirp, incorporating propagation delay, Doppler shift, RCS weighting, and thermal noise.
Output shape: [channels, pulses, samples] → [1, 256, 160].
# Import radar simulator
from radarsimpy.simulator import sim_radar
# Simulate FMCW radar returns from three targets
data = sim_radar(radar, targets)
# Extract timestamp and baseband signals
timestamp = data["timestamp"] # Time axis [1, 256, 160]
baseband = data["baseband"] + data["noise"] # Complex I/Q with noise [1, 256, 160]
Visualize Baseband I/Q Signals¶
Display time-domain baseband waveform for first chirp showing mixed beat frequencies.
# Create figure for baseband visualization
fig = go.Figure()
# Plot In-phase (I) component of first chirp
fig.add_trace(
go.Scatter(
x=timestamp[0, 0, :],
y=np.real(baseband[0, 0, :]),
name="I (In-phase)",
line=dict(color="blue", width=1),
)
)
# Plot Quadrature (Q) component of first chirp
fig.add_trace(
go.Scatter(
x=timestamp[0, 0, :],
y=np.imag(baseband[0, 0, :]),
name="Q (Quadrature)",
line=dict(color="red", width=1),
)
)
fig.update_layout(
title="Baseband I/Q Signals: First Chirp (Three-Target Mixture)",
yaxis=dict(title="Amplitude (V)", gridcolor="lightgray"),
xaxis=dict(title="Time (s)", gridcolor="lightgray"),
height=500,
legend=dict(x=0.02, y=0.98),
)
show(fig)
# Import signal processing modules
from scipy import signal
import radarsimpy.processing as proc
# Create Chebyshev window for range FFT (60 dB sidelobe suppression)
range_window = signal.windows.chebwin(radar.sample_prop["samples_per_pulse"], at=60)
# Perform range FFT to compress chirp into range bins
# Input: baseband [1, 256, 160]
# Output: range_profile [1, 256, 160 range bins]
range_profile = proc.range_fft(baseband, range_window)
Visualize Range Profiles¶
Display 3D surface showing range profiles across all chirps.
Interpretation:
- X-axis: Range (meters) → Target distance
- Y-axis: Chirp index (0-255) → Time progression
- Z-axis: Amplitude (dB) → Detection strength
- Peaks: Target locations at specific ranges
- Vertical lines: Stationary or slow-moving targets
- Slanted lines: Fast-moving targets (Doppler-induced range migration)
# Calculate maximum unambiguous range
max_range = (
3e8
* radar.radar_prop["receiver"].bb_prop["fs"]
* radar.radar_prop["transmitter"].waveform_prop["pulse_length"]
/ radar.radar_prop["transmitter"].waveform_prop["bandwidth"]
/ 2
)
range_axis = np.linspace(
0, max_range, radar.sample_prop["samples_per_pulse"], endpoint=False
)
chirp_axis = np.linspace(
0,
radar.radar_prop["transmitter"].waveform_prop["pulses"],
radar.radar_prop["transmitter"].waveform_prop["pulses"],
endpoint=False,
)
fig = go.Figure()
fig.add_trace(
go.Surface(
x=range_axis,
y=chirp_axis,
z=20 * np.log10(np.abs(range_profile[0, :, :])),
colorscale="Rainbow",
colorbar=dict(title="Amplitude (dB)"),
)
)
fig.update_layout(
title="Range Profiles: Amplitude vs. Range and Chirp Index",
height=600,
scene=dict(
xaxis=dict(title="Range (m)"),
yaxis=dict(title="Chirp Index"),
zaxis=dict(title="Amplitude (dB)"),
camera=dict(eye=dict(x=1.8, y=1.8, z=1.8)),
aspectmode="cube",
),
margin=dict(l=0, r=0, b=0, t=40),
)
show(fig)
Doppler FFT (Slow-Time Processing)¶
A second Chebyshev window is applied across the chirp (slow-time) dimension, then an FFT over chirps converts chirp-to-chirp phase into Doppler frequency → radial velocity:
$$v = \frac{f_d \cdot c}{2 f_c}$$
Output: Range-Doppler map [velocity bins × range bins].
Visualize Range-Doppler Map¶
# Create Chebyshev window for Doppler FFT (60 dB sidelobe suppression)
doppler_window = signal.windows.chebwin(
radar.radar_prop["transmitter"].waveform_prop["pulses"], at=60
)
# Perform Doppler FFT to extract velocity information
# Input: range_profile [1, 256, 160]
# Output: range_doppler [1, 256 velocity bins, 160 range bins]
range_doppler = proc.doppler_fft(range_profile, doppler_window)
The 3D surface shows target peaks at their (range, velocity) coordinates. Negative velocity = approaching. Expect peaks near: (200 m, −5 m/s), (95 m, −50 m/s*), (30 m, −22 m/s).
* Target 2 may alias since $v_{max} \approx 31$ m/s.
# Calculate maximum unambiguous velocity
unambiguous_speed = (
3e8 / radar.radar_prop["transmitter"].waveform_prop["prp"][0] / 24.125e9 / 2
)
range_axis = np.linspace(
0, max_range, radar.sample_prop["samples_per_pulse"], endpoint=False
)
doppler_axis = np.linspace(
-unambiguous_speed,
0,
radar.radar_prop["transmitter"].waveform_prop["pulses"],
endpoint=False,
)
fig = go.Figure()
fig.add_trace(
go.Surface(
x=range_axis,
y=doppler_axis,
z=20 * np.log10(np.abs(range_doppler[0, :, :])),
colorscale="Rainbow",
colorbar=dict(title="Amplitude (dB)"),
)
)
fig.update_layout(
title="Range-Doppler Map: Three Targets in Range-Velocity Space",
height=600,
scene=dict(
xaxis=dict(title="Range (m)"),
yaxis=dict(title="Velocity (m/s)"),
zaxis=dict(title="Amplitude (dB)"),
aspectmode="cube",
camera=dict(eye=dict(x=1.8, y=1.8, z=1.8)),
),
margin=dict(l=0, r=0, b=0, t=40),
)
show(fig)
Summary¶
This notebook demonstrated a complete FMCW radar simulation and range-Doppler processing chain:
- Beat frequency encodes range; phase change across chirps encodes velocity.
- Range resolution $\Delta R = c/(2B) = 1.5$ m set by bandwidth; velocity resolution $\Delta v \approx 0.24$ m/s set by CPI length.
- Chebyshev windowing in both FFT dimensions reduces sidelobe interference between targets.
- All three targets are resolved as distinct peaks in the 2D Range-Doppler map.
Things to Try¶
| Experiment | Parameter to change | Observable effect |
|---|---|---|
| Better range resolution | Increase B |
Peaks sharpen along range axis |
| Better velocity resolution | Increase pulses |
Peaks sharpen along velocity axis |
| Avoid Doppler aliasing | Decrease prp |
$v_{max}$ increases |
| Weaker target detection | Reduce rcs |
Peak amplitude drops; noise floor visible |
| Sidelobe comparison | Swap window type | Change dynamic range around target peaks |
Hello, thank you for the insightful tutorial. I have just one question about the final range-doppler visualization:
Why is the velocity axis just one sided – shouldn’t it be teo sided to reflect also the positive speeds (which are in principle possbile to be defined in the targest?)
Hi Martin,
In FMCW radar, the Doppler axis can look one‑sided simply because the designer chooses how to map the unambiguous Doppler coverage Vua; this range can be allocated in several valid ways—such as from −Vua/2 to +Vua/2, or only negative velocities −Vua to 0, or only positive velocities 0 to Vua, depending on the application and how the FFT bins are labeled. So when a tutorial shows only one‑sided Doppler, it’s not a limitation of FMCW physics, but just a plotting or design choice in how the velocity interval is displayed.