""" v7.dashboard — Main application window for the PLFM Radar GUI V7. RadarDashboard is a QMainWindow with four tabs: 1. Main View — Range-Doppler matplotlib canvas, device combos, Start/Stop, targets table 2. Map View — Embedded Leaflet map + sidebar (position, coverage, demo, target info) 3. Diagnostics — Connection indicators, packet stats, dependency status, log viewer 4. Settings — All radar parameters + About section Integrates: hardware interfaces, QThread workers, TargetSimulator, RadarMapWidget. """ import time import logging from typing import List, Optional import numpy as np from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QTabWidget, QSplitter, QGroupBox, QFrame, QLabel, QPushButton, QComboBox, QCheckBox, QDoubleSpinBox, QSpinBox, QTableWidget, QTableWidgetItem, QHeaderView, QPlainTextEdit, QStatusBar, QMessageBox, ) from PyQt6.QtCore import Qt, QTimer, pyqtSlot from PyQt6.QtGui import QColor from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure from .models import ( RadarTarget, RadarSettings, GPSData, ProcessingConfig, DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, DARK_TREEVIEW, DARK_TREEVIEW_ALT, DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO, USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE, ) from .hardware import FT2232HQInterface, STM32USBInterface from .processing import RadarProcessor, RadarPacketParser, USBPacketParser from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator from .map_widget import RadarMapWidget logger = logging.getLogger(__name__) # ============================================================================= # Range-Doppler Canvas (matplotlib) # ============================================================================= class RangeDopplerCanvas(FigureCanvasQTAgg): """Matplotlib canvas showing the Range-Doppler map with dark theme.""" def __init__(self, parent=None): fig = Figure(figsize=(10, 6), facecolor=DARK_BG) self.ax = fig.add_subplot(111, facecolor=DARK_ACCENT) self._data = np.zeros((1024, 32)) self.im = self.ax.imshow( self._data, aspect="auto", cmap="hot", extent=[0, 32, 0, 1024], origin="lower", ) self.ax.set_title("Range-Doppler Map (Pitch Corrected)", color=DARK_FG) self.ax.set_xlabel("Doppler Bin", color=DARK_FG) self.ax.set_ylabel("Range Bin", color=DARK_FG) self.ax.tick_params(colors=DARK_FG) for spine in self.ax.spines.values(): spine.set_color(DARK_BORDER) fig.tight_layout() super().__init__(fig) def update_map(self, rdm: np.ndarray): display = np.log10(rdm + 1) self.im.set_data(display) self.im.set_clim(vmin=display.min(), vmax=max(display.max(), 0.1)) self.draw_idle() # ============================================================================= # RadarDashboard — main window # ============================================================================= class RadarDashboard(QMainWindow): """Main application window with 4 tabs.""" def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("PLFM Radar System GUI V7 — PyQt6") self.setGeometry(100, 60, 1500, 950) # ---- Core objects -------------------------------------------------- self._settings = RadarSettings() self._radar_position = GPSData( latitude=41.9028, longitude=12.4964, altitude=0.0, pitch=0.0, heading=0.0, timestamp=0.0, ) # Hardware interfaces self._stm32 = STM32USBInterface() self._ft2232hq = FT2232HQInterface() # Processing self._processor = RadarProcessor() self._radar_parser = RadarPacketParser() self._usb_parser = USBPacketParser() self._processing_config = ProcessingConfig() # Device lists (cached for index lookup) self._stm32_devices: list = [] self._ft2232hq_devices: list = [] # Workers (created on demand) self._radar_worker: Optional[RadarDataWorker] = None self._gps_worker: Optional[GPSDataWorker] = None self._simulator: Optional[TargetSimulator] = None # State self._running = False self._demo_mode = False self._start_time = time.time() self._radar_stats: dict = {} self._gps_packet_count = 0 self._current_targets: List[RadarTarget] = [] self._corrected_elevations: list = [] # ---- Build UI ------------------------------------------------------ self._apply_dark_theme() self._setup_ui() self._setup_statusbar() # GUI refresh timer (100 ms) self._gui_timer = QTimer(self) self._gui_timer.timeout.connect(self._refresh_gui) self._gui_timer.start(100) # Log handler for diagnostics self._log_handler = _QtLogHandler(self._log_append) self._log_handler.setLevel(logging.INFO) logging.getLogger().addHandler(self._log_handler) logger.info("RadarDashboard initialised") # ===================================================================== # Dark theme # ===================================================================== def _apply_dark_theme(self): self.setStyleSheet(f""" QMainWindow, QWidget {{ background-color: {DARK_BG}; color: {DARK_FG}; }} QTabWidget::pane {{ border: 1px solid {DARK_BORDER}; background-color: {DARK_BG}; }} QTabBar::tab {{ background-color: {DARK_ACCENT}; color: {DARK_FG}; padding: 8px 18px; border: 1px solid {DARK_BORDER}; border-bottom: none; border-top-left-radius: 4px; border-top-right-radius: 4px; }} QTabBar::tab:selected {{ background-color: {DARK_HIGHLIGHT}; }} QTabBar::tab:hover {{ background-color: {DARK_BUTTON_HOVER}; }} QGroupBox {{ border: 1px solid {DARK_BORDER}; border-radius: 4px; margin-top: 12px; padding-top: 12px; font-weight: bold; color: {DARK_FG}; }} QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 6px; }} QPushButton {{ background-color: {DARK_BUTTON}; color: {DARK_FG}; border: 1px solid {DARK_BORDER}; padding: 6px 16px; border-radius: 4px; }} QPushButton:hover {{ background-color: {DARK_BUTTON_HOVER}; }} QPushButton:pressed {{ background-color: {DARK_HIGHLIGHT}; }} QPushButton:disabled {{ color: {DARK_BORDER}; }} QComboBox {{ background-color: {DARK_ACCENT}; color: {DARK_FG}; border: 1px solid {DARK_BORDER}; padding: 4px 8px; border-radius: 4px; }} QLineEdit, QSpinBox, QDoubleSpinBox {{ background-color: {DARK_ACCENT}; color: {DARK_FG}; border: 1px solid {DARK_BORDER}; padding: 4px 8px; border-radius: 4px; }} QCheckBox {{ color: {DARK_FG}; spacing: 6px; }} QLabel {{ color: {DARK_FG}; }} QTableWidget {{ background-color: {DARK_TREEVIEW}; alternate-background-color: {DARK_TREEVIEW_ALT}; color: {DARK_FG}; gridline-color: {DARK_BORDER}; border: 1px solid {DARK_BORDER}; }} QTableWidget::item:selected {{ background-color: {DARK_INFO}; }} QHeaderView::section {{ background-color: {DARK_HIGHLIGHT}; color: {DARK_FG}; padding: 6px; border: none; border-right: 1px solid {DARK_BORDER}; border-bottom: 1px solid {DARK_BORDER}; }} QPlainTextEdit {{ background-color: {DARK_ACCENT}; color: {DARK_FG}; border: 1px solid {DARK_BORDER}; font-family: 'Courier New', monospace; font-size: 11px; }} QScrollBar:vertical {{ background-color: {DARK_ACCENT}; width: 12px; }} QScrollBar::handle:vertical {{ background-color: {DARK_HIGHLIGHT}; border-radius: 6px; min-height: 20px; }} QStatusBar {{ background-color: {DARK_ACCENT}; color: {DARK_FG}; }} """) # ===================================================================== # UI construction # ===================================================================== def _setup_ui(self): central = QWidget() self.setCentralWidget(central) main_layout = QVBoxLayout(central) main_layout.setContentsMargins(8, 8, 8, 8) main_layout.setSpacing(8) self._tabs = QTabWidget() main_layout.addWidget(self._tabs) self._create_main_tab() self._create_map_tab() self._create_diagnostics_tab() self._create_settings_tab() # ----------------------------------------------------------------- # TAB 1: Main View # ----------------------------------------------------------------- def _create_main_tab(self): tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(8, 8, 8, 8) # ---- Control bar --------------------------------------------------- ctrl = QFrame() ctrl.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;") ctrl_layout = QGridLayout(ctrl) ctrl_layout.setContentsMargins(8, 6, 8, 6) # Row 0: device combos & buttons ctrl_layout.addWidget(QLabel("STM32 USB:"), 0, 0) self._stm32_combo = QComboBox() self._stm32_combo.setMinimumWidth(200) ctrl_layout.addWidget(self._stm32_combo, 0, 1) ctrl_layout.addWidget(QLabel("FT2232HQ (Primary):"), 0, 2) self._ft2232hq_combo = QComboBox() self._ft2232hq_combo.setMinimumWidth(200) ctrl_layout.addWidget(self._ft2232hq_combo, 0, 3) refresh_btn = QPushButton("Refresh Devices") refresh_btn.clicked.connect(self._refresh_devices) ctrl_layout.addWidget(refresh_btn, 0, 4) self._start_btn = QPushButton("Start Radar") self._start_btn.setStyleSheet( f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}" f"QPushButton:hover {{ background-color: #66BB6A; }}" ) self._start_btn.clicked.connect(self._start_radar) ctrl_layout.addWidget(self._start_btn, 0, 8) self._stop_btn = QPushButton("Stop Radar") self._stop_btn.setEnabled(False) self._stop_btn.setStyleSheet( f"QPushButton {{ background-color: {DARK_ERROR}; color: white; font-weight: bold; }}" f"QPushButton:hover {{ background-color: #EF5350; }}" ) self._stop_btn.clicked.connect(self._stop_radar) ctrl_layout.addWidget(self._stop_btn, 0, 9) self._demo_btn_main = QPushButton("Start Demo") self._demo_btn_main.setStyleSheet( f"QPushButton {{ background-color: {DARK_INFO}; color: white; font-weight: bold; }}" f"QPushButton:hover {{ background-color: #42A5F5; }}" ) self._demo_btn_main.clicked.connect(self._toggle_demo_main) ctrl_layout.addWidget(self._demo_btn_main, 0, 10) # Row 1: status labels self._gps_label = QLabel("GPS: Waiting for data...") ctrl_layout.addWidget(self._gps_label, 1, 0, 1, 4) self._pitch_label = QLabel("Pitch: --.--\u00b0") ctrl_layout.addWidget(self._pitch_label, 1, 4, 1, 2) self._status_label_main = QLabel("Status: Ready") self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight) ctrl_layout.addWidget(self._status_label_main, 1, 6, 1, 5) layout.addWidget(ctrl) # ---- Display area (range-doppler + targets table) ------------------ display_splitter = QSplitter(Qt.Orientation.Horizontal) # Range-Doppler canvas self._rdm_canvas = RangeDopplerCanvas() display_splitter.addWidget(self._rdm_canvas) # Targets table targets_group = QGroupBox("Detected Targets (Pitch Corrected)") tg_layout = QVBoxLayout(targets_group) self._targets_table_main = QTableWidget() self._targets_table_main.setColumnCount(7) self._targets_table_main.setHorizontalHeaderLabels([ "Track ID", "Range (m)", "Velocity (m/s)", "Azimuth", "Raw Elev", "Corr Elev", "SNR (dB)", ]) self._targets_table_main.setAlternatingRowColors(True) self._targets_table_main.setSelectionBehavior( QTableWidget.SelectionBehavior.SelectRows ) header = self._targets_table_main.horizontalHeader() header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) tg_layout.addWidget(self._targets_table_main) display_splitter.addWidget(targets_group) display_splitter.setSizes([800, 400]) layout.addWidget(display_splitter, stretch=1) self._tabs.addTab(tab, "Main View") # ----------------------------------------------------------------- # TAB 2: Map View # ----------------------------------------------------------------- def _create_map_tab(self): tab = QWidget() layout = QHBoxLayout(tab) layout.setContentsMargins(4, 4, 4, 4) splitter = QSplitter(Qt.Orientation.Horizontal) # Map widget self._map_widget = RadarMapWidget( radar_lat=self._radar_position.latitude, radar_lon=self._radar_position.longitude, ) self._map_widget.targetSelected.connect(self._on_target_selected) splitter.addWidget(self._map_widget) # Sidebar sidebar = QWidget() sidebar.setMaximumWidth(320) sidebar.setMinimumWidth(280) sb_layout = QVBoxLayout(sidebar) sb_layout.setContentsMargins(8, 8, 8, 8) # Radar position group pos_group = QGroupBox("Radar Position") pos_layout = QGridLayout(pos_group) self._lat_spin = QDoubleSpinBox() 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.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.setRange(0, 50000) self._alt_spin.setDecimals(1) self._alt_spin.setValue(0.0) self._alt_spin.setSuffix(" m") pos_layout.addWidget(QLabel("Latitude:"), 0, 0) pos_layout.addWidget(self._lat_spin, 0, 1) pos_layout.addWidget(QLabel("Longitude:"), 1, 0) pos_layout.addWidget(self._lon_spin, 1, 1) pos_layout.addWidget(QLabel("Altitude:"), 2, 0) pos_layout.addWidget(self._alt_spin, 2, 1) sb_layout.addWidget(pos_group) # Coverage group cov_group = QGroupBox("Coverage") cov_layout = QGridLayout(cov_group) self._coverage_spin = QDoubleSpinBox() self._coverage_spin.setRange(1, 200) self._coverage_spin.setDecimals(1) self._coverage_spin.setValue(self._settings.coverage_radius / 1000) self._coverage_spin.setSuffix(" km") self._coverage_spin.valueChanged.connect(self._on_coverage_changed) cov_layout.addWidget(QLabel("Radius:"), 0, 0) cov_layout.addWidget(self._coverage_spin, 0, 1) sb_layout.addWidget(cov_group) # Demo controls group demo_group = QGroupBox("Demo Mode") demo_layout = QVBoxLayout(demo_group) self._demo_btn_map = QPushButton("Start Demo") self._demo_btn_map.setCheckable(True) self._demo_btn_map.clicked.connect(self._toggle_demo_map) demo_layout.addWidget(self._demo_btn_map) add_btn = QPushButton("Add Random Target") add_btn.clicked.connect(self._add_demo_target) demo_layout.addWidget(add_btn) sb_layout.addWidget(demo_group) # Selected target info info_group = QGroupBox("Selected Target") info_layout = QVBoxLayout(info_group) self._target_info_label = QLabel("No target selected") self._target_info_label.setWordWrap(True) self._target_info_label.setStyleSheet(f"color: {DARK_TEXT}; padding: 8px;") info_layout.addWidget(self._target_info_label) sb_layout.addWidget(info_group) sb_layout.addStretch() splitter.addWidget(sidebar) splitter.setSizes([900, 300]) layout.addWidget(splitter) self._tabs.addTab(tab, "Map View") # ----------------------------------------------------------------- # TAB 3: Diagnostics # ----------------------------------------------------------------- def _create_diagnostics_tab(self): tab = QWidget() layout = QVBoxLayout(tab) layout.setContentsMargins(8, 8, 8, 8) top_row = QHBoxLayout() # Connection status conn_group = QGroupBox("Connection Status") conn_layout = QGridLayout(conn_group) self._conn_stm32 = self._make_status_label("STM32 USB") self._conn_ft2232hq = self._make_status_label("FT2232HQ (Primary)") conn_layout.addWidget(QLabel("STM32 USB:"), 0, 0) conn_layout.addWidget(self._conn_stm32, 0, 1) conn_layout.addWidget(QLabel("FT2232HQ:"), 1, 0) conn_layout.addWidget(self._conn_ft2232hq, 1, 1) top_row.addWidget(conn_group) # Packet statistics stats_group = QGroupBox("Packet Statistics") stats_layout = QGridLayout(stats_group) labels = [ "Radar Packets:", "Bytes Received:", "GPS Packets:", "Errors:", "Active Tracks:", "Detected Targets:", "Uptime:", "Packet Rate:", ] self._diag_values: list = [] for i, text in enumerate(labels): r, c = divmod(i, 2) stats_layout.addWidget(QLabel(text), r, c * 2) val = QLabel("0") val.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;") stats_layout.addWidget(val, r, c * 2 + 1) self._diag_values.append(val) top_row.addWidget(stats_group) # Dependency status dep_group = QGroupBox("Optional Dependencies") dep_layout = QGridLayout(dep_group) deps = [ ("pyusb", USB_AVAILABLE), ("pyftdi", FTDI_AVAILABLE), ("scipy", SCIPY_AVAILABLE), ("sklearn", SKLEARN_AVAILABLE), ("filterpy", FILTERPY_AVAILABLE), ("crcmod", CRCMOD_AVAILABLE), ] for i, (name, avail) in enumerate(deps): dep_layout.addWidget(QLabel(name), i, 0) lbl = QLabel("Available" if avail else "Missing") lbl.setStyleSheet( f"color: {DARK_SUCCESS}; font-weight: bold;" if avail else f"color: {DARK_WARNING}; font-weight: bold;" ) dep_layout.addWidget(lbl, i, 1) top_row.addWidget(dep_group) layout.addLayout(top_row) # Log viewer log_group = QGroupBox("System Log") log_layout = QVBoxLayout(log_group) self._log_text = QPlainTextEdit() self._log_text.setReadOnly(True) self._log_text.setMaximumBlockCount(500) log_layout.addWidget(self._log_text) clear_btn = QPushButton("Clear Log") clear_btn.clicked.connect(self._log_text.clear) log_layout.addWidget(clear_btn) layout.addWidget(log_group, stretch=1) self._tabs.addTab(tab, "Diagnostics") # ----------------------------------------------------------------- # TAB 4: Settings # ----------------------------------------------------------------- def _create_settings_tab(self): from PyQt6.QtWidgets import QScrollArea tab = QWidget() scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QFrame.Shape.NoFrame) inner = QWidget() layout = QVBoxLayout(inner) layout.setContentsMargins(8, 8, 8, 8) # ---- Radar parameters group ---------------------------------------- radar_group = QGroupBox("Radar Parameters") r_layout = QGridLayout(radar_group) self._setting_spins: dict = {} param_defs = [ ("System Frequency (GHz):", "system_frequency", 1, 100, 2, self._settings.system_frequency / 1e9, " GHz"), ("Chirp Duration 1 (us):", "chirp_duration_1", 0.01, 10000, 2, self._settings.chirp_duration_1 * 1e6, " us"), ("Chirp Duration 2 (us):", "chirp_duration_2", 0.001, 10000, 3, self._settings.chirp_duration_2 * 1e6, " us"), ("Chirps per Position:", "chirps_per_position", 1, 1024, 0, self._settings.chirps_per_position, ""), ("Freq Min (MHz):", "freq_min", 0.1, 1000, 1, self._settings.freq_min / 1e6, " MHz"), ("Freq Max (MHz):", "freq_max", 0.1, 1000, 1, self._settings.freq_max / 1e6, " MHz"), ("PRF 1 (Hz):", "prf1", 100, 100000, 0, self._settings.prf1, " Hz"), ("PRF 2 (Hz):", "prf2", 100, 100000, 0, self._settings.prf2, " Hz"), ("Max Distance (km):", "max_distance", 1, 500, 1, self._settings.max_distance / 1000, " km"), ("Map Size (km):", "map_size", 1, 500, 1, self._settings.map_size / 1000, " km"), ] for i, (label, key, lo, hi, dec, default, suffix) in enumerate(param_defs): r_layout.addWidget(QLabel(label), i, 0) if dec == 0: spin = QSpinBox() spin.setRange(int(lo), int(hi)) spin.setValue(int(default)) if suffix: spin.setSuffix(suffix) else: spin = QDoubleSpinBox() spin.setRange(lo, hi) spin.setDecimals(dec) spin.setValue(default) if suffix: spin.setSuffix(suffix) r_layout.addWidget(spin, i, 1) self._setting_spins[key] = spin apply_btn = QPushButton("Apply Settings") apply_btn.setStyleSheet( f"QPushButton {{ background-color: {DARK_INFO}; color: white; font-weight: bold; }}" ) apply_btn.clicked.connect(self._apply_settings) r_layout.addWidget(apply_btn, len(param_defs), 0, 1, 2) layout.addWidget(radar_group) # ---- Signal Processing group --------------------------------------- proc_group = QGroupBox("Signal Processing") p_layout = QGridLayout(proc_group) row = 0 # -- MTI -- self._mti_check = QCheckBox("MTI (Moving Target Indication)") self._mti_check.setChecked(self._processing_config.mti_enabled) p_layout.addWidget(self._mti_check, row, 0, 1, 2) row += 1 p_layout.addWidget(QLabel("MTI Order:"), row, 0) self._mti_order_spin = QSpinBox() self._mti_order_spin.setRange(1, 3) self._mti_order_spin.setValue(self._processing_config.mti_order) self._mti_order_spin.setToolTip("1 = single canceller, 2 = double, 3 = triple") p_layout.addWidget(self._mti_order_spin, row, 1) row += 1 # -- Separator -- sep1 = QFrame() sep1.setFrameShape(QFrame.Shape.HLine) sep1.setStyleSheet(f"color: {DARK_BORDER};") p_layout.addWidget(sep1, row, 0, 1, 2) row += 1 # -- CFAR -- self._cfar_check = QCheckBox("CFAR (Constant False Alarm Rate)") self._cfar_check.setChecked(self._processing_config.cfar_enabled) p_layout.addWidget(self._cfar_check, row, 0, 1, 2) row += 1 p_layout.addWidget(QLabel("CFAR Type:"), row, 0) self._cfar_type_combo = QComboBox() self._cfar_type_combo.addItems(["CA-CFAR", "OS-CFAR", "GO-CFAR", "SO-CFAR"]) self._cfar_type_combo.setCurrentText(self._processing_config.cfar_type) p_layout.addWidget(self._cfar_type_combo, row, 1) row += 1 p_layout.addWidget(QLabel("Guard Cells:"), row, 0) self._cfar_guard_spin = QSpinBox() self._cfar_guard_spin.setRange(1, 20) self._cfar_guard_spin.setValue(self._processing_config.cfar_guard_cells) p_layout.addWidget(self._cfar_guard_spin, row, 1) row += 1 p_layout.addWidget(QLabel("Training Cells:"), row, 0) self._cfar_train_spin = QSpinBox() self._cfar_train_spin.setRange(1, 50) self._cfar_train_spin.setValue(self._processing_config.cfar_training_cells) p_layout.addWidget(self._cfar_train_spin, row, 1) row += 1 p_layout.addWidget(QLabel("Threshold Factor:"), row, 0) self._cfar_thresh_spin = QDoubleSpinBox() self._cfar_thresh_spin.setRange(0.1, 50.0) self._cfar_thresh_spin.setDecimals(1) self._cfar_thresh_spin.setValue(self._processing_config.cfar_threshold_factor) self._cfar_thresh_spin.setSingleStep(0.5) p_layout.addWidget(self._cfar_thresh_spin, row, 1) row += 1 # -- Separator -- sep2 = QFrame() sep2.setFrameShape(QFrame.Shape.HLine) sep2.setStyleSheet(f"color: {DARK_BORDER};") p_layout.addWidget(sep2, row, 0, 1, 2) row += 1 # -- DC Notch -- self._dc_notch_check = QCheckBox("DC Notch / Zero-Doppler Removal") self._dc_notch_check.setChecked(self._processing_config.dc_notch_enabled) p_layout.addWidget(self._dc_notch_check, row, 0, 1, 2) row += 1 # -- Separator -- sep3 = QFrame() sep3.setFrameShape(QFrame.Shape.HLine) sep3.setStyleSheet(f"color: {DARK_BORDER};") p_layout.addWidget(sep3, row, 0, 1, 2) row += 1 # -- Windowing -- p_layout.addWidget(QLabel("Window Function:"), row, 0) self._window_combo = QComboBox() self._window_combo.addItems(["None", "Hann", "Hamming", "Blackman", "Kaiser", "Chebyshev"]) self._window_combo.setCurrentText(self._processing_config.window_type) if not SCIPY_AVAILABLE: # Without scipy, only None/Hann/Hamming/Blackman via numpy self._window_combo.setToolTip("Kaiser and Chebyshev require scipy") p_layout.addWidget(self._window_combo, row, 1) row += 1 # -- Separator -- sep4 = QFrame() sep4.setFrameShape(QFrame.Shape.HLine) sep4.setStyleSheet(f"color: {DARK_BORDER};") p_layout.addWidget(sep4, row, 0, 1, 2) row += 1 # -- Detection Threshold -- p_layout.addWidget(QLabel("Detection Threshold (dB):"), row, 0) self._det_thresh_spin = QDoubleSpinBox() self._det_thresh_spin.setRange(0.0, 60.0) self._det_thresh_spin.setDecimals(1) self._det_thresh_spin.setValue(self._processing_config.detection_threshold_db) self._det_thresh_spin.setSuffix(" dB") self._det_thresh_spin.setSingleStep(1.0) self._det_thresh_spin.setToolTip( "SNR threshold above noise floor (used when CFAR is disabled)" ) p_layout.addWidget(self._det_thresh_spin, row, 1) row += 1 # -- Separator -- sep5 = QFrame() sep5.setFrameShape(QFrame.Shape.HLine) sep5.setStyleSheet(f"color: {DARK_BORDER};") p_layout.addWidget(sep5, row, 0, 1, 2) row += 1 # -- Clustering -- self._cluster_check = QCheckBox("DBSCAN Clustering") self._cluster_check.setChecked(self._processing_config.clustering_enabled) if not SKLEARN_AVAILABLE: self._cluster_check.setEnabled(False) self._cluster_check.setToolTip("Requires scikit-learn") p_layout.addWidget(self._cluster_check, row, 0, 1, 2) row += 1 p_layout.addWidget(QLabel("DBSCAN eps:"), row, 0) self._cluster_eps_spin = QDoubleSpinBox() 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) self._cluster_eps_spin.setSingleStep(10.0) p_layout.addWidget(self._cluster_eps_spin, row, 1) row += 1 p_layout.addWidget(QLabel("Min Samples:"), row, 0) self._cluster_min_spin = QSpinBox() self._cluster_min_spin.setRange(1, 20) self._cluster_min_spin.setValue(self._processing_config.clustering_min_samples) p_layout.addWidget(self._cluster_min_spin, row, 1) row += 1 # -- Separator -- sep6 = QFrame() sep6.setFrameShape(QFrame.Shape.HLine) sep6.setStyleSheet(f"color: {DARK_BORDER};") p_layout.addWidget(sep6, row, 0, 1, 2) row += 1 # -- Kalman Tracking -- self._tracking_check = QCheckBox("Kalman Tracking") self._tracking_check.setChecked(self._processing_config.tracking_enabled) if not FILTERPY_AVAILABLE: self._tracking_check.setEnabled(False) self._tracking_check.setToolTip("Requires filterpy") p_layout.addWidget(self._tracking_check, row, 0, 1, 2) row += 1 # Apply Processing button apply_proc_btn = QPushButton("Apply Processing Settings") apply_proc_btn.setStyleSheet( f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}" f"QPushButton:hover {{ background-color: #66BB6A; }}" ) apply_proc_btn.clicked.connect(self._apply_processing_config) p_layout.addWidget(apply_proc_btn, row, 0, 1, 2) layout.addWidget(proc_group) # ---- About group --------------------------------------------------- about_group = QGroupBox("About") about_layout = QVBoxLayout(about_group) about_lbl = QLabel( "PLFM Radar System GUI V7
" "PyQt6 Edition with Embedded Leaflet Map

" "Data Interface: FT2232HQ (USB 2.0)
" "Map: OpenStreetMap + Leaflet.js
" "Framework: PyQt6 + QWebEngine
" "Version: 7.0.0" ) about_lbl.setStyleSheet(f"color: {DARK_TEXT}; padding: 12px;") about_layout.addWidget(about_lbl) layout.addWidget(about_group) layout.addStretch() scroll.setWidget(inner) tab_layout = QVBoxLayout(tab) tab_layout.setContentsMargins(0, 0, 0, 0) tab_layout.addWidget(scroll) self._tabs.addTab(tab, "Settings") # ===================================================================== # Status bar # ===================================================================== def _setup_statusbar(self): bar = QStatusBar() self.setStatusBar(bar) self._sb_status = QLabel("Ready") bar.addWidget(self._sb_status) self._sb_targets = QLabel("Targets: 0") bar.addPermanentWidget(self._sb_targets) self._sb_mode = QLabel("Idle") self._sb_mode.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;") bar.addPermanentWidget(self._sb_mode) # ===================================================================== # Device management # ===================================================================== def _refresh_devices(self): # STM32 self._stm32_devices = self._stm32.list_devices() self._stm32_combo.clear() for d in self._stm32_devices: self._stm32_combo.addItem(d["description"]) if self._stm32_devices: self._stm32_combo.setCurrentIndex(0) # FT2232HQ (primary) self._ft2232hq_devices = self._ft2232hq.list_devices() self._ft2232hq_combo.clear() for d in self._ft2232hq_devices: self._ft2232hq_combo.addItem(d["description"]) if self._ft2232hq_devices: self._ft2232hq_combo.setCurrentIndex(0) logger.info( f"Devices refreshed: {len(self._stm32_devices)} STM32, " f"{len(self._ft2232hq_devices)} FT2232HQ" ) # ===================================================================== # Start / Stop radar # ===================================================================== def _start_radar(self): try: # Open STM32 idx = self._stm32_combo.currentIndex() if idx < 0 or idx >= len(self._stm32_devices): QMessageBox.warning(self, "Warning", "Please select an STM32 USB device.") return if not self._stm32.open_device(self._stm32_devices[idx]): QMessageBox.critical(self, "Error", "Failed to open STM32 USB device.") return # Open FT2232HQ (primary) idx2 = self._ft2232hq_combo.currentIndex() if idx2 >= 0 and idx2 < len(self._ft2232hq_devices): url = self._ft2232hq_devices[idx2]["url"] if not self._ft2232hq.open_device(url): QMessageBox.warning( self, "Warning", "Failed to open FT2232HQ device. Radar data may not be available.", ) # Send start flag + settings if not self._stm32.send_start_flag(): QMessageBox.critical(self, "Error", "Failed to send start flag to STM32.") return self._apply_settings_to_model() self._stm32.send_settings(self._settings) # Start workers self._radar_worker = RadarDataWorker( ft2232hq=self._ft2232hq, processor=self._processor, packet_parser=self._radar_parser, settings=self._settings, gps_data_ref=self._radar_position, ) self._radar_worker.targetsUpdated.connect(self._on_radar_targets) self._radar_worker.statsUpdated.connect(self._on_radar_stats) self._radar_worker.errorOccurred.connect(self._on_worker_error) self._radar_worker.start() self._gps_worker = GPSDataWorker( stm32=self._stm32, usb_parser=self._usb_parser, ) self._gps_worker.gpsReceived.connect(self._on_gps_received) self._gps_worker.errorOccurred.connect(self._on_worker_error) self._gps_worker.start() # UI state self._running = True self._start_time = time.time() self._start_btn.setEnabled(False) self._stop_btn.setEnabled(True) self._status_label_main.setText("Status: Radar running") self._sb_status.setText("Radar running") self._sb_mode.setText("Live") logger.info("Radar system started") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to start radar: {e}") logger.error(f"Start radar error: {e}") def _stop_radar(self): self._running = False if self._radar_worker: self._radar_worker.stop() self._radar_worker.wait(2000) self._radar_worker = None if self._gps_worker: self._gps_worker.stop() self._gps_worker.wait(2000) self._gps_worker = None self._stm32.close() self._ft2232hq.close() self._start_btn.setEnabled(True) self._stop_btn.setEnabled(False) self._status_label_main.setText("Status: Radar stopped") self._sb_status.setText("Radar stopped") self._sb_mode.setText("Idle") logger.info("Radar system stopped") # ===================================================================== # Demo mode # ===================================================================== def _start_demo(self): if self._simulator: return self._simulator = TargetSimulator(self._radar_position, self) self._simulator.targetsUpdated.connect(self._on_demo_targets) self._simulator.start(500) self._demo_mode = True self._sb_mode.setText("Demo Mode") self._sb_status.setText("Demo mode active") self._demo_btn_main.setText("Stop Demo") self._demo_btn_map.setText("Stop Demo") self._demo_btn_map.setChecked(True) logger.info("Demo mode started") def _stop_demo(self): if self._simulator: self._simulator.stop() self._simulator = None self._demo_mode = False self._sb_mode.setText("Idle" if not self._running else "Live") self._sb_status.setText("Demo stopped") self._demo_btn_main.setText("Start Demo") self._demo_btn_map.setText("Start Demo") self._demo_btn_map.setChecked(False) logger.info("Demo mode stopped") def _toggle_demo_main(self): if self._demo_mode: self._stop_demo() else: self._start_demo() def _toggle_demo_map(self, checked: bool): if checked: self._start_demo() else: self._stop_demo() def _add_demo_target(self): if self._simulator: self._simulator.add_random_target() logger.info("Added random demo target") # ===================================================================== # Slots — data from workers / simulator # ===================================================================== @pyqtSlot(list) def _on_radar_targets(self, targets: list): self._current_targets = targets self._map_widget.set_targets(targets) @pyqtSlot(dict) def _on_radar_stats(self, stats: dict): self._radar_stats = stats @pyqtSlot(str) def _on_worker_error(self, msg: str): logger.error(f"Worker error: {msg}") @pyqtSlot(object) def _on_gps_received(self, gps: GPSData): self._gps_packet_count += 1 self._radar_position.latitude = gps.latitude self._radar_position.longitude = gps.longitude self._radar_position.altitude = gps.altitude self._radar_position.pitch = gps.pitch self._radar_position.timestamp = gps.timestamp self._map_widget.set_radar_position(self._radar_position) if self._simulator: self._simulator.set_radar_position(self._radar_position) @pyqtSlot(list) def _on_demo_targets(self, targets: list): self._current_targets = targets self._map_widget.set_targets(targets) self._sb_targets.setText(f"Targets: {len(targets)}") def _on_target_selected(self, target_id: int): for t in self._current_targets: if t.id == target_id: self._show_target_info(t) break def _show_target_info(self, target: RadarTarget): status = ("Approaching" if target.velocity > 1 else ("Receding" if target.velocity < -1 else "Stationary")) color = (DARK_ERROR if status == "Approaching" else (DARK_INFO if status == "Receding" else DARK_TEXT)) info = ( f"Target #{target.id}

" f"Track ID: {target.track_id}
" f"Range: {target.range:.1f} m
" f"Velocity: {target.velocity:+.1f} m/s
" f"Azimuth: {target.azimuth:.1f}\u00b0
" f"Elevation: {target.elevation:.1f}\u00b0
" f"SNR: {target.snr:.1f} dB
" f"Class: {target.classification}
" f'Status: {status}' ) self._target_info_label.setText(info) # ===================================================================== # Position / coverage callbacks (map sidebar) # ===================================================================== def _on_position_changed(self): self._radar_position.latitude = self._lat_spin.value() self._radar_position.longitude = self._lon_spin.value() self._radar_position.altitude = self._alt_spin.value() self._map_widget.set_radar_position(self._radar_position) if self._simulator: self._simulator.set_radar_position(self._radar_position) def _on_coverage_changed(self, value: float): radius_m = value * 1000 self._settings.coverage_radius = radius_m self._map_widget.set_coverage_radius(radius_m) # ===================================================================== # Settings # ===================================================================== def _apply_settings_to_model(self): """Read spin values into the RadarSettings model.""" s = self._settings sp = self._setting_spins s.system_frequency = sp["system_frequency"].value() * 1e9 s.chirp_duration_1 = sp["chirp_duration_1"].value() * 1e-6 s.chirp_duration_2 = sp["chirp_duration_2"].value() * 1e-6 s.chirps_per_position = int(sp["chirps_per_position"].value()) s.freq_min = sp["freq_min"].value() * 1e6 s.freq_max = sp["freq_max"].value() * 1e6 s.prf1 = sp["prf1"].value() s.prf2 = sp["prf2"].value() s.max_distance = sp["max_distance"].value() * 1000 s.map_size = sp["map_size"].value() * 1000 def _apply_settings(self): try: self._apply_settings_to_model() if self._stm32.is_open: self._stm32.send_settings(self._settings) logger.info("Radar settings applied") QMessageBox.information(self, "Settings", "Radar settings applied.") except Exception as e: QMessageBox.critical(self, "Error", f"Invalid setting value: {e}") logger.error(f"Settings error: {e}") def _apply_processing_config(self): """Read signal processing controls into ProcessingConfig and push to processor.""" try: cfg = ProcessingConfig( mti_enabled=self._mti_check.isChecked(), mti_order=self._mti_order_spin.value(), cfar_enabled=self._cfar_check.isChecked(), cfar_type=self._cfar_type_combo.currentText(), cfar_guard_cells=self._cfar_guard_spin.value(), cfar_training_cells=self._cfar_train_spin.value(), cfar_threshold_factor=self._cfar_thresh_spin.value(), dc_notch_enabled=self._dc_notch_check.isChecked(), window_type=self._window_combo.currentText(), detection_threshold_db=self._det_thresh_spin.value(), clustering_enabled=self._cluster_check.isChecked(), clustering_eps=self._cluster_eps_spin.value(), clustering_min_samples=self._cluster_min_spin.value(), tracking_enabled=self._tracking_check.isChecked(), ) self._processing_config = cfg self._processor.set_config(cfg) logger.info( f"Processing config applied: MTI={cfg.mti_enabled}(order {cfg.mti_order}), " f"CFAR={cfg.cfar_enabled}({cfg.cfar_type}), DC_Notch={cfg.dc_notch_enabled}, " f"Window={cfg.window_type}, Threshold={cfg.detection_threshold_db} dB, " f"Clustering={cfg.clustering_enabled}, Tracking={cfg.tracking_enabled}" ) QMessageBox.information(self, "Processing", "Signal processing settings applied.") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to apply processing settings: {e}") logger.error(f"Processing config error: {e}") # ===================================================================== # Periodic GUI refresh (100 ms timer) # ===================================================================== def _refresh_gui(self): try: # GPS label gps = self._radar_position self._gps_label.setText( f"GPS: Lat {gps.latitude:.6f}, Lon {gps.longitude:.6f}, " f"Alt {gps.altitude:.1f}m" ) # Pitch label with colour coding pitch_text = f"Pitch: {gps.pitch:+.1f}\u00b0" self._pitch_label.setText(pitch_text) if abs(gps.pitch) > 10: self._pitch_label.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;") elif abs(gps.pitch) > 5: self._pitch_label.setStyleSheet(f"color: {DARK_WARNING}; font-weight: bold;") else: self._pitch_label.setStyleSheet(f"color: {DARK_SUCCESS}; font-weight: bold;") # Range-Doppler map self._rdm_canvas.update_map(self._processor.range_doppler_map) # Targets table (main tab) self._update_main_targets_table() # Status label (main tab) if self._running: pkt = self._radar_stats.get("packets", 0) self._status_label_main.setText( f"Status: Running \u2014 Packets: {pkt} \u2014 Pitch: {gps.pitch:+.1f}\u00b0" ) # Diagnostics values self._update_diagnostics() # Status-bar target count self._sb_targets.setText(f"Targets: {len(self._current_targets)}") except Exception as e: logger.error(f"GUI refresh error: {e}") def _update_main_targets_table(self): targets = self._current_targets[-20:] # last 20 self._targets_table_main.setRowCount(len(targets)) for row, t in enumerate(targets): self._targets_table_main.setItem( row, 0, QTableWidgetItem(str(t.track_id))) self._targets_table_main.setItem( row, 1, QTableWidgetItem(f"{t.range:.1f}")) vel_item = QTableWidgetItem(f"{t.velocity:+.1f}") if t.velocity > 1: vel_item.setForeground(QColor(DARK_ERROR)) elif t.velocity < -1: vel_item.setForeground(QColor(DARK_INFO)) self._targets_table_main.setItem(row, 2, vel_item) self._targets_table_main.setItem( row, 3, QTableWidgetItem(f"{t.azimuth:.1f}")) # Raw elevation — show stored value from corrections cache raw_text = "N/A" for corr in self._corrected_elevations[-20:]: if abs(corr["corrected"] - t.elevation) < 0.1: raw_text = f"{corr['raw']}" break self._targets_table_main.setItem( row, 4, QTableWidgetItem(raw_text)) self._targets_table_main.setItem( row, 5, QTableWidgetItem(f"{t.elevation:.1f}")) self._targets_table_main.setItem( row, 6, QTableWidgetItem(f"{t.snr:.1f}")) def _update_diagnostics(self): # Connection indicators self._set_conn_indicator(self._conn_stm32, self._stm32.is_open) self._set_conn_indicator(self._conn_ft2232hq, self._ft2232hq.is_open) stats = self._radar_stats gps_count = self._gps_packet_count if self._gps_worker: gps_count = self._gps_worker.gps_count uptime = time.time() - self._start_time pkt = stats.get("packets", 0) pkt_rate = pkt / max(uptime, 1) vals = [ str(pkt), f"{stats.get('bytes', 0):,}", str(gps_count), str(stats.get("errors", 0)), str(stats.get("active_tracks", len(self._processor.tracks))), str(stats.get("targets", len(self._current_targets))), f"{uptime:.0f}s", f"{pkt_rate:.1f}/s", ] for lbl, v in zip(self._diag_values, vals): lbl.setText(v) # ===================================================================== # Helpers # ===================================================================== @staticmethod def _make_status_label(name: str) -> QLabel: lbl = QLabel("Disconnected") lbl.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;") return lbl @staticmethod def _set_conn_indicator(label: QLabel, connected: bool): if connected: label.setText("Connected") label.setStyleSheet(f"color: {DARK_SUCCESS}; font-weight: bold;") else: label.setText("Disconnected") label.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;") def _log_append(self, message: str): """Append a log message to the diagnostics log viewer.""" self._log_text.appendPlainText(message) # ===================================================================== # Close event # ===================================================================== def closeEvent(self, event): if self._simulator: self._simulator.stop() if self._radar_worker: self._radar_worker.stop() self._radar_worker.wait(1000) if self._gps_worker: self._gps_worker.stop() self._gps_worker.wait(1000) self._stm32.close() self._ft2232hq.close() logging.getLogger().removeHandler(self._log_handler) event.accept() # ============================================================================= # Qt-compatible log handler (routes Python logging → QTextEdit) # ============================================================================= class _QtLogHandler(logging.Handler): """Sends log records to a callback (called on the thread that emitted).""" def __init__(self, callback): super().__init__() self._callback = callback self.setFormatter(logging.Formatter( "%(asctime)s %(levelname)-8s %(message)s", datefmt="%H:%M:%S", )) def emit(self, record): try: msg = self.format(record) self._callback(msg) except Exception: pass