fix(gui): align radar parameters to FPGA truth (radar_scene.py)

- Bandwidth 500 MHz -> 20 MHz, sample rate 4 MHz -> 100 MHz (DDC output)
- Range formula: deramped FMCW -> matched-filter c/(2*Fs)*decimation
- Velocity formula: use PRI (167 us) and chirps_per_subframe (16)
- Carrier frequency: 10.525 GHz -> 10.5 GHz per radar_scene.py
- Range per bin: 4.8 m -> 24 m, max range: 307 m -> 1536 m
- Fix simulator target spawn range to match new coverage (50-1400 m)
- Remove dead BANDWIDTH constant, add SAMPLE_RATE to V65 Tk
- All 174 tests pass, ruff clean
This commit is contained in:
Jason
2026-04-16 21:35:01 +05:45
parent 161e9a66e4
commit 76cfc71b19
5 changed files with 58 additions and 49 deletions
+10 -10
View File
@@ -98,9 +98,10 @@ class DemoTarget:
__slots__ = ("azimuth", "classification", "id", "range_m", "snr", "velocity") __slots__ = ("azimuth", "classification", "id", "range_m", "snr", "velocity")
# Physical range grid: 64 bins x ~4.8 m/bin = ~307 m max # Physical range grid: 64 bins x ~24 m/bin = ~1536 m max
_RANGE_PER_BIN: float = (3e8 / (2 * 500e6)) * 16 # ~4.8 m # Bin spacing = c / (2 * Fs) * decimation, where Fs = 100 MHz DDC output.
_MAX_RANGE: float = _RANGE_PER_BIN * NUM_RANGE_BINS # ~307 m _RANGE_PER_BIN: float = (3e8 / (2 * 100e6)) * 16 # ~24 m
_MAX_RANGE: float = _RANGE_PER_BIN * NUM_RANGE_BINS # ~1536 m
def __init__(self, tid: int): def __init__(self, tid: int):
self.id = tid self.id = tid
@@ -187,10 +188,10 @@ class DemoSimulator:
mag = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.float64) mag = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.float64)
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8) det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8)
# Range/Doppler scaling (approximate) # Range/Doppler scaling: bin spacing = c/(2*Fs)*decimation
range_per_bin = (3e8 / (2 * 500e6)) * 16 # ~4.8 m/bin range_per_bin = (3e8 / (2 * 100e6)) * 16 # ~24 m/bin
max_range = range_per_bin * NUM_RANGE_BINS max_range = range_per_bin * NUM_RANGE_BINS
vel_per_bin = 1.484 # m/s per Doppler bin (from WaveformConfig) vel_per_bin = 5.34 # m/s per Doppler bin (radar_scene.py: lam/(2*16*PRI))
for t in targets: for t in targets:
if t.range_m > max_range or t.range_m < 0: if t.range_m > max_range or t.range_m < 0:
@@ -385,7 +386,7 @@ class RadarDashboard:
UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh
# Radar parameters used for range-axis scaling. # Radar parameters used for range-axis scaling.
BANDWIDTH = 500e6 # Hz — chirp bandwidth SAMPLE_RATE = 100e6 # Hz — DDC output I/Q rate (matched filter input)
C = 3e8 # m/s — speed of light C = 3e8 # m/s — speed of light
def __init__(self, root: tk.Tk, mock: bool, def __init__(self, root: tk.Tk, mock: bool,
@@ -526,9 +527,8 @@ class RadarDashboard:
def _build_display_tab(self, parent): def _build_display_tab(self, parent):
# Compute physical axis limits # Compute physical axis limits
range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin # Bin spacing = c / (2 * Fs_ddc) for matched-filter processing.
# After decimation 1024→64, each range bin = 16 FFT bins range_per_bin = self.C / (2.0 * self.SAMPLE_RATE) * 16 # ~24 m
range_per_bin = range_res * 16
max_range = range_per_bin * NUM_RANGE_BINS max_range = range_per_bin * NUM_RANGE_BINS
doppler_bin_lo = 0 doppler_bin_lo = 0
+16 -14
View File
@@ -65,9 +65,9 @@ class TestRadarSettings(unittest.TestCase):
def test_defaults(self): def test_defaults(self):
s = _models().RadarSettings() s = _models().RadarSettings()
self.assertEqual(s.system_frequency, 10e9) self.assertEqual(s.system_frequency, 10.5e9)
self.assertEqual(s.coverage_radius, 50000) self.assertEqual(s.coverage_radius, 1536)
self.assertEqual(s.max_distance, 50000) self.assertEqual(s.max_distance, 1536)
class TestGPSData(unittest.TestCase): class TestGPSData(unittest.TestCase):
@@ -425,26 +425,28 @@ class TestWaveformConfig(unittest.TestCase):
def test_defaults(self): def test_defaults(self):
from v7.models import WaveformConfig from v7.models import WaveformConfig
wc = WaveformConfig() wc = WaveformConfig()
self.assertEqual(wc.sample_rate_hz, 4e6) self.assertEqual(wc.sample_rate_hz, 100e6)
self.assertEqual(wc.bandwidth_hz, 500e6) self.assertEqual(wc.bandwidth_hz, 20e6)
self.assertEqual(wc.chirp_duration_s, 300e-6) self.assertEqual(wc.chirp_duration_s, 30e-6)
self.assertEqual(wc.center_freq_hz, 10.525e9) self.assertEqual(wc.pri_s, 167e-6)
self.assertEqual(wc.center_freq_hz, 10.5e9)
self.assertEqual(wc.n_range_bins, 64) self.assertEqual(wc.n_range_bins, 64)
self.assertEqual(wc.n_doppler_bins, 32) self.assertEqual(wc.n_doppler_bins, 32)
self.assertEqual(wc.chirps_per_subframe, 16)
self.assertEqual(wc.fft_size, 1024) self.assertEqual(wc.fft_size, 1024)
self.assertEqual(wc.decimation_factor, 16) self.assertEqual(wc.decimation_factor, 16)
def test_range_resolution(self): def test_range_resolution(self):
"""range_resolution_m should be ~5.62 m/bin with ADI defaults.""" """range_resolution_m should be ~23.98 m/bin (matched filter, 100 MSPS)."""
from v7.models import WaveformConfig from v7.models import WaveformConfig
wc = WaveformConfig() wc = WaveformConfig()
self.assertAlmostEqual(wc.range_resolution_m, 5.621, places=1) self.assertAlmostEqual(wc.range_resolution_m, 23.983, places=1)
def test_velocity_resolution(self): def test_velocity_resolution(self):
"""velocity_resolution_mps should be ~1.484 m/s/bin.""" """velocity_resolution_mps should be ~5.34 m/s/bin (PRI=167us, 16 chirps)."""
from v7.models import WaveformConfig from v7.models import WaveformConfig
wc = WaveformConfig() wc = WaveformConfig()
self.assertAlmostEqual(wc.velocity_resolution_mps, 1.484, places=2) self.assertAlmostEqual(wc.velocity_resolution_mps, 5.343, places=1)
def test_max_range(self): def test_max_range(self):
"""max_range_m = range_resolution * n_range_bins.""" """max_range_m = range_resolution * n_range_bins."""
@@ -466,7 +468,7 @@ class TestWaveformConfig(unittest.TestCase):
"""Non-default parameters correctly change derived values.""" """Non-default parameters correctly change derived values."""
from v7.models import WaveformConfig from v7.models import WaveformConfig
wc1 = WaveformConfig() wc1 = WaveformConfig()
wc2 = WaveformConfig(bandwidth_hz=1e9) # double BW → halve range res wc2 = WaveformConfig(sample_rate_hz=200e6) # double Fs → halve range bin
self.assertAlmostEqual(wc2.range_resolution_m, wc1.range_resolution_m / 2, places=2) self.assertAlmostEqual(wc2.range_resolution_m, wc1.range_resolution_m / 2, places=2)
def test_zero_center_freq_velocity(self): def test_zero_center_freq_velocity(self):
@@ -925,9 +927,9 @@ class TestExtractTargetsFromFrame(unittest.TestCase):
"""Detection at range bin 10 → range = 10 * range_resolution.""" """Detection at range bin 10 → range = 10 * range_resolution."""
from v7.processing import extract_targets_from_frame from v7.processing import extract_targets_from_frame
frame = self._make_frame(det_cells=[(10, 16)]) # dbin=16 = center → vel=0 frame = self._make_frame(det_cells=[(10, 16)]) # dbin=16 = center → vel=0
targets = extract_targets_from_frame(frame, range_resolution=5.621) targets = extract_targets_from_frame(frame, range_resolution=23.983)
self.assertEqual(len(targets), 1) self.assertEqual(len(targets), 1)
self.assertAlmostEqual(targets[0].range, 10 * 5.621, places=2) self.assertAlmostEqual(targets[0].range, 10 * 23.983, places=1)
self.assertAlmostEqual(targets[0].velocity, 0.0, places=2) self.assertAlmostEqual(targets[0].velocity, 0.0, places=2)
def test_velocity_sign(self): def test_velocity_sign(self):
+1 -1
View File
@@ -98,7 +98,7 @@ class RadarMapWidget(QWidget):
) )
self._targets: list[RadarTarget] = [] self._targets: list[RadarTarget] = []
self._pending_targets: list[RadarTarget] | None = None self._pending_targets: list[RadarTarget] | None = None
self._coverage_radius = 50_000 # metres self._coverage_radius = 1_536 # metres (64 bins x ~24 m/bin)
self._tile_server = TileServer.OPENSTREETMAP self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True self._show_coverage = True
self._show_trails = False self._show_trails = False
+29 -22
View File
@@ -108,12 +108,12 @@ class RadarSettings:
range_resolution and velocity_resolution should be calibrated to range_resolution and velocity_resolution should be calibrated to
the actual waveform parameters. the actual waveform parameters.
""" """
system_frequency: float = 10e9 # Hz (carrier, used for velocity calc) system_frequency: float = 10.5e9 # Hz (carrier, used for velocity calc)
range_resolution: float = 781.25 # Meters per range bin (default: 50km/64) range_resolution: float = 24.0 # Meters per range bin (c/(2*Fs)*decim)
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform) velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform)
max_distance: float = 50000 # Max detection range (m) max_distance: float = 1536 # Max detection range (m)
map_size: float = 50000 # Map display size (m) map_size: float = 2000 # Map display size (m)
coverage_radius: float = 50000 # Map coverage radius (m) coverage_radius: float = 1536 # Map coverage radius (m)
@dataclass @dataclass
@@ -199,39 +199,46 @@ class WaveformConfig:
Encapsulates the radar waveform so that range/velocity resolution Encapsulates the radar waveform so that range/velocity resolution
can be derived automatically instead of hardcoded in RadarSettings. can be derived automatically instead of hardcoded in RadarSettings.
Defaults match the ADI CN0566 Phaser capture parameters used in Defaults match the AERIS-10 production system parameters from
the golden_reference cosim (4 MSPS, 500 MHz BW, 300 us chirp). radar_scene.py / plfm_chirp_controller.v:
100 MSPS DDC output, 20 MHz chirp BW, 30 us long chirp,
167 us long-chirp PRI, X-band 10.5 GHz carrier.
""" """
sample_rate_hz: float = 4e6 # ADC sample rate sample_rate_hz: float = 100e6 # DDC output I/Q rate (matched filter input)
bandwidth_hz: float = 500e6 # Chirp bandwidth bandwidth_hz: float = 20e6 # Chirp bandwidth (not used in range calc;
chirp_duration_s: float = 300e-6 # Chirp ramp time # retained for time-bandwidth product / display)
center_freq_hz: float = 10.525e9 # Carrier frequency chirp_duration_s: float = 30e-6 # Long chirp ramp time
pri_s: float = 167e-6 # Pulse repetition interval (chirp + listen)
center_freq_hz: float = 10.5e9 # Carrier frequency (radar_scene.py: F_CARRIER)
n_range_bins: int = 64 # After decimation n_range_bins: int = 64 # After decimation
n_doppler_bins: int = 32 # After Doppler FFT n_doppler_bins: int = 32 # Total Doppler bins (2 sub-frames x 16)
chirps_per_subframe: int = 16 # Chirps in one Doppler sub-frame
fft_size: int = 1024 # Pre-decimation FFT length fft_size: int = 1024 # Pre-decimation FFT length
decimation_factor: int = 16 # 1024 → 64 decimation_factor: int = 16 # 1024 → 64
@property @property
def range_resolution_m(self) -> float: def range_resolution_m(self) -> float:
"""Meters per decimated range bin (FMCW deramped baseband). """Meters per decimated range bin (matched-filter pulse compression).
For deramped FMCW: bin spacing = c * Fs * T / (2 * N_FFT * BW). For FFT-based matched filtering, each IFFT output bin spans
After decimation the bin spacing grows by *decimation_factor*. c / (2 * Fs) in range, where Fs is the I/Q sample rate at the
matched-filter input (DDC output). After decimation the bin
spacing grows by *decimation_factor*.
""" """
c = 299_792_458.0 c = 299_792_458.0
raw_bin = ( raw_bin = c / (2.0 * self.sample_rate_hz)
c * self.sample_rate_hz * self.chirp_duration_s
/ (2.0 * self.fft_size * self.bandwidth_hz)
)
return raw_bin * self.decimation_factor return raw_bin * self.decimation_factor
@property @property
def velocity_resolution_mps(self) -> float: def velocity_resolution_mps(self) -> float:
"""m/s per Doppler bin. lambda / (2 * n_doppler * chirp_duration).""" """m/s per Doppler bin.
lambda / (2 * chirps_per_subframe * PRI), matching radar_scene.py.
"""
c = 299_792_458.0 c = 299_792_458.0
wavelength = c / self.center_freq_hz wavelength = c / self.center_freq_hz
return wavelength / (2.0 * self.n_doppler_bins * self.chirp_duration_s) return wavelength / (2.0 * self.chirps_per_subframe * self.pri_s)
@property @property
def max_range_m(self) -> float: def max_range_m(self) -> float:
+2 -2
View File
@@ -334,7 +334,7 @@ class TargetSimulator(QObject):
self._add_random_target() self._add_random_target()
def _add_random_target(self): def _add_random_target(self):
range_m = random.uniform(5000, 40000) range_m = random.uniform(50, 1400)
azimuth = random.uniform(0, 360) azimuth = random.uniform(0, 360)
velocity = random.uniform(-100, 100) velocity = random.uniform(-100, 100)
elevation = random.uniform(-5, 45) elevation = random.uniform(-5, 45)
@@ -368,7 +368,7 @@ class TargetSimulator(QObject):
for t in self._targets: for t in self._targets:
new_range = t.range - t.velocity * 0.5 new_range = t.range - t.velocity * 0.5
if new_range < 500 or new_range > 50000: if new_range < 10 or new_range > 1536:
continue # target exits coverage — drop it continue # target exits coverage — drop it
new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2))) new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2)))