import radarsimpy
print("`RadarSimPy` used in this example is version: " + str(radarsimpy.__version__))
`RadarSimPy` used in this example is version: 15.2.0
Pulse Radar Altimeter: Altitude Measurement over Terrain¶
A pulse radar altimeter measures altitude by transmitting short pulses downward and timing the ground return:
$$h = \frac{c \cdot \tau}{2}$$
where $h$ = altitude (m), $c$ = 3×10⁸ m/s, and $\tau$ = round-trip delay (s).
Range resolution is determined by pulse bandwidth $B$:
$$\Delta R = \frac{c}{2B}$$
Matched filtering maximizes SNR of the received pulse for reliable altitude extraction.
This Example¶
- Radar: 10 GHz (X-band), 1 MW peak power, 333 ns pulse (~3 MHz bandwidth → 50 m resolution)
- Platform: Flying at 4000 m altitude, 200 m/s, over Grand Canyon terrain
- Antenna: 20 dBi narrow beam pointing nadir (cos³⁰/cos⁴⁰ azimuth/elevation taper)
- Processing: Matched filtering → altitude profile heatmap along flight path
Radar System Configuration¶
Import Required Modules¶
import numpy as np
import plotly.graph_objs as go
from plotly.subplots import make_subplots
from IPython.display import Image, display
from radarsimpy import Radar, Transmitter, Receiver
# 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¶
Custom antenna gain pattern for a narrow nadir-pointing beam:
| Axis | Pattern | Beamwidth |
|---|---|---|
| Azimuth | cos³⁰(θ) + 20 dBi | ~15° (−3 dB) |
| Elevation | cos⁴⁰(θ) + 20 dBi | ~13° (−3 dB) |
| Angular range | −5° to +5°, 1° steps |
# Define antenna gain
antenna_gain = 20 # dBi - High-gain directional antenna
### Azimuth Pattern (Horizontal Plane) ###
# Define azimuth angle array: -5° to +5° in 1° steps
az_angle = np.arange(-5, 6, 1) # 11 points
# Azimuth pattern: cos^30 taper with 20 dBi gain
# Moderate beamwidth (~15° @ -3dB) suitable for altimeter
az_pattern = 20 * np.log10(np.cos(az_angle / 180 * np.pi) ** 30) + antenna_gain # dB
### Elevation Pattern (Vertical Plane) ###
# Define elevation angle array: -5° to +5° in 1° steps
el_angle = np.arange(-5, 6, 1) # 11 points
# Elevation pattern: cos^40 taper with 20 dBi gain
# Moderate beamwidth (~13° @ -3dB) suitable for altimeter
el_pattern = 20 * np.log10((np.cos(el_angle / 180 * np.pi)) ** 40) + antenna_gain # dB
Waveform Parameters¶
Derived from the design requirements ($R_{max}$ = 5000 m, $\Delta R$ = 50 m):
| Parameter | Formula | Value |
|---|---|---|
| Pulse bandwidth | $c / (2\Delta R)$ | 3 MHz |
| Pulse width | $1 / B$ | ~333 ns |
| PRF | $c / (2R_{max})$ | 30 kHz |
| PRP | $1 / PRF$ | ~33.3 µs |
| Duty cycle | $T_p / PRP$ | ~1% |
| Sampling rate | $2 \times B$ (Nyquist) | 6 MHz |
| Carrier | X-band | 10 GHz |
# Import constants
from scipy import constants
# Speed of light constant
light_speed = constants.c # 299,792,458 m/s
### Calculate Pulsed Radar Waveform Parameters ###
# Performance Requirements
max_range = 5000 # Maximum unambiguous range: 5000 m (5 km)
range_res = 50 # Required range resolution: 50 m
# Derived Waveform Parameters
pulse_bw = light_speed / (2 * range_res) # Pulse bandwidth: c/(2*ΔR) = 3 MHz
pulse_width = 1 / pulse_bw # Pulse width: 1/B ≈ 333 ns
prf = light_speed / (2 * max_range) # PRF: c/(2*R_max) = 30 kHz
prp = 1 / prf # PRP: 1/PRF ≈ 33.3 μs
# Radar Parameters
fs = 6e6 # Sampling rate: 6 MHz (> 2 × pulse_bw for Nyquist)
fc = 10e9 # Carrier frequency: 10 GHz (X-band)
num_pulse = 1 # Number of pulses: 1 (for single pulse demonstration)
# Calculate sample counts
total_samples = int(prp * num_pulse * fs) # Total samples in observation
sample_per_pulse = int(total_samples / num_pulse) # Samples per pulse
### Create Rectangular Pulse Envelope ###
# Create time array for modulation (spans entire observation)
mod_t = np.arange(0, total_samples) / fs # Time vector (seconds)
# Initialize amplitude modulation array (all zeros initially)
amp = np.zeros_like(mod_t)
# Set pulse-on period: amplitude = 1 for duration of pulse_width
amp[mod_t <= pulse_width] = 1 # Rectangular pulse (first ~333 ns)
### Define Transmitter Channel ###
# Configure TX channel with antenna pattern and pulse modulation
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)
amp=amp, # Pulse amplitude modulation
mod_t=mod_t, # Modulation time array
)
### Create Pulsed Radar Transmitter ###
tx = Transmitter(
f=fc, # Carrier frequency: 10 GHz
t=1 / prf, # Pulse duration: PRP ≈ 33.3 μs
tx_power=90, # Transmit power: 90 dBm = 1 MW
pulses=num_pulse, # Number of pulses: 1
channels=[tx_channel], # Transmitter antenna configuration
)
# Display calculated parameters
print(f"Pulsed Radar Waveform Parameters:")
print(f" Pulse Width: {pulse_width*1e9:.2f} ns")
print(f" Pulse Bandwidth: {pulse_bw/1e6:.1f} MHz")
print(f" PRF: {prf/1e3:.1f} kHz")
print(f" PRP: {prp*1e6:.2f} μs")
print(f" Duty Cycle: {(pulse_width/prp)*100:.2f}%")
print(f" Samples per Pulse: {sample_per_pulse}")
print(f" Number of Pulses: {num_pulse}")
Pulsed Radar Waveform Parameters: Pulse Width: 333.56 ns Pulse Bandwidth: 3.0 MHz PRF: 30.0 kHz PRP: 33.36 μs Duty Cycle: 1.00% Samples per Pulse: 200 Number of Pulses: 1
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=mod_t[0:100] * 1e9,
y=amp[0:100],
name="Transmit Pulse",
line=dict(color="green", width=2),
fill="tozeroy",
fillcolor="rgba(0,255,0,0.3)",
)
)
fig.update_layout(
title="Rectangular Transmit Pulse (~333 ns Duration)",
yaxis=dict(title="Amplitude (normalized)"),
xaxis=dict(title="Time (ns)"),
height=400,
)
show(fig)
Receiver¶
Monostatic configuration — same antenna pattern as TX.
| Parameter | Value |
|---|---|
| Sampling rate | 6 MHz |
| Noise figure | 6 dB |
| RF gain | 20 dB |
| Baseband gain | 80 dB |
| Load resistor | 500 Ω |
# Define receiver channel with same antenna pattern (monostatic)
rx_channel = dict(
location=(0, 0, 0), # Receiver position at origin (same as TX)
azimuth_angle=az_angle, # Same azimuth pattern as TX
azimuth_pattern=az_pattern, # Same azimuth gain
elevation_angle=el_angle, # Same elevation pattern
elevation_pattern=el_pattern, # Same elevation gain
)
# Create pulsed radar receiver
rx = Receiver(
fs=fs, # Sampling rate: 6 MHz
noise_figure=6, # Noise figure: 6 dB
rf_gain=20, # RF gain: 20 dB (LNA)
load_resistor=500, # Load resistance: 500 Ω
baseband_gain=80, # Baseband gain: 80 dB
channels=[rx_channel], # Receiver antenna configuration
)
Radar Platform¶
| Parameter | Value |
|---|---|
| Location | (10000, 30000, 4000) m → 4000 m altitude |
| Speed | (200, 0, 0) m/s → 200 m/s forward |
| Rotation | (0, −90°, 0) → antenna pointing nadir |
| Measurement times | 0–149 s (1 s steps, 150 frames) |
measurement_time = np.arange(0, 150, 1) # Time vector for measurement
radar_location = (10000, 30000, 4000) # Radar location (x, y, z) in meters
radar_speed = (200, 0, 0) # Radar speed (x, y, z) in m/s
# Create complete pulsed radar system
radar = Radar(
transmitter=tx,
receiver=rx,
location=radar_location,
speed=radar_speed,
rotation=(0, -90, 0),
frame_time=measurement_time,
) # Monostatic radar at specified location
Terrain Configuration¶
The terrain is modeled as a 3D mesh target (Grand Canyon STL), stationary, with soil permittivity εᵣ = 5.
- Radar altitude: 4000 m above terrain footprint
- Flight path: 200 m/s × 150 s = 30 km ground track
- Expected return: main peak at ~4000 m range, spreading from terrain elevation variations
# Configure Target 1: Terrain model
target_1 = {
"model": "../models/grand_canyon.stl", # Terrain 3D model
"unit": "m", # Model units in meters
"location": (0, 0, 0), # Position: Origin
"speed": (0, 0, 0), # Velocity: Stationary
"permittivity": 5,
}
# Combine targets for simulation
targets = [target_1]
Visualize Terrain and Flight Path¶
The terrain is colored by elevation; the blue line shows the radar flight path at 4000 m altitude above the Grand Canyon surface.
import pymeshlab
ms = pymeshlab.MeshSet()
ms.load_new_mesh(target_1["model"])
t_mesh = ms.current_mesh()
v_matrix = np.array(t_mesh.vertex_matrix())
f_matrix = np.array(t_mesh.face_matrix())
fig = go.Figure()
fig.add_trace(
go.Mesh3d(
x=v_matrix[:, 0],
y=v_matrix[:, 1],
z=v_matrix[:, 2],
i=f_matrix[:, 0],
j=f_matrix[:, 1],
k=f_matrix[:, 2],
intensity=v_matrix[:, 2],
colorscale="Earth",
colorbar=dict(title="Elevation (m)"),
name="Terrain",
)
)
flight_path_x = radar_location[0] + radar_speed[0] * measurement_time
flight_path_y = radar_location[1] + radar_speed[1] * measurement_time
flight_path_z = radar_location[2] + radar_speed[2] * measurement_time
fig.add_trace(
go.Scatter3d(
x=flight_path_x,
y=flight_path_y,
z=flight_path_z,
mode="lines+markers",
line=dict(color="blue", width=2),
marker=dict(size=2, color="blue"),
name="Flight Path",
)
)
fig.update_layout(
scene=dict(
aspectmode="data",
camera=dict(eye=dict(x=2.0, y=2.0, z=1.5)),
),
height=500,
showlegend=True,
legend=dict(orientation="h", yanchor="bottom", y=-0.15, xanchor="center", x=0.5),
margin=dict(l=10, r=10, b=50, t=10),
)
show(fig)
Simulate Baseband Signals¶
sim_radar returns baseband shape [frames, pulses, samples] — 150 frames × 1 pulse × 200 samples/pulse (33.3 µs × 6 MHz).
# Import radar simulator
from radarsimpy.simulator import sim_radar
# Simulate pulsed radar with terrain target
data = sim_radar(radar, targets, density=0.05)
# Extract simulation results
timestamp = data["timestamp"] # Time array
baseband = data["baseband"] + data["noise"] # Complex I/Q + noise
print(f"Simulation complete:")
print(f" Baseband shape: {baseband.shape}")
print(f" Number of frames: {baseband.shape[0]}")
print(f" Samples per pulse: {baseband.shape[2]}")
Simulation complete: Baseband shape: (150, 1, 200) Number of frames: 150 Samples per pulse: 200
Visualize Baseband Signals¶
First 3 measurement frames (I and Q channels). The ground return pulse is visible as a brief amplitude burst; surrounding samples are thermal noise.
fig = make_subplots(
rows=3,
cols=1,
subplot_titles=(
f"Measurement at t={measurement_time[0]:.0f}s",
f"Measurement at t={measurement_time[1]:.0f}s",
f"Measurement at t={measurement_time[2]:.0f}s",
),
vertical_spacing=0.09,
)
for idx in range(3):
t = timestamp[idx, 0, :] * 1e6
I = np.real(baseband[idx, 0, :])
Q = np.imag(baseband[idx, 0, :])
fig.add_trace(
go.Scatter(x=t, y=I, mode="lines", name=f"I (t={measurement_time[idx]:.0f}s)"),
row=idx + 1, col=1,
)
fig.add_trace(
go.Scatter(x=t, y=Q, mode="lines", name=f"Q (t={measurement_time[idx]:.0f}s)"),
row=idx + 1, col=1,
)
for idx in range(3):
fig.update_xaxes(title_text="Time (μs)", row=idx + 1, col=1)
fig.update_yaxes(title_text="Amplitude (V)", row=idx + 1, col=1)
fig.update_layout(
title="Baseband Signal (I and Q) — First 3 Measurements",
height=900,
)
show(fig)
Matched Filter Processing¶
Matched filtering (convolution with the transmitted pulse replica) maximizes SNR by coherently integrating pulse energy. For a rectangular pulse of length $N$ samples:
$$\text{SNR gain} = 10\log_{10}(N) \text{ dB}$$
The output range_profile[frame, pulse, sample] contains a peak at the range bin corresponding to terrain altitude.
# Import signal processing module
from scipy import signal
### Apply Matched Filter to Each Pulse ###
# Extract matching coefficients (transmitted pulse shape)
# Use only the non-zero portion of the amplitude array
matchingcoeff = amp[amp != 0] # Pulse replica for matched filtering
# Initialize range profile storage [frames, pulses, samples]
range_profile = np.zeros_like(baseband, dtype=np.complex128)
for f_idx in range(0, len(measurement_time)):
# Convolve received signal with pulse replica
# mode='same' keeps output length same as input
range_profile[f_idx, 0, :] = signal.convolve(
baseband[f_idx, 0, :], # Received baseband for this pulse
matchingcoeff, # Transmitted pulse (matched filter)
mode="same", # Keep same length as input
)
print(f"Matched filtering complete:")
print(f" Matched filter length: {len(matchingcoeff)} samples")
print(f" Matched Filter SNR Improvement: ~{10*np.log10(len(matchingcoeff)):.1f} dB")
print(f" Altitude measurement ready for extraction")
Matched filtering complete: Matched filter length: 3 samples Matched Filter SNR Improvement: ~4.8 dB Altitude measurement ready for extraction
Altitude Profile Heatmap¶
Range axis: $R = c \cdot t / 2$. Each row in the heatmap is one matched-filter output frame, stacked along the flight path.
The heatmap shows altitude returns along the 30 km flight track. The main return traces the terrain surface at ~4000 m; variations reflect Grand Canyon elevation changes beneath the flight path.
range_axis = mod_t * light_speed / 2
heatmap_data = 20 * np.log10(np.abs(range_profile[:, 0, :]))
z_max = np.max(heatmap_data)
z_min = z_max - 110
fig = go.Figure(
data=go.Heatmap(
x=range_axis,
y=measurement_time,
z=heatmap_data,
colorscale="Hot",
zmin=z_min,
zmax=z_max,
colorbar=dict(title="Amplitude (dB)"),
)
)
fig.update_layout(
title="Altitude Profile Along Flight Path",
xaxis=dict(title="Altitude (m)"),
yaxis=dict(title="Measurement Time (s)"),
height=600,
)
show(fig)
Summary¶
- A pulse radar altimeter measures altitude via $h = c\tau/2$; matched filtering extracts the terrain return peak.
- 333 ns pulse (3 MHz bandwidth) gives 50 m range resolution; increasing bandwidth improves resolution.
- The altitude heatmap tracks terrain elevation variation along the Grand Canyon flight path.
- Permittivity (εᵣ = 5 for soil) affects ground return amplitude through Fresnel reflection.
Things to Try¶
| Experiment | Parameter to change | Observable effect |
|---|---|---|
| Finer resolution | range_res = 10 m |
Narrower pulse, sharper returns |
| Higher altitude | radar_location = (10000, 30000, 8000) |
Return peak at ~8000 m |
| Different terrain | Load another STL terrain | Different altitude profile shape |
| Wider beam | Reduce cos exponents (e.g., cos¹⁰) | Larger terrain footprint, signal spreading |
| Multi-look average | Average adjacent frames | Reduce speckle in profile |