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 59% rename from 9_Firmware/9_3_GUI/radar_dashboard.py rename to 9_Firmware/9_3_GUI/GUI_V65_Tk.py index 7575e63..6ac8007 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,36 +14,52 @@ 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 -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 ( - RadarProtocol, FT2232HConnection, ReplayConnection, + RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition, RadarFrame, StatusResponse, NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH, @@ -54,7 +70,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 +89,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 +399,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 +432,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 +488,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 +523,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 +569,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 +840,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 +1030,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 +1078,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 +1121,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 +1395,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,24 +1533,20 @@ 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("--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)") + 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.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: @@ -939,7 +1566,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/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/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_GUI_V65_Tk.py similarity index 74% rename from 9_Firmware/9_3_GUI/test_radar_dashboard.py rename to 9_Firmware/9_3_GUI/test_GUI_V65_Tk.py index b8bf6cf..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 @@ -19,10 +19,10 @@ 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, ) +from GUI_V65_Tk import DemoTarget, DemoSimulator, _ReplayController class TestRadarProtocol(unittest.TestCase): @@ -459,218 +459,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 +478,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 +496,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, @@ -946,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/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/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..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,11 +35,11 @@ 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, ) -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 @@ -52,7 +55,6 @@ from .models import ( ) from .hardware import ( FT2232HConnection, - ReplayConnection, RadarProtocol, RadarFrame, StatusResponse, @@ -60,15 +62,30 @@ 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 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) @@ -142,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 @@ -341,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) @@ -390,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) ------------------ @@ -452,19 +524,19 @@ 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) @@ -483,7 +555,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) @@ -899,7 +971,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, @@ -1047,7 +1119,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) @@ -1164,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: @@ -1174,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.""" @@ -1191,36 +1278,112 @@ 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() 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, @@ -1254,6 +1417,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) @@ -1271,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) @@ -1285,11 +1462,120 @@ 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._status_label_main.setText("Status: Radar stopped") self._sb_status.setText("Radar stopped") 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 # ===================================================================== @@ -1297,6 +1583,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) @@ -1315,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/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/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, + }) 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