feat: Raw IQ Replay mode — software FPGA signal chain with playback controls

Add a 4th connection mode to the V7 dashboard that loads raw complex IQ
captures (.npy) and runs the full FPGA signal processing chain in software:
quantize → AGC → Range FFT → Doppler FFT → MTI → DC notch → CFAR.

Implementation (7 steps):
- v7/agc_sim.py: bit-accurate AGC runtime extracted from adi_agc_analysis.py
- v7/processing.py: RawIQFrameProcessor (full signal chain) + shared
  extract_targets_from_frame() for bin-to-physical conversion
- v7/raw_iq_replay.py: RawIQReplayController with thread-safe playback
  state machine (play/pause/stop/step/seek/loop/FPS)
- v7/workers.py: RawIQReplayWorker (QThread) emitting same signals as
  RadarDataWorker + playback state/index signals
- v7/dashboard.py: mode combo entry, playback controls UI, dynamic
  RangeDopplerCanvas that adapts to any frame size

Bug fixes included:
- RangeDopplerCanvas no longer hardcodes 64x32; resizes dynamically
- Doppler centre bin uses n_doppler//2 instead of hardcoded 16
- Shared target extraction eliminates duplicate code between workers

Ruff clean, 120/120 tests pass.
This commit is contained in:
Jason
2026-04-14 01:25:25 +05:45
parent 77496ccc88
commit 2cb56e8b13
5 changed files with 1280 additions and 65 deletions
+261 -8
View File
@@ -25,6 +25,7 @@ commands sent over FT2232H.
import time
import logging
from collections import deque
from pathlib import Path
import numpy as np
@@ -59,8 +60,10 @@ from .hardware import (
DataRecorder,
STM32USBInterface,
)
from .processing import RadarProcessor, USBPacketParser
from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator
from .processing import RadarProcessor, USBPacketParser, RawIQFrameProcessor
from .workers import RadarDataWorker, RawIQReplayWorker, GPSDataWorker, TargetSimulator
from .raw_iq_replay import RawIQReplayController, PlaybackState
from .agc_sim import AGCConfig
from .map_widget import RadarMapWidget
logger = logging.getLogger(__name__)
@@ -75,19 +78,29 @@ NUM_DOPPLER_BINS = 32
# =============================================================================
class RangeDopplerCanvas(FigureCanvasQTAgg):
"""Matplotlib canvas showing the 64x32 Range-Doppler map with dark theme."""
"""Matplotlib canvas showing a Range-Doppler map with dark theme.
Adapts dynamically to incoming frame dimensions (e.g. 64x32 from FPGA,
or different sizes from Raw IQ Replay).
"""
def __init__(self, _parent=None):
fig = Figure(figsize=(10, 6), facecolor=DARK_BG)
self.ax = fig.add_subplot(111, facecolor=DARK_ACCENT)
self._data = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS))
# Initial backing data — will resize on first update_map call
self._n_range = NUM_RANGE_BINS
self._n_doppler = NUM_DOPPLER_BINS
self._data = np.zeros((self._n_range, self._n_doppler))
self.im = self.ax.imshow(
self._data, aspect="auto", cmap="hot",
extent=[0, NUM_DOPPLER_BINS, 0, NUM_RANGE_BINS], origin="lower",
extent=[0, self._n_doppler, 0, self._n_range], origin="lower",
)
self.ax.set_title("Range-Doppler Map (64x32)", color=DARK_FG)
self.ax.set_title(
f"Range-Doppler Map ({self._n_range}x{self._n_doppler})",
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)
@@ -98,7 +111,20 @@ class RangeDopplerCanvas(FigureCanvasQTAgg):
super().__init__(fig)
def update_map(self, magnitude: np.ndarray, _detections: np.ndarray = None):
"""Update the heatmap with new magnitude data."""
"""Update the heatmap with new magnitude data.
Automatically resizes the canvas if the incoming shape differs from
the current backing array.
"""
nr, nd = magnitude.shape
if nr != self._n_range or nd != self._n_doppler:
self._n_range = nr
self._n_doppler = nd
self._data = np.zeros((nr, nd))
self.im.set_extent([0, nd, 0, nr])
self.ax.set_title(
f"Range-Doppler Map ({nr}x{nd})", color=DARK_FG)
display = np.log10(magnitude + 1)
self.im.set_data(display)
self.im.set_clim(vmin=display.min(), vmax=max(display.max(), 0.1))
@@ -142,6 +168,11 @@ class RadarDashboard(QMainWindow):
self._gps_worker: GPSDataWorker | None = None
self._simulator: TargetSimulator | None = None
# Raw IQ Replay
self._replay_controller: RawIQReplayController | None = None
self._replay_worker: RawIQReplayWorker | None = None
self._iq_processor: RawIQFrameProcessor | None = None
# State
self._running = False
self._demo_mode = False
@@ -341,7 +372,8 @@ class RadarDashboard(QMainWindow):
# Row 0: connection mode + device combos + buttons
ctrl_layout.addWidget(QLabel("Mode:"), 0, 0)
self._mode_combo = QComboBox()
self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay (.npy)"])
self._mode_combo.addItems([
"Mock", "Live FT2232H", "Replay (.npy)", "Raw IQ Replay (.npy)"])
self._mode_combo.setCurrentIndex(0)
ctrl_layout.addWidget(self._mode_combo, 0, 1)
@@ -390,6 +422,55 @@ class RadarDashboard(QMainWindow):
self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight)
ctrl_layout.addWidget(self._status_label_main, 1, 5, 1, 5)
# Row 2: Raw IQ playback controls (hidden until Raw IQ mode active)
self._playback_frame = QFrame()
self._playback_frame.setStyleSheet(
f"background-color: {DARK_HIGHLIGHT}; border-radius: 4px;")
pb_layout = QHBoxLayout(self._playback_frame)
pb_layout.setContentsMargins(8, 4, 8, 4)
self._pb_play_btn = QPushButton("Play")
self._pb_play_btn.setStyleSheet(
f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; }}")
self._pb_play_btn.clicked.connect(self._pb_play_pause)
pb_layout.addWidget(self._pb_play_btn)
self._pb_step_btn = QPushButton("Step")
self._pb_step_btn.clicked.connect(self._pb_step)
pb_layout.addWidget(self._pb_step_btn)
self._pb_stop_btn = QPushButton("Stop")
self._pb_stop_btn.setStyleSheet(
f"QPushButton {{ background-color: {DARK_ERROR}; color: white; }}")
self._pb_stop_btn.clicked.connect(self._stop_radar)
pb_layout.addWidget(self._pb_stop_btn)
pb_layout.addWidget(QLabel("FPS:"))
self._pb_fps_spin = QDoubleSpinBox()
self._pb_fps_spin.setRange(0.1, 60.0)
self._pb_fps_spin.setValue(10.0)
self._pb_fps_spin.setSingleStep(1.0)
self._pb_fps_spin.valueChanged.connect(self._pb_fps_changed)
pb_layout.addWidget(self._pb_fps_spin)
self._pb_loop_check = QCheckBox("Loop")
self._pb_loop_check.setChecked(True)
self._pb_loop_check.toggled.connect(self._pb_loop_changed)
pb_layout.addWidget(self._pb_loop_check)
self._pb_frame_label = QLabel("Frame: 0 / 0")
self._pb_frame_label.setStyleSheet(
f"color: {DARK_INFO}; font-weight: bold;")
pb_layout.addWidget(self._pb_frame_label)
self._pb_file_label = QLabel("")
self._pb_file_label.setStyleSheet(f"color: {DARK_TEXT}; font-size: 10px;")
pb_layout.addWidget(self._pb_file_label)
pb_layout.addStretch()
self._playback_frame.setVisible(False)
ctrl_layout.addWidget(self._playback_frame, 2, 0, 1, 10)
layout.addWidget(ctrl)
# ---- Display area (range-doppler + targets table) ------------------
@@ -1194,6 +1275,10 @@ class RadarDashboard(QMainWindow):
try:
mode = self._mode_combo.currentText()
if "Raw IQ" in mode:
self._start_raw_iq_replay()
return
if "Mock" in mode:
self._connection = FT2232HConnection(mock=True)
if not self._connection.open():
@@ -1271,6 +1356,16 @@ class RadarDashboard(QMainWindow):
self._radar_worker.wait(2000)
self._radar_worker = None
# Raw IQ Replay cleanup
if self._replay_controller is not None:
self._replay_controller.stop()
if self._replay_worker is not None:
self._replay_worker.stop()
self._replay_worker.wait(2000)
self._replay_worker = None
self._replay_controller = None
self._iq_processor = None
if self._gps_worker:
self._gps_worker.stop()
self._gps_worker.wait(2000)
@@ -1285,11 +1380,162 @@ class RadarDashboard(QMainWindow):
self._start_btn.setEnabled(True)
self._stop_btn.setEnabled(False)
self._mode_combo.setEnabled(True)
self._playback_frame.setVisible(False)
self._status_label_main.setText("Status: Radar stopped")
self._sb_status.setText("Radar stopped")
self._sb_mode.setText("Idle")
logger.info("Radar system stopped")
# =====================================================================
# Raw IQ Replay
# =====================================================================
def _start_raw_iq_replay(self):
"""Start raw IQ replay mode: load .npy file and begin playback."""
from PyQt6.QtWidgets import QFileDialog
npy_path, _ = QFileDialog.getOpenFileName(
self, "Select Raw IQ .npy file", "",
"NumPy files (*.npy);;All files (*)")
if not npy_path:
return
try:
# Create controller and load file
self._replay_controller = RawIQReplayController()
info = self._replay_controller.load_file(npy_path)
# Create frame processor
self._iq_processor = RawIQFrameProcessor(
n_range_out=min(64, info.n_samples),
n_doppler_out=min(32, info.n_chirps),
)
# Apply current AGC settings from FPGA Control tab
agc_enable = self._param_spins.get("0x28")
agc_target = self._param_spins.get("0x29")
agc_attack = self._param_spins.get("0x2A")
agc_decay = self._param_spins.get("0x2B")
agc_holdoff = self._param_spins.get("0x2C")
self._iq_processor.set_agc_config(AGCConfig(
enabled=bool(agc_enable.value()) if agc_enable else False,
target=agc_target.value() if agc_target else 200,
attack=agc_attack.value() if agc_attack else 1,
decay=agc_decay.value() if agc_decay else 1,
holdoff=agc_holdoff.value() if agc_holdoff else 4,
))
# Apply CFAR settings
cfar_en = self._param_spins.get("0x25")
cfar_guard = self._param_spins.get("0x21")
cfar_train = self._param_spins.get("0x22")
cfar_alpha = self._param_spins.get("0x23")
cfar_mode = self._param_spins.get("0x24")
self._iq_processor.set_cfar_params(
enabled=bool(cfar_en.value()) if cfar_en else False,
guard=cfar_guard.value() if cfar_guard else 2,
train=cfar_train.value() if cfar_train else 8,
alpha_q44=cfar_alpha.value() if cfar_alpha else 0x30,
mode=cfar_mode.value() if cfar_mode else 0,
)
# Apply MTI / DC notch
mti_en = self._param_spins.get("0x26")
dc_notch = self._param_spins.get("0x27")
self._iq_processor.set_mti_enabled(
bool(mti_en.value()) if mti_en else False)
self._iq_processor.set_dc_notch_width(
dc_notch.value() if dc_notch else 0)
# Threshold
thresh = self._param_spins.get("0x03")
self._iq_processor.set_detect_threshold(
thresh.value() if thresh else 10000)
# Create worker
self._replay_worker = RawIQReplayWorker(
controller=self._replay_controller,
processor=self._iq_processor,
host_processor=self._processor,
settings=self._settings,
)
self._replay_worker.frameReady.connect(self._on_frame_ready)
self._replay_worker.statusReceived.connect(self._on_status_received)
self._replay_worker.targetsUpdated.connect(self._on_radar_targets)
self._replay_worker.statsUpdated.connect(self._on_radar_stats)
self._replay_worker.errorOccurred.connect(self._on_worker_error)
self._replay_worker.playbackStateChanged.connect(
self._on_playback_state_changed)
self._replay_worker.frameIndexChanged.connect(
self._on_frame_index_changed)
# Start worker (paused initially)
self._replay_worker.start()
# UI state
self._running = True
self._start_time = time.time()
self._frame_count = 0
self._start_btn.setEnabled(False)
self._stop_btn.setEnabled(True)
self._mode_combo.setEnabled(False)
self._playback_frame.setVisible(True)
self._pb_frame_label.setText(f"Frame: 0 / {info.n_frames}")
self._pb_file_label.setText(
f"{Path(npy_path).name} "
f"({info.n_chirps}x{info.n_samples}, "
f"{info.file_size_mb:.1f} MB)")
self._status_label_main.setText("Status: Raw IQ Replay (paused)")
self._sb_status.setText("Raw IQ Replay")
self._sb_mode.setText("Raw IQ Replay")
logger.info(f"Raw IQ Replay started: {npy_path}")
except (ValueError, OSError) as e:
QMessageBox.critical(self, "Error",
f"Failed to load raw IQ file:\n{e}")
logger.error(f"Raw IQ load error: {e}")
# ---- Playback control slots --------------------------------------------
def _pb_play_pause(self):
"""Toggle play/pause for raw IQ replay."""
if self._replay_controller is None:
return
state = self._replay_controller.state
if state == PlaybackState.PLAYING:
self._replay_controller.pause()
self._pb_play_btn.setText("Play")
else:
self._replay_controller.play()
self._pb_play_btn.setText("Pause")
def _pb_step(self):
"""Step one frame forward in raw IQ replay."""
if self._replay_controller is not None:
self._replay_controller.step_forward()
def _pb_fps_changed(self, value: float):
if self._replay_controller is not None:
self._replay_controller.set_fps(value)
def _pb_loop_changed(self, checked: bool):
if self._replay_controller is not None:
self._replay_controller.set_loop(checked)
@pyqtSlot(str)
def _on_playback_state_changed(self, state_str: str):
if state_str == "playing":
self._pb_play_btn.setText("Pause")
elif state_str == "paused":
self._pb_play_btn.setText("Play")
elif state_str == "stopped":
self._pb_play_btn.setText("Play")
self._status_label_main.setText("Status: Replay finished")
@pyqtSlot(int, int)
def _on_frame_index_changed(self, current: int, total: int):
self._pb_frame_label.setText(f"Frame: {current} / {total}")
# =====================================================================
# Demo mode
# =====================================================================
@@ -1315,6 +1561,8 @@ class RadarDashboard(QMainWindow):
self._demo_mode = False
if not self._running:
mode = "Idle"
elif self._replay_controller is not None:
mode = "Raw IQ Replay"
elif isinstance(self._connection, ReplayConnection):
mode = "Replay"
else:
@@ -1714,6 +1962,11 @@ class RadarDashboard(QMainWindow):
def closeEvent(self, event):
if self._simulator:
self._simulator.stop()
if self._replay_controller is not None:
self._replay_controller.stop()
if self._replay_worker is not None:
self._replay_worker.stop()
self._replay_worker.wait(1000)
if self._radar_worker:
self._radar_worker.stop()
self._radar_worker.wait(1000)