feat: 2048-pt FFT upgrade with decimation=4, 512 output bins, 6m spacing

Complete cross-layer upgrade from 1024-pt/64-bin to 2048-pt/512-bin FFT:

FPGA RTL (14+ modules):
- radar_params.vh: FFT_SIZE=2048, RANGE_BINS=512, 9-bit range, 6-bit stream
- fft_engine.v: 2048-pt FFT with XPM BRAM
- chirp_memory_loader_param.v: 2 segments x 2048 (was 4 x 1024)
- matched_filter_multi_segment.v: BRAM inference for overlap_cache, explicit ov_waddr
- mti_canceller.v: BRAM inference for prev_i/q arrays (was fabric FFs)
- doppler_processor.v: 16384-deep memory, 14-bit addressing
- cfar_ca.v: 512 rows, indentation fix
- radar_receiver_final.v: rising-edge detector for frame_complete, 11-bit sample_addr
- range_bin_decimator.v: 512 output bins
- usb_data_interface_ft2232h.v: bulk per-frame with Manhattan magnitude
- radar_mode_controller.v: XOR edge detector for toggle signals
- rx_gain_control.v: updated for new bin count

Python GUI + Protocol (8 files):
- radar_protocol.py: 512-bin bulk frame parser, LSB-first bitmap
- GUI_V65_Tk.py, v7/*.py: updated for 512 bins, 6m range resolution

Golden data + tests:
- All .hex/.csv/.npy golden references regenerated for 2048/512
- fft_twiddle_2048.mem added
- Deleted stale seg2/seg3 chirp mem files
- 9 new bulk frame cross-layer tests, deleted 6 stale per-sample tests
- Deleted stale tb_cross_layer_ft2232h.v and dead contract_parser functions
- Updated validate_mem_files.py for 2048/2-segment config

MCU: RadarSettings.cpp max_distance/map_size 1536->3072

All 4 CI jobs pass: 285 tests, 0 failures, 0 skips
This commit is contained in:
Jason
2026-04-16 17:27:55 +05:45
parent affa40a9d3
commit e9705e40b7
178 changed files with 687738 additions and 122880 deletions
+7 -7
View File
@@ -72,7 +72,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Frame dimensions from FPGA
NUM_RANGE_BINS = 64
NUM_RANGE_BINS = 512
NUM_DOPPLER_BINS = 32
# Force C locale (period as decimal separator) for all QDoubleSpinBox instances.
@@ -92,7 +92,7 @@ def _make_dspin() -> QDoubleSpinBox:
# =============================================================================
class RangeDopplerCanvas(FigureCanvasQTAgg):
"""Matplotlib canvas showing the 64x32 Range-Doppler map with dark theme."""
"""Matplotlib canvas showing the 512x32 Range-Doppler map with dark theme."""
def __init__(self, _parent=None):
fig = Figure(figsize=(10, 6), facecolor=DARK_BG)
@@ -104,7 +104,7 @@ class RangeDopplerCanvas(FigureCanvasQTAgg):
extent=[0, NUM_DOPPLER_BINS, 0, NUM_RANGE_BINS], origin="lower",
)
self.ax.set_title("Range-Doppler Map (64x32)", color=DARK_FG)
self.ax.set_title("Range-Doppler Map (512x32)", color=DARK_FG)
self.ax.set_xlabel("Doppler Bin", color=DARK_FG)
self.ax.set_ylabel("Range Bin", color=DARK_FG)
self.ax.tick_params(colors=DARK_FG)
@@ -643,9 +643,9 @@ class RadarDashboard(QMainWindow):
btn_trigger.clicked.connect(lambda: self._send_fpga_cmd(0x02, 1))
op_layout.addWidget(btn_trigger)
# Stream Control (3-bit mask)
self._add_fpga_param_row(op_layout, "Stream Control", 0x04, 7, 3,
"0-7, 3-bit mask, rst=7")
# Stream Control (6-bit)
self._add_fpga_param_row(op_layout, "Stream Control", 0x04, 63, 6,
"0-63, 6-bit stream/format, rst=15")
btn_status = QPushButton("Request Status")
btn_status.clicked.connect(lambda: self._send_fpga_cmd(0xFF, 0))
@@ -1639,7 +1639,7 @@ class RadarDashboard(QMainWindow):
@pyqtSlot(object)
def _on_frame_ready(self, frame: RadarFrame):
"""Handle a complete 64x32 radar frame from production acquisition."""
"""Handle a complete 512x32 radar frame from production acquisition."""
self._current_frame = frame
self._frame_count += 1
+1 -1
View File
@@ -98,7 +98,7 @@ class RadarMapWidget(QWidget):
)
self._targets: list[RadarTarget] = []
self._pending_targets: list[RadarTarget] | None = None
self._coverage_radius = 1_536 # metres (64 bins x 24 m, 3 km mode)
self._coverage_radius = 3_072 # metres (512 bins x 6 m, 3 km mode)
self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True
self._show_trails = False
+7 -7
View File
@@ -109,11 +109,11 @@ class RadarSettings:
the actual waveform parameters.
"""
system_frequency: float = 10.5e9 # Hz (PLFM TX LO, verified from ADF4382 config)
range_bin_spacing: float = 24.0 # Meters per decimated range bin (c/(2*100MSPS)*16)
range_bin_spacing: float = 6.0 # Meters per decimated range bin (c/(2*100MSPS)*4)
velocity_resolution: float = 2.67 # m/s per Doppler bin (lam/(2*32*167us))
max_distance: float = 1536 # Max detection range (m) -- 64 bins x 24 m (3 km mode)
map_size: float = 1536 # Map display size (m)
coverage_radius: float = 1536 # Map coverage radius (m)
max_distance: float = 3072 # Max detection range (m) -- 512 bins x 6 m (3 km mode)
map_size: float = 3072 # Map display size (m)
coverage_radius: float = 3072 # Map coverage radius (m)
@dataclass
@@ -210,10 +210,10 @@ class WaveformConfig:
chirp_duration_s: float = 30e-6 # Long chirp ramp (informational only)
pri_s: float = 167e-6 # Pulse repetition interval (chirp + listen)
center_freq_hz: float = 10.5e9 # TX LO carrier (verified: ADF4382 config)
n_range_bins: int = 64 # After decimation (3 km mode)
n_range_bins: int = 512 # After decimation (3 km mode)
n_doppler_bins: int = 32 # After Doppler FFT
fft_size: int = 1024 # Pre-decimation FFT length
decimation_factor: int = 16 # 1024 → 64
fft_size: int = 2048 # Pre-decimation FFT length
decimation_factor: int = 4 # 2048 → 512
@property
def bin_spacing_m(self) -> float:
+1 -1
View File
@@ -56,7 +56,7 @@ class RadarProcessor:
"""Full radar processing pipeline: fusion, clustering, association, tracking."""
def __init__(self):
self.range_doppler_map = np.zeros((1024, 32))
self.range_doppler_map = np.zeros((512, 32))
self.detected_targets: list[RadarTarget] = []
self.track_id_counter: int = 0
self.tracks: dict[int, dict] = {}
+19 -7
View File
@@ -56,7 +56,8 @@ log = logging.getLogger(__name__)
# Twiddle factor file paths (relative to FPGA root)
# ---------------------------------------------------------------------------
_FPGA_DIR = Path(__file__).resolve().parents[2] / "9_2_FPGA"
TWIDDLE_1024 = str(_FPGA_DIR / "fft_twiddle_1024.mem")
TWIDDLE_2048 = str(_FPGA_DIR / "fft_twiddle_2048.mem")
TWIDDLE_1024 = str(_FPGA_DIR / "fft_twiddle_1024.mem") # kept for reference
TWIDDLE_16 = str(_FPGA_DIR / "fft_twiddle_16.mem")
# CFAR mode int→string mapping (FPGA register 0x24: 0=CA, 1=GO, 2=SO)
@@ -179,15 +180,19 @@ class SoftwareFPGA:
# --- Stage 1: Range FFT (per chirp) ---
range_i = np.zeros((n_chirps, n_samples), dtype=np.int64)
range_q = np.zeros((n_chirps, n_samples), dtype=np.int64)
twiddle_1024 = TWIDDLE_1024 if os.path.exists(TWIDDLE_1024) else None
# Select twiddle file matching input FFT size
if n_samples >= 2048:
twiddle = TWIDDLE_2048 if os.path.exists(TWIDDLE_2048) else None
else:
twiddle = TWIDDLE_1024 if os.path.exists(TWIDDLE_1024) else None
for c in range(n_chirps):
range_i[c], range_q[c] = run_range_fft(
iq_i[c].astype(np.int64),
iq_q[c].astype(np.int64),
twiddle_file=twiddle_1024,
twiddle_file=twiddle,
)
# --- Stage 2: Range bin decimation (1024 → 64) ---
# --- Stage 2: Range bin decimation (2048 → 512) ---
decim_i, decim_q = run_range_bin_decimator(range_i, range_q)
# --- Stage 3: MTI canceller (pre-Doppler, per-chirp) ---
@@ -230,6 +235,10 @@ class SoftwareFPGA:
frame.range_doppler_q = np.clip(notch_q, -32768, 32767).astype(np.int16)
frame.magnitude = mag
frame.detections = det_mask
# Range profile: magnitude at Doppler bin 0 (zero-velocity / stationary).
# This differs from the FPGA USB stream which sends per-chirp decimated
# Manhattan magnitude. The zero-Doppler slice is more useful for the
# host-side display because it represents coherently integrated range energy.
frame.range_profile = np.sqrt(
notch_i[:, 0].astype(np.float64) ** 2
+ notch_q[:, 0].astype(np.float64) ** 2
@@ -257,7 +266,7 @@ def quantize_raw_iq(
n_chirps : int
Number of chirps to keep (default 32, matching FPGA).
n_samples : int
Number of samples per chirp to keep (default 1024, matching FFT).
Number of samples per chirp to keep (default 2048, matching FFT).
peak_target : int
Target peak magnitude after scaling (default 200, matching
golden_reference INPUT_PEAK_TARGET).
@@ -270,8 +279,11 @@ def quantize_raw_iq(
# (frames, chirps, samples) — take first frame
raw_complex = raw_complex[0]
# Truncate to FPGA dimensions
block = raw_complex[:n_chirps, :n_samples]
# Truncate chirps, zero-pad samples if source is shorter than n_samples
block = np.zeros((n_chirps, n_samples), dtype=raw_complex.dtype)
avail_chirps = min(raw_complex.shape[0], n_chirps)
avail_samples = min(raw_complex.shape[1], n_samples)
block[:avail_chirps, :avail_samples] = raw_complex[:avail_chirps, :avail_samples]
max_abs = np.max(np.abs(block))
if max_abs == 0:
+4 -4
View File
@@ -3,7 +3,7 @@ v7.workers — QThread-based workers and demo target simulator.
Classes:
- RadarDataWorker — reads from FT2232H via production RadarAcquisition,
parses 0xAA/0xBB packets, assembles 64x32 frames,
parses 0xAA/0xBB packets, assembles 512x32 frames,
runs host-side DSP, emits PyQt signals.
- GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals.
- TargetSimulator — QTimer-based demo target generator.
@@ -52,11 +52,11 @@ class RadarDataWorker(QThread):
and emits PyQt signals with results.
Uses production radar_protocol.py for all packet parsing and frame
assembly (11-byte 0xAA data packets → 64x32 RadarFrame).
assembly (bulk frames or legacy 11-byte 0xAA data packets → 512x32 RadarFrame).
For replay, use ReplayWorker instead.
Signals:
frameReady(RadarFrame) — a complete 64x32 radar frame
frameReady(RadarFrame) — a complete 512x32 radar frame
statusReceived(object) — StatusResponse from FPGA
targetsUpdated(list) — list of RadarTarget after host-side DSP
errorOccurred(str) — error message
@@ -368,7 +368,7 @@ class TargetSimulator(QObject):
for t in self._targets:
new_range = t.range - t.velocity * 0.5
if new_range < 50 or new_range > 1536:
if new_range < 50 or new_range > 3072:
continue # target exits coverage — drop it
new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2)))