"""
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