import radarsimpy
print("`RadarSimPy` used in this example is version: " + str(radarsimpy.__version__))
`RadarSimPy` used in this example is version: 15.2.0
PMCW Radar: Phase-Modulated Continuous Wave¶
Introduction¶
PMCW (Phase-Modulated Continuous Wave) radar modulates the phase of a constant-frequency carrier using binary code sequences (BPSK) instead of sweeping the frequency like FMCW.
The transmitted signal is:
$$s(t) = A \cos(2\pi f_c t + \phi(t)), \qquad \phi(t) \in \{0°, 180°\}$$
Range is extracted from code correlation delay $\tau$:
$$\Delta R = \frac{c \cdot T_{chip}}{2}$$
Maximum unambiguous velocity is chip-duration limited:
$$v_{max} = \frac{c}{2 f_c T_{chip}}$$
For 24.125 GHz with $T_{chip} = 4$ ns: $v_{max} \approx 1550$ m/s — far beyond FMCW's typical 30–100 m/s.
PMCW vs. FMCW¶
| Feature | FMCW | PMCW |
|---|---|---|
| Modulation | Frequency chirp | Phase code (BPSK) |
| Range | Beat frequency | Code correlation |
| Max velocity | ~30–100 m/s | ~1550 m/s (chip-limited) |
| Interference | Sensitive | Orthogonal code rejection |
| MIMO | Limited | Code-division multiplexing |
| Sampling rate | ~MHz (beat freq) | ~250 MHz (chip rate) |
This Example¶
Uses RadarSimPy to simulate a 2-channel MIMO PMCW radar:
- Carrier: 24.125 GHz, 255-chip binary codes at 4 ns/chip (1.02 μs)
- MIMO: TX1 at (0, 0, 0) m and TX2 at (1, 0, 0) m with orthogonal codes
- 256 pulses for Doppler processing
- 3 targets: −200 m/s at 20 m, 0 m/s at 70 m, +100 m/s at ~34 m
- Processing: matched filtering → MIMO channel separation → range-Doppler maps
Phase Code Definition¶
Define binary BPSK phase code sequences for both MIMO channels.
import numpy as np
import plotly.graph_objs as go
from IPython.display import Image, display
from radarsimpy import Radar, Transmitter, Receiver
from radarsimpy.simulator import sim_radar
from scipy import signal
INTERACTIVE = False
def show(fig):
if INTERACTIVE:
fig.show()
else:
display(Image(fig.to_image(format="jpg", scale=2)))
### Phase Code Sequences ###
# Define binary phase code 1 (255 chips)
# Values: +1 or -1 (will be converted to 0° or 180° phase)
code1 = np.array(
[
1, -1, 1, 1, -1, -1, -1, 1, -1, 1, -1, 1, 1, 1, -1, 1, 1,
-1, -1, 1, 1, 1, -1, -1, -1, -1, 1, -1, -1, 1, -1, -1, 1, 1,
1, 1, 1, -1, 1, 1, 1, -1, -1, 1, -1, 1, 1, -1, 1, -1, -1,
-1, -1, -1, 1, 1, -1, 1, -1, 1, -1, -1, 1, -1, -1, -1, 1, 1,
-1, -1, 1, -1, 1, -1, 1, 1, -1, -1, -1, -1, 1, 1, 1, 1, -1,
-1, 1, 1, -1, 1, 1, 1, 1, -1, -1, -1, 1, -1, -1, -1, -1, -1,
-1, 1, -1, 1, 1, 1, 1, 1, 1, -1, -1, 1, -1, -1, 1, 1, -1,
-1, -1, -1, 1, -1, -1, -1, -1, 1, -1, 1, -1, -1, 1, 1, -1, -1,
1, 1, 1, -1, 1, -1, 1, 1, 1, -1, 1, 1, 1, 1, 1, 1, 1,
-1, 1, -1, -1, -1, 1, -1, 1, 1, -1, -1, 1, 1, -1, 1, -1, -1,
1, -1, 1, 1, -1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1, 1,
1, -1, -1, -1, 1, 1, 1, 1, 1, -1, 1, -1, 1, -1, 1, -1, -1,
-1, -1, 1, 1, -1, 1, 1, 1, -1, 1, 1, -1, 1, -1, -1, -1, 1,
1, 1, -1, -1, 1, -1, -1, -1, 1, -1, 1, -1, 1, -1, 1, 1, 1,
1, -1, 1, 1, -1, 1, -1, 1, -1, -1, -1, -1, -1, 1, -1, -1, 1,
]
)
# Define binary phase code 2 (255 chips, orthogonal to code1)
code2 = np.array(
[
1, 1, 1, -1, -1, -1, -1, 1, 1, -1, -1, 1, -1, 1, 1, -1, -1,
1, -1, 1, -1, -1, 1, -1, -1, 1, 1, -1, 1, 1, -1, 1, -1, -1,
-1, 1, -1, 1, 1, 1, 1, 1, -1, 1, 1, 1, -1, -1, 1, 1, -1,
1, 1, -1, -1, -1, -1, -1, -1, 1, -1, 1, 1, 1, 1, 1, 1, -1,
-1, 1, -1, -1, 1, 1, -1, -1, -1, -1, 1, -1, -1, -1, -1, 1, -1,
1, -1, -1, 1, 1, -1, -1, 1, 1, 1, -1, 1, -1, 1, 1, 1, -1,
1, 1, 1, 1, 1, 1, 1, -1, 1, -1, -1, -1, 1, -1, 1, 1, -1,
1, -1, -1, -1, 1, 1, -1, -1, 1, -1, 1, -1, 1, 1, -1, -1, -1,
-1, 1, 1, 1, 1, -1, -1, 1, 1, -1, 1, 1, 1, -1, 1, -1, -1,
1, -1, 1, 1, -1, 1, 1, -1, -1, -1, 1, -1, -1, 1, -1, 1, 1,
1, -1, 1, -1, 1, -1, -1, -1, -1, 1, 1, -1, 1, 1, 1, -1, 1,
1, -1, 1, -1, -1, -1, 1, 1, 1, -1, -1, 1, -1, -1, -1, 1, -1,
1, 1, -1, -1, 1, 1, -1, 1, -1, -1, 1, -1, -1, -1, -1, -1, 1,
1, -1, 1, -1, 1, -1, -1, 1, -1, -1, -1, 1, 1, -1, -1, 1, -1,
1, -1, 1, 1, 1, -1, 1, 1, 1, 1, -1, -1, -1, 1, -1, -1, 1,
]
)
# Convert ±1 binary codes to phase (degrees): +1 → 0°, -1 → 180°
phase_code1 = np.where(code1 > 0, 0, 180) # TX channel 1 phase sequence
phase_code2 = np.where(code2 > 0, 0, 180) # TX channel 2 phase sequence
# Define chip duration and timing
chip_duration = 4e-9 # 4 ns per chip
# Create time arrays for phase modulation (one entry per chip transition)
t_mod1 = np.arange(0, 255) * chip_duration # 255 chip times for TX1
t_mod2 = np.arange(0, 255) * chip_duration # 255 chip times for TX2
Visualize Phase Code Sequences¶
Display phase modulation sequences for both MIMO channels showing BPSK modulation pattern.
# Create figure for phase code visualization
fig = go.Figure()
# Plot phase code sequence 1 (TX channel 1)
fig.add_trace(
go.Scatter(
x=t_mod1 * 1e9, # Convert to nanoseconds
y=phase_code1, # Phase in degrees
name="Phase code 1 (TX1)",
line=dict(color='blue', width=1),
mode='lines',
)
)
# Plot phase code sequence 2 (TX channel 2)
fig.add_trace(
go.Scatter(
x=t_mod2 * 1e9, # Convert to nanoseconds
y=phase_code2, # Phase in degrees
name="Phase code 2 (TX2)",
line=dict(color='red', width=1),
mode='lines',
)
)
# Configure plot layout
fig.update_layout(
title="Binary Phase Code Sequences: 255 Chips @ 4 ns/chip (BPSK)",
yaxis=dict(
title="Phase (degrees)",
gridcolor='lightgray',
tickvals=[0, 180], # Only show 0° and 180°
),
xaxis=dict(title="Time (ns)", gridcolor='lightgray'),
height=500,
legend=dict(x=0.02, y=0.98),
)
show(fig)
Radar System Configuration¶
Transmitter Configuration¶
| Parameter | Value | Notes |
|---|---|---|
| Carrier frequency | 24.125 GHz | Constant — no frequency modulation |
| Pulse duration | 2.1 μs | Code (1.02 μs) + guard time |
| Transmit power | 20 dBm | ~100 mW |
| Pulses | 256 | For Doppler processing |
| TX1 location | (0, 0, 0) m | Phase code 1 |
| TX2 location | (1, 0, 0) m | Phase code 2 (orthogonal), 1 m spacing |
Both channels transmit simultaneously at the same carrier frequency; orthogonal codes enable receiver-side separation via matched filtering.
### Define TX Channel 1 with Phase Code 1 ###
tx_channel_1 = dict(
location=(0, 0, 0), # TX1 antenna position at origin
mod_t=t_mod1, # Phase modulation timing (255 points × 4 ns)
phs=phase_code1, # Phase sequence for channel 1 (0° or 180°)
)
### Define TX Channel 2 with Phase Code 2 ###
tx_channel_2 = dict(
location=(1, 0, 0), # TX2 antenna position 1m offset in X
mod_t=t_mod2, # Phase modulation timing (same as TX1)
phs=phase_code2, # Phase sequence for channel 2 (orthogonal code)
)
### Create PMCW Transmitter ###
tx = Transmitter(
f=24.125e9, # Carrier frequency: 24.125 GHz (constant, no chirp)
t=2.1e-6, # Pulse duration: 2.1 μs (includes code + guard time)
tx_power=20, # Transmit power: 20 dBm (~100 mW)
pulses=256, # Number of pulses: 256 (for Doppler processing)
channels=[tx_channel_1, tx_channel_2], # 2-channel MIMO configuration
)
Receiver Configuration¶
| Parameter | Value | Notes |
|---|---|---|
| Sampling rate | 250 MHz | 4 ns/sample — matches chip duration |
| Noise figure | 10 dB | — |
| RF gain | 20 dB | — |
| Baseband gain | 30 dB | Total: 50 dB |
| Load resistor | 1000 Ω | — |
| Location | (0, 0, 0) m | Monostatic with TX1 |
PMCW requires a much higher sampling rate than FMCW because phase transitions (4 ns chips) must be captured directly, unlike FMCW which samples only the beat frequency (~MHz).
# Configure PMCW radar receiver
rx = Receiver(
fs=250e6, # Sampling rate: 250 MHz (4 ns per sample, matches chip rate)
noise_figure=10, # Noise figure: 10 dB
rf_gain=20, # RF gain: 20 dB
baseband_gain=30, # Baseband gain: 30 dB (total: 50 dB)
load_resistor=1000, # Load resistance: 1000 Ω
channels=[dict(location=(0, 0, 0))], # Single RX at origin
)
Create Radar System¶
Combine transmitter and receiver to form the complete PMCW MIMO radar.
# Create complete PMCW MIMO radar system
radar = Radar(transmitter=tx, receiver=rx)
Target Configuration¶
Three targets with velocities that exceed typical FMCW capability ($v_{max,FMCW} \approx 30$–100 m/s):
| Target | Range | Velocity | RCS | Notes |
|---|---|---|---|---|
| 1 | 20 m | −200 m/s | 10 dBsm | Approaching at 720 km/h |
| 2 | 70 m | 0 m/s | 35 dBsm | Stationary (large) |
| 3 | ~34 m | +100 m/s | 20 dBsm | Receding at 360 km/h |
All targets are within PMCW's $v_{max} \approx 1550$ m/s without Doppler ambiguity.
# Configure Target 1: Very high closing speed (beyond FMCW capability)
target_1 = dict(
location=(20, 0, 0), # Position: 20m range
speed=(-200, 0, 0), # Velocity: -200 m/s (720 km/h approaching!)
rcs=10, # Radar cross section: 10 dBsm
phase=0, # Initial phase: 0 degrees
)
# Configure Target 2: Stationary, large RCS (reference)
target_2 = dict(
location=(70, 0, 0), # Position: 70m range
speed=(0, 0, 0), # Velocity: 0 m/s (stationary)
rcs=35, # Radar cross section: 35 dBsm (very large)
phase=0, # Initial phase: 0 degrees
)
# Configure Target 3: High receding speed
target_3 = dict(
location=(33, 10, 0), # Position: ~34m range, 10m Y-offset
speed=(100, 0, 0), # Velocity: +100 m/s (360 km/h receding)
rcs=20, # Radar cross section: 20 dBsm
phase=0, # Initial phase: 0 degrees
)
# Combine targets for simulation
targets = [target_1, target_2, target_3]
Simulate Baseband Signals¶
Simulate PMCW baseband I/Q from both MIMO TX channels. Output shape: [2 TX channels, 256 pulses, ~525 samples/pulse].
# Simulate PMCW radar with 2-channel MIMO and three high-velocity targets
data = sim_radar(radar, targets)
# Extract timestamp and baseband signals
timestamp = data["timestamp"] # Time axis [2, 256, 525]
data_matrix = data["baseband"] + data["noise"] # Complex I/Q + noise [2, 256, 525]
Combine Simultaneous TX Channels¶
In hardware, TX1 and TX2 transmit simultaneously — the RX receives their superposition. Sum both channel contributions to form the combined baseband [256 pulses, ~525 samples] before matched filtering.
# Combine baseband from both TX channels (simultaneous transmission)
# TX1 and TX2 signals are received together at RX
baseband = data_matrix[0, :, :] + data_matrix[1, :, :] # [256 pulses, 525 samples]
Visualize Combined Baseband¶
Display time-domain I/Q signals showing superposition of both phase-coded TX channels.
# Create figure for combined baseband visualization
fig = go.Figure()
# Plot In-phase (I) component of first pulse
fig.add_trace(
go.Scatter(
x=timestamp[0, 0, :] * 1e6, # Convert to microseconds
y=np.real(baseband[0, :]), # Real part (I channel)
name="I (In-phase)",
)
)
# Plot Quadrature (Q) component of first pulse
fig.add_trace(
go.Scatter(
x=timestamp[0, 0, :] * 1e6, # Convert to microseconds
y=np.imag(baseband[0, :]), # Imaginary part (Q channel)
name="Q (Quadrature)",
)
)
# Configure plot layout
fig.update_layout(
title="Combined Baseband I/Q: Superposition of Both MIMO Channels",
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)
Radar Signal Processing¶
Matched Filtering (Code Correlation)¶
Range is extracted by correlating the received signal with each code:
$$R[n] = \sum_{k=0}^{N-1} s[k] \cdot c^*[k-n]$$
Due to low cross-correlation between orthogonal codes, correlating with code1 extracts TX1's contribution and code2 extracts TX2's — separating the two MIMO channels. Result: range profile matrix [2 channels, 256 pulses, 255 range bins].
Demodulate Channel 1¶
# Initialize range profile storage
# Dimensions: [2 MIMO channels, 256 pulses, 255 range bins]
range_profile = np.zeros(
(
radar.array_prop["size"], # 2 TX channels (MIMO)
radar.radar_prop["transmitter"].waveform_prop["pulses"], # 256 pulses
255, # 255 range bins (one per chip offset)
),
dtype=complex, # Complex I/Q data
)
### Matched Filtering for Channel 1 (TX1 with code1) ###
# Loop over all pulses
for pulse_idx in range(0, radar.radar_prop["transmitter"].waveform_prop["pulses"]):
# Loop over all range bins (delay offsets)
for bin_idx in range(0, 255):
# Correlate received signal with code1
# Extract 255 samples starting at bin_idx
# Multiply element-wise with code1 and sum (correlation)
range_profile[0, pulse_idx, bin_idx] = np.sum(
code1 * baseband[pulse_idx, bin_idx : (bin_idx + 255)]
)
Demodulate Channel 2¶
Correlate with code2 to extract TX channel 2 contribution.
### Matched Filtering for Channel 2 (TX2 with code2) ###
# Loop over all pulses
for pulse_idx in range(0, radar.radar_prop["transmitter"].waveform_prop["pulses"]):
# Loop over all range bins (delay offsets)
for bin_idx in range(0, 255):
# Correlate received signal with code2
# Orthogonal code extracts TX2 contribution
range_profile[1, pulse_idx, bin_idx] = np.sum(
code2 * baseband[pulse_idx, bin_idx : (bin_idx + 255)]
)
Visualize Range Profiles¶
Display averaged range profiles for both MIMO channels showing target detection.
# Calculate range bin size
# Range resolution = c / 2 × chip_duration
bin_size = 3e8 / 2 * 4e-9 # Speed of light / 2 × 4 ns = 0.6 m per bin
# Create range axis
range_bin = np.arange(0, 255, 1) * bin_size # 255 bins × 0.6 m
# Create comparison figure
fig = go.Figure()
# Plot Channel 1 range profile (averaged over all pulses)
fig.add_trace(
go.Scatter(
x=range_bin,
y=20 * np.log10(np.mean(np.abs(range_profile[0, :, :]), axis=0)),
name="Channel 1 (TX1, code1)",
)
)
# Plot Channel 2 range profile (averaged over all pulses)
fig.add_trace(
go.Scatter(
x=range_bin,
y=20 * np.log10(np.mean(np.abs(range_profile[1, :, :]), axis=0)),
name="Channel 2 (TX2, code2)",
)
)
# Configure plot layout
fig.update_layout(
title="Range Profiles: Both MIMO Channels Detect All Three Targets",
yaxis=dict(title="Amplitude (dB)", gridcolor='lightgray'),
xaxis=dict(title="Range (m)", gridcolor='lightgray'),
height=500,
legend=dict(x=0.02, y=0.98),
)
show(fig)
Doppler Processing¶
Apply Chebyshev-windowed FFT across pulses for each range bin:
$$v_{max} = \frac{c}{2 f_c \cdot PRP}$$
A 50-dB Chebyshev window suppresses sidelobes before the 256-point FFT. Result: range-Doppler map [2 channels, 256 Doppler bins, 255 range bins].
# Create Chebyshev window for Doppler FFT (50 dB sidelobe suppression)
doppler_window = signal.windows.chebwin(
radar.radar_prop["transmitter"].waveform_prop["pulses"], at=50
)
# Initialize range-Doppler map storage
# Dimensions: [2 MIMO channels, 256 Doppler bins, 255 range bins]
range_doppler = np.zeros(np.shape(range_profile), dtype=complex)
# Perform Doppler FFT for each channel and range bin
for ii in range(0, radar.array_prop["size"]): # Loop over MIMO channels (2)
for jj in range(0, 255): # Loop over range bins (255)
# Apply window, FFT, and shift for each range bin
range_doppler[ii, :, jj] = np.fft.fftshift(
np.fft.fft(
range_profile[ii, :, jj] * doppler_window, # Window across pulses
n=radar.radar_prop["transmitter"].waveform_prop["pulses"], # 256-point FFT
)
)
# Calculate maximum unambiguous velocity
# v_max = c / (2 * f_c * PRP)
unambiguous_speed = (
3e8 # Speed of light
/ radar.radar_prop["transmitter"].waveform_prop["prp"] # Pulse repetition period
/ 24.125e9 # Carrier frequency
/ 2 # Two-way Doppler
)
Visualize Range-Doppler Map¶
Display 3D range-Doppler map for Channel 1 showing high-velocity targets.
Interpretation:
- X-axis: Range (m) → Target distance
- Y-axis: Velocity (m/s) → Radial velocity (negative = approaching, positive = receding)
- Z-axis: Amplitude (dB) → Detection strength
- Peaks: Three targets at their (range, velocity) coordinates
Expected Targets:
- Target 1: ~20m, -200 m/s (very high closing speed)
- Target 2: ~70m, 0 m/s (stationary)
- Target 3: ~33m, +100 m/s (high receding speed)
PMCW Advantage:
All three targets are clearly resolved despite extreme velocities (±200 m/s) that would cause severe Doppler ambiguity in typical FMCW radar.
# Create range axis (same as before)
range_axis = np.arange(0, 255, 1) * bin_size # 0 to ~153 m
# Create velocity axis (centered around zero)
doppler_axis = np.linspace(
-unambiguous_speed / 2, # Maximum approaching velocity
unambiguous_speed / 2, # Maximum receding velocity
radar.radar_prop["transmitter"].waveform_prop["pulses"], # 256 bins
endpoint=False,
)
# Create 3D surface plot of range-Doppler map (Channel 1)
fig = go.Figure()
fig.add_trace(
go.Surface(
x=range_axis, # Range axis (m)
y=doppler_axis, # Velocity axis (m/s)
z=20 * np.log10(np.abs(range_doppler[0, :, :])), # Amplitude (dB) for channel 1
colorscale="Rainbow", # Color scheme
colorbar=dict(title="Amplitude (dB)"),
)
)
# Configure 3D plot layout
fig.update_layout(
title="Range-Doppler Map: PMCW Channel 1 (High-Velocity Targets)",
height=800,
scene=dict(
xaxis=dict(title="Range (m)"),
yaxis=dict(title="Velocity (m/s)"),
zaxis=dict(title="Amplitude (dB)"),
aspectmode="cube",
),
margin=dict(l=0, r=0, b=0, t=40),
)
show(fig)
Summary¶
This example demonstrated a 2-channel MIMO PMCW radar with high-velocity target detection:
- BPSK phase coding on a constant carrier enables range via code correlation, not beat frequency
- $v_{max} \approx 1550$ m/s (chip-limited) far exceeds FMCW's typical 30–100 m/s range
- Orthogonal codes allow simultaneous MIMO transmission and receiver-side channel separation
- 250 MHz sampling required to capture 4 ns chip transitions (much higher than FMCW)
- Matched filtering provides range compression with processing gain equal to code length (255)
- Doppler FFT across 256 pulses resolves all three targets from −200 m/s to +100 m/s
Things to Try¶
| Experiment | What to Change | What to Observe |
|---|---|---|
| Code length | 127 or 511 chips | Range resolution and processing gain |
| Chip duration | 2, 8, 16 ns | Resolution vs. sampling rate |
| Number of pulses | 128, 512 | Velocity resolution vs. update rate |
| Carrier frequency | 77 GHz | Doppler sensitivity ($f_d \propto f_c$) |
| Target velocity | ±500 m/s | $v_{max}$ limits and ambiguity |
| MIMO scaling | 3–4 TX channels | Virtual array size and angular resolution |
| Code selection | Gold, Kasami codes | Cross-correlation and interference rejection |
| Window functions | Hamming, Taylor | Sidelobe vs. resolution trade-off |
Hi,
Excellent resource, thanks.
When plotting the range profile, we should first average the complex values and then take the absolute. This way we filter out complex noise better.
This becomes
y=20 * np.log10(np.abs(np.mean(range_profile[1, :, :], axis=0))),
Just a suggestion 🙂