34 KiB
📡 Radar Deep Dive: The Comprehensive Guide to ADC Data (Raw Samples)
This document serves as the master reference for Raw ADC (Analog-to-Digital Converter) data within the Fox CARLA Shenron simulation engine. It is designed to bridge the gap between Mechanical Engineering, Computer Science, and Electrical Engineering, providing intuitive explanations and deep technical dives into how radar physics are simulated in code.
1. Foundation: The FMCW Radar Principle (For Non-EE Engineers)
Before analyzing data structures, we must understand the physical mechanism of the sensor. The sensors we simulate (TI AWRL1432, Radarbook) are FMCW (Frequency Modulated Continuous Wave) radars.
1.1 What is a Chirp? (The Sweeping Pitch Analogy)
Imagine you are standing in a large empty factory hall and you have a special whistle that changes pitch continuously. You press the whistle and it starts at a very low bass note, then steadily ramps up to a high-pitched squeal over 40 microseconds (0.00004 seconds), then instantly resets to the low note and repeats. Each of these pitch-sweeps is called a "Chirp."
In radar, we do the same with electromagnetic waves (radio waves) instead of sound:
- Start Frequency ($f_c$): The starting pitch. For automotive radars this is 77 GHz (77,000,000,000 vibrations per second). This frequency is invisible and harmless — think of it like a colour of light you cannot see.
- Bandwidth ($B$): The total pitch range swept. The AWRL1432 sweeps 137.2 MHz; the Radarbook sweeps 250 MHz. A wider sweep = finer ability to separate nearby objects.
- Chirp Duration ($T_c$): How long one sweep takes. For the Radarbook:
N_sample / samp_rate = 256 / 1e6 = 256 microseconds. - Frequency Slope ($k = B / T_c$): How fast the frequency increases. Think of this as "how steep the ramp is."
- Range Resolution Formula:
range_res = c / (2 * B). For the Radarbook (B=250 MHz):3e8 / (2 * 250e6) = 0.6 metres. This means two objects 0.6 m apart are the minimum resolvable.
1.2 What is a Mixer? (The Pitch-Comparator Analogy)
Back to the factory hall. You blow your sweeping whistle at a metal wall. The sound travels to the wall and echoes back. Here is the key insight:
Because sound takes time to travel, the echo reaching your ear right now was emitted slightly in the past. Since your whistle pitch is always increasing, the echo has a lower pitch than what you are currently blowing.
Now imagine you hold two instruments simultaneously:
- Your left hand plays the current whistle note (the transmitted signal, TX).
- Your right hand plays the echo from the wall (the received signal, RX).
If you play both at the same time, your ear (or a microphone) hears an acoustic phenomenon called a "beat" — a slow wobble in loudness caused by two slightly different frequencies competing. The rate of this wobble is exactly the difference between the two frequencies. This is the Beat Frequency.
In hardware, the Mixer is the electronic component that performs this comparison. It multiplies TX by RX mathematically. The result contains the Beat Frequency (the difference), which is then isolated by a low-pass filter. This Beat Frequency signal is what the ADC samples.
The golden rule: Beat Frequency = (2 * k * Range) / c
- Close target (5 m) → small beat frequency → low-pitched wobble.
- Far target (80 m) → large beat frequency → high-pitched wobble.
The ADC simply records this "wobble" at discrete time steps. When you later apply an FFT, each distinct wobble frequency appears as a peak — and you convert it back to a distance.
1.3 Hardware Parameters in Our Codebase
These parameters are defined in scripts/ISOLATE/e2e_agent_sem_lidar2shenron_package/ConfigureRadar.py:
| Parameter | AWRL1432 | Radarbook | Meaning |
|---|---|---|---|
f |
77 GHz | 24 GHz | Start frequency |
B |
137.2 MHz | 250 MHz | Bandwidth (range resolution) |
N_sample |
256 | 256 | ADC samples per chirp |
chirps |
128 | 128 | Chirps per frame (velocity resolution) |
nRx |
6 | 8 | Receiver antenna channels |
chirp_rep |
36.4 µs | 750 µs | Repetition interval between chirps |
noise_amp |
0.005 | 0.005 | Noise floor amplitude |
2. I/Q Sampling: The Reason ADC Data is Complex
A common point of confusion is why ADC data is stored as Complex Numbers (Real + Imaginary pairs) rather than a simple list of voltage values. This section explains the physics and hardware design that necessitates this.
2.1 The Ambiguity Problem (The Blindfold Analogy)
Imagine you are blindfolded and someone hands you a microphone. A person walks past you dragging a stick along a picket fence: "clack-clack-clack." You can determine how fast they are walking from the rhythm. But which direction are they walking? Left-to-right, or right-to-left? With only one microphone (one measurement), you cannot tell. The signal is intrinsically ambiguous about direction.
In radar signal processing, this problem appears as Doppler ambiguity. The Doppler effect shifts the beat frequency slightly depending on whether the target is approaching or receding. However, if we only record a single real voltage stream, the mathematical result is a cosine wave A·cos(2π·f·t + φ). By fundamental trigonometry, cos(x) = cos(-x), meaning the positive and negative frequency components look identical. A car moving toward you at 10 m/s produces the exact same signal as a car moving away at 10 m/s. You know the speed magnitude, but the direction is lost.
2.2 The Hardware Solution: Two Mixers at 90° Offset
The hardware solution is elegant. Instead of one mixer, the radar uses two mixers in parallel:
Path 1 — I (In-phase): The received echo is multiplied by the transmitted chirp at exactly 0° phase. This produces the In-phase (I) voltage.
Path 2 — Q (Quadrature): A copy of the transmitted chirp is shifted by exactly 90° (a quarter of a full wave cycle) using a phase-shifter circuit. The received echo is multiplied by this 90°-shifted version. This produces the Quadrature (Q) voltage.
The word Quadrature simply means "at 90 degrees" — it comes from the Latin for "square," referring to the quarter-turn rotation. Two separate ADC chips sample I and Q simultaneously.
2.3 What I and Q Values Tell You
Let us walk through a concrete example. Suppose a car is at 20 metres, moving toward the sensor at 8 m/s.
- The beat frequency encodes the range (20 m).
- The Doppler shift creates a tiny phase advancement in the I and Q signals from chirp to chirp.
- If the car is approaching: I leads Q in phase (I peaks before Q).
- If the car is receding: Q leads I in phase (Q peaks before I).
By looking at the relationship between I and Q, the DSP can unambiguously determine direction.
2.4 The Complex Representation (The Clock-Hand Analogy)
Mathematically, we merge the two real streams into one complex number:
S(t) = I(t) + j·Q(t)
Where j = √(-1) is the imaginary unit. This is not just a notation trick — it allows us to use the powerful mathematics of complex exponentials.
Instead of thinking of the signal as a wave going up and down, picture it as a clock hand spinning on a dial:
- The length of the clock hand is the signal amplitude (how strong the reflection is).
- The angle of the clock hand is the signal phase (encoding range and velocity information).
- Approaching target: the clock hand spins counter-clockwise over successive chirps.
- Receding target: the clock hand spins clockwise over successive chirps.
A complex FFT naturally sees the direction of this spin and separates positive from negative velocities into different bins on the Doppler axis. A real-only FFT cannot.
2.5 Practical Consequence in Our Data
Because we use I/Q sampling:
- Our ADC tensors use
dtype=complex128(orcomplex64), storing a real and imaginary float per sample. - The
reformat_adc_shenron()function insim_radar_utils/utils_radar.pyrearranges the axes but preserves the complex dtype throughout. - The
RadarProcessor.cal_range_fft()andcal_doppler_fft()functions usescipy.fft.fft()which accepts complex input and correctly resolves positive/negative velocities. - If ADC data were real-valued only, the Range-Doppler plot would be symmetric (mirrored), and we could not distinguish which side was real.
2.6 SNR Benefit of Complex Sampling
There is an additional engineering benefit beyond direction disambiguation. A complex FFT effectively doubles the number of usable frequency bins compared to a real FFT of the same length. This results in approximately 3 dB better Signal-to-Noise Ratio (SNR) — meaning we can detect targets that are half as reflective. For ADAS this is significant: a pedestrian at 50 m reflects far less energy than a truck.
3. Shenron's Signal Synthesis: Physics to Code
In heatmap_gen_fast.py, since we have no physical antenna, we mathematically synthesize what the ADC voltages would be, using GPU-accelerated tensor operations (PyTorch). Here is the exact step-by-step physics model with the corresponding code.
Step 3.1: Input Geometry — The Point Cloud as Scatterers
The pipeline takes the semantic LiDAR point cloud where every $(x, y, z)$ coordinate is treated as an independent Point Scatterer — a tiny reflective surface.
For each scatterer, three physical quantities are extracted:
- Range ($\rho$): Euclidean distance
np.linalg.norm([x, y, z]). This determines the time delay. - Azimuth ($\theta$): Angle relative to the radar boresight. Used for Angle of Arrival (AoA) computation.
- Radar Cross Section (loss): The LiDAR intensity (optical reflectivity) is used as a proxy for electronic reflectivity. It is scaled by
1/rho^2to model the radar range equation: the power returned from a target falls off as the 4th power of distance (the signal travels twice — once to the target, once back). The code does this with:loss = torch.tensor(loss * (1 / rho ** 2)).
Step 3.2: The Round-Trip Time Delay ($\tau$)
For each scatterer and each chirp, the code computes the exact round-trip time delay:
tau = 2 * (rho / radar.c + chirp * radar.chirp_rep * speed / radar.c)
Breaking this down:
2 * rho / c— the static delay for sound (or light) to travel to the target and back.chirp * chirp_rep * speed / c— the additional delay caused by the target moving during the frame.chirp_repis the time between two consecutive chirps. So for chirp number 3, the target has moved3 * chirp_rep * speedfurther. Dividing bycconverts that distance to additional time.
This second term is what physically generates the Doppler Effect across chirps. It is typically nanometer-scale but critically important because the Doppler FFT is extremely sensitive to phase.
Step 3.3: Antenna Array Spatial Delays ($\delta$)
For a multi-antenna radar (e.g., AWRL1432 has 6 Rx antennas; Radarbook has 8), the signal hits each antenna at a slightly different time depending on the target's azimuth angle. The code computes a per-antenna spatial delay:
delta[i, :] = i * sRx * torch.sin(np.pi / 2 - theta) / radar.c
Where sRx = lambda / 2 is the half-wavelength antenna spacing (a standard MIMO design). i is the antenna index (0 through nRx-1). A target directly in front (theta=0) has zero additional delay across antennas. A target at 45° off-boresight has a significant inter-antenna delay — this is what allows us to compute the Angle of Arrival later.
Step 3.4: The Beamforming Vector (Spatial Phase)
The beamforming vector encodes the complete spatial and temporal phase for every point, antenna, and time sample:
beamforming_vector = torch.exp(
1j*2*np.pi*(
radar.f * delta[:,:,None] # Carrier phase across antennas
- 0.5 * k * delta[:,:,None]**2 # Second-order FMCW correction
- k * tau[None,:,None] * delta[:,:,None] # Cross-coupling (range x angle)
+ k * delta[:,:,None] @ t[None,None,:]) # Per-sample phase
)
This is the most mathematically complex line. It computes exp(j * phase) which produces a complex unit vector rotating at the beat frequency. The @ (matrix multiply) operation broadcasts this across all 256 sample times simultaneously on the GPU.
Step 3.5: The Dechirped Beat Signal
The actual beat signal (what the mixer outputs) is:
dechirped = torch.exp((1j * 2 * np.pi) *
(radar.f * tau[:,None] + k * tau[:,None] @ t[None,:] - 0.5 * k * tau[:,None]**2))
This implements the FMCW beat frequency equation directly. For a target at 20 m (tau = 2*20/3e8 ≈ 133 ns), this produces a complex sinusoid oscillating at the beat frequency corresponding to that range.
Step 3.6: Signal Superposition (The Summation)
All scatterer contributions are combined:
signal_single_antenna = loss_factor * dechirped # Per-scatterer amplitude
signal = beamforming_vector * signal_single_antenna[None,:,:] # Spread across antennas
signal = torch.squeeze(torch.sum(signal, 1)) # Sum all scatterers -> final voltage
At every single ADC time sample, this produces the total complex voltage that a physical antenna would measure if all those LiDAR points were real physical objects.
Step 3.7: Hardware Gain Scaling
After superposition, the signal is scaled by the radar's system gain constant:
adc_sampled = torch.sqrt(torch.tensor(radar.gain * _lambda**2 / (4*np.pi)**3)) * signal
This implements the Friis Transmission Equation accounting for antenna efficiency and wavelength. radar.gain = 10**(110/10) for the current calibration in ConfigureRadar.py.
Step 3.8: Noise Floor Addition and Tuning
The clean synthetic signal is then corrupted with thermal noise:
adc_sampled = adc_sampled + signal_Noisy
Where signal_Noisy = radar.get_noise() generates:
noise_real = np.random.normal(0, 1, size=(nRx, N_sample))
noise_complex = np.random.normal(0, 1, size=(nRx, N_sample))
signal_Noisy = (noise_real + 1j * noise_complex) * self.noise_amp
Why this matters for real-world matching:
Real radar hardware has a Noise Figure (NF) (typically 10-15 dB for automotive radar) which determines the minimum detectable signal. Our noise_amp = 0.005 parameter controls this floor. If noise_amp is too low, every distant weak scatterer (road surface dust, tree leaves) registers as a target. If too high, real targets get buried.
The current calibration (noise_amp = 0.005) was determined empirically through iteration testing to match the CFAR false-alarm rate to realistic values. In a real system, you would characterize the hardware Noise Figure via bench measurements and set noise_amp accordingly.
Step 3.9: Writing Into the ADC Measurement Cube
The final complex voltages are placed into the output 3D array:
measurement[chirp, :, :] = adc_sampled # Shape: (N_chirps, nRx, N_sample)
After all chirps are processed, measurement is the complete raw ADC tensor — a 3D complex array representing what the physical radar hardware would have recorded.
4. Pipeline Integration: The Complete Data Flow
The Fox pipeline orchestrates radar signal synthesis and storage through a layered architecture. Understanding this flow is essential for debugging, modifying, or extending the ADC capture capability.
4.1 High-Level Data Flow Diagram
CARA Simulator
└─> Semantic LiDAR (.npy per frame)
└─> generate_shenron.py [Orchestrator]
└─> ShenronRadarModel.process() [model_wrapper.py]
└─> run_lidar() [lidar.py]
└─> heatmap_gen() [heatmap_gen_fast.py]
└─> RAW ADC TENSOR ← saved HERE
└─> reformat_adc_shenron() [utils_radar.py]
└─> REFORMATTED ADC
└─> RadarProcessor [radar_processor.py]
├─> cal_range_fft() → Range Profile
├─> cal_doppler_fft() → Doppler Profile
└─> convert_to_pcd() → Rich Point Cloud + Heatmaps
├─> Saves: <session>/<radar_type>/<frame>.npy (Point Cloud)
├─> Saves: <session>/<radar_type>/adc_raw/<frame>.npy (Raw ADC)
└─> Saves: <session>/<radar_type>/metrology/{rd,ra,cfar}/<frame>.npy
4.2 The Orchestrator: generate_shenron.py
This script is the master batch processor. It processes every simulation session in the data/ directory.
Initialization Phase:
- For each radar type (
awrl1432,radarbook), aShenronRadarModelis instantiated. - The model internally creates a
radarhardware object (fromConfigureRadar.py) and aRadarProcessor. - Three metrology sub-folders are created:
rd/,ra/,cfar/. - The physical axes (
range_axis.npy,angle_axis.npy) are saved once per session — these do not change between frames as they are determined by the hardware config. - The
adc_raw/folder is also created here, one per radar type per session.
Per-Frame Processing Loop:
# From generate_shenron.py (simplified)
for lidar_file in lidar_files:
data = np.load(lidar_file) # Load LiDAR cloud
rich_pcd = model.process(data) # Run physics synthesis
# Capture raw ADC from buffer
if hasattr(model, 'last_adc') and model.last_adc is not None:
adc_path = session_path / r_type / 'adc_raw' / lidar_file.name
np.save(adc_path, model.last_adc) # Save raw ADC
np.save(output_file, rich_pcd) # Save point cloud
4.3 The Physics Bridge: model_wrapper.py
The ShenronRadarModel.process() method is the critical integration point.
Inside process(data):
# Step 1: Re-sync hardware config (critical for multi-radar sessions)
self._sync_configs()
# Step 2: Synthesize raw ADC (this calls heatmap_gen_fast.py)
raw_adc = run_lidar(self.sim_config, semantic_lidar_data, radarobj=self.radar_obj)
# raw_adc shape at this point: (N_chirps, nRx, N_sample) — as output by heatmap_gen
# Step 3: Buffer raw ADC for external access
self.last_adc = raw_adc
adc_data = raw_adc
# Step 4: Reformat axes to match RadarProcessor expectations
adc_data = reformat_adc_shenron(adc_data)
# After reformat: (N_sample, N_chirps, nRx) — transposed
# Step 5: Run DSP chain
range_profile = self.processor.cal_range_fft(adc_data)
doppler_profile = self.processor.cal_doppler_fft(range_profile)
_, rich_pcd, metrology = self.processor.convert_to_pcd(doppler_profile)
4.4 The reformat_adc_shenron() Step — Why It Matters
The raw ADC from heatmap_gen_fast.py has shape (N_chirps, nRx, N_sample) because the synthesis loops over chirps and builds the antenna dimension first.
The RadarProcessor in radar_processor.py expects data as (N_sample, N_chirps, nRx) because its windowing arrays (rangeWin, velWin) are pre-computed in that axis order. The reformat_adc_shenron() function performs this axis reordering:
def reformat_adc_shenron(data):
data = np.swapaxes(data, 1, 2) # Swap axes 1 and 2: (Np, N, nRx)
chirpLevelData = data
return np.transpose(chirpLevelData[:, :, :], (1, 0, 2)) # -> (N, Np, nRx)
This is the step where we save self.last_adc = raw_adc BEFORE this reformat — so the stored ADC is in the original synthesis shape (N_chirps, nRx, N_sample), closest to the hardware physical format.
4.5 Memory Footprint and Disk Impact
| Radar | Chirps | Samples | Antennas | Per-Frame Size (complex64) |
|---|---|---|---|---|
| AWRL1432 | 128 | 256 | 6 | 128×256×6×8 bytes ≈ 1.5 MB |
| Radarbook | 128 | 256 | 8 | 128×256×8×8 bytes ≈ 2.0 MB |
- A 500-frame braking scenario generates ~750 MB of ADC data (both radars combined).
- Compared to the point cloud (~4 KB per frame), ADC data is ~375x larger.
- If disk space is constrained, set a frame sub-sampling factor in
generate_shenron.py.
5. DSP Implementation: From ADC to Heatmaps (Codebase Walkthrough)
This section traces exactly how the stored ADC data is processed into the RD and RA heatmaps visible in the Foxglove dashboard. All code references are from scripts/ISOLATE/sim_radar_utils/radar_processor.py.
5.1 The RadarProcessor Initialization
When ShenronRadarModel is created, it instantiates a RadarProcessor. Inside __init__, the processor pre-computes all the windowing arrays and physical axes that will be reused for every frame:
# Hanning windows for Range and Doppler dimensions
self.rangeWin = np.tile(signal.windows.hann(radarCfg['N']), (radarCfg['Np'], radarCfg['NrChn'], 1))
self.rangeWin = np.transpose(self.rangeWin, (2, 0, 1)) # -> (N_sample, N_chirps, nRx)
self.velWin = np.tile(signal.windows.hann(radarCfg['Np']), (radarCfg['N'], radarCfg['NrChn'], 1))
self.velWin = np.transpose(self.velWin, (0, 2, 1)) # -> (N_sample, N_chirps, nRx)
Why Hanning Windows? When you take an FFT of a finite signal, the sharp edges at the start and end of the data cause the frequency content to "smear" into adjacent bins (Spectral Leakage). A Hanning window gently tapers the data to zero at both edges, dramatically reducing this leakage and creating cleaner peaks. It is the equivalent of slowly fading in and fading out a recording instead of cutting it sharply.
Physical range and velocity axes are pre-calculated:
rangeRes = c0 / (2 * (fStop - fStrt)) # metres per bin
self.rangeAxis = np.arange(0, NFFT) * N/NFFT * rangeRes # physical range values
self.velAxis = np.arange(-NFFTVel//2, NFFTVel//2)/NFFTVel * (1/Tp) * c0/(2*fc) # m/s
5.2 Step 1: The Range FFT (cal_range_fft)
def cal_range_fft(self, data):
return fft(data * self.rangeWin * self.velWin, fftCfg['NFFT'], 0)
What this does:
- Windowing:
data * self.rangeWin * self.velWinapplies the Hanning window to both the samples and chirps dimensions simultaneously. - FFT along axis 0: The FFT is applied across the N_sample (range) dimension. For each chirp and each antenna, the time-domain beat frequency signal is converted into a frequency-domain spectrum. Each frequency bin maps to a specific range.
- Output shape:
(NFFT, N_chirps, nRx)whereNFFT >= N_sample(zero-padded for interpolation).
After this step, you have a Range Profile — for each antenna and each chirp, you can see which distances have strong reflections.
5.3 Step 2: The Doppler FFT (cal_doppler_fft)
def cal_doppler_fft(self, rangeProfile):
return fftshift(fft(rangeProfile[self.RMinIdx:self.RMaxIdx+1, :], fftCfg['NFFTVel'], 1), 1)
What this does:
- Range Clipping:
rangeProfile[RMinIdx:RMaxIdx+1, :]discards range bins outside the configuredRMin/RMaxwindow (set inconfig.yaml). - FFT along axis 1: The FFT is applied across the N_chirps (Doppler) dimension. For each range bin and each antenna, the slow phase rotation from chirp to chirp is converted into a velocity spectrum.
fftshift: By default, FFT places zero frequency at index 0, with the positive half first and negative half at the end.fftshiftreorders this so zero velocity is in the centre, negative velocities are on the left, and positive are on the right — exactly as seen in the RD heatmap.- Output shape:
(N_range_bins, NFFTVel, nRx).
5.4 Step 3: Range-Doppler Heatmap (Inside convert_to_pcd)
rd_heatmap = np.sum(np.abs(dopplerProfile)**2, axis=2)
This collapses the antenna dimension by:
np.abs(dopplerProfile)— computing the magnitude of each complex number (clock hand length).**2— squaring it to get power (energy) rather than amplitude.np.sum(..., axis=2)— incoherent summation across allnRxantennas.
"Incoherent" means we add the powers (not the complex voltages). This avoids antenna-phase cancellation from interfering signals. The result is a 2D array (N_range_bins, NFFTVel) — the brightly coloured Range-Doppler map.
5.5 Step 4: CFAR Detection
hit_matrix, threshold_matrix = self.cfar(rd_heatmap)
The CA_CFAR (Cell Averaging CFAR) detector scans every cell of the RD heatmap. For each cell:
- It looks at a window of surrounding cells (guard cells excluded) and averages their power — this is the estimated noise floor.
- It multiplies by a threshold factor:
threshold = noise_floor * cfar_threshold. - If the current cell exceeds this threshold, it is marked as a target in
hit_matrix.
The threshold_matrix (the CFAR noise floor) is also saved as the cfar metrology file.
5.6 Step 5: Angle of Arrival via Antenna FFT
For each detected target (row, col) in the RD map:
pointSel[i] = dopplerProfile[row, col, :] # Complex voltages at the 8 antennas
pointSel_windowed = pointSel * self.spatialWin # Apply Hanning spatial window
aoaProfile = fftshift(fft(pointSel_windowed, fftCfg['NFFTAnt'], 1), 1)
angleIdx = np.argmax(np.abs(aoaProfile), axis=1) # Peak = angle bin
angleVals = self.angleAxis[angleIdx] # Convert bin to radians
The complex voltages across the 8 antennas form a spatial array. An FFT across this array converts inter-antenna phase differences into an angle spectrum. The peak of this spectrum is the Angle of Arrival. Combined with range (rangeVals = self.rangeAxis[rowSel]), we get $(x, y)$ positions.
5.7 Step 6: Range-Azimuth Heatmap (Doppler-Slice Accumulation)
ra_heatmap = np.zeros((N_range, fftCfg['NFFTAnt']), dtype=np.float64)
for d in range(N_doppler):
doppler_slice = dopplerProfile[:, d, :] # One velocity slice
doppler_slice_windowed = doppler_slice * self.spatialWin
angle_fft = fftshift(fft(doppler_slice_windowed, fftCfg['NFFTAnt'], axis=1), axes=1)
ra_heatmap += np.abs(angle_fft) ** 2 # Accumulate power
This is the most important difference from a simple RA plot. By iterating through every Doppler bin and doing the Angle-FFT independently, we preserve the angular sharpness of moving targets. If we had collapsed the Doppler dimension first (averaged all chirps), a moving target's phase would smear across the chirps and produce a blurred angle estimate.
The resulting ra_heatmap is saved as the ra metrology file and rendered as the BEV (Bird's Eye View) polar sector image in Foxglove.
6. The "Reverse DSP" Fallacy: A Deep Dive
A persistent engineering hypothesis is: If we have the final Radar Point Cloud, can we read the $(x, y, z)$ coordinates and run the math backward to re-create the raw ADC data?
This question is technically answerable with "yes, you can generate a signal," but the critical answer is no, you cannot recover the original signal. The two are fundamentally different things, and conflating them leads to systematically over-optimistic simulations.
6.1 The One-Way Information Bottleneck
The entire DSP chain from ADC to Point Cloud is a lossy compression pipeline:
RAW ADC (131,072 complex numbers per frame)
| Range FFT
v
Range Profile (still 131,072 numbers, but transformed)
| Doppler FFT
v
RD Heatmap (reduced to range_bins x doppler_bins = ~5,000 numbers)
| CFAR threshold (discards ~99% of cells)
v
Point Cloud (perhaps 20-50 complex detections)
At each step, information is permanently discarded. Going backwards from 20 points to 131,072 complex numbers requires inventing the missing 131,052 numbers. There is no mathematical inverse of a lossy compression.
6.2 The "Silent Background" Problem — CFAR Entropy Loss
In radar_processor.py, the CA_CFAR detector examines every cell of the RD heatmap. It estimates the local noise floor by averaging surrounding cells and only retains cells exceeding a threshold. Everything below the threshold — the noise floor itself, ground clutter, and marginal reflections — is discarded.
The concrete consequence: A reconstructed ADC signal has absolute silence between targets. When you re-run CFAR on this reconstructed ADC, the adaptive threshold drops to near-zero because there is no noise floor for CFAR to measure. Every tiny numerical artefact exceeds the threshold. The CFAR algorithm, calibrated against a real noise floor, now produces hundreds of false detections in a signal that was supposed to be clean.
This is the "clean-room" paradox: the cleaner you make the reconstructed data, the more wrong CFAR behaves on it.
6.3 The Illusion of Perfect Sidelobes
When an FFT processes a finite-duration signal, energy unavoidably leaks into neighbouring frequency bins. These ripples are called Sidelobes. Every real target produces a main peak surrounded by a pattern of sidelobes.
- In real hardware: The sidelobes are distorted by Local Oscillator (LO) phase noise (random jitter in the clock), amplifier non-linearities (signal clipping), and temperature-dependent gain drift. The sidelobe pattern is messy and irregular.
- In reconstructed ADC: Each target is represented by a pure, perfect complex sinusoid. Its FFT produces textbook-perfect sidelobes defined only by the Hanning window function. There is no irregularity.
Consequence: A CFAR algorithm tuned on real hardware sidelobes will behave completely differently when tested on perfect-sidelobe reconstructed data. A tracker that correctly ignores real hardware sidelobes may falsely detect the perfect sidelobes as real targets, or vice versa.
6.4 Multipath Erasure
In the real physical world (and in our Shenron simulation from LiDAR), electromagnetic waves bounce multiple times. A signal transmitted from the radar may:
- Travel directly to a car and reflect back — the primary detection.
- Bounce off the ground first, hit the car at an angle, and return — a "Ghost" at a slightly different apparent range.
- Reflect off the car, bounce off a nearby wall, and return after an extra delay — another ghost.
These multipath reflections create Ghost Targets in the RD map. The CFAR algorithm may or may not detect them as targets depending on their strength. If they survive into the point cloud, they appear as extra spurious points.
The fallacy: If the point cloud has already filtered out ghost targets (or included them), reconstructing ADC from just the surviving real targets produces a signal with zero multipath. Testing a tracker on this tells you nothing about how it handles real-world multipath environments.
6.5 Irreversible Sub-Resolution Merging
The Radarbook (B=250 MHz) has a range resolution of 0.6 m. This means any two objects closer than 0.6 m to each other are indistinguishable in range — their beats overlap and appear as one stronger peak.
If two pedestrians are walking 0.4 m apart, the CFAR detects them as a single, stronger point at the midpoint between them. The point cloud stores one point. When you reconstruct ADC from that one point, you generate one single sine wave at the midpoint. There is no mathematical transformation that recovers two separate sine waves from that one point — the information is permanently merged.
6.6 Phase Matrix Loss — The AoA Bottleneck
Angle of Arrival estimation extracts the target angle from the inter-antenna phase pattern. After extracting the angle (angleVals in convert_to_pcd), the original complex phase across the 8 antennas is immediately discarded — only the angle in degrees is stored in the point cloud.
To reconstruct ADC for that target, you need to synthesize the complex voltages at all 8 antennas. You can compute what those phases should be given the stored angle, but there are two problems:
- Quantization error: The stored angle is a single bin from an FFT. The true angle might be between two bins. The reconstruction picks exactly one bin, creating a slightly wrong phase pattern.
- Amplitude ambiguity: The relative magnitudes across antennas were shaped by the real hardware antenna pattern. Unless you model that pattern precisely, the per-antenna amplitudes will be wrong, producing an AoA estimate that is biased after re-processing.
6.7 When Reverse DSP Is Acceptable
Despite all the above limitations, there are narrow use cases where reconstructing ADC from a point cloud is acceptable:
- Unit testing DSP code: If you want to verify that your
cal_range_fftandcal_doppler_fftfunctions correctly detect a target at 30 m, constructing a synthetic single-target ADC (a pure sine wave at the 30 m beat frequency) is a valid unit test. You are testing the code logic, not the realism. - HIL injection with known ground truth: If you inject a synthetic perfect ADC into a hardware ECU and measure where the ECU places the detection, you are characterizing the hardware's detection bias, not testing its performance against real noise.
For any scenario where you need the result to be comparable to real-world driving data, you must use the forward path: CARLA → LiDAR → ADC Synthesis → DSP → Detection.
7. Advanced Use Cases: The Value of Raw ADC
Given the memory footprint, why did we modify the Fox pipeline to capture ADC data?
- Custom DSP Architecture Design: Engineers can bypass
sim_radar_utils. With raw ADC data, they can write and test custom FFT windowing, dynamic 2D-CFAR algorithms, or Super-Resolution Angle estimation algorithms (like MUSIC or ESPRIT) directly in Python, validating changes without re-running the heavy CARLA simulation. - Machine Learning & Neural Perception: The cutting edge of AI research abandons traditional FFT/CFAR chains. Instead, massive 3D Complex ADC cubes are fed directly into 3D Convolutional Neural Networks (3D-CNNs) or Spatiotemporal Transformers. The AI learns the physics directly from the raw voltage, bypassing human-engineered thresholds.
- Hardware-in-the-Loop (HIL) Stimulation: Raw ADC matrices can be formatted and injected directly into the digital baseband ports of physical radar Electronic Control Units (ECUs) on a testbench. This physically tricks the silicon processor into "seeing" the virtual CARLA simulation, allowing for hardware validation before the sensor is ever mounted on a car.
Created: 2026-04-30 | Part of the Fox Radar Metrology Suite | Generated via Antigravity Agentic Memory.