Resolve two targets with MUSIC
Build a 20 x 20 virtual planar array, gate the range profile, and separate two targets 19 degrees apart with a MUSIC angle image.
In this tutorial you separate two targets at the same range with MUSIC, a subspace method that resolves angles more finely than an FFT over the array. You build a 20-TX by 20-RX radar whose virtual array is a planar 20 x 20 grid, gate the range profile around the targets, and read the two angles off the image. It is a trimmed version of examples/music_imaging.py in the Radar repository.
The script#
import math
import torch
from witwin.core import Scene
from witwin.radar import Noise, PointTargets, Radar
from witwin.radar.processing import music_image
N = 20
CONFIG = {
"num_tx": N, "num_rx": N, "fc": 77e9, "slope": 60.012, "adc_samples": 256,
"adc_start_time": 6, "sample_rate": 4400, "idle_time": 7, "ramp_end_time": 65,
"chirp_per_frame": 8, "power": 15,
"tx_loc": [[i, 0, 0] for i in range(N)], # transmit row along local x
"rx_loc": [[N, -i, 0] for i in range(N)], # receive row along local y
}
radar = Radar.from_dict(CONFIG, noise=Noise(figure=10.0), seed=20260727, look_at=(0.0, 0.0, -1.0))
targets = PointTargets(positions=[(-0.5, 0.0, -3.0), (0.5, 0.0, -3.0)], rcs=1.0)
result = radar.simulate(Scene(structures=(), endpoints=[]), targets, times=(0.0,), reflections=0)
print("cube", tuple(result.cube.shape), "rows", result.last_radar_paths.path_count)
frame = result.frame(0)
profile = frame.range_profile()
energy = profile.data.abs().sum(dim=(0, 1, 2))
peak = int(energy.argmax())
range_bins = torch.arange(peak - 2, peak + 3, device=radar.device)
print(f"range gate bins {peak - 2}..{peak + 2}, centre {float(frame.axes.range_m[peak]):.4f} m")
grid = torch.linspace(-math.pi / 4, math.pi / 4, 128, device=radar.device)
image = music_image(profile, frame.array(), elevation_rad=grid, azimuth_rad=grid,
range_bins=range_bins, num_signals=7, spatial_smooth=3, num_snapshots=8)
print("image", tuple(image.shape))
lobe = image.abs().amax(dim=2).amax(dim=1).cpu()
inner = lobe[1:-1]
peaks = torch.nonzero((inner > lobe[:-2]) & (inner > lobe[2:])).reshape(-1) + 1
top = peaks[lobe[peaks].argsort(descending=True)[:2]]
found = sorted(round(math.degrees(float(grid[i])), 2) for i in top)
expected = sorted(round(math.degrees(math.atan2(x, 3.0)), 2) for x in (-0.5, 0.5))
print("found deg", found, "expected deg", expected)Output on an RTX 5080:
cube (1, 20, 20, 8, 256) rows 800
range gate bins 69..73, centre 3.0481 m
image (128, 128, 5)
found deg [-9.57, 10.28] expected deg [-9.46, 9.46]The image grid has a step of 0.71°, so both lobes are within two pixels of the true angles.
How it works#
A planar virtual array
In TDM-MIMO, each TX-RX pair acts as one virtual element at the sum of the two offsets. Transmitters along local
xand receivers along localygive a 20 x 20 grid of 400 virtual elements and 800 composed rows for two targets.frame.array()returns that geometry as anArrayGeometry.A range gate
music_imageneedsrange_bins: it does not pick a peak for you, because that choice changes the answer. Here the gate is the five bins around the strongest range.Spatial smoothing
The two echoes are coherent, which makes the sample covariance rank-deficient.
spatial_smooth=3averages the covariance over shifted sub-arrays to decorrelate them.num_signalsis the assumed signal-subspace size.Read the angles
The image is
[elevation, azimuth, bins]. Both targets lie on the first axis, which follows the transmit row, so the script takes the maximum over the other two axes and finds the two strongest local maxima.
music_image and music_spectrum are differentiable. Reading a peak position off the image is not. See Differentiability.
Things to try#
- Move the targets closer together and find the separation at which the two lobes merge.
- Raise
chirp_per_frameto get more snapshots for the covariance. - Lower
num_signalsto 2, the true number of sources here, and compare the image.