From 2cb56e8b134a3859a93ade103510092ef2f29e00 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:25:25 +0545 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20Raw=20IQ=20Replay=20mode=20?= =?UTF-8?q?=E2=80=94=20software=20FPGA=20signal=20chain=20with=20playback?= =?UTF-8?q?=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- 9_Firmware/9_3_GUI/v7/agc_sim.py | 222 +++++++++++++++ 9_Firmware/9_3_GUI/v7/dashboard.py | 269 +++++++++++++++++- 9_Firmware/9_3_GUI/v7/processing.py | 377 ++++++++++++++++++++++++- 9_Firmware/9_3_GUI/v7/raw_iq_replay.py | 264 +++++++++++++++++ 9_Firmware/9_3_GUI/v7/workers.py | 213 ++++++++++---- 5 files changed, 1280 insertions(+), 65 deletions(-) create mode 100644 9_Firmware/9_3_GUI/v7/agc_sim.py create mode 100644 9_Firmware/9_3_GUI/v7/raw_iq_replay.py diff --git a/9_Firmware/9_3_GUI/v7/agc_sim.py b/9_Firmware/9_3_GUI/v7/agc_sim.py new file mode 100644 index 0000000..222836c --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/agc_sim.py @@ -0,0 +1,222 @@ +""" +v7.agc_sim -- Bit-accurate AGC simulation matching rx_gain_control.v. + +Provides stateful, frame-by-frame AGC processing for the Raw IQ Replay +mode and offline analysis. All gain encoding, clamping, and attack/decay/ +holdoff logic is identical to the FPGA RTL. + +Classes: + - AGCState -- mutable internal AGC state (gain, holdoff counter) + - AGCFrameResult -- per-frame AGC metrics after processing + +Functions: + - signed_to_encoding -- signed gain (-7..+7) -> 4-bit encoding + - encoding_to_signed -- 4-bit encoding -> signed gain + - clamp_gain -- clamp to [-7, +7] + - apply_gain_shift -- apply gain_shift to 16-bit IQ arrays + - process_agc_frame -- run one frame through AGC, update state +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np + + +# --------------------------------------------------------------------------- +# FPGA AGC parameters (rx_gain_control.v reset defaults) +# --------------------------------------------------------------------------- +AGC_TARGET_DEFAULT = 200 # host_agc_target (8-bit) +AGC_ATTACK_DEFAULT = 1 # host_agc_attack (4-bit) +AGC_DECAY_DEFAULT = 1 # host_agc_decay (4-bit) +AGC_HOLDOFF_DEFAULT = 4 # host_agc_holdoff (4-bit) + + +# --------------------------------------------------------------------------- +# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed) +# --------------------------------------------------------------------------- + +def signed_to_encoding(g: int) -> int: + """Convert signed gain (-7..+7) to gain_shift[3:0] encoding. + + [3]=0, [2:0]=N -> amplify (left shift) by N + [3]=1, [2:0]=N -> attenuate (right shift) by N + """ + if g >= 0: + return g & 0x07 + return 0x08 | ((-g) & 0x07) + + +def encoding_to_signed(enc: int) -> int: + """Convert gain_shift[3:0] encoding to signed gain.""" + if (enc & 0x08) == 0: + return enc & 0x07 + return -(enc & 0x07) + + +def clamp_gain(val: int) -> int: + """Clamp to [-7, +7] (matches RTL clamp_gain function).""" + return max(-7, min(7, val)) + + +# --------------------------------------------------------------------------- +# Apply gain shift to IQ data (matches RTL combinational logic) +# --------------------------------------------------------------------------- + +def apply_gain_shift( + frame_i: np.ndarray, + frame_q: np.ndarray, + gain_enc: int, +) -> tuple[np.ndarray, np.ndarray, int]: + """Apply gain_shift encoding to 16-bit signed IQ arrays. + + Returns (shifted_i, shifted_q, overflow_count). + Matches the RTL: left shift = amplify, right shift = attenuate, + saturate to +/-32767 on overflow. + """ + direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate + amount = gain_enc & 0x07 + + if amount == 0: + return frame_i.copy(), frame_q.copy(), 0 + + if direction == 0: + # Left shift (amplify) + si = frame_i.astype(np.int64) * (1 << amount) + sq = frame_q.astype(np.int64) * (1 << amount) + else: + # Arithmetic right shift (attenuate) + si = frame_i.astype(np.int64) >> amount + sq = frame_q.astype(np.int64) >> amount + + # Count overflows (post-shift values outside 16-bit signed range) + overflow_i = (si > 32767) | (si < -32768) + overflow_q = (sq > 32767) | (sq < -32768) + overflow_count = int((overflow_i | overflow_q).sum()) + + # Saturate to +/-32767 + si = np.clip(si, -32768, 32767).astype(np.int16) + sq = np.clip(sq, -32768, 32767).astype(np.int16) + + return si, sq, overflow_count + + +# --------------------------------------------------------------------------- +# AGC state and per-frame result dataclasses +# --------------------------------------------------------------------------- + +@dataclass +class AGCConfig: + """AGC tuning parameters (mirrors FPGA host registers 0x28-0x2C).""" + + enabled: bool = False + target: int = AGC_TARGET_DEFAULT # 8-bit peak target + attack: int = AGC_ATTACK_DEFAULT # 4-bit attenuation step + decay: int = AGC_DECAY_DEFAULT # 4-bit gain-up step + holdoff: int = AGC_HOLDOFF_DEFAULT # 4-bit frames to hold + + +@dataclass +class AGCState: + """Mutable internal AGC state — persists across frames.""" + + gain: int = 0 # signed gain, -7..+7 + holdoff_counter: int = 0 # frames remaining before gain-up allowed + was_enabled: bool = False # tracks enable transitions + + +@dataclass +class AGCFrameResult: + """Per-frame AGC metrics returned by process_agc_frame().""" + + gain_enc: int = 0 # gain_shift[3:0] encoding applied this frame + gain_signed: int = 0 # signed gain for display + peak_mag_8bit: int = 0 # pre-gain peak magnitude (upper 8 of 15 bits) + saturation_count: int = 0 # post-gain overflow count (clamped to 255) + overflow_raw: int = 0 # raw overflow count (unclamped) + shifted_i: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.int16)) + shifted_q: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.int16)) + + +# --------------------------------------------------------------------------- +# Per-frame AGC processing (bit-accurate to rx_gain_control.v) +# --------------------------------------------------------------------------- + +def quantize_iq(frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Quantize complex IQ to 16-bit signed I and Q arrays. + + Input: 2-D complex array (chirps x samples) — any complex dtype. + Output: (frame_i, frame_q) as int16. + """ + frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16) + frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16) + return frame_i, frame_q + + +def process_agc_frame( + frame_i: np.ndarray, + frame_q: np.ndarray, + config: AGCConfig, + state: AGCState, +) -> AGCFrameResult: + """Run one frame through the FPGA AGC inner loop. + + Mutates *state* in place (gain and holdoff_counter). + Returns AGCFrameResult with metrics and shifted IQ data. + + Parameters + ---------- + frame_i, frame_q : int16 arrays (any shape, typically chirps x samples) + config : AGC tuning parameters + state : mutable AGC state from previous frame + """ + # --- PRE-gain peak measurement (RTL lines 133-135, 211-213) --- + abs_i = np.abs(frame_i.astype(np.int32)) + abs_q = np.abs(frame_q.astype(np.int32)) + max_iq = np.maximum(abs_i, abs_q) + frame_peak_15bit = int(max_iq.max()) if max_iq.size > 0 else 0 + peak_8bit = (frame_peak_15bit >> 7) & 0xFF + + # --- Handle AGC enable transition (RTL lines 250-253) --- + if config.enabled and not state.was_enabled: + state.gain = 0 + state.holdoff_counter = config.holdoff + state.was_enabled = config.enabled + + # --- Determine effective gain encoding --- + if config.enabled: + effective_enc = signed_to_encoding(state.gain) + else: + effective_enc = signed_to_encoding(state.gain) + + # --- Apply gain shift + count POST-gain overflow --- + shifted_i, shifted_q, overflow_raw = apply_gain_shift( + frame_i, frame_q, effective_enc) + sat_count = min(255, overflow_raw) + + # --- AGC update at frame boundary (RTL lines 226-246) --- + if config.enabled: + if sat_count > 0: + # Clipping: reduce gain immediately (attack) + state.gain = clamp_gain(state.gain - config.attack) + state.holdoff_counter = config.holdoff + elif peak_8bit < config.target: + # Signal too weak: increase gain after holdoff + if state.holdoff_counter == 0: + state.gain = clamp_gain(state.gain + config.decay) + else: + state.holdoff_counter -= 1 + else: + # Good range (peak >= target, no sat): hold, reset holdoff + state.holdoff_counter = config.holdoff + + return AGCFrameResult( + gain_enc=effective_enc, + gain_signed=state.gain if config.enabled else encoding_to_signed(effective_enc), + peak_mag_8bit=peak_8bit, + saturation_count=sat_count, + overflow_raw=overflow_raw, + shifted_i=shifted_i, + shifted_q=shifted_q, + ) diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index 0f9a53b..ef15021 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -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) diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py index c6ce2cd..9bc5752 100644 --- a/9_Firmware/9_3_GUI/v7/processing.py +++ b/9_Firmware/9_3_GUI/v7/processing.py @@ -2,9 +2,12 @@ v7.processing — Radar signal processing and GPS parsing. Classes: - - RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering, - association, Kalman tracking - - USBPacketParser — parse GPS text/binary frames from STM32 CDC + - RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering, + association, Kalman tracking + - RawIQFrameProcessor — full signal chain for raw IQ replay: + quantize -> AGC -> Range FFT -> Doppler FFT -> + crop -> MTI -> DC notch -> mag -> CFAR + - USBPacketParser — parse GPS text/binary frames from STM32 CDC Note: RadarPacketParser (old A5/C3 sync + CRC16 format) was removed. All packet parsing now uses production RadarProtocol (0xAA/0xBB format) @@ -22,6 +25,11 @@ from .models import ( RadarTarget, GPSData, ProcessingConfig, SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, ) +from .agc_sim import ( + AGCConfig, AGCState, AGCFrameResult, + quantize_iq, process_agc_frame, +) +from .hardware import RadarFrame, StatusResponse if SKLEARN_AVAILABLE: from sklearn.cluster import DBSCAN @@ -48,6 +56,103 @@ def apply_pitch_correction(raw_elevation: float, pitch: float) -> float: return raw_elevation - pitch +# ============================================================================= +# Utility: bin-to-physical target extraction (shared by all workers) +# ============================================================================= + +def extract_targets_from_frame( + frame: RadarFrame, + range_resolution: float, + velocity_resolution: float, + *, + gps: GPSData | None = None, +) -> list[RadarTarget]: + """Extract RadarTargets from a RadarFrame's detection mask. + + Performs bin-to-physical conversion and optional GPS coordinate mapping. + This is the shared implementation used by both RadarDataWorker (live mode) + and RawIQReplayWorker (replay mode). + + Args: + frame: RadarFrame with populated ``detections`` and ``magnitude`` arrays. + range_resolution: Metres per range bin. + velocity_resolution: m/s per Doppler bin. + gps: Optional GPSData for pitch correction and geographic mapping. + + Returns: + List of RadarTarget with physical-unit range, velocity, SNR, and + (if GPS available) lat/lon/azimuth/elevation. + """ + det_indices = np.argwhere(frame.detections > 0) + if len(det_indices) == 0: + return [] + + n_doppler = frame.magnitude.shape[1] + center_dbin = n_doppler // 2 + targets: list[RadarTarget] = [] + + for idx in det_indices: + rbin, dbin = idx + mag = frame.magnitude[rbin, dbin] + snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0.0 + + range_m = float(rbin) * range_resolution + velocity_ms = float(dbin - center_dbin) * velocity_resolution + + # GPS-dependent fields + raw_elev = 0.0 + corr_elev = raw_elev + lat, lon, azimuth = 0.0, 0.0, 0.0 + if gps is not None: + corr_elev = apply_pitch_correction(raw_elev, gps.pitch) + azimuth = gps.heading + lat, lon = _polar_to_geographic( + gps.latitude, gps.longitude, range_m, azimuth) + + targets.append(RadarTarget( + id=len(targets), + range=range_m, + velocity=velocity_ms, + azimuth=azimuth, + elevation=corr_elev, + latitude=lat, + longitude=lon, + snr=snr, + timestamp=frame.timestamp, + )) + + return targets + + +def _polar_to_geographic( + radar_lat: float, radar_lon: float, range_m: float, bearing_deg: float, +) -> tuple[float, float]: + """Convert polar (range, bearing) to geographic (lat, lon). + + Uses the spherical-Earth approximation (adequate for <50 km ranges). + Duplicated from ``workers.polar_to_geographic`` to keep processing.py + self-contained; the workers module still exports its own copy for + backward-compat. + """ + if range_m <= 0: + return radar_lat, radar_lon + earth_r = 6_371_000.0 + lat_r = math.radians(radar_lat) + lon_r = math.radians(radar_lon) + brg_r = math.radians(bearing_deg) + d_r = range_m / earth_r + + new_lat = math.asin( + math.sin(lat_r) * math.cos(d_r) + + math.cos(lat_r) * math.sin(d_r) * math.cos(brg_r) + ) + new_lon = lon_r + math.atan2( + math.sin(brg_r) * math.sin(d_r) * math.cos(lat_r), + math.cos(d_r) - math.sin(lat_r) * math.sin(new_lat), + ) + return math.degrees(new_lat), math.degrees(new_lon) + + # ============================================================================= # Radar Processor — signal-level processing & tracking pipeline # ============================================================================= @@ -451,3 +556,269 @@ class USBPacketParser: except (ValueError, struct.error) as e: logger.error(f"Error parsing binary GPS: {e}") return None + + +# ============================================================================= +# Raw IQ Frame Processor — full signal chain for replay mode +# ============================================================================= + +class RawIQFrameProcessor: + """Process raw complex IQ frames through the full radar signal chain. + + This replicates the FPGA processing pipeline in software so that + raw ADI CN0566 captures (or similar) can be visualised in the V7 + dashboard exactly as they would appear from the FPGA. + + Pipeline per frame: + 1. Quantize raw complex → 16-bit signed I/Q + 2. AGC gain application (bit-accurate to rx_gain_control.v) + 3. Range FFT (across samples) + 4. Doppler FFT (across chirps) + fftshift + centre crop + 5. Optional MTI (2-pulse canceller using history) + 6. Optional DC notch (zero-Doppler removal) + 7. Magnitude (|I| + |Q| approximation matching FPGA, or true |.|) + 8. CFAR or simple threshold detection + 9. Build RadarFrame + synthetic StatusResponse + """ + + def __init__( + self, + n_range_out: int = 64, + n_doppler_out: int = 32, + ): + self._n_range = n_range_out + self._n_doppler = n_doppler_out + + # AGC state (persists across frames) + self._agc_config = AGCConfig() + self._agc_state = AGCState() + + # MTI history buffer (stores previous Range-Doppler maps) + self._mti_history: list[np.ndarray] = [] + self._mti_enabled: bool = False + + # DC notch + self._dc_notch_width: int = 0 + + # CFAR / threshold config + self._cfar_enabled: bool = False + self._cfar_guard: int = 2 + self._cfar_train: int = 8 + self._cfar_alpha_q44: int = 0x30 # Q4.4 → 3.0 + self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO + self._detect_threshold: int = 10000 + + # Frame counter + self._frame_number: int = 0 + + # Host-side processing (windowing, clustering, etc.) + self._host_processor = RadarProcessor() + + # ---- Configuration setters --------------------------------------------- + + def set_agc_config(self, config: AGCConfig) -> None: + self._agc_config = config + + def reset_agc_state(self) -> None: + """Reset AGC state (e.g. on seek).""" + self._agc_state = AGCState() + self._mti_history.clear() + + def set_mti_enabled(self, enabled: bool) -> None: + if self._mti_enabled != enabled: + self._mti_history.clear() + self._mti_enabled = enabled + + def set_dc_notch_width(self, width: int) -> None: + self._dc_notch_width = max(0, min(7, width)) + + def set_cfar_params( + self, + enabled: bool, + guard: int = 2, + train: int = 8, + alpha_q44: int = 0x30, + mode: int = 0, + ) -> None: + self._cfar_enabled = enabled + self._cfar_guard = guard + self._cfar_train = train + self._cfar_alpha_q44 = alpha_q44 + self._cfar_mode = mode + + def set_detect_threshold(self, threshold: int) -> None: + self._detect_threshold = threshold + + @property + def agc_state(self) -> AGCState: + return self._agc_state + + @property + def agc_config(self) -> AGCConfig: + return self._agc_config + + @property + def frame_number(self) -> int: + return self._frame_number + + # ---- Main processing entry point --------------------------------------- + + def process_frame( + self, + raw_frame: np.ndarray, + timestamp: float = 0.0, + ) -> tuple[RadarFrame, StatusResponse, AGCFrameResult]: + """Process one raw IQ frame through the full chain. + + Parameters + ---------- + raw_frame : complex array, shape (n_chirps, n_samples) + timestamp : frame timestamp for RadarFrame + + Returns + ------- + (radar_frame, status_response, agc_result) + """ + n_chirps, _n_samples = raw_frame.shape + self._frame_number += 1 + + # 1. Quantize to 16-bit signed IQ + frame_i, frame_q = quantize_iq(raw_frame) + + # 2. AGC + agc_result = process_agc_frame( + frame_i, frame_q, self._agc_config, self._agc_state) + + # Use AGC-shifted IQ for downstream processing + iq = agc_result.shifted_i.astype(np.float64) + 1j * agc_result.shifted_q.astype(np.float64) + + # 3. Range FFT (across samples axis) + range_fft = np.fft.fft(iq, axis=1)[:, :self._n_range] + + # 4. Doppler FFT (across chirps axis) + fftshift + centre crop + doppler_fft = np.fft.fft(range_fft, axis=0) + doppler_fft = np.fft.fftshift(doppler_fft, axes=0) + # Centre-crop to n_doppler bins + center = n_chirps // 2 + half_d = self._n_doppler // 2 + start_d = max(0, center - half_d) + end_d = start_d + self._n_doppler + if end_d > n_chirps: + end_d = n_chirps + start_d = max(0, end_d - self._n_doppler) + rd_complex = doppler_fft[start_d:end_d, :] + # shape: (n_doppler, n_range) → transpose to (n_range, n_doppler) + rd_complex = rd_complex.T + + # 5. Optional MTI (2-pulse canceller) + if self._mti_enabled: + rd_complex = self._apply_mti(rd_complex) + + # 6. Optional DC notch (zero-Doppler bins) + if self._dc_notch_width > 0: + rd_complex = self._apply_dc_notch(rd_complex) + + # Extract I/Q for RadarFrame + rd_i = np.round(rd_complex.real).astype(np.int16) + rd_q = np.round(rd_complex.imag).astype(np.int16) + + # 7. Magnitude (FPGA uses |I|+|Q| approximation) + magnitude = np.abs(rd_complex.real) + np.abs(rd_complex.imag) + + # Range profile (sum across Doppler) + range_profile = np.sum(magnitude, axis=1) + + # 8. Detection (CFAR or simple threshold) + if self._cfar_enabled: + detections = self._run_cfar(magnitude) + else: + detections = self._run_threshold(magnitude) + + detection_count = int(np.sum(detections > 0)) + + # 9. Build RadarFrame + radar_frame = RadarFrame( + timestamp=timestamp, + range_doppler_i=rd_i, + range_doppler_q=rd_q, + magnitude=magnitude, + detections=detections, + range_profile=range_profile, + detection_count=detection_count, + frame_number=self._frame_number, + ) + + # 10. Build synthetic StatusResponse + status = self._build_status(agc_result) + + return radar_frame, status, agc_result + + # ---- Internal helpers -------------------------------------------------- + + def _apply_mti(self, rd: np.ndarray) -> np.ndarray: + """2-pulse MTI canceller: y[n] = x[n] - x[n-1].""" + self._mti_history.append(rd.copy()) + if len(self._mti_history) > 2: + self._mti_history = self._mti_history[-2:] + + if len(self._mti_history) < 2: + return np.zeros_like(rd) # suppress first frame + + return self._mti_history[-1] - self._mti_history[-2] + + def _apply_dc_notch(self, rd: np.ndarray) -> np.ndarray: + """Zero out centre Doppler bins (DC notch).""" + n_doppler = rd.shape[1] + center = n_doppler // 2 + w = self._dc_notch_width + lo = max(0, center - w) + hi = min(n_doppler, center + w + 1) + result = rd.copy() + result[:, lo:hi] = 0 + return result + + def _run_cfar(self, magnitude: np.ndarray) -> np.ndarray: + """Run 1-D CFAR along each range bin (Doppler direction). + + Uses the host-side CFAR from RadarProcessor with alpha converted + from Q4.4 to float. + """ + alpha_float = self._cfar_alpha_q44 / 16.0 + cfar_types = {0: "CA-CFAR", 1: "GO-CFAR", 2: "SO-CFAR"} + cfar_type = cfar_types.get(self._cfar_mode, "CA-CFAR") + + power = magnitude ** 2 + power = np.maximum(power, 1e-20) + + mask = np.zeros_like(magnitude, dtype=np.uint8) + for r in range(magnitude.shape[0]): + row = power[r, :] + if row.max() > 0: + det = RadarProcessor.cfar_1d( + row, self._cfar_guard, self._cfar_train, + alpha_float, cfar_type) + mask[r, :] = det.astype(np.uint8) + return mask + + def _run_threshold(self, magnitude: np.ndarray) -> np.ndarray: + """Simple threshold detection (matches FPGA detect_threshold).""" + return (magnitude > self._detect_threshold).astype(np.uint8) + + def _build_status(self, agc_result: AGCFrameResult) -> StatusResponse: + """Build a synthetic StatusResponse from current processor state.""" + return StatusResponse( + radar_mode=1, # active + stream_ctrl=0b111, + cfar_threshold=self._detect_threshold, + long_chirp=3000, + long_listen=13700, + guard=17540, + short_chirp=50, + short_listen=17450, + chirps_per_elev=32, + range_mode=0, + agc_current_gain=agc_result.gain_enc, + agc_peak_magnitude=agc_result.peak_mag_8bit, + agc_saturation_count=agc_result.saturation_count, + agc_enable=1 if self._agc_config.enabled else 0, + ) diff --git a/9_Firmware/9_3_GUI/v7/raw_iq_replay.py b/9_Firmware/9_3_GUI/v7/raw_iq_replay.py new file mode 100644 index 0000000..fc04517 --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/raw_iq_replay.py @@ -0,0 +1,264 @@ +""" +v7.raw_iq_replay -- Raw IQ replay controller for the V7 dashboard. + +Manages loading of raw complex IQ .npy captures, playback state +(play/pause/step/speed/loop), and delivers frames to a worker thread. + +The controller is thread-safe: the worker calls ``next_frame()`` which +blocks until a frame is available or playback is stopped. + +Supported file formats: + - 3-D .npy: (n_frames, n_chirps, n_samples) complex + - 2-D .npy: (n_chirps, n_samples) complex -> treated as single frame + +Classes: + - RawIQReplayController -- playback state machine + frame delivery + - RawIQFileInfo -- metadata about the loaded file +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from enum import Enum, auto +from pathlib import Path + +import numpy as np + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Playback state enum +# --------------------------------------------------------------------------- + +class PlaybackState(Enum): + """Playback state for the replay controller.""" + STOPPED = auto() + PLAYING = auto() + PAUSED = auto() + + +# --------------------------------------------------------------------------- +# File metadata +# --------------------------------------------------------------------------- + +@dataclass +class RawIQFileInfo: + """Metadata about a loaded raw IQ .npy file.""" + path: str + n_frames: int + n_chirps: int + n_samples: int + dtype: str + file_size_mb: float + + +# --------------------------------------------------------------------------- +# Replay Controller +# --------------------------------------------------------------------------- + +class RawIQReplayController: + """Manages raw IQ file loading and playback state. + + Thread-safety: the controller uses a condition variable so the worker + thread can block on ``next_frame()`` waiting for play/step events, + while the GUI thread calls ``play()``, ``pause()``, ``step()``, etc. + """ + + def __init__(self) -> None: + self._data: np.ndarray | None = None + self._info: RawIQFileInfo | None = None + + # Playback state + self._state = PlaybackState.STOPPED + self._frame_index: int = 0 + self._fps: float = 10.0 # target frames per second + self._loop: bool = True + + # Thread synchronisation + self._lock = threading.Lock() + self._cond = threading.Condition(self._lock) + + # Step request flag (set by GUI, consumed by worker) + self._step_requested: bool = False + + # Stop signal + self._stop_requested: bool = False + + # ---- File loading ------------------------------------------------------ + + def load_file(self, path: str) -> RawIQFileInfo: + """Load a .npy file containing raw IQ data. + + Raises ValueError if the file is not a valid raw IQ capture. + """ + p = Path(path) + if not p.exists(): + msg = f"File not found: {path}" + raise ValueError(msg) + if p.suffix.lower() != ".npy": + msg = f"Expected .npy file, got: {p.suffix}" + raise ValueError(msg) + + # Memory-map for large files + data = np.load(str(p), mmap_mode="r") + + if not np.iscomplexobj(data): + msg = f"Expected complex data, got dtype={data.dtype}" + raise ValueError(msg) + + # Normalise shape + if data.ndim == 2: + # Single frame: (chirps, samples) -> (1, chirps, samples) + data = data[np.newaxis, :, :] + elif data.ndim == 3: + pass # (frames, chirps, samples) — expected + else: + msg = f"Expected 2-D or 3-D array, got {data.ndim}-D" + raise ValueError(msg) + + with self._lock: + self._data = data + self._frame_index = 0 + self._state = PlaybackState.PAUSED + self._stop_requested = False + + self._info = RawIQFileInfo( + path=str(p), + n_frames=data.shape[0], + n_chirps=data.shape[1], + n_samples=data.shape[2], + dtype=str(data.dtype), + file_size_mb=p.stat().st_size / (1024 * 1024), + ) + + logger.info( + f"Loaded raw IQ: {p.name} — {self._info.n_frames} frames, " + f"{self._info.n_chirps} chirps, {self._info.n_samples} samples, " + f"{self._info.file_size_mb:.1f} MB" + ) + return self._info + + def unload(self) -> None: + """Unload the current file and stop playback.""" + with self._lock: + self._state = PlaybackState.STOPPED + self._stop_requested = True + self._data = None + self._info = None + self._frame_index = 0 + self._cond.notify_all() + + # ---- Playback control (called from GUI thread) ------------------------- + + def play(self) -> None: + with self._lock: + if self._data is None: + return + self._state = PlaybackState.PLAYING + self._stop_requested = False + self._cond.notify_all() + + def pause(self) -> None: + with self._lock: + if self._state == PlaybackState.PLAYING: + self._state = PlaybackState.PAUSED + + def stop(self) -> None: + with self._lock: + self._state = PlaybackState.STOPPED + self._stop_requested = True + self._cond.notify_all() + + def step_forward(self) -> None: + """Advance one frame (works in PAUSED state).""" + with self._lock: + if self._data is None: + return + self._step_requested = True + self._cond.notify_all() + + def seek(self, frame_index: int) -> None: + """Jump to a specific frame index.""" + with self._lock: + if self._data is None: + return + self._frame_index = max(0, min(frame_index, self._data.shape[0] - 1)) + + def set_fps(self, fps: float) -> None: + with self._lock: + self._fps = max(0.1, min(60.0, fps)) + + def set_loop(self, loop: bool) -> None: + with self._lock: + self._loop = loop + + # ---- State queries (thread-safe) --------------------------------------- + + @property + def state(self) -> PlaybackState: + with self._lock: + return self._state + + @property + def frame_index(self) -> int: + with self._lock: + return self._frame_index + + @property + def info(self) -> RawIQFileInfo | None: + with self._lock: + return self._info + + @property + def fps(self) -> float: + with self._lock: + return self._fps + + @property + def is_loaded(self) -> bool: + with self._lock: + return self._data is not None + + # ---- Frame delivery (called from worker thread) ------------------------ + + def next_frame(self) -> np.ndarray | None: + """Block until the next frame is available, then return it. + + Returns None when playback is stopped or file is unloaded. + The caller (worker thread) should use this in a loop. + """ + with self._cond: + while True: + if self._stop_requested or self._data is None: + return None + + if self._state == PlaybackState.PLAYING: + return self._deliver_frame() + + if self._step_requested: + self._step_requested = False + return self._deliver_frame() + + # PAUSED or STOPPED — wait for signal + self._cond.wait(timeout=0.1) + + def _deliver_frame(self) -> np.ndarray | None: + """Return current frame and advance index. Must hold lock.""" + if self._data is None: + return None + + n_frames = self._data.shape[0] + if self._frame_index >= n_frames: + if self._loop: + self._frame_index = 0 + else: + self._state = PlaybackState.PAUSED + return None + + # Read the frame (memory-mapped, so this is cheap) + frame = np.array(self._data[self._frame_index]) # copy from mmap + self._frame_index += 1 + return frame diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index c467c98..ccf1b3c 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -2,11 +2,14 @@ v7.workers — QThread-based workers and demo target simulator. Classes: - - RadarDataWorker — reads from FT2232H via production RadarAcquisition, - parses 0xAA/0xBB packets, assembles 64x32 frames, - runs host-side DSP, emits PyQt signals. - - GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals. - - TargetSimulator — QTimer-based demo target generator. + - RadarDataWorker — reads from FT2232H via production RadarAcquisition, + parses 0xAA/0xBB packets, assembles 64x32 frames, + runs host-side DSP, emits PyQt signals. + - RawIQReplayWorker — reads raw IQ .npy frames from RawIQReplayController, + processes through RawIQFrameProcessor, emits same + signals as RadarDataWorker + playback state. + - GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals. + - TargetSimulator — QTimer-based demo target generator. The old V6/V7 packet parsing (sync A5 C3 + type + CRC16) has been removed. All packet parsing now uses the production radar_protocol.py which matches @@ -20,8 +23,6 @@ import queue import struct import logging -import numpy as np - from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal from .models import RadarTarget, GPSData, RadarSettings @@ -34,9 +35,11 @@ from .hardware import ( ) from .processing import ( RadarProcessor, + RawIQFrameProcessor, USBPacketParser, - apply_pitch_correction, + extract_targets_from_frame, ) +from .raw_iq_replay import RawIQReplayController, PlaybackState logger = logging.getLogger(__name__) @@ -206,55 +209,16 @@ class RadarDataWorker(QThread): Bin-to-physical conversion uses RadarSettings.range_resolution and velocity_resolution (should be calibrated to actual waveform). """ - targets: list[RadarTarget] = [] - cfg = self._processor.config if not (cfg.clustering_enabled or cfg.tracking_enabled): - return targets + return [] - # Extract detections from FPGA CFAR flags - det_indices = np.argwhere(frame.detections > 0) - r_res = self._settings.range_resolution - v_res = self._settings.velocity_resolution - - for idx in det_indices: - rbin, dbin = idx - mag = frame.magnitude[rbin, dbin] - snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0 - - # Convert bin indices to physical units - range_m = float(rbin) * r_res - # Doppler: centre bin (16) = 0 m/s; positive bins = approaching - velocity_ms = float(dbin - 16) * v_res - - # Apply pitch correction if GPS data available - raw_elev = 0.0 # FPGA doesn't send elevation per-detection - corr_elev = raw_elev - if self._gps: - corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch) - - # Compute geographic position if GPS available - lat, lon = 0.0, 0.0 - azimuth = 0.0 # No azimuth from single-beam; set to heading - if self._gps: - azimuth = self._gps.heading - lat, lon = polar_to_geographic( - self._gps.latitude, self._gps.longitude, - range_m, azimuth, - ) - - target = RadarTarget( - id=len(targets), - range=range_m, - velocity=velocity_ms, - azimuth=azimuth, - elevation=corr_elev, - latitude=lat, - longitude=lon, - snr=snr, - timestamp=frame.timestamp, - ) - targets.append(target) + targets = extract_targets_from_frame( + frame, + self._settings.range_resolution, + self._settings.velocity_resolution, + gps=self._gps, + ) # DBSCAN clustering if cfg.clustering_enabled and len(targets) > 0: @@ -268,6 +232,147 @@ class RadarDataWorker(QThread): return targets +# ============================================================================= +# Raw IQ Replay Worker (QThread) — processes raw .npy captures +# ============================================================================= + +class RawIQReplayWorker(QThread): + """Background worker for raw IQ replay mode. + + Reads frames from a RawIQReplayController, processes them through + RawIQFrameProcessor (quantize -> AGC -> FFT -> CFAR -> RadarFrame), + and emits the same signals as RadarDataWorker so the dashboard can + display them identically. + + Additional signal: + playbackStateChanged(str) — "playing", "paused", "stopped" + frameIndexChanged(int, int) — (current_index, total_frames) + + Signals: + frameReady(RadarFrame) + statusReceived(object) + targetsUpdated(list) + errorOccurred(str) + statsUpdated(dict) + playbackStateChanged(str) + frameIndexChanged(int, int) + """ + + frameReady = pyqtSignal(object) + statusReceived = pyqtSignal(object) + targetsUpdated = pyqtSignal(list) + errorOccurred = pyqtSignal(str) + statsUpdated = pyqtSignal(dict) + playbackStateChanged = pyqtSignal(str) + frameIndexChanged = pyqtSignal(int, int) + + def __init__( + self, + controller: RawIQReplayController, + processor: RawIQFrameProcessor, + host_processor: RadarProcessor | None = None, + settings: RadarSettings | None = None, + parent=None, + ): + super().__init__(parent) + self._controller = controller + self._processor = processor + self._host_processor = host_processor + self._settings = settings or RadarSettings() + self._running = False + self._frame_count = 0 + self._error_count = 0 + + def stop(self): + self._running = False + self._controller.stop() + + def run(self): + self._running = True + self._frame_count = 0 + logger.info("RawIQReplayWorker started") + + info = self._controller.info + total_frames = info.n_frames if info else 0 + + while self._running: + try: + # Block until next frame or stop + raw_frame = self._controller.next_frame() + if raw_frame is None: + # Stopped or end of file + if self._running: + self.playbackStateChanged.emit("stopped") + break + + # Process through full signal chain + import time as _time + ts = _time.time() + frame, status, _agc_result = self._processor.process_frame( + raw_frame, timestamp=ts) + self._frame_count += 1 + + # Emit signals + self.frameReady.emit(frame) + self.statusReceived.emit(status) + + # Emit frame index + idx = self._controller.frame_index + self.frameIndexChanged.emit(idx, total_frames) + + # Emit playback state + state = self._controller.state + self.playbackStateChanged.emit(state.name.lower()) + + # Run host-side DSP if configured + if self._host_processor is not None: + targets = self._extract_targets(frame) + if targets: + self.targetsUpdated.emit(targets) + + # Stats + self.statsUpdated.emit({ + "frames": self._frame_count, + "detection_count": frame.detection_count, + "errors": self._error_count, + "frame_index": idx, + "total_frames": total_frames, + }) + + # Rate limiting: sleep to match target FPS + fps = self._controller.fps + if fps > 0 and self._controller.state == PlaybackState.PLAYING: + self.msleep(int(1000.0 / fps)) + + except (ValueError, IndexError) as e: + self._error_count += 1 + self.errorOccurred.emit(str(e)) + logger.error(f"RawIQReplayWorker error: {e}") + + self._running = False + logger.info("RawIQReplayWorker stopped") + + def _extract_targets(self, frame: RadarFrame) -> list[RadarTarget]: + """Extract targets from detection mask using shared bin-to-physical conversion.""" + targets = extract_targets_from_frame( + frame, + self._settings.range_resolution, + self._settings.velocity_resolution, + ) + + # Clustering + tracking + if self._host_processor is not None: + cfg = self._host_processor.config + if cfg.clustering_enabled and len(targets) > 0: + clusters = self._host_processor.clustering( + targets, cfg.clustering_eps, cfg.clustering_min_samples) + if cfg.tracking_enabled: + targets = self._host_processor.association(targets, clusters) + self._host_processor.tracking(targets) + + return targets + + # ============================================================================= # GPS Data Worker (QThread) # ============================================================================= From a12ea90cdfb636896745800e09f025c0ae88adaa Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:49:34 +0545 Subject: [PATCH 2/8] fix: 8 button-state bugs + wire radar position into replay for map display State machine fixes: 1. Raw IQ replay EOF now calls _stop_radar() to fully restore UI 2. Worker thread finished signal triggers UI recovery on crash/exit 3. _stop_radar() stops demo simulator to prevent cross-mode interference 4. _stop_demo() correctly identifies Mock mode via combo text 5. Demo start no longer clobbers status bar when acquisition is running 6. _stop_radar() resets playback button text, frame counter, file label 7. _start_raw_iq_replay() error path cleans up stale controller/worker 8. _refresh_gui() preserves Raw IQ paused status instead of overwriting Map/location: - RawIQReplayWorker now receives _radar_position (GPSData ref) so targets get real lat/lon projected from the virtual radar position - Added heading control to Map tab sidebar (0-360 deg, wrapping) - Manual lat/lon/heading changes in Map tab apply to replay targets Ruff clean, 120/120 tests pass. --- 9_Firmware/9_3_GUI/v7/dashboard.py | 76 ++++++++++++++++++++++++++---- 9_Firmware/9_3_GUI/v7/workers.py | 3 ++ 2 files changed, 69 insertions(+), 10 deletions(-) diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index ef15021..c323739 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -176,6 +176,7 @@ class RadarDashboard(QMainWindow): # State self._running = False self._demo_mode = False + self._replay_status_override: str | None = None # "playing"/"paused" self._start_time = time.time() self._current_frame: RadarFrame | None = None self._last_status: StatusResponse | None = None @@ -551,12 +552,22 @@ class RadarDashboard(QMainWindow): self._alt_spin.setValue(0.0) self._alt_spin.setSuffix(" m") + self._heading_spin = QDoubleSpinBox() + self._heading_spin.setRange(0, 360) + self._heading_spin.setDecimals(1) + self._heading_spin.setValue(0.0) + self._heading_spin.setSuffix("\u00b0") + self._heading_spin.setWrapping(True) + self._heading_spin.valueChanged.connect(self._on_position_changed) + pos_layout.addWidget(QLabel("Latitude:"), 0, 0) pos_layout.addWidget(self._lat_spin, 0, 1) pos_layout.addWidget(QLabel("Longitude:"), 1, 0) pos_layout.addWidget(self._lon_spin, 1, 1) pos_layout.addWidget(QLabel("Altitude:"), 2, 0) pos_layout.addWidget(self._alt_spin, 2, 1) + pos_layout.addWidget(QLabel("Heading:"), 3, 0) + pos_layout.addWidget(self._heading_spin, 3, 1) sb_layout.addWidget(pos_group) @@ -1318,6 +1329,7 @@ class RadarDashboard(QMainWindow): self._radar_worker.targetsUpdated.connect(self._on_radar_targets) self._radar_worker.statsUpdated.connect(self._on_radar_stats) self._radar_worker.errorOccurred.connect(self._on_worker_error) + self._radar_worker.finished.connect(self._on_worker_finished) self._radar_worker.start() # Optionally start GPS worker @@ -1350,6 +1362,16 @@ class RadarDashboard(QMainWindow): def _stop_radar(self): self._running = False + self._replay_status_override = None + + # Stop demo simulator if active (prevents cross-mode interference) + if self._simulator: + self._simulator.stop() + self._simulator = None + self._demo_mode = False + self._demo_btn_main.setText("Start Demo") + self._demo_btn_map.setText("Start Demo") + self._demo_btn_map.setChecked(False) if self._radar_worker: self._radar_worker.stop() @@ -1381,6 +1403,9 @@ class RadarDashboard(QMainWindow): self._stop_btn.setEnabled(False) self._mode_combo.setEnabled(True) self._playback_frame.setVisible(False) + self._pb_play_btn.setText("Play") + self._pb_frame_label.setText("Frame: 0 / 0") + self._pb_file_label.setText("") self._status_label_main.setText("Status: Radar stopped") self._sb_status.setText("Radar stopped") self._sb_mode.setText("Idle") @@ -1458,12 +1483,14 @@ class RadarDashboard(QMainWindow): processor=self._iq_processor, host_processor=self._processor, settings=self._settings, + gps_data_ref=self._radar_position, ) 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.finished.connect(self._on_worker_finished) self._replay_worker.playbackStateChanged.connect( self._on_playback_state_changed) self._replay_worker.frameIndexChanged.connect( @@ -1474,6 +1501,7 @@ class RadarDashboard(QMainWindow): # UI state self._running = True + self._replay_status_override = "paused" self._start_time = time.time() self._frame_count = 0 self._start_btn.setEnabled(False) @@ -1491,6 +1519,13 @@ class RadarDashboard(QMainWindow): logger.info(f"Raw IQ Replay started: {npy_path}") except (ValueError, OSError) as e: + # Clean up any partially-created objects + if self._replay_worker is not None: + self._replay_worker.stop() + self._replay_worker.wait(1000) + self._replay_worker = None + self._replay_controller = None + self._iq_processor = None QMessageBox.critical(self, "Error", f"Failed to load raw IQ file:\n{e}") logger.error(f"Raw IQ load error: {e}") @@ -1526,11 +1561,13 @@ class RadarDashboard(QMainWindow): def _on_playback_state_changed(self, state_str: str): if state_str == "playing": self._pb_play_btn.setText("Pause") + self._replay_status_override = "playing" elif state_str == "paused": self._pb_play_btn.setText("Play") + self._replay_status_override = "paused" elif state_str == "stopped": - self._pb_play_btn.setText("Play") - self._status_label_main.setText("Status: Replay finished") + self._replay_status_override = None + self._stop_radar() @pyqtSlot(int, int) def _on_frame_index_changed(self, current: int, total: int): @@ -1547,8 +1584,9 @@ class RadarDashboard(QMainWindow): self._simulator.targetsUpdated.connect(self._on_demo_targets) self._simulator.start(500) self._demo_mode = True - self._sb_mode.setText("Demo Mode") - self._sb_status.setText("Demo mode active") + if not self._running: + self._sb_mode.setText("Demo Mode") + self._sb_status.setText("Demo mode active") self._demo_btn_main.setText("Stop Demo") self._demo_btn_map.setText("Stop Demo") self._demo_btn_map.setChecked(True) @@ -1566,9 +1604,13 @@ class RadarDashboard(QMainWindow): elif isinstance(self._connection, ReplayConnection): mode = "Replay" else: - mode = "Live" + # Use mode combo text for Mock vs Live distinction + mode = self._mode_combo.currentText() + if "Mock" not in mode and "Live" not in mode: + mode = "Live" self._sb_mode.setText(mode) - self._sb_status.setText("Demo stopped") + if not self._running: + self._sb_status.setText("Demo stopped") self._demo_btn_main.setText("Start Demo") self._demo_btn_map.setText("Start Demo") self._demo_btn_map.setChecked(False) @@ -1620,6 +1662,12 @@ class RadarDashboard(QMainWindow): def _on_worker_error(self, msg: str): logger.error(f"Worker error: {msg}") + def _on_worker_finished(self): + """Handle unexpected worker thread exit — recover UI to stopped state.""" + if self._running: + logger.warning("Worker thread exited unexpectedly, resetting UI") + self._stop_radar() + @pyqtSlot(object) def _on_gps_received(self, gps: GPSData): self._gps_packet_count += 1 @@ -1800,6 +1848,7 @@ class RadarDashboard(QMainWindow): self._radar_position.latitude = self._lat_spin.value() self._radar_position.longitude = self._lon_spin.value() self._radar_position.altitude = self._alt_spin.value() + self._radar_position.heading = self._heading_spin.value() self._map_widget.set_radar_position(self._radar_position) if self._simulator: self._simulator.set_radar_position(self._radar_position) @@ -1874,10 +1923,17 @@ class RadarDashboard(QMainWindow): if self._running: det = (self._current_frame.detection_count if self._current_frame else 0) - self._status_label_main.setText( - f"Status: Running \u2014 Frames: {self._frame_count} " - f"\u2014 Detections: {det}" - ) + # Preserve replay-specific status (paused/playing) + if self._replay_status_override == "paused": + self._status_label_main.setText( + f"Status: Raw IQ Replay (paused) \u2014 " + f"Frames: {self._frame_count} \u2014 Detections: {det}" + ) + else: + self._status_label_main.setText( + f"Status: Running \u2014 Frames: {self._frame_count} " + f"\u2014 Detections: {det}" + ) # Diagnostics values self._update_diagnostics() diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index ccf1b3c..b1e6d1d 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -272,6 +272,7 @@ class RawIQReplayWorker(QThread): processor: RawIQFrameProcessor, host_processor: RadarProcessor | None = None, settings: RadarSettings | None = None, + gps_data_ref: GPSData | None = None, parent=None, ): super().__init__(parent) @@ -279,6 +280,7 @@ class RawIQReplayWorker(QThread): self._processor = processor self._host_processor = host_processor self._settings = settings or RadarSettings() + self._gps = gps_data_ref self._running = False self._frame_count = 0 self._error_count = 0 @@ -358,6 +360,7 @@ class RawIQReplayWorker(QThread): frame, self._settings.range_resolution, self._settings.velocity_resolution, + gps=self._gps, ) # Clustering + tracking From a16472480a0b8278b913b64bde1e61c1c78cf2b1 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:09:39 +0545 Subject: [PATCH 3/8] fix: playback state race condition, C-locale spinboxes, and Leaflet CDN loading - workers.py: Only emit playbackStateChanged on state transitions to prevent stale 'playing' signal from overwriting pause button text - dashboard.py: Force C locale on all QDoubleSpinBox instances so comma-decimal locales don't break numeric input; add missing 'Saturation' legend label to AGC chart - map_widget.py: Enable LocalContentCanAccessRemoteUrls and set HTTP base URL so Leaflet CDN tiles/scripts load correctly in QtWebEngine --- 9_Firmware/9_3_GUI/v7/dashboard.py | 29 ++++++++++++++++++++--------- 9_Firmware/9_3_GUI/v7/map_widget.py | 19 ++++++++++++++++--- 9_Firmware/9_3_GUI/v7/workers.py | 9 +++++++-- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index c323739..728847a 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -37,7 +37,7 @@ from PyQt6.QtWidgets import ( QTableWidget, QTableWidgetItem, QHeaderView, QPlainTextEdit, QStatusBar, QMessageBox, ) -from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QObject +from PyQt6.QtCore import Qt, QLocale, QTimer, pyqtSignal, pyqtSlot, QObject from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure @@ -72,6 +72,17 @@ logger = logging.getLogger(__name__) NUM_RANGE_BINS = 64 NUM_DOPPLER_BINS = 32 +# Force C locale (period as decimal separator) for all QDoubleSpinBox instances. +_C_LOCALE = QLocale(QLocale.Language.C) +_C_LOCALE.setNumberOptions(QLocale.NumberOption.RejectGroupSeparator) + + +def _make_dspin() -> QDoubleSpinBox: + """Create a QDoubleSpinBox with C locale (no comma decimals).""" + sb = QDoubleSpinBox() + sb.setLocale(_C_LOCALE) + return sb + # ============================================================================= # Range-Doppler Canvas (matplotlib) @@ -447,7 +458,7 @@ class RadarDashboard(QMainWindow): pb_layout.addWidget(self._pb_stop_btn) pb_layout.addWidget(QLabel("FPS:")) - self._pb_fps_spin = QDoubleSpinBox() + self._pb_fps_spin = _make_dspin() self._pb_fps_spin.setRange(0.1, 60.0) self._pb_fps_spin.setValue(10.0) self._pb_fps_spin.setSingleStep(1.0) @@ -534,25 +545,25 @@ class RadarDashboard(QMainWindow): pos_group = QGroupBox("Radar Position") pos_layout = QGridLayout(pos_group) - self._lat_spin = QDoubleSpinBox() + self._lat_spin = _make_dspin() self._lat_spin.setRange(-90, 90) self._lat_spin.setDecimals(6) self._lat_spin.setValue(self._radar_position.latitude) self._lat_spin.valueChanged.connect(self._on_position_changed) - self._lon_spin = QDoubleSpinBox() + self._lon_spin = _make_dspin() self._lon_spin.setRange(-180, 180) self._lon_spin.setDecimals(6) self._lon_spin.setValue(self._radar_position.longitude) self._lon_spin.valueChanged.connect(self._on_position_changed) - self._alt_spin = QDoubleSpinBox() + self._alt_spin = _make_dspin() self._alt_spin.setRange(0, 50000) self._alt_spin.setDecimals(1) self._alt_spin.setValue(0.0) self._alt_spin.setSuffix(" m") - self._heading_spin = QDoubleSpinBox() + self._heading_spin = _make_dspin() self._heading_spin.setRange(0, 360) self._heading_spin.setDecimals(1) self._heading_spin.setValue(0.0) @@ -575,7 +586,7 @@ class RadarDashboard(QMainWindow): cov_group = QGroupBox("Coverage") cov_layout = QGridLayout(cov_group) - self._coverage_spin = QDoubleSpinBox() + self._coverage_spin = _make_dspin() self._coverage_spin.setRange(1, 200) self._coverage_spin.setDecimals(1) self._coverage_spin.setValue(self._settings.coverage_radius / 1000) @@ -991,7 +1002,7 @@ class RadarDashboard(QMainWindow): for spine in self._agc_ax_sat.spines.values(): spine.set_color(DARK_BORDER) self._agc_sat_line, = self._agc_ax_sat.plot( - [], [], color=DARK_ERROR, linewidth=1.0) + [], [], color=DARK_ERROR, linewidth=1.0, label="Saturation") self._agc_sat_fill_artist = None self._agc_ax_sat.legend( loc="upper right", fontsize=8, @@ -1139,7 +1150,7 @@ class RadarDashboard(QMainWindow): row += 1 p_layout.addWidget(QLabel("DBSCAN eps:"), row, 0) - self._cluster_eps_spin = QDoubleSpinBox() + self._cluster_eps_spin = _make_dspin() self._cluster_eps_spin.setRange(1.0, 5000.0) self._cluster_eps_spin.setDecimals(1) self._cluster_eps_spin.setValue(self._processing_config.clustering_eps) diff --git a/9_Firmware/9_3_GUI/v7/map_widget.py b/9_Firmware/9_3_GUI/v7/map_widget.py index 08a6b04..fa0fcb1 100644 --- a/9_Firmware/9_3_GUI/v7/map_widget.py +++ b/9_Firmware/9_3_GUI/v7/map_widget.py @@ -17,7 +17,8 @@ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFrame, QComboBox, QCheckBox, QPushButton, QLabel, ) -from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot, QObject +from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot, QObject +from PyQt6.QtWebEngineCore import QWebEngineSettings from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebChannel import QWebChannel @@ -517,8 +518,20 @@ document.addEventListener('DOMContentLoaded', function() {{ # ---- load / helpers ---------------------------------------------------- def _load_map(self): - self._web_view.setHtml(self._get_map_html()) - logger.info("Leaflet map HTML loaded") + # Enable remote resource access so Leaflet CDN scripts/tiles can load. + settings = self._web_view.page().settings() + settings.setAttribute( + QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, + True, + ) + # Provide an HTTP base URL so the page has a proper origin; + # without this, setHtml() defaults to about:blank which blocks + # external resource loading in modern Chromium. + self._web_view.setHtml( + self._get_map_html(), + QUrl("http://localhost/radar_map"), + ) + logger.info("Leaflet map HTML loaded (with HTTP base URL)") def _on_map_ready(self): self._status_label.setText(f"Map ready - {len(self._targets)} targets") diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index b1e6d1d..281195d 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -292,6 +292,7 @@ class RawIQReplayWorker(QThread): def run(self): self._running = True self._frame_count = 0 + self._last_emitted_state: str | None = None logger.info("RawIQReplayWorker started") info = self._controller.info @@ -322,9 +323,13 @@ class RawIQReplayWorker(QThread): idx = self._controller.frame_index self.frameIndexChanged.emit(idx, total_frames) - # Emit playback state + # Emit playback state only on transitions (avoid race + # where a stale "playing" signal overwrites a pause) state = self._controller.state - self.playbackStateChanged.emit(state.name.lower()) + state_str = state.name.lower() + if state_str != self._last_emitted_state: + self._last_emitted_state = state_str + self.playbackStateChanged.emit(state_str) # Run host-side DSP if configured if self._host_processor is not None: From 609589349dfde89395fc22bc3a648a5e1eab95fd Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 03:19:58 +0545 Subject: [PATCH 4/8] fix: range calibration, demo/radar mutual exclusion, AGC analysis refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug #1 — Range calibration for Raw IQ Replay: - Add WaveformConfig dataclass (models.py) with FMCW waveform params (fs, BW, T_chirp, fc) and methods to compute range/velocity resolution - Add waveform parameter spinboxes to playback controls (dashboard.py) - Auto-parse waveform params from ADI phaser filename convention - Create replay-specific RadarSettings with correct calibration instead of using FPGA defaults (781.25 m/bin → 0.334 m/bin for ADI phaser) - Add 4 unit tests validating WaveformConfig math Bug #2 — Demo + radar mutual exclusion: - _start_demo() now refuses if radar is running (_running=True) - _start_radar() stops demo first if _demo_mode is active - Demo buttons disabled while radar/replay is running, re-enabled on stop Bug #3 — Refactor adi_agc_analysis.py: - Remove 60+ lines of duplicated AGC functions (signed_to_encoding, encoding_to_signed, clamp_gain, apply_gain_shift) - Import from v7.agc_sim canonical implementation - Rewrite simulate_agc() to use process_agc_frame() in a loop - Rewrite process_frame_rd() to use quantize_iq() from agc_sim --- 9_Firmware/9_3_GUI/adi_agc_analysis.py | 161 ++++++------------------ 9_Firmware/9_3_GUI/test_v7.py | 33 +++++ 9_Firmware/9_3_GUI/v7/__init__.py | 3 +- 9_Firmware/9_3_GUI/v7/dashboard.py | 163 ++++++++++++++++++++++++- 9_Firmware/9_3_GUI/v7/models.py | 41 +++++++ 5 files changed, 270 insertions(+), 131 deletions(-) diff --git a/9_Firmware/9_3_GUI/adi_agc_analysis.py b/9_Firmware/9_3_GUI/adi_agc_analysis.py index 8ebc0b9..f52505a 100644 --- a/9_Firmware/9_3_GUI/adi_agc_analysis.py +++ b/9_Firmware/9_3_GUI/adi_agc_analysis.py @@ -32,83 +32,24 @@ from pathlib import Path import matplotlib.pyplot as plt import numpy as np +from v7.agc_sim import ( + encoding_to_signed, + apply_gain_shift, + quantize_iq, + AGCConfig, + AGCState, + process_agc_frame, +) + # --------------------------------------------------------------------------- # FPGA AGC parameters (rx_gain_control.v reset defaults) # --------------------------------------------------------------------------- AGC_TARGET = 200 # host_agc_target (8-bit, default 200) -AGC_ATTACK = 1 # host_agc_attack (4-bit, default 1) -AGC_DECAY = 1 # host_agc_decay (4-bit, default 1) -AGC_HOLDOFF = 4 # host_agc_holdoff (4-bit, default 4) ADC_RAIL = 4095 # 12-bit ADC max absolute value # --------------------------------------------------------------------------- -# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed) -# --------------------------------------------------------------------------- - -def signed_to_encoding(g: int) -> int: - """Convert signed gain (-7..+7) to gain_shift[3:0] encoding. - [3]=0, [2:0]=N → amplify (left shift) by N - [3]=1, [2:0]=N → attenuate (right shift) by N - """ - if g >= 0: - return g & 0x07 - return 0x08 | ((-g) & 0x07) - - -def encoding_to_signed(enc: int) -> int: - """Convert gain_shift[3:0] encoding to signed gain.""" - if (enc & 0x08) == 0: - return enc & 0x07 - return -(enc & 0x07) - - -def clamp_gain(val: int) -> int: - """Clamp to [-7, +7] (matches RTL clamp_gain function).""" - return max(-7, min(7, val)) - - -# --------------------------------------------------------------------------- -# Apply gain shift to IQ data (matches RTL combinational logic) -# --------------------------------------------------------------------------- - -def apply_gain_shift(frame_i: np.ndarray, frame_q: np.ndarray, - gain_enc: int) -> tuple[np.ndarray, np.ndarray, int]: - """Apply gain_shift encoding to 16-bit signed IQ arrays. - - Returns (shifted_i, shifted_q, overflow_count). - Matches the RTL: left shift = amplify, right shift = attenuate, - saturate to ±32767 on overflow. - """ - direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate - amount = gain_enc & 0x07 - - if amount == 0: - return frame_i.copy(), frame_q.copy(), 0 - - if direction == 0: - # Left shift (amplify) - si = frame_i.astype(np.int64) * (1 << amount) - sq = frame_q.astype(np.int64) * (1 << amount) - else: - # Arithmetic right shift (attenuate) - si = frame_i.astype(np.int64) >> amount - sq = frame_q.astype(np.int64) >> amount - - # Count overflows (post-shift values outside 16-bit signed range) - overflow_i = (si > 32767) | (si < -32768) - overflow_q = (sq > 32767) | (sq < -32768) - overflow_count = int((overflow_i | overflow_q).sum()) - - # Saturate to ±32767 - si = np.clip(si, -32768, 32767).astype(np.int16) - sq = np.clip(sq, -32768, 32767).astype(np.int16) - - return si, sq, overflow_count - - -# --------------------------------------------------------------------------- -# Per-frame AGC simulation (bit-accurate to rx_gain_control.v) +# Per-frame AGC simulation using v7.agc_sim (bit-accurate to RTL) # --------------------------------------------------------------------------- def simulate_agc(frames: np.ndarray, agc_enabled: bool = True, @@ -126,79 +67,46 @@ def simulate_agc(frames: np.ndarray, agc_enabled: bool = True, n_frames = frames.shape[0] # Output arrays - out_gain_enc = np.zeros(n_frames, dtype=int) # gain_shift encoding [3:0] - out_gain_signed = np.zeros(n_frames, dtype=int) # signed gain for plotting - out_peak_mag = np.zeros(n_frames, dtype=int) # peak_magnitude[7:0] - out_sat_count = np.zeros(n_frames, dtype=int) # saturation_count[7:0] + out_gain_enc = np.zeros(n_frames, dtype=int) + out_gain_signed = np.zeros(n_frames, dtype=int) + out_peak_mag = np.zeros(n_frames, dtype=int) + out_sat_count = np.zeros(n_frames, dtype=int) out_sat_rate = np.zeros(n_frames, dtype=float) - out_rms_post = np.zeros(n_frames, dtype=float) # RMS after gain shift + out_rms_post = np.zeros(n_frames, dtype=float) - # AGC internal state - agc_gain = 0 # signed, -7..+7 - holdoff_counter = 0 - agc_was_enabled = False + # AGC state — managed by process_agc_frame() + state = AGCState( + gain=encoding_to_signed(initial_gain_enc), + holdoff_counter=0, + was_enabled=False, + ) for i in range(n_frames): - frame = frames[i] - # Quantize to 16-bit signed (ADC is 12-bit, sign-extended to 16) - frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16) - frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16) + frame_i, frame_q = quantize_iq(frames[i]) - # --- PRE-gain peak measurement (RTL lines 133-135, 211-213) --- - abs_i = np.abs(frame_i.astype(np.int32)) - abs_q = np.abs(frame_q.astype(np.int32)) - max_iq = np.maximum(abs_i, abs_q) - frame_peak_15bit = int(max_iq.max()) # 15-bit unsigned - peak_8bit = (frame_peak_15bit >> 7) & 0xFF # Upper 8 bits - - # --- Determine effective gain --- agc_active = agc_enabled and (i >= enable_at_frame) - # AGC enable transition (RTL lines 250-253) - if agc_active and not agc_was_enabled: - agc_gain = encoding_to_signed(initial_gain_enc) - holdoff_counter = AGC_HOLDOFF + # Build per-frame config (enable toggles at enable_at_frame) + config = AGCConfig(enabled=agc_active) - effective_enc = signed_to_encoding(agc_gain) if agc_active else initial_gain_enc - - agc_was_enabled = agc_active - - # --- Apply gain shift + count POST-gain overflow (RTL lines 114-126, 207-209) --- - shifted_i, shifted_q, frame_overflow = apply_gain_shift( - frame_i, frame_q, effective_enc) - frame_sat = min(255, frame_overflow) + result = process_agc_frame(frame_i, frame_q, config, state) # RMS of shifted signal rms = float(np.sqrt(np.mean( - shifted_i.astype(np.float64)**2 + shifted_q.astype(np.float64)**2))) + result.shifted_i.astype(np.float64)**2 + + result.shifted_q.astype(np.float64)**2))) total_samples = frame_i.size + frame_q.size - sat_rate = frame_overflow / total_samples if total_samples > 0 else 0.0 + sat_rate = result.overflow_raw / total_samples if total_samples > 0 else 0.0 - # --- Record outputs --- - out_gain_enc[i] = effective_enc - out_gain_signed[i] = agc_gain if agc_active else encoding_to_signed(initial_gain_enc) - out_peak_mag[i] = peak_8bit - out_sat_count[i] = frame_sat + # Record outputs + out_gain_enc[i] = result.gain_enc + out_gain_signed[i] = result.gain_signed + out_peak_mag[i] = result.peak_mag_8bit + out_sat_count[i] = result.saturation_count out_sat_rate[i] = sat_rate out_rms_post[i] = rms - # --- AGC update at frame boundary (RTL lines 226-246) --- - if agc_active: - if frame_sat > 0: - # Clipping: reduce gain immediately (attack) - agc_gain = clamp_gain(agc_gain - AGC_ATTACK) - holdoff_counter = AGC_HOLDOFF - elif peak_8bit < AGC_TARGET: - # Signal too weak: increase gain after holdoff - if holdoff_counter == 0: - agc_gain = clamp_gain(agc_gain + AGC_DECAY) - else: - holdoff_counter -= 1 - else: - # Good range (peak >= target, no sat): hold, reset holdoff - holdoff_counter = AGC_HOLDOFF - return { "gain_enc": out_gain_enc, "gain_signed": out_gain_signed, @@ -217,8 +125,7 @@ def process_frame_rd(frame: np.ndarray, gain_enc: int, n_range: int = 64, n_doppler: int = 32) -> np.ndarray: """Range-Doppler magnitude for one frame with gain applied.""" - frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16) - frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16) + frame_i, frame_q = quantize_iq(frame) si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc) iq = si.astype(np.float64) + 1j * sq.astype(np.float64) diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index bb54e48..7994622 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -69,6 +69,39 @@ class TestRadarSettings(unittest.TestCase): self.assertEqual(s.max_distance, 50000) +class TestWaveformConfig(unittest.TestCase): + """WaveformConfig — range/velocity resolution from waveform params.""" + + def test_adi_phaser_range_resolution(self): + """ADI CN0566 defaults: 4MSPS, 500MHz BW, 300µs chirp, 1079 samples.""" + wf = _models().WaveformConfig() # ADI phaser defaults + r_res = wf.range_resolution(n_samples=1079) + # Expected: c * fs / (2 * N * slope) = 3e8 * 4e6 / (2 * 1079 * 1.667e12) + # ≈ 0.334 m/bin + self.assertAlmostEqual(r_res, 0.334, places=2) + + def test_adi_phaser_velocity_resolution(self): + """ADI phaser: 256 chirps, 1079 samples at 4 MSPS.""" + wf = _models().WaveformConfig() + v_res = wf.velocity_resolution(n_samples=1079, n_chirps=256) + # λ * fs / (2 * N * M) = 0.03 * 4e6 / (2 * 1079 * 256) ≈ 0.217 m/s/bin + self.assertAlmostEqual(v_res, 0.217, places=2) + + def test_max_range(self): + wf = _models().WaveformConfig() + max_r = wf.max_range(n_range_bins=64, n_samples=1079) + # 0.334 * 64 ≈ 21.4 m + self.assertAlmostEqual(max_r, 21.4, places=0) + + def test_plfm_defaults_differ(self): + """PLFM FPGA defaults (781.25 m/bin) must NOT equal ADI phaser.""" + default_settings = _models().RadarSettings() + wf = _models().WaveformConfig() + r_res = wf.range_resolution(n_samples=1079) + self.assertNotAlmostEqual(default_settings.range_resolution, r_res, + places=0) # 781 vs 0.33 + + class TestGPSData(unittest.TestCase): def test_to_dict(self): g = _models().GPSData(latitude=41.9, longitude=12.5, diff --git a/9_Firmware/9_3_GUI/v7/__init__.py b/9_Firmware/9_3_GUI/v7/__init__.py index 175da91..ef053fe 100644 --- a/9_Firmware/9_3_GUI/v7/__init__.py +++ b/9_Firmware/9_3_GUI/v7/__init__.py @@ -11,6 +11,7 @@ top-level imports: from .models import ( RadarTarget, RadarSettings, + WaveformConfig, GPSData, ProcessingConfig, TileServer, @@ -66,7 +67,7 @@ except ImportError: # PyQt6 not installed (e.g. CI headless runner) __all__ = [ # noqa: RUF022 # models - "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer", + "RadarTarget", "RadarSettings", "WaveformConfig", "GPSData", "ProcessingConfig", "TileServer", "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER", "DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER", "DARK_TREEVIEW", "DARK_TREEVIEW_ALT", diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index 728847a..b202d17 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -23,6 +23,7 @@ commands sent over FT2232H. """ import time +import re import logging from collections import deque from pathlib import Path @@ -43,7 +44,7 @@ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure from .models import ( - RadarTarget, RadarSettings, GPSData, ProcessingConfig, + RadarTarget, RadarSettings, WaveformConfig, GPSData, ProcessingConfig, DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, DARK_TREEVIEW, DARK_TREEVIEW_ALT, @@ -84,6 +85,41 @@ def _make_dspin() -> QDoubleSpinBox: return sb +def _parse_waveform_from_filename(name: str) -> WaveformConfig | None: + """Try to extract waveform params from ADI phaser filename convention. + + Expected pattern fragments (order-independent): + ``MSPS`` or ``MSps`` → sample rate in MHz + ``M`` (followed by _ or end) → bandwidth in MHz + ``u`` (followed by _ or end) → chirp duration in µs + + Returns a WaveformConfig with parsed values (defaults for un-parsed), + or None if nothing recognisable was found. + """ + cfg = WaveformConfig() # ADI phaser defaults + found = False + + # Sample rate: "4MSPS" or "4MSps" + m = re.search(r"(\d+)M[Ss][Pp][Ss]", name) + if m: + cfg.sample_rate_hz = float(m.group(1)) * 1e6 + found = True + + # Bandwidth: "500M" (must NOT be followed by S for MSPS) + m = re.search(r"(\d+)M(?![Ss])", name) + if m: + cfg.bandwidth_hz = float(m.group(1)) * 1e6 + found = True + + # Chirp duration: "300u" + m = re.search(r"(\d+)u", name) + if m: + cfg.chirp_duration_s = float(m.group(1)) * 1e-6 + found = True + + return cfg if found else None + + # ============================================================================= # Range-Doppler Canvas (matplotlib) # ============================================================================= @@ -483,6 +519,55 @@ class RadarDashboard(QMainWindow): self._playback_frame.setVisible(False) ctrl_layout.addWidget(self._playback_frame, 2, 0, 1, 10) + # -- Waveform config row (Raw IQ replay only) ----------------------- + self._waveform_frame = QFrame() + self._waveform_frame.setStyleSheet( + f"background-color: {DARK_ACCENT}; border-radius: 4px;") + wf_layout = QHBoxLayout(self._waveform_frame) + wf_layout.setContentsMargins(8, 4, 8, 4) + + wf_layout.addWidget(QLabel("Waveform:")) + + wf_layout.addWidget(QLabel("fs (MHz):")) + self._wf_fs_spin = _make_dspin() + self._wf_fs_spin.setRange(0.1, 100.0) + self._wf_fs_spin.setValue(4.0) + self._wf_fs_spin.setDecimals(2) + self._wf_fs_spin.setToolTip("ADC sample rate in MHz") + wf_layout.addWidget(self._wf_fs_spin) + + wf_layout.addWidget(QLabel("BW (MHz):")) + self._wf_bw_spin = _make_dspin() + self._wf_bw_spin.setRange(1.0, 5000.0) + self._wf_bw_spin.setValue(500.0) + self._wf_bw_spin.setDecimals(1) + self._wf_bw_spin.setToolTip("Chirp bandwidth in MHz") + wf_layout.addWidget(self._wf_bw_spin) + + wf_layout.addWidget(QLabel("T (us):")) + self._wf_chirp_spin = _make_dspin() + self._wf_chirp_spin.setRange(1.0, 10000.0) + self._wf_chirp_spin.setValue(300.0) + self._wf_chirp_spin.setDecimals(1) + self._wf_chirp_spin.setToolTip("Chirp duration in microseconds") + wf_layout.addWidget(self._wf_chirp_spin) + + wf_layout.addWidget(QLabel("fc (GHz):")) + self._wf_fc_spin = _make_dspin() + self._wf_fc_spin.setRange(0.1, 100.0) + self._wf_fc_spin.setValue(10.0) + self._wf_fc_spin.setDecimals(2) + self._wf_fc_spin.setToolTip("Carrier frequency in GHz") + wf_layout.addWidget(self._wf_fc_spin) + + self._wf_res_label = QLabel("") + self._wf_res_label.setStyleSheet(f"color: {DARK_INFO}; font-size: 10px;") + wf_layout.addWidget(self._wf_res_label) + + wf_layout.addStretch() + self._waveform_frame.setVisible(False) + ctrl_layout.addWidget(self._waveform_frame, 3, 0, 1, 10) + layout.addWidget(ctrl) # ---- Display area (range-doppler + targets table) ------------------ @@ -1294,6 +1379,10 @@ class RadarDashboard(QMainWindow): def _start_radar(self): """Start radar data acquisition using production protocol.""" + # Mutual exclusion: stop demo if running + if self._demo_mode: + self._stop_demo() + try: mode = self._mode_combo.currentText() @@ -1362,6 +1451,8 @@ class RadarDashboard(QMainWindow): self._start_btn.setEnabled(False) self._stop_btn.setEnabled(True) self._mode_combo.setEnabled(False) + self._demo_btn_main.setEnabled(False) + self._demo_btn_map.setEnabled(False) self._status_label_main.setText(f"Status: Running ({mode})") self._sb_status.setText(f"Running ({mode})") self._sb_mode.setText(mode) @@ -1413,7 +1504,10 @@ class RadarDashboard(QMainWindow): self._start_btn.setEnabled(True) self._stop_btn.setEnabled(False) self._mode_combo.setEnabled(True) + self._demo_btn_main.setEnabled(True) + self._demo_btn_map.setEnabled(True) self._playback_frame.setVisible(False) + self._waveform_frame.setVisible(False) self._pb_play_btn.setText("Play") self._pb_frame_label.setText("Frame: 0 / 0") self._pb_file_label.setText("") @@ -1441,9 +1535,44 @@ class RadarDashboard(QMainWindow): self._replay_controller = RawIQReplayController() info = self._replay_controller.load_file(npy_path) + # -- Waveform calibration: try to parse from filename ----------- + parsed_wf = _parse_waveform_from_filename(Path(npy_path).name) + if parsed_wf is not None: + self._wf_fs_spin.setValue(parsed_wf.sample_rate_hz / 1e6) + self._wf_bw_spin.setValue(parsed_wf.bandwidth_hz / 1e6) + self._wf_chirp_spin.setValue(parsed_wf.chirp_duration_s / 1e-6) + self._wf_fc_spin.setValue(parsed_wf.center_freq_hz / 1e9) + logger.info("Waveform params parsed from filename: %s", parsed_wf) + + # Build waveform config from (possibly updated) spinboxes + wfc = self._waveform_config_from_ui() + range_res = wfc.range_resolution(info.n_samples) + vel_res = wfc.velocity_resolution(info.n_samples, info.n_chirps) + n_range_out = min(64, info.n_samples) + max_range = range_res * n_range_out + + # Create replay-specific RadarSettings with correct calibration + replay_settings = RadarSettings( + system_frequency=wfc.center_freq_hz, + range_resolution=range_res, + velocity_resolution=vel_res, + max_distance=max_range, + map_size=max_range * 1.2, + coverage_radius=max_range * 1.2, + ) + logger.info( + "Replay calibration: range_res=%.4f m/bin, vel_res=%.4f m/s/bin, " + "max_range=%.1f m", + range_res, vel_res, max_range, + ) + + # Update coverage/map spinboxes to match replay scale + self._coverage_spin.setValue(replay_settings.coverage_radius / 1000) + self._update_waveform_res_label(info.n_samples, info.n_chirps) + # Create frame processor self._iq_processor = RawIQFrameProcessor( - n_range_out=min(64, info.n_samples), + n_range_out=n_range_out, n_doppler_out=min(32, info.n_chirps), ) @@ -1493,7 +1622,7 @@ class RadarDashboard(QMainWindow): controller=self._replay_controller, processor=self._iq_processor, host_processor=self._processor, - settings=self._settings, + settings=replay_settings, gps_data_ref=self._radar_position, ) self._replay_worker.frameReady.connect(self._on_frame_ready) @@ -1518,7 +1647,10 @@ class RadarDashboard(QMainWindow): self._start_btn.setEnabled(False) self._stop_btn.setEnabled(True) self._mode_combo.setEnabled(False) + self._demo_btn_main.setEnabled(False) + self._demo_btn_map.setEnabled(False) self._playback_frame.setVisible(True) + self._waveform_frame.setVisible(True) self._pb_frame_label.setText(f"Frame: 0 / {info.n_frames}") self._pb_file_label.setText( f"{Path(npy_path).name} " @@ -1568,6 +1700,27 @@ class RadarDashboard(QMainWindow): if self._replay_controller is not None: self._replay_controller.set_loop(checked) + def _waveform_config_from_ui(self) -> WaveformConfig: + """Build a WaveformConfig from the waveform spinboxes.""" + return WaveformConfig( + sample_rate_hz=self._wf_fs_spin.value() * 1e6, + bandwidth_hz=self._wf_bw_spin.value() * 1e6, + chirp_duration_s=self._wf_chirp_spin.value() * 1e-6, + center_freq_hz=self._wf_fc_spin.value() * 1e9, + ) + + def _update_waveform_res_label(self, n_samples: int, n_chirps: int) -> None: + """Update the waveform resolution info label.""" + wfc = self._waveform_config_from_ui() + r_res = wfc.range_resolution(n_samples) + v_res = wfc.velocity_resolution(n_samples, n_chirps) + n_r = min(64, n_samples) + max_r = r_res * n_r + self._wf_res_label.setText( + f"Range: {r_res:.3f} m/bin | Vel: {v_res:.3f} m/s/bin | " + f"Max range: {max_r:.1f} m ({n_r} bins)" + ) + @pyqtSlot(str) def _on_playback_state_changed(self, state_str: str): if state_str == "playing": @@ -1591,6 +1744,10 @@ class RadarDashboard(QMainWindow): def _start_demo(self): if self._simulator: return + # Mutual exclusion: do not start demo while radar/replay is running + if self._running: + logger.warning("Cannot start demo while radar is running") + return self._simulator = TargetSimulator(self._radar_position, self) self._simulator.targetsUpdated.connect(self._on_demo_targets) self._simulator.start(500) diff --git a/9_Firmware/9_3_GUI/v7/models.py b/9_Firmware/9_3_GUI/v7/models.py index a5eb40e..9695b9f 100644 --- a/9_Firmware/9_3_GUI/v7/models.py +++ b/9_Firmware/9_3_GUI/v7/models.py @@ -96,6 +96,47 @@ class RadarTarget: return asdict(self) +@dataclass +class WaveformConfig: + """FMCW waveform parameters for bin-to-physical-unit conversion. + + Defaults are for the ADI CN0566 phaser (10 GHz, 500 MHz BW, 300 µs chirp, + 4 MSPS ADC). For the PLFM FPGA waveform the values differ — but the FPGA + pipeline hardcodes its own bin widths, so this config is only used for + Raw IQ Replay (host-side FFT processing). + """ + sample_rate_hz: float = 4e6 # ADC sample rate (Hz) + bandwidth_hz: float = 500e6 # Chirp bandwidth (Hz) + chirp_duration_s: float = 300e-6 # Chirp sweep time (s) + center_freq_hz: float = 10e9 # Carrier frequency (Hz) + + # --- derived quantities (need n_samples, n_chirps from file) --- + + def range_resolution(self, n_samples: int) -> float: + """Metres per range bin for an N-point range FFT. + + range_per_bin = c · fs / (2 · N · slope) + where slope = BW / T_chirp. + """ + c = 299_792_458.0 + slope = self.bandwidth_hz / self.chirp_duration_s + return c * self.sample_rate_hz / (2.0 * n_samples * slope) + + def velocity_resolution(self, n_samples: int, n_chirps: int) -> float: + """m/s per Doppler bin for an M-chirp Doppler FFT. + + vel_per_bin = λ · fs / (2 · N · M) + where λ = c / fc, N = n_samples (PRI = N/fs), M = n_chirps. + """ + c = 299_792_458.0 + wavelength = c / self.center_freq_hz + return wavelength * self.sample_rate_hz / (2.0 * n_samples * n_chirps) + + def max_range(self, n_range_bins: int, n_samples: int) -> float: + """Maximum unambiguous range for the given bin count.""" + return self.range_resolution(n_samples) * n_range_bins + + @dataclass class RadarSettings: """Radar system display/map configuration. From 2387f7f29fb5595f7ed5561930d333259b66f18d Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:57:25 +0545 Subject: [PATCH 5/8] refactor: revert replay code, preserve non-replay fixes Revert raw IQ replay (commits 2cb56e8..6095893) to prepare for unified SoftwareFPGA replay architecture. Preserved: C-locale spinboxes, AGC chart label, demo/radar mutual exclusion. Delete v7/raw_iq_replay.py Restore workers.py, processing.py, models.py, __init__.py, test_v7.py --- 9_Firmware/9_3_GUI/test_v7.py | 33 -- 9_Firmware/9_3_GUI/v7/__init__.py | 3 +- 9_Firmware/9_3_GUI/v7/dashboard.py | 488 +------------------------ 9_Firmware/9_3_GUI/v7/models.py | 41 --- 9_Firmware/9_3_GUI/v7/processing.py | 377 +------------------ 9_Firmware/9_3_GUI/v7/raw_iq_replay.py | 264 ------------- 9_Firmware/9_3_GUI/v7/workers.py | 221 +++-------- 7 files changed, 75 insertions(+), 1352 deletions(-) delete mode 100644 9_Firmware/9_3_GUI/v7/raw_iq_replay.py diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index 7994622..bb54e48 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -69,39 +69,6 @@ class TestRadarSettings(unittest.TestCase): self.assertEqual(s.max_distance, 50000) -class TestWaveformConfig(unittest.TestCase): - """WaveformConfig — range/velocity resolution from waveform params.""" - - def test_adi_phaser_range_resolution(self): - """ADI CN0566 defaults: 4MSPS, 500MHz BW, 300µs chirp, 1079 samples.""" - wf = _models().WaveformConfig() # ADI phaser defaults - r_res = wf.range_resolution(n_samples=1079) - # Expected: c * fs / (2 * N * slope) = 3e8 * 4e6 / (2 * 1079 * 1.667e12) - # ≈ 0.334 m/bin - self.assertAlmostEqual(r_res, 0.334, places=2) - - def test_adi_phaser_velocity_resolution(self): - """ADI phaser: 256 chirps, 1079 samples at 4 MSPS.""" - wf = _models().WaveformConfig() - v_res = wf.velocity_resolution(n_samples=1079, n_chirps=256) - # λ * fs / (2 * N * M) = 0.03 * 4e6 / (2 * 1079 * 256) ≈ 0.217 m/s/bin - self.assertAlmostEqual(v_res, 0.217, places=2) - - def test_max_range(self): - wf = _models().WaveformConfig() - max_r = wf.max_range(n_range_bins=64, n_samples=1079) - # 0.334 * 64 ≈ 21.4 m - self.assertAlmostEqual(max_r, 21.4, places=0) - - def test_plfm_defaults_differ(self): - """PLFM FPGA defaults (781.25 m/bin) must NOT equal ADI phaser.""" - default_settings = _models().RadarSettings() - wf = _models().WaveformConfig() - r_res = wf.range_resolution(n_samples=1079) - self.assertNotAlmostEqual(default_settings.range_resolution, r_res, - places=0) # 781 vs 0.33 - - class TestGPSData(unittest.TestCase): def test_to_dict(self): g = _models().GPSData(latitude=41.9, longitude=12.5, diff --git a/9_Firmware/9_3_GUI/v7/__init__.py b/9_Firmware/9_3_GUI/v7/__init__.py index ef053fe..175da91 100644 --- a/9_Firmware/9_3_GUI/v7/__init__.py +++ b/9_Firmware/9_3_GUI/v7/__init__.py @@ -11,7 +11,6 @@ top-level imports: from .models import ( RadarTarget, RadarSettings, - WaveformConfig, GPSData, ProcessingConfig, TileServer, @@ -67,7 +66,7 @@ except ImportError: # PyQt6 not installed (e.g. CI headless runner) __all__ = [ # noqa: RUF022 # models - "RadarTarget", "RadarSettings", "WaveformConfig", "GPSData", "ProcessingConfig", "TileServer", + "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer", "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER", "DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER", "DARK_TREEVIEW", "DARK_TREEVIEW_ALT", diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index b202d17..1b3b2a8 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -23,10 +23,8 @@ commands sent over FT2232H. """ import time -import re import logging from collections import deque -from pathlib import Path import numpy as np @@ -44,7 +42,7 @@ from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure from .models import ( - RadarTarget, RadarSettings, WaveformConfig, GPSData, ProcessingConfig, + RadarTarget, RadarSettings, GPSData, ProcessingConfig, DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, DARK_TREEVIEW, DARK_TREEVIEW_ALT, @@ -61,10 +59,8 @@ from .hardware import ( DataRecorder, STM32USBInterface, ) -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 .processing import RadarProcessor, USBPacketParser +from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator from .map_widget import RadarMapWidget logger = logging.getLogger(__name__) @@ -85,69 +81,24 @@ def _make_dspin() -> QDoubleSpinBox: return sb -def _parse_waveform_from_filename(name: str) -> WaveformConfig | None: - """Try to extract waveform params from ADI phaser filename convention. - - Expected pattern fragments (order-independent): - ``MSPS`` or ``MSps`` → sample rate in MHz - ``M`` (followed by _ or end) → bandwidth in MHz - ``u`` (followed by _ or end) → chirp duration in µs - - Returns a WaveformConfig with parsed values (defaults for un-parsed), - or None if nothing recognisable was found. - """ - cfg = WaveformConfig() # ADI phaser defaults - found = False - - # Sample rate: "4MSPS" or "4MSps" - m = re.search(r"(\d+)M[Ss][Pp][Ss]", name) - if m: - cfg.sample_rate_hz = float(m.group(1)) * 1e6 - found = True - - # Bandwidth: "500M" (must NOT be followed by S for MSPS) - m = re.search(r"(\d+)M(?![Ss])", name) - if m: - cfg.bandwidth_hz = float(m.group(1)) * 1e6 - found = True - - # Chirp duration: "300u" - m = re.search(r"(\d+)u", name) - if m: - cfg.chirp_duration_s = float(m.group(1)) * 1e-6 - found = True - - return cfg if found else None - - # ============================================================================= # Range-Doppler Canvas (matplotlib) # ============================================================================= class RangeDopplerCanvas(FigureCanvasQTAgg): - """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). - """ + """Matplotlib canvas showing the 64x32 Range-Doppler map with dark theme.""" def __init__(self, _parent=None): fig = Figure(figsize=(10, 6), facecolor=DARK_BG) self.ax = fig.add_subplot(111, facecolor=DARK_ACCENT) - # 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._data = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS)) self.im = self.ax.imshow( self._data, aspect="auto", cmap="hot", - extent=[0, self._n_doppler, 0, self._n_range], origin="lower", + extent=[0, NUM_DOPPLER_BINS, 0, NUM_RANGE_BINS], origin="lower", ) - self.ax.set_title( - f"Range-Doppler Map ({self._n_range}x{self._n_doppler})", - color=DARK_FG, - ) + self.ax.set_title("Range-Doppler Map (64x32)", 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) @@ -158,20 +109,7 @@ class RangeDopplerCanvas(FigureCanvasQTAgg): super().__init__(fig) def update_map(self, magnitude: np.ndarray, _detections: np.ndarray = None): - """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) - + """Update the heatmap with new magnitude data.""" display = np.log10(magnitude + 1) self.im.set_data(display) self.im.set_clim(vmin=display.min(), vmax=max(display.max(), 0.1)) @@ -215,15 +153,9 @@ 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 - self._replay_status_override: str | None = None # "playing"/"paused" self._start_time = time.time() self._current_frame: RadarFrame | None = None self._last_status: StatusResponse | None = None @@ -420,8 +352,7 @@ 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)", "Raw IQ Replay (.npy)"]) + self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay (.npy)"]) self._mode_combo.setCurrentIndex(0) ctrl_layout.addWidget(self._mode_combo, 0, 1) @@ -470,104 +401,6 @@ 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 = _make_dspin() - 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) - - # -- Waveform config row (Raw IQ replay only) ----------------------- - self._waveform_frame = QFrame() - self._waveform_frame.setStyleSheet( - f"background-color: {DARK_ACCENT}; border-radius: 4px;") - wf_layout = QHBoxLayout(self._waveform_frame) - wf_layout.setContentsMargins(8, 4, 8, 4) - - wf_layout.addWidget(QLabel("Waveform:")) - - wf_layout.addWidget(QLabel("fs (MHz):")) - self._wf_fs_spin = _make_dspin() - self._wf_fs_spin.setRange(0.1, 100.0) - self._wf_fs_spin.setValue(4.0) - self._wf_fs_spin.setDecimals(2) - self._wf_fs_spin.setToolTip("ADC sample rate in MHz") - wf_layout.addWidget(self._wf_fs_spin) - - wf_layout.addWidget(QLabel("BW (MHz):")) - self._wf_bw_spin = _make_dspin() - self._wf_bw_spin.setRange(1.0, 5000.0) - self._wf_bw_spin.setValue(500.0) - self._wf_bw_spin.setDecimals(1) - self._wf_bw_spin.setToolTip("Chirp bandwidth in MHz") - wf_layout.addWidget(self._wf_bw_spin) - - wf_layout.addWidget(QLabel("T (us):")) - self._wf_chirp_spin = _make_dspin() - self._wf_chirp_spin.setRange(1.0, 10000.0) - self._wf_chirp_spin.setValue(300.0) - self._wf_chirp_spin.setDecimals(1) - self._wf_chirp_spin.setToolTip("Chirp duration in microseconds") - wf_layout.addWidget(self._wf_chirp_spin) - - wf_layout.addWidget(QLabel("fc (GHz):")) - self._wf_fc_spin = _make_dspin() - self._wf_fc_spin.setRange(0.1, 100.0) - self._wf_fc_spin.setValue(10.0) - self._wf_fc_spin.setDecimals(2) - self._wf_fc_spin.setToolTip("Carrier frequency in GHz") - wf_layout.addWidget(self._wf_fc_spin) - - self._wf_res_label = QLabel("") - self._wf_res_label.setStyleSheet(f"color: {DARK_INFO}; font-size: 10px;") - wf_layout.addWidget(self._wf_res_label) - - wf_layout.addStretch() - self._waveform_frame.setVisible(False) - ctrl_layout.addWidget(self._waveform_frame, 3, 0, 1, 10) - layout.addWidget(ctrl) # ---- Display area (range-doppler + targets table) ------------------ @@ -648,22 +481,12 @@ class RadarDashboard(QMainWindow): self._alt_spin.setValue(0.0) self._alt_spin.setSuffix(" m") - self._heading_spin = _make_dspin() - self._heading_spin.setRange(0, 360) - self._heading_spin.setDecimals(1) - self._heading_spin.setValue(0.0) - self._heading_spin.setSuffix("\u00b0") - self._heading_spin.setWrapping(True) - self._heading_spin.valueChanged.connect(self._on_position_changed) - pos_layout.addWidget(QLabel("Latitude:"), 0, 0) pos_layout.addWidget(self._lat_spin, 0, 1) pos_layout.addWidget(QLabel("Longitude:"), 1, 0) pos_layout.addWidget(self._lon_spin, 1, 1) pos_layout.addWidget(QLabel("Altitude:"), 2, 0) pos_layout.addWidget(self._alt_spin, 2, 1) - pos_layout.addWidget(QLabel("Heading:"), 3, 0) - pos_layout.addWidget(self._heading_spin, 3, 1) sb_layout.addWidget(pos_group) @@ -1386,10 +1209,6 @@ 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(): @@ -1429,7 +1248,6 @@ class RadarDashboard(QMainWindow): self._radar_worker.targetsUpdated.connect(self._on_radar_targets) self._radar_worker.statsUpdated.connect(self._on_radar_stats) self._radar_worker.errorOccurred.connect(self._on_worker_error) - self._radar_worker.finished.connect(self._on_worker_finished) self._radar_worker.start() # Optionally start GPS worker @@ -1464,32 +1282,12 @@ class RadarDashboard(QMainWindow): def _stop_radar(self): self._running = False - self._replay_status_override = None - - # Stop demo simulator if active (prevents cross-mode interference) - if self._simulator: - self._simulator.stop() - self._simulator = None - self._demo_mode = False - self._demo_btn_main.setText("Start Demo") - self._demo_btn_map.setText("Start Demo") - self._demo_btn_map.setChecked(False) if self._radar_worker: self._radar_worker.stop() 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) @@ -1506,237 +1304,11 @@ class RadarDashboard(QMainWindow): self._mode_combo.setEnabled(True) self._demo_btn_main.setEnabled(True) self._demo_btn_map.setEnabled(True) - self._playback_frame.setVisible(False) - self._waveform_frame.setVisible(False) - self._pb_play_btn.setText("Play") - self._pb_frame_label.setText("Frame: 0 / 0") - self._pb_file_label.setText("") 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) - - # -- Waveform calibration: try to parse from filename ----------- - parsed_wf = _parse_waveform_from_filename(Path(npy_path).name) - if parsed_wf is not None: - self._wf_fs_spin.setValue(parsed_wf.sample_rate_hz / 1e6) - self._wf_bw_spin.setValue(parsed_wf.bandwidth_hz / 1e6) - self._wf_chirp_spin.setValue(parsed_wf.chirp_duration_s / 1e-6) - self._wf_fc_spin.setValue(parsed_wf.center_freq_hz / 1e9) - logger.info("Waveform params parsed from filename: %s", parsed_wf) - - # Build waveform config from (possibly updated) spinboxes - wfc = self._waveform_config_from_ui() - range_res = wfc.range_resolution(info.n_samples) - vel_res = wfc.velocity_resolution(info.n_samples, info.n_chirps) - n_range_out = min(64, info.n_samples) - max_range = range_res * n_range_out - - # Create replay-specific RadarSettings with correct calibration - replay_settings = RadarSettings( - system_frequency=wfc.center_freq_hz, - range_resolution=range_res, - velocity_resolution=vel_res, - max_distance=max_range, - map_size=max_range * 1.2, - coverage_radius=max_range * 1.2, - ) - logger.info( - "Replay calibration: range_res=%.4f m/bin, vel_res=%.4f m/s/bin, " - "max_range=%.1f m", - range_res, vel_res, max_range, - ) - - # Update coverage/map spinboxes to match replay scale - self._coverage_spin.setValue(replay_settings.coverage_radius / 1000) - self._update_waveform_res_label(info.n_samples, info.n_chirps) - - # Create frame processor - self._iq_processor = RawIQFrameProcessor( - n_range_out=n_range_out, - 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=replay_settings, - gps_data_ref=self._radar_position, - ) - 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.finished.connect(self._on_worker_finished) - 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._replay_status_override = "paused" - 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._demo_btn_main.setEnabled(False) - self._demo_btn_map.setEnabled(False) - self._playback_frame.setVisible(True) - self._waveform_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: - # Clean up any partially-created objects - if self._replay_worker is not None: - self._replay_worker.stop() - self._replay_worker.wait(1000) - self._replay_worker = None - self._replay_controller = None - self._iq_processor = None - 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) - - def _waveform_config_from_ui(self) -> WaveformConfig: - """Build a WaveformConfig from the waveform spinboxes.""" - return WaveformConfig( - sample_rate_hz=self._wf_fs_spin.value() * 1e6, - bandwidth_hz=self._wf_bw_spin.value() * 1e6, - chirp_duration_s=self._wf_chirp_spin.value() * 1e-6, - center_freq_hz=self._wf_fc_spin.value() * 1e9, - ) - - def _update_waveform_res_label(self, n_samples: int, n_chirps: int) -> None: - """Update the waveform resolution info label.""" - wfc = self._waveform_config_from_ui() - r_res = wfc.range_resolution(n_samples) - v_res = wfc.velocity_resolution(n_samples, n_chirps) - n_r = min(64, n_samples) - max_r = r_res * n_r - self._wf_res_label.setText( - f"Range: {r_res:.3f} m/bin | Vel: {v_res:.3f} m/s/bin | " - f"Max range: {max_r:.1f} m ({n_r} bins)" - ) - - @pyqtSlot(str) - def _on_playback_state_changed(self, state_str: str): - if state_str == "playing": - self._pb_play_btn.setText("Pause") - self._replay_status_override = "playing" - elif state_str == "paused": - self._pb_play_btn.setText("Play") - self._replay_status_override = "paused" - elif state_str == "stopped": - self._replay_status_override = None - self._stop_radar() - - @pyqtSlot(int, int) - def _on_frame_index_changed(self, current: int, total: int): - self._pb_frame_label.setText(f"Frame: {current} / {total}") - # ===================================================================== # Demo mode # ===================================================================== @@ -1752,9 +1324,8 @@ class RadarDashboard(QMainWindow): self._simulator.targetsUpdated.connect(self._on_demo_targets) self._simulator.start(500) self._demo_mode = True - if not self._running: - self._sb_mode.setText("Demo Mode") - self._sb_status.setText("Demo mode active") + self._sb_mode.setText("Demo Mode") + self._sb_status.setText("Demo mode active") self._demo_btn_main.setText("Stop Demo") self._demo_btn_map.setText("Stop Demo") self._demo_btn_map.setChecked(True) @@ -1767,18 +1338,12 @@ 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: - # Use mode combo text for Mock vs Live distinction - mode = self._mode_combo.currentText() - if "Mock" not in mode and "Live" not in mode: - mode = "Live" + mode = "Live" self._sb_mode.setText(mode) - if not self._running: - self._sb_status.setText("Demo stopped") + self._sb_status.setText("Demo stopped") self._demo_btn_main.setText("Start Demo") self._demo_btn_map.setText("Start Demo") self._demo_btn_map.setChecked(False) @@ -1830,12 +1395,6 @@ class RadarDashboard(QMainWindow): def _on_worker_error(self, msg: str): logger.error(f"Worker error: {msg}") - def _on_worker_finished(self): - """Handle unexpected worker thread exit — recover UI to stopped state.""" - if self._running: - logger.warning("Worker thread exited unexpectedly, resetting UI") - self._stop_radar() - @pyqtSlot(object) def _on_gps_received(self, gps: GPSData): self._gps_packet_count += 1 @@ -2016,7 +1575,6 @@ class RadarDashboard(QMainWindow): self._radar_position.latitude = self._lat_spin.value() self._radar_position.longitude = self._lon_spin.value() self._radar_position.altitude = self._alt_spin.value() - self._radar_position.heading = self._heading_spin.value() self._map_widget.set_radar_position(self._radar_position) if self._simulator: self._simulator.set_radar_position(self._radar_position) @@ -2091,17 +1649,10 @@ class RadarDashboard(QMainWindow): if self._running: det = (self._current_frame.detection_count if self._current_frame else 0) - # Preserve replay-specific status (paused/playing) - if self._replay_status_override == "paused": - self._status_label_main.setText( - f"Status: Raw IQ Replay (paused) \u2014 " - f"Frames: {self._frame_count} \u2014 Detections: {det}" - ) - else: - self._status_label_main.setText( - f"Status: Running \u2014 Frames: {self._frame_count} " - f"\u2014 Detections: {det}" - ) + self._status_label_main.setText( + f"Status: Running \u2014 Frames: {self._frame_count} " + f"\u2014 Detections: {det}" + ) # Diagnostics values self._update_diagnostics() @@ -2186,11 +1737,6 @@ 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) diff --git a/9_Firmware/9_3_GUI/v7/models.py b/9_Firmware/9_3_GUI/v7/models.py index 9695b9f..a5eb40e 100644 --- a/9_Firmware/9_3_GUI/v7/models.py +++ b/9_Firmware/9_3_GUI/v7/models.py @@ -96,47 +96,6 @@ class RadarTarget: return asdict(self) -@dataclass -class WaveformConfig: - """FMCW waveform parameters for bin-to-physical-unit conversion. - - Defaults are for the ADI CN0566 phaser (10 GHz, 500 MHz BW, 300 µs chirp, - 4 MSPS ADC). For the PLFM FPGA waveform the values differ — but the FPGA - pipeline hardcodes its own bin widths, so this config is only used for - Raw IQ Replay (host-side FFT processing). - """ - sample_rate_hz: float = 4e6 # ADC sample rate (Hz) - bandwidth_hz: float = 500e6 # Chirp bandwidth (Hz) - chirp_duration_s: float = 300e-6 # Chirp sweep time (s) - center_freq_hz: float = 10e9 # Carrier frequency (Hz) - - # --- derived quantities (need n_samples, n_chirps from file) --- - - def range_resolution(self, n_samples: int) -> float: - """Metres per range bin for an N-point range FFT. - - range_per_bin = c · fs / (2 · N · slope) - where slope = BW / T_chirp. - """ - c = 299_792_458.0 - slope = self.bandwidth_hz / self.chirp_duration_s - return c * self.sample_rate_hz / (2.0 * n_samples * slope) - - def velocity_resolution(self, n_samples: int, n_chirps: int) -> float: - """m/s per Doppler bin for an M-chirp Doppler FFT. - - vel_per_bin = λ · fs / (2 · N · M) - where λ = c / fc, N = n_samples (PRI = N/fs), M = n_chirps. - """ - c = 299_792_458.0 - wavelength = c / self.center_freq_hz - return wavelength * self.sample_rate_hz / (2.0 * n_samples * n_chirps) - - def max_range(self, n_range_bins: int, n_samples: int) -> float: - """Maximum unambiguous range for the given bin count.""" - return self.range_resolution(n_samples) * n_range_bins - - @dataclass class RadarSettings: """Radar system display/map configuration. diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py index 9bc5752..c6ce2cd 100644 --- a/9_Firmware/9_3_GUI/v7/processing.py +++ b/9_Firmware/9_3_GUI/v7/processing.py @@ -2,12 +2,9 @@ v7.processing — Radar signal processing and GPS parsing. Classes: - - RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering, - association, Kalman tracking - - RawIQFrameProcessor — full signal chain for raw IQ replay: - quantize -> AGC -> Range FFT -> Doppler FFT -> - crop -> MTI -> DC notch -> mag -> CFAR - - USBPacketParser — parse GPS text/binary frames from STM32 CDC + - RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering, + association, Kalman tracking + - USBPacketParser — parse GPS text/binary frames from STM32 CDC Note: RadarPacketParser (old A5/C3 sync + CRC16 format) was removed. All packet parsing now uses production RadarProtocol (0xAA/0xBB format) @@ -25,11 +22,6 @@ from .models import ( RadarTarget, GPSData, ProcessingConfig, SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, ) -from .agc_sim import ( - AGCConfig, AGCState, AGCFrameResult, - quantize_iq, process_agc_frame, -) -from .hardware import RadarFrame, StatusResponse if SKLEARN_AVAILABLE: from sklearn.cluster import DBSCAN @@ -56,103 +48,6 @@ def apply_pitch_correction(raw_elevation: float, pitch: float) -> float: return raw_elevation - pitch -# ============================================================================= -# Utility: bin-to-physical target extraction (shared by all workers) -# ============================================================================= - -def extract_targets_from_frame( - frame: RadarFrame, - range_resolution: float, - velocity_resolution: float, - *, - gps: GPSData | None = None, -) -> list[RadarTarget]: - """Extract RadarTargets from a RadarFrame's detection mask. - - Performs bin-to-physical conversion and optional GPS coordinate mapping. - This is the shared implementation used by both RadarDataWorker (live mode) - and RawIQReplayWorker (replay mode). - - Args: - frame: RadarFrame with populated ``detections`` and ``magnitude`` arrays. - range_resolution: Metres per range bin. - velocity_resolution: m/s per Doppler bin. - gps: Optional GPSData for pitch correction and geographic mapping. - - Returns: - List of RadarTarget with physical-unit range, velocity, SNR, and - (if GPS available) lat/lon/azimuth/elevation. - """ - det_indices = np.argwhere(frame.detections > 0) - if len(det_indices) == 0: - return [] - - n_doppler = frame.magnitude.shape[1] - center_dbin = n_doppler // 2 - targets: list[RadarTarget] = [] - - for idx in det_indices: - rbin, dbin = idx - mag = frame.magnitude[rbin, dbin] - snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0.0 - - range_m = float(rbin) * range_resolution - velocity_ms = float(dbin - center_dbin) * velocity_resolution - - # GPS-dependent fields - raw_elev = 0.0 - corr_elev = raw_elev - lat, lon, azimuth = 0.0, 0.0, 0.0 - if gps is not None: - corr_elev = apply_pitch_correction(raw_elev, gps.pitch) - azimuth = gps.heading - lat, lon = _polar_to_geographic( - gps.latitude, gps.longitude, range_m, azimuth) - - targets.append(RadarTarget( - id=len(targets), - range=range_m, - velocity=velocity_ms, - azimuth=azimuth, - elevation=corr_elev, - latitude=lat, - longitude=lon, - snr=snr, - timestamp=frame.timestamp, - )) - - return targets - - -def _polar_to_geographic( - radar_lat: float, radar_lon: float, range_m: float, bearing_deg: float, -) -> tuple[float, float]: - """Convert polar (range, bearing) to geographic (lat, lon). - - Uses the spherical-Earth approximation (adequate for <50 km ranges). - Duplicated from ``workers.polar_to_geographic`` to keep processing.py - self-contained; the workers module still exports its own copy for - backward-compat. - """ - if range_m <= 0: - return radar_lat, radar_lon - earth_r = 6_371_000.0 - lat_r = math.radians(radar_lat) - lon_r = math.radians(radar_lon) - brg_r = math.radians(bearing_deg) - d_r = range_m / earth_r - - new_lat = math.asin( - math.sin(lat_r) * math.cos(d_r) - + math.cos(lat_r) * math.sin(d_r) * math.cos(brg_r) - ) - new_lon = lon_r + math.atan2( - math.sin(brg_r) * math.sin(d_r) * math.cos(lat_r), - math.cos(d_r) - math.sin(lat_r) * math.sin(new_lat), - ) - return math.degrees(new_lat), math.degrees(new_lon) - - # ============================================================================= # Radar Processor — signal-level processing & tracking pipeline # ============================================================================= @@ -556,269 +451,3 @@ class USBPacketParser: except (ValueError, struct.error) as e: logger.error(f"Error parsing binary GPS: {e}") return None - - -# ============================================================================= -# Raw IQ Frame Processor — full signal chain for replay mode -# ============================================================================= - -class RawIQFrameProcessor: - """Process raw complex IQ frames through the full radar signal chain. - - This replicates the FPGA processing pipeline in software so that - raw ADI CN0566 captures (or similar) can be visualised in the V7 - dashboard exactly as they would appear from the FPGA. - - Pipeline per frame: - 1. Quantize raw complex → 16-bit signed I/Q - 2. AGC gain application (bit-accurate to rx_gain_control.v) - 3. Range FFT (across samples) - 4. Doppler FFT (across chirps) + fftshift + centre crop - 5. Optional MTI (2-pulse canceller using history) - 6. Optional DC notch (zero-Doppler removal) - 7. Magnitude (|I| + |Q| approximation matching FPGA, or true |.|) - 8. CFAR or simple threshold detection - 9. Build RadarFrame + synthetic StatusResponse - """ - - def __init__( - self, - n_range_out: int = 64, - n_doppler_out: int = 32, - ): - self._n_range = n_range_out - self._n_doppler = n_doppler_out - - # AGC state (persists across frames) - self._agc_config = AGCConfig() - self._agc_state = AGCState() - - # MTI history buffer (stores previous Range-Doppler maps) - self._mti_history: list[np.ndarray] = [] - self._mti_enabled: bool = False - - # DC notch - self._dc_notch_width: int = 0 - - # CFAR / threshold config - self._cfar_enabled: bool = False - self._cfar_guard: int = 2 - self._cfar_train: int = 8 - self._cfar_alpha_q44: int = 0x30 # Q4.4 → 3.0 - self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO - self._detect_threshold: int = 10000 - - # Frame counter - self._frame_number: int = 0 - - # Host-side processing (windowing, clustering, etc.) - self._host_processor = RadarProcessor() - - # ---- Configuration setters --------------------------------------------- - - def set_agc_config(self, config: AGCConfig) -> None: - self._agc_config = config - - def reset_agc_state(self) -> None: - """Reset AGC state (e.g. on seek).""" - self._agc_state = AGCState() - self._mti_history.clear() - - def set_mti_enabled(self, enabled: bool) -> None: - if self._mti_enabled != enabled: - self._mti_history.clear() - self._mti_enabled = enabled - - def set_dc_notch_width(self, width: int) -> None: - self._dc_notch_width = max(0, min(7, width)) - - def set_cfar_params( - self, - enabled: bool, - guard: int = 2, - train: int = 8, - alpha_q44: int = 0x30, - mode: int = 0, - ) -> None: - self._cfar_enabled = enabled - self._cfar_guard = guard - self._cfar_train = train - self._cfar_alpha_q44 = alpha_q44 - self._cfar_mode = mode - - def set_detect_threshold(self, threshold: int) -> None: - self._detect_threshold = threshold - - @property - def agc_state(self) -> AGCState: - return self._agc_state - - @property - def agc_config(self) -> AGCConfig: - return self._agc_config - - @property - def frame_number(self) -> int: - return self._frame_number - - # ---- Main processing entry point --------------------------------------- - - def process_frame( - self, - raw_frame: np.ndarray, - timestamp: float = 0.0, - ) -> tuple[RadarFrame, StatusResponse, AGCFrameResult]: - """Process one raw IQ frame through the full chain. - - Parameters - ---------- - raw_frame : complex array, shape (n_chirps, n_samples) - timestamp : frame timestamp for RadarFrame - - Returns - ------- - (radar_frame, status_response, agc_result) - """ - n_chirps, _n_samples = raw_frame.shape - self._frame_number += 1 - - # 1. Quantize to 16-bit signed IQ - frame_i, frame_q = quantize_iq(raw_frame) - - # 2. AGC - agc_result = process_agc_frame( - frame_i, frame_q, self._agc_config, self._agc_state) - - # Use AGC-shifted IQ for downstream processing - iq = agc_result.shifted_i.astype(np.float64) + 1j * agc_result.shifted_q.astype(np.float64) - - # 3. Range FFT (across samples axis) - range_fft = np.fft.fft(iq, axis=1)[:, :self._n_range] - - # 4. Doppler FFT (across chirps axis) + fftshift + centre crop - doppler_fft = np.fft.fft(range_fft, axis=0) - doppler_fft = np.fft.fftshift(doppler_fft, axes=0) - # Centre-crop to n_doppler bins - center = n_chirps // 2 - half_d = self._n_doppler // 2 - start_d = max(0, center - half_d) - end_d = start_d + self._n_doppler - if end_d > n_chirps: - end_d = n_chirps - start_d = max(0, end_d - self._n_doppler) - rd_complex = doppler_fft[start_d:end_d, :] - # shape: (n_doppler, n_range) → transpose to (n_range, n_doppler) - rd_complex = rd_complex.T - - # 5. Optional MTI (2-pulse canceller) - if self._mti_enabled: - rd_complex = self._apply_mti(rd_complex) - - # 6. Optional DC notch (zero-Doppler bins) - if self._dc_notch_width > 0: - rd_complex = self._apply_dc_notch(rd_complex) - - # Extract I/Q for RadarFrame - rd_i = np.round(rd_complex.real).astype(np.int16) - rd_q = np.round(rd_complex.imag).astype(np.int16) - - # 7. Magnitude (FPGA uses |I|+|Q| approximation) - magnitude = np.abs(rd_complex.real) + np.abs(rd_complex.imag) - - # Range profile (sum across Doppler) - range_profile = np.sum(magnitude, axis=1) - - # 8. Detection (CFAR or simple threshold) - if self._cfar_enabled: - detections = self._run_cfar(magnitude) - else: - detections = self._run_threshold(magnitude) - - detection_count = int(np.sum(detections > 0)) - - # 9. Build RadarFrame - radar_frame = RadarFrame( - timestamp=timestamp, - range_doppler_i=rd_i, - range_doppler_q=rd_q, - magnitude=magnitude, - detections=detections, - range_profile=range_profile, - detection_count=detection_count, - frame_number=self._frame_number, - ) - - # 10. Build synthetic StatusResponse - status = self._build_status(agc_result) - - return radar_frame, status, agc_result - - # ---- Internal helpers -------------------------------------------------- - - def _apply_mti(self, rd: np.ndarray) -> np.ndarray: - """2-pulse MTI canceller: y[n] = x[n] - x[n-1].""" - self._mti_history.append(rd.copy()) - if len(self._mti_history) > 2: - self._mti_history = self._mti_history[-2:] - - if len(self._mti_history) < 2: - return np.zeros_like(rd) # suppress first frame - - return self._mti_history[-1] - self._mti_history[-2] - - def _apply_dc_notch(self, rd: np.ndarray) -> np.ndarray: - """Zero out centre Doppler bins (DC notch).""" - n_doppler = rd.shape[1] - center = n_doppler // 2 - w = self._dc_notch_width - lo = max(0, center - w) - hi = min(n_doppler, center + w + 1) - result = rd.copy() - result[:, lo:hi] = 0 - return result - - def _run_cfar(self, magnitude: np.ndarray) -> np.ndarray: - """Run 1-D CFAR along each range bin (Doppler direction). - - Uses the host-side CFAR from RadarProcessor with alpha converted - from Q4.4 to float. - """ - alpha_float = self._cfar_alpha_q44 / 16.0 - cfar_types = {0: "CA-CFAR", 1: "GO-CFAR", 2: "SO-CFAR"} - cfar_type = cfar_types.get(self._cfar_mode, "CA-CFAR") - - power = magnitude ** 2 - power = np.maximum(power, 1e-20) - - mask = np.zeros_like(magnitude, dtype=np.uint8) - for r in range(magnitude.shape[0]): - row = power[r, :] - if row.max() > 0: - det = RadarProcessor.cfar_1d( - row, self._cfar_guard, self._cfar_train, - alpha_float, cfar_type) - mask[r, :] = det.astype(np.uint8) - return mask - - def _run_threshold(self, magnitude: np.ndarray) -> np.ndarray: - """Simple threshold detection (matches FPGA detect_threshold).""" - return (magnitude > self._detect_threshold).astype(np.uint8) - - def _build_status(self, agc_result: AGCFrameResult) -> StatusResponse: - """Build a synthetic StatusResponse from current processor state.""" - return StatusResponse( - radar_mode=1, # active - stream_ctrl=0b111, - cfar_threshold=self._detect_threshold, - long_chirp=3000, - long_listen=13700, - guard=17540, - short_chirp=50, - short_listen=17450, - chirps_per_elev=32, - range_mode=0, - agc_current_gain=agc_result.gain_enc, - agc_peak_magnitude=agc_result.peak_mag_8bit, - agc_saturation_count=agc_result.saturation_count, - agc_enable=1 if self._agc_config.enabled else 0, - ) diff --git a/9_Firmware/9_3_GUI/v7/raw_iq_replay.py b/9_Firmware/9_3_GUI/v7/raw_iq_replay.py deleted file mode 100644 index fc04517..0000000 --- a/9_Firmware/9_3_GUI/v7/raw_iq_replay.py +++ /dev/null @@ -1,264 +0,0 @@ -""" -v7.raw_iq_replay -- Raw IQ replay controller for the V7 dashboard. - -Manages loading of raw complex IQ .npy captures, playback state -(play/pause/step/speed/loop), and delivers frames to a worker thread. - -The controller is thread-safe: the worker calls ``next_frame()`` which -blocks until a frame is available or playback is stopped. - -Supported file formats: - - 3-D .npy: (n_frames, n_chirps, n_samples) complex - - 2-D .npy: (n_chirps, n_samples) complex -> treated as single frame - -Classes: - - RawIQReplayController -- playback state machine + frame delivery - - RawIQFileInfo -- metadata about the loaded file -""" - -from __future__ import annotations - -import logging -import threading -from dataclasses import dataclass -from enum import Enum, auto -from pathlib import Path - -import numpy as np - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Playback state enum -# --------------------------------------------------------------------------- - -class PlaybackState(Enum): - """Playback state for the replay controller.""" - STOPPED = auto() - PLAYING = auto() - PAUSED = auto() - - -# --------------------------------------------------------------------------- -# File metadata -# --------------------------------------------------------------------------- - -@dataclass -class RawIQFileInfo: - """Metadata about a loaded raw IQ .npy file.""" - path: str - n_frames: int - n_chirps: int - n_samples: int - dtype: str - file_size_mb: float - - -# --------------------------------------------------------------------------- -# Replay Controller -# --------------------------------------------------------------------------- - -class RawIQReplayController: - """Manages raw IQ file loading and playback state. - - Thread-safety: the controller uses a condition variable so the worker - thread can block on ``next_frame()`` waiting for play/step events, - while the GUI thread calls ``play()``, ``pause()``, ``step()``, etc. - """ - - def __init__(self) -> None: - self._data: np.ndarray | None = None - self._info: RawIQFileInfo | None = None - - # Playback state - self._state = PlaybackState.STOPPED - self._frame_index: int = 0 - self._fps: float = 10.0 # target frames per second - self._loop: bool = True - - # Thread synchronisation - self._lock = threading.Lock() - self._cond = threading.Condition(self._lock) - - # Step request flag (set by GUI, consumed by worker) - self._step_requested: bool = False - - # Stop signal - self._stop_requested: bool = False - - # ---- File loading ------------------------------------------------------ - - def load_file(self, path: str) -> RawIQFileInfo: - """Load a .npy file containing raw IQ data. - - Raises ValueError if the file is not a valid raw IQ capture. - """ - p = Path(path) - if not p.exists(): - msg = f"File not found: {path}" - raise ValueError(msg) - if p.suffix.lower() != ".npy": - msg = f"Expected .npy file, got: {p.suffix}" - raise ValueError(msg) - - # Memory-map for large files - data = np.load(str(p), mmap_mode="r") - - if not np.iscomplexobj(data): - msg = f"Expected complex data, got dtype={data.dtype}" - raise ValueError(msg) - - # Normalise shape - if data.ndim == 2: - # Single frame: (chirps, samples) -> (1, chirps, samples) - data = data[np.newaxis, :, :] - elif data.ndim == 3: - pass # (frames, chirps, samples) — expected - else: - msg = f"Expected 2-D or 3-D array, got {data.ndim}-D" - raise ValueError(msg) - - with self._lock: - self._data = data - self._frame_index = 0 - self._state = PlaybackState.PAUSED - self._stop_requested = False - - self._info = RawIQFileInfo( - path=str(p), - n_frames=data.shape[0], - n_chirps=data.shape[1], - n_samples=data.shape[2], - dtype=str(data.dtype), - file_size_mb=p.stat().st_size / (1024 * 1024), - ) - - logger.info( - f"Loaded raw IQ: {p.name} — {self._info.n_frames} frames, " - f"{self._info.n_chirps} chirps, {self._info.n_samples} samples, " - f"{self._info.file_size_mb:.1f} MB" - ) - return self._info - - def unload(self) -> None: - """Unload the current file and stop playback.""" - with self._lock: - self._state = PlaybackState.STOPPED - self._stop_requested = True - self._data = None - self._info = None - self._frame_index = 0 - self._cond.notify_all() - - # ---- Playback control (called from GUI thread) ------------------------- - - def play(self) -> None: - with self._lock: - if self._data is None: - return - self._state = PlaybackState.PLAYING - self._stop_requested = False - self._cond.notify_all() - - def pause(self) -> None: - with self._lock: - if self._state == PlaybackState.PLAYING: - self._state = PlaybackState.PAUSED - - def stop(self) -> None: - with self._lock: - self._state = PlaybackState.STOPPED - self._stop_requested = True - self._cond.notify_all() - - def step_forward(self) -> None: - """Advance one frame (works in PAUSED state).""" - with self._lock: - if self._data is None: - return - self._step_requested = True - self._cond.notify_all() - - def seek(self, frame_index: int) -> None: - """Jump to a specific frame index.""" - with self._lock: - if self._data is None: - return - self._frame_index = max(0, min(frame_index, self._data.shape[0] - 1)) - - def set_fps(self, fps: float) -> None: - with self._lock: - self._fps = max(0.1, min(60.0, fps)) - - def set_loop(self, loop: bool) -> None: - with self._lock: - self._loop = loop - - # ---- State queries (thread-safe) --------------------------------------- - - @property - def state(self) -> PlaybackState: - with self._lock: - return self._state - - @property - def frame_index(self) -> int: - with self._lock: - return self._frame_index - - @property - def info(self) -> RawIQFileInfo | None: - with self._lock: - return self._info - - @property - def fps(self) -> float: - with self._lock: - return self._fps - - @property - def is_loaded(self) -> bool: - with self._lock: - return self._data is not None - - # ---- Frame delivery (called from worker thread) ------------------------ - - def next_frame(self) -> np.ndarray | None: - """Block until the next frame is available, then return it. - - Returns None when playback is stopped or file is unloaded. - The caller (worker thread) should use this in a loop. - """ - with self._cond: - while True: - if self._stop_requested or self._data is None: - return None - - if self._state == PlaybackState.PLAYING: - return self._deliver_frame() - - if self._step_requested: - self._step_requested = False - return self._deliver_frame() - - # PAUSED or STOPPED — wait for signal - self._cond.wait(timeout=0.1) - - def _deliver_frame(self) -> np.ndarray | None: - """Return current frame and advance index. Must hold lock.""" - if self._data is None: - return None - - n_frames = self._data.shape[0] - if self._frame_index >= n_frames: - if self._loop: - self._frame_index = 0 - else: - self._state = PlaybackState.PAUSED - return None - - # Read the frame (memory-mapped, so this is cheap) - frame = np.array(self._data[self._frame_index]) # copy from mmap - self._frame_index += 1 - return frame diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index 281195d..c467c98 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -2,14 +2,11 @@ v7.workers — QThread-based workers and demo target simulator. Classes: - - RadarDataWorker — reads from FT2232H via production RadarAcquisition, - parses 0xAA/0xBB packets, assembles 64x32 frames, - runs host-side DSP, emits PyQt signals. - - RawIQReplayWorker — reads raw IQ .npy frames from RawIQReplayController, - processes through RawIQFrameProcessor, emits same - signals as RadarDataWorker + playback state. - - GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals. - - TargetSimulator — QTimer-based demo target generator. + - RadarDataWorker — reads from FT2232H via production RadarAcquisition, + parses 0xAA/0xBB packets, assembles 64x32 frames, + runs host-side DSP, emits PyQt signals. + - GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals. + - TargetSimulator — QTimer-based demo target generator. The old V6/V7 packet parsing (sync A5 C3 + type + CRC16) has been removed. All packet parsing now uses the production radar_protocol.py which matches @@ -23,6 +20,8 @@ import queue import struct import logging +import numpy as np + from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal from .models import RadarTarget, GPSData, RadarSettings @@ -35,11 +34,9 @@ from .hardware import ( ) from .processing import ( RadarProcessor, - RawIQFrameProcessor, USBPacketParser, - extract_targets_from_frame, + apply_pitch_correction, ) -from .raw_iq_replay import RawIQReplayController, PlaybackState logger = logging.getLogger(__name__) @@ -209,16 +206,55 @@ class RadarDataWorker(QThread): Bin-to-physical conversion uses RadarSettings.range_resolution and velocity_resolution (should be calibrated to actual waveform). """ + targets: list[RadarTarget] = [] + cfg = self._processor.config if not (cfg.clustering_enabled or cfg.tracking_enabled): - return [] + return targets - targets = extract_targets_from_frame( - frame, - self._settings.range_resolution, - self._settings.velocity_resolution, - gps=self._gps, - ) + # Extract detections from FPGA CFAR flags + det_indices = np.argwhere(frame.detections > 0) + r_res = self._settings.range_resolution + v_res = self._settings.velocity_resolution + + for idx in det_indices: + rbin, dbin = idx + mag = frame.magnitude[rbin, dbin] + snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0 + + # Convert bin indices to physical units + range_m = float(rbin) * r_res + # Doppler: centre bin (16) = 0 m/s; positive bins = approaching + velocity_ms = float(dbin - 16) * v_res + + # Apply pitch correction if GPS data available + raw_elev = 0.0 # FPGA doesn't send elevation per-detection + corr_elev = raw_elev + if self._gps: + corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch) + + # Compute geographic position if GPS available + lat, lon = 0.0, 0.0 + azimuth = 0.0 # No azimuth from single-beam; set to heading + if self._gps: + azimuth = self._gps.heading + lat, lon = polar_to_geographic( + self._gps.latitude, self._gps.longitude, + range_m, azimuth, + ) + + target = RadarTarget( + id=len(targets), + range=range_m, + velocity=velocity_ms, + azimuth=azimuth, + elevation=corr_elev, + latitude=lat, + longitude=lon, + snr=snr, + timestamp=frame.timestamp, + ) + targets.append(target) # DBSCAN clustering if cfg.clustering_enabled and len(targets) > 0: @@ -232,155 +268,6 @@ class RadarDataWorker(QThread): return targets -# ============================================================================= -# Raw IQ Replay Worker (QThread) — processes raw .npy captures -# ============================================================================= - -class RawIQReplayWorker(QThread): - """Background worker for raw IQ replay mode. - - Reads frames from a RawIQReplayController, processes them through - RawIQFrameProcessor (quantize -> AGC -> FFT -> CFAR -> RadarFrame), - and emits the same signals as RadarDataWorker so the dashboard can - display them identically. - - Additional signal: - playbackStateChanged(str) — "playing", "paused", "stopped" - frameIndexChanged(int, int) — (current_index, total_frames) - - Signals: - frameReady(RadarFrame) - statusReceived(object) - targetsUpdated(list) - errorOccurred(str) - statsUpdated(dict) - playbackStateChanged(str) - frameIndexChanged(int, int) - """ - - frameReady = pyqtSignal(object) - statusReceived = pyqtSignal(object) - targetsUpdated = pyqtSignal(list) - errorOccurred = pyqtSignal(str) - statsUpdated = pyqtSignal(dict) - playbackStateChanged = pyqtSignal(str) - frameIndexChanged = pyqtSignal(int, int) - - def __init__( - self, - controller: RawIQReplayController, - processor: RawIQFrameProcessor, - host_processor: RadarProcessor | None = None, - settings: RadarSettings | None = None, - gps_data_ref: GPSData | None = None, - parent=None, - ): - super().__init__(parent) - self._controller = controller - self._processor = processor - self._host_processor = host_processor - self._settings = settings or RadarSettings() - self._gps = gps_data_ref - self._running = False - self._frame_count = 0 - self._error_count = 0 - - def stop(self): - self._running = False - self._controller.stop() - - def run(self): - self._running = True - self._frame_count = 0 - self._last_emitted_state: str | None = None - logger.info("RawIQReplayWorker started") - - info = self._controller.info - total_frames = info.n_frames if info else 0 - - while self._running: - try: - # Block until next frame or stop - raw_frame = self._controller.next_frame() - if raw_frame is None: - # Stopped or end of file - if self._running: - self.playbackStateChanged.emit("stopped") - break - - # Process through full signal chain - import time as _time - ts = _time.time() - frame, status, _agc_result = self._processor.process_frame( - raw_frame, timestamp=ts) - self._frame_count += 1 - - # Emit signals - self.frameReady.emit(frame) - self.statusReceived.emit(status) - - # Emit frame index - idx = self._controller.frame_index - self.frameIndexChanged.emit(idx, total_frames) - - # Emit playback state only on transitions (avoid race - # where a stale "playing" signal overwrites a pause) - state = self._controller.state - state_str = state.name.lower() - if state_str != self._last_emitted_state: - self._last_emitted_state = state_str - self.playbackStateChanged.emit(state_str) - - # Run host-side DSP if configured - if self._host_processor is not None: - targets = self._extract_targets(frame) - if targets: - self.targetsUpdated.emit(targets) - - # Stats - self.statsUpdated.emit({ - "frames": self._frame_count, - "detection_count": frame.detection_count, - "errors": self._error_count, - "frame_index": idx, - "total_frames": total_frames, - }) - - # Rate limiting: sleep to match target FPS - fps = self._controller.fps - if fps > 0 and self._controller.state == PlaybackState.PLAYING: - self.msleep(int(1000.0 / fps)) - - except (ValueError, IndexError) as e: - self._error_count += 1 - self.errorOccurred.emit(str(e)) - logger.error(f"RawIQReplayWorker error: {e}") - - self._running = False - logger.info("RawIQReplayWorker stopped") - - def _extract_targets(self, frame: RadarFrame) -> list[RadarTarget]: - """Extract targets from detection mask using shared bin-to-physical conversion.""" - targets = extract_targets_from_frame( - frame, - self._settings.range_resolution, - self._settings.velocity_resolution, - gps=self._gps, - ) - - # Clustering + tracking - if self._host_processor is not None: - cfg = self._host_processor.config - if cfg.clustering_enabled and len(targets) > 0: - clusters = self._host_processor.clustering( - targets, cfg.clustering_eps, cfg.clustering_min_samples) - if cfg.tracking_enabled: - targets = self._host_processor.association(targets, clusters) - self._host_processor.tracking(targets) - - return targets - - # ============================================================================= # GPS Data Worker (QThread) # ============================================================================= From 24b8442e40106549617950f8979803c8b6127519 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:14:00 +0545 Subject: [PATCH 6/8] feat: unified replay with SoftwareFPGA bit-accurate signal chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SoftwareFPGA class that imports golden_reference functions to replicate the FPGA pipeline in software, enabling bit-accurate replay of raw IQ, FPGA co-sim, and HDF5 recordings through the same dashboard path as live data. New modules: software_fpga.py, replay.py (ReplayEngine + 3 loaders) Enhanced: WaveformConfig model, extract_targets_from_frame() in processing, ReplayWorker with thread-safe playback controls, dashboard replay UI with transport controls and dual-dispatch FPGA parameter routing. Removed: ReplayConnection (from radar_protocol, hardware, dashboard, tests) — replaced by the unified replay architecture. 150/150 tests pass, ruff clean. --- 9_Firmware/9_3_GUI/radar_dashboard.py | 13 +- 9_Firmware/9_3_GUI/radar_protocol.py | 385 -------------- 9_Firmware/9_3_GUI/test_radar_dashboard.py | 229 +-------- 9_Firmware/9_3_GUI/test_v7.py | 554 +++++++++++++++++++++ 9_Firmware/9_3_GUI/v7/__init__.py | 30 +- 9_Firmware/9_3_GUI/v7/dashboard.py | 301 ++++++++++- 9_Firmware/9_3_GUI/v7/hardware.py | 6 +- 9_Firmware/9_3_GUI/v7/models.py | 56 +++ 9_Firmware/9_3_GUI/v7/processing.py | 100 ++++ 9_Firmware/9_3_GUI/v7/replay.py | 288 +++++++++++ 9_Firmware/9_3_GUI/v7/software_fpga.py | 287 +++++++++++ 9_Firmware/9_3_GUI/v7/workers.py | 217 ++++++-- 12 files changed, 1773 insertions(+), 693 deletions(-) create mode 100644 9_Firmware/9_3_GUI/v7/replay.py create mode 100644 9_Firmware/9_3_GUI/v7/software_fpga.py diff --git a/9_Firmware/9_3_GUI/radar_dashboard.py b/9_Firmware/9_3_GUI/radar_dashboard.py index 7575e63..3d6988c 100644 --- a/9_Firmware/9_3_GUI/radar_dashboard.py +++ b/9_Firmware/9_3_GUI/radar_dashboard.py @@ -43,7 +43,7 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg # Import protocol layer (no GUI deps) from radar_protocol import ( - RadarProtocol, FT2232HConnection, ReplayConnection, + RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition, RadarFrame, StatusResponse, NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH, @@ -904,22 +904,13 @@ def main(): parser = argparse.ArgumentParser(description="AERIS-10 Radar Dashboard") parser.add_argument("--live", action="store_true", help="Use real FT2232H hardware (default: mock mode)") - parser.add_argument("--replay", type=str, metavar="NPY_DIR", - help="Replay real data from .npy directory " - "(e.g. tb/cosim/real_data/hex/)") - parser.add_argument("--no-mti", action="store_true", - help="With --replay, use non-MTI Doppler data") parser.add_argument("--record", action="store_true", help="Start HDF5 recording immediately") parser.add_argument("--device", type=int, default=0, help="FT2232H device index (default: 0)") args = parser.parse_args() - if args.replay: - npy_dir = os.path.abspath(args.replay) - conn = ReplayConnection(npy_dir, use_mti=not args.no_mti) - mode_str = f"REPLAY ({npy_dir}, MTI={'OFF' if args.no_mti else 'ON'})" - elif args.live: + if args.live: conn = FT2232HConnection(mock=False) mode_str = "LIVE" else: diff --git a/9_Firmware/9_3_GUI/radar_protocol.py b/9_Firmware/9_3_GUI/radar_protocol.py index c266b6d..e04d768 100644 --- a/9_Firmware/9_3_GUI/radar_protocol.py +++ b/9_Firmware/9_3_GUI/radar_protocol.py @@ -15,7 +15,6 @@ USB Packet Protocol (11-byte): Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo} """ -import os import struct import time import threading @@ -443,391 +442,7 @@ class FT2232HConnection: return bytes(buf) -# ============================================================================ -# Replay Connection — feed real .npy data through the dashboard -# ============================================================================ -# Hardware-only opcodes that cannot be adjusted in replay mode -# Values must match radar_system_top.v case(usb_cmd_opcode). -_HARDWARE_ONLY_OPCODES = { - 0x01, # RADAR_MODE - 0x02, # TRIGGER_PULSE - # 0x03 (DETECT_THRESHOLD) is NOT hardware-only — it's in _REPLAY_ADJUSTABLE_OPCODES - 0x04, # STREAM_CONTROL - 0x10, # LONG_CHIRP - 0x11, # LONG_LISTEN - 0x12, # GUARD - 0x13, # SHORT_CHIRP - 0x14, # SHORT_LISTEN - 0x15, # CHIRPS_PER_ELEV - 0x16, # GAIN_SHIFT - 0x20, # RANGE_MODE - 0x28, # AGC_ENABLE - 0x29, # AGC_TARGET - 0x2A, # AGC_ATTACK - 0x2B, # AGC_DECAY - 0x2C, # AGC_HOLDOFF - 0x30, # SELF_TEST_TRIGGER - 0x31, # SELF_TEST_STATUS - 0xFF, # STATUS_REQUEST -} - -# Replay-adjustable opcodes (re-run signal processing) -_REPLAY_ADJUSTABLE_OPCODES = { - 0x03, # DETECT_THRESHOLD - 0x21, # CFAR_GUARD - 0x22, # CFAR_TRAIN - 0x23, # CFAR_ALPHA - 0x24, # CFAR_MODE - 0x25, # CFAR_ENABLE - 0x26, # MTI_ENABLE - 0x27, # DC_NOTCH_WIDTH -} - - -def _saturate(val: int, bits: int) -> int: - """Saturate signed value to fit in 'bits' width.""" - max_pos = (1 << (bits - 1)) - 1 - max_neg = -(1 << (bits - 1)) - return max(max_neg, min(max_pos, int(val))) - - -def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray, - width: int) -> tuple[np.ndarray, np.ndarray]: - """Bit-accurate DC notch filter (matches radar_system_top.v inline). - - Dual sub-frame notch: doppler_bin[4:0] = {sub_frame, bin[3:0]}. - Each 16-bin sub-frame has its own DC at bin 0, so we zero bins - where ``bin_within_sf < width`` or ``bin_within_sf > (15 - width + 1)``. - """ - out_i = doppler_i.copy() - out_q = doppler_q.copy() - if width == 0: - return out_i, out_q - n_doppler = doppler_i.shape[1] - for dbin in range(n_doppler): - bin_within_sf = dbin & 0xF - if bin_within_sf < width or bin_within_sf > (15 - width + 1): - out_i[:, dbin] = 0 - out_q[:, dbin] = 0 - return out_i, out_q - - -def _replay_cfar(doppler_i: np.ndarray, doppler_q: np.ndarray, - guard: int, train: int, alpha_q44: int, - mode: int) -> tuple[np.ndarray, np.ndarray]: - """ - Bit-accurate CA-CFAR detector (matches cfar_ca.v). - Returns (detect_flags, magnitudes) both (64, 32). - """ - ALPHA_FRAC_BITS = 4 - n_range, n_doppler = doppler_i.shape - if train == 0: - train = 1 - - # Compute magnitudes: |I| + |Q| (17-bit unsigned L1 norm) - magnitudes = np.zeros((n_range, n_doppler), dtype=np.int64) - for r in range(n_range): - for d in range(n_doppler): - i_val = int(doppler_i[r, d]) - q_val = int(doppler_q[r, d]) - abs_i = (-i_val) & 0xFFFF if i_val < 0 else i_val & 0xFFFF - abs_q = (-q_val) & 0xFFFF if q_val < 0 else q_val & 0xFFFF - magnitudes[r, d] = abs_i + abs_q - - detect_flags = np.zeros((n_range, n_doppler), dtype=np.bool_) - MAX_MAG = (1 << 17) - 1 - - mode_names = {0: 'CA', 1: 'GO', 2: 'SO'} - mode_str = mode_names.get(mode, 'CA') - - for dbin in range(n_doppler): - col = magnitudes[:, dbin] - for cut in range(n_range): - lead_sum, lead_cnt = 0, 0 - for t in range(1, train + 1): - idx = cut - guard - t - if 0 <= idx < n_range: - lead_sum += int(col[idx]) - lead_cnt += 1 - lag_sum, lag_cnt = 0, 0 - for t in range(1, train + 1): - idx = cut + guard + t - if 0 <= idx < n_range: - lag_sum += int(col[idx]) - lag_cnt += 1 - - if mode_str == 'CA': - noise = lead_sum + lag_sum - elif mode_str == 'GO': - if lead_cnt > 0 and lag_cnt > 0: - noise = lead_sum if lead_sum * lag_cnt > lag_sum * lead_cnt else lag_sum - else: - noise = lead_sum if lead_cnt > 0 else lag_sum - elif mode_str == 'SO': - if lead_cnt > 0 and lag_cnt > 0: - noise = lead_sum if lead_sum * lag_cnt < lag_sum * lead_cnt else lag_sum - else: - noise = lead_sum if lead_cnt > 0 else lag_sum - else: - noise = lead_sum + lag_sum - - thr = min((alpha_q44 * noise) >> ALPHA_FRAC_BITS, MAX_MAG) - if int(col[cut]) > thr: - detect_flags[cut, dbin] = True - - return detect_flags, magnitudes - - -class ReplayConnection: - """ - Loads pre-computed .npy arrays (from golden_reference.py co-sim output) - and serves them as USB data packets to the dashboard, exercising the full - parsing pipeline with real ADI CN0566 radar data. - - Signal processing parameters (CFAR guard/train/alpha/mode, MTI enable, - DC notch width) can be adjusted at runtime via write() — the connection - re-runs the bit-accurate processing pipeline and rebuilds packets. - - Required npy directory layout (e.g. tb/cosim/real_data/hex/): - decimated_range_i.npy (32, 64) int — pre-Doppler range I - decimated_range_q.npy (32, 64) int — pre-Doppler range Q - doppler_map_i.npy (64, 32) int — Doppler I (no MTI) - doppler_map_q.npy (64, 32) int — Doppler Q (no MTI) - fullchain_mti_doppler_i.npy (64, 32) int — Doppler I (with MTI) - fullchain_mti_doppler_q.npy (64, 32) int — Doppler Q (with MTI) - fullchain_cfar_flags.npy (64, 32) bool — CFAR detections - fullchain_cfar_mag.npy (64, 32) int — CFAR |I|+|Q| magnitude - """ - - def __init__(self, npy_dir: str, use_mti: bool = True, - replay_fps: float = 5.0): - self._npy_dir = npy_dir - self._use_mti = use_mti - self._replay_fps = max(replay_fps, 0.1) - self._lock = threading.Lock() - self.is_open = False - self._packets: bytes = b"" - self._read_offset = 0 - self._frame_len = 0 - # Current signal-processing parameters - self._mti_enable: bool = use_mti - self._dc_notch_width: int = 2 - self._cfar_guard: int = 2 - self._cfar_train: int = 8 - self._cfar_alpha: int = 0x30 - self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO - self._cfar_enable: bool = True - self._detect_threshold: int = 10000 # RTL default (host_detect_threshold) - # Raw source arrays (loaded once, reprocessed on param change) - self._dop_mti_i: np.ndarray | None = None - self._dop_mti_q: np.ndarray | None = None - self._dop_nomti_i: np.ndarray | None = None - self._dop_nomti_q: np.ndarray | None = None - self._range_i_vec: np.ndarray | None = None - self._range_q_vec: np.ndarray | None = None - # Rebuild flag - self._needs_rebuild = False - - def open(self, _device_index: int = 0) -> bool: - try: - self._load_arrays() - self._packets = self._build_packets() - self._frame_len = len(self._packets) - self._read_offset = 0 - self.is_open = True - log.info(f"Replay connection opened: {self._npy_dir} " - f"(MTI={'ON' if self._mti_enable else 'OFF'}, " - f"{self._frame_len} bytes/frame)") - return True - except (OSError, ValueError, IndexError, struct.error) as e: - log.error(f"Replay open failed: {e}") - return False - - def close(self): - self.is_open = False - - def read(self, size: int = 4096) -> bytes | None: - if not self.is_open: - return None - # Pace reads to target FPS (spread across ~64 reads per frame) - time.sleep((1.0 / self._replay_fps) / (NUM_CELLS / 32)) - with self._lock: - # If params changed, rebuild packets - if self._needs_rebuild: - self._packets = self._build_packets() - self._frame_len = len(self._packets) - self._read_offset = 0 - self._needs_rebuild = False - end = self._read_offset + size - if end <= self._frame_len: - chunk = self._packets[self._read_offset:end] - self._read_offset = end - else: - chunk = self._packets[self._read_offset:] - self._read_offset = 0 - return chunk - - def write(self, data: bytes) -> bool: - """ - Handle host commands in replay mode. - Signal-processing params (CFAR, MTI, DC notch) trigger re-processing. - Hardware-only params are silently ignored. - """ - if len(data) < 4: - return True - word = struct.unpack(">I", data[:4])[0] - opcode = (word >> 24) & 0xFF - value = word & 0xFFFF - - if opcode in _REPLAY_ADJUSTABLE_OPCODES: - changed = False - with self._lock: - if opcode == 0x03: # DETECT_THRESHOLD - if self._detect_threshold != value: - self._detect_threshold = value - changed = True - elif opcode == 0x21: # CFAR_GUARD - if self._cfar_guard != value: - self._cfar_guard = value - changed = True - elif opcode == 0x22: # CFAR_TRAIN - if self._cfar_train != value: - self._cfar_train = value - changed = True - elif opcode == 0x23: # CFAR_ALPHA - if self._cfar_alpha != value: - self._cfar_alpha = value - changed = True - elif opcode == 0x24: # CFAR_MODE - if self._cfar_mode != value: - self._cfar_mode = value - changed = True - elif opcode == 0x25: # CFAR_ENABLE - new_en = bool(value) - if self._cfar_enable != new_en: - self._cfar_enable = new_en - changed = True - elif opcode == 0x26: # MTI_ENABLE - new_en = bool(value) - if self._mti_enable != new_en: - self._mti_enable = new_en - changed = True - elif opcode == 0x27 and self._dc_notch_width != value: # DC_NOTCH_WIDTH - self._dc_notch_width = value - changed = True - if changed: - self._needs_rebuild = True - if changed: - log.info(f"Replay param updated: opcode=0x{opcode:02X} " - f"value={value} — will re-process") - else: - log.debug(f"Replay param unchanged: opcode=0x{opcode:02X} " - f"value={value}") - elif opcode in _HARDWARE_ONLY_OPCODES: - log.debug(f"Replay: hardware-only opcode 0x{opcode:02X} " - f"(ignored in replay mode)") - else: - log.debug(f"Replay: unknown opcode 0x{opcode:02X} (ignored)") - return True - - def _load_arrays(self): - """Load source npy arrays once.""" - npy = self._npy_dir - # MTI Doppler - self._dop_mti_i = np.load( - os.path.join(npy, "fullchain_mti_doppler_i.npy")).astype(np.int64) - self._dop_mti_q = np.load( - os.path.join(npy, "fullchain_mti_doppler_q.npy")).astype(np.int64) - # Non-MTI Doppler - self._dop_nomti_i = np.load( - os.path.join(npy, "doppler_map_i.npy")).astype(np.int64) - self._dop_nomti_q = np.load( - os.path.join(npy, "doppler_map_q.npy")).astype(np.int64) - # Range data - try: - range_i_all = np.load( - os.path.join(npy, "decimated_range_i.npy")).astype(np.int64) - range_q_all = np.load( - os.path.join(npy, "decimated_range_q.npy")).astype(np.int64) - self._range_i_vec = range_i_all[-1, :] # last chirp - self._range_q_vec = range_q_all[-1, :] - except FileNotFoundError: - self._range_i_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64) - self._range_q_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64) - - def _build_packets(self) -> bytes: - """Build a full frame of USB data packets from current params.""" - # Select Doppler data based on MTI - if self._mti_enable: - dop_i = self._dop_mti_i - dop_q = self._dop_mti_q - else: - dop_i = self._dop_nomti_i - dop_q = self._dop_nomti_q - - # Apply DC notch - dop_i, dop_q = _replay_dc_notch(dop_i, dop_q, self._dc_notch_width) - - # Run CFAR - if self._cfar_enable: - det, _mag = _replay_cfar( - dop_i, dop_q, - guard=self._cfar_guard, - train=self._cfar_train, - alpha_q44=self._cfar_alpha, - mode=self._cfar_mode, - ) - else: - # Simple threshold fallback matching RTL cfar_ca.v: - # detect = (|I| + |Q|) > detect_threshold (L1 norm) - mag = np.abs(dop_i) + np.abs(dop_q) - det = mag > self._detect_threshold - - det_count = int(det.sum()) - log.info(f"Replay: rebuilt {NUM_CELLS} packets (" - f"MTI={'ON' if self._mti_enable else 'OFF'}, " - f"DC_notch={self._dc_notch_width}, " - f"CFAR={'ON' if self._cfar_enable else 'OFF'} " - f"G={self._cfar_guard} T={self._cfar_train} " - f"a=0x{self._cfar_alpha:02X} m={self._cfar_mode}, " - f"{det_count} detections)") - - range_i = self._range_i_vec - range_q = self._range_q_vec - - return self._build_packets_data(range_i, range_q, dop_i, dop_q, det) - - def _build_packets_data(self, range_i, range_q, dop_i, dop_q, det) -> bytes: - """Build 11-byte data packets for FT2232H interface.""" - buf = bytearray(NUM_CELLS * DATA_PACKET_SIZE) - pos = 0 - for rbin in range(NUM_RANGE_BINS): - ri = int(np.clip(range_i[rbin], -32768, 32767)) - rq = int(np.clip(range_q[rbin], -32768, 32767)) - rq_bytes = struct.pack(">h", rq) - ri_bytes = struct.pack(">h", ri) - for dbin in range(NUM_DOPPLER_BINS): - di = int(np.clip(dop_i[rbin, dbin], -32768, 32767)) - dq = int(np.clip(dop_q[rbin, dbin], -32768, 32767)) - d = 1 if det[rbin, dbin] else 0 - - buf[pos] = HEADER_BYTE - pos += 1 - buf[pos:pos+2] = rq_bytes - pos += 2 - buf[pos:pos+2] = ri_bytes - pos += 2 - buf[pos:pos+2] = struct.pack(">h", di) - pos += 2 - buf[pos:pos+2] = struct.pack(">h", dq) - pos += 2 - buf[pos] = d - pos += 1 - buf[pos] = FOOTER_BYTE - pos += 1 - - return bytes(buf) # ============================================================================ diff --git a/9_Firmware/9_3_GUI/test_radar_dashboard.py b/9_Firmware/9_3_GUI/test_radar_dashboard.py index b8bf6cf..f94b85a 100644 --- a/9_Firmware/9_3_GUI/test_radar_dashboard.py +++ b/9_Firmware/9_3_GUI/test_radar_dashboard.py @@ -19,9 +19,8 @@ from radar_protocol import ( RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition, RadarFrame, StatusResponse, Opcode, HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE, - NUM_RANGE_BINS, NUM_DOPPLER_BINS, NUM_CELLS, + NUM_RANGE_BINS, NUM_DOPPLER_BINS, DATA_PACKET_SIZE, - _HARDWARE_ONLY_OPCODES, ) @@ -459,218 +458,6 @@ class TestEndToEnd(unittest.TestCase): self.assertEqual(result["detection"], 1) -class TestReplayConnection(unittest.TestCase): - """Test ReplayConnection with real .npy data files.""" - - NPY_DIR = os.path.join( - os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim", - "real_data", "hex" - ) - - def _npy_available(self): - """Check if the npy data files exist.""" - return os.path.isfile(os.path.join(self.NPY_DIR, - "fullchain_mti_doppler_i.npy")) - - def test_replay_open_close(self): - """ReplayConnection opens and closes without error.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - self.assertTrue(conn.open()) - self.assertTrue(conn.is_open) - conn.close() - self.assertFalse(conn.is_open) - - def test_replay_packet_count(self): - """Replay builds exactly NUM_CELLS (2048) packets.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # Each packet is 11 bytes, total = 2048 * 11 - expected_bytes = NUM_CELLS * DATA_PACKET_SIZE - self.assertEqual(conn._frame_len, expected_bytes) - conn.close() - - def test_replay_packets_parseable(self): - """Every packet from replay can be parsed by RadarProtocol.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - raw = conn._packets - boundaries = RadarProtocol.find_packet_boundaries(raw) - self.assertEqual(len(boundaries), NUM_CELLS) - parsed_count = 0 - det_count = 0 - for start, end, ptype in boundaries: - self.assertEqual(ptype, "data") - result = RadarProtocol.parse_data_packet(raw[start:end]) - self.assertIsNotNone(result) - parsed_count += 1 - if result["detection"]: - det_count += 1 - self.assertEqual(parsed_count, NUM_CELLS) - # Default: MTI=ON, DC_notch=2, CFAR CA g=2 t=8 a=0x30 → 4 detections - self.assertEqual(det_count, 4) - conn.close() - - def test_replay_read_loops(self): - """Read returns data and loops back around.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True, replay_fps=1000) - conn.open() - total_read = 0 - for _ in range(100): - chunk = conn.read(1024) - self.assertIsNotNone(chunk) - total_read += len(chunk) - self.assertGreater(total_read, 0) - conn.close() - - def test_replay_no_mti(self): - """ReplayConnection works with use_mti=False (CFAR still runs).""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=False) - conn.open() - self.assertEqual(conn._frame_len, NUM_CELLS * DATA_PACKET_SIZE) - # No-MTI with DC notch=2 and default CFAR → 0 detections - raw = conn._packets - boundaries = RadarProtocol.find_packet_boundaries(raw) - det_count = sum(1 for s, e, t in boundaries - if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0)) - self.assertEqual(det_count, 0) - conn.close() - - def test_replay_write_returns_true(self): - """Write on replay connection returns True.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR) - conn.open() - self.assertTrue(conn.write(b"\x01\x00\x00\x01")) - conn.close() - - def test_replay_adjustable_param_cfar_guard(self): - """Changing CFAR guard via write() triggers re-processing.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # Initial: guard=2 → 4 detections - self.assertFalse(conn._needs_rebuild) - # Send CFAR_GUARD=4 - cmd = RadarProtocol.build_command(0x21, 4) - conn.write(cmd) - self.assertTrue(conn._needs_rebuild) - self.assertEqual(conn._cfar_guard, 4) - # Read triggers rebuild - conn.read(1024) - self.assertFalse(conn._needs_rebuild) - conn.close() - - def test_replay_adjustable_param_mti_toggle(self): - """Toggling MTI via write() triggers re-processing.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # Disable MTI - cmd = RadarProtocol.build_command(0x26, 0) - conn.write(cmd) - self.assertTrue(conn._needs_rebuild) - self.assertFalse(conn._mti_enable) - # Read to trigger rebuild, then count detections - # Drain all packets after rebuild - conn.read(1024) # triggers rebuild - raw = conn._packets - boundaries = RadarProtocol.find_packet_boundaries(raw) - det_count = sum(1 for s, e, t in boundaries - if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0)) - # No-MTI with default CFAR → 0 detections - self.assertEqual(det_count, 0) - conn.close() - - def test_replay_adjustable_param_dc_notch(self): - """Changing DC notch width via write() triggers re-processing.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # Change DC notch to 0 (no notch) - cmd = RadarProtocol.build_command(0x27, 0) - conn.write(cmd) - self.assertTrue(conn._needs_rebuild) - self.assertEqual(conn._dc_notch_width, 0) - conn.read(1024) # triggers rebuild - raw = conn._packets - boundaries = RadarProtocol.find_packet_boundaries(raw) - det_count = sum(1 for s, e, t in boundaries - if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0)) - # DC notch=0 with MTI → 6 detections (more noise passes through) - self.assertEqual(det_count, 6) - conn.close() - - def test_replay_hardware_opcode_ignored(self): - """Hardware-only opcodes don't trigger rebuild.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # Send TRIGGER (hardware-only) - cmd = RadarProtocol.build_command(0x01, 1) - conn.write(cmd) - self.assertFalse(conn._needs_rebuild) - # Send STREAM_CONTROL (hardware-only, opcode 0x04) - cmd = RadarProtocol.build_command(0x04, 7) - conn.write(cmd) - self.assertFalse(conn._needs_rebuild) - conn.close() - - def test_replay_same_value_no_rebuild(self): - """Setting same value as current doesn't trigger rebuild.""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # CFAR guard already 2 - cmd = RadarProtocol.build_command(0x21, 2) - conn.write(cmd) - self.assertFalse(conn._needs_rebuild) - conn.close() - - def test_replay_self_test_opcodes_are_hardware_only(self): - """Self-test opcodes 0x30/0x31 are hardware-only (ignored in replay).""" - if not self._npy_available(): - self.skipTest("npy data files not found") - from radar_protocol import ReplayConnection - conn = ReplayConnection(self.NPY_DIR, use_mti=True) - conn.open() - # Send self-test trigger - cmd = RadarProtocol.build_command(0x30, 1) - conn.write(cmd) - self.assertFalse(conn._needs_rebuild) - # Send self-test status request - cmd = RadarProtocol.build_command(0x31, 0) - conn.write(cmd) - self.assertFalse(conn._needs_rebuild) - conn.close() - - class TestOpcodeEnum(unittest.TestCase): """Verify Opcode enum matches RTL host register map (radar_system_top.v).""" @@ -690,15 +477,6 @@ class TestOpcodeEnum(unittest.TestCase): """SELF_TEST_STATUS opcode must be 0x31.""" self.assertEqual(Opcode.SELF_TEST_STATUS, 0x31) - def test_self_test_in_hardware_only(self): - """Self-test opcodes must be in _HARDWARE_ONLY_OPCODES.""" - self.assertIn(0x30, _HARDWARE_ONLY_OPCODES) - self.assertIn(0x31, _HARDWARE_ONLY_OPCODES) - - def test_0x16_in_hardware_only(self): - """GAIN_SHIFT 0x16 must be in _HARDWARE_ONLY_OPCODES.""" - self.assertIn(0x16, _HARDWARE_ONLY_OPCODES) - def test_stream_control_is_0x04(self): """STREAM_CONTROL must be 0x04 (matches radar_system_top.v:906).""" self.assertEqual(Opcode.STREAM_CONTROL, 0x04) @@ -717,11 +495,6 @@ class TestOpcodeEnum(unittest.TestCase): self.assertEqual(Opcode.DETECT_THRESHOLD, 0x03) self.assertEqual(Opcode.STREAM_CONTROL, 0x04) - def test_stale_opcodes_not_in_hardware_only(self): - """Old wrong opcode values must not be in _HARDWARE_ONLY_OPCODES.""" - self.assertNotIn(0x05, _HARDWARE_ONLY_OPCODES) # was wrong STREAM_ENABLE - self.assertNotIn(0x06, _HARDWARE_ONLY_OPCODES) # was wrong GAIN_SHIFT - def test_all_rtl_opcodes_present(self): """Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member.""" expected = {0x01, 0x02, 0x03, 0x04, diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index bb54e48..4f70ecd 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -11,6 +11,7 @@ Does NOT require a running Qt event loop — only unit-testable components. Run with: python -m unittest test_v7 -v """ +import os import struct import unittest from dataclasses import asdict @@ -414,6 +415,559 @@ class TestAGCVisualizationV7(unittest.TestCase): self.assertEqual(pick_color(11), DARK_ERROR) +# ============================================================================= +# Test: v7.models.WaveformConfig +# ============================================================================= + +class TestWaveformConfig(unittest.TestCase): + """WaveformConfig dataclass and derived physical properties.""" + + def test_defaults(self): + from v7.models import WaveformConfig + wc = WaveformConfig() + self.assertEqual(wc.sample_rate_hz, 4e6) + self.assertEqual(wc.bandwidth_hz, 500e6) + self.assertEqual(wc.chirp_duration_s, 300e-6) + self.assertEqual(wc.center_freq_hz, 10.525e9) + self.assertEqual(wc.n_range_bins, 64) + self.assertEqual(wc.n_doppler_bins, 32) + self.assertEqual(wc.fft_size, 1024) + self.assertEqual(wc.decimation_factor, 16) + + def test_range_resolution(self): + """range_resolution_m should be ~5.62 m/bin with ADI defaults.""" + from v7.models import WaveformConfig + wc = WaveformConfig() + self.assertAlmostEqual(wc.range_resolution_m, 5.621, places=1) + + def test_velocity_resolution(self): + """velocity_resolution_mps should be ~1.484 m/s/bin.""" + from v7.models import WaveformConfig + wc = WaveformConfig() + self.assertAlmostEqual(wc.velocity_resolution_mps, 1.484, places=2) + + def test_max_range(self): + """max_range_m = range_resolution * n_range_bins.""" + from v7.models import WaveformConfig + wc = WaveformConfig() + self.assertAlmostEqual(wc.max_range_m, wc.range_resolution_m * 64, places=1) + + def test_max_velocity(self): + """max_velocity_mps = velocity_resolution * n_doppler_bins / 2.""" + from v7.models import WaveformConfig + wc = WaveformConfig() + self.assertAlmostEqual( + wc.max_velocity_mps, + wc.velocity_resolution_mps * 16, + places=2, + ) + + def test_custom_params(self): + """Non-default parameters correctly change derived values.""" + from v7.models import WaveformConfig + wc1 = WaveformConfig() + wc2 = WaveformConfig(bandwidth_hz=1e9) # double BW → halve range res + self.assertAlmostEqual(wc2.range_resolution_m, wc1.range_resolution_m / 2, places=2) + + def test_zero_center_freq_velocity(self): + """Zero center freq should cause ZeroDivisionError in velocity calc.""" + from v7.models import WaveformConfig + wc = WaveformConfig(center_freq_hz=0.0) + with self.assertRaises(ZeroDivisionError): + _ = wc.velocity_resolution_mps + + +# ============================================================================= +# Test: v7.software_fpga.SoftwareFPGA +# ============================================================================= + +class TestSoftwareFPGA(unittest.TestCase): + """SoftwareFPGA register interface and signal chain.""" + + def _make_fpga(self): + from v7.software_fpga import SoftwareFPGA + return SoftwareFPGA() + + def test_reset_defaults(self): + """Register reset values match FPGA RTL (radar_system_top.v).""" + fpga = self._make_fpga() + self.assertEqual(fpga.detect_threshold, 10_000) + self.assertEqual(fpga.gain_shift, 0) + self.assertFalse(fpga.cfar_enable) + self.assertEqual(fpga.cfar_guard, 2) + self.assertEqual(fpga.cfar_train, 8) + self.assertEqual(fpga.cfar_alpha, 0x30) + self.assertEqual(fpga.cfar_mode, 0) + self.assertFalse(fpga.mti_enable) + self.assertEqual(fpga.dc_notch_width, 0) + self.assertFalse(fpga.agc_enable) + self.assertEqual(fpga.agc_target, 200) + self.assertEqual(fpga.agc_attack, 1) + self.assertEqual(fpga.agc_decay, 1) + self.assertEqual(fpga.agc_holdoff, 4) + + def test_setter_detect_threshold(self): + fpga = self._make_fpga() + fpga.set_detect_threshold(5000) + self.assertEqual(fpga.detect_threshold, 5000) + + def test_setter_detect_threshold_clamp_16bit(self): + fpga = self._make_fpga() + fpga.set_detect_threshold(0x1FFFF) # 17-bit + self.assertEqual(fpga.detect_threshold, 0xFFFF) + + def test_setter_gain_shift_clamp_4bit(self): + fpga = self._make_fpga() + fpga.set_gain_shift(0xFF) + self.assertEqual(fpga.gain_shift, 0x0F) + + def test_setter_cfar_enable(self): + fpga = self._make_fpga() + fpga.set_cfar_enable(True) + self.assertTrue(fpga.cfar_enable) + fpga.set_cfar_enable(False) + self.assertFalse(fpga.cfar_enable) + + def test_setter_cfar_guard_clamp_4bit(self): + fpga = self._make_fpga() + fpga.set_cfar_guard(0x1F) + self.assertEqual(fpga.cfar_guard, 0x0F) + + def test_setter_cfar_train_min_1(self): + """CFAR train cells clamped to min 1.""" + fpga = self._make_fpga() + fpga.set_cfar_train(0) + self.assertEqual(fpga.cfar_train, 1) + + def test_setter_cfar_train_clamp_5bit(self): + fpga = self._make_fpga() + fpga.set_cfar_train(0x3F) + self.assertEqual(fpga.cfar_train, 0x1F) + + def test_setter_cfar_alpha_clamp_8bit(self): + fpga = self._make_fpga() + fpga.set_cfar_alpha(0x1FF) + self.assertEqual(fpga.cfar_alpha, 0xFF) + + def test_setter_cfar_mode_clamp_2bit(self): + fpga = self._make_fpga() + fpga.set_cfar_mode(7) + self.assertEqual(fpga.cfar_mode, 3) + + def test_setter_mti_enable(self): + fpga = self._make_fpga() + fpga.set_mti_enable(True) + self.assertTrue(fpga.mti_enable) + + def test_setter_dc_notch_clamp_3bit(self): + fpga = self._make_fpga() + fpga.set_dc_notch_width(0xFF) + self.assertEqual(fpga.dc_notch_width, 7) + + def test_setter_agc_params_selective(self): + """set_agc_params only changes provided fields.""" + fpga = self._make_fpga() + fpga.set_agc_params(target=100) + self.assertEqual(fpga.agc_target, 100) + self.assertEqual(fpga.agc_attack, 1) # unchanged + fpga.set_agc_params(attack=3, decay=5) + self.assertEqual(fpga.agc_attack, 3) + self.assertEqual(fpga.agc_decay, 5) + self.assertEqual(fpga.agc_target, 100) # unchanged + + def test_setter_agc_params_clamp(self): + fpga = self._make_fpga() + fpga.set_agc_params(target=0xFFF, attack=0xFF, decay=0xFF, holdoff=0xFF) + self.assertEqual(fpga.agc_target, 0xFF) + self.assertEqual(fpga.agc_attack, 0x0F) + self.assertEqual(fpga.agc_decay, 0x0F) + self.assertEqual(fpga.agc_holdoff, 0x0F) + + +class TestSoftwareFPGASignalChain(unittest.TestCase): + """SoftwareFPGA.process_chirps with real co-sim data.""" + + COSIM_DIR = os.path.join( + os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim", + "real_data", "hex" + ) + + def _cosim_available(self): + return os.path.isfile(os.path.join(self.COSIM_DIR, "doppler_map_i.npy")) + + def test_process_chirps_returns_radar_frame(self): + """process_chirps produces a RadarFrame with correct shapes.""" + if not self._cosim_available(): + self.skipTest("co-sim data not found") + from v7.software_fpga import SoftwareFPGA + from radar_protocol import RadarFrame + + # Load decimated range data as minimal input (32 chirps x 64 bins) + dec_i = np.load(os.path.join(self.COSIM_DIR, "decimated_range_i.npy")) + dec_q = np.load(os.path.join(self.COSIM_DIR, "decimated_range_q.npy")) + + # Build fake 1024-sample chirps from decimated data (pad with zeros) + n_chirps = dec_i.shape[0] + iq_i = np.zeros((n_chirps, 1024), dtype=np.int64) + iq_q = np.zeros((n_chirps, 1024), dtype=np.int64) + # Put decimated data into first 64 bins so FFT has something + iq_i[:, :dec_i.shape[1]] = dec_i + iq_q[:, :dec_q.shape[1]] = dec_q + + fpga = SoftwareFPGA() + frame = fpga.process_chirps(iq_i, iq_q, frame_number=42, timestamp=1.0) + + self.assertIsInstance(frame, RadarFrame) + self.assertEqual(frame.frame_number, 42) + self.assertAlmostEqual(frame.timestamp, 1.0) + self.assertEqual(frame.range_doppler_i.shape, (64, 32)) + self.assertEqual(frame.range_doppler_q.shape, (64, 32)) + self.assertEqual(frame.magnitude.shape, (64, 32)) + self.assertEqual(frame.detections.shape, (64, 32)) + self.assertEqual(frame.range_profile.shape, (64,)) + self.assertEqual(frame.detection_count, int(frame.detections.sum())) + + def test_cfar_enable_changes_detections(self): + """Enabling CFAR vs simple threshold should yield different detection counts.""" + if not self._cosim_available(): + self.skipTest("co-sim data not found") + from v7.software_fpga import SoftwareFPGA + + iq_i = np.zeros((32, 1024), dtype=np.int64) + iq_q = np.zeros((32, 1024), dtype=np.int64) + # Inject a single strong tone in bin 10 of every chirp + iq_i[:, 10] = 5000 + iq_q[:, 10] = 3000 + + fpga_thresh = SoftwareFPGA() + fpga_thresh.set_detect_threshold(1) # very low → many detections + frame_thresh = fpga_thresh.process_chirps(iq_i, iq_q) + + fpga_cfar = SoftwareFPGA() + fpga_cfar.set_cfar_enable(True) + fpga_cfar.set_cfar_alpha(0x10) # low alpha → more detections + frame_cfar = fpga_cfar.process_chirps(iq_i, iq_q) + + # Just verify both produce valid frames — exact counts depend on chain + self.assertIsNotNone(frame_thresh) + self.assertIsNotNone(frame_cfar) + self.assertEqual(frame_thresh.magnitude.shape, (64, 32)) + self.assertEqual(frame_cfar.magnitude.shape, (64, 32)) + + +class TestQuantizeRawIQ(unittest.TestCase): + """quantize_raw_iq utility function.""" + + def test_3d_input(self): + """3-D (frames, chirps, samples) → uses first frame.""" + from v7.software_fpga import quantize_raw_iq + raw = np.random.randn(5, 32, 1024) + 1j * np.random.randn(5, 32, 1024) + iq_i, iq_q = quantize_raw_iq(raw) + self.assertEqual(iq_i.shape, (32, 1024)) + self.assertEqual(iq_q.shape, (32, 1024)) + self.assertTrue(np.all(np.abs(iq_i) <= 32767)) + self.assertTrue(np.all(np.abs(iq_q) <= 32767)) + + def test_2d_input(self): + """2-D (chirps, samples) → works directly.""" + from v7.software_fpga import quantize_raw_iq + raw = np.random.randn(32, 1024) + 1j * np.random.randn(32, 1024) + iq_i, _iq_q = quantize_raw_iq(raw) + self.assertEqual(iq_i.shape, (32, 1024)) + + def test_zero_input(self): + """All-zero complex input → all-zero output.""" + from v7.software_fpga import quantize_raw_iq + raw = np.zeros((32, 1024), dtype=np.complex128) + iq_i, iq_q = quantize_raw_iq(raw) + self.assertTrue(np.all(iq_i == 0)) + self.assertTrue(np.all(iq_q == 0)) + + def test_peak_target_scaling(self): + """Peak of output should be near peak_target.""" + from v7.software_fpga import quantize_raw_iq + raw = np.zeros((32, 1024), dtype=np.complex128) + raw[0, 0] = 1.0 + 0j # single peak + iq_i, _iq_q = quantize_raw_iq(raw, peak_target=500) + # The peak I value should be exactly 500 (sole max) + self.assertEqual(int(iq_i[0, 0]), 500) + + +# ============================================================================= +# Test: v7.replay (ReplayEngine, detect_format) +# ============================================================================= + +class TestDetectFormat(unittest.TestCase): + """detect_format auto-detection logic.""" + + COSIM_DIR = os.path.join( + os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim", + "real_data", "hex" + ) + + def test_cosim_dir(self): + if not os.path.isdir(self.COSIM_DIR): + self.skipTest("co-sim dir not found") + from v7.replay import detect_format, ReplayFormat + self.assertEqual(detect_format(self.COSIM_DIR), ReplayFormat.COSIM_DIR) + + def test_npy_file(self): + """A .npy file → RAW_IQ_NPY.""" + from v7.replay import detect_format, ReplayFormat + import tempfile + with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f: + np.save(f, np.zeros((2, 32, 1024), dtype=np.complex128)) + tmp = f.name + try: + self.assertEqual(detect_format(tmp), ReplayFormat.RAW_IQ_NPY) + finally: + os.unlink(tmp) + + def test_h5_file(self): + """A .h5 file → HDF5.""" + from v7.replay import detect_format, ReplayFormat + self.assertEqual(detect_format("/tmp/fake_recording.h5"), ReplayFormat.HDF5) + + def test_unknown_extension_raises(self): + from v7.replay import detect_format + with self.assertRaises(ValueError): + detect_format("/tmp/data.csv") + + def test_empty_dir_raises(self): + """Directory without co-sim files → ValueError.""" + from v7.replay import detect_format + import tempfile + with tempfile.TemporaryDirectory() as td, self.assertRaises(ValueError): + detect_format(td) + + +class TestReplayEngineCosim(unittest.TestCase): + """ReplayEngine loading from FPGA co-sim directory.""" + + COSIM_DIR = os.path.join( + os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim", + "real_data", "hex" + ) + + def _available(self): + return os.path.isfile(os.path.join(self.COSIM_DIR, "doppler_map_i.npy")) + + def test_load_cosim(self): + if not self._available(): + self.skipTest("co-sim data not found") + from v7.replay import ReplayEngine, ReplayFormat + engine = ReplayEngine(self.COSIM_DIR) + self.assertEqual(engine.fmt, ReplayFormat.COSIM_DIR) + self.assertEqual(engine.total_frames, 1) + + def test_get_frame_cosim(self): + if not self._available(): + self.skipTest("co-sim data not found") + from v7.replay import ReplayEngine + from radar_protocol import RadarFrame + engine = ReplayEngine(self.COSIM_DIR) + frame = engine.get_frame(0) + self.assertIsInstance(frame, RadarFrame) + self.assertEqual(frame.range_doppler_i.shape, (64, 32)) + self.assertEqual(frame.magnitude.shape, (64, 32)) + + def test_get_frame_out_of_range(self): + if not self._available(): + self.skipTest("co-sim data not found") + from v7.replay import ReplayEngine + engine = ReplayEngine(self.COSIM_DIR) + with self.assertRaises(IndexError): + engine.get_frame(1) + with self.assertRaises(IndexError): + engine.get_frame(-1) + + +class TestReplayEngineRawIQ(unittest.TestCase): + """ReplayEngine loading from raw IQ .npy cube.""" + + def test_load_raw_iq_synthetic(self): + """Synthetic raw IQ cube loads and produces correct frame count.""" + import tempfile + from v7.replay import ReplayEngine, ReplayFormat + from v7.software_fpga import SoftwareFPGA + + raw = np.random.randn(3, 32, 1024) + 1j * np.random.randn(3, 32, 1024) + with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f: + np.save(f, raw) + tmp = f.name + try: + fpga = SoftwareFPGA() + engine = ReplayEngine(tmp, software_fpga=fpga) + self.assertEqual(engine.fmt, ReplayFormat.RAW_IQ_NPY) + self.assertEqual(engine.total_frames, 3) + finally: + os.unlink(tmp) + + def test_get_frame_raw_iq_synthetic(self): + """get_frame on raw IQ runs SoftwareFPGA and returns RadarFrame.""" + import tempfile + from v7.replay import ReplayEngine + from v7.software_fpga import SoftwareFPGA + from radar_protocol import RadarFrame + + raw = np.random.randn(2, 32, 1024) + 1j * np.random.randn(2, 32, 1024) + with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f: + np.save(f, raw) + tmp = f.name + try: + fpga = SoftwareFPGA() + engine = ReplayEngine(tmp, software_fpga=fpga) + frame = engine.get_frame(0) + self.assertIsInstance(frame, RadarFrame) + self.assertEqual(frame.range_doppler_i.shape, (64, 32)) + self.assertEqual(frame.frame_number, 0) + finally: + os.unlink(tmp) + + def test_raw_iq_no_fpga_raises(self): + """Raw IQ get_frame without SoftwareFPGA → RuntimeError.""" + import tempfile + from v7.replay import ReplayEngine + + raw = np.random.randn(1, 32, 1024) + 1j * np.random.randn(1, 32, 1024) + with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f: + np.save(f, raw) + tmp = f.name + try: + engine = ReplayEngine(tmp) + with self.assertRaises(RuntimeError): + engine.get_frame(0) + finally: + os.unlink(tmp) + + +class TestReplayEngineHDF5(unittest.TestCase): + """ReplayEngine loading from HDF5 recordings.""" + + def _skip_no_h5py(self): + try: + import h5py # noqa: F401 + except ImportError: + self.skipTest("h5py not installed") + + def test_load_hdf5_synthetic(self): + """Synthetic HDF5 loads and iterates frames.""" + self._skip_no_h5py() + import tempfile + import h5py + from v7.replay import ReplayEngine, ReplayFormat + from radar_protocol import RadarFrame + + with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f: + tmp = f.name + + try: + with h5py.File(tmp, "w") as hf: + hf.attrs["creator"] = "test" + hf.attrs["range_bins"] = 64 + hf.attrs["doppler_bins"] = 32 + grp = hf.create_group("frames") + for i in range(3): + fg = grp.create_group(f"frame_{i:06d}") + fg.attrs["timestamp"] = float(i) + fg.attrs["frame_number"] = i + fg.attrs["detection_count"] = 0 + fg.create_dataset("range_doppler_i", + data=np.zeros((64, 32), dtype=np.int16)) + fg.create_dataset("range_doppler_q", + data=np.zeros((64, 32), dtype=np.int16)) + fg.create_dataset("magnitude", + data=np.zeros((64, 32), dtype=np.float64)) + fg.create_dataset("detections", + data=np.zeros((64, 32), dtype=np.uint8)) + fg.create_dataset("range_profile", + data=np.zeros(64, dtype=np.float64)) + + engine = ReplayEngine(tmp) + self.assertEqual(engine.fmt, ReplayFormat.HDF5) + self.assertEqual(engine.total_frames, 3) + + frame = engine.get_frame(1) + self.assertIsInstance(frame, RadarFrame) + self.assertEqual(frame.frame_number, 1) + self.assertEqual(frame.range_doppler_i.shape, (64, 32)) + engine.close() + finally: + os.unlink(tmp) + + +# ============================================================================= +# Test: v7.processing.extract_targets_from_frame +# ============================================================================= + +class TestExtractTargetsFromFrame(unittest.TestCase): + """extract_targets_from_frame bin-to-physical conversion.""" + + def _make_frame(self, det_cells=None): + """Create a minimal RadarFrame with optional detection cells.""" + from radar_protocol import RadarFrame + frame = RadarFrame() + if det_cells: + for rbin, dbin in det_cells: + frame.detections[rbin, dbin] = 1 + frame.magnitude[rbin, dbin] = 1000.0 + frame.detection_count = int(frame.detections.sum()) + frame.timestamp = 1.0 + return frame + + def test_no_detections(self): + from v7.processing import extract_targets_from_frame + frame = self._make_frame() + targets = extract_targets_from_frame(frame) + self.assertEqual(len(targets), 0) + + def test_single_detection_range(self): + """Detection at range bin 10 → range = 10 * range_resolution.""" + from v7.processing import extract_targets_from_frame + frame = self._make_frame(det_cells=[(10, 16)]) # dbin=16 = center → vel=0 + targets = extract_targets_from_frame(frame, range_resolution=5.621) + self.assertEqual(len(targets), 1) + self.assertAlmostEqual(targets[0].range, 10 * 5.621, places=2) + self.assertAlmostEqual(targets[0].velocity, 0.0, places=2) + + def test_velocity_sign(self): + """Doppler bin < center → negative velocity, > center → positive.""" + from v7.processing import extract_targets_from_frame + frame = self._make_frame(det_cells=[(5, 10), (5, 20)]) + targets = extract_targets_from_frame(frame, velocity_resolution=1.484) + # dbin=10: vel = (10-16)*1.484 = -8.904 (approaching) + # dbin=20: vel = (20-16)*1.484 = +5.936 (receding) + self.assertLess(targets[0].velocity, 0) + self.assertGreater(targets[1].velocity, 0) + + def test_snr_positive_for_nonzero_mag(self): + from v7.processing import extract_targets_from_frame + frame = self._make_frame(det_cells=[(3, 16)]) + targets = extract_targets_from_frame(frame) + self.assertGreater(targets[0].snr, 0) + + def test_gps_georef(self): + """With GPS data, targets get non-zero lat/lon.""" + from v7.processing import extract_targets_from_frame + from v7.models import GPSData + gps = GPSData(latitude=41.9, longitude=12.5, altitude=0.0, + pitch=0.0, heading=90.0) + frame = self._make_frame(det_cells=[(10, 16)]) + targets = extract_targets_from_frame( + frame, range_resolution=100.0, gps=gps) + # Should be roughly east of radar position + self.assertAlmostEqual(targets[0].latitude, 41.9, places=2) + self.assertGreater(targets[0].longitude, 12.5) + + def test_multiple_detections(self): + from v7.processing import extract_targets_from_frame + frame = self._make_frame(det_cells=[(0, 0), (10, 10), (63, 31)]) + targets = extract_targets_from_frame(frame) + self.assertEqual(len(targets), 3) + # IDs should be sequential 0, 1, 2 + self.assertEqual([t.id for t in targets], [0, 1, 2]) + + # ============================================================================= # Helper: lazy import of v7.models # ============================================================================= diff --git a/9_Firmware/9_3_GUI/v7/__init__.py b/9_Firmware/9_3_GUI/v7/__init__.py index 175da91..1da6cdb 100644 --- a/9_Firmware/9_3_GUI/v7/__init__.py +++ b/9_Firmware/9_3_GUI/v7/__init__.py @@ -14,6 +14,7 @@ from .models import ( GPSData, ProcessingConfig, TileServer, + WaveformConfig, DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, DARK_TREEVIEW, DARK_TREEVIEW_ALT, @@ -25,7 +26,6 @@ from .models import ( # Hardware interfaces — production protocol via radar_protocol.py from .hardware import ( FT2232HConnection, - ReplayConnection, RadarProtocol, Opcode, RadarAcquisition, @@ -40,8 +40,22 @@ from .processing import ( RadarProcessor, USBPacketParser, apply_pitch_correction, + polar_to_geographic, + extract_targets_from_frame, ) +# Software FPGA (depends on golden_reference.py in FPGA cosim tree) +try: # noqa: SIM105 + from .software_fpga import SoftwareFPGA, quantize_raw_iq +except ImportError: # golden_reference.py not available (e.g. deployment without FPGA tree) + pass + +# Replay engine (no PyQt6 dependency, but needs SoftwareFPGA for raw IQ path) +try: # noqa: SIM105 + from .replay import ReplayEngine, ReplayFormat +except ImportError: # software_fpga unavailable → replay also unavailable + pass + # Workers, map widget, and dashboard require PyQt6 — import lazily so that # tests/CI environments without PyQt6 can still access models/hardware/processing. try: @@ -49,7 +63,7 @@ try: RadarDataWorker, GPSDataWorker, TargetSimulator, - polar_to_geographic, + ReplayWorker, ) from .map_widget import ( @@ -67,6 +81,7 @@ except ImportError: # PyQt6 not installed (e.g. CI headless runner) __all__ = [ # noqa: RUF022 # models "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer", + "WaveformConfig", "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER", "DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER", "DARK_TREEVIEW", "DARK_TREEVIEW_ALT", @@ -74,15 +89,18 @@ __all__ = [ # noqa: RUF022 "USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE", "SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", # hardware — production FPGA protocol - "FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode", + "FT2232HConnection", "RadarProtocol", "Opcode", "RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder", "STM32USBInterface", # processing "RadarProcessor", "USBPacketParser", - "apply_pitch_correction", + "apply_pitch_correction", "polar_to_geographic", + "extract_targets_from_frame", + # software FPGA + replay + "SoftwareFPGA", "quantize_raw_iq", + "ReplayEngine", "ReplayFormat", # workers - "RadarDataWorker", "GPSDataWorker", "TargetSimulator", - "polar_to_geographic", + "RadarDataWorker", "GPSDataWorker", "TargetSimulator", "ReplayWorker", # map "MapBridge", "RadarMapWidget", # dashboard diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index 1b3b2a8..c7e2c64 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -14,7 +14,7 @@ RadarDashboard is a QMainWindow with six tabs: Uses production radar_protocol.py for all FPGA communication: - FT2232HConnection for real hardware - - ReplayConnection for offline .npy replay + - Unified replay via SoftwareFPGA + ReplayEngine + ReplayWorker - Mock mode (FT2232HConnection(mock=True)) for development The old STM32 magic-packet start flow has been removed. FPGA registers @@ -22,9 +22,12 @@ are controlled directly via 4-byte {opcode, addr, value_hi, value_lo} commands sent over FT2232H. """ +from __future__ import annotations + import time import logging from collections import deque +from typing import TYPE_CHECKING import numpy as np @@ -32,7 +35,7 @@ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QTabWidget, QSplitter, QGroupBox, QFrame, QScrollArea, QLabel, QPushButton, QComboBox, QCheckBox, - QDoubleSpinBox, QSpinBox, QLineEdit, + QDoubleSpinBox, QSpinBox, QLineEdit, QSlider, QFileDialog, QTableWidget, QTableWidgetItem, QHeaderView, QPlainTextEdit, QStatusBar, QMessageBox, ) @@ -52,7 +55,6 @@ from .models import ( ) from .hardware import ( FT2232HConnection, - ReplayConnection, RadarProtocol, RadarFrame, StatusResponse, @@ -60,9 +62,13 @@ from .hardware import ( STM32USBInterface, ) from .processing import RadarProcessor, USBPacketParser -from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator +from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator, ReplayWorker from .map_widget import RadarMapWidget +if TYPE_CHECKING: + from .software_fpga import SoftwareFPGA + from .replay import ReplayEngine + logger = logging.getLogger(__name__) # Frame dimensions from FPGA @@ -153,6 +159,12 @@ class RadarDashboard(QMainWindow): self._gps_worker: GPSDataWorker | None = None self._simulator: TargetSimulator | None = None + # Replay-specific objects (created when entering replay mode) + self._replay_worker: ReplayWorker | None = None + self._replay_engine: ReplayEngine | None = None + self._software_fpga: SoftwareFPGA | None = None + self._replay_mode = False + # State self._running = False self._demo_mode = False @@ -352,7 +364,7 @@ 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"]) self._mode_combo.setCurrentIndex(0) ctrl_layout.addWidget(self._mode_combo, 0, 1) @@ -401,6 +413,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: replay transport controls (hidden until replay mode) + self._replay_file_label = QLabel("No file loaded") + self._replay_file_label.setMinimumWidth(200) + ctrl_layout.addWidget(self._replay_file_label, 2, 0, 1, 2) + + self._replay_browse_btn = QPushButton("Browse...") + self._replay_browse_btn.clicked.connect(self._browse_replay_file) + ctrl_layout.addWidget(self._replay_browse_btn, 2, 2) + + self._replay_play_btn = QPushButton("Play") + self._replay_play_btn.clicked.connect(self._replay_play_pause) + ctrl_layout.addWidget(self._replay_play_btn, 2, 3) + + self._replay_stop_btn = QPushButton("Stop") + self._replay_stop_btn.clicked.connect(self._replay_stop) + ctrl_layout.addWidget(self._replay_stop_btn, 2, 4) + + self._replay_slider = QSlider(Qt.Orientation.Horizontal) + self._replay_slider.setMinimum(0) + self._replay_slider.setMaximum(0) + self._replay_slider.valueChanged.connect(self._replay_seek) + ctrl_layout.addWidget(self._replay_slider, 2, 5, 1, 2) + + self._replay_frame_label = QLabel("0 / 0") + ctrl_layout.addWidget(self._replay_frame_label, 2, 7) + + self._replay_speed_combo = QComboBox() + self._replay_speed_combo.addItems(["50 ms", "100 ms", "200 ms", "500 ms"]) + self._replay_speed_combo.setCurrentIndex(1) + self._replay_speed_combo.currentIndexChanged.connect(self._replay_speed_changed) + ctrl_layout.addWidget(self._replay_speed_combo, 2, 8) + + self._replay_loop_cb = QCheckBox("Loop") + self._replay_loop_cb.stateChanged.connect(self._replay_loop_changed) + ctrl_layout.addWidget(self._replay_loop_cb, 2, 9) + + # Collect replay widgets to toggle visibility + self._replay_controls = [ + self._replay_file_label, self._replay_browse_btn, + self._replay_play_btn, self._replay_stop_btn, + self._replay_slider, self._replay_frame_label, + self._replay_speed_combo, self._replay_loop_cb, + ] + for w in self._replay_controls: + w.setVisible(False) + + # Show/hide replay row when mode changes + self._mode_combo.currentTextChanged.connect(self._on_mode_changed) + layout.addWidget(ctrl) # ---- Display area (range-doppler + targets table) ------------------ @@ -1175,7 +1236,11 @@ class RadarDashboard(QMainWindow): logger.error(f"Failed to send FPGA cmd: 0x{opcode:02X}") def _send_fpga_validated(self, opcode: int, value: int, bits: int): - """Clamp value to bit-width and send.""" + """Clamp value to bit-width and send. + + In replay mode, also dispatch to the SoftwareFPGA setter and + re-process the current frame so the user sees immediate effect. + """ max_val = (1 << bits) - 1 clamped = max(0, min(value, max_val)) if clamped != value: @@ -1185,7 +1250,18 @@ class RadarDashboard(QMainWindow): key = f"0x{opcode:02X}" if key in self._param_spins: self._param_spins[key].setValue(clamped) - self._send_fpga_cmd(opcode, clamped) + + # Dispatch to real FPGA (live/mock mode) + if not self._replay_mode: + self._send_fpga_cmd(opcode, clamped) + return + + # Dispatch to SoftwareFPGA (replay mode) + if self._software_fpga is not None: + self._dispatch_to_software_fpga(opcode, clamped) + # Re-process current frame so the effect is visible immediately + if self._replay_worker is not None: + self._replay_worker.seek(self._replay_worker.current_index) def _send_custom_command(self): """Send custom opcode + value from the FPGA Control tab.""" @@ -1210,32 +1286,104 @@ class RadarDashboard(QMainWindow): mode = self._mode_combo.currentText() if "Mock" in mode: + self._replay_mode = False self._connection = FT2232HConnection(mock=True) if not self._connection.open(): QMessageBox.critical(self, "Error", "Failed to open mock connection.") return elif "Live" in mode: + self._replay_mode = False self._connection = FT2232HConnection(mock=False) if not self._connection.open(): QMessageBox.critical(self, "Error", "Failed to open FT2232H. Check USB connection.") return elif "Replay" in mode: - from PyQt6.QtWidgets import QFileDialog - npy_dir = QFileDialog.getExistingDirectory( - self, "Select .npy replay directory") - if not npy_dir: + self._replay_mode = True + replay_path = self._replay_file_label.text() + if replay_path == "No file loaded" or not replay_path: + QMessageBox.warning( + self, "Replay", + "Use 'Browse...' to select a replay" + " file or directory first.") return - self._connection = ReplayConnection(npy_dir) - if not self._connection.open(): - QMessageBox.critical(self, "Error", - "Failed to open replay connection.") + + from .software_fpga import SoftwareFPGA + from .replay import ReplayEngine + + self._software_fpga = SoftwareFPGA() + # Enable CFAR by default for raw IQ replay (avoids 2000+ detections) + self._software_fpga.set_cfar_enable(True) + + try: + self._replay_engine = ReplayEngine( + replay_path, self._software_fpga) + except (OSError, ValueError, RuntimeError) as exc: + QMessageBox.critical(self, "Replay Error", + f"Failed to open replay data:\n{exc}") + self._software_fpga = None return + + if self._replay_engine.total_frames == 0: + QMessageBox.warning(self, "Replay", "No frames found in the selected source.") + self._replay_engine.close() + self._replay_engine = None + self._software_fpga = None + return + + speed_map = {0: 50, 1: 100, 2: 200, 3: 500} + interval = speed_map.get(self._replay_speed_combo.currentIndex(), 100) + + self._replay_worker = ReplayWorker( + replay_engine=self._replay_engine, + settings=self._settings, + gps=self._radar_position, + frame_interval_ms=interval, + ) + self._replay_worker.frameReady.connect(self._on_frame_ready) + 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) + self._replay_worker.set_loop(self._replay_loop_cb.isChecked()) + + self._replay_slider.setMaximum( + self._replay_engine.total_frames - 1) + self._replay_slider.setValue(0) + self._replay_frame_label.setText( + f"0 / {self._replay_engine.total_frames}") + + self._replay_worker.start() + # Update CFAR enable spinbox to reflect default-on for replay + if "0x25" in self._param_spins: + self._param_spins["0x25"].setValue(1) + + # 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._demo_btn_main.setEnabled(False) + self._demo_btn_map.setEnabled(False) + n_frames = self._replay_engine.total_frames + self._status_label_main.setText( + f"Status: Replay ({n_frames} frames)") + self._sb_status.setText(f"Replay ({n_frames} frames)") + self._sb_mode.setText("Replay") + logger.info( + "Replay started: %s (%d frames)", + replay_path, n_frames) + return else: QMessageBox.warning(self, "Warning", "Unknown connection mode.") return - # Start radar worker + # Start radar worker (mock / live — NOT replay) self._radar_worker = RadarDataWorker( connection=self._connection, processor=self._processor, @@ -1288,6 +1436,18 @@ class RadarDashboard(QMainWindow): self._radar_worker.wait(2000) self._radar_worker = None + if self._replay_worker: + self._replay_worker.stop() + self._replay_worker.wait(2000) + self._replay_worker = None + + if self._replay_engine: + self._replay_engine.close() + self._replay_engine = None + + self._software_fpga = None + self._replay_mode = False + if self._gps_worker: self._gps_worker.stop() self._gps_worker.wait(2000) @@ -1309,6 +1469,113 @@ class RadarDashboard(QMainWindow): self._sb_mode.setText("Idle") logger.info("Radar system stopped") + # ===================================================================== + # Replay helpers + # ===================================================================== + + def _on_mode_changed(self, text: str): + """Show/hide replay transport controls based on mode selection.""" + is_replay = "Replay" in text + for w in self._replay_controls: + w.setVisible(is_replay) + + def _browse_replay_file(self): + """Open file/directory picker for replay source.""" + path, _ = QFileDialog.getOpenFileName( + self, "Select replay file", + "", + "All supported (*.npy *.h5);;NumPy files (*.npy);;HDF5 files (*.h5);;All files (*)", + ) + if path: + self._replay_file_label.setText(path) + return + # If no file selected, try directory (for co-sim) + dir_path = QFileDialog.getExistingDirectory( + self, "Select co-sim replay directory") + if dir_path: + self._replay_file_label.setText(dir_path) + + def _replay_play_pause(self): + """Toggle play/pause on the replay worker.""" + if self._replay_worker is None: + return + if self._replay_worker.is_playing: + self._replay_worker.pause() + self._replay_play_btn.setText("Play") + else: + self._replay_worker.play() + self._replay_play_btn.setText("Pause") + + def _replay_stop(self): + """Stop replay playback (keeps data loaded).""" + if self._replay_worker is not None: + self._replay_worker.pause() + self._replay_worker.seek(0) + self._replay_play_btn.setText("Play") + + def _replay_seek(self, value: int): + """Seek to a specific frame from the slider.""" + if self._replay_worker is not None and not self._replay_worker.is_playing: + self._replay_worker.seek(value) + + def _replay_speed_changed(self, index: int): + """Update replay frame interval from speed combo.""" + speed_map = {0: 50, 1: 100, 2: 200, 3: 500} + ms = speed_map.get(index, 100) + if self._replay_worker is not None: + self._replay_worker.set_frame_interval(ms) + + def _replay_loop_changed(self, state: int): + """Update replay loop setting.""" + if self._replay_worker is not None: + self._replay_worker.set_loop(state == Qt.CheckState.Checked.value) + + @pyqtSlot(str) + def _on_playback_state_changed(self, state: str): + """Update UI when replay playback state changes.""" + if state == "playing": + self._replay_play_btn.setText("Pause") + elif state in ("paused", "stopped"): + self._replay_play_btn.setText("Play") + if state == "stopped" and self._replay_worker is not None: + self._status_label_main.setText("Status: Replay finished") + + @pyqtSlot(int, int) + def _on_frame_index_changed(self, current: int, total: int): + """Update slider and frame label from replay worker.""" + self._replay_slider.blockSignals(True) + self._replay_slider.setValue(current) + self._replay_slider.blockSignals(False) + self._replay_frame_label.setText(f"{current} / {total}") + + def _dispatch_to_software_fpga(self, opcode: int, value: int): + """Route an FPGA opcode+value to the SoftwareFPGA setter.""" + fpga = self._software_fpga + if fpga is None: + return + _opcode_dispatch = { + 0x03: lambda v: fpga.set_detect_threshold(v), + 0x16: lambda v: fpga.set_gain_shift(v), + 0x21: lambda v: fpga.set_cfar_guard(v), + 0x22: lambda v: fpga.set_cfar_train(v), + 0x23: lambda v: fpga.set_cfar_alpha(v), + 0x24: lambda v: fpga.set_cfar_mode(v), + 0x25: lambda v: fpga.set_cfar_enable(bool(v)), + 0x26: lambda v: fpga.set_mti_enable(bool(v)), + 0x27: lambda v: fpga.set_dc_notch_width(v), + 0x28: lambda v: fpga.set_agc_enable(bool(v)), + 0x29: lambda v: fpga.set_agc_params(target=v), + 0x2A: lambda v: fpga.set_agc_params(attack=v), + 0x2B: lambda v: fpga.set_agc_params(decay=v), + 0x2C: lambda v: fpga.set_agc_params(holdoff=v), + } + handler = _opcode_dispatch.get(opcode) + if handler is not None: + handler(value) + logger.info(f"SoftwareFPGA: 0x{opcode:02X} = {value}") + else: + logger.debug(f"SoftwareFPGA: opcode 0x{opcode:02X} not handled (no-op)") + # ===================================================================== # Demo mode # ===================================================================== @@ -1338,7 +1605,7 @@ class RadarDashboard(QMainWindow): self._demo_mode = False if not self._running: mode = "Idle" - elif isinstance(self._connection, ReplayConnection): + elif self._replay_mode: mode = "Replay" else: mode = "Live" diff --git a/9_Firmware/9_3_GUI/v7/hardware.py b/9_Firmware/9_3_GUI/v7/hardware.py index e0231ef..84bbb9a 100644 --- a/9_Firmware/9_3_GUI/v7/hardware.py +++ b/9_Firmware/9_3_GUI/v7/hardware.py @@ -3,14 +3,11 @@ v7.hardware — Hardware interface classes for the PLFM Radar GUI V7. Provides: - FT2232H radar data + command interface via production radar_protocol module - - ReplayConnection for offline .npy replay via production radar_protocol module - STM32USBInterface for GPS data only (USB CDC) The FT2232H interface uses the production protocol layer (radar_protocol.py) which sends 4-byte {opcode, addr, value_hi, value_lo} register commands and -parses 0xAA data / 0xBB status packets from the FPGA. The old magic-packet -and 'SET'...'END' binary settings protocol has been removed — it was -incompatible with the FPGA register interface. +parses 0xAA data / 0xBB status packets from the FPGA. """ import sys @@ -28,7 +25,6 @@ if USB_AVAILABLE: sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from radar_protocol import ( # noqa: F401 — re-exported for v7 package FT2232HConnection, - ReplayConnection, RadarProtocol, Opcode, RadarAcquisition, diff --git a/9_Firmware/9_3_GUI/v7/models.py b/9_Firmware/9_3_GUI/v7/models.py index a5eb40e..c4b277c 100644 --- a/9_Firmware/9_3_GUI/v7/models.py +++ b/9_Firmware/9_3_GUI/v7/models.py @@ -186,3 +186,59 @@ class TileServer(Enum): GOOGLE_SATELLITE = "google_sat" GOOGLE_HYBRID = "google_hybrid" ESRI_SATELLITE = "esri_sat" + + +# --------------------------------------------------------------------------- +# Waveform configuration (physical parameters for bin→unit conversion) +# --------------------------------------------------------------------------- + +@dataclass +class WaveformConfig: + """Physical waveform parameters for converting bins to SI units. + + Encapsulates the radar waveform so that range/velocity resolution + can be derived automatically instead of hardcoded in RadarSettings. + + Defaults match the ADI CN0566 Phaser capture parameters used in + the golden_reference cosim (4 MSPS, 500 MHz BW, 300 us chirp). + """ + + sample_rate_hz: float = 4e6 # ADC sample rate + bandwidth_hz: float = 500e6 # Chirp bandwidth + chirp_duration_s: float = 300e-6 # Chirp ramp time + center_freq_hz: float = 10.525e9 # Carrier frequency + n_range_bins: int = 64 # After decimation + n_doppler_bins: int = 32 # After Doppler FFT + fft_size: int = 1024 # Pre-decimation FFT length + decimation_factor: int = 16 # 1024 → 64 + + @property + def range_resolution_m(self) -> float: + """Meters per decimated range bin (FMCW deramped baseband). + + For deramped FMCW: bin spacing = c * Fs * T / (2 * N_FFT * BW). + After decimation the bin spacing grows by *decimation_factor*. + """ + c = 299_792_458.0 + raw_bin = ( + c * self.sample_rate_hz * self.chirp_duration_s + / (2.0 * self.fft_size * self.bandwidth_hz) + ) + return raw_bin * self.decimation_factor + + @property + def velocity_resolution_mps(self) -> float: + """m/s per Doppler bin. lambda / (2 * n_doppler * chirp_duration).""" + c = 299_792_458.0 + wavelength = c / self.center_freq_hz + return wavelength / (2.0 * self.n_doppler_bins * self.chirp_duration_s) + + @property + def max_range_m(self) -> float: + """Maximum unambiguous range in meters.""" + return self.range_resolution_m * self.n_range_bins + + @property + def max_velocity_mps(self) -> float: + """Maximum unambiguous velocity (±) in m/s.""" + return self.velocity_resolution_mps * self.n_doppler_bins / 2.0 diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py index c6ce2cd..c601097 100644 --- a/9_Firmware/9_3_GUI/v7/processing.py +++ b/9_Firmware/9_3_GUI/v7/processing.py @@ -451,3 +451,103 @@ class USBPacketParser: except (ValueError, struct.error) as e: logger.error(f"Error parsing binary GPS: {e}") return None + + +# ============================================================================ +# Utility: polar → geographic coordinate conversion +# ============================================================================ + +def polar_to_geographic( + radar_lat: float, + radar_lon: float, + range_m: float, + azimuth_deg: float, +) -> tuple: + """Convert polar (range, azimuth) relative to radar → (lat, lon). + + azimuth_deg: 0 = North, clockwise. + """ + r_earth = 6_371_000.0 # Earth radius in metres + + lat1 = math.radians(radar_lat) + lon1 = math.radians(radar_lon) + bearing = math.radians(azimuth_deg) + + lat2 = math.asin( + math.sin(lat1) * math.cos(range_m / r_earth) + + math.cos(lat1) * math.sin(range_m / r_earth) * math.cos(bearing) + ) + lon2 = lon1 + math.atan2( + math.sin(bearing) * math.sin(range_m / r_earth) * math.cos(lat1), + math.cos(range_m / r_earth) - math.sin(lat1) * math.sin(lat2), + ) + return (math.degrees(lat2), math.degrees(lon2)) + + +# ============================================================================ +# Shared target extraction (used by both RadarDataWorker and ReplayWorker) +# ============================================================================ + +def extract_targets_from_frame( + frame, + range_resolution: float = 1.0, + velocity_resolution: float = 1.0, + gps: GPSData | None = None, +) -> list[RadarTarget]: + """Extract RadarTarget list from a RadarFrame's detection mask. + + This is the bin-to-physical conversion + geo-mapping shared between + the live and replay data paths. + + Parameters + ---------- + frame : RadarFrame + Frame with populated ``detections``, ``magnitude``, ``range_doppler_i/q``. + range_resolution : float + Meters per range bin. + velocity_resolution : float + m/s per Doppler bin. + gps : GPSData | None + GPS position for geo-mapping (latitude/longitude). + + Returns + ------- + list[RadarTarget] + One target per detection cell. + """ + det_indices = np.argwhere(frame.detections > 0) + n_doppler = frame.detections.shape[1] if frame.detections.ndim == 2 else 32 + doppler_center = n_doppler // 2 + + targets: list[RadarTarget] = [] + for idx in det_indices: + rbin, dbin = int(idx[0]), int(idx[1]) + mag = float(frame.magnitude[rbin, dbin]) + snr = 10.0 * math.log10(max(mag, 1.0)) if mag > 0 else 0.0 + + range_m = float(rbin) * range_resolution + velocity_ms = float(dbin - doppler_center) * velocity_resolution + + lat, lon, azimuth, elevation = 0.0, 0.0, 0.0, 0.0 + if gps is not None: + azimuth = gps.heading + # Spread detections across ±15° sector for single-beam radar + if len(det_indices) > 1: + spread = (dbin - doppler_center) / max(doppler_center, 1) * 15.0 + azimuth = gps.heading + spread + lat, lon = polar_to_geographic( + gps.latitude, gps.longitude, range_m, azimuth, + ) + + targets.append(RadarTarget( + id=len(targets), + range=range_m, + velocity=velocity_ms, + azimuth=azimuth, + elevation=elevation, + latitude=lat, + longitude=lon, + snr=snr, + timestamp=frame.timestamp, + )) + return targets diff --git a/9_Firmware/9_3_GUI/v7/replay.py b/9_Firmware/9_3_GUI/v7/replay.py new file mode 100644 index 0000000..3510d4b --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/replay.py @@ -0,0 +1,288 @@ +""" +v7.replay — ReplayEngine: auto-detect format, load, and iterate RadarFrames. + +Supports three data sources: + 1. **FPGA co-sim directory** — pre-computed ``.npy`` files from golden_reference + 2. **Raw IQ cube** ``.npy`` — complex baseband capture (e.g. ADI Phaser) + 3. **HDF5 recording** ``.h5`` — frames captured by ``DataRecorder`` + +For raw IQ data the engine uses :class:`SoftwareFPGA` to run the full +bit-accurate signal chain, so changing FPGA control registers in the +dashboard re-processes the data. +""" + +from __future__ import annotations + +import logging +import time +from enum import Enum, auto +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from .software_fpga import SoftwareFPGA + +# radar_protocol is a sibling module (not inside v7/) +import sys as _sys + +_GUI_DIR = str(Path(__file__).resolve().parent.parent) +if _GUI_DIR not in _sys.path: + _sys.path.insert(0, _GUI_DIR) +from radar_protocol import RadarFrame # noqa: E402 + +log = logging.getLogger(__name__) + +# Lazy import — h5py is optional +try: + import h5py + + HDF5_AVAILABLE = True +except ImportError: + HDF5_AVAILABLE = False + + +class ReplayFormat(Enum): + """Detected input format.""" + + COSIM_DIR = auto() + RAW_IQ_NPY = auto() + HDF5 = auto() + + +# ─────────────────────────────────────────────────────────────────── +# Format detection +# ─────────────────────────────────────────────────────────────────── + +_COSIM_REQUIRED = {"doppler_map_i.npy", "doppler_map_q.npy"} + + +def detect_format(path: str) -> ReplayFormat: + """Auto-detect the replay data format from *path*. + + Raises + ------ + ValueError + If the format cannot be determined. + """ + p = Path(path) + + if p.is_dir(): + children = {f.name for f in p.iterdir()} + if _COSIM_REQUIRED.issubset(children): + return ReplayFormat.COSIM_DIR + msg = f"Directory {p} does not contain required co-sim files: {_COSIM_REQUIRED - children}" + raise ValueError(msg) + + if p.suffix == ".h5": + return ReplayFormat.HDF5 + + if p.suffix == ".npy": + return ReplayFormat.RAW_IQ_NPY + + msg = f"Cannot determine replay format for: {p}" + raise ValueError(msg) + + +# ─────────────────────────────────────────────────────────────────── +# ReplayEngine +# ─────────────────────────────────────────────────────────────────── + +class ReplayEngine: + """Load replay data and serve RadarFrames on demand. + + Parameters + ---------- + path : str + File or directory path to load. + software_fpga : SoftwareFPGA | None + Required only for ``RAW_IQ_NPY`` format. For other formats the + data is already processed and the FPGA instance is ignored. + """ + + def __init__(self, path: str, software_fpga: SoftwareFPGA | None = None) -> None: + self.path = path + self.fmt = detect_format(path) + self.software_fpga = software_fpga + + # Populated by _load_* + self._total_frames: int = 0 + self._raw_iq: np.ndarray | None = None # for RAW_IQ_NPY + self._h5_file = None + self._h5_keys: list[str] = [] + self._cosim_frame = None # single RadarFrame for co-sim + + self._load() + + # ------------------------------------------------------------------ + # Loading + # ------------------------------------------------------------------ + + def _load(self) -> None: + if self.fmt is ReplayFormat.COSIM_DIR: + self._load_cosim() + elif self.fmt is ReplayFormat.RAW_IQ_NPY: + self._load_raw_iq() + elif self.fmt is ReplayFormat.HDF5: + self._load_hdf5() + + def _load_cosim(self) -> None: + """Load FPGA co-sim directory (already-processed .npy arrays). + + Prefers fullchain (MTI-enabled) files when CFAR outputs are present, + so that I/Q data is consistent with the detection mask. Falls back + to the non-MTI ``doppler_map`` files when fullchain data is absent. + """ + d = Path(self.path) + + # CFAR outputs (from the MTI→Doppler→DC-notch→CFAR chain) + cfar_flags = d / "fullchain_cfar_flags.npy" + cfar_mag = d / "fullchain_cfar_mag.npy" + has_cfar = cfar_flags.exists() and cfar_mag.exists() + + # MTI-consistent I/Q (same chain that produced CFAR outputs) + mti_dop_i = d / "fullchain_mti_doppler_i.npy" + mti_dop_q = d / "fullchain_mti_doppler_q.npy" + has_mti_doppler = mti_dop_i.exists() and mti_dop_q.exists() + + # Choose I/Q: prefer MTI-chain when CFAR data comes from that chain + if has_cfar and has_mti_doppler: + dop_i = np.load(mti_dop_i).astype(np.int16) + dop_q = np.load(mti_dop_q).astype(np.int16) + log.info("Co-sim: using fullchain MTI+Doppler I/Q (matches CFAR chain)") + else: + dop_i = np.load(d / "doppler_map_i.npy").astype(np.int16) + dop_q = np.load(d / "doppler_map_q.npy").astype(np.int16) + log.info("Co-sim: using non-MTI doppler_map I/Q") + + frame = RadarFrame() + frame.range_doppler_i = dop_i + frame.range_doppler_q = dop_q + + if has_cfar: + frame.detections = np.load(cfar_flags).astype(np.uint8) + frame.magnitude = np.load(cfar_mag).astype(np.float64) + else: + frame.magnitude = np.sqrt( + dop_i.astype(np.float64) ** 2 + dop_q.astype(np.float64) ** 2 + ) + frame.detections = np.zeros_like(dop_i, dtype=np.uint8) + + frame.range_profile = frame.magnitude[:, 0] + frame.detection_count = int(frame.detections.sum()) + frame.frame_number = 0 + frame.timestamp = time.time() + + self._cosim_frame = frame + self._total_frames = 1 + log.info("Loaded co-sim directory: %s (1 frame)", self.path) + + def _load_raw_iq(self) -> None: + """Load raw complex IQ cube (.npy).""" + data = np.load(self.path, mmap_mode="r") + if data.ndim == 2: + # (chirps, samples) — single frame + data = data[np.newaxis, ...] + if data.ndim != 3: + msg = f"Expected 3-D array (frames, chirps, samples), got shape {data.shape}" + raise ValueError(msg) + self._raw_iq = data + self._total_frames = data.shape[0] + log.info( + "Loaded raw IQ: %s, shape %s (%d frames)", + self.path, + data.shape, + self._total_frames, + ) + + def _load_hdf5(self) -> None: + """Load HDF5 recording (.h5).""" + if not HDF5_AVAILABLE: + msg = "h5py is required to load HDF5 recordings" + raise ImportError(msg) + self._h5_file = h5py.File(self.path, "r") + frames_grp = self._h5_file.get("frames") + if frames_grp is None: + msg = f"HDF5 file {self.path} has no 'frames' group" + raise ValueError(msg) + self._h5_keys = sorted(frames_grp.keys()) + self._total_frames = len(self._h5_keys) + log.info("Loaded HDF5: %s (%d frames)", self.path, self._total_frames) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + @property + def total_frames(self) -> int: + return self._total_frames + + def get_frame(self, index: int) -> RadarFrame: + """Return the RadarFrame at *index* (0-based). + + For ``RAW_IQ_NPY`` format, this runs the SoftwareFPGA chain + on the requested frame's chirps. + """ + if index < 0 or index >= self._total_frames: + msg = f"Frame index {index} out of range [0, {self._total_frames})" + raise IndexError(msg) + + if self.fmt is ReplayFormat.COSIM_DIR: + return self._get_cosim(index) + if self.fmt is ReplayFormat.RAW_IQ_NPY: + return self._get_raw_iq(index) + return self._get_hdf5(index) + + def close(self) -> None: + """Release any open file handles.""" + if self._h5_file is not None: + self._h5_file.close() + self._h5_file = None + + # ------------------------------------------------------------------ + # Per-format frame getters + # ------------------------------------------------------------------ + + def _get_cosim(self, _index: int) -> RadarFrame: + """Co-sim: single static frame (index ignored). + + Uses deepcopy so numpy arrays are not shared with the source, + preventing in-place mutation from corrupting cached data. + """ + import copy + frame = copy.deepcopy(self._cosim_frame) + frame.timestamp = time.time() + return frame + + def _get_raw_iq(self, index: int) -> RadarFrame: + """Raw IQ: quantize one frame and run through SoftwareFPGA.""" + if self.software_fpga is None: + msg = "SoftwareFPGA is required for raw IQ replay" + raise RuntimeError(msg) + + from .software_fpga import quantize_raw_iq + + raw = self._raw_iq[index] # (chirps, samples) complex + iq_i, iq_q = quantize_raw_iq(raw[np.newaxis, ...]) + return self.software_fpga.process_chirps( + iq_i, iq_q, frame_number=index, timestamp=time.time() + ) + + def _get_hdf5(self, index: int) -> RadarFrame: + """HDF5: reconstruct RadarFrame from stored datasets.""" + key = self._h5_keys[index] + grp = self._h5_file["frames"][key] + + frame = RadarFrame() + frame.timestamp = float(grp.attrs.get("timestamp", time.time())) + frame.frame_number = int(grp.attrs.get("frame_number", index)) + frame.detection_count = int(grp.attrs.get("detection_count", 0)) + + frame.range_doppler_i = np.array(grp["range_doppler_i"], dtype=np.int16) + frame.range_doppler_q = np.array(grp["range_doppler_q"], dtype=np.int16) + frame.magnitude = np.array(grp["magnitude"], dtype=np.float64) + frame.detections = np.array(grp["detections"], dtype=np.uint8) + frame.range_profile = np.array(grp["range_profile"], dtype=np.float64) + + return frame diff --git a/9_Firmware/9_3_GUI/v7/software_fpga.py b/9_Firmware/9_3_GUI/v7/software_fpga.py new file mode 100644 index 0000000..9b0d669 --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/software_fpga.py @@ -0,0 +1,287 @@ +""" +v7.software_fpga — Bit-accurate software replica of the AERIS-10 FPGA signal chain. + +Imports processing functions directly from golden_reference.py (Option A) +to avoid code duplication. Every stage is toggleable via the same host +register interface the real FPGA exposes, so the dashboard spinboxes can +drive either backend transparently. + +Signal chain order (matching RTL): + quantize → range_fft → decimator → MTI → doppler_fft → dc_notch → CFAR → RadarFrame + +Usage: + fpga = SoftwareFPGA() + fpga.set_cfar_enable(True) + frame = fpga.process_chirps(iq_i, iq_q, frame_number=0) +""" + +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path + +import numpy as np + +# --------------------------------------------------------------------------- +# Import golden_reference by adding the cosim path to sys.path +# --------------------------------------------------------------------------- +_GOLDEN_REF_DIR = str( + Path(__file__).resolve().parents[2] # 9_Firmware/ + / "9_2_FPGA" / "tb" / "cosim" / "real_data" +) +if _GOLDEN_REF_DIR not in sys.path: + sys.path.insert(0, _GOLDEN_REF_DIR) + +from golden_reference import ( # noqa: E402 + run_range_fft, + run_range_bin_decimator, + run_mti_canceller, + run_doppler_fft, + run_dc_notch, + run_cfar_ca, + run_detection, + FFT_SIZE, + DOPPLER_CHIRPS, +) + +# RadarFrame lives in radar_protocol (no circular dep — protocol has no GUI) +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from radar_protocol import RadarFrame # noqa: E402 + +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_16 = str(_FPGA_DIR / "fft_twiddle_16.mem") + +# CFAR mode int→string mapping (FPGA register 0x24: 0=CA, 1=GO, 2=SO) +_CFAR_MODE_MAP = {0: "CA", 1: "GO", 2: "SO", 3: "CA"} + + +class SoftwareFPGA: + """Bit-accurate replica of the AERIS-10 FPGA signal processing chain. + + All registers mirror FPGA reset defaults from ``radar_system_top.v``. + Setters accept the same integer values as the FPGA host commands. + """ + + def __init__(self) -> None: + # --- FPGA register mirror (reset defaults) --- + # Detection + self.detect_threshold: int = 10_000 # 0x03 + self.gain_shift: int = 0 # 0x16 + + # CFAR + self.cfar_enable: bool = False # 0x25 + self.cfar_guard: int = 2 # 0x21 + self.cfar_train: int = 8 # 0x22 + self.cfar_alpha: int = 0x30 # 0x23 Q4.4 + self.cfar_mode: int = 0 # 0x24 0=CA,1=GO,2=SO + + # MTI + self.mti_enable: bool = False # 0x26 + + # DC notch + self.dc_notch_width: int = 0 # 0x27 + + # AGC (tracked but not applied in software chain — AGC operates + # on the analog front-end gain, which doesn't exist in replay) + self.agc_enable: bool = False # 0x28 + self.agc_target: int = 200 # 0x29 + self.agc_attack: int = 1 # 0x2A + self.agc_decay: int = 1 # 0x2B + self.agc_holdoff: int = 4 # 0x2C + + # ------------------------------------------------------------------ + # Register setters (same interface as UART commands to real FPGA) + # ------------------------------------------------------------------ + def set_detect_threshold(self, val: int) -> None: + self.detect_threshold = int(val) & 0xFFFF + + def set_gain_shift(self, val: int) -> None: + self.gain_shift = int(val) & 0x0F + + def set_cfar_enable(self, val: bool) -> None: + self.cfar_enable = bool(val) + + def set_cfar_guard(self, val: int) -> None: + self.cfar_guard = int(val) & 0x0F + + def set_cfar_train(self, val: int) -> None: + self.cfar_train = max(1, int(val) & 0x1F) + + def set_cfar_alpha(self, val: int) -> None: + self.cfar_alpha = int(val) & 0xFF + + def set_cfar_mode(self, val: int) -> None: + self.cfar_mode = int(val) & 0x03 + + def set_mti_enable(self, val: bool) -> None: + self.mti_enable = bool(val) + + def set_dc_notch_width(self, val: int) -> None: + self.dc_notch_width = int(val) & 0x07 + + def set_agc_enable(self, val: bool) -> None: + self.agc_enable = bool(val) + + def set_agc_params( + self, + target: int | None = None, + attack: int | None = None, + decay: int | None = None, + holdoff: int | None = None, + ) -> None: + if target is not None: + self.agc_target = int(target) & 0xFF + if attack is not None: + self.agc_attack = int(attack) & 0x0F + if decay is not None: + self.agc_decay = int(decay) & 0x0F + if holdoff is not None: + self.agc_holdoff = int(holdoff) & 0x0F + + # ------------------------------------------------------------------ + # Core processing: raw IQ chirps → RadarFrame + # ------------------------------------------------------------------ + def process_chirps( + self, + iq_i: np.ndarray, + iq_q: np.ndarray, + frame_number: int = 0, + timestamp: float = 0.0, + ) -> RadarFrame: + """Run the full FPGA signal chain on pre-quantized 16-bit I/Q chirps. + + Parameters + ---------- + iq_i, iq_q : ndarray, shape (n_chirps, n_samples), int16/int64 + Post-DDC I/Q samples. For ADI phaser data, use + ``quantize_raw_iq()`` first. + frame_number : int + Frame counter for the output RadarFrame. + timestamp : float + Timestamp for the output RadarFrame. + + Returns + ------- + RadarFrame + Populated frame identical to what the real FPGA would produce. + """ + n_chirps = iq_i.shape[0] + n_samples = iq_i.shape[1] + + # --- 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 + 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, + ) + + # --- Stage 2: Range bin decimation (1024 → 64) --- + decim_i, decim_q = run_range_bin_decimator(range_i, range_q) + + # --- Stage 3: MTI canceller (pre-Doppler, per-chirp) --- + mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=self.mti_enable) + + # --- Stage 4: Doppler FFT (dual 16-pt Hamming) --- + twiddle_16 = TWIDDLE_16 if os.path.exists(TWIDDLE_16) else None + doppler_i, doppler_q = run_doppler_fft(mti_i, mti_q, twiddle_file_16=twiddle_16) + + # --- Stage 5: DC notch (bin zeroing) --- + notch_i, notch_q = run_dc_notch(doppler_i, doppler_q, width=self.dc_notch_width) + + # --- Stage 6: Detection --- + if self.cfar_enable: + mode_str = _CFAR_MODE_MAP.get(self.cfar_mode, "CA") + detect_flags, magnitudes, _thresholds = run_cfar_ca( + notch_i, + notch_q, + guard=self.cfar_guard, + train=self.cfar_train, + alpha_q44=self.cfar_alpha, + mode=mode_str, + ) + det_mask = detect_flags.astype(np.uint8) + mag = magnitudes.astype(np.float64) + else: + mag_raw, det_indices = run_detection( + notch_i, notch_q, threshold=self.detect_threshold + ) + mag = mag_raw.astype(np.float64) + det_mask = np.zeros_like(mag, dtype=np.uint8) + for idx in det_indices: + det_mask[idx[0], idx[1]] = 1 + + # --- Assemble RadarFrame --- + frame = RadarFrame() + frame.timestamp = timestamp + frame.frame_number = frame_number + frame.range_doppler_i = np.clip(notch_i, -32768, 32767).astype(np.int16) + frame.range_doppler_q = np.clip(notch_q, -32768, 32767).astype(np.int16) + frame.magnitude = mag + frame.detections = det_mask + frame.range_profile = np.sqrt( + notch_i[:, 0].astype(np.float64) ** 2 + + notch_q[:, 0].astype(np.float64) ** 2 + ) + frame.detection_count = int(det_mask.sum()) + return frame + + +# --------------------------------------------------------------------------- +# Utility: quantize arbitrary complex IQ to 16-bit post-DDC format +# --------------------------------------------------------------------------- +def quantize_raw_iq( + raw_complex: np.ndarray, + n_chirps: int = DOPPLER_CHIRPS, + n_samples: int = FFT_SIZE, + peak_target: int = 200, +) -> tuple[np.ndarray, np.ndarray]: + """Quantize complex IQ data to 16-bit signed, matching DDC output level. + + Parameters + ---------- + raw_complex : ndarray, shape (chirps, samples) or (frames, chirps, samples) + Complex64/128 baseband IQ from SDR capture. If 3-D, the first + axis is treated as frame index and only the first frame is used. + 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). + peak_target : int + Target peak magnitude after scaling (default 200, matching + golden_reference INPUT_PEAK_TARGET). + + Returns + ------- + iq_i, iq_q : ndarray, each (n_chirps, n_samples) int64 + """ + if raw_complex.ndim == 3: + # (frames, chirps, samples) — take first frame + raw_complex = raw_complex[0] + + # Truncate to FPGA dimensions + block = raw_complex[:n_chirps, :n_samples] + + max_abs = np.max(np.abs(block)) + if max_abs == 0: + return ( + np.zeros((n_chirps, n_samples), dtype=np.int64), + np.zeros((n_chirps, n_samples), dtype=np.int64), + ) + + scale = peak_target / max_abs + scaled = block * scale + iq_i = np.clip(np.round(np.real(scaled)).astype(np.int64), -32768, 32767) + iq_q = np.clip(np.round(np.imag(scaled)).astype(np.int64), -32768, 32767) + return iq_i, iq_q diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index c467c98..c29f3bd 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -13,7 +13,6 @@ All packet parsing now uses the production radar_protocol.py which matches the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte). """ -import math import time import random import queue @@ -36,58 +35,25 @@ from .processing import ( RadarProcessor, USBPacketParser, apply_pitch_correction, + polar_to_geographic, ) logger = logging.getLogger(__name__) -# ============================================================================= -# Utility: polar → geographic -# ============================================================================= - -def polar_to_geographic( - radar_lat: float, - radar_lon: float, - range_m: float, - azimuth_deg: float, -) -> tuple: - """ - Convert polar coordinates (range, azimuth) relative to radar - to geographic (latitude, longitude). - - azimuth_deg: 0 = North, clockwise. - Returns (lat, lon). - """ - R = 6_371_000 # Earth radius in meters - - lat1 = math.radians(radar_lat) - lon1 = math.radians(radar_lon) - bearing = math.radians(azimuth_deg) - - lat2 = math.asin( - math.sin(lat1) * math.cos(range_m / R) - + math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing) - ) - lon2 = lon1 + math.atan2( - math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1), - math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2), - ) - return (math.degrees(lat2), math.degrees(lon2)) - - # ============================================================================= # Radar Data Worker (QThread) — production protocol # ============================================================================= class RadarDataWorker(QThread): """ - Background worker that reads radar data from FT2232H (or ReplayConnection), - parses 0xAA/0xBB packets via production RadarAcquisition, runs optional - host-side DSP, and emits PyQt signals with results. + Background worker that reads radar data from FT2232H, parses 0xAA/0xBB + packets via production RadarAcquisition, runs optional host-side DSP, + and emits PyQt signals with results. - This replaces the old V7 worker which used an incompatible packet format. - Now uses production radar_protocol.py for all packet parsing and frame + Uses production radar_protocol.py for all packet parsing and frame assembly (11-byte 0xAA data packets → 64x32 RadarFrame). + For replay, use ReplayWorker instead. Signals: frameReady(RadarFrame) — a complete 64x32 radar frame @@ -105,7 +71,7 @@ class RadarDataWorker(QThread): def __init__( self, - connection, # FT2232HConnection or ReplayConnection + connection, # FT2232HConnection processor: RadarProcessor | None = None, recorder: DataRecorder | None = None, gps_data_ref: GPSData | None = None, @@ -436,3 +402,172 @@ class TargetSimulator(QObject): self._targets = updated self.targetsUpdated.emit(updated) + + +# ============================================================================= +# Replay Worker (QThread) — unified replay playback +# ============================================================================= + +class ReplayWorker(QThread): + """Background worker for replay data playback. + + Emits the same signals as ``RadarDataWorker`` so the dashboard + treats live and replay identically. Additionally emits playback + state and frame-index signals for the transport controls. + + Signals + ------- + frameReady(object) RadarFrame + targetsUpdated(list) list[RadarTarget] + statsUpdated(dict) processing stats + errorOccurred(str) error message + playbackStateChanged(str) "playing" | "paused" | "stopped" + frameIndexChanged(int, int) (current_index, total_frames) + """ + + frameReady = pyqtSignal(object) + targetsUpdated = pyqtSignal(list) + statsUpdated = pyqtSignal(dict) + errorOccurred = pyqtSignal(str) + playbackStateChanged = pyqtSignal(str) + frameIndexChanged = pyqtSignal(int, int) + + def __init__( + self, + replay_engine, + settings: RadarSettings | None = None, + gps: GPSData | None = None, + frame_interval_ms: int = 100, + parent: QObject | None = None, + ) -> None: + super().__init__(parent) + import threading + + from .processing import extract_targets_from_frame + from .models import WaveformConfig + + self._engine = replay_engine + self._settings = settings or RadarSettings() + self._gps = gps + self._waveform = WaveformConfig() + self._frame_interval_ms = frame_interval_ms + self._extract_targets = extract_targets_from_frame + + self._current_index = 0 + self._last_emitted_index = 0 + self._playing = False + self._stop_flag = False + self._loop = False + self._lock = threading.Lock() # guards _current_index and _emit_frame + + # -- Public control API -- + + @property + def current_index(self) -> int: + """Index of the last frame emitted (for re-seek on param change).""" + return self._last_emitted_index + + @property + def total_frames(self) -> int: + return self._engine.total_frames + + def set_gps(self, gps: GPSData | None) -> None: + self._gps = gps + + def set_waveform(self, wf) -> None: + self._waveform = wf + + def set_loop(self, loop: bool) -> None: + self._loop = loop + + def set_frame_interval(self, ms: int) -> None: + self._frame_interval_ms = max(10, ms) + + def play(self) -> None: + self._playing = True + # If at EOF, rewind so play actually does something + with self._lock: + if self._current_index >= self._engine.total_frames: + self._current_index = 0 + self.playbackStateChanged.emit("playing") + + def pause(self) -> None: + self._playing = False + self.playbackStateChanged.emit("paused") + + def stop(self) -> None: + self._playing = False + self._stop_flag = True + self.playbackStateChanged.emit("stopped") + + @property + def is_playing(self) -> bool: + """Thread-safe read of playback state (for GUI queries).""" + return self._playing + + def seek(self, index: int) -> None: + """Jump to a specific frame and emit it (thread-safe).""" + with self._lock: + idx = max(0, min(index, self._engine.total_frames - 1)) + self._current_index = idx + self._emit_frame(idx) + self._last_emitted_index = idx + + # -- Thread entry -- + + def run(self) -> None: + self._stop_flag = False + self._playing = True + self.playbackStateChanged.emit("playing") + + try: + while not self._stop_flag: + if self._playing: + with self._lock: + if self._current_index < self._engine.total_frames: + self._emit_frame(self._current_index) + self._last_emitted_index = self._current_index + self._current_index += 1 + + # Loop or pause at end + if self._current_index >= self._engine.total_frames: + if self._loop: + self._current_index = 0 + else: + # Pause — keep thread alive for restart + self._playing = False + self.playbackStateChanged.emit("stopped") + + self.msleep(self._frame_interval_ms) + except (OSError, ValueError, RuntimeError, IndexError) as exc: + self.errorOccurred.emit(str(exc)) + + self.playbackStateChanged.emit("stopped") + + # -- Internal -- + + def _emit_frame(self, index: int) -> None: + try: + frame = self._engine.get_frame(index) + except (OSError, ValueError, RuntimeError, IndexError) as exc: + self.errorOccurred.emit(f"Frame {index}: {exc}") + return + + self.frameReady.emit(frame) + self.frameIndexChanged.emit(index, self._engine.total_frames) + + # Target extraction + targets = self._extract_targets( + frame, + range_resolution=self._waveform.range_resolution_m, + velocity_resolution=self._waveform.velocity_resolution_mps, + gps=self._gps, + ) + self.targetsUpdated.emit(targets) + self.statsUpdated.emit({ + "frame_number": frame.frame_number, + "detection_count": frame.detection_count, + "target_count": len(targets), + "replay_index": index, + "replay_total": self._engine.total_frames, + }) From 34ecaf360b83921bec886de6a79a97ef1331d932 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:54:00 +0545 Subject: [PATCH 7/8] feat: rename Tkinter dashboard to GUI_V65_Tk, add replay/demo/targets parity Rename radar_dashboard.py -> GUI_V65_Tk.py and add core feature parity with the v7 PyQt dashboard while keeping Tkinter as the framework: Replay mode: - _ReplayController with threading.Event-based play/pause/stop - Reuses v7.ReplayEngine and v7.SoftwareFPGA for all 3 input formats - Dual dispatch routes FPGA control opcodes to SoftwareFPGA during raw IQ replay; non-routable opcodes show user-visible status message - Seek slider with re-emit guard, speed combo, loop checkbox - close() properly releases engine file handles on stop/reload Demo mode: - DemoTarget kinematics scaled to physical range grid (~307m max) - DemoSimulator generates synthetic RadarFrames with Gaussian blobs - Targets table (ttk.Treeview) updates from demo target list Mode exclusion (bidirectional): - Connect stops active demo/replay before starting acquisition - Replay load stops previous controller and demo before loading - Demo start stops active replay; refuses if live-connected - --live/--replay/--demo in mutually exclusive CLI arg group Bug fixes: - seek() now increments past emitted frame to prevent re-emit on resume - Failed replay load nulls controller ref to prevent dangling state Tests: 17 new tests for DemoTarget, DemoSimulator, _ReplayController CI: all 4 jobs pass (167+21+25+29 = 242 tests) --- .github/workflows/ci-tests.yml | 2 +- .../{radar_dashboard.py => GUI_V65_Tk.py} | 671 +++++++++++++++++- 9_Firmware/9_3_GUI/GUI_versions.txt | 2 +- ..._radar_dashboard.py => test_GUI_V65_Tk.py} | 199 +++++- CONTRIBUTING.md | 6 +- 5 files changed, 859 insertions(+), 21 deletions(-) rename 9_Firmware/9_3_GUI/{radar_dashboard.py => GUI_V65_Tk.py} (60%) rename 9_Firmware/9_3_GUI/{test_radar_dashboard.py => test_GUI_V65_Tk.py} (80%) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 33db606..be7637a 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -46,7 +46,7 @@ jobs: - name: Unit tests run: > uv run pytest - 9_Firmware/9_3_GUI/test_radar_dashboard.py + 9_Firmware/9_3_GUI/test_GUI_V65_Tk.py 9_Firmware/9_3_GUI/test_v7.py -v --tb=short diff --git a/9_Firmware/9_3_GUI/radar_dashboard.py b/9_Firmware/9_3_GUI/GUI_V65_Tk.py similarity index 60% rename from 9_Firmware/9_3_GUI/radar_dashboard.py rename to 9_Firmware/9_3_GUI/GUI_V65_Tk.py index 3d6988c..6755534 100644 --- a/9_Firmware/9_3_GUI/radar_dashboard.py +++ b/9_Firmware/9_3_GUI/GUI_V65_Tk.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -AERIS-10 Radar Dashboard +AERIS-10 Radar Dashboard (Tkinter) =================================================== Real-time visualization and control for the AERIS-10 phased-array radar via FT2232H USB 2.0 interface. @@ -14,22 +14,33 @@ Features: 0x01-0x04, 0x10-0x16, 0x20-0x27, 0x30-0x31, 0xFF) - Configuration panel for all radar parameters - HDF5 data recording for offline analysis + - Replay mode (co-sim dirs, raw IQ .npy, HDF5) with transport controls + - Demo mode with synthetic moving targets + - Detected targets table + - Dual dispatch: FPGA controls route to SoftwareFPGA during replay - Mock mode for development/testing without hardware Usage: - python radar_dashboard.py # Launch with mock data - python radar_dashboard.py --live # Launch with FT2232H hardware - python radar_dashboard.py --record # Launch with HDF5 recording + python GUI_V65_Tk.py # Launch with mock data + python GUI_V65_Tk.py --live # Launch with FT2232H hardware + python GUI_V65_Tk.py --record # Launch with HDF5 recording + python GUI_V65_Tk.py --replay path/to/data # Auto-load replay + python GUI_V65_Tk.py --demo # Start in demo mode """ import os +import math import time +import copy import queue +import random import logging import argparse import threading import contextlib from collections import deque +from pathlib import Path +from typing import ClassVar import numpy as np @@ -54,7 +65,7 @@ logging.basicConfig( format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) -log = logging.getLogger("radar_dashboard") +log = logging.getLogger("GUI_V65_Tk") @@ -73,6 +84,296 @@ YELLOW = "#f9e2af" SURFACE = "#313244" +# ============================================================================ +# Demo Target Simulator (Tkinter timer-based) +# ============================================================================ + +class DemoTarget: + """Single simulated target with kinematics.""" + + __slots__ = ("azimuth", "classification", "id", "range_m", "snr", "velocity") + + # Physical range grid: 64 bins x ~4.8 m/bin = ~307 m max + _RANGE_PER_BIN: float = (3e8 / (2 * 500e6)) * 16 # ~4.8 m + _MAX_RANGE: float = _RANGE_PER_BIN * NUM_RANGE_BINS # ~307 m + + def __init__(self, tid: int): + self.id = tid + self.range_m = random.uniform(20, self._MAX_RANGE - 20) + self.velocity = random.uniform(-10, 10) + self.azimuth = random.uniform(0, 360) + self.snr = random.uniform(10, 35) + self.classification = random.choice( + ["aircraft", "drone", "bird", "unknown"]) + + def step(self) -> bool: + """Advance one tick. Return False if target exits coverage.""" + self.range_m -= self.velocity * 0.1 + if self.range_m < 5 or self.range_m > self._MAX_RANGE: + return False + self.velocity = max(-20, min(20, self.velocity + random.uniform(-1, 1))) + self.azimuth = (self.azimuth + random.uniform(-0.5, 0.5)) % 360 + self.snr = max(0, min(50, self.snr + random.uniform(-1, 1))) + return True + + +class DemoSimulator: + """Timer-driven demo target generator for the Tkinter dashboard. + + Produces synthetic ``RadarFrame`` objects and a target list each tick, + pushing them into the dashboard's ``frame_queue`` and ``_ui_queue``. + """ + + def __init__(self, frame_queue: queue.Queue, ui_queue: queue.Queue, + root: tk.Tk, interval_ms: int = 500): + self._frame_queue = frame_queue + self._ui_queue = ui_queue + self._root = root + self._interval_ms = interval_ms + self._targets: list[DemoTarget] = [] + self._next_id = 1 + self._frame_number = 0 + self._after_id: str | None = None + + # Seed initial targets + for _ in range(8): + self._add_target() + + def start(self): + self._tick() + + def stop(self): + if self._after_id is not None: + self._root.after_cancel(self._after_id) + self._after_id = None + + def add_random_target(self): + self._add_target() + + def _add_target(self): + t = DemoTarget(self._next_id) + self._next_id += 1 + self._targets.append(t) + + def _tick(self): + updated: list[DemoTarget] = [t for t in self._targets if t.step()] + if len(updated) < 5 or (random.random() < 0.05 and len(updated) < 15): + self._add_target() + updated.append(self._targets[-1]) + self._targets = updated + + # Synthesize a RadarFrame with Gaussian blobs for each target + frame = self._make_frame(updated) + with contextlib.suppress(queue.Full): + self._frame_queue.put_nowait(frame) + + # Post target info for the detected-targets treeview + target_dicts = [ + {"id": t.id, "range_m": t.range_m, "velocity": t.velocity, + "azimuth": t.azimuth, "snr": t.snr, "class": t.classification} + for t in updated + ] + self._ui_queue.put(("demo_targets", target_dicts)) + + self._after_id = self._root.after(self._interval_ms, self._tick) + + def _make_frame(self, targets: list[DemoTarget]) -> RadarFrame: + """Build a synthetic RadarFrame from target list.""" + mag = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.float64) + det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8) + + # Range/Doppler scaling (approximate) + range_per_bin = (3e8 / (2 * 500e6)) * 16 # ~4.8 m/bin + max_range = range_per_bin * NUM_RANGE_BINS + vel_per_bin = 1.484 # m/s per Doppler bin (from WaveformConfig) + + for t in targets: + if t.range_m > max_range or t.range_m < 0: + continue + r_bin = int(t.range_m / range_per_bin) + d_bin = int((t.velocity / vel_per_bin) + NUM_DOPPLER_BINS / 2) + r_bin = max(0, min(NUM_RANGE_BINS - 1, r_bin)) + d_bin = max(0, min(NUM_DOPPLER_BINS - 1, d_bin)) + + # Gaussian-ish blob + amplitude = 500 + t.snr * 200 + for dr in range(-2, 3): + for dd in range(-1, 2): + ri = r_bin + dr + di = d_bin + dd + if 0 <= ri < NUM_RANGE_BINS and 0 <= di < NUM_DOPPLER_BINS: + w = math.exp(-0.5 * (dr**2 + dd**2)) + mag[ri, di] += amplitude * w + if w > 0.5: + det[ri, di] = 1 + + rd_i = (mag * 0.5).astype(np.int16) + rd_q = np.zeros_like(rd_i) + rp = mag.max(axis=1) + + self._frame_number += 1 + return RadarFrame( + timestamp=time.time(), + range_doppler_i=rd_i, + range_doppler_q=rd_q, + magnitude=mag, + detections=det, + range_profile=rp, + detection_count=int(det.sum()), + frame_number=self._frame_number, + ) + + +# ============================================================================ +# Replay Controller (threading-based, reuses v7.ReplayEngine) +# ============================================================================ + +class _ReplayController: + """Manages replay playback in a background thread for the Tkinter dashboard. + + Imports ``ReplayEngine`` and ``SoftwareFPGA`` from ``v7`` lazily so + they are only required when replay is actually used. + """ + + # Speed multiplier → frame interval in seconds + SPEED_MAP: ClassVar[dict[str, float]] = { + "0.25x": 0.400, + "0.5x": 0.200, + "1x": 0.100, + "2x": 0.050, + "5x": 0.020, + "10x": 0.010, + } + + def __init__(self, frame_queue: queue.Queue, ui_queue: queue.Queue): + self._frame_queue = frame_queue + self._ui_queue = ui_queue + self._engine = None # lazy + self._software_fpga = None # lazy + self._thread: threading.Thread | None = None + self._play_event = threading.Event() + self._stop_event = threading.Event() + self._lock = threading.Lock() + self._current_index = 0 + self._last_emitted_index = -1 + self._loop = False + self._frame_interval = 0.100 # 1x speed + + def load(self, path: str) -> int: + """Load replay data from path. Returns total frames or raises.""" + from v7.replay import ReplayEngine, ReplayFormat, detect_format + from v7.software_fpga import SoftwareFPGA + + fmt = detect_format(path) + if fmt == ReplayFormat.RAW_IQ_NPY: + self._software_fpga = SoftwareFPGA() + self._engine = ReplayEngine(path, software_fpga=self._software_fpga) + else: + self._engine = ReplayEngine(path) + + self._current_index = 0 + self._last_emitted_index = -1 + self._stop_event.clear() + self._play_event.clear() + return self._engine.total_frames + + @property + def total_frames(self) -> int: + return self._engine.total_frames if self._engine else 0 + + @property + def current_index(self) -> int: + return self._last_emitted_index if self._last_emitted_index >= 0 else 0 + + @property + def is_playing(self) -> bool: + return self._play_event.is_set() + + @property + def software_fpga(self): + return self._software_fpga + + def set_speed(self, label: str): + self._frame_interval = self.SPEED_MAP.get(label, 0.100) + + def set_loop(self, loop: bool): + self._loop = loop + + def play(self): + self._play_event.set() + with self._lock: + if self._current_index >= self.total_frames: + self._current_index = 0 + self._ui_queue.put(("replay_state", "playing")) + if self._thread is None or not self._thread.is_alive(): + self._stop_event.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + + def pause(self): + self._play_event.clear() + self._ui_queue.put(("replay_state", "paused")) + + def stop(self): + self._stop_event.set() + self._play_event.set() # unblock wait so thread exits promptly + with self._lock: + self._current_index = 0 + self._last_emitted_index = -1 + if self._thread is not None: + self._thread.join(timeout=2) + self._thread = None + self._play_event.clear() + self._ui_queue.put(("replay_state", "stopped")) + + def close(self): + """Stop playback and release underlying engine resources.""" + self.stop() + if self._engine is not None: + self._engine.close() + self._engine = None + self._software_fpga = None + + def seek(self, index: int): + with self._lock: + self._current_index = max(0, min(index, self.total_frames - 1)) + self._emit_frame() + self._last_emitted_index = self._current_index + # Advance past the emitted frame so _run doesn't re-emit it + self._current_index += 1 + + def _run(self): + while not self._stop_event.is_set(): + # Block until play or stop is signalled — no busy-sleep + self._play_event.wait() + if self._stop_event.is_set(): + break + with self._lock: + if self._current_index >= self.total_frames: + if self._loop: + self._current_index = 0 + else: + self._play_event.clear() + self._ui_queue.put(("replay_state", "paused")) + continue + self._emit_frame() + self._last_emitted_index = self._current_index + idx = self._current_index + self._current_index += 1 + self._ui_queue.put(("replay_index", (idx, self.total_frames))) + time.sleep(self._frame_interval) + + def _emit_frame(self): + """Get current frame and push to queue. Must be called with lock held.""" + if self._engine is None: + return + frame = self._engine.get_frame(self._current_index) + if frame is not None: + frame = copy.deepcopy(frame) + with contextlib.suppress(queue.Full): + self._frame_queue.put_nowait(frame) + + class RadarDashboard: """Main tkinter application: real-time radar visualization and control.""" @@ -93,7 +394,7 @@ class RadarDashboard: self.root.geometry("1600x950") self.root.configure(bg=BG) - # Frame queue (acquisition → display) + # Frame queue (acquisition / replay / demo → display) self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8) self._acq_thread: RadarAcquisition | None = None @@ -126,6 +427,17 @@ class RadarDashboard: self._agc_last_redraw: float = 0.0 # throttle chart redraws self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws + # Replay state + self._replay_ctrl: _ReplayController | None = None + self._replay_active = False + + # Demo state + self._demo_sim: DemoSimulator | None = None + self._demo_active = False + + # Detected targets (from demo or replay host-DSP) + self._detected_targets: list[dict] = [] + self._build_ui() self._schedule_update() @@ -171,30 +483,33 @@ class RadarDashboard: self.btn_record = ttk.Button(top, text="Record", command=self._on_record) self.btn_record.pack(side="right", padx=4) + self.btn_demo = ttk.Button(top, text="Start Demo", + command=self._toggle_demo) + self.btn_demo.pack(side="right", padx=4) + # -- Tabbed notebook layout -- nb = ttk.Notebook(self.root) nb.pack(fill="both", expand=True, padx=8, pady=8) tab_display = ttk.Frame(nb) tab_control = ttk.Frame(nb) + tab_replay = ttk.Frame(nb) tab_agc = ttk.Frame(nb) tab_log = ttk.Frame(nb) nb.add(tab_display, text=" Display ") nb.add(tab_control, text=" Control ") + nb.add(tab_replay, text=" Replay ") nb.add(tab_agc, text=" AGC Monitor ") nb.add(tab_log, text=" Log ") self._build_display_tab(tab_display) self._build_control_tab(tab_control) + self._build_replay_tab(tab_replay) self._build_agc_tab(tab_agc) self._build_log_tab(tab_log) def _build_display_tab(self, parent): # Compute physical axis limits - # Range resolution: dR = c / (2 * BW) per range bin - # But we decimate 1024→64 bins, so each bin spans 16 FFT bins. - # Range resolution derivation: c/(2*BW) gives ~0.3 m per FFT bin. - # After 1024-to-64 decimation each displayed range bin spans 16 FFT bins. range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin # After decimation 1024→64, each range bin = 16 FFT bins range_per_bin = range_res * 16 @@ -203,8 +518,12 @@ class RadarDashboard: doppler_bin_lo = 0 doppler_bin_hi = NUM_DOPPLER_BINS + # Top pane: plots + plot_frame = ttk.Frame(parent) + plot_frame.pack(fill="both", expand=True) + # Matplotlib figure with 3 subplots - self.fig = Figure(figsize=(14, 7), facecolor=BG) + self.fig = Figure(figsize=(14, 5), facecolor=BG) self.fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.10, wspace=0.30, hspace=0.35) @@ -245,11 +564,35 @@ class RadarDashboard: self.ax_wf.set_ylabel("Frame", color=FG) self.ax_wf.tick_params(colors=FG) - canvas = FigureCanvasTkAgg(self.fig, master=parent) + canvas = FigureCanvasTkAgg(self.fig, master=plot_frame) canvas.draw() canvas.get_tk_widget().pack(fill="both", expand=True) self._canvas = canvas + # Bottom pane: detected targets table + tgt_frame = ttk.LabelFrame(parent, text="Detected Targets", padding=4) + tgt_frame.pack(fill="x", padx=8, pady=(0, 4)) + + cols = ("id", "range_m", "velocity", "azimuth", "snr", "class") + self._tgt_tree = ttk.Treeview( + tgt_frame, columns=cols, show="headings", height=5) + for col, heading, width in [ + ("id", "ID", 50), + ("range_m", "Range (m)", 100), + ("velocity", "Vel (m/s)", 90), + ("azimuth", "Az (deg)", 90), + ("snr", "SNR (dB)", 80), + ("class", "Class", 100), + ]: + self._tgt_tree.heading(col, text=heading) + self._tgt_tree.column(col, width=width, anchor="center") + + scrollbar = ttk.Scrollbar( + tgt_frame, orient="vertical", command=self._tgt_tree.yview) + self._tgt_tree.configure(yscrollcommand=scrollbar.set) + self._tgt_tree.pack(side="left", fill="x", expand=True) + scrollbar.pack(side="right", fill="y") + def _build_control_tab(self, parent): """Host command sender — organized by FPGA register groups. @@ -492,6 +835,86 @@ class RadarDashboard: var.set(str(clamped)) self._send_cmd(opcode, clamped) + def _build_replay_tab(self, parent): + """Replay tab — load file, transport controls, seek slider.""" + # File selection + file_frame = ttk.LabelFrame(parent, text="Replay Source", padding=10) + file_frame.pack(fill="x", padx=8, pady=(8, 4)) + + self._replay_path_var = tk.StringVar(value="(none)") + ttk.Label(file_frame, textvariable=self._replay_path_var, + font=("Menlo", 9)).pack(side="left", fill="x", expand=True) + + ttk.Button(file_frame, text="Browse File...", + command=self._replay_browse_file).pack(side="right", padx=(4, 0)) + ttk.Button(file_frame, text="Browse Dir...", + command=self._replay_browse_dir).pack(side="right", padx=(4, 0)) + + # Transport controls + ctrl_frame = ttk.LabelFrame(parent, text="Transport", padding=10) + ctrl_frame.pack(fill="x", padx=8, pady=4) + + btn_row = ttk.Frame(ctrl_frame) + btn_row.pack(fill="x", pady=(0, 6)) + + self._rp_play_btn = ttk.Button( + btn_row, text="Play", command=self._replay_play, state="disabled") + self._rp_play_btn.pack(side="left", padx=2) + + self._rp_pause_btn = ttk.Button( + btn_row, text="Pause", command=self._replay_pause, state="disabled") + self._rp_pause_btn.pack(side="left", padx=2) + + self._rp_stop_btn = ttk.Button( + btn_row, text="Stop", command=self._replay_stop, state="disabled") + self._rp_stop_btn.pack(side="left", padx=2) + + # Speed selector + ttk.Label(btn_row, text="Speed:").pack(side="left", padx=(16, 4)) + self._rp_speed_var = tk.StringVar(value="1x") + speed_combo = ttk.Combobox( + btn_row, textvariable=self._rp_speed_var, + values=list(_ReplayController.SPEED_MAP.keys()), + state="readonly", width=6) + speed_combo.pack(side="left", padx=2) + speed_combo.bind("<>", self._replay_speed_changed) + + # Loop checkbox + self._rp_loop_var = tk.BooleanVar(value=False) + ttk.Checkbutton(btn_row, text="Loop", + variable=self._rp_loop_var, + command=self._replay_loop_changed).pack(side="left", padx=8) + + # Seek slider + slider_row = ttk.Frame(ctrl_frame) + slider_row.pack(fill="x") + + self._rp_slider = tk.Scale( + slider_row, from_=0, to=0, orient="horizontal", + bg=SURFACE, fg=FG, highlightthickness=0, + troughcolor=BG2, command=self._replay_seek) + self._rp_slider.pack(side="left", fill="x", expand=True) + + self._rp_frame_label = ttk.Label( + slider_row, text="0 / 0", font=("Menlo", 10)) + self._rp_frame_label.pack(side="right", padx=8) + + # Status + self._rp_status_label = ttk.Label( + parent, text="No replay loaded", font=("Menlo", 10)) + self._rp_status_label.pack(padx=8, pady=4, anchor="w") + + # Info frame for FPGA controls during replay + info = ttk.LabelFrame(parent, text="Replay FPGA Controls", padding=10) + info.pack(fill="x", padx=8, pady=4) + ttk.Label( + info, + text=("When replaying Raw IQ data, FPGA Control tab " + "parameters are routed to the SoftwareFPGA.\n" + "Changes take effect on the next frame."), + font=("Menlo", 9), foreground=ACCENT, + ).pack(anchor="w") + def _build_agc_tab(self, parent): """AGC Monitor tab — real-time strip charts for gain, peak, and saturation.""" # Top row: AGC status badge + saturation indicator @@ -602,6 +1025,12 @@ class RadarDashboard: log.info("Disconnected") return + # Stop any active demo or replay before going live + if self._demo_active: + self._stop_demo() + if self._replay_active: + self._replay_stop() + # Open connection in a background thread to avoid blocking the GUI self.lbl_status.config(text="CONNECTING...", foreground=YELLOW) self.btn_connect.config(state="disabled") @@ -644,7 +1073,37 @@ class RadarDashboard: self.recorder.start(filepath) self.btn_record.config(text="Stop Rec") + # Opcode → SoftwareFPGA setter method name for dual dispatch during replay + _SFPGA_SETTER_NAMES: ClassVar[dict[int, str]] = { + 0x03: "set_detect_threshold", + 0x16: "set_gain_shift", + 0x21: "set_cfar_guard", + 0x22: "set_cfar_train", + 0x23: "set_cfar_alpha", + 0x24: "set_cfar_mode", + 0x25: "set_cfar_enable", + 0x26: "set_mti_enable", + 0x27: "set_dc_notch_width", + 0x28: "set_agc_enable", + } + def _send_cmd(self, opcode: int, value: int): + """Send command — routes to SoftwareFPGA when replaying raw IQ.""" + if (self._replay_active and self._replay_ctrl is not None + and self._replay_ctrl.software_fpga is not None): + sfpga = self._replay_ctrl.software_fpga + setter_name = self._SFPGA_SETTER_NAMES.get(opcode) + if setter_name is not None: + getattr(sfpga, setter_name)(value) + log.info( + f"SoftwareFPGA 0x{opcode:02X} val={value}") + return + log.warning( + f"Opcode 0x{opcode:02X} not routable in replay mode") + self._ui_queue.put( + ("status_msg", + f"Opcode 0x{opcode:02X} is hardware-only (ignored in replay)")) + return cmd = RadarProtocol.build_command(opcode, value) ok = self.conn.write(cmd) log.info(f"CMD 0x{opcode:02X} val={value} ({'OK' if ok else 'FAIL'})") @@ -657,6 +1116,133 @@ class RadarDashboard: except ValueError: log.error("Invalid custom command values") + # -------------------------------------------------------- Replay actions + def _replay_browse_file(self): + path = filedialog.askopenfilename( + title="Select replay file", + filetypes=[ + ("NumPy files", "*.npy"), + ("HDF5 files", "*.h5"), + ("All files", "*.*"), + ], + ) + if path: + self._replay_load(path) + + def _replay_browse_dir(self): + path = filedialog.askdirectory(title="Select co-sim directory") + if path: + self._replay_load(path) + + def _replay_load(self, path: str): + """Load replay data and enable transport controls.""" + # Stop any running mode + if self._demo_active: + self._stop_demo() + # Safely shutdown and disable UI controls before loading the new file + if self._replay_active or self._replay_ctrl is not None: + self._replay_stop() + if self._acq_thread is not None: + if self.conn.is_open: + self._on_connect() # disconnect + else: + # Connection dropped unexpectedly — just clean up the thread + self._acq_thread.stop() + self._acq_thread.join(timeout=2) + self._acq_thread = None + + try: + self._replay_ctrl = _ReplayController( + self.frame_queue, self._ui_queue) + total = self._replay_ctrl.load(path) + except Exception as exc: # noqa: BLE001 + log.error(f"Failed to load replay: {exc}") + self._rp_status_label.config( + text=f"Load failed: {exc}", foreground=RED) + self._replay_ctrl = None + return + + short_path = Path(path).name + self._replay_path_var.set(short_path) + self._rp_slider.config(to=max(0, total - 1)) + self._rp_frame_label.config(text=f"0 / {total}") + self._rp_status_label.config( + text=f"Loaded: {total} frames from {short_path}", + foreground=GREEN) + + # Enable transport buttons + for btn in (self._rp_play_btn, self._rp_pause_btn, self._rp_stop_btn): + btn.config(state="normal") + + self._replay_active = True + self.lbl_status.config(text="REPLAY", foreground=ACCENT) + log.info(f"Replay loaded: {total} frames from {path}") + + def _replay_play(self): + if self._replay_ctrl: + self._replay_ctrl.play() + + def _replay_pause(self): + if self._replay_ctrl: + self._replay_ctrl.pause() + + def _replay_stop(self): + if self._replay_ctrl: + self._replay_ctrl.close() + self._replay_ctrl = None + self._replay_active = False + self.lbl_status.config(text="DISCONNECTED", foreground=RED) + self._rp_slider.set(0) + self._rp_frame_label.config(text="0 / 0") + for btn in (self._rp_play_btn, self._rp_pause_btn, self._rp_stop_btn): + btn.config(state="disabled") + + def _replay_seek(self, value): + if (self._replay_ctrl and self._replay_active + and not self._replay_ctrl.is_playing): + self._replay_ctrl.seek(int(value)) + + def _replay_speed_changed(self, _event=None): + if self._replay_ctrl: + self._replay_ctrl.set_speed(self._rp_speed_var.get()) + + def _replay_loop_changed(self): + if self._replay_ctrl: + self._replay_ctrl.set_loop(self._rp_loop_var.get()) + + # ---------------------------------------------------------- Demo actions + def _toggle_demo(self): + if self._demo_active: + self._stop_demo() + else: + self._start_demo() + + def _start_demo(self): + """Start demo mode with synthetic targets.""" + # Mutual exclusion + if self._replay_active: + self._replay_stop() + if self._acq_thread is not None: + log.warning("Cannot start demo while radar is connected") + return + + self._demo_sim = DemoSimulator( + self.frame_queue, self._ui_queue, self.root, interval_ms=500) + self._demo_sim.start() + self._demo_active = True + self.lbl_status.config(text="DEMO", foreground=YELLOW) + self.btn_demo.config(text="Stop Demo") + log.info("Demo mode started") + + def _stop_demo(self): + if self._demo_sim is not None: + self._demo_sim.stop() + self._demo_sim = None + self._demo_active = False + self.lbl_status.config(text="DISCONNECTED", foreground=RED) + self.btn_demo.config(text="Start Demo") + log.info("Demo mode stopped") + def _on_status_received(self, status: StatusResponse): """Called from acquisition thread — post to UI queue for main thread.""" self._ui_queue.put(("status", status)) @@ -804,6 +1390,46 @@ class RadarDashboard: self._update_self_test_labels(payload) elif tag == "log": self._log_handler_append(payload) + elif tag == "replay_state": + self._on_replay_state(payload) + elif tag == "replay_index": + self._on_replay_index(*payload) + elif tag == "demo_targets": + self._on_demo_targets(payload) + elif tag == "status_msg": + self.lbl_status.config(text=str(payload), foreground=YELLOW) + + def _on_replay_state(self, state: str): + if state == "playing": + self._rp_status_label.config(text="Playing", foreground=GREEN) + elif state == "paused": + self._rp_status_label.config(text="Paused", foreground=YELLOW) + elif state == "stopped": + self._rp_status_label.config(text="Stopped", foreground=FG) + + def _on_replay_index(self, index: int, total: int): + self._rp_frame_label.config(text=f"{index} / {total}") + self._rp_slider.set(index) + + def _on_demo_targets(self, targets: list[dict]): + """Update the detected targets treeview from demo data.""" + self._update_targets_table(targets) + + def _update_targets_table(self, targets: list[dict]): + """Refresh the detected targets treeview.""" + # Clear existing rows + for item in self._tgt_tree.get_children(): + self._tgt_tree.delete(item) + # Insert new rows + for t in targets: + self._tgt_tree.insert("", "end", values=( + t.get("id", ""), + f"{t.get('range_m', 0):.0f}", + f"{t.get('velocity', 0):.1f}", + f"{t.get('azimuth', 0):.1f}", + f"{t.get('snr', 0):.1f}", + t.get("class", ""), + )) def _log_handler_append(self, msg: str): """Append a log message to the log Text widget (main thread only).""" @@ -902,12 +1528,17 @@ class _TextHandler(logging.Handler): def main(): parser = argparse.ArgumentParser(description="AERIS-10 Radar Dashboard") - parser.add_argument("--live", action="store_true", - help="Use real FT2232H hardware (default: mock mode)") parser.add_argument("--record", action="store_true", help="Start HDF5 recording immediately") parser.add_argument("--device", type=int, default=0, help="FT2232H device index (default: 0)") + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument("--live", action="store_true", + help="Use real FT2232H hardware (default: mock mode)") + mode_group.add_argument("--replay", type=str, default=None, + help="Auto-load replay file or directory on startup") + mode_group.add_argument("--demo", action="store_true", + help="Start in demo mode with synthetic targets") args = parser.parse_args() if args.live: @@ -930,7 +1561,19 @@ def main(): ) recorder.start(filepath) + if args.replay: + dashboard._replay_load(args.replay) + + if args.demo: + dashboard._start_demo() + def on_closing(): + # Stop demo if active + if dashboard._demo_active: + dashboard._stop_demo() + # Stop replay if active + if dashboard._replay_ctrl is not None: + dashboard._replay_ctrl.close() if dashboard._acq_thread is not None: dashboard._acq_thread.stop() dashboard._acq_thread.join(timeout=2) diff --git a/9_Firmware/9_3_GUI/GUI_versions.txt b/9_Firmware/9_3_GUI/GUI_versions.txt index c424412..5ed1fa2 100644 --- a/9_Firmware/9_3_GUI/GUI_versions.txt +++ b/9_Firmware/9_3_GUI/GUI_versions.txt @@ -8,6 +8,6 @@ GUI_V5 ==> Added Mercury Color GUI_V6 ==> Added USB3 FT601 support -radar_dashboard ==> Board bring-up dashboard (FT2232H reader, real-time R-D heatmap, CFAR overlay, waterfall, host commands, HDF5 recording) +GUI_V65_Tk ==> Board bring-up dashboard (FT2232H reader, real-time R-D heatmap, CFAR overlay, waterfall, host commands, HDF5 recording, replay, demo mode) radar_protocol ==> Protocol layer (packet parsing, command building, FT2232H connection, data recorder, acquisition thread) smoke_test ==> Board bring-up smoke test host script (triggers FPGA self-test via opcode 0x30) diff --git a/9_Firmware/9_3_GUI/test_radar_dashboard.py b/9_Firmware/9_3_GUI/test_GUI_V65_Tk.py similarity index 80% rename from 9_Firmware/9_3_GUI/test_radar_dashboard.py rename to 9_Firmware/9_3_GUI/test_GUI_V65_Tk.py index f94b85a..de5f18f 100644 --- a/9_Firmware/9_3_GUI/test_radar_dashboard.py +++ b/9_Firmware/9_3_GUI/test_GUI_V65_Tk.py @@ -3,8 +3,8 @@ Tests for AERIS-10 Radar Dashboard protocol parsing, command building, data recording, and acquisition logic. -Run: python -m pytest test_radar_dashboard.py -v - or: python test_radar_dashboard.py +Run: python -m pytest test_GUI_V65_Tk.py -v + or: python test_GUI_V65_Tk.py """ import struct @@ -22,6 +22,7 @@ from radar_protocol import ( NUM_RANGE_BINS, NUM_DOPPLER_BINS, DATA_PACKET_SIZE, ) +from GUI_V65_Tk import DemoTarget, DemoSimulator, _ReplayController class TestRadarProtocol(unittest.TestCase): @@ -719,5 +720,199 @@ class TestAGCVisualizationHistory(unittest.TestCase): self.assertAlmostEqual(max(200 * 1.5, 5), 300.0) +# ===================================================================== +# Tests for DemoTarget, DemoSimulator, and _ReplayController +# ===================================================================== + + +class TestDemoTarget(unittest.TestCase): + """Unit tests for DemoTarget kinematics.""" + + def test_initial_values_in_range(self): + t = DemoTarget(1) + self.assertEqual(t.id, 1) + self.assertGreaterEqual(t.range_m, 20) + self.assertLessEqual(t.range_m, DemoTarget._MAX_RANGE) + self.assertIn(t.classification, ["aircraft", "drone", "bird", "unknown"]) + + def test_step_returns_true_in_normal_range(self): + t = DemoTarget(2) + t.range_m = 150.0 + t.velocity = 0.0 + self.assertTrue(t.step()) + + def test_step_returns_false_when_out_of_range_high(self): + t = DemoTarget(3) + t.range_m = DemoTarget._MAX_RANGE + 1 + t.velocity = -1.0 # moving away + self.assertFalse(t.step()) + + def test_step_returns_false_when_out_of_range_low(self): + t = DemoTarget(4) + t.range_m = 2.0 + t.velocity = 1.0 # moving closer + self.assertFalse(t.step()) + + def test_velocity_clamped(self): + t = DemoTarget(5) + t.velocity = 19.0 + t.range_m = 150.0 + # Step many times — velocity should stay within [-20, 20] + for _ in range(100): + t.range_m = 150.0 # keep in range + t.step() + self.assertGreaterEqual(t.velocity, -20) + self.assertLessEqual(t.velocity, 20) + + def test_snr_clamped(self): + t = DemoTarget(6) + t.snr = 49.5 + t.range_m = 150.0 + for _ in range(100): + t.range_m = 150.0 + t.step() + self.assertGreaterEqual(t.snr, 0) + self.assertLessEqual(t.snr, 50) + + +class TestDemoSimulatorNoTk(unittest.TestCase): + """Test DemoSimulator logic without a real Tk event loop. + + We replace ``root.after`` with a mock to avoid needing a display. + """ + + def _make_simulator(self): + from unittest.mock import MagicMock + + fq = queue.Queue(maxsize=100) + uq = queue.Queue(maxsize=100) + mock_root = MagicMock() + # root.after(ms, fn) should return an id (str) + mock_root.after.return_value = "mock_after_id" + sim = DemoSimulator(fq, uq, mock_root, interval_ms=100) + return sim, fq, uq, mock_root + + def test_initial_targets_created(self): + sim, _fq, _uq, _root = self._make_simulator() + # Should seed 8 initial targets + self.assertEqual(len(sim._targets), 8) + + def test_tick_produces_frame_and_targets(self): + sim, fq, uq, _root = self._make_simulator() + sim._tick() + # Should have a frame + self.assertFalse(fq.empty()) + frame = fq.get_nowait() + self.assertIsInstance(frame, RadarFrame) + self.assertEqual(frame.frame_number, 1) + # Should have demo_targets in ui_queue + tag, payload = uq.get_nowait() + self.assertEqual(tag, "demo_targets") + self.assertIsInstance(payload, list) + + def test_tick_produces_nonzero_detections(self): + """Demo targets should actually render into the range-Doppler grid.""" + sim, fq, _uq, _root = self._make_simulator() + sim._tick() + frame = fq.get_nowait() + # At least some targets should produce magnitude > 0 and detections + self.assertGreater(frame.magnitude.sum(), 0, + "Demo targets should render into range-Doppler grid") + self.assertGreater(frame.detection_count, 0, + "Demo targets should produce detections") + + def test_stop_cancels_after(self): + sim, _fq, _uq, mock_root = self._make_simulator() + sim._tick() # sets _after_id + sim.stop() + mock_root.after_cancel.assert_called_once_with("mock_after_id") + self.assertIsNone(sim._after_id) + + +class TestReplayController(unittest.TestCase): + """Unit tests for _ReplayController (no GUI required).""" + + def test_initial_state(self): + fq = queue.Queue() + uq = queue.Queue() + ctrl = _ReplayController(fq, uq) + self.assertEqual(ctrl.total_frames, 0) + self.assertEqual(ctrl.current_index, 0) + self.assertFalse(ctrl.is_playing) + self.assertIsNone(ctrl.software_fpga) + + def test_set_speed(self): + ctrl = _ReplayController(queue.Queue(), queue.Queue()) + ctrl.set_speed("2x") + self.assertAlmostEqual(ctrl._frame_interval, 0.050) + + def test_set_speed_unknown_falls_back(self): + ctrl = _ReplayController(queue.Queue(), queue.Queue()) + ctrl.set_speed("99x") + self.assertAlmostEqual(ctrl._frame_interval, 0.100) + + def test_set_loop(self): + ctrl = _ReplayController(queue.Queue(), queue.Queue()) + ctrl.set_loop(True) + self.assertTrue(ctrl._loop) + ctrl.set_loop(False) + self.assertFalse(ctrl._loop) + + def test_seek_increments_past_emitted(self): + """After seek(), _current_index should be one past the seeked frame.""" + fq = queue.Queue(maxsize=100) + uq = queue.Queue(maxsize=100) + ctrl = _ReplayController(fq, uq) + # Manually set engine to a mock to allow seek + from unittest.mock import MagicMock + mock_engine = MagicMock() + mock_engine.total_frames = 10 + mock_engine.get_frame.return_value = RadarFrame() + ctrl._engine = mock_engine + ctrl.seek(5) + # _current_index should be 6 (past the emitted frame) + self.assertEqual(ctrl._current_index, 6) + self.assertEqual(ctrl._last_emitted_index, 5) + # Frame should be in the queue + self.assertFalse(fq.empty()) + + def test_seek_clamps_to_bounds(self): + from unittest.mock import MagicMock + + fq = queue.Queue(maxsize=100) + uq = queue.Queue(maxsize=100) + ctrl = _ReplayController(fq, uq) + mock_engine = MagicMock() + mock_engine.total_frames = 5 + mock_engine.get_frame.return_value = RadarFrame() + ctrl._engine = mock_engine + + ctrl.seek(100) + # Should clamp to last frame (index 4), then _current_index = 5 + self.assertEqual(ctrl._last_emitted_index, 4) + self.assertEqual(ctrl._current_index, 5) + + ctrl.seek(-10) + # Should clamp to 0, then _current_index = 1 + self.assertEqual(ctrl._last_emitted_index, 0) + self.assertEqual(ctrl._current_index, 1) + + def test_close_releases_engine(self): + from unittest.mock import MagicMock + + fq = queue.Queue(maxsize=100) + uq = queue.Queue(maxsize=100) + ctrl = _ReplayController(fq, uq) + mock_engine = MagicMock() + mock_engine.total_frames = 5 + mock_engine.get_frame.return_value = RadarFrame() + ctrl._engine = mock_engine + + ctrl.close() + mock_engine.close.assert_called_once() + self.assertIsNone(ctrl._engine) + self.assertIsNone(ctrl.software_fpga) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f97c55..6b34008 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,9 +78,9 @@ Every test binary must exit 0. ```bash cd 9_Firmware/9_3_GUI -python3 -m pytest test_radar_dashboard.py -v +python3 -m pytest test_GUI_V65_Tk.py -v # or without pytest: -python3 -m unittest test_radar_dashboard -v +python3 -m unittest test_GUI_V65_Tk -v ``` 57+ protocol and rendering tests. The `test_record_and_stop` test @@ -130,7 +130,7 @@ Before pushing, confirm: 1. `bash run_regression.sh` — all phases pass 2. `make all` (MCU tests) — 20/20 pass -3. `python3 -m unittest test_radar_dashboard -v` — all pass +3. `python3 -m unittest test_GUI_V65_Tk -v` — all pass 4. `python3 validate_mem_files.py` — all checks pass 5. `python3 compare.py dc && python3 compare_doppler.py stationary && python3 compare_mf.py all` 6. `git diff --check` — no whitespace issues From d8d30a631555963ac2dae04e3082cad1dac0012b Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:04:57 +0545 Subject: [PATCH 8/8] fix: guard tkinter/matplotlib imports for headless CI environments --- 9_Firmware/9_3_GUI/GUI_V65_Tk.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/9_Firmware/9_3_GUI/GUI_V65_Tk.py b/9_Firmware/9_3_GUI/GUI_V65_Tk.py index 6755534..6ac8007 100644 --- a/9_Firmware/9_3_GUI/GUI_V65_Tk.py +++ b/9_Firmware/9_3_GUI/GUI_V65_Tk.py @@ -44,13 +44,18 @@ from typing import ClassVar import numpy as np -import tkinter as tk -from tkinter import ttk, filedialog +try: + import tkinter as tk + from tkinter import ttk, filedialog -import matplotlib -matplotlib.use("TkAgg") -from matplotlib.figure import Figure -from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + import matplotlib + matplotlib.use("TkAgg") + from matplotlib.figure import Figure + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + + _HAS_GUI = True +except (ModuleNotFoundError, ImportError): + _HAS_GUI = False # Import protocol layer (no GUI deps) from radar_protocol import (