Moving target and Doppler

Give a point target a trajectory, simulate four frames with chirp-rate motion sampling, and read range and radial velocity from the point cloud.

In this tutorial you simulate a person-sized point target walking toward the radar at 1.5 m/s next to a target that stands still. You read the walker's range and radial velocity from four frames, see how the velocity resolution follows from the waveform, and compare chirp-rate motion sampling with the exact ADC-rate reference.

Read Motion sampling for the background on the motion modes.

The script#

import torch
from witwin.core import Scene
from witwin.radar import Motion, Noise, PointTargets, Radar
 
CONFIG = {
    "num_tx": 3, "num_rx": 4, "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": 128, "power": 15,
    "tx_loc": [[0, 0, 0], [4, 0, 0], [2, 1, 0]],
    "rx_loc": [[-6, 0, 0], [-5, 0, 0], [-4, 0, 0], [-3, 0, 0]],
}
radar = Radar.from_dict(CONFIG, noise=Noise(figure=10.0), seed=7, look_at=(0.0, 0.0, -1.0))
scene = Scene(structures=(), endpoints=[])
 
SPEED = 1.5   # m/s toward the radar
START = torch.tensor([[0.0, 0.0, -4.0], [0.6, 0.0, -2.5]], device=radar.device)
 
 
def trajectory(t):
    # Target 0 walks toward the radar along +z; target 1 stands still.
    positions = START.clone()
    positions[0, 2] += SPEED * float(t)
    return positions
 
 
targets = PointTargets(positions=START, rcs=1.0, trajectory=trajectory)
times = (0.0, 0.1, 0.2, 0.3)
 
result = radar.simulate(scene, targets, times=times, reflections=0, motion=Motion.chirp())
print("motion", result.motion_sampling, "cube", tuple(result.cube.shape))
 
axes = result.frame(0).axes
print(f"velocity bin {axes.velocity_bin_mps:.4f} m/s, max unambiguous speed {axes.max_unambiguous_speed_mps:.3f} m/s")
 
for k in range(result.frame_count):
    cloud = result.frame(k).points(max_points=8)
    strongest = cloud.energy.argsort(descending=True)[:2]
    found = sorted((round(float(cloud.range_m[i]), 3), round(float(cloud.velocity_mps[i]), 3)) for i in strongest)
    print(f"t={result.times_s[k]:.1f} s  (range m, velocity m/s): {found}")
 
try:
    radar.simulate(scene, targets, times=times, reflections=0, motion=Motion.static())
except ValueError as error:
    print("Motion.static refused:", str(error)[:120])

Output on an RTX 5080:

motion chirp cube (4, 3, 4, 128, 256)
velocity bin 0.0704 m/s, max unambiguous speed 4.506 m/s
t=0.0 s  (range m, velocity m/s): [(2.576, 0.0), (3.993, 1.549)]
t=0.1 s  (range m, velocity m/s): [(2.576, 0.0), (3.821, 1.549)]
t=0.2 s  (range m, velocity m/s): [(2.576, 0.0), (3.692, 1.549)]
t=0.3 s  (range m, velocity m/s): [(3.52, 1.479), (3.52, 1.549)]
Motion.static refused: Motion.static() asks for one observation per frame, but this session moves within a frame; use Motion.adc() for the exha

What the output shows#

  1. A trajectory makes the world move

    trajectory(t) returns both positions at time t. Radar calls it at every observation instant, so the walker moves inside each frame as well as between frames. Motion.static() would freeze each frame and is refused for a world that moves.

  2. Velocity resolution

    With 3 transmitters in TDM, one transmitter repeats every 3 · 72 µs = 216 µs. Over 128 chirps that gives a velocity bin of λ / (2 · 128 · 216 µs) = 0.0704 m/s and an unambiguous speed of ±4.5 m/s. The walker's 1.5 m/s lands on the nearest bin, 22 bins or 1.549 m/s. Positive velocity means closing.

  3. Range follows the walk

    The walker starts at 4 m and closes 0.15 m per frame. The detected range moves 3.993, 3.821, 3.692, 3.52 m, within one 4.3 cm range bin of the true 4.00, 3.85, 3.70 and 3.55 m. The standing target stays at 2.576 m, the bin nearest its true 2.571 m, with zero velocity.

  4. Two strongest cells

    The script prints the two strongest points of each frame. At t = 0.3 s both belong to the walker, in two neighbouring Doppler bins, so the standing target is not among the two. Raise the [:2] slice to see it.

Chirp-rate against ADC-rate sampling#

Motion.chirp() freezes the geometry within each chirp and moves it between chirps (stop and hop). Motion.adc() moves it at every ADC sample, which is exact but much slower. To compare them on one frame, append:

import time
 
for motion in (Motion.chirp(), Motion.adc()):
    begin = time.perf_counter()
    cube = radar.simulate(scene, targets, times=(0.0,), reflections=0, motion=motion).cube
    torch.cuda.synchronize()
    print(f"{motion.kind}: {time.perf_counter() - begin:.2f} s")
    if motion.kind == "chirp":
        reference = cube
error = (cube - reference).norm() / cube.norm()
print(f"relative difference chirp vs adc: {float(error):.2e}")

Output on an RTX 5080:

chirp: 4.41 s
adc: 1085.52 s
relative difference chirp vs adc: 7.39e-02

ADC-rate sampling is slow

This frame has 128 · 3 · 256 = 98304 ADC instants, and Motion.adc() evaluates the world at every one of them. It took 18 minutes here. Use Motion.chirp() for exploration and Motion.adaptive() when you need ADC-rate accuracy on long sequences.

The 7% difference is the motion within each chirp that stop-and-hop leaves out. It matters most for losses on the raw cube and for fast targets.

Things to try#

  • Give the walker a sideways component and watch the point cloud's x move while the radial velocity drops.
  • Raise SPEED above 4.5 m/s and watch the velocity alias to the negative side.
  • Replace Motion.chirp() with Motion.adaptive() and read result.adaptive_diagnostics.