import radarsimpy
print("`RadarSimPy` used in this example is version: " + str(radarsimpy.__version__))
`RadarSimPy` used in this example is version: 15.2.0
TDM MIMO FMCW Radar: Virtual Array Beamforming¶
Introduction¶
TDM MIMO FMCW radar transmits chirps from multiple TX antennas sequentially (time-division), and each TX-RX pair forms a unique virtual element, creating a large aperture from fewer physical elements:
$$N_{virtual} = N_{TX} \times N_{RX}, \qquad \vec{v}_{ij} = \vec{t}_i + \vec{r}_j$$
Angular resolution scales with virtual aperture length $L$:
$$\theta_{3dB} \approx \frac{\lambda}{L}$$
TDM is enabled in RadarSimPy via the delay parameter per TX channel. All TX share the same PRP, which must accommodate $N_{TX}$ time slots.
Digital beamforming applies a steering matrix across the virtual array:
$$\mathbf{Y} = \mathbf{A}^H (\mathbf{X} \odot \vec{w}), \quad A_{n,\theta} = e^{j2\pi d_n \sin\theta / \lambda}$$
This Example¶
Uses RadarSimPy to simulate a 4 TX × 8 RX TDM MIMO FMCW radar:
- Carrier: 24.125 GHz, 100 MHz bandwidth (24.075–24.175 GHz), chirp = 80 μs
- Arrays: 4 TX (2λ spacing) + 8 RX (λ/2 spacing) = 32 virtual channels
- TDM delays: 0 / 100 / 200 / 300 μs, PRP = 400 μs
- Targets: 3 stationary targets at different ranges and angles
- Processing: range FFT → virtual array stacking → digital beamforming → range-angle map
Radar System Configuration¶
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
import radarsimpy.processing as proc
INTERACTIVE = False
def show(fig):
if INTERACTIVE:
fig.show()
else:
display(Image(fig.to_image(format="jpg", scale=2)))
TX Array Configuration¶
4 elements with 2λ spacing transmit sequentially via TDM. The delay parameter sets each element's time slot:
| TX Channel | Y-position | TDM delay | Time slot |
|---|---|---|---|
| TX1 | −12λ | 0 μs | 0–80 μs |
| TX2 | −8λ | 100 μs | 100–180 μs |
| TX3 | −4λ | 200 μs | 200–280 μs |
| TX4 | 0 | 300 μs | 300–380 μs |
PRP must satisfy $PRP \geq N_{TX} \times 100\,\text{μs} = 400\,\text{μs}$.
# Calculate wavelength at carrier frequency
wavelength = 3e8 / 24.125e9 # λ ≈ 12.4 mm
### Define Antenna Pattern ###
# Angular coverage: -90° to +90° in 1° steps
angle = np.arange(-90, 91, 1)
# Cosine pattern with 6 dB gain (broad beam for wide coverage)
pattern = 20 * np.log10(np.cos(angle / 180 * np.pi) + 0.01) + 6 # dB
### Configure TX Array (4 elements, 2λ spacing, TDM) ###
# TX Channel 1: Position -12λ, delay 0 μs (first time slot)
tx_channel_1 = dict(
location=(0, -12 * wavelength, 0), # Y-position: -12λ (leftmost)
azimuth_angle=angle, # Azimuth angles
azimuth_pattern=pattern, # Azimuth gain pattern (dB)
elevation_angle=angle, # Elevation angles
elevation_pattern=pattern, # Elevation gain pattern (dB)
delay=0, # TDM time slot: 0 μs (transmits first)
)
# TX Channel 2: Position -8λ, delay 100 μs (second time slot)
tx_channel_2 = dict(
location=(0, -8 * wavelength, 0), # Y-position: -8λ
azimuth_angle=angle,
azimuth_pattern=pattern,
elevation_angle=angle,
elevation_pattern=pattern,
delay=100e-6, # TDM time slot: 100 μs (transmits second)
)
# TX Channel 3: Position -4λ, delay 200 μs (third time slot)
tx_channel_3 = dict(
location=(0, -4 * wavelength, 0), # Y-position: -4λ
azimuth_angle=angle,
azimuth_pattern=pattern,
elevation_angle=angle,
elevation_pattern=pattern,
delay=200e-6, # TDM time slot: 200 μs (transmits third)
)
# TX Channel 4: Position 0, delay 300 μs (fourth time slot)
tx_channel_4 = dict(
location=(0, 0, 0), # Y-position: 0 (rightmost)
azimuth_angle=angle,
azimuth_pattern=pattern,
elevation_angle=angle,
elevation_pattern=pattern,
delay=300e-6, # TDM time slot: 300 μs (transmits fourth)
)
### Create FMCW Transmitter with TDM ###
tx = Transmitter(
f=[24.075e9, 24.175e9], # FMCW chirp: 24.075-24.175 GHz (100 MHz bandwidth)
t=80e-6, # Chirp duration: 80 μs
tx_power=15, # Transmit power: 15 dBm per channel
prp=400e-6, # Pulse repetition period: 400 μs (accommodates 4 TX slots)
pulses=1, # Number of pulses: 1 (single chirp per TX)
channels=[tx_channel_1, tx_channel_2, tx_channel_3, tx_channel_4], # 4 TX channels
)
print(f"TX Array Configuration:")
print(f" Number of TX elements: 4")
print(f" TX spacing: 2λ = {2*wavelength*1000:.2f} mm")
print(f" TX aperture: 12λ = {12*wavelength*1000:.1f} mm")
print(f" TDM time slots: 0, 100, 200, 300 μs")
print(f" Chirp bandwidth: 100 MHz")
print(f" PRP: 400 μs")
TX Array Configuration: Number of TX elements: 4 TX spacing: 2λ = 24.87 mm TX aperture: 12λ = 149.2 mm TDM time slots: 0, 100, 200, 300 μs Chirp bandwidth: 100 MHz PRP: 400 μs
RX Array Configuration¶
8 elements with λ/2 spacing provide uniform spatial sampling of the virtual array.
| Parameter | Value |
|---|---|
| Elements | 8 |
| Spacing | λ/2 ≈ 6.2 mm |
| RX aperture | 3.5λ |
| Sampling rate | 2 MHz |
| Noise figure | 8 dB |
| RF / BB gain | 20 / 50 dB |
| Virtual elements | 4 × 8 = 32 |
### Configure RX Array (8 elements, λ/2 spacing) ###
# Create list of RX channels with uniform spacing
channels = []
for idx in range(0, 8):
channels.append(
dict(
location=(0, wavelength / 2 * idx, 0), # Y-position: 0, λ/2, λ, 1.5λ, ..., 3.5λ
azimuth_angle=angle, # Same azimuth pattern as TX
azimuth_pattern=pattern, # Same gain pattern (6 dB)
elevation_angle=angle,
elevation_pattern=pattern,
)
)
### Create FMCW Receiver ###
rx = Receiver(
fs=2e6, # Sampling rate: 2 MHz (Nyquist for baseband)
noise_figure=8, # Noise figure: 8 dB
rf_gain=20, # RF gain: 20 dB (LNA)
baseband_gain=50, # Baseband gain: 50 dB
load_resistor=500, # Load resistance: 500 Ω
channels=channels, # 8 RX channels
)
print(f"\nRX Array Configuration:")
print(f" Number of RX elements: 8")
print(f" RX spacing: λ/2 = {wavelength/2*1000:.2f} mm")
print(f" RX aperture: 3.5λ = {3.5*wavelength*1000:.1f} mm")
print(f"\nVirtual Array:")
print(f" Virtual elements: 4 TX × 8 RX = 32")
print(f" Physical elements: 4 + 8 = 12")
print(f" Array efficiency: {32/12:.2f}× (virtual/physical)")
RX Array Configuration: Number of RX elements: 8 RX spacing: λ/2 = 6.22 mm RX aperture: 3.5λ = 43.5 mm Virtual Array: Virtual elements: 4 TX × 8 RX = 32 Physical elements: 4 + 8 = 12 Array efficiency: 2.67× (virtual/physical)
Create Radar System¶
Combine transmitter and receiver to form the complete TDM MIMO FMCW radar.
# Create complete TDM MIMO FMCW radar system
radar = Radar(transmitter=tx, receiver=rx)
Target Configuration¶
Three stationary targets at different ranges and angles to demonstrate angular resolution:
| Target 1 | Target 2 | Target 3 | |
|---|---|---|---|
| Location (x, y, z) m | (160, 0, 0) | (80, −80, 0) | (30, 20, 0) |
| Speed (m/s) | (0, 0, 0) | (0, 0, 0) | (0, 0, 0) |
| RCS (dBsm) | 25 | 20 | 8 |
| Range | 160 m, 0° | 113 m, −45° | 36 m, +34° |
# Configure Target 1: Far range, on-axis (0°)
target_1 = dict(
location=(160, 0, 0), # Position: 160m range, 0° azimuth
speed=(0, 0, 0), # Velocity: stationary
rcs=25, # Radar cross section: 25 dBsm (large vehicle)
phase=0, # Initial phase: 0 degrees
)
# Configure Target 2: Medium range, left side (-45°)
target_2 = dict(
location=(80, -80, 0), # Position: 113m range, -45° azimuth
speed=(0, 0, 0), # Velocity: stationary
rcs=20, # Radar cross section: 20 dBsm (medium vehicle)
phase=0, # Initial phase: 0 degrees
)
# Configure Target 3: Near range, right side (+34°)
target_3 = dict(
location=(30, 20, 0), # Position: 36m range, +34° azimuth
speed=(0, 0, 0), # Velocity: stationary
rcs=8, # Radar cross section: 8 dBsm (small target)
phase=0, # Initial phase: 0 degrees
)
# Combine targets for simulation
targets = [target_1, target_2, target_3]
Simulate Baseband Signals¶
4 TX channels fire sequentially across one PRP. The simulator returns 32 virtual channels (4 TX × 8 RX) of complex I/Q beat signal.
Output shape: [channels, pulses, samples] = [32, 1, ~160].
data = sim_radar(radar, targets)
timestamp = data["timestamp"]
baseband = data["baseband"] + data["noise"]
print(f"Baseband shape: {baseband.shape} [channels, pulses, samples]")
Baseband shape: (32, 1, 160) [channels, pulses, samples]
Visualize TDM Chirps¶
Display frequency-time diagram showing sequential TX transmission.
fig = go.Figure()
for idx in range(0, 1):
for ch_idx in range(0, 32, 8):
freq_ramp = np.linspace(
24.125e9 - radar.radar_prop["transmitter"].waveform_prop["bandwidth"] / 2,
24.125e9 + radar.radar_prop["transmitter"].waveform_prop["bandwidth"] / 2,
radar.sample_prop["samples_per_pulse"],
endpoint=False,
) / 1e9
fig.add_trace(go.Scatter(
x=timestamp[ch_idx, idx, :] * 1e6,
y=freq_ramp,
name="Tx " + str(int(ch_idx / 8)) + ", Chirp " + str(idx),
line=dict(width=2),
))
fig.update_layout(
title="TDM Chirp Timing: 4 TX Channels Sequential",
yaxis=dict(tickformat=".2f", title="Frequency (GHz)", gridcolor="lightgray"),
xaxis=dict(tickformat=".0f", title="Time (μs)", gridcolor="lightgray"),
height=500,
legend=dict(x=0.02, y=0.98),
)
show(fig)
Visualize Beat Signal¶
Display time-domain I/Q beat signal from first virtual channel (TX1-RX1).
fig = go.Figure()
fig.add_trace(go.Scatter(
x=timestamp[0, 0, :] * 1e6, y=np.real(baseband[0, 0, :]),
name="I (In-phase)", line=dict(color="blue", width=1),
))
fig.add_trace(go.Scatter(
x=timestamp[0, 0, :] * 1e6, y=np.imag(baseband[0, 0, :]),
name="Q (Quadrature)", line=dict(color="red", width=1),
))
fig.update_layout(
title="Beat Signal: Virtual Channel 0 (TX1–RX1)",
yaxis=dict(title="Amplitude (V)", gridcolor="lightgray"),
xaxis=dict(title="Time (μs)", gridcolor="lightgray"),
height=450,
legend=dict(x=0.02, y=0.98),
)
show(fig)
range_window = signal.windows.chebwin(radar.sample_prop["samples_per_pulse"], at=60)
range_profile = proc.range_fft(baseband, range_window)
print(f"Range profile shape: {range_profile.shape} [channels, pulses, range bins]")
Range profile shape: (32, 1, 160) [channels, pulses, range bins]
Visualize Range Profiles¶
Display range profiles for first channel of each TX (demonstrating TDM separation).
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)
fig = go.Figure()
for ch_idx, label in zip([0, 8, 16, 24], ["TX1-RX1", "TX2-RX1", "TX3-RX1", "TX4-RX1"]):
fig.add_trace(go.Scatter(
x=range_axis,
y=20 * np.log10(np.abs(range_profile[ch_idx, 0, :])),
name=label, line=dict(width=2),
))
fig.update_layout(
title="Range Profiles: TDM Channels (same targets, different phases)",
xaxis=dict(title="Range (m)", gridcolor="lightgray"),
yaxis=dict(title="Amplitude (dB)", gridcolor="lightgray"),
height=500,
legend=dict(x=0.02, y=0.98),
)
show(fig)
Digital Beamforming¶
Each virtual element at position $d_n$ contributes a phase shift $e^{j2\pi d_n \sin\theta/\lambda}$. Sweeping $\theta$ from −90° to +89° yields the steering matrix:
$$\mathbf{A} \in \mathbb{C}^{180 \times 32}, \quad A_{n,\theta} = e^{j2\pi d_n \sin\theta / \lambda}$$
A Chebyshev window (50 dB) suppresses spatial sidelobes. The range-angle map is:
$$\mathbf{Y} = \mathbf{A}^H \left(\mathbf{X} \odot \vec{w}\right)$$
azimuth = np.arange(-90, 90, 1)
# Virtual array Y-positions in wavelengths
array_loc_x = np.zeros((1, len(radar.array_prop["virtual_array"])))
for va_idx, va in enumerate(radar.array_prop["virtual_array"]):
array_loc_x[0, va_idx] = va[1] * 24.125e9 / 3e8
# Steering matrix: [180 angles × 32 elements]
azimuth_grid, array_loc_grid = np.meshgrid(azimuth, array_loc_x)
A = np.transpose(
np.exp(1j * 2 * np.pi * array_loc_grid * np.sin(azimuth_grid / 180 * np.pi))
)
# Chebyshev window across virtual array
bf_window = np.transpose(
np.array([signal.windows.chebwin(len(radar.array_prop["virtual_array"]), at=50)])
)
# Beamforming: Y = A^H * (X * w)
AF = np.matmul(
A,
range_profile[:, 0, :]
* np.repeat(bf_window, radar.sample_prop["samples_per_pulse"], axis=1),
)
print(f"Range-angle map shape: {AF.shape} [angles × range bins]")
Range-angle map shape: (180, 160) [angles × range bins]
Range-Angle Map¶
3D surface showing amplitude vs. range and azimuth. Expected peaks:
- Target 1: 160 m, 0°
- Target 2: 113 m, −45°
- Target 3: 36 m, +34°
range_axis = np.linspace(0, max_range, radar.sample_prop["samples_per_pulse"], endpoint=False)
fig = go.Figure()
fig.add_trace(go.Surface(
x=range_axis,
y=azimuth,
z=20 * np.log10(np.abs(AF) + 0.1),
colorscale="Rainbow",
colorbar=dict(title="Amplitude (dB)"),
))
fig.update_layout(
title="Range-Angle Map: TDM MIMO Virtual Array Beamforming",
height=700,
scene=dict(
xaxis=dict(title="Range (m)"),
yaxis=dict(title="Azimuth (degrees)"),
zaxis=dict(title="Amplitude (dB)"),
aspectmode="manual",
aspectratio=dict(x=2, y=1, z=0.5),
),
margin=dict(l=0, r=0, b=0, t=40),
)
show(fig)
Summary¶
This notebook demonstrated TDM MIMO FMCW radar with digital beamforming using RadarSimPy:
- Configured 4 TX (2λ spacing) + 8 RX (λ/2 spacing) = 32 virtual channels
- Used
delayparameter to implement TDM with 100 μs slots and PRP = 400 μs - Applied range FFT with Chebyshev windowing to extract beat-frequency range profiles
- Formed virtual array and applied steering-matrix beamforming (50 dB sidelobes)
- Generated a range-angle map resolving three targets across a wide angular span
Things to Try¶
| Experiment | Change | Expected Effect |
|---|---|---|
| More TX elements | Add TX channels with larger delays | More virtual elements, narrower beam |
| Denser RX spacing | Reduce RX spacing to λ/4 | Tests spatial Nyquist / grating lobes |
| Wider TX spacing | Change TX spacing to 4λ | Grating lobes appear in angle domain |
| Higher bandwidth | Change chirp BW to 500 MHz | Better range resolution (~0.3 m) |
| Beamforming window | Replace chebwin with hamming |
Different sidelobe/mainlobe trade-off |
| 77 GHz carrier | Change fc to 77 GHz | Scaled apertures; automotive standard |
| Multiple pulses | Increase pulses and add Doppler FFT |
3D range-Doppler-angle cube |
| Closer targets | Move targets to <5° angular separation | Tests resolution limit (~3–4°) |