fix: enforce strict ruff lint (17 rule sets) across entire repo
- Expand ruff config from E/F to 17 rule sets (B, RUF, SIM, PIE, T20, ARG, ERA, A, BLE, RET, ISC, TCH, UP, C4, PERF) - Fix 907 lint errors across all Python files (GUI, FPGA cosim, schematics scripts, simulations, utilities, tools) - Replace all blind except-Exception with specific exception types - Remove commented-out dead code (ERA001) from cosim/simulation files - Modernize typing: deprecated typing.List/Dict/Tuple to builtins - Fix unused args/loop vars, ambiguous unicode, perf anti-patterns - Delete legacy GUI files V1-V4 - Add V7 test suite, requirements files - All CI jobs pass: ruff (0 errors), py_compile, pytest (92/92), MCU tests (20/20), FPGA regression (25/25)
This commit is contained in:
@@ -26,7 +26,6 @@ import time
|
||||
import random
|
||||
import logging
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from enum import Enum
|
||||
|
||||
# PyQt6 imports
|
||||
@@ -198,12 +197,12 @@ class RadarMapWidget(QWidget):
|
||||
altitude=100.0,
|
||||
pitch=0.0
|
||||
)
|
||||
self._targets: List[RadarTarget] = []
|
||||
self._targets: list[RadarTarget] = []
|
||||
self._coverage_radius = 50000 # meters
|
||||
self._tile_server = TileServer.OPENSTREETMAP
|
||||
self._show_coverage = True
|
||||
self._show_trails = False
|
||||
self._target_history: Dict[int, List[Tuple[float, float]]] = {}
|
||||
self._target_history: dict[int, list[tuple[float, float]]] = {}
|
||||
|
||||
# Setup UI
|
||||
self._setup_ui()
|
||||
@@ -908,7 +907,7 @@ class RadarMapWidget(QWidget):
|
||||
"""Handle marker click events"""
|
||||
self.targetSelected.emit(target_id)
|
||||
|
||||
def _on_tile_server_changed(self, index: int):
|
||||
def _on_tile_server_changed(self, _index: int):
|
||||
"""Handle tile server change"""
|
||||
server = self._tile_combo.currentData()
|
||||
self._tile_server = server
|
||||
@@ -947,7 +946,7 @@ class RadarMapWidget(QWidget):
|
||||
f"{gps_data.altitude}, {gps_data.pitch}, {gps_data.heading})"
|
||||
)
|
||||
|
||||
def set_targets(self, targets: List[RadarTarget]):
|
||||
def set_targets(self, targets: list[RadarTarget]):
|
||||
"""Update all targets on the map"""
|
||||
self._targets = targets
|
||||
|
||||
@@ -980,7 +979,7 @@ def polar_to_geographic(
|
||||
radar_lon: float,
|
||||
range_m: float,
|
||||
azimuth_deg: float
|
||||
) -> Tuple[float, float]:
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Convert polar coordinates (range, azimuth) relative to radar
|
||||
to geographic coordinates (latitude, longitude).
|
||||
@@ -1028,7 +1027,7 @@ class TargetSimulator(QObject):
|
||||
super().__init__(parent)
|
||||
|
||||
self._radar_position = radar_position
|
||||
self._targets: List[RadarTarget] = []
|
||||
self._targets: list[RadarTarget] = []
|
||||
self._next_id = 1
|
||||
self._timer = QTimer()
|
||||
self._timer.timeout.connect(self._update_targets)
|
||||
@@ -1164,7 +1163,7 @@ class RadarDashboard(QMainWindow):
|
||||
timestamp=time.time()
|
||||
)
|
||||
self._settings = RadarSettings()
|
||||
self._simulator: Optional[TargetSimulator] = None
|
||||
self._simulator: TargetSimulator | None = None
|
||||
self._demo_mode = True
|
||||
|
||||
# Setup UI
|
||||
@@ -1571,7 +1570,7 @@ class RadarDashboard(QMainWindow):
|
||||
self._simulator._add_random_target()
|
||||
logger.info("Added random target")
|
||||
|
||||
def _on_targets_updated(self, targets: List[RadarTarget]):
|
||||
def _on_targets_updated(self, targets: list[RadarTarget]):
|
||||
"""Handle updated target list from simulator"""
|
||||
# Update map
|
||||
self._map_widget.set_targets(targets)
|
||||
@@ -1582,7 +1581,7 @@ class RadarDashboard(QMainWindow):
|
||||
# Update table
|
||||
self._update_targets_table(targets)
|
||||
|
||||
def _update_targets_table(self, targets: List[RadarTarget]):
|
||||
def _update_targets_table(self, targets: list[RadarTarget]):
|
||||
"""Update the targets table"""
|
||||
self._targets_table.setRowCount(len(targets))
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import logging
|
||||
import queue
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
|
||||
class RadarGUI:
|
||||
def update_gps_display(self):
|
||||
"""Step 18: Update GPS display and center map"""
|
||||
try:
|
||||
while not self.gps_data_queue.empty():
|
||||
gps_data = self.gps_data_queue.get_nowait()
|
||||
self.current_gps = gps_data
|
||||
|
||||
# Update GPS label
|
||||
self.gps_label.config(
|
||||
text=(
|
||||
f"GPS: Lat {gps_data.latitude:.6f}, "
|
||||
f"Lon {gps_data.longitude:.6f}, "
|
||||
f"Alt {gps_data.altitude:.1f}m"
|
||||
)
|
||||
)
|
||||
|
||||
# Update map
|
||||
self.update_map_display(gps_data)
|
||||
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
def update_map_display(self, gps_data):
|
||||
"""Step 18: Update map display with current GPS position"""
|
||||
try:
|
||||
self.map_label.config(
|
||||
text=(
|
||||
f"Radar Position: {gps_data.latitude:.6f}, {gps_data.longitude:.6f}\n"
|
||||
f"Altitude: {gps_data.altitude:.1f}m\n"
|
||||
f"Coverage: 50km radius\n"
|
||||
f"Map centered on GPS coordinates"
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error updating map display: {e}")
|
||||
|
||||
def main():
|
||||
"""Main application entry point"""
|
||||
try:
|
||||
root = tk.Tk()
|
||||
_app = RadarGUI(root)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
logging.error(f"Application error: {e}")
|
||||
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,715 +0,0 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
from matplotlib.figure import Figure
|
||||
from scipy.fft import fft, fftshift
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Tuple
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
|
||||
@dataclass
|
||||
class RadarTarget:
|
||||
range: float
|
||||
velocity: float
|
||||
azimuth: int
|
||||
elevation: int
|
||||
snr: float
|
||||
chirp_type: str
|
||||
timestamp: float
|
||||
|
||||
|
||||
class SignalProcessor:
|
||||
def __init__(self):
|
||||
self.range_resolution = 1.0 # meters
|
||||
self.velocity_resolution = 0.1 # m/s
|
||||
self.cfar_threshold = 15.0 # dB
|
||||
|
||||
def doppler_fft(self, iq_data: np.ndarray, fs: float = 100e6) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Perform Doppler FFT on IQ data
|
||||
Returns Doppler frequencies and spectrum
|
||||
"""
|
||||
# Window function for FFT
|
||||
window = np.hanning(len(iq_data))
|
||||
windowed_data = (iq_data["I_value"].values + 1j * iq_data["Q_value"].values) * window
|
||||
|
||||
# Perform FFT
|
||||
doppler_fft = fft(windowed_data)
|
||||
doppler_fft = fftshift(doppler_fft)
|
||||
|
||||
# Frequency axis
|
||||
N = len(iq_data)
|
||||
freq_axis = np.linspace(-fs / 2, fs / 2, N)
|
||||
|
||||
# Convert to velocity (assuming radar frequency = 10 GHz)
|
||||
radar_freq = 10e9
|
||||
wavelength = 3e8 / radar_freq
|
||||
velocity_axis = freq_axis * wavelength / 2
|
||||
|
||||
return velocity_axis, np.abs(doppler_fft)
|
||||
|
||||
def mti_filter(self, iq_data: np.ndarray, filter_type: str = "single_canceler") -> np.ndarray:
|
||||
"""
|
||||
Moving Target Indicator filter
|
||||
Removes stationary clutter with better shape handling
|
||||
"""
|
||||
if iq_data is None or len(iq_data) < 2:
|
||||
return np.array([], dtype=complex)
|
||||
|
||||
try:
|
||||
# Ensure we're working with complex data
|
||||
complex_data = iq_data.astype(complex)
|
||||
|
||||
if filter_type == "single_canceler":
|
||||
# Single delay line canceler
|
||||
if len(complex_data) < 2:
|
||||
return np.array([], dtype=complex)
|
||||
filtered = np.zeros(len(complex_data) - 1, dtype=complex)
|
||||
for i in range(1, len(complex_data)):
|
||||
filtered[i - 1] = complex_data[i] - complex_data[i - 1]
|
||||
return filtered
|
||||
|
||||
elif filter_type == "double_canceler":
|
||||
# Double delay line canceler
|
||||
if len(complex_data) < 3:
|
||||
return np.array([], dtype=complex)
|
||||
filtered = np.zeros(len(complex_data) - 2, dtype=complex)
|
||||
for i in range(2, len(complex_data)):
|
||||
filtered[i - 2] = (
|
||||
complex_data[i] - 2 * complex_data[i - 1] + complex_data[i - 2]
|
||||
)
|
||||
return filtered
|
||||
|
||||
else:
|
||||
return complex_data
|
||||
except Exception as e:
|
||||
logging.error(f"MTI filter error: {e}")
|
||||
return np.array([], dtype=complex)
|
||||
|
||||
def cfar_detection(
|
||||
self,
|
||||
range_profile: np.ndarray,
|
||||
guard_cells: int = 2,
|
||||
training_cells: int = 10,
|
||||
threshold_factor: float = 3.0,
|
||||
) -> List[Tuple[int, float]]:
|
||||
detections = []
|
||||
N = len(range_profile)
|
||||
|
||||
# Ensure guard_cells and training_cells are integers
|
||||
guard_cells = int(guard_cells)
|
||||
training_cells = int(training_cells)
|
||||
|
||||
for i in range(N):
|
||||
# Convert to integer indices
|
||||
i_int = int(i)
|
||||
if i_int < guard_cells + training_cells or i_int >= N - guard_cells - training_cells:
|
||||
continue
|
||||
|
||||
# Leading window - ensure integer indices
|
||||
lead_start = i_int - guard_cells - training_cells
|
||||
lead_end = i_int - guard_cells
|
||||
lead_cells = range_profile[lead_start:lead_end]
|
||||
|
||||
# Lagging window - ensure integer indices
|
||||
lag_start = i_int + guard_cells + 1
|
||||
lag_end = i_int + guard_cells + training_cells + 1
|
||||
lag_cells = range_profile[lag_start:lag_end]
|
||||
|
||||
# Combine training cells
|
||||
training_cells_combined = np.concatenate([lead_cells, lag_cells])
|
||||
|
||||
# Calculate noise floor (mean of training cells)
|
||||
if len(training_cells_combined) > 0:
|
||||
noise_floor = np.mean(training_cells_combined)
|
||||
|
||||
# Apply threshold
|
||||
threshold = noise_floor * threshold_factor
|
||||
|
||||
if range_profile[i_int] > threshold:
|
||||
detections.append(
|
||||
(i_int, float(range_profile[i_int]))
|
||||
) # Ensure float magnitude
|
||||
|
||||
return detections
|
||||
|
||||
def range_fft(
|
||||
self, iq_data: np.ndarray, fs: float = 100e6, bw: float = 20e6
|
||||
) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Perform range FFT on IQ data
|
||||
Returns range profile
|
||||
"""
|
||||
# Window function
|
||||
window = np.hanning(len(iq_data))
|
||||
windowed_data = np.abs(iq_data) * window
|
||||
|
||||
# Perform FFT
|
||||
range_fft = fft(windowed_data)
|
||||
|
||||
# Range calculation
|
||||
N = len(iq_data)
|
||||
range_max = (3e8 * N) / (2 * bw)
|
||||
range_axis = np.linspace(0, range_max, N)
|
||||
|
||||
return range_axis, np.abs(range_fft)
|
||||
|
||||
def process_chirp_sequence(self, df: pd.DataFrame, chirp_type: str = "LONG") -> Dict:
|
||||
try:
|
||||
# Filter data by chirp type
|
||||
chirp_data = df[df["chirp_type"] == chirp_type]
|
||||
|
||||
if len(chirp_data) == 0:
|
||||
return {}
|
||||
|
||||
# Group by chirp number
|
||||
chirp_numbers = chirp_data["chirp_number"].unique()
|
||||
num_chirps = len(chirp_numbers)
|
||||
|
||||
if num_chirps == 0:
|
||||
return {}
|
||||
|
||||
# Get samples per chirp and ensure consistency
|
||||
samples_per_chirp_list = [
|
||||
len(chirp_data[chirp_data["chirp_number"] == num]) for num in chirp_numbers
|
||||
]
|
||||
|
||||
# Use minimum samples to ensure consistent shape
|
||||
samples_per_chirp = min(samples_per_chirp_list)
|
||||
|
||||
# Create range-Doppler matrix with consistent shape
|
||||
range_doppler_matrix = np.zeros((samples_per_chirp, num_chirps), dtype=complex)
|
||||
|
||||
for i, chirp_num in enumerate(chirp_numbers):
|
||||
chirp_samples = chirp_data[chirp_data["chirp_number"] == chirp_num]
|
||||
# Take only the first samples_per_chirp samples to ensure consistent shape
|
||||
chirp_samples = chirp_samples.head(samples_per_chirp)
|
||||
|
||||
# Create complex IQ data
|
||||
iq_data = chirp_samples["I_value"].values + 1j * chirp_samples["Q_value"].values
|
||||
|
||||
# Ensure the shape matches
|
||||
if len(iq_data) == samples_per_chirp:
|
||||
range_doppler_matrix[:, i] = iq_data
|
||||
|
||||
# Apply MTI filter along slow-time (chirp-to-chirp)
|
||||
mti_filtered = np.zeros_like(range_doppler_matrix)
|
||||
for i in range(samples_per_chirp):
|
||||
slow_time_data = range_doppler_matrix[i, :]
|
||||
filtered = self.mti_filter(slow_time_data)
|
||||
# Ensure filtered data matches expected shape
|
||||
if len(filtered) == num_chirps:
|
||||
mti_filtered[i, :] = filtered
|
||||
else:
|
||||
# Handle shape mismatch by padding or truncating
|
||||
if len(filtered) < num_chirps:
|
||||
padded = np.zeros(num_chirps, dtype=complex)
|
||||
padded[: len(filtered)] = filtered
|
||||
mti_filtered[i, :] = padded
|
||||
else:
|
||||
mti_filtered[i, :] = filtered[:num_chirps]
|
||||
|
||||
# Perform Doppler FFT along slow-time dimension
|
||||
doppler_fft_result = np.zeros((samples_per_chirp, num_chirps), dtype=complex)
|
||||
for i in range(samples_per_chirp):
|
||||
doppler_fft_result[i, :] = fft(mti_filtered[i, :])
|
||||
|
||||
return {
|
||||
"range_doppler_matrix": np.abs(doppler_fft_result),
|
||||
"chirp_type": chirp_type,
|
||||
"num_chirps": num_chirps,
|
||||
"samples_per_chirp": samples_per_chirp,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error in process_chirp_sequence: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
class RadarGUI:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("Radar Signal Processor - CSV Analysis")
|
||||
self.root.geometry("1400x900")
|
||||
|
||||
# Initialize processor
|
||||
self.processor = SignalProcessor()
|
||||
|
||||
# Data storage
|
||||
self.df = None
|
||||
self.processed_data = {}
|
||||
self.detected_targets = []
|
||||
|
||||
# Create GUI
|
||||
self.create_gui()
|
||||
|
||||
# Start background processing
|
||||
self.processing_queue = queue.Queue()
|
||||
self.processing_thread = threading.Thread(target=self.background_processing, daemon=True)
|
||||
self.processing_thread.start()
|
||||
|
||||
# Update GUI periodically
|
||||
self.root.after(100, self.update_gui)
|
||||
|
||||
def create_gui(self):
|
||||
"""Create the main GUI layout"""
|
||||
# Main frame
|
||||
main_frame = ttk.Frame(self.root)
|
||||
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# Control panel
|
||||
control_frame = ttk.LabelFrame(main_frame, text="File Controls")
|
||||
control_frame.pack(fill="x", pady=5)
|
||||
|
||||
# File selection
|
||||
ttk.Button(control_frame, text="Load CSV File", command=self.load_csv_file).pack(
|
||||
side="left", padx=5, pady=5
|
||||
)
|
||||
|
||||
self.file_label = ttk.Label(control_frame, text="No file loaded")
|
||||
self.file_label.pack(side="left", padx=10, pady=5)
|
||||
|
||||
# Processing controls
|
||||
ttk.Button(control_frame, text="Process Data", command=self.process_data).pack(
|
||||
side="left", padx=5, pady=5
|
||||
)
|
||||
|
||||
ttk.Button(control_frame, text="Run CFAR Detection", command=self.run_cfar_detection).pack(
|
||||
side="left", padx=5, pady=5
|
||||
)
|
||||
|
||||
# Status
|
||||
self.status_label = ttk.Label(control_frame, text="Status: Ready")
|
||||
self.status_label.pack(side="right", padx=10, pady=5)
|
||||
|
||||
# Display area
|
||||
display_frame = ttk.Frame(main_frame)
|
||||
display_frame.pack(fill="both", expand=True, pady=5)
|
||||
|
||||
# Create matplotlib figures
|
||||
self.create_plots(display_frame)
|
||||
|
||||
# Targets list
|
||||
targets_frame = ttk.LabelFrame(main_frame, text="Detected Targets")
|
||||
targets_frame.pack(fill="x", pady=5)
|
||||
|
||||
self.targets_tree = ttk.Treeview(
|
||||
targets_frame,
|
||||
columns=("Range", "Velocity", "Azimuth", "Elevation", "SNR", "Chirp Type"),
|
||||
show="headings",
|
||||
height=8,
|
||||
)
|
||||
|
||||
self.targets_tree.heading("Range", text="Range (m)")
|
||||
self.targets_tree.heading("Velocity", text="Velocity (m/s)")
|
||||
self.targets_tree.heading("Azimuth", text="Azimuth (°)")
|
||||
self.targets_tree.heading("Elevation", text="Elevation (°)")
|
||||
self.targets_tree.heading("SNR", text="SNR (dB)")
|
||||
self.targets_tree.heading("Chirp Type", text="Chirp Type")
|
||||
|
||||
self.targets_tree.column("Range", width=100)
|
||||
self.targets_tree.column("Velocity", width=100)
|
||||
self.targets_tree.column("Azimuth", width=80)
|
||||
self.targets_tree.column("Elevation", width=80)
|
||||
self.targets_tree.column("SNR", width=80)
|
||||
self.targets_tree.column("Chirp Type", width=100)
|
||||
|
||||
self.targets_tree.pack(fill="x", padx=5, pady=5)
|
||||
|
||||
def create_plots(self, parent):
|
||||
"""Create matplotlib plots"""
|
||||
# Create figure with subplots
|
||||
self.fig = Figure(figsize=(12, 8))
|
||||
self.canvas = FigureCanvasTkAgg(self.fig, parent)
|
||||
self.canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||
|
||||
# Create subplots
|
||||
self.ax1 = self.fig.add_subplot(221) # Range profile
|
||||
self.ax2 = self.fig.add_subplot(222) # Doppler spectrum
|
||||
self.ax3 = self.fig.add_subplot(223) # Range-Doppler map
|
||||
self.ax4 = self.fig.add_subplot(224) # MTI filtered data
|
||||
|
||||
# Set titles
|
||||
self.ax1.set_title("Range Profile")
|
||||
self.ax1.set_xlabel("Range (m)")
|
||||
self.ax1.set_ylabel("Magnitude")
|
||||
self.ax1.grid(True)
|
||||
|
||||
self.ax2.set_title("Doppler Spectrum")
|
||||
self.ax2.set_xlabel("Velocity (m/s)")
|
||||
self.ax2.set_ylabel("Magnitude")
|
||||
self.ax2.grid(True)
|
||||
|
||||
self.ax3.set_title("Range-Doppler Map")
|
||||
self.ax3.set_xlabel("Doppler Bin")
|
||||
self.ax3.set_ylabel("Range Bin")
|
||||
|
||||
self.ax4.set_title("MTI Filtered Data")
|
||||
self.ax4.set_xlabel("Sample")
|
||||
self.ax4.set_ylabel("Magnitude")
|
||||
self.ax4.grid(True)
|
||||
|
||||
self.fig.tight_layout()
|
||||
|
||||
def load_csv_file(self):
|
||||
"""Load CSV file generated by testbench"""
|
||||
filename = filedialog.askopenfilename(
|
||||
title="Select CSV file", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]
|
||||
)
|
||||
|
||||
# Add magnitude and phase calculations after loading CSV
|
||||
if self.df is not None:
|
||||
# Calculate magnitude from I/Q values
|
||||
self.df["magnitude"] = np.sqrt(self.df["I_value"] ** 2 + self.df["Q_value"] ** 2)
|
||||
|
||||
# Calculate phase from I/Q values
|
||||
self.df["phase_rad"] = np.arctan2(self.df["Q_value"], self.df["I_value"])
|
||||
|
||||
# If you used magnitude_squared in CSV, calculate actual magnitude
|
||||
if "magnitude_squared" in self.df.columns:
|
||||
self.df["magnitude"] = np.sqrt(self.df["magnitude_squared"])
|
||||
if filename:
|
||||
try:
|
||||
self.status_label.config(text="Status: Loading CSV file...")
|
||||
self.df = pd.read_csv(filename)
|
||||
self.file_label.config(text=f"Loaded: {filename.split('/')[-1]}")
|
||||
self.status_label.config(text=f"Status: Loaded {len(self.df)} samples")
|
||||
|
||||
# Show basic info
|
||||
self.show_file_info()
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to load CSV file: {e}")
|
||||
self.status_label.config(text="Status: Error loading file")
|
||||
|
||||
def show_file_info(self):
|
||||
"""Display basic information about loaded data"""
|
||||
if self.df is not None:
|
||||
info_text = f"Samples: {len(self.df)} | "
|
||||
info_text += f"Chirps: {self.df['chirp_number'].nunique()} | "
|
||||
info_text += f"Long: {len(self.df[self.df['chirp_type'] == 'LONG'])} | "
|
||||
info_text += f"Short: {len(self.df[self.df['chirp_type'] == 'SHORT'])}"
|
||||
|
||||
self.file_label.config(text=info_text)
|
||||
|
||||
def process_data(self):
|
||||
"""Process loaded CSV data"""
|
||||
if self.df is None:
|
||||
messagebox.showwarning("Warning", "Please load a CSV file first")
|
||||
return
|
||||
|
||||
self.status_label.config(text="Status: Processing data...")
|
||||
|
||||
# Add to processing queue
|
||||
self.processing_queue.put(("process", self.df))
|
||||
|
||||
def run_cfar_detection(self):
|
||||
"""Run CFAR detection on processed data"""
|
||||
if self.df is None:
|
||||
messagebox.showwarning("Warning", "Please load and process data first")
|
||||
return
|
||||
|
||||
self.status_label.config(text="Status: Running CFAR detection...")
|
||||
self.processing_queue.put(("cfar", self.df))
|
||||
|
||||
def background_processing(self):
|
||||
|
||||
while True:
|
||||
try:
|
||||
task_type, data = self.processing_queue.get(timeout=1.0)
|
||||
|
||||
if task_type == "process":
|
||||
self._process_data_background(data)
|
||||
elif task_type == "cfar":
|
||||
self._run_cfar_background(data)
|
||||
else:
|
||||
logging.warning(f"Unknown task type: {task_type}")
|
||||
|
||||
self.processing_queue.task_done()
|
||||
|
||||
except queue.Empty:
|
||||
continue
|
||||
except Exception as e:
|
||||
logging.error(f"Background processing error: {e}")
|
||||
# Update GUI to show error state
|
||||
self.root.after(
|
||||
0,
|
||||
lambda: self.status_label.config(
|
||||
text=f"Status: Processing error - {e}" # noqa: F821
|
||||
),
|
||||
)
|
||||
|
||||
def _process_data_background(self, df):
|
||||
try:
|
||||
# Process long chirps
|
||||
long_chirp_data = self.processor.process_chirp_sequence(df, "LONG")
|
||||
|
||||
# Process short chirps
|
||||
short_chirp_data = self.processor.process_chirp_sequence(df, "SHORT")
|
||||
|
||||
# Store results
|
||||
self.processed_data = {"long": long_chirp_data, "short": short_chirp_data}
|
||||
|
||||
# Update GUI in main thread
|
||||
self.root.after(0, self._update_plots_after_processing)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Processing error: {e}")
|
||||
error_msg = str(e)
|
||||
self.root.after(
|
||||
0,
|
||||
lambda msg=error_msg: self.status_label.config(
|
||||
text=f"Status: Processing error - {msg}"
|
||||
),
|
||||
)
|
||||
|
||||
def _run_cfar_background(self, df):
|
||||
try:
|
||||
# Get first chirp for CFAR demonstration
|
||||
first_chirp = df[df["chirp_number"] == df["chirp_number"].min()]
|
||||
|
||||
if len(first_chirp) == 0:
|
||||
return
|
||||
|
||||
# Create IQ data - FIXED TYPO: first_chirp not first_chip
|
||||
iq_data = first_chirp["I_value"].values + 1j * first_chirp["Q_value"].values
|
||||
|
||||
# Perform range FFT
|
||||
range_axis, range_profile = self.processor.range_fft(iq_data)
|
||||
|
||||
# Run CFAR detection
|
||||
detections = self.processor.cfar_detection(range_profile)
|
||||
|
||||
# Convert to target objects
|
||||
self.detected_targets = []
|
||||
for range_bin, magnitude in detections:
|
||||
target = RadarTarget(
|
||||
range=range_axis[range_bin],
|
||||
velocity=0, # Would need Doppler processing for velocity
|
||||
azimuth=0, # From actual data
|
||||
elevation=0, # From actual data
|
||||
snr=20 * np.log10(magnitude + 1e-9), # Convert to dB
|
||||
chirp_type="LONG",
|
||||
timestamp=time.time(),
|
||||
)
|
||||
self.detected_targets.append(target)
|
||||
|
||||
# Update GUI in main thread
|
||||
self.root.after(
|
||||
0, lambda: self._update_cfar_results(range_axis, range_profile, detections)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"CFAR detection error: {e}")
|
||||
error_msg = str(e)
|
||||
self.root.after(
|
||||
0,
|
||||
lambda msg=error_msg: self.status_label.config(text=f"Status: CFAR error - {msg}"),
|
||||
)
|
||||
|
||||
def _update_plots_after_processing(self):
|
||||
try:
|
||||
# Clear all plots
|
||||
for ax in [self.ax1, self.ax2, self.ax3, self.ax4]:
|
||||
ax.clear()
|
||||
|
||||
# Plot 1: Range profile from first chirp
|
||||
if self.df is not None and len(self.df) > 0:
|
||||
try:
|
||||
first_chirp_num = self.df["chirp_number"].min()
|
||||
first_chirp = self.df[self.df["chirp_number"] == first_chirp_num]
|
||||
|
||||
if len(first_chirp) > 0:
|
||||
iq_data = first_chirp["I_value"].values + 1j * first_chirp["Q_value"].values
|
||||
range_axis, range_profile = self.processor.range_fft(iq_data)
|
||||
|
||||
if len(range_axis) > 0 and len(range_profile) > 0:
|
||||
self.ax1.plot(range_axis, range_profile, "b-")
|
||||
self.ax1.set_title("Range Profile - First Chirp")
|
||||
self.ax1.set_xlabel("Range (m)")
|
||||
self.ax1.set_ylabel("Magnitude")
|
||||
self.ax1.grid(True)
|
||||
except Exception as e:
|
||||
logging.warning(f"Range profile plot error: {e}")
|
||||
self.ax1.set_title("Range Profile - Error")
|
||||
|
||||
# Plot 2: Doppler spectrum
|
||||
if self.df is not None and len(self.df) > 0:
|
||||
try:
|
||||
sample_data = self.df.head(1024)
|
||||
if len(sample_data) > 10:
|
||||
iq_data = sample_data["I_value"].values + 1j * sample_data["Q_value"].values
|
||||
velocity_axis, doppler_spectrum = self.processor.doppler_fft(iq_data)
|
||||
|
||||
if len(velocity_axis) > 0 and len(doppler_spectrum) > 0:
|
||||
self.ax2.plot(velocity_axis, doppler_spectrum, "g-")
|
||||
self.ax2.set_title("Doppler Spectrum")
|
||||
self.ax2.set_xlabel("Velocity (m/s)")
|
||||
self.ax2.set_ylabel("Magnitude")
|
||||
self.ax2.grid(True)
|
||||
except Exception as e:
|
||||
logging.warning(f"Doppler spectrum plot error: {e}")
|
||||
self.ax2.set_title("Doppler Spectrum - Error")
|
||||
|
||||
# Plot 3: Range-Doppler map
|
||||
if (
|
||||
self.processed_data.get("long")
|
||||
and "range_doppler_matrix" in self.processed_data["long"]
|
||||
and self.processed_data["long"]["range_doppler_matrix"].size > 0
|
||||
):
|
||||
try:
|
||||
rd_matrix = self.processed_data["long"]["range_doppler_matrix"]
|
||||
# Use integer indices for extent
|
||||
extent = [0, int(rd_matrix.shape[1]), 0, int(rd_matrix.shape[0])]
|
||||
|
||||
im = self.ax3.imshow(
|
||||
10 * np.log10(rd_matrix + 1e-9), aspect="auto", cmap="hot", extent=extent
|
||||
)
|
||||
self.ax3.set_title("Range-Doppler Map (Long Chirps)")
|
||||
self.ax3.set_xlabel("Doppler Bin")
|
||||
self.ax3.set_ylabel("Range Bin")
|
||||
self.fig.colorbar(im, ax=self.ax3, label="dB")
|
||||
except Exception as e:
|
||||
logging.warning(f"Range-Doppler map plot error: {e}")
|
||||
self.ax3.set_title("Range-Doppler Map - Error")
|
||||
|
||||
# Plot 4: MTI filtered data
|
||||
if self.df is not None and len(self.df) > 0:
|
||||
try:
|
||||
sample_data = self.df.head(100)
|
||||
if len(sample_data) > 10:
|
||||
iq_data = sample_data["I_value"].values + 1j * sample_data["Q_value"].values
|
||||
|
||||
# Original data
|
||||
original_mag = np.abs(iq_data)
|
||||
|
||||
# MTI filtered
|
||||
mti_filtered = self.processor.mti_filter(iq_data)
|
||||
|
||||
if mti_filtered is not None and len(mti_filtered) > 0:
|
||||
mti_mag = np.abs(mti_filtered)
|
||||
|
||||
# Use integer indices for plotting
|
||||
x_original = np.arange(len(original_mag))
|
||||
x_mti = np.arange(len(mti_mag))
|
||||
|
||||
self.ax4.plot(
|
||||
x_original, original_mag, "b-", label="Original", alpha=0.7
|
||||
)
|
||||
self.ax4.plot(x_mti, mti_mag, "r-", label="MTI Filtered", alpha=0.7)
|
||||
self.ax4.set_title("MTI Filter Comparison")
|
||||
self.ax4.set_xlabel("Sample Index")
|
||||
self.ax4.set_ylabel("Magnitude")
|
||||
self.ax4.legend()
|
||||
self.ax4.grid(True)
|
||||
except Exception as e:
|
||||
logging.warning(f"MTI filter plot error: {e}")
|
||||
self.ax4.set_title("MTI Filter - Error")
|
||||
|
||||
# Adjust layout and draw
|
||||
self.fig.tight_layout()
|
||||
self.canvas.draw()
|
||||
self.status_label.config(text="Status: Processing complete")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Plot update error: {e}")
|
||||
error_msg = str(e)
|
||||
self.status_label.config(text=f"Status: Plot error - {error_msg}")
|
||||
|
||||
def _update_cfar_results(self, range_axis, range_profile, detections):
|
||||
try:
|
||||
# Clear the plot
|
||||
self.ax1.clear()
|
||||
|
||||
# Plot range profile
|
||||
self.ax1.plot(range_axis, range_profile, "b-", label="Range Profile")
|
||||
|
||||
# Plot detections - ensure we use integer indices
|
||||
if detections and len(range_axis) > 0:
|
||||
detection_ranges = []
|
||||
detection_mags = []
|
||||
|
||||
for bin_idx, mag in detections:
|
||||
# Convert bin_idx to integer and ensure it's within bounds
|
||||
bin_idx_int = int(bin_idx)
|
||||
if 0 <= bin_idx_int < len(range_axis):
|
||||
detection_ranges.append(range_axis[bin_idx_int])
|
||||
detection_mags.append(mag)
|
||||
|
||||
if detection_ranges: # Only plot if we have valid detections
|
||||
self.ax1.plot(
|
||||
detection_ranges,
|
||||
detection_mags,
|
||||
"ro",
|
||||
markersize=8,
|
||||
label="CFAR Detections",
|
||||
)
|
||||
|
||||
self.ax1.set_title("Range Profile with CFAR Detections")
|
||||
self.ax1.set_xlabel("Range (m)")
|
||||
self.ax1.set_ylabel("Magnitude")
|
||||
self.ax1.legend()
|
||||
self.ax1.grid(True)
|
||||
|
||||
# Update targets list
|
||||
self.update_targets_list()
|
||||
|
||||
self.canvas.draw()
|
||||
self.status_label.config(
|
||||
text=f"Status: CFAR complete - {len(detections)} targets detected"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"CFAR results update error: {e}")
|
||||
error_msg = str(e)
|
||||
self.status_label.config(text=f"Status: CFAR results error - {error_msg}")
|
||||
|
||||
def update_targets_list(self):
|
||||
"""Update the targets list display"""
|
||||
# Clear current list
|
||||
for item in self.targets_tree.get_children():
|
||||
self.targets_tree.delete(item)
|
||||
|
||||
# Add detected targets
|
||||
for i, target in enumerate(self.detected_targets):
|
||||
self.targets_tree.insert(
|
||||
"",
|
||||
"end",
|
||||
values=(
|
||||
f"{target.range:.1f}",
|
||||
f"{target.velocity:.1f}",
|
||||
f"{target.azimuth}",
|
||||
f"{target.elevation}",
|
||||
f"{target.snr:.1f}",
|
||||
target.chirp_type,
|
||||
),
|
||||
)
|
||||
|
||||
def update_gui(self):
|
||||
"""Periodic GUI update"""
|
||||
# You can add any periodic updates here
|
||||
self.root.after(100, self.update_gui)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main application entry point"""
|
||||
try:
|
||||
root = tk.Tk()
|
||||
_app = RadarGUI(root)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
logging.error(f"Application error: {e}")
|
||||
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -27,9 +27,9 @@ except ImportError:
|
||||
USB_AVAILABLE = False
|
||||
logging.warning("pyusb not available. USB CDC functionality will be disabled.")
|
||||
|
||||
try:
|
||||
from pyftdi.ftdi import Ftdi
|
||||
from pyftdi.usbtools import UsbTools
|
||||
try:
|
||||
from pyftdi.ftdi import Ftdi, FtdiError
|
||||
from pyftdi.usbtools import UsbTools
|
||||
|
||||
FTDI_AVAILABLE = True
|
||||
except ImportError:
|
||||
@@ -288,18 +288,16 @@ class MapGenerator:
|
||||
targets_json = str(map_targets).replace("'", '"')
|
||||
targets_script = f"updateTargets({targets_json});"
|
||||
|
||||
# Fill template
|
||||
map_html = self.map_html_template.format(
|
||||
lat=gps_data.latitude,
|
||||
lon=gps_data.longitude,
|
||||
alt=gps_data.altitude,
|
||||
pitch=gps_data.pitch,
|
||||
coverage_radius=coverage_radius,
|
||||
targets_script=targets_script,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
return map_html
|
||||
# Fill template
|
||||
return self.map_html_template.format(
|
||||
lat=gps_data.latitude,
|
||||
lon=gps_data.longitude,
|
||||
alt=gps_data.altitude,
|
||||
pitch=gps_data.pitch,
|
||||
coverage_radius=coverage_radius,
|
||||
targets_script=targets_script,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
|
||||
"""
|
||||
@@ -369,7 +367,7 @@ class STM32USBInterface:
|
||||
"device": dev,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
except (usb.core.USBError, ValueError):
|
||||
devices.append(
|
||||
{
|
||||
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
|
||||
@@ -380,7 +378,7 @@ class STM32USBInterface:
|
||||
)
|
||||
|
||||
return devices
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error listing USB devices: {e}")
|
||||
# Return mock devices for testing
|
||||
return [
|
||||
@@ -430,7 +428,7 @@ class STM32USBInterface:
|
||||
logging.info(f"STM32 USB device opened: {device_info['description']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error opening USB device: {e}")
|
||||
return False
|
||||
|
||||
@@ -446,7 +444,7 @@ class STM32USBInterface:
|
||||
packet = self._create_settings_packet(settings)
|
||||
logging.info("Sending radar settings to STM32 via USB...")
|
||||
return self._send_data(packet)
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, struct.error) as e:
|
||||
logging.error(f"Error sending settings via USB: {e}")
|
||||
return False
|
||||
|
||||
@@ -463,9 +461,6 @@ class STM32USBInterface:
|
||||
return None
|
||||
logging.error(f"USB read error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading from USB: {e}")
|
||||
return None
|
||||
|
||||
def _send_data(self, data):
|
||||
"""Send data to STM32 via USB"""
|
||||
@@ -483,7 +478,7 @@ class STM32USBInterface:
|
||||
self.ep_out.write(chunk)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error sending data via USB: {e}")
|
||||
return False
|
||||
|
||||
@@ -509,7 +504,7 @@ class STM32USBInterface:
|
||||
try:
|
||||
usb.util.dispose_resources(self.device)
|
||||
self.is_open = False
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error closing USB device: {e}")
|
||||
|
||||
|
||||
@@ -524,16 +519,14 @@ class FTDIInterface:
|
||||
logging.warning("FTDI not available - please install pyftdi")
|
||||
return []
|
||||
|
||||
try:
|
||||
devices = []
|
||||
# Get list of all FTDI devices
|
||||
for device in UsbTools.find_all([(0x0403, 0x6010)]): # FT2232H vendor/product ID
|
||||
devices.append(
|
||||
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
|
||||
)
|
||||
return devices
|
||||
except Exception as e:
|
||||
logging.error(f"Error listing FTDI devices: {e}")
|
||||
try:
|
||||
# Get list of all FTDI devices
|
||||
return [
|
||||
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
|
||||
for device in UsbTools.find_all([(0x0403, 0x6010)])
|
||||
] # FT2232H vendor/product ID
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error listing FTDI devices: {e}")
|
||||
# Return mock devices for testing
|
||||
return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}]
|
||||
|
||||
@@ -560,7 +553,7 @@ class FTDIInterface:
|
||||
logging.info(f"FTDI device opened: {device_url}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
logging.error(f"Error opening FTDI device: {e}")
|
||||
return False
|
||||
|
||||
@@ -574,7 +567,7 @@ class FTDIInterface:
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
logging.error(f"Error reading from FTDI: {e}")
|
||||
return None
|
||||
|
||||
@@ -595,8 +588,7 @@ class RadarProcessor:
|
||||
|
||||
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
|
||||
"""Dual-CPI fusion for better detection"""
|
||||
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
return fused_profile
|
||||
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
|
||||
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
|
||||
"""Multi-PRF velocity unwrapping"""
|
||||
@@ -643,7 +635,7 @@ class RadarProcessor:
|
||||
|
||||
return clusters
|
||||
|
||||
def association(self, detections, clusters):
|
||||
def association(self, detections, _clusters):
|
||||
"""Association of detections to tracks"""
|
||||
associated_detections = []
|
||||
|
||||
@@ -737,7 +729,7 @@ class USBPacketParser:
|
||||
if len(data) >= 30 and data[0:4] == b"GPSB":
|
||||
return self._parse_binary_gps_with_pitch(data)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing GPS data: {e}")
|
||||
|
||||
return None
|
||||
@@ -789,7 +781,7 @@ class USBPacketParser:
|
||||
timestamp=time.time(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing binary GPS with pitch: {e}")
|
||||
return None
|
||||
|
||||
@@ -831,13 +823,12 @@ class RadarPacketParser:
|
||||
|
||||
if packet_type == 0x01:
|
||||
return self.parse_range_packet(payload)
|
||||
elif packet_type == 0x02:
|
||||
if packet_type == 0x02:
|
||||
return self.parse_doppler_packet(payload)
|
||||
elif packet_type == 0x03:
|
||||
if packet_type == 0x03:
|
||||
return self.parse_detection_packet(payload)
|
||||
else:
|
||||
logging.warning(f"Unknown packet type: {packet_type:02X}")
|
||||
return None
|
||||
logging.warning(f"Unknown packet type: {packet_type:02X}")
|
||||
return None
|
||||
|
||||
def calculate_crc(self, data):
|
||||
return self.crc16_func(data)
|
||||
@@ -860,7 +851,7 @@ class RadarPacketParser:
|
||||
"chirp": chirp_counter,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing range packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -884,7 +875,7 @@ class RadarPacketParser:
|
||||
"chirp": chirp_counter,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing Doppler packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -906,7 +897,7 @@ class RadarPacketParser:
|
||||
"chirp": chirp_counter,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing detection packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -1345,7 +1336,7 @@ class RadarGUI:
|
||||
|
||||
logging.info("Radar system started successfully via USB CDC")
|
||||
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, FtdiError, ValueError) as e:
|
||||
messagebox.showerror("Error", f"Failed to start radar: {e}")
|
||||
logging.error(f"Start radar error: {e}")
|
||||
|
||||
@@ -1414,7 +1405,7 @@ class RadarGUI:
|
||||
else:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
logging.error(f"Error processing radar data: {e}")
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
@@ -1438,7 +1429,7 @@ class RadarGUI:
|
||||
f"Alt {gps_data.altitude:.1f}m, "
|
||||
f"Pitch {gps_data.pitch:.1f}°"
|
||||
)
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError, struct.error) as e:
|
||||
logging.error(f"Error processing GPS data via USB: {e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -1501,7 +1492,7 @@ class RadarGUI:
|
||||
f"Pitch {self.current_gps.pitch:.1f}°"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, KeyError) as e:
|
||||
logging.error(f"Error processing radar packet: {e}")
|
||||
|
||||
def update_range_doppler_map(self, target):
|
||||
@@ -1568,9 +1559,9 @@ class RadarGUI:
|
||||
)
|
||||
logging.info(f"Map generated: {self.map_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, ValueError) as e:
|
||||
logging.error(f"Error generating map: {e}")
|
||||
self.map_status_label.config(text=f"Map: Error - {str(e)}")
|
||||
self.map_status_label.config(text=f"Map: Error - {e!s}")
|
||||
|
||||
def update_gps_display(self):
|
||||
"""Step 18: Update GPS and pitch display"""
|
||||
@@ -1657,7 +1648,7 @@ class RadarGUI:
|
||||
# Update GPS and pitch display
|
||||
self.update_gps_display()
|
||||
|
||||
except Exception as e:
|
||||
except (tk.TclError, RuntimeError) as e:
|
||||
logging.error(f"Error updating GUI: {e}")
|
||||
|
||||
self.root.after(100, self.update_gui)
|
||||
@@ -1669,7 +1660,7 @@ def main():
|
||||
root = tk.Tk()
|
||||
_app = RadarGUI(root)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.error(f"Application error: {e}")
|
||||
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
|
||||
|
||||
|
||||
@@ -36,9 +36,9 @@ except ImportError:
|
||||
USB_AVAILABLE = False
|
||||
logging.warning("pyusb not available. USB CDC functionality will be disabled.")
|
||||
|
||||
try:
|
||||
from pyftdi.ftdi import Ftdi
|
||||
from pyftdi.usbtools import UsbTools
|
||||
try:
|
||||
from pyftdi.ftdi import Ftdi, FtdiError
|
||||
from pyftdi.usbtools import UsbTools
|
||||
|
||||
FTDI_AVAILABLE = True
|
||||
except ImportError:
|
||||
@@ -108,8 +108,7 @@ class RadarProcessor:
|
||||
|
||||
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
|
||||
"""Dual-CPI fusion for better detection"""
|
||||
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
return fused_profile
|
||||
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
|
||||
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
|
||||
"""Multi-PRF velocity unwrapping"""
|
||||
@@ -156,7 +155,7 @@ class RadarProcessor:
|
||||
|
||||
return clusters
|
||||
|
||||
def association(self, detections, clusters):
|
||||
def association(self, detections, _clusters):
|
||||
"""Association of detections to tracks"""
|
||||
associated_detections = []
|
||||
|
||||
@@ -250,7 +249,7 @@ class USBPacketParser:
|
||||
if len(data) >= 30 and data[0:4] == b"GPSB":
|
||||
return self._parse_binary_gps_with_pitch(data)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing GPS data: {e}")
|
||||
|
||||
return None
|
||||
@@ -302,7 +301,7 @@ class USBPacketParser:
|
||||
timestamp=time.time(),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing binary GPS with pitch: {e}")
|
||||
return None
|
||||
|
||||
@@ -344,13 +343,12 @@ class RadarPacketParser:
|
||||
|
||||
if packet_type == 0x01:
|
||||
return self.parse_range_packet(payload)
|
||||
elif packet_type == 0x02:
|
||||
if packet_type == 0x02:
|
||||
return self.parse_doppler_packet(payload)
|
||||
elif packet_type == 0x03:
|
||||
if packet_type == 0x03:
|
||||
return self.parse_detection_packet(payload)
|
||||
else:
|
||||
logging.warning(f"Unknown packet type: {packet_type:02X}")
|
||||
return None
|
||||
logging.warning(f"Unknown packet type: {packet_type:02X}")
|
||||
return None
|
||||
|
||||
def calculate_crc(self, data):
|
||||
return self.crc16_func(data)
|
||||
@@ -373,7 +371,7 @@ class RadarPacketParser:
|
||||
"chirp": chirp_counter,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing range packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -397,7 +395,7 @@ class RadarPacketParser:
|
||||
"chirp": chirp_counter,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing Doppler packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -419,7 +417,7 @@ class RadarPacketParser:
|
||||
"chirp": chirp_counter,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing detection packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -687,23 +685,22 @@ class MapGenerator:
|
||||
# Calculate coverage radius in km
|
||||
coverage_radius_km = coverage_radius / 1000.0
|
||||
|
||||
# Generate HTML content
|
||||
map_html = self.map_html_template.replace("{lat}", str(gps_data.latitude))
|
||||
map_html = map_html.replace("{lon}", str(gps_data.longitude))
|
||||
map_html = map_html.replace("{alt:.1f}", f"{gps_data.altitude:.1f}")
|
||||
map_html = map_html.replace("{pitch:+.1f}", f"{gps_data.pitch:+.1f}")
|
||||
map_html = map_html.replace("{coverage_radius}", str(coverage_radius))
|
||||
map_html = map_html.replace("{coverage_radius_km:.1f}", f"{coverage_radius_km:.1f}")
|
||||
map_html = map_html.replace("{target_count}", str(len(map_targets)))
|
||||
|
||||
# Inject initial targets as JavaScript variable
|
||||
targets_json = json.dumps(map_targets)
|
||||
map_html = map_html.replace(
|
||||
"// Display initial targets if any",
|
||||
f"window.initialTargets = {targets_json};\n // Display initial targets if any",
|
||||
)
|
||||
|
||||
return map_html
|
||||
# Generate HTML content
|
||||
targets_json = json.dumps(map_targets)
|
||||
return (
|
||||
self.map_html_template.replace("{lat}", str(gps_data.latitude))
|
||||
.replace("{lon}", str(gps_data.longitude))
|
||||
.replace("{alt:.1f}", f"{gps_data.altitude:.1f}")
|
||||
.replace("{pitch:+.1f}", f"{gps_data.pitch:+.1f}")
|
||||
.replace("{coverage_radius}", str(coverage_radius))
|
||||
.replace("{coverage_radius_km:.1f}", f"{coverage_radius_km:.1f}")
|
||||
.replace("{target_count}", str(len(map_targets)))
|
||||
.replace(
|
||||
"// Display initial targets if any",
|
||||
"window.initialTargets = "
|
||||
f"{targets_json};\n // Display initial targets if any",
|
||||
)
|
||||
)
|
||||
|
||||
def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
|
||||
"""
|
||||
@@ -775,7 +772,7 @@ class STM32USBInterface:
|
||||
"device": dev,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
except (usb.core.USBError, ValueError):
|
||||
devices.append(
|
||||
{
|
||||
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
|
||||
@@ -786,7 +783,7 @@ class STM32USBInterface:
|
||||
)
|
||||
|
||||
return devices
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error listing USB devices: {e}")
|
||||
# Return mock devices for testing
|
||||
return [
|
||||
@@ -836,7 +833,7 @@ class STM32USBInterface:
|
||||
logging.info(f"STM32 USB device opened: {device_info['description']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error opening USB device: {e}")
|
||||
return False
|
||||
|
||||
@@ -852,7 +849,7 @@ class STM32USBInterface:
|
||||
packet = self._create_settings_packet(settings)
|
||||
logging.info("Sending radar settings to STM32 via USB...")
|
||||
return self._send_data(packet)
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, struct.error) as e:
|
||||
logging.error(f"Error sending settings via USB: {e}")
|
||||
return False
|
||||
|
||||
@@ -869,9 +866,6 @@ class STM32USBInterface:
|
||||
return None
|
||||
logging.error(f"USB read error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error(f"Error reading from USB: {e}")
|
||||
return None
|
||||
|
||||
def _send_data(self, data):
|
||||
"""Send data to STM32 via USB"""
|
||||
@@ -889,7 +883,7 @@ class STM32USBInterface:
|
||||
self.ep_out.write(chunk)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error sending data via USB: {e}")
|
||||
return False
|
||||
|
||||
@@ -915,7 +909,7 @@ class STM32USBInterface:
|
||||
try:
|
||||
usb.util.dispose_resources(self.device)
|
||||
self.is_open = False
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error closing USB device: {e}")
|
||||
|
||||
|
||||
@@ -930,16 +924,14 @@ class FTDIInterface:
|
||||
logging.warning("FTDI not available - please install pyftdi")
|
||||
return []
|
||||
|
||||
try:
|
||||
devices = []
|
||||
# Get list of all FTDI devices
|
||||
for device in UsbTools.find_all([(0x0403, 0x6010)]): # FT2232H vendor/product ID
|
||||
devices.append(
|
||||
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
|
||||
)
|
||||
return devices
|
||||
except Exception as e:
|
||||
logging.error(f"Error listing FTDI devices: {e}")
|
||||
try:
|
||||
# Get list of all FTDI devices
|
||||
return [
|
||||
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
|
||||
for device in UsbTools.find_all([(0x0403, 0x6010)])
|
||||
] # FT2232H vendor/product ID
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error listing FTDI devices: {e}")
|
||||
# Return mock devices for testing
|
||||
return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}]
|
||||
|
||||
@@ -966,7 +958,7 @@ class FTDIInterface:
|
||||
logging.info(f"FTDI device opened: {device_url}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
logging.error(f"Error opening FTDI device: {e}")
|
||||
return False
|
||||
|
||||
@@ -980,7 +972,7 @@ class FTDIInterface:
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
logging.error(f"Error reading from FTDI: {e}")
|
||||
return None
|
||||
|
||||
@@ -1242,7 +1234,7 @@ class RadarGUI:
|
||||
"""
|
||||
self.browser.load_html(placeholder_html)
|
||||
|
||||
except Exception as e:
|
||||
except (tk.TclError, RuntimeError) as e:
|
||||
logging.error(f"Failed to create embedded browser: {e}")
|
||||
self.create_browser_fallback()
|
||||
else:
|
||||
@@ -1340,7 +1332,7 @@ Map HTML will appear here when generated.
|
||||
self.fallback_text.configure(state="disabled")
|
||||
self.fallback_text.see("1.0") # Scroll to top
|
||||
logging.info("Fallback text widget updated with map HTML")
|
||||
except Exception as e:
|
||||
except (tk.TclError, RuntimeError) as e:
|
||||
logging.error(f"Error updating embedded browser: {e}")
|
||||
|
||||
def generate_map(self):
|
||||
@@ -1386,7 +1378,7 @@ Map HTML will appear here when generated.
|
||||
|
||||
logging.info(f"Map generated with {len(targets)} targets")
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, ValueError) as e:
|
||||
logging.error(f"Error generating map: {e}")
|
||||
self.map_status_label.config(text=f"Map: Error - {str(e)[:50]}")
|
||||
|
||||
@@ -1400,19 +1392,19 @@ Map HTML will appear here when generated.
|
||||
# Create temporary HTML file
|
||||
import tempfile
|
||||
|
||||
temp_file = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".html", delete=False, encoding="utf-8"
|
||||
)
|
||||
temp_file.write(self.current_map_html)
|
||||
temp_file.close()
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".html", delete=False, encoding="utf-8"
|
||||
) as temp_file:
|
||||
temp_file.write(self.current_map_html)
|
||||
temp_file_path = temp_file.name
|
||||
|
||||
# Open in default browser
|
||||
webbrowser.open("file://" + os.path.abspath(temp_file.name))
|
||||
logging.info(f"Map opened in external browser: {temp_file.name}")
|
||||
webbrowser.open("file://" + os.path.abspath(temp_file_path))
|
||||
logging.info(f"Map opened in external browser: {temp_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error opening external browser: {e}")
|
||||
messagebox.showerror("Error", f"Failed to open browser: {e}")
|
||||
except (OSError, ValueError) as e:
|
||||
logging.error(f"Error opening external browser: {e}")
|
||||
messagebox.showerror("Error", f"Failed to open browser: {e}")
|
||||
|
||||
# ... [Rest of the methods remain the same - demo mode, radar processing, etc.] ...
|
||||
|
||||
@@ -1427,7 +1419,7 @@ def main():
|
||||
root = tk.Tk()
|
||||
_app = RadarGUI(root)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.error(f"Application error: {e}")
|
||||
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ except ImportError:
|
||||
logging.warning("pyusb not available. USB functionality will be disabled.")
|
||||
|
||||
try:
|
||||
from pyftdi.ftdi import Ftdi # noqa: F401
|
||||
from pyftdi.usbtools import UsbTools # noqa: F401
|
||||
from pyftdi.ftdi import FtdiError # noqa: F401
|
||||
from pyftdi.ftdi import Ftdi
|
||||
from pyftdi.usbtools import UsbTools # noqa: F401
|
||||
from pyftdi.ftdi import FtdiError # noqa: F401
|
||||
FTDI_AVAILABLE = True
|
||||
except ImportError:
|
||||
FTDI_AVAILABLE = False
|
||||
@@ -242,7 +242,6 @@ class MapGenerator:
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
pass
|
||||
|
||||
class FT601Interface:
|
||||
"""
|
||||
@@ -298,7 +297,7 @@ class FT601Interface:
|
||||
'device': dev,
|
||||
'serial': serial
|
||||
})
|
||||
except Exception:
|
||||
except (usb.core.USBError, ValueError):
|
||||
devices.append({
|
||||
'description': f"FT601 USB3.0 (VID:{vid:04X}, PID:{pid:04X})",
|
||||
'vendor_id': vid,
|
||||
@@ -308,7 +307,7 @@ class FT601Interface:
|
||||
})
|
||||
|
||||
return devices
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logging.error(f"Error listing FT601 devices: {e}")
|
||||
# Return mock devices for testing
|
||||
return [
|
||||
@@ -350,7 +349,7 @@ class FT601Interface:
|
||||
logging.info(f"FT601 device opened: {device_url}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
logging.error(f"Error opening FT601 device: {e}")
|
||||
return False
|
||||
|
||||
@@ -403,7 +402,7 @@ class FT601Interface:
|
||||
logging.info(f"FT601 device opened: {device_info['description']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error opening FT601 device: {e}")
|
||||
return False
|
||||
|
||||
@@ -427,7 +426,7 @@ class FT601Interface:
|
||||
return bytes(data)
|
||||
return None
|
||||
|
||||
elif self.device and self.ep_in:
|
||||
if self.device and self.ep_in:
|
||||
# Direct USB access
|
||||
if bytes_to_read is None:
|
||||
bytes_to_read = 512
|
||||
@@ -448,7 +447,7 @@ class FT601Interface:
|
||||
|
||||
return bytes(data) if data else None
|
||||
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, OSError) as e:
|
||||
logging.error(f"Error reading from FT601: {e}")
|
||||
return None
|
||||
|
||||
@@ -468,7 +467,7 @@ class FT601Interface:
|
||||
self.ftdi.write_data(data)
|
||||
return True
|
||||
|
||||
elif self.device and self.ep_out:
|
||||
if self.device and self.ep_out:
|
||||
# Direct USB access
|
||||
# FT601 supports large transfers
|
||||
max_packet = 512
|
||||
@@ -479,7 +478,7 @@ class FT601Interface:
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error writing to FT601: {e}")
|
||||
return False
|
||||
|
||||
@@ -498,7 +497,7 @@ class FT601Interface:
|
||||
self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.RESET)
|
||||
logging.info("FT601 burst mode disabled")
|
||||
return True
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
logging.error(f"Error configuring burst mode: {e}")
|
||||
return False
|
||||
return False
|
||||
@@ -510,14 +509,14 @@ class FT601Interface:
|
||||
self.ftdi.close()
|
||||
self.is_open = False
|
||||
logging.info("FT601 device closed")
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
logging.error(f"Error closing FT601 device: {e}")
|
||||
|
||||
if self.device and self.is_open:
|
||||
try:
|
||||
usb.util.dispose_resources(self.device)
|
||||
self.is_open = False
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error closing FT601 device: {e}")
|
||||
|
||||
class STM32USBInterface:
|
||||
@@ -563,7 +562,7 @@ class STM32USBInterface:
|
||||
'product_id': pid,
|
||||
'device': dev
|
||||
})
|
||||
except Exception:
|
||||
except (usb.core.USBError, ValueError):
|
||||
devices.append({
|
||||
'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
|
||||
'vendor_id': vid,
|
||||
@@ -572,7 +571,7 @@ class STM32USBInterface:
|
||||
})
|
||||
|
||||
return devices
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logging.error(f"Error listing USB devices: {e}")
|
||||
# Return mock devices for testing
|
||||
return [{
|
||||
@@ -626,7 +625,7 @@ class STM32USBInterface:
|
||||
logging.info(f"STM32 USB device opened: {device_info['description']}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error opening USB device: {e}")
|
||||
return False
|
||||
|
||||
@@ -642,7 +641,7 @@ class STM32USBInterface:
|
||||
packet = self._create_settings_packet(settings)
|
||||
logging.info("Sending radar settings to STM32 via USB...")
|
||||
return self._send_data(packet)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error sending settings via USB: {e}")
|
||||
return False
|
||||
|
||||
@@ -659,7 +658,7 @@ class STM32USBInterface:
|
||||
return None
|
||||
logging.error(f"USB read error: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
except ValueError as e:
|
||||
logging.error(f"Error reading from USB: {e}")
|
||||
return None
|
||||
|
||||
@@ -679,7 +678,7 @@ class STM32USBInterface:
|
||||
self.ep_out.write(chunk)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error sending data via USB: {e}")
|
||||
return False
|
||||
|
||||
@@ -705,7 +704,7 @@ class STM32USBInterface:
|
||||
try:
|
||||
usb.util.dispose_resources(self.device)
|
||||
self.is_open = False
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error closing USB device: {e}")
|
||||
|
||||
|
||||
@@ -720,8 +719,7 @@ class RadarProcessor:
|
||||
|
||||
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
|
||||
"""Dual-CPI fusion for better detection"""
|
||||
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
return fused_profile
|
||||
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
|
||||
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
|
||||
"""Multi-PRF velocity unwrapping"""
|
||||
@@ -766,7 +764,7 @@ class RadarProcessor:
|
||||
|
||||
return clusters
|
||||
|
||||
def association(self, detections, clusters):
|
||||
def association(self, detections, _clusters):
|
||||
"""Association of detections to tracks"""
|
||||
associated_detections = []
|
||||
|
||||
@@ -862,7 +860,7 @@ class USBPacketParser:
|
||||
if len(data) >= 30 and data[0:4] == b'GPSB':
|
||||
return self._parse_binary_gps_with_pitch(data)
|
||||
|
||||
except Exception as e:
|
||||
except ValueError as e:
|
||||
logging.error(f"Error parsing GPS data: {e}")
|
||||
|
||||
return None
|
||||
@@ -914,7 +912,7 @@ class USBPacketParser:
|
||||
timestamp=time.time()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing binary GPS with pitch: {e}")
|
||||
return None
|
||||
|
||||
@@ -936,7 +934,7 @@ class RadarPacketParser:
|
||||
if len(packet) < 6:
|
||||
return None
|
||||
|
||||
_sync = packet[0:2] # noqa: F841
|
||||
_sync = packet[0:2]
|
||||
packet_type = packet[2]
|
||||
length = packet[3]
|
||||
|
||||
@@ -956,13 +954,12 @@ class RadarPacketParser:
|
||||
|
||||
if packet_type == 0x01:
|
||||
return self.parse_range_packet(payload)
|
||||
elif packet_type == 0x02:
|
||||
if packet_type == 0x02:
|
||||
return self.parse_doppler_packet(payload)
|
||||
elif packet_type == 0x03:
|
||||
if packet_type == 0x03:
|
||||
return self.parse_detection_packet(payload)
|
||||
else:
|
||||
logging.warning(f"Unknown packet type: {packet_type:02X}")
|
||||
return None
|
||||
logging.warning(f"Unknown packet type: {packet_type:02X}")
|
||||
return None
|
||||
|
||||
def calculate_crc(self, data):
|
||||
return self.crc16_func(data)
|
||||
@@ -985,7 +982,7 @@ class RadarPacketParser:
|
||||
'chirp': chirp_counter,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing range packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -1009,7 +1006,7 @@ class RadarPacketParser:
|
||||
'chirp': chirp_counter,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logging.error(f"Error parsing Doppler packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -1031,7 +1028,7 @@ class RadarPacketParser:
|
||||
'chirp': chirp_counter,
|
||||
'timestamp': time.time()
|
||||
}
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logging.error(f"Error parsing detection packet: {e}")
|
||||
return None
|
||||
|
||||
@@ -1371,9 +1368,9 @@ class RadarGUI:
|
||||
|
||||
logging.info("Radar system started successfully with FT601 USB 3.0")
|
||||
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to start radar: {e}")
|
||||
logging.error(f"Start radar error: {e}")
|
||||
except usb.core.USBError as e:
|
||||
messagebox.showerror("Error", f"Failed to start radar: {e}")
|
||||
logging.error(f"Start radar error: {e}")
|
||||
|
||||
def stop_radar(self):
|
||||
"""Stop radar operation"""
|
||||
@@ -1416,13 +1413,13 @@ class RadarGUI:
|
||||
else:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error processing radar data: {e}")
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
time.sleep(0.1)
|
||||
|
||||
def get_packet_length(self, packet):
|
||||
def get_packet_length(self, _packet):
|
||||
"""Calculate packet length including header and footer"""
|
||||
# This should match your packet structure
|
||||
return 64 # Example: 64-byte packets
|
||||
@@ -1443,7 +1440,7 @@ class RadarGUI:
|
||||
f"Lon {gps_data.longitude:.6f}, "
|
||||
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°"
|
||||
)
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error processing GPS data via USB: {e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -1506,7 +1503,7 @@ class RadarGUI:
|
||||
f"Pitch {self.current_gps.pitch:.1f}°"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, IndexError) as e:
|
||||
logging.error(f"Error processing radar packet: {e}")
|
||||
|
||||
def update_range_doppler_map(self, target):
|
||||
@@ -1604,9 +1601,9 @@ class RadarGUI:
|
||||
)
|
||||
logging.info(f"Map generated: {self.map_file_path}")
|
||||
|
||||
except Exception as e:
|
||||
except OSError as e:
|
||||
logging.error(f"Error generating map: {e}")
|
||||
self.map_status_label.config(text=f"Map: Error - {str(e)}")
|
||||
self.map_status_label.config(text=f"Map: Error - {e!s}")
|
||||
|
||||
def update_gps_display(self):
|
||||
"""Step 18: Update GPS and pitch display"""
|
||||
@@ -1753,7 +1750,7 @@ class RadarGUI:
|
||||
else:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError, struct.error) as e:
|
||||
logging.error(f"Error processing radar data: {e}")
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
@@ -1775,7 +1772,7 @@ class RadarGUI:
|
||||
f"Lon {gps_data.longitude:.6f}, "
|
||||
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°"
|
||||
)
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logging.error(f"Error processing GPS data via USB: {e}")
|
||||
time.sleep(0.1)
|
||||
|
||||
@@ -1803,7 +1800,7 @@ class RadarGUI:
|
||||
# Update GPS and pitch display
|
||||
self.update_gps_display()
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, IndexError) as e:
|
||||
logging.error(f"Error updating GUI: {e}")
|
||||
|
||||
self.root.after(100, self.update_gui)
|
||||
@@ -1812,9 +1809,9 @@ def main():
|
||||
"""Main application entry point"""
|
||||
try:
|
||||
root = tk.Tk()
|
||||
_app = RadarGUI(root) # noqa: F841 – must stay alive for mainloop
|
||||
_app = RadarGUI(root) # must stay alive for mainloop
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.error(f"Application error: {e}")
|
||||
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Radar System GUI - Fully Functional Demo Version
|
||||
@@ -15,7 +14,6 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||
from matplotlib.figure import Figure
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict
|
||||
import random
|
||||
import json
|
||||
from datetime import datetime
|
||||
@@ -65,7 +63,7 @@ class SimulatedRadarProcessor:
|
||||
self.noise_floor = 10
|
||||
self.clutter_level = 5
|
||||
|
||||
def _create_targets(self) -> List[Dict]:
|
||||
def _create_targets(self) -> list[dict]:
|
||||
"""Create moving targets"""
|
||||
return [
|
||||
{
|
||||
@@ -210,22 +208,20 @@ class SimulatedRadarProcessor:
|
||||
|
||||
return rd_map
|
||||
|
||||
def _detect_targets(self) -> List[RadarTarget]:
|
||||
def _detect_targets(self) -> list[RadarTarget]:
|
||||
"""Detect targets from current state"""
|
||||
detected = []
|
||||
for t in self.targets:
|
||||
# Random detection based on SNR
|
||||
if random.random() < (t['snr'] / 35):
|
||||
# Add some measurement noise
|
||||
detected.append(RadarTarget(
|
||||
id=t['id'],
|
||||
range=t['range'] + random.gauss(0, 10),
|
||||
velocity=t['velocity'] + random.gauss(0, 2),
|
||||
azimuth=t['azimuth'] + random.gauss(0, 1),
|
||||
elevation=t['elevation'] + random.gauss(0, 0.5),
|
||||
snr=t['snr'] + random.gauss(0, 2)
|
||||
))
|
||||
return detected
|
||||
return [
|
||||
RadarTarget(
|
||||
id=t['id'],
|
||||
range=t['range'] + random.gauss(0, 10),
|
||||
velocity=t['velocity'] + random.gauss(0, 2),
|
||||
azimuth=t['azimuth'] + random.gauss(0, 1),
|
||||
elevation=t['elevation'] + random.gauss(0, 0.5),
|
||||
snr=t['snr'] + random.gauss(0, 2)
|
||||
)
|
||||
for t in self.targets
|
||||
if random.random() < (t['snr'] / 35)
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# MAIN GUI APPLICATION
|
||||
@@ -566,7 +562,7 @@ class RadarDemoGUI:
|
||||
|
||||
scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
lambda _e: canvas.configure(scrollregion=canvas.bbox("all"))
|
||||
)
|
||||
|
||||
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
|
||||
@@ -586,7 +582,7 @@ class RadarDemoGUI:
|
||||
('CFAR Threshold (dB):', 'cfar', 13.0, 5.0, 30.0)
|
||||
]
|
||||
|
||||
for i, (label, key, default, minv, maxv) in enumerate(settings):
|
||||
for _i, (label, key, default, minv, maxv) in enumerate(settings):
|
||||
frame = ttk.Frame(scrollable_frame)
|
||||
frame.pack(fill='x', padx=10, pady=5)
|
||||
|
||||
@@ -745,7 +741,7 @@ class RadarDemoGUI:
|
||||
# Update time
|
||||
self.time_label.config(text=time.strftime("%H:%M:%S"))
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.error(f"Animation error: {e}")
|
||||
|
||||
# Schedule next update
|
||||
@@ -940,7 +936,7 @@ class RadarDemoGUI:
|
||||
messagebox.showinfo("Success", "Settings applied")
|
||||
logger.info("Settings updated")
|
||||
|
||||
except Exception as e:
|
||||
except (ValueError, tk.TclError) as e:
|
||||
messagebox.showerror("Error", f"Invalid settings: {e}")
|
||||
|
||||
def apply_display_settings(self):
|
||||
@@ -981,7 +977,7 @@ class RadarDemoGUI:
|
||||
)
|
||||
if filename:
|
||||
try:
|
||||
with open(filename, 'r') as f:
|
||||
with open(filename) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Apply settings
|
||||
@@ -1004,7 +1000,7 @@ class RadarDemoGUI:
|
||||
messagebox.showinfo("Success", f"Loaded configuration from {filename}")
|
||||
logger.info(f"Configuration loaded from {filename}")
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, json.JSONDecodeError, ValueError, tk.TclError) as e:
|
||||
messagebox.showerror("Error", f"Failed to load: {e}")
|
||||
|
||||
def save_config(self):
|
||||
@@ -1031,7 +1027,7 @@ class RadarDemoGUI:
|
||||
messagebox.showinfo("Success", f"Saved configuration to {filename}")
|
||||
logger.info(f"Configuration saved to {filename}")
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, TypeError, ValueError) as e:
|
||||
messagebox.showerror("Error", f"Failed to save: {e}")
|
||||
|
||||
def export_data(self):
|
||||
@@ -1061,7 +1057,7 @@ class RadarDemoGUI:
|
||||
messagebox.showinfo("Success", f"Exported {len(frames)} frames to {filename}")
|
||||
logger.info(f"Data exported to {filename}")
|
||||
|
||||
except Exception as e:
|
||||
except (OSError, ValueError) as e:
|
||||
messagebox.showerror("Error", f"Failed to export: {e}")
|
||||
|
||||
def show_calibration(self):
|
||||
@@ -1205,7 +1201,7 @@ def main():
|
||||
root = tk.Tk()
|
||||
|
||||
# Create application
|
||||
_app = RadarDemoGUI(root) # noqa: F841 — keeps reference alive
|
||||
_app = RadarDemoGUI(root) # keeps reference alive
|
||||
|
||||
# Center window
|
||||
root.update_idletasks()
|
||||
@@ -1218,7 +1214,7 @@ def main():
|
||||
# Start main loop
|
||||
root.mainloop()
|
||||
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error(f"Fatal error: {e}")
|
||||
messagebox.showerror("Fatal Error", f"Application failed to start:\n{e}")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
AERIS-10 Radar Dashboard — Board Bring-Up Edition
|
||||
AERIS-10 Radar Dashboard
|
||||
===================================================
|
||||
Real-time visualization and control for the AERIS-10 phased-array radar
|
||||
via FT2232H USB 2.0 interface.
|
||||
@@ -10,7 +10,8 @@ Features:
|
||||
- Real-time range-Doppler magnitude heatmap (64x32)
|
||||
- CFAR detection overlay (flagged cells highlighted)
|
||||
- Range profile waterfall plot (range vs. time)
|
||||
- Host command sender (opcodes 0x01-0x27, 0x30, 0xFF)
|
||||
- Host command sender (opcodes per radar_system_top.v:
|
||||
0x01-0x04, 0x10-0x16, 0x20-0x27, 0x30-0x31, 0xFF)
|
||||
- Configuration panel for all radar parameters
|
||||
- HDF5 data recording for offline analysis
|
||||
- Mock mode for development/testing without hardware
|
||||
@@ -27,7 +28,7 @@ import queue
|
||||
import logging
|
||||
import argparse
|
||||
import threading
|
||||
from typing import Optional, Dict
|
||||
import contextlib
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
@@ -82,18 +83,19 @@ class RadarDashboard:
|
||||
C = 3e8 # m/s — speed of light
|
||||
|
||||
def __init__(self, root: tk.Tk, connection: FT2232HConnection,
|
||||
recorder: DataRecorder):
|
||||
recorder: DataRecorder, device_index: int = 0):
|
||||
self.root = root
|
||||
self.conn = connection
|
||||
self.recorder = recorder
|
||||
self.device_index = device_index
|
||||
|
||||
self.root.title("AERIS-10 Radar Dashboard — Bring-Up Edition")
|
||||
self.root.title("AERIS-10 Radar Dashboard")
|
||||
self.root.geometry("1600x950")
|
||||
self.root.configure(bg=BG)
|
||||
|
||||
# Frame queue (acquisition → display)
|
||||
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
|
||||
self._acq_thread: Optional[RadarAcquisition] = None
|
||||
self._acq_thread: RadarAcquisition | None = None
|
||||
|
||||
# Display state
|
||||
self._current_frame = RadarFrame()
|
||||
@@ -154,7 +156,7 @@ class RadarDashboard:
|
||||
self.btn_record = ttk.Button(top, text="Record", command=self._on_record)
|
||||
self.btn_record.pack(side="right", padx=4)
|
||||
|
||||
# Notebook (tabs)
|
||||
# -- Tabbed notebook layout --
|
||||
nb = ttk.Notebook(self.root)
|
||||
nb.pack(fill="both", expand=True, padx=8, pady=8)
|
||||
|
||||
@@ -173,9 +175,8 @@ class RadarDashboard:
|
||||
# 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 per FFT bin = c / (2 * BW) * (Fs / FFT_SIZE) — simplified:
|
||||
# max_range = c * Fs / (4 * BW) for Fs-sampled baseband
|
||||
# range_per_bin = max_range / NUM_RANGE_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
|
||||
@@ -232,39 +233,92 @@ class RadarDashboard:
|
||||
self._canvas = canvas
|
||||
|
||||
def _build_control_tab(self, parent):
|
||||
"""Host command sender and configuration panel."""
|
||||
outer = ttk.Frame(parent)
|
||||
outer.pack(fill="both", expand=True, padx=16, pady=16)
|
||||
"""Host command sender — organized by FPGA register groups.
|
||||
|
||||
# Left column: Quick actions
|
||||
left = ttk.LabelFrame(outer, text="Quick Actions", padding=12)
|
||||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
|
||||
Layout: scrollable canvas with three columns:
|
||||
Left: Quick Actions + Diagnostics (self-test)
|
||||
Center: Waveform Timing + Signal Processing
|
||||
Right: Detection (CFAR) + Custom Command
|
||||
"""
|
||||
# Scrollable wrapper for small screens
|
||||
canvas = tk.Canvas(parent, bg=BG, highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
|
||||
outer = ttk.Frame(canvas)
|
||||
outer.bind("<Configure>",
|
||||
lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.create_window((0, 0), window=outer, anchor="nw")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
canvas.pack(side="left", fill="both", expand=True, padx=8, pady=8)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
ttk.Button(left, text="Trigger Chirp (0x01)",
|
||||
command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=3)
|
||||
ttk.Button(left, text="Enable MTI (0x26)",
|
||||
command=lambda: self._send_cmd(0x26, 1)).pack(fill="x", pady=3)
|
||||
ttk.Button(left, text="Disable MTI (0x26)",
|
||||
command=lambda: self._send_cmd(0x26, 0)).pack(fill="x", pady=3)
|
||||
ttk.Button(left, text="Enable CFAR (0x25)",
|
||||
command=lambda: self._send_cmd(0x25, 1)).pack(fill="x", pady=3)
|
||||
ttk.Button(left, text="Disable CFAR (0x25)",
|
||||
command=lambda: self._send_cmd(0x25, 0)).pack(fill="x", pady=3)
|
||||
ttk.Button(left, text="Request Status (0xFF)",
|
||||
command=lambda: self._send_cmd(0xFF, 0)).pack(fill="x", pady=3)
|
||||
self._param_vars: dict[str, tk.StringVar] = {}
|
||||
|
||||
ttk.Separator(left, orient="horizontal").pack(fill="x", pady=6)
|
||||
# ── Left column: Quick Actions + Diagnostics ──────────────────
|
||||
left = ttk.Frame(outer)
|
||||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||||
|
||||
ttk.Label(left, text="FPGA Self-Test", font=("Menlo", 10, "bold")).pack(
|
||||
anchor="w", pady=(2, 0))
|
||||
ttk.Button(left, text="Run Self-Test (0x30)",
|
||||
command=lambda: self._send_cmd(0x30, 1)).pack(fill="x", pady=3)
|
||||
ttk.Button(left, text="Read Self-Test Result (0x31)",
|
||||
command=lambda: self._send_cmd(0x31, 0)).pack(fill="x", pady=3)
|
||||
# -- Radar Operation --
|
||||
grp_op = ttk.LabelFrame(left, text="Radar Operation", padding=10)
|
||||
grp_op.pack(fill="x", pady=(0, 8))
|
||||
|
||||
# Self-test result display
|
||||
st_frame = ttk.LabelFrame(left, text="Self-Test Results", padding=6)
|
||||
st_frame.pack(fill="x", pady=(6, 0))
|
||||
ttk.Button(grp_op, text="Radar Mode On",
|
||||
command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=2)
|
||||
ttk.Button(grp_op, text="Radar Mode Off",
|
||||
command=lambda: self._send_cmd(0x01, 0)).pack(fill="x", pady=2)
|
||||
ttk.Button(grp_op, text="Trigger Chirp",
|
||||
command=lambda: self._send_cmd(0x02, 1)).pack(fill="x", pady=2)
|
||||
|
||||
# Stream Control (3-bit mask)
|
||||
sc_row = ttk.Frame(grp_op)
|
||||
sc_row.pack(fill="x", pady=2)
|
||||
ttk.Label(sc_row, text="Stream Control").pack(side="left")
|
||||
var_sc = tk.StringVar(value="7")
|
||||
self._param_vars["4"] = var_sc
|
||||
ttk.Entry(sc_row, textvariable=var_sc, width=6).pack(side="left", padx=6)
|
||||
ttk.Label(sc_row, text="0-7", foreground=ACCENT,
|
||||
font=("Menlo", 9)).pack(side="left")
|
||||
ttk.Button(sc_row, text="Set",
|
||||
command=lambda: self._send_validated(
|
||||
0x04, var_sc, bits=3)).pack(side="right")
|
||||
|
||||
ttk.Button(grp_op, text="Request Status",
|
||||
command=lambda: self._send_cmd(0xFF, 0)).pack(fill="x", pady=2)
|
||||
|
||||
# -- Signal Processing --
|
||||
grp_sp = ttk.LabelFrame(left, text="Signal Processing", padding=10)
|
||||
grp_sp.pack(fill="x", pady=(0, 8))
|
||||
|
||||
sp_params = [
|
||||
# Format: label, opcode, default, bits, hint
|
||||
("Detect Threshold", 0x03, "10000", 16, "0-65535"),
|
||||
("Gain Shift", 0x16, "0", 4, "0-15, dir+shift"),
|
||||
("MTI Enable", 0x26, "0", 1, "0=off, 1=on"),
|
||||
("DC Notch Width", 0x27, "0", 3, "0-7 bins"),
|
||||
]
|
||||
for label, opcode, default, bits, hint in sp_params:
|
||||
self._add_param_row(grp_sp, label, opcode, default, bits, hint)
|
||||
|
||||
# MTI quick toggle
|
||||
mti_row = ttk.Frame(grp_sp)
|
||||
mti_row.pack(fill="x", pady=2)
|
||||
ttk.Button(mti_row, text="Enable MTI",
|
||||
command=lambda: self._send_cmd(0x26, 1)).pack(
|
||||
side="left", expand=True, fill="x", padx=(0, 2))
|
||||
ttk.Button(mti_row, text="Disable MTI",
|
||||
command=lambda: self._send_cmd(0x26, 0)).pack(
|
||||
side="left", expand=True, fill="x", padx=(2, 0))
|
||||
|
||||
# -- Diagnostics --
|
||||
grp_diag = ttk.LabelFrame(left, text="Diagnostics", padding=10)
|
||||
grp_diag.pack(fill="x", pady=(0, 8))
|
||||
|
||||
ttk.Button(grp_diag, text="Run Self-Test",
|
||||
command=lambda: self._send_cmd(0x30, 1)).pack(fill="x", pady=2)
|
||||
ttk.Button(grp_diag, text="Read Self-Test Result",
|
||||
command=lambda: self._send_cmd(0x31, 0)).pack(fill="x", pady=2)
|
||||
|
||||
st_frame = ttk.LabelFrame(grp_diag, text="Self-Test Results", padding=6)
|
||||
st_frame.pack(fill="x", pady=(4, 0))
|
||||
self._st_labels = {}
|
||||
for name, default_text in [
|
||||
("busy", "Busy: --"),
|
||||
@@ -280,59 +334,108 @@ class RadarDashboard:
|
||||
lbl.pack(anchor="w")
|
||||
self._st_labels[name] = lbl
|
||||
|
||||
# Right column: Parameter configuration
|
||||
right = ttk.LabelFrame(outer, text="Parameter Configuration", padding=12)
|
||||
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
|
||||
# ── Center column: Waveform Timing ────────────────────────────
|
||||
center = ttk.Frame(outer)
|
||||
center.grid(row=0, column=1, sticky="nsew", padx=6)
|
||||
|
||||
self._param_vars: Dict[str, tk.StringVar] = {}
|
||||
params = [
|
||||
("CFAR Guard (0x21)", 0x21, "2"),
|
||||
("CFAR Train (0x22)", 0x22, "8"),
|
||||
("CFAR Alpha Q4.4 (0x23)", 0x23, "48"),
|
||||
("CFAR Mode (0x24)", 0x24, "0"),
|
||||
("Threshold (0x10)", 0x10, "500"),
|
||||
("Gain Shift (0x06)", 0x06, "0"),
|
||||
("DC Notch Width (0x27)", 0x27, "0"),
|
||||
("Range Mode (0x20)", 0x20, "0"),
|
||||
("Stream Enable (0x05)", 0x05, "7"),
|
||||
grp_wf = ttk.LabelFrame(center, text="Waveform Timing", padding=10)
|
||||
grp_wf.pack(fill="x", pady=(0, 8))
|
||||
|
||||
wf_params = [
|
||||
("Long Chirp Cycles", 0x10, "3000", 16, "0-65535, rst=3000"),
|
||||
("Long Listen Cycles", 0x11, "13700", 16, "0-65535, rst=13700"),
|
||||
("Guard Cycles", 0x12, "17540", 16, "0-65535, rst=17540"),
|
||||
("Short Chirp Cycles", 0x13, "50", 16, "0-65535, rst=50"),
|
||||
("Short Listen Cycles", 0x14, "17450", 16, "0-65535, rst=17450"),
|
||||
("Chirps Per Elevation", 0x15, "32", 6, "1-32, clamped"),
|
||||
]
|
||||
for label, opcode, default, bits, hint in wf_params:
|
||||
self._add_param_row(grp_wf, label, opcode, default, bits, hint)
|
||||
|
||||
for row_idx, (label, opcode, default) in enumerate(params):
|
||||
ttk.Label(right, text=label).grid(row=row_idx, column=0,
|
||||
sticky="w", pady=2)
|
||||
var = tk.StringVar(value=default)
|
||||
self._param_vars[str(opcode)] = var
|
||||
ent = ttk.Entry(right, textvariable=var, width=10)
|
||||
ent.grid(row=row_idx, column=1, padx=8, pady=2)
|
||||
ttk.Button(
|
||||
right, text="Set",
|
||||
command=lambda op=opcode, v=var: self._send_cmd(op, int(v.get()))
|
||||
).grid(row=row_idx, column=2, pady=2)
|
||||
# ── Right column: Detection (CFAR) + Custom ───────────────────
|
||||
right = ttk.Frame(outer)
|
||||
right.grid(row=0, column=2, sticky="nsew", padx=(6, 0))
|
||||
|
||||
# Custom command
|
||||
ttk.Separator(right, orient="horizontal").grid(
|
||||
row=len(params), column=0, columnspan=3, sticky="ew", pady=8)
|
||||
grp_cfar = ttk.LabelFrame(right, text="Detection (CFAR)", padding=10)
|
||||
grp_cfar.pack(fill="x", pady=(0, 8))
|
||||
|
||||
ttk.Label(right, text="Custom Opcode (hex)").grid(
|
||||
row=len(params) + 1, column=0, sticky="w")
|
||||
cfar_params = [
|
||||
("CFAR Enable", 0x25, "0", 1, "0=off, 1=on"),
|
||||
("CFAR Guard Cells", 0x21, "2", 4, "0-15, rst=2"),
|
||||
("CFAR Train Cells", 0x22, "8", 5, "1-31, rst=8"),
|
||||
("CFAR Alpha (Q4.4)", 0x23, "48", 8, "0-255, rst=0x30=3.0"),
|
||||
("CFAR Mode", 0x24, "0", 2, "0=CA 1=GO 2=SO"),
|
||||
]
|
||||
for label, opcode, default, bits, hint in cfar_params:
|
||||
self._add_param_row(grp_cfar, label, opcode, default, bits, hint)
|
||||
|
||||
# CFAR quick toggle
|
||||
cfar_row = ttk.Frame(grp_cfar)
|
||||
cfar_row.pack(fill="x", pady=2)
|
||||
ttk.Button(cfar_row, text="Enable CFAR",
|
||||
command=lambda: self._send_cmd(0x25, 1)).pack(
|
||||
side="left", expand=True, fill="x", padx=(0, 2))
|
||||
ttk.Button(cfar_row, text="Disable CFAR",
|
||||
command=lambda: self._send_cmd(0x25, 0)).pack(
|
||||
side="left", expand=True, fill="x", padx=(2, 0))
|
||||
|
||||
# ── Custom Command (advanced / debug) ─────────────────────────
|
||||
grp_cust = ttk.LabelFrame(right, text="Custom Command", padding=10)
|
||||
grp_cust.pack(fill="x", pady=(0, 8))
|
||||
|
||||
r0 = ttk.Frame(grp_cust)
|
||||
r0.pack(fill="x", pady=2)
|
||||
ttk.Label(r0, text="Opcode (hex)").pack(side="left")
|
||||
self._custom_op = tk.StringVar(value="01")
|
||||
ttk.Entry(right, textvariable=self._custom_op, width=10).grid(
|
||||
row=len(params) + 1, column=1, padx=8)
|
||||
ttk.Entry(r0, textvariable=self._custom_op, width=8).pack(
|
||||
side="left", padx=6)
|
||||
|
||||
ttk.Label(right, text="Value (dec)").grid(
|
||||
row=len(params) + 2, column=0, sticky="w")
|
||||
r1 = ttk.Frame(grp_cust)
|
||||
r1.pack(fill="x", pady=2)
|
||||
ttk.Label(r1, text="Value (dec)").pack(side="left")
|
||||
self._custom_val = tk.StringVar(value="0")
|
||||
ttk.Entry(right, textvariable=self._custom_val, width=10).grid(
|
||||
row=len(params) + 2, column=1, padx=8)
|
||||
ttk.Entry(r1, textvariable=self._custom_val, width=8).pack(
|
||||
side="left", padx=6)
|
||||
|
||||
ttk.Button(right, text="Send Custom",
|
||||
command=self._send_custom).grid(
|
||||
row=len(params) + 2, column=2, pady=2)
|
||||
ttk.Button(grp_cust, text="Send",
|
||||
command=self._send_custom).pack(fill="x", pady=2)
|
||||
|
||||
# Column weights
|
||||
outer.columnconfigure(0, weight=1)
|
||||
outer.columnconfigure(1, weight=2)
|
||||
outer.columnconfigure(1, weight=1)
|
||||
outer.columnconfigure(2, weight=1)
|
||||
outer.rowconfigure(0, weight=1)
|
||||
|
||||
def _add_param_row(self, parent, label: str, opcode: int,
|
||||
default: str, bits: int, hint: str):
|
||||
"""Add a single parameter row: label, entry, hint, Set button with validation."""
|
||||
row = ttk.Frame(parent)
|
||||
row.pack(fill="x", pady=2)
|
||||
ttk.Label(row, text=label).pack(side="left")
|
||||
var = tk.StringVar(value=default)
|
||||
self._param_vars[str(opcode)] = var
|
||||
ttk.Entry(row, textvariable=var, width=8).pack(side="left", padx=6)
|
||||
ttk.Label(row, text=hint, foreground=ACCENT,
|
||||
font=("Menlo", 9)).pack(side="left")
|
||||
ttk.Button(row, text="Set",
|
||||
command=lambda: self._send_validated(
|
||||
opcode, var, bits=bits)).pack(side="right")
|
||||
|
||||
def _send_validated(self, opcode: int, var: tk.StringVar, bits: int):
|
||||
"""Parse, clamp to bit-width, send command, and update the entry."""
|
||||
try:
|
||||
raw = int(var.get())
|
||||
except ValueError:
|
||||
log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}")
|
||||
return
|
||||
max_val = (1 << bits) - 1
|
||||
clamped = max(0, min(raw, max_val))
|
||||
if clamped != raw:
|
||||
log.warning(f"Value {raw} clamped to {clamped} "
|
||||
f"({bits}-bit max={max_val}) for opcode 0x{opcode:02X}")
|
||||
var.set(str(clamped))
|
||||
self._send_cmd(opcode, clamped)
|
||||
|
||||
def _build_log_tab(self, parent):
|
||||
self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10),
|
||||
insertbackground=FG, wrap="word")
|
||||
@@ -364,7 +467,7 @@ class RadarDashboard:
|
||||
self.root.update_idletasks()
|
||||
|
||||
def _do_connect():
|
||||
ok = self.conn.open()
|
||||
ok = self.conn.open(self.device_index)
|
||||
# Schedule UI update back on the main thread
|
||||
self.root.after(0, lambda: self._on_connect_done(ok))
|
||||
|
||||
@@ -530,10 +633,8 @@ class _TextHandler(logging.Handler):
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self._text.after(0, self._append, msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _append(self, msg: str):
|
||||
self._text.insert("end", msg + "\n")
|
||||
@@ -578,7 +679,7 @@ def main():
|
||||
|
||||
root = tk.Tk()
|
||||
|
||||
dashboard = RadarDashboard(root, conn, recorder)
|
||||
dashboard = RadarDashboard(root, conn, recorder, device_index=args.device)
|
||||
|
||||
if args.record:
|
||||
filepath = os.path.join(
|
||||
|
||||
@@ -10,7 +10,7 @@ USB Interface: FT2232H USB 2.0 (8-bit, 50T production board) via pyftdi
|
||||
USB Packet Protocol (11-byte):
|
||||
TX (FPGA→Host):
|
||||
Data packet: [0xAA] [range_q 2B] [range_i 2B] [dop_re 2B] [dop_im 2B] [det 1B] [0x55]
|
||||
Status packet: [0xBB] [status 6×32b] [0x55]
|
||||
Status packet: [0xBB] [status 6x32b] [0x55]
|
||||
RX (Host→FPGA):
|
||||
Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo}
|
||||
"""
|
||||
@@ -21,8 +21,9 @@ import time
|
||||
import threading
|
||||
import queue
|
||||
import logging
|
||||
import contextlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Tuple, Dict, Any
|
||||
from typing import Any
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
@@ -50,20 +51,36 @@ WATERFALL_DEPTH = 64
|
||||
|
||||
|
||||
class Opcode(IntEnum):
|
||||
"""Host register opcodes (matches radar_system_top.v command decode)."""
|
||||
TRIGGER = 0x01
|
||||
PRF_DIV = 0x02
|
||||
NUM_CHIRPS = 0x03
|
||||
CHIRP_TIMER = 0x04
|
||||
STREAM_ENABLE = 0x05
|
||||
GAIN_SHIFT = 0x06
|
||||
THRESHOLD = 0x10
|
||||
"""Host register opcodes — must match radar_system_top.v case(usb_cmd_opcode).
|
||||
|
||||
FPGA truth table (from radar_system_top.v lines 902-944):
|
||||
0x01 host_radar_mode 0x14 host_short_listen_cycles
|
||||
0x02 host_trigger_pulse 0x15 host_chirps_per_elev
|
||||
0x03 host_detect_threshold 0x16 host_gain_shift
|
||||
0x04 host_stream_control 0x20 host_range_mode
|
||||
0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch
|
||||
0x11 host_long_listen_cycles 0x30 host_self_test_trigger
|
||||
0x12 host_guard_cycles 0x31 host_status_request
|
||||
0x13 host_short_chirp_cycles 0xFF host_status_request
|
||||
"""
|
||||
# --- Basic control (0x01-0x04) ---
|
||||
RADAR_MODE = 0x01 # 2-bit mode select
|
||||
TRIGGER_PULSE = 0x02 # self-clearing one-shot trigger
|
||||
DETECT_THRESHOLD = 0x03 # 16-bit detection threshold value
|
||||
STREAM_CONTROL = 0x04 # 3-bit stream enable mask
|
||||
|
||||
# --- Digital gain (0x16) ---
|
||||
GAIN_SHIFT = 0x16 # 4-bit digital gain shift
|
||||
|
||||
# --- Chirp timing (0x10-0x15) ---
|
||||
LONG_CHIRP = 0x10
|
||||
LONG_LISTEN = 0x11
|
||||
GUARD = 0x12
|
||||
SHORT_CHIRP = 0x13
|
||||
SHORT_LISTEN = 0x14
|
||||
CHIRPS_PER_ELEV = 0x15
|
||||
|
||||
# --- Signal processing (0x20-0x27) ---
|
||||
RANGE_MODE = 0x20
|
||||
CFAR_GUARD = 0x21
|
||||
CFAR_TRAIN = 0x22
|
||||
@@ -72,6 +89,8 @@ class Opcode(IntEnum):
|
||||
CFAR_ENABLE = 0x25
|
||||
MTI_ENABLE = 0x26
|
||||
DC_NOTCH_WIDTH = 0x27
|
||||
|
||||
# --- Board self-test / status (0x30-0x31, 0xFF) ---
|
||||
SELF_TEST_TRIGGER = 0x30
|
||||
SELF_TEST_STATUS = 0x31
|
||||
STATUS_REQUEST = 0xFF
|
||||
@@ -83,7 +102,7 @@ class Opcode(IntEnum):
|
||||
|
||||
@dataclass
|
||||
class RadarFrame:
|
||||
"""One complete radar frame (64 range × 32 Doppler)."""
|
||||
"""One complete radar frame (64 range x 32 Doppler)."""
|
||||
timestamp: float = 0.0
|
||||
range_doppler_i: np.ndarray = field(
|
||||
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.int16))
|
||||
@@ -101,7 +120,7 @@ class RadarFrame:
|
||||
|
||||
@dataclass
|
||||
class StatusResponse:
|
||||
"""Parsed status response from FPGA (8-word packet as of Build 26)."""
|
||||
"""Parsed status response from FPGA (6-word / 26-byte packet)."""
|
||||
radar_mode: int = 0
|
||||
stream_ctrl: int = 0
|
||||
cfar_threshold: int = 0
|
||||
@@ -144,7 +163,7 @@ class RadarProtocol:
|
||||
return struct.pack(">I", word)
|
||||
|
||||
@staticmethod
|
||||
def parse_data_packet(raw: bytes) -> Optional[Dict[str, Any]]:
|
||||
def parse_data_packet(raw: bytes) -> dict[str, Any] | None:
|
||||
"""
|
||||
Parse an 11-byte data packet from the FT2232H byte stream.
|
||||
Returns dict with keys: 'range_i', 'range_q', 'doppler_i', 'doppler_q',
|
||||
@@ -181,10 +200,10 @@ class RadarProtocol:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def parse_status_packet(raw: bytes) -> Optional[StatusResponse]:
|
||||
def parse_status_packet(raw: bytes) -> StatusResponse | None:
|
||||
"""
|
||||
Parse a status response packet.
|
||||
Format: [0xBB] [6×4B status words] [0x55] = 1 + 24 + 1 = 26 bytes
|
||||
Format: [0xBB] [6x4B status words] [0x55] = 1 + 24 + 1 = 26 bytes
|
||||
"""
|
||||
if len(raw) < 26:
|
||||
return None
|
||||
@@ -223,7 +242,7 @@ class RadarProtocol:
|
||||
return sr
|
||||
|
||||
@staticmethod
|
||||
def find_packet_boundaries(buf: bytes) -> List[Tuple[int, int, str]]:
|
||||
def find_packet_boundaries(buf: bytes) -> list[tuple[int, int, str]]:
|
||||
"""
|
||||
Scan buffer for packet start markers (0xAA data, 0xBB status).
|
||||
Returns list of (start_idx, expected_end_idx, packet_type).
|
||||
@@ -233,19 +252,22 @@ class RadarProtocol:
|
||||
while i < len(buf):
|
||||
if buf[i] == HEADER_BYTE:
|
||||
end = i + DATA_PACKET_SIZE
|
||||
if end <= len(buf):
|
||||
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
|
||||
packets.append((i, end, "data"))
|
||||
i = end
|
||||
else:
|
||||
break
|
||||
if end > len(buf):
|
||||
break # partial packet at end — leave for residual
|
||||
i += 1 # footer mismatch — skip this false header
|
||||
elif buf[i] == STATUS_HEADER_BYTE:
|
||||
# Status packet: 26 bytes (same for both interfaces)
|
||||
end = i + STATUS_PACKET_SIZE
|
||||
if end <= len(buf):
|
||||
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
|
||||
packets.append((i, end, "status"))
|
||||
i = end
|
||||
else:
|
||||
break
|
||||
if end > len(buf):
|
||||
break # partial status packet — leave for residual
|
||||
i += 1 # footer mismatch — skip
|
||||
else:
|
||||
i += 1
|
||||
return packets
|
||||
@@ -257,9 +279,13 @@ class RadarProtocol:
|
||||
|
||||
# Optional pyftdi import
|
||||
try:
|
||||
from pyftdi.ftdi import Ftdi as PyFtdi
|
||||
from pyftdi.ftdi import Ftdi, FtdiError
|
||||
PyFtdi = Ftdi
|
||||
PYFTDI_AVAILABLE = True
|
||||
except ImportError:
|
||||
class FtdiError(Exception):
|
||||
"""Fallback FTDI error type when pyftdi is unavailable."""
|
||||
|
||||
PYFTDI_AVAILABLE = False
|
||||
|
||||
|
||||
@@ -306,20 +332,18 @@ class FT2232HConnection:
|
||||
self.is_open = True
|
||||
log.info(f"FT2232H device opened: {url}")
|
||||
return True
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
log.error(f"FT2232H open failed: {e}")
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self._ftdi is not None:
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
self._ftdi.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ftdi = None
|
||||
self.is_open = False
|
||||
|
||||
def read(self, size: int = 4096) -> Optional[bytes]:
|
||||
def read(self, size: int = 4096) -> bytes | None:
|
||||
"""Read raw bytes from FT2232H. Returns None on error/timeout."""
|
||||
if not self.is_open:
|
||||
return None
|
||||
@@ -331,7 +355,7 @@ class FT2232HConnection:
|
||||
try:
|
||||
data = self._ftdi.read_data(size)
|
||||
return bytes(data) if data else None
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
log.error(f"FT2232H read error: {e}")
|
||||
return None
|
||||
|
||||
@@ -348,24 +372,29 @@ class FT2232HConnection:
|
||||
try:
|
||||
written = self._ftdi.write_data(data)
|
||||
return written == len(data)
|
||||
except Exception as e:
|
||||
except FtdiError as e:
|
||||
log.error(f"FT2232H write error: {e}")
|
||||
return False
|
||||
|
||||
def _mock_read(self, size: int) -> bytes:
|
||||
"""
|
||||
Generate synthetic compact radar data packets (11-byte) for testing.
|
||||
Generate synthetic 11-byte radar data packets for testing.
|
||||
Simulates a batch of packets with a target near range bin 20, Doppler bin 8.
|
||||
Emits packets in sequential FPGA order (range_bin 0..63, doppler_bin
|
||||
0..31 within each range bin) so that RadarAcquisition._ingest_sample()
|
||||
places them correctly. A target is injected near range bin 20,
|
||||
Doppler bin 8.
|
||||
"""
|
||||
time.sleep(0.05)
|
||||
self._mock_frame_num += 1
|
||||
|
||||
buf = bytearray()
|
||||
num_packets = min(32, size // DATA_PACKET_SIZE)
|
||||
for _ in range(num_packets):
|
||||
rbin = self._mock_rng.randint(0, NUM_RANGE_BINS)
|
||||
dbin = self._mock_rng.randint(0, NUM_DOPPLER_BINS)
|
||||
num_packets = min(NUM_CELLS, size // DATA_PACKET_SIZE)
|
||||
start_idx = getattr(self, '_mock_seq_idx', 0)
|
||||
|
||||
for n in range(num_packets):
|
||||
idx = (start_idx + n) % NUM_CELLS
|
||||
rbin = idx // NUM_DOPPLER_BINS
|
||||
dbin = idx % NUM_DOPPLER_BINS
|
||||
|
||||
range_i = int(self._mock_rng.normal(0, 100))
|
||||
range_q = int(self._mock_rng.normal(0, 100))
|
||||
@@ -393,6 +422,7 @@ class FT2232HConnection:
|
||||
|
||||
buf += pkt
|
||||
|
||||
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
@@ -401,19 +431,19 @@ class FT2232HConnection:
|
||||
# ============================================================================
|
||||
|
||||
# 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, # TRIGGER
|
||||
0x02, # PRF_DIV
|
||||
0x03, # NUM_CHIRPS
|
||||
0x04, # CHIRP_TIMER
|
||||
0x05, # STREAM_ENABLE
|
||||
0x06, # GAIN_SHIFT
|
||||
0x10, # THRESHOLD / LONG_CHIRP
|
||||
0x01, # RADAR_MODE
|
||||
0x02, # TRIGGER_PULSE
|
||||
0x03, # DETECT_THRESHOLD
|
||||
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
|
||||
0x30, # SELF_TEST_TRIGGER
|
||||
0x31, # SELF_TEST_STATUS
|
||||
@@ -439,26 +469,8 @@ def _saturate(val: int, bits: int) -> int:
|
||||
return max(max_neg, min(max_pos, int(val)))
|
||||
|
||||
|
||||
def _replay_mti(decim_i: np.ndarray, decim_q: np.ndarray,
|
||||
enable: bool) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Bit-accurate 2-pulse MTI canceller (matches mti_canceller.v)."""
|
||||
n_chirps, n_bins = decim_i.shape
|
||||
mti_i = np.zeros_like(decim_i)
|
||||
mti_q = np.zeros_like(decim_q)
|
||||
if not enable:
|
||||
return decim_i.copy(), decim_q.copy()
|
||||
for c in range(n_chirps):
|
||||
if c == 0:
|
||||
pass # muted
|
||||
else:
|
||||
for r in range(n_bins):
|
||||
mti_i[c, r] = _saturate(int(decim_i[c, r]) - int(decim_i[c - 1, r]), 16)
|
||||
mti_q[c, r] = _saturate(int(decim_q[c, r]) - int(decim_q[c - 1, r]), 16)
|
||||
return mti_i, mti_q
|
||||
|
||||
|
||||
def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray,
|
||||
width: int) -> Tuple[np.ndarray, 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]}.
|
||||
@@ -480,7 +492,7 @@ def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray,
|
||||
|
||||
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]:
|
||||
mode: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""
|
||||
Bit-accurate CA-CFAR detector (matches cfar_ca.v).
|
||||
Returns (detect_flags, magnitudes) both (64, 32).
|
||||
@@ -584,16 +596,16 @@ class ReplayConnection:
|
||||
self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO
|
||||
self._cfar_enable: bool = True
|
||||
# Raw source arrays (loaded once, reprocessed on param change)
|
||||
self._dop_mti_i: Optional[np.ndarray] = None
|
||||
self._dop_mti_q: Optional[np.ndarray] = None
|
||||
self._dop_nomti_i: Optional[np.ndarray] = None
|
||||
self._dop_nomti_q: Optional[np.ndarray] = None
|
||||
self._range_i_vec: Optional[np.ndarray] = None
|
||||
self._range_q_vec: Optional[np.ndarray] = None
|
||||
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:
|
||||
def open(self, _device_index: int = 0) -> bool:
|
||||
try:
|
||||
self._load_arrays()
|
||||
self._packets = self._build_packets()
|
||||
@@ -604,14 +616,14 @@ class ReplayConnection:
|
||||
f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
|
||||
f"{self._frame_len} bytes/frame)")
|
||||
return True
|
||||
except Exception as e:
|
||||
except (OSError, ValueError, 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) -> Optional[bytes]:
|
||||
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)
|
||||
@@ -673,10 +685,9 @@ class ReplayConnection:
|
||||
if self._mti_enable != new_en:
|
||||
self._mti_enable = new_en
|
||||
changed = True
|
||||
elif opcode == 0x27: # DC_NOTCH_WIDTH
|
||||
if self._dc_notch_width != value:
|
||||
self._dc_notch_width = value
|
||||
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:
|
||||
@@ -827,7 +838,7 @@ class DataRecorder:
|
||||
self._frame_count = 0
|
||||
self._recording = True
|
||||
log.info(f"Recording started: {filepath}")
|
||||
except Exception as e:
|
||||
except (OSError, ValueError) as e:
|
||||
log.error(f"Failed to start recording: {e}")
|
||||
|
||||
def record_frame(self, frame: RadarFrame):
|
||||
@@ -844,7 +855,7 @@ class DataRecorder:
|
||||
fg.create_dataset("detections", data=frame.detections, compression="gzip")
|
||||
fg.create_dataset("range_profile", data=frame.range_profile, compression="gzip")
|
||||
self._frame_count += 1
|
||||
except Exception as e:
|
||||
except (OSError, ValueError, TypeError) as e:
|
||||
log.error(f"Recording error: {e}")
|
||||
|
||||
def stop(self):
|
||||
@@ -853,7 +864,7 @@ class DataRecorder:
|
||||
self._file.attrs["end_time"] = time.time()
|
||||
self._file.attrs["total_frames"] = self._frame_count
|
||||
self._file.close()
|
||||
except Exception:
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
pass
|
||||
self._file = None
|
||||
self._recording = False
|
||||
@@ -871,7 +882,7 @@ class RadarAcquisition(threading.Thread):
|
||||
"""
|
||||
|
||||
def __init__(self, connection, frame_queue: queue.Queue,
|
||||
recorder: Optional[DataRecorder] = None,
|
||||
recorder: DataRecorder | None = None,
|
||||
status_callback=None):
|
||||
super().__init__(daemon=True)
|
||||
self.conn = connection
|
||||
@@ -888,13 +899,25 @@ class RadarAcquisition(threading.Thread):
|
||||
|
||||
def run(self):
|
||||
log.info("Acquisition thread started")
|
||||
residual = b""
|
||||
while not self._stop_event.is_set():
|
||||
raw = self.conn.read(4096)
|
||||
if raw is None or len(raw) == 0:
|
||||
chunk = self.conn.read(4096)
|
||||
if chunk is None or len(chunk) == 0:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
|
||||
raw = residual + chunk
|
||||
packets = RadarProtocol.find_packet_boundaries(raw)
|
||||
|
||||
# Keep unparsed tail bytes for next iteration
|
||||
if packets:
|
||||
last_end = packets[-1][1]
|
||||
residual = raw[last_end:]
|
||||
else:
|
||||
# No packets found — keep entire buffer as residual
|
||||
# but cap at 2x max packet size to avoid unbounded growth
|
||||
max_residual = 2 * max(DATA_PACKET_SIZE, STATUS_PACKET_SIZE)
|
||||
residual = raw[-max_residual:] if len(raw) > max_residual else raw
|
||||
for start, end, ptype in packets:
|
||||
if ptype == "data":
|
||||
parsed = RadarProtocol.parse_data_packet(
|
||||
@@ -913,12 +936,12 @@ class RadarAcquisition(threading.Thread):
|
||||
if self._status_callback is not None:
|
||||
try:
|
||||
self._status_callback(status)
|
||||
except Exception as e:
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error(f"Status callback error: {e}")
|
||||
|
||||
log.info("Acquisition thread stopped")
|
||||
|
||||
def _ingest_sample(self, sample: Dict):
|
||||
def _ingest_sample(self, sample: dict):
|
||||
"""Place sample into current frame and emit when complete."""
|
||||
rbin = self._sample_idx // NUM_DOPPLER_BINS
|
||||
dbin = self._sample_idx % NUM_DOPPLER_BINS
|
||||
@@ -948,10 +971,8 @@ class RadarAcquisition(threading.Thread):
|
||||
try:
|
||||
self.frame_queue.put_nowait(self._frame)
|
||||
except queue.Full:
|
||||
try:
|
||||
with contextlib.suppress(queue.Empty):
|
||||
self.frame_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
self.frame_queue.put_nowait(self._frame)
|
||||
|
||||
if self.recorder and self.recorder.recording:
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Requirements for PLFM Radar Dashboard - PyQt6 Edition
|
||||
# ======================================================
|
||||
# Install with: pip install -r requirements_pyqt_gui.txt
|
||||
|
||||
# Core PyQt6 framework
|
||||
PyQt6>=6.5.0
|
||||
|
||||
# Web engine for embedded Leaflet maps
|
||||
PyQt6-WebEngine>=6.5.0
|
||||
|
||||
# Optional: Additional dependencies from existing radar code
|
||||
# (uncomment if integrating with existing radar processing)
|
||||
# numpy>=1.24
|
||||
# scipy>=1.10
|
||||
# scikit-learn>=1.2
|
||||
# filterpy>=1.4
|
||||
# matplotlib>=3.7
|
||||
|
||||
# Note: The GUI uses Leaflet.js (loaded from CDN) for maps
|
||||
# No additional Python map libraries required
|
||||
@@ -0,0 +1,22 @@
|
||||
# PLFM Radar GUI V7 — Python dependencies
|
||||
# Install with: pip install -r requirements_v7.txt
|
||||
|
||||
# Core (required)
|
||||
PyQt6>=6.5
|
||||
PyQt6-WebEngine>=6.5
|
||||
numpy>=1.24
|
||||
matplotlib>=3.7
|
||||
|
||||
# Hardware interfaces (optional — GUI degrades gracefully)
|
||||
pyusb>=1.2
|
||||
pyftdi>=0.54
|
||||
|
||||
# Signal processing (optional)
|
||||
scipy>=1.10
|
||||
|
||||
# Tracking / clustering (optional)
|
||||
scikit-learn>=1.2
|
||||
filterpy>=1.4
|
||||
|
||||
# CRC validation (optional)
|
||||
crcmod>=1.7
|
||||
@@ -66,7 +66,7 @@ TEST_NAMES = {
|
||||
class SmokeTest:
|
||||
"""Host-side smoke test controller."""
|
||||
|
||||
def __init__(self, connection: FT2232HConnection, adc_dump_path: str = None):
|
||||
def __init__(self, connection: FT2232HConnection, adc_dump_path: str | None = None):
|
||||
self.conn = connection
|
||||
self.adc_dump_path = adc_dump_path
|
||||
self._adc_samples = []
|
||||
@@ -82,10 +82,9 @@ class SmokeTest:
|
||||
log.info("")
|
||||
|
||||
# Step 1: Connect
|
||||
if not self.conn.is_open:
|
||||
if not self.conn.open():
|
||||
log.error("Failed to open FT2232H connection")
|
||||
return False
|
||||
if not self.conn.is_open and not self.conn.open():
|
||||
log.error("Failed to open FT2232H connection")
|
||||
return False
|
||||
|
||||
# Step 2: Send self-test trigger (opcode 0x30)
|
||||
log.info("Sending self-test trigger (opcode 0x30)...")
|
||||
@@ -188,10 +187,9 @@ class SmokeTest:
|
||||
|
||||
def _save_adc_dump(self):
|
||||
"""Save captured ADC samples to numpy file."""
|
||||
if not self._adc_samples:
|
||||
if not self._adc_samples and self.conn._mock:
|
||||
# In mock mode, generate synthetic ADC data
|
||||
if self.conn._mock:
|
||||
self._adc_samples = list(np.random.randint(0, 65536, 256, dtype=np.uint16))
|
||||
self._adc_samples = list(np.random.randint(0, 65536, 256, dtype=np.uint16))
|
||||
|
||||
if self._adc_samples:
|
||||
arr = np.array(self._adc_samples, dtype=np.uint16)
|
||||
|
||||
@@ -368,7 +368,7 @@ class TestRadarAcquisition(unittest.TestCase):
|
||||
# Wait for at least one frame (mock produces ~32 samples per read,
|
||||
# need 2048 for a full frame, so may take a few seconds)
|
||||
frame = None
|
||||
try:
|
||||
try: # noqa: SIM105
|
||||
frame = fq.get(timeout=10)
|
||||
except queue.Empty:
|
||||
pass
|
||||
@@ -421,8 +421,8 @@ class TestEndToEnd(unittest.TestCase):
|
||||
|
||||
def test_command_roundtrip_all_opcodes(self):
|
||||
"""Verify all opcodes produce valid 4-byte commands."""
|
||||
opcodes = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x10, 0x11, 0x12,
|
||||
0x13, 0x14, 0x15, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25,
|
||||
opcodes = [0x01, 0x02, 0x03, 0x04, 0x10, 0x11, 0x12,
|
||||
0x13, 0x14, 0x15, 0x16, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25,
|
||||
0x26, 0x27, 0x30, 0x31, 0xFF]
|
||||
for op in opcodes:
|
||||
cmd = RadarProtocol.build_command(op, 42)
|
||||
@@ -630,8 +630,8 @@ class TestReplayConnection(unittest.TestCase):
|
||||
cmd = RadarProtocol.build_command(0x01, 1)
|
||||
conn.write(cmd)
|
||||
self.assertFalse(conn._needs_rebuild)
|
||||
# Send STREAM_ENABLE (hardware-only)
|
||||
cmd = RadarProtocol.build_command(0x05, 7)
|
||||
# Send STREAM_CONTROL (hardware-only, opcode 0x04)
|
||||
cmd = RadarProtocol.build_command(0x04, 7)
|
||||
conn.write(cmd)
|
||||
self.assertFalse(conn._needs_rebuild)
|
||||
conn.close()
|
||||
@@ -668,14 +668,14 @@ class TestReplayConnection(unittest.TestCase):
|
||||
|
||||
|
||||
class TestOpcodeEnum(unittest.TestCase):
|
||||
"""Verify Opcode enum matches RTL host register map."""
|
||||
"""Verify Opcode enum matches RTL host register map (radar_system_top.v)."""
|
||||
|
||||
def test_gain_shift_is_0x06(self):
|
||||
"""GAIN_SHIFT opcode must be 0x06 (not 0x16)."""
|
||||
self.assertEqual(Opcode.GAIN_SHIFT, 0x06)
|
||||
def test_gain_shift_is_0x16(self):
|
||||
"""GAIN_SHIFT opcode must be 0x16 (matches radar_system_top.v:928)."""
|
||||
self.assertEqual(Opcode.GAIN_SHIFT, 0x16)
|
||||
|
||||
def test_no_digital_gain_alias(self):
|
||||
"""DIGITAL_GAIN should NOT exist (was bogus 0x16 alias)."""
|
||||
"""DIGITAL_GAIN should NOT exist (use GAIN_SHIFT)."""
|
||||
self.assertFalse(hasattr(Opcode, 'DIGITAL_GAIN'))
|
||||
|
||||
def test_self_test_trigger(self):
|
||||
@@ -691,21 +691,40 @@ class TestOpcodeEnum(unittest.TestCase):
|
||||
self.assertIn(0x30, _HARDWARE_ONLY_OPCODES)
|
||||
self.assertIn(0x31, _HARDWARE_ONLY_OPCODES)
|
||||
|
||||
def test_0x16_not_in_hardware_only(self):
|
||||
"""Bogus 0x16 must not be in _HARDWARE_ONLY_OPCODES."""
|
||||
self.assertNotIn(0x16, _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_enable_is_0x05(self):
|
||||
"""STREAM_ENABLE must be 0x05 (not 0x04)."""
|
||||
self.assertEqual(Opcode.STREAM_ENABLE, 0x05)
|
||||
def test_stream_control_is_0x04(self):
|
||||
"""STREAM_CONTROL must be 0x04 (matches radar_system_top.v:906)."""
|
||||
self.assertEqual(Opcode.STREAM_CONTROL, 0x04)
|
||||
|
||||
def test_legacy_aliases_removed(self):
|
||||
"""Legacy aliases must NOT exist in production Opcode enum."""
|
||||
for name in ("TRIGGER", "PRF_DIV", "NUM_CHIRPS", "CHIRP_TIMER",
|
||||
"STREAM_ENABLE", "THRESHOLD"):
|
||||
self.assertFalse(hasattr(Opcode, name),
|
||||
f"Legacy alias Opcode.{name} should not exist")
|
||||
|
||||
def test_radar_mode_names(self):
|
||||
"""New canonical names must exist and match FPGA opcodes."""
|
||||
self.assertEqual(Opcode.RADAR_MODE, 0x01)
|
||||
self.assertEqual(Opcode.TRIGGER_PULSE, 0x02)
|
||||
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 has a matching Opcode enum member."""
|
||||
expected = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
|
||||
"""Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member."""
|
||||
expected = {0x01, 0x02, 0x03, 0x04,
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
|
||||
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
|
||||
0x30, 0x31, 0xFF}
|
||||
enum_values = set(int(m) for m in Opcode)
|
||||
enum_values = {int(m) for m in Opcode}
|
||||
for op in expected:
|
||||
self.assertIn(op, enum_values, f"0x{op:02X} missing from Opcode enum")
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
V7-specific unit tests for the PLFM Radar GUI V7 modules.
|
||||
|
||||
Tests cover:
|
||||
- v7.models: RadarTarget, RadarSettings, GPSData, ProcessingConfig
|
||||
- v7.processing: RadarProcessor, USBPacketParser, apply_pitch_correction
|
||||
- v7.workers: polar_to_geographic
|
||||
- v7.hardware: STM32USBInterface (basic), production protocol re-exports
|
||||
|
||||
Does NOT require a running Qt event loop — only unit-testable components.
|
||||
Run with: python -m unittest test_v7 -v
|
||||
"""
|
||||
|
||||
import struct
|
||||
import unittest
|
||||
from dataclasses import asdict
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: v7.models
|
||||
# =============================================================================
|
||||
|
||||
class TestRadarTarget(unittest.TestCase):
|
||||
"""RadarTarget dataclass."""
|
||||
|
||||
def test_defaults(self):
|
||||
t = _models().RadarTarget(id=1, range=1000.0, velocity=5.0,
|
||||
azimuth=45.0, elevation=2.0)
|
||||
self.assertEqual(t.id, 1)
|
||||
self.assertEqual(t.range, 1000.0)
|
||||
self.assertEqual(t.snr, 0.0)
|
||||
self.assertEqual(t.track_id, -1)
|
||||
self.assertEqual(t.classification, "unknown")
|
||||
|
||||
def test_to_dict(self):
|
||||
t = _models().RadarTarget(id=1, range=500.0, velocity=-10.0,
|
||||
azimuth=0.0, elevation=0.0, snr=15.0)
|
||||
d = t.to_dict()
|
||||
self.assertIsInstance(d, dict)
|
||||
self.assertEqual(d["range"], 500.0)
|
||||
self.assertEqual(d["snr"], 15.0)
|
||||
|
||||
|
||||
class TestRadarSettings(unittest.TestCase):
|
||||
"""RadarSettings — verify stale STM32 fields are removed."""
|
||||
|
||||
def test_no_stale_fields(self):
|
||||
"""chirp_duration, freq_min/max, prf1/2 must NOT exist."""
|
||||
s = _models().RadarSettings()
|
||||
d = asdict(s)
|
||||
for stale in ["chirp_duration_1", "chirp_duration_2",
|
||||
"freq_min", "freq_max", "prf1", "prf2",
|
||||
"chirps_per_position"]:
|
||||
self.assertNotIn(stale, d, f"Stale field '{stale}' still present")
|
||||
|
||||
def test_has_physical_conversion_fields(self):
|
||||
s = _models().RadarSettings()
|
||||
self.assertIsInstance(s.range_resolution, float)
|
||||
self.assertIsInstance(s.velocity_resolution, float)
|
||||
self.assertGreater(s.range_resolution, 0)
|
||||
self.assertGreater(s.velocity_resolution, 0)
|
||||
|
||||
def test_defaults(self):
|
||||
s = _models().RadarSettings()
|
||||
self.assertEqual(s.system_frequency, 10e9)
|
||||
self.assertEqual(s.coverage_radius, 50000)
|
||||
self.assertEqual(s.max_distance, 50000)
|
||||
|
||||
|
||||
class TestGPSData(unittest.TestCase):
|
||||
def test_to_dict(self):
|
||||
g = _models().GPSData(latitude=41.9, longitude=12.5,
|
||||
altitude=100.0, pitch=2.5)
|
||||
d = g.to_dict()
|
||||
self.assertAlmostEqual(d["latitude"], 41.9)
|
||||
self.assertAlmostEqual(d["pitch"], 2.5)
|
||||
|
||||
|
||||
class TestProcessingConfig(unittest.TestCase):
|
||||
def test_defaults(self):
|
||||
cfg = _models().ProcessingConfig()
|
||||
self.assertTrue(cfg.clustering_enabled)
|
||||
self.assertTrue(cfg.tracking_enabled)
|
||||
self.assertFalse(cfg.mti_enabled)
|
||||
self.assertFalse(cfg.cfar_enabled)
|
||||
|
||||
|
||||
class TestNoCrcmodDependency(unittest.TestCase):
|
||||
"""crcmod was removed — verify it's not exported."""
|
||||
|
||||
def test_no_crcmod_available(self):
|
||||
models = _models()
|
||||
self.assertFalse(hasattr(models, "CRCMOD_AVAILABLE"),
|
||||
"CRCMOD_AVAILABLE should be removed from models")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: v7.processing
|
||||
# =============================================================================
|
||||
|
||||
class TestApplyPitchCorrection(unittest.TestCase):
|
||||
def test_positive_pitch(self):
|
||||
from v7.processing import apply_pitch_correction
|
||||
self.assertAlmostEqual(apply_pitch_correction(10.0, 3.0), 7.0)
|
||||
|
||||
def test_zero_pitch(self):
|
||||
from v7.processing import apply_pitch_correction
|
||||
self.assertAlmostEqual(apply_pitch_correction(5.0, 0.0), 5.0)
|
||||
|
||||
|
||||
class TestRadarProcessorMTI(unittest.TestCase):
|
||||
def test_mti_order1(self):
|
||||
from v7.processing import RadarProcessor
|
||||
from v7.models import ProcessingConfig
|
||||
proc = RadarProcessor()
|
||||
proc.set_config(ProcessingConfig(mti_enabled=True, mti_order=1))
|
||||
|
||||
frame1 = np.ones((64, 32))
|
||||
frame2 = np.ones((64, 32)) * 3
|
||||
|
||||
result1 = proc.mti_filter(frame1)
|
||||
np.testing.assert_array_equal(result1, np.zeros((64, 32)),
|
||||
err_msg="First frame should be zeros (no history)")
|
||||
|
||||
result2 = proc.mti_filter(frame2)
|
||||
expected = frame2 - frame1
|
||||
np.testing.assert_array_almost_equal(result2, expected)
|
||||
|
||||
def test_mti_order2(self):
|
||||
from v7.processing import RadarProcessor
|
||||
from v7.models import ProcessingConfig
|
||||
proc = RadarProcessor()
|
||||
proc.set_config(ProcessingConfig(mti_enabled=True, mti_order=2))
|
||||
|
||||
f1 = np.ones((4, 4))
|
||||
f2 = np.ones((4, 4)) * 2
|
||||
f3 = np.ones((4, 4)) * 5
|
||||
|
||||
proc.mti_filter(f1) # zeros (need 3 frames)
|
||||
proc.mti_filter(f2) # zeros
|
||||
result = proc.mti_filter(f3)
|
||||
# Order 2: x[n] - 2*x[n-1] + x[n-2] = 5 - 4 + 1 = 2
|
||||
np.testing.assert_array_almost_equal(result, np.ones((4, 4)) * 2)
|
||||
|
||||
|
||||
class TestRadarProcessorCFAR(unittest.TestCase):
|
||||
def test_cfar_1d_detects_peak(self):
|
||||
from v7.processing import RadarProcessor
|
||||
signal = np.ones(64) * 10
|
||||
signal[32] = 500 # inject a strong target
|
||||
det = RadarProcessor.cfar_1d(signal, guard=2, train=4,
|
||||
threshold_factor=3.0, cfar_type="CA-CFAR")
|
||||
self.assertTrue(det[32], "Should detect strong peak at bin 32")
|
||||
|
||||
def test_cfar_1d_no_false_alarm(self):
|
||||
from v7.processing import RadarProcessor
|
||||
signal = np.ones(64) * 10 # uniform — no target
|
||||
det = RadarProcessor.cfar_1d(signal, guard=2, train=4,
|
||||
threshold_factor=3.0)
|
||||
self.assertEqual(det.sum(), 0, "Should have no detections in flat noise")
|
||||
|
||||
|
||||
class TestRadarProcessorProcessFrame(unittest.TestCase):
|
||||
def test_process_frame_returns_shapes(self):
|
||||
from v7.processing import RadarProcessor
|
||||
proc = RadarProcessor()
|
||||
frame = np.random.randn(64, 32) * 10
|
||||
frame[20, 8] = 5000 # inject a target
|
||||
power, mask = proc.process_frame(frame)
|
||||
self.assertEqual(power.shape, (64, 32))
|
||||
self.assertEqual(mask.shape, (64, 32))
|
||||
self.assertEqual(mask.dtype, bool)
|
||||
|
||||
|
||||
class TestRadarProcessorWindowing(unittest.TestCase):
|
||||
def test_hann_window(self):
|
||||
from v7.processing import RadarProcessor
|
||||
data = np.ones((4, 32))
|
||||
windowed = RadarProcessor.apply_window(data, "Hann")
|
||||
# Hann window tapers to ~0 at edges
|
||||
self.assertLess(windowed[0, 0], 0.1)
|
||||
self.assertGreater(windowed[0, 16], 0.5)
|
||||
|
||||
def test_none_window(self):
|
||||
from v7.processing import RadarProcessor
|
||||
data = np.ones((4, 32))
|
||||
result = RadarProcessor.apply_window(data, "None")
|
||||
np.testing.assert_array_equal(result, data)
|
||||
|
||||
|
||||
class TestRadarProcessorDCNotch(unittest.TestCase):
|
||||
def test_dc_removal(self):
|
||||
from v7.processing import RadarProcessor
|
||||
data = np.ones((4, 8)) * 100
|
||||
data[0, :] += 50 # DC offset in range bin 0
|
||||
result = RadarProcessor.dc_notch(data)
|
||||
# Mean along axis=1 should be ~0
|
||||
row_means = np.mean(result, axis=1)
|
||||
for m in row_means:
|
||||
self.assertAlmostEqual(m, 0, places=10)
|
||||
|
||||
|
||||
class TestRadarProcessorClustering(unittest.TestCase):
|
||||
def test_clustering_empty(self):
|
||||
from v7.processing import RadarProcessor
|
||||
result = RadarProcessor.clustering([], eps=100, min_samples=2)
|
||||
self.assertEqual(result, [])
|
||||
|
||||
|
||||
class TestUSBPacketParser(unittest.TestCase):
|
||||
def test_parse_gps_text(self):
|
||||
from v7.processing import USBPacketParser
|
||||
parser = USBPacketParser()
|
||||
data = b"GPS:41.9028,12.4964,100.0,2.5\r\n"
|
||||
gps = parser.parse_gps_data(data)
|
||||
self.assertIsNotNone(gps)
|
||||
self.assertAlmostEqual(gps.latitude, 41.9028, places=3)
|
||||
self.assertAlmostEqual(gps.longitude, 12.4964, places=3)
|
||||
self.assertAlmostEqual(gps.altitude, 100.0)
|
||||
self.assertAlmostEqual(gps.pitch, 2.5)
|
||||
|
||||
def test_parse_gps_text_invalid(self):
|
||||
from v7.processing import USBPacketParser
|
||||
parser = USBPacketParser()
|
||||
self.assertIsNone(parser.parse_gps_data(b"NOT_GPS_DATA"))
|
||||
self.assertIsNone(parser.parse_gps_data(b""))
|
||||
self.assertIsNone(parser.parse_gps_data(None))
|
||||
|
||||
def test_parse_binary_gps(self):
|
||||
from v7.processing import USBPacketParser
|
||||
parser = USBPacketParser()
|
||||
# Build a valid binary GPS packet
|
||||
pkt = bytearray(b"GPSB")
|
||||
pkt += struct.pack(">d", 41.9028) # lat
|
||||
pkt += struct.pack(">d", 12.4964) # lon
|
||||
pkt += struct.pack(">f", 100.0) # alt
|
||||
pkt += struct.pack(">f", 2.5) # pitch
|
||||
# Simple checksum
|
||||
cksum = sum(pkt) & 0xFFFF
|
||||
pkt += struct.pack(">H", cksum)
|
||||
self.assertEqual(len(pkt), 30)
|
||||
|
||||
gps = parser.parse_gps_data(bytes(pkt))
|
||||
self.assertIsNotNone(gps)
|
||||
self.assertAlmostEqual(gps.latitude, 41.9028, places=3)
|
||||
|
||||
def test_no_crc16_func_attribute(self):
|
||||
"""crcmod was removed — USBPacketParser should not have crc16_func."""
|
||||
from v7.processing import USBPacketParser
|
||||
parser = USBPacketParser()
|
||||
self.assertFalse(hasattr(parser, "crc16_func"),
|
||||
"crc16_func should be removed (crcmod dead code)")
|
||||
|
||||
def test_no_multi_prf_unwrap(self):
|
||||
"""multi_prf_unwrap was removed (never called, prf fields removed)."""
|
||||
from v7.processing import RadarProcessor
|
||||
self.assertFalse(hasattr(RadarProcessor, "multi_prf_unwrap"),
|
||||
"multi_prf_unwrap should be removed")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: v7.workers — polar_to_geographic
|
||||
# =============================================================================
|
||||
|
||||
class TestPolarToGeographic(unittest.TestCase):
|
||||
def test_north_bearing(self):
|
||||
from v7.workers import polar_to_geographic
|
||||
lat, lon = polar_to_geographic(0.0, 0.0, 1000.0, 0.0)
|
||||
# Moving 1km north from equator
|
||||
self.assertGreater(lat, 0.0)
|
||||
self.assertAlmostEqual(lon, 0.0, places=4)
|
||||
|
||||
def test_east_bearing(self):
|
||||
from v7.workers import polar_to_geographic
|
||||
lat, lon = polar_to_geographic(0.0, 0.0, 1000.0, 90.0)
|
||||
self.assertAlmostEqual(lat, 0.0, places=4)
|
||||
self.assertGreater(lon, 0.0)
|
||||
|
||||
def test_zero_range(self):
|
||||
from v7.workers import polar_to_geographic
|
||||
lat, lon = polar_to_geographic(41.9, 12.5, 0.0, 0.0)
|
||||
self.assertAlmostEqual(lat, 41.9, places=6)
|
||||
self.assertAlmostEqual(lon, 12.5, places=6)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: v7.hardware — production protocol re-exports
|
||||
# =============================================================================
|
||||
|
||||
class TestHardwareReExports(unittest.TestCase):
|
||||
"""Verify hardware.py re-exports all production protocol classes."""
|
||||
|
||||
def test_exports(self):
|
||||
from v7.hardware import (
|
||||
FT2232HConnection,
|
||||
RadarProtocol,
|
||||
STM32USBInterface,
|
||||
)
|
||||
# Verify these are actual classes/types, not None
|
||||
self.assertTrue(callable(FT2232HConnection))
|
||||
self.assertTrue(callable(RadarProtocol))
|
||||
self.assertTrue(callable(STM32USBInterface))
|
||||
|
||||
def test_stm32_list_devices_no_crash(self):
|
||||
from v7.hardware import STM32USBInterface
|
||||
stm = STM32USBInterface()
|
||||
self.assertFalse(stm.is_open)
|
||||
# list_devices should return empty list (no USB in test env), not crash
|
||||
devs = stm.list_devices()
|
||||
self.assertIsInstance(devs, list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: v7.__init__ — clean exports
|
||||
# =============================================================================
|
||||
|
||||
class TestV7Init(unittest.TestCase):
|
||||
"""Verify top-level v7 package exports."""
|
||||
|
||||
def test_no_crcmod_export(self):
|
||||
import v7
|
||||
self.assertFalse(hasattr(v7, "CRCMOD_AVAILABLE"),
|
||||
"CRCMOD_AVAILABLE should not be in v7.__all__")
|
||||
|
||||
def test_key_exports(self):
|
||||
import v7
|
||||
for name in ["RadarTarget", "RadarSettings", "GPSData",
|
||||
"ProcessingConfig", "FT2232HConnection",
|
||||
"RadarProtocol", "RadarProcessor",
|
||||
"RadarDataWorker", "RadarMapWidget",
|
||||
"RadarDashboard"]:
|
||||
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper: lazy import of v7.models
|
||||
# =============================================================================
|
||||
|
||||
def _models():
|
||||
import v7.models
|
||||
return v7.models
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -19,19 +19,25 @@ from .models import (
|
||||
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,
|
||||
SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
|
||||
)
|
||||
|
||||
# Hardware interfaces
|
||||
# Hardware interfaces — production protocol via radar_protocol.py
|
||||
from .hardware import (
|
||||
FT2232HQInterface,
|
||||
FT2232HConnection,
|
||||
ReplayConnection,
|
||||
RadarProtocol,
|
||||
Opcode,
|
||||
RadarAcquisition,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
DataRecorder,
|
||||
STM32USBInterface,
|
||||
)
|
||||
|
||||
# Processing pipeline
|
||||
from .processing import (
|
||||
RadarProcessor,
|
||||
RadarPacketParser,
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
)
|
||||
@@ -56,7 +62,7 @@ from .dashboard import (
|
||||
RangeDopplerCanvas,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
__all__ = [ # noqa: RUF022
|
||||
# models
|
||||
"RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
|
||||
"DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
|
||||
@@ -64,11 +70,13 @@ __all__ = [
|
||||
"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",
|
||||
# hardware
|
||||
"FT2232HQInterface", "STM32USBInterface",
|
||||
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE",
|
||||
# hardware — production FPGA protocol
|
||||
"FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode",
|
||||
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
|
||||
"STM32USBInterface",
|
||||
# processing
|
||||
"RadarProcessor", "RadarPacketParser", "USBPacketParser",
|
||||
"RadarProcessor", "USBPacketParser",
|
||||
"apply_pitch_correction",
|
||||
# workers
|
||||
"RadarDataWorker", "GPSDataWorker", "TargetSimulator",
|
||||
|
||||
+539
-393
File diff suppressed because it is too large
Load Diff
@@ -1,141 +1,62 @@
|
||||
"""
|
||||
v7.hardware — Hardware interface classes for the PLFM Radar GUI V7.
|
||||
|
||||
Provides two USB hardware interfaces:
|
||||
- FT2232HQInterface (PRIMARY — USB 2.0, VID 0x0403 / PID 0x6010)
|
||||
- STM32USBInterface (USB CDC for commands and GPS)
|
||||
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.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from typing import ClassVar
|
||||
|
||||
from .models import (
|
||||
USB_AVAILABLE, FTDI_AVAILABLE,
|
||||
RadarSettings,
|
||||
)
|
||||
from .models import USB_AVAILABLE
|
||||
|
||||
if USB_AVAILABLE:
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
if FTDI_AVAILABLE:
|
||||
from pyftdi.ftdi import Ftdi
|
||||
from pyftdi.usbtools import UsbTools
|
||||
# Import production protocol layer — single source of truth for FPGA comms
|
||||
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,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
DataRecorder,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FT2232HQ Interface — PRIMARY data path (USB 2.0)
|
||||
# =============================================================================
|
||||
|
||||
class FT2232HQInterface:
|
||||
"""
|
||||
Interface for FT2232HQ (USB 2.0 Hi-Speed) in synchronous FIFO mode.
|
||||
|
||||
This is the **primary** radar data interface.
|
||||
VID/PID: 0x0403 / 0x6010
|
||||
"""
|
||||
|
||||
VID = 0x0403
|
||||
PID = 0x6010
|
||||
|
||||
def __init__(self):
|
||||
self.ftdi: Optional[object] = None
|
||||
self.is_open: bool = False
|
||||
|
||||
# ---- enumeration -------------------------------------------------------
|
||||
|
||||
def list_devices(self) -> List[Dict]:
|
||||
"""List available FT2232H devices using pyftdi."""
|
||||
if not FTDI_AVAILABLE:
|
||||
logger.warning("pyftdi not available — cannot enumerate FT2232H devices")
|
||||
return []
|
||||
|
||||
try:
|
||||
devices = []
|
||||
for device_desc in UsbTools.find_all([(self.VID, self.PID)]):
|
||||
devices.append({
|
||||
"description": f"FT2232H Device {device_desc}",
|
||||
"url": f"ftdi://{device_desc}/1",
|
||||
})
|
||||
return devices
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing FT2232H devices: {e}")
|
||||
return []
|
||||
|
||||
# ---- open / close ------------------------------------------------------
|
||||
|
||||
def open_device(self, device_url: str) -> bool:
|
||||
"""Open FT2232H device in synchronous FIFO mode."""
|
||||
if not FTDI_AVAILABLE:
|
||||
logger.error("pyftdi not available — cannot open device")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.ftdi = Ftdi()
|
||||
self.ftdi.open_from_url(device_url)
|
||||
|
||||
# Synchronous FIFO mode
|
||||
self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.SYNCFF)
|
||||
|
||||
# Low-latency timer (2 ms)
|
||||
self.ftdi.set_latency_timer(2)
|
||||
|
||||
# Purge stale data
|
||||
self.ftdi.purge_buffers()
|
||||
|
||||
self.is_open = True
|
||||
logger.info(f"FT2232H device opened: {device_url}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening FT2232H device: {e}")
|
||||
self.ftdi = None
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""Close FT2232H device."""
|
||||
if self.ftdi and self.is_open:
|
||||
try:
|
||||
self.ftdi.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing FT2232H device: {e}")
|
||||
finally:
|
||||
self.is_open = False
|
||||
self.ftdi = None
|
||||
|
||||
# ---- data I/O ----------------------------------------------------------
|
||||
|
||||
def read_data(self, bytes_to_read: int = 4096) -> Optional[bytes]:
|
||||
"""Read data from FT2232H."""
|
||||
if not self.is_open or self.ftdi is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = self.ftdi.read_data(bytes_to_read)
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading from FT2232H: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STM32 USB CDC Interface — commands & GPS data
|
||||
# STM32 USB CDC Interface — GPS data ONLY
|
||||
# =============================================================================
|
||||
|
||||
class STM32USBInterface:
|
||||
"""
|
||||
Interface for STM32 USB CDC (Virtual COM Port).
|
||||
|
||||
Used to:
|
||||
- Send start flag and radar settings to the MCU
|
||||
- Receive GPS data from the MCU
|
||||
Used ONLY for receiving GPS data from the MCU.
|
||||
|
||||
FPGA register commands are sent via FT2232H (see FT2232HConnection
|
||||
from radar_protocol.py). The old send_start_flag() / send_settings()
|
||||
methods have been removed — they used an incompatible magic-packet
|
||||
protocol that the FPGA does not understand.
|
||||
"""
|
||||
|
||||
STM32_VID_PIDS = [
|
||||
STM32_VID_PIDS: ClassVar[list[tuple[int, int]]] = [
|
||||
(0x0483, 0x5740), # STM32 Virtual COM Port
|
||||
(0x0483, 0x3748), # STM32 Discovery
|
||||
(0x0483, 0x374B),
|
||||
@@ -152,7 +73,7 @@ class STM32USBInterface:
|
||||
|
||||
# ---- enumeration -------------------------------------------------------
|
||||
|
||||
def list_devices(self) -> List[Dict]:
|
||||
def list_devices(self) -> list[dict]:
|
||||
"""List available STM32 USB CDC devices."""
|
||||
if not USB_AVAILABLE:
|
||||
logger.warning("pyusb not available — cannot enumerate STM32 devices")
|
||||
@@ -174,20 +95,20 @@ class STM32USBInterface:
|
||||
"product_id": pid,
|
||||
"device": dev,
|
||||
})
|
||||
except Exception:
|
||||
except (usb.core.USBError, ValueError):
|
||||
devices.append({
|
||||
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
|
||||
"vendor_id": vid,
|
||||
"product_id": pid,
|
||||
"device": dev,
|
||||
})
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logger.error(f"Error listing STM32 devices: {e}")
|
||||
return devices
|
||||
|
||||
# ---- open / close ------------------------------------------------------
|
||||
|
||||
def open_device(self, device_info: Dict) -> bool:
|
||||
def open_device(self, device_info: dict) -> bool:
|
||||
"""Open STM32 USB CDC device."""
|
||||
if not USB_AVAILABLE:
|
||||
logger.error("pyusb not available — cannot open STM32 device")
|
||||
@@ -225,7 +146,7 @@ class STM32USBInterface:
|
||||
self.is_open = True
|
||||
logger.info(f"STM32 USB device opened: {device_info.get('description', '')}")
|
||||
return True
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logger.error(f"Error opening STM32 device: {e}")
|
||||
return False
|
||||
|
||||
@@ -234,74 +155,22 @@ class STM32USBInterface:
|
||||
if self.device and self.is_open:
|
||||
try:
|
||||
usb.util.dispose_resources(self.device)
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logger.error(f"Error closing STM32 device: {e}")
|
||||
self.is_open = False
|
||||
self.device = None
|
||||
self.ep_in = None
|
||||
self.ep_out = None
|
||||
|
||||
# ---- commands ----------------------------------------------------------
|
||||
# ---- GPS data I/O ------------------------------------------------------
|
||||
|
||||
def send_start_flag(self) -> bool:
|
||||
"""Send start flag to STM32 (4-byte magic)."""
|
||||
start_packet = bytes([23, 46, 158, 237])
|
||||
logger.info("Sending start flag to STM32 via USB...")
|
||||
return self._send_data(start_packet)
|
||||
|
||||
def send_settings(self, settings: RadarSettings) -> bool:
|
||||
"""Send radar settings binary packet to STM32."""
|
||||
try:
|
||||
packet = self._create_settings_packet(settings)
|
||||
logger.info("Sending radar settings to STM32 via USB...")
|
||||
return self._send_data(packet)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending settings via USB: {e}")
|
||||
return False
|
||||
|
||||
# ---- data I/O ----------------------------------------------------------
|
||||
|
||||
def read_data(self, size: int = 64, timeout: int = 1000) -> Optional[bytes]:
|
||||
"""Read data from STM32 via USB CDC."""
|
||||
def read_data(self, size: int = 64, timeout: int = 1000) -> bytes | None:
|
||||
"""Read GPS data from STM32 via USB CDC."""
|
||||
if not self.is_open or self.ep_in is None:
|
||||
return None
|
||||
try:
|
||||
data = self.ep_in.read(size, timeout=timeout)
|
||||
return bytes(data)
|
||||
except Exception:
|
||||
except usb.core.USBError:
|
||||
# Timeout or other USB error
|
||||
return None
|
||||
|
||||
# ---- internal helpers --------------------------------------------------
|
||||
|
||||
def _send_data(self, data: bytes) -> bool:
|
||||
if not self.is_open or self.ep_out is None:
|
||||
return False
|
||||
try:
|
||||
packet_size = 64
|
||||
for i in range(0, len(data), packet_size):
|
||||
chunk = data[i : i + packet_size]
|
||||
if len(chunk) < packet_size:
|
||||
chunk += b"\x00" * (packet_size - len(chunk))
|
||||
self.ep_out.write(chunk)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending data via USB: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _create_settings_packet(settings: RadarSettings) -> bytes:
|
||||
"""Create binary settings packet: 'SET' ... 'END'."""
|
||||
packet = b"SET"
|
||||
packet += struct.pack(">d", settings.system_frequency)
|
||||
packet += struct.pack(">d", settings.chirp_duration_1)
|
||||
packet += struct.pack(">d", settings.chirp_duration_2)
|
||||
packet += struct.pack(">I", settings.chirps_per_position)
|
||||
packet += struct.pack(">d", settings.freq_min)
|
||||
packet += struct.pack(">d", settings.freq_max)
|
||||
packet += struct.pack(">d", settings.prf1)
|
||||
packet += struct.pack(">d", settings.prf2)
|
||||
packet += struct.pack(">d", settings.max_distance)
|
||||
packet += struct.pack(">d", settings.map_size)
|
||||
packet += b"END"
|
||||
return packet
|
||||
|
||||
@@ -12,7 +12,6 @@ coverage circle, target trails, velocity-based color coding, popups, legend.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QFrame,
|
||||
@@ -65,7 +64,7 @@ class MapBridge(QObject):
|
||||
|
||||
@pyqtSlot(str)
|
||||
def logFromJS(self, message: str):
|
||||
logger.debug(f"[JS] {message}")
|
||||
logger.info(f"[JS] {message}")
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
@@ -96,7 +95,8 @@ class RadarMapWidget(QWidget):
|
||||
latitude=radar_lat, longitude=radar_lon,
|
||||
altitude=0.0, pitch=0.0, heading=0.0,
|
||||
)
|
||||
self._targets: List[RadarTarget] = []
|
||||
self._targets: list[RadarTarget] = []
|
||||
self._pending_targets: list[RadarTarget] | None = None
|
||||
self._coverage_radius = 50_000 # metres
|
||||
self._tile_server = TileServer.OPENSTREETMAP
|
||||
self._show_coverage = True
|
||||
@@ -282,15 +282,10 @@ function initMap() {{
|
||||
.setView([{lat}, {lon}], 10);
|
||||
setTileServer('osm');
|
||||
|
||||
var radarIcon = L.divIcon({{
|
||||
className:'radar-icon',
|
||||
html:'<div style="background:radial-gradient(circle,#FF5252 0%,#D32F2F 100%);'+
|
||||
'width:24px;height:24px;border-radius:50%;border:3px solid white;'+
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,0.5);"></div>',
|
||||
iconSize:[24,24], iconAnchor:[12,12]
|
||||
}});
|
||||
|
||||
radarMarker = L.marker([{lat},{lon}], {{ icon:radarIcon, zIndexOffset:1000 }}).addTo(map);
|
||||
radarMarker = L.circleMarker([{lat},{lon}], {{
|
||||
radius:12, fillColor:'#FF5252', color:'white',
|
||||
weight:3, opacity:1, fillOpacity:1
|
||||
}}).addTo(map);
|
||||
updateRadarPopup();
|
||||
|
||||
coverageCircle = L.circle([{lat},{lon}], {{
|
||||
@@ -366,102 +361,99 @@ function updateRadarPosition(lat,lon,alt,pitch,heading) {{
|
||||
}}
|
||||
|
||||
function updateTargets(targetsJson) {{
|
||||
var targets = JSON.parse(targetsJson);
|
||||
var currentIds = {{}};
|
||||
try {{
|
||||
if(!map) {{
|
||||
if(bridge) bridge.logFromJS('updateTargets: map not ready yet');
|
||||
return;
|
||||
}}
|
||||
var targets = JSON.parse(targetsJson);
|
||||
if(bridge) bridge.logFromJS('updateTargets: parsed '+targets.length+' targets');
|
||||
var currentIds = {{}};
|
||||
|
||||
targets.forEach(function(t) {{
|
||||
currentIds[t.id] = true;
|
||||
var lat=t.latitude, lon=t.longitude;
|
||||
var color = getTargetColor(t.velocity);
|
||||
var sz = Math.max(10, Math.min(20, 10+t.snr/3));
|
||||
targets.forEach(function(t) {{
|
||||
currentIds[t.id] = true;
|
||||
var lat=t.latitude, lon=t.longitude;
|
||||
var color = getTargetColor(t.velocity);
|
||||
var radius = Math.max(5, Math.min(12, 5+(t.snr||0)/5));
|
||||
|
||||
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
|
||||
targetTrailHistory[t.id].push([lat,lon]);
|
||||
if(targetTrailHistory[t.id].length > maxTrailLength)
|
||||
targetTrailHistory[t.id].shift();
|
||||
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
|
||||
targetTrailHistory[t.id].push([lat,lon]);
|
||||
if(targetTrailHistory[t.id].length > maxTrailLength)
|
||||
targetTrailHistory[t.id].shift();
|
||||
|
||||
if(targetMarkers[t.id]) {{
|
||||
targetMarkers[t.id].setLatLng([lat,lon]);
|
||||
targetMarkers[t.id].setIcon(makeIcon(color,sz));
|
||||
if(targetTrails[t.id]) {{
|
||||
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
|
||||
targetTrails[t.id].setStyle({{ color:color }});
|
||||
}}
|
||||
}} else {{
|
||||
var marker = L.marker([lat,lon], {{ icon:makeIcon(color,sz) }}).addTo(map);
|
||||
marker.on(
|
||||
'click',
|
||||
(function(id){{
|
||||
return function(){{ if(bridge) bridge.onMarkerClick(id); }};
|
||||
}})(t.id)
|
||||
);
|
||||
targetMarkers[t.id] = marker;
|
||||
if(showTrails) {{
|
||||
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
|
||||
color:color, weight:3, opacity:0.7, lineCap:'round', lineJoin:'round'
|
||||
if(targetMarkers[t.id]) {{
|
||||
targetMarkers[t.id].setLatLng([lat,lon]);
|
||||
targetMarkers[t.id].setStyle({{
|
||||
fillColor:color, color:'white', radius:radius
|
||||
}});
|
||||
if(targetTrails[t.id]) {{
|
||||
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
|
||||
targetTrails[t.id].setStyle({{ color:color }});
|
||||
}}
|
||||
}} else {{
|
||||
var marker = L.circleMarker([lat,lon], {{
|
||||
radius:radius, fillColor:color, color:'white',
|
||||
weight:2, opacity:1, fillOpacity:0.9
|
||||
}}).addTo(map);
|
||||
marker.on(
|
||||
'click',
|
||||
(function(id){{
|
||||
return function(){{ if(bridge) bridge.onMarkerClick(id); }};
|
||||
}})(t.id)
|
||||
);
|
||||
targetMarkers[t.id] = marker;
|
||||
if(showTrails) {{
|
||||
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
|
||||
color:color, weight:3, opacity:0.7,
|
||||
lineCap:'round', lineJoin:'round'
|
||||
}}).addTo(map);
|
||||
}}
|
||||
}}
|
||||
updateTargetPopup(t);
|
||||
}});
|
||||
|
||||
for(var id in targetMarkers) {{
|
||||
if(!currentIds[id]) {{
|
||||
map.removeLayer(targetMarkers[id]); delete targetMarkers[id];
|
||||
if(targetTrails[id]) {{
|
||||
map.removeLayer(targetTrails[id]);
|
||||
delete targetTrails[id];
|
||||
}}
|
||||
delete targetTrailHistory[id];
|
||||
}}
|
||||
}}
|
||||
updateTargetPopup(t);
|
||||
}});
|
||||
|
||||
for(var id in targetMarkers) {{
|
||||
if(!currentIds[id]) {{
|
||||
map.removeLayer(targetMarkers[id]); delete targetMarkers[id];
|
||||
if(targetTrails[id]) {{ map.removeLayer(targetTrails[id]); delete targetTrails[id]; }}
|
||||
delete targetTrailHistory[id];
|
||||
}}
|
||||
}} catch(e) {{
|
||||
if(bridge) bridge.logFromJS('updateTargets ERROR: '+e.message);
|
||||
}}
|
||||
}}
|
||||
|
||||
function makeIcon(color,sz) {{
|
||||
return L.divIcon({{
|
||||
className:'target-icon',
|
||||
html:'<div style="background-color:'+color+';width:'+sz+'px;height:'+sz+'px;'+
|
||||
(
|
||||
'border-radius:50%;border:2px solid white;'+
|
||||
'box-shadow:0 2px 6px rgba(0,0,0,0.4);'
|
||||
)+'</div>',
|
||||
iconSize:[sz,sz], iconAnchor:[sz/2,sz/2]
|
||||
}});
|
||||
}}
|
||||
|
||||
function updateTargetPopup(t) {{
|
||||
if(!targetMarkers[t.id]) return;
|
||||
var sc = t.velocity>1
|
||||
? 'status-approaching'
|
||||
: (t.velocity<-1 ? 'status-receding' : 'status-stationary');
|
||||
var st = t.velocity>1?'Approaching':(t.velocity<-1?'Receding':'Stationary');
|
||||
var rng = (typeof t.range === 'number') ? t.range.toFixed(1) : '?';
|
||||
var vel = (typeof t.velocity === 'number') ? t.velocity.toFixed(1) : '?';
|
||||
var az = (typeof t.azimuth === 'number') ? t.azimuth.toFixed(1) : '?';
|
||||
var el = (typeof t.elevation === 'number') ? t.elevation.toFixed(1) : '?';
|
||||
var snr = (typeof t.snr === 'number') ? t.snr.toFixed(1) : '?';
|
||||
targetMarkers[t.id].bindPopup(
|
||||
'<div class="popup-title">Target #'+t.id+'</div>'+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Range:</span>'+
|
||||
'<span class="popup-value">'+t.range.toFixed(1)+' m</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
|
||||
'<span class="popup-value">'+t.velocity.toFixed(1)+' m/s</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
|
||||
'<span class="popup-value">'+t.azimuth.toFixed(1)+'°</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
|
||||
'<span class="popup-value">'+t.elevation.toFixed(1)+'°</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
|
||||
'<span class="popup-value">'+t.snr.toFixed(1)+' dB</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Track:</span>'+
|
||||
'<span class="popup-value">'+t.track_id+'</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Status:</span>'+
|
||||
'<span class="popup-value '+sc+'">'+st+'</span></div>'
|
||||
)
|
||||
'<div class="popup-row"><span class="popup-label">Range:</span>'+
|
||||
'<span class="popup-value">'+rng+' m</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
|
||||
'<span class="popup-value">'+vel+' m/s</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
|
||||
'<span class="popup-value">'+az+'°</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
|
||||
'<span class="popup-value">'+el+'°</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
|
||||
'<span class="popup-value">'+snr+' dB</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">Track:</span>'+
|
||||
'<span class="popup-value">'+t.track_id+'</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">Status:</span>'+
|
||||
'<span class="popup-value '+sc+'">'+st+'</span></div>'
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -531,12 +523,19 @@ document.addEventListener('DOMContentLoaded', function() {{
|
||||
def _on_map_ready(self):
|
||||
self._status_label.setText(f"Map ready - {len(self._targets)} targets")
|
||||
self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
|
||||
# Flush any targets that arrived before the map was ready
|
||||
if self._pending_targets is not None:
|
||||
self.set_targets(self._pending_targets)
|
||||
self._pending_targets = None
|
||||
|
||||
def _on_marker_clicked(self, tid: int):
|
||||
self.targetSelected.emit(tid)
|
||||
|
||||
def _run_js(self, script: str):
|
||||
self._web_view.page().runJavaScript(script)
|
||||
def _js_callback(result):
|
||||
if result is not None:
|
||||
logger.info("JS result: %s", result)
|
||||
self._web_view.page().runJavaScript(script, 0, _js_callback)
|
||||
|
||||
# ---- control bar callbacks ---------------------------------------------
|
||||
|
||||
@@ -571,12 +570,20 @@ document.addEventListener('DOMContentLoaded', function() {{
|
||||
f"{gps.altitude},{gps.pitch},{gps.heading})"
|
||||
)
|
||||
|
||||
def set_targets(self, targets: List[RadarTarget]):
|
||||
def set_targets(self, targets: list[RadarTarget]):
|
||||
self._targets = targets
|
||||
if not self._bridge.is_ready:
|
||||
logger.info("Map not ready yet — queuing %d targets", len(targets))
|
||||
self._pending_targets = targets
|
||||
return
|
||||
data = [t.to_dict() for t in targets]
|
||||
js = json.dumps(data).replace("'", "\\'")
|
||||
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
|
||||
logger.info(
|
||||
"set_targets: %d targets, JSON len=%d, first 200 chars: %s",
|
||||
len(targets), len(js_payload), js_payload[:200],
|
||||
)
|
||||
self._status_label.setText(f"{len(targets)} targets tracked")
|
||||
self._run_js(f"updateTargets('{js}')")
|
||||
self._run_js(f"updateTargets('{js_payload}')")
|
||||
|
||||
def set_coverage_radius(self, radius_m: float):
|
||||
self._coverage_radius = radius_m
|
||||
|
||||
@@ -54,13 +54,6 @@ except ImportError:
|
||||
FILTERPY_AVAILABLE = False
|
||||
logging.warning("filterpy not available. Kalman tracking will be disabled.")
|
||||
|
||||
try:
|
||||
import crcmod as _crcmod # noqa: F401 — availability check
|
||||
CRCMOD_AVAILABLE = True
|
||||
except ImportError:
|
||||
CRCMOD_AVAILABLE = False
|
||||
logging.warning("crcmod not available. CRC validation will use fallback.")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark theme color constants (shared by all modules)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -105,15 +98,19 @@ class RadarTarget:
|
||||
|
||||
@dataclass
|
||||
class RadarSettings:
|
||||
"""Radar system configuration parameters."""
|
||||
system_frequency: float = 10e9 # Hz
|
||||
chirp_duration_1: float = 30e-6 # Long chirp duration (s)
|
||||
chirp_duration_2: float = 0.5e-6 # Short chirp duration (s)
|
||||
chirps_per_position: int = 32
|
||||
freq_min: float = 10e6 # Hz
|
||||
freq_max: float = 30e6 # Hz
|
||||
prf1: float = 1000 # PRF 1 (Hz)
|
||||
prf2: float = 2000 # PRF 2 (Hz)
|
||||
"""Radar system display/map configuration.
|
||||
|
||||
FPGA register parameters (chirp timing, CFAR, MTI, gain, etc.) are
|
||||
controlled directly via 4-byte opcode commands — see the FPGA Control
|
||||
tab and Opcode enum in radar_protocol.py. This dataclass holds only
|
||||
host-side display/map settings and physical-unit conversion factors.
|
||||
|
||||
range_resolution and velocity_resolution should be calibrated to
|
||||
the actual waveform parameters.
|
||||
"""
|
||||
system_frequency: float = 10e9 # Hz (carrier, used for velocity calc)
|
||||
range_resolution: float = 781.25 # Meters per range bin (default: 50km/64)
|
||||
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform)
|
||||
max_distance: float = 50000 # Max detection range (m)
|
||||
map_size: float = 50000 # Map display size (m)
|
||||
coverage_radius: float = 50000 # Map coverage radius (m)
|
||||
@@ -139,10 +136,14 @@ class GPSData:
|
||||
|
||||
@dataclass
|
||||
class ProcessingConfig:
|
||||
"""Signal processing pipeline configuration.
|
||||
"""Host-side signal processing pipeline configuration.
|
||||
|
||||
Controls: MTI filter, CFAR detector, DC notch removal,
|
||||
windowing, detection threshold, DBSCAN clustering, and Kalman tracking.
|
||||
These control host-side DSP that runs AFTER the FPGA processing
|
||||
pipeline. FPGA-side MTI, CFAR, and DC notch are controlled via
|
||||
register opcodes from the FPGA Control tab.
|
||||
|
||||
Controls: DBSCAN clustering, Kalman tracking, and optional
|
||||
host-side reprocessing (MTI, CFAR, windowing, DC notch).
|
||||
"""
|
||||
|
||||
# MTI (Moving Target Indication)
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
"""
|
||||
v7.processing — Radar signal processing, packet parsing, and GPS parsing.
|
||||
v7.processing — Radar signal processing and GPS parsing.
|
||||
|
||||
Classes:
|
||||
- RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering,
|
||||
association, Kalman tracking
|
||||
- RadarPacketParser — parse raw byte streams into typed radar packets
|
||||
(FIX: returns (parsed_dict, bytes_consumed) tuple)
|
||||
- USBPacketParser — parse GPS text/binary frames from STM32 CDC
|
||||
|
||||
Bug fixes vs V6:
|
||||
1. RadarPacketParser.parse_packet() now returns (dict, bytes_consumed) tuple
|
||||
so the caller knows exactly how many bytes to strip from the buffer.
|
||||
2. apply_pitch_correction() is a proper standalone function.
|
||||
Note: RadarPacketParser (old A5/C3 sync + CRC16 format) was removed.
|
||||
All packet parsing now uses production RadarProtocol (0xAA/0xBB format)
|
||||
from radar_protocol.py.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional, Tuple, List, Dict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .models import (
|
||||
RadarTarget, GPSData, ProcessingConfig,
|
||||
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
|
||||
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
|
||||
)
|
||||
|
||||
if SKLEARN_AVAILABLE:
|
||||
@@ -33,9 +29,6 @@ if SKLEARN_AVAILABLE:
|
||||
if FILTERPY_AVAILABLE:
|
||||
from filterpy.kalman import KalmanFilter
|
||||
|
||||
if CRCMOD_AVAILABLE:
|
||||
import crcmod
|
||||
|
||||
if SCIPY_AVAILABLE:
|
||||
from scipy.signal import windows as scipy_windows
|
||||
|
||||
@@ -64,14 +57,14 @@ class RadarProcessor:
|
||||
|
||||
def __init__(self):
|
||||
self.range_doppler_map = np.zeros((1024, 32))
|
||||
self.detected_targets: List[RadarTarget] = []
|
||||
self.detected_targets: list[RadarTarget] = []
|
||||
self.track_id_counter: int = 0
|
||||
self.tracks: Dict[int, dict] = {}
|
||||
self.tracks: dict[int, dict] = {}
|
||||
self.frame_count: int = 0
|
||||
self.config = ProcessingConfig()
|
||||
|
||||
# MTI state: store previous frames for cancellation
|
||||
self._mti_history: List[np.ndarray] = []
|
||||
self._mti_history: list[np.ndarray] = []
|
||||
|
||||
# ---- Configuration -----------------------------------------------------
|
||||
|
||||
@@ -160,12 +153,11 @@ class RadarProcessor:
|
||||
h = self._mti_history
|
||||
if order == 1:
|
||||
return h[-1] - h[-2]
|
||||
elif order == 2:
|
||||
if order == 2:
|
||||
return h[-1] - 2.0 * h[-2] + h[-3]
|
||||
elif order == 3:
|
||||
if order == 3:
|
||||
return h[-1] - 3.0 * h[-2] + 3.0 * h[-3] - h[-4]
|
||||
else:
|
||||
return h[-1] - h[-2]
|
||||
return h[-1] - h[-2]
|
||||
|
||||
# ---- CFAR (Constant False Alarm Rate) -----------------------------------
|
||||
|
||||
@@ -234,7 +226,7 @@ class RadarProcessor:
|
||||
|
||||
# ---- Full processing pipeline -------------------------------------------
|
||||
|
||||
def process_frame(self, raw_frame: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||
def process_frame(self, raw_frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Run the full signal processing chain on a Range x Doppler frame.
|
||||
|
||||
Parameters
|
||||
@@ -289,34 +281,10 @@ class RadarProcessor:
|
||||
"""Dual-CPI fusion for better detection."""
|
||||
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
|
||||
# ---- Multi-PRF velocity unwrapping -------------------------------------
|
||||
|
||||
def multi_prf_unwrap(self, doppler_measurements, prf1: float, prf2: float):
|
||||
"""Multi-PRF velocity unwrapping (Chinese Remainder Theorem)."""
|
||||
lam = 3e8 / 10e9
|
||||
v_max1 = prf1 * lam / 2
|
||||
v_max2 = prf2 * lam / 2
|
||||
|
||||
unwrapped = []
|
||||
for doppler in doppler_measurements:
|
||||
v1 = doppler * lam / 2
|
||||
v2 = doppler * lam / 2
|
||||
velocity = self._solve_chinese_remainder(v1, v2, v_max1, v_max2)
|
||||
unwrapped.append(velocity)
|
||||
return unwrapped
|
||||
|
||||
@staticmethod
|
||||
def _solve_chinese_remainder(v1, v2, max1, max2):
|
||||
for k in range(-5, 6):
|
||||
candidate = v1 + k * max1
|
||||
if abs(candidate - v2) < max2 / 2:
|
||||
return candidate
|
||||
return v1
|
||||
|
||||
# ---- DBSCAN clustering -------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def clustering(detections: List[RadarTarget],
|
||||
def clustering(detections: list[RadarTarget],
|
||||
eps: float = 100, min_samples: int = 2) -> list:
|
||||
"""DBSCAN clustering of detections (requires sklearn)."""
|
||||
if not SKLEARN_AVAILABLE or len(detections) == 0:
|
||||
@@ -339,8 +307,8 @@ class RadarProcessor:
|
||||
|
||||
# ---- Association -------------------------------------------------------
|
||||
|
||||
def association(self, detections: List[RadarTarget],
|
||||
clusters: list) -> List[RadarTarget]:
|
||||
def association(self, detections: list[RadarTarget],
|
||||
_clusters: list) -> list[RadarTarget]:
|
||||
"""Associate detections to existing tracks (nearest-neighbour)."""
|
||||
associated = []
|
||||
for det in detections:
|
||||
@@ -366,7 +334,7 @@ class RadarProcessor:
|
||||
|
||||
# ---- Kalman tracking ---------------------------------------------------
|
||||
|
||||
def tracking(self, associated_detections: List[RadarTarget]):
|
||||
def tracking(self, associated_detections: list[RadarTarget]):
|
||||
"""Kalman filter tracking (requires filterpy)."""
|
||||
if not FILTERPY_AVAILABLE:
|
||||
return
|
||||
@@ -412,158 +380,6 @@ class RadarProcessor:
|
||||
del self.tracks[tid]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Radar Packet Parser
|
||||
# =============================================================================
|
||||
|
||||
class RadarPacketParser:
|
||||
"""
|
||||
Parse binary radar packets from the raw byte stream.
|
||||
|
||||
Packet format:
|
||||
[Sync 2][Type 1][Length 1][Payload N][CRC16 2]
|
||||
Sync pattern: 0xA5 0xC3
|
||||
|
||||
Bug fix vs V6:
|
||||
parse_packet() now returns ``(parsed_dict, bytes_consumed)`` so the
|
||||
caller can correctly advance the read pointer in the buffer.
|
||||
"""
|
||||
|
||||
SYNC = b"\xA5\xC3"
|
||||
|
||||
def __init__(self):
|
||||
if CRCMOD_AVAILABLE:
|
||||
self.crc16_func = crcmod.mkCrcFun(
|
||||
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
|
||||
)
|
||||
else:
|
||||
self.crc16_func = None
|
||||
|
||||
# ---- main entry point --------------------------------------------------
|
||||
|
||||
def parse_packet(self, data: bytes) -> Optional[Tuple[dict, int]]:
|
||||
"""
|
||||
Attempt to parse one radar packet from *data*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(parsed_dict, bytes_consumed) on success, or None if no valid packet.
|
||||
"""
|
||||
if len(data) < 6:
|
||||
return None
|
||||
|
||||
idx = data.find(self.SYNC)
|
||||
if idx == -1:
|
||||
return None
|
||||
|
||||
pkt = data[idx:]
|
||||
if len(pkt) < 6:
|
||||
return None
|
||||
|
||||
pkt_type = pkt[2]
|
||||
length = pkt[3]
|
||||
total_len = 4 + length + 2 # sync(2) + type(1) + len(1) + payload + crc(2)
|
||||
|
||||
if len(pkt) < total_len:
|
||||
return None
|
||||
|
||||
payload = pkt[4 : 4 + length]
|
||||
crc_received = struct.unpack("<H", pkt[4 + length : 4 + length + 2])[0]
|
||||
|
||||
# CRC check
|
||||
if self.crc16_func is not None:
|
||||
crc_calc = self.crc16_func(pkt[0 : 4 + length])
|
||||
if crc_calc != crc_received:
|
||||
logger.warning(
|
||||
f"CRC mismatch: got {crc_received:04X}, calc {crc_calc:04X}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Bytes consumed = offset to sync + total packet length
|
||||
consumed = idx + total_len
|
||||
|
||||
parsed = None
|
||||
if pkt_type == 0x01:
|
||||
parsed = self._parse_range(payload)
|
||||
elif pkt_type == 0x02:
|
||||
parsed = self._parse_doppler(payload)
|
||||
elif pkt_type == 0x03:
|
||||
parsed = self._parse_detection(payload)
|
||||
else:
|
||||
logger.warning(f"Unknown packet type: {pkt_type:02X}")
|
||||
|
||||
if parsed is None:
|
||||
return None
|
||||
return (parsed, consumed)
|
||||
|
||||
# ---- sub-parsers -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_range(payload: bytes) -> Optional[dict]:
|
||||
if len(payload) < 12:
|
||||
return None
|
||||
try:
|
||||
range_val = struct.unpack(">I", payload[0:4])[0]
|
||||
elevation = payload[4] & 0x1F
|
||||
azimuth = payload[5] & 0x3F
|
||||
chirp = payload[6] & 0x1F
|
||||
return {
|
||||
"type": "range",
|
||||
"range": range_val,
|
||||
"elevation": elevation,
|
||||
"azimuth": azimuth,
|
||||
"chirp": chirp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing range packet: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_doppler(payload: bytes) -> Optional[dict]:
|
||||
if len(payload) < 12:
|
||||
return None
|
||||
try:
|
||||
real = struct.unpack(">h", payload[0:2])[0]
|
||||
imag = struct.unpack(">h", payload[2:4])[0]
|
||||
elevation = payload[4] & 0x1F
|
||||
azimuth = payload[5] & 0x3F
|
||||
chirp = payload[6] & 0x1F
|
||||
return {
|
||||
"type": "doppler",
|
||||
"doppler_real": real,
|
||||
"doppler_imag": imag,
|
||||
"elevation": elevation,
|
||||
"azimuth": azimuth,
|
||||
"chirp": chirp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing doppler packet: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_detection(payload: bytes) -> Optional[dict]:
|
||||
if len(payload) < 8:
|
||||
return None
|
||||
try:
|
||||
detected = (payload[0] & 0x01) != 0
|
||||
elevation = payload[1] & 0x1F
|
||||
azimuth = payload[2] & 0x3F
|
||||
chirp = payload[3] & 0x1F
|
||||
return {
|
||||
"type": "detection",
|
||||
"detected": detected,
|
||||
"elevation": elevation,
|
||||
"azimuth": azimuth,
|
||||
"chirp": chirp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing detection packet: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# USB / GPS Packet Parser
|
||||
# =============================================================================
|
||||
@@ -578,14 +394,9 @@ class USBPacketParser:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if CRCMOD_AVAILABLE:
|
||||
self.crc16_func = crcmod.mkCrcFun(
|
||||
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
|
||||
)
|
||||
else:
|
||||
self.crc16_func = None
|
||||
pass
|
||||
|
||||
def parse_gps_data(self, data: bytes) -> Optional[GPSData]:
|
||||
def parse_gps_data(self, data: bytes) -> GPSData | None:
|
||||
"""Attempt to parse GPS data from a raw USB CDC frame."""
|
||||
if not data:
|
||||
return None
|
||||
@@ -607,12 +418,12 @@ class USBPacketParser:
|
||||
# Binary format: [GPSB 4][lat 8][lon 8][alt 4][pitch 4][CRC 2] = 30 bytes
|
||||
if len(data) >= 30 and data[0:4] == b"GPSB":
|
||||
return self._parse_binary_gps(data)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logger.error(f"Error parsing GPS data: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_binary_gps(data: bytes) -> Optional[GPSData]:
|
||||
def _parse_binary_gps(data: bytes) -> GPSData | None:
|
||||
"""Parse 30-byte binary GPS frame."""
|
||||
try:
|
||||
if len(data) < 30:
|
||||
@@ -637,6 +448,6 @@ class USBPacketParser:
|
||||
pitch=pitch,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logger.error(f"Error parsing binary GPS: {e}")
|
||||
return None
|
||||
|
||||
+168
-119
@@ -2,24 +2,39 @@
|
||||
v7.workers — QThread-based workers and demo target simulator.
|
||||
|
||||
Classes:
|
||||
- RadarDataWorker — reads from FT2232HQ, parses packets,
|
||||
emits signals with processed data.
|
||||
- RadarDataWorker — reads from FT2232H via production RadarAcquisition,
|
||||
parses 0xAA/0xBB packets, assembles 64x32 frames,
|
||||
runs host-side DSP, emits PyQt signals.
|
||||
- GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals.
|
||||
- TargetSimulator — QTimer-based demo target generator (from GUI_PyQt_Map.py).
|
||||
- TargetSimulator — QTimer-based demo target generator.
|
||||
|
||||
The old V6/V7 packet parsing (sync A5 C3 + type + CRC16) has been removed.
|
||||
All packet parsing now uses the production radar_protocol.py which matches
|
||||
the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte).
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
import queue
|
||||
import struct
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
|
||||
|
||||
from .models import RadarTarget, RadarSettings, GPSData
|
||||
from .hardware import FT2232HQInterface, STM32USBInterface
|
||||
from .models import RadarTarget, GPSData, RadarSettings
|
||||
from .hardware import (
|
||||
RadarAcquisition,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
DataRecorder,
|
||||
STM32USBInterface,
|
||||
)
|
||||
from .processing import (
|
||||
RadarProcessor, RadarPacketParser, USBPacketParser,
|
||||
RadarProcessor,
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
)
|
||||
|
||||
@@ -61,162 +76,196 @@ def polar_to_geographic(
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Radar Data Worker (QThread)
|
||||
# Radar Data Worker (QThread) — production protocol
|
||||
# =============================================================================
|
||||
|
||||
class RadarDataWorker(QThread):
|
||||
"""
|
||||
Background worker that continuously reads radar data from the primary
|
||||
FT2232HQ interface, parses packets, runs the processing pipeline, and
|
||||
emits signals with results.
|
||||
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.
|
||||
|
||||
This replaces the old V7 worker which used an incompatible packet format.
|
||||
Now uses production radar_protocol.py for all packet parsing and frame
|
||||
assembly (11-byte 0xAA data packets → 64x32 RadarFrame).
|
||||
|
||||
Signals:
|
||||
packetReceived(dict) — a single parsed packet dict
|
||||
targetsUpdated(list) — list of RadarTarget after processing
|
||||
errorOccurred(str) — error message
|
||||
statsUpdated(dict) — packet/byte counters
|
||||
frameReady(RadarFrame) — a complete 64x32 radar frame
|
||||
statusReceived(object) — StatusResponse from FPGA
|
||||
targetsUpdated(list) — list of RadarTarget after host-side DSP
|
||||
errorOccurred(str) — error message
|
||||
statsUpdated(dict) — frame/byte counters
|
||||
"""
|
||||
|
||||
packetReceived = pyqtSignal(dict)
|
||||
targetsUpdated = pyqtSignal(list)
|
||||
frameReady = pyqtSignal(object) # RadarFrame
|
||||
statusReceived = pyqtSignal(object) # StatusResponse
|
||||
targetsUpdated = pyqtSignal(list) # List[RadarTarget]
|
||||
errorOccurred = pyqtSignal(str)
|
||||
statsUpdated = pyqtSignal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ft2232hq: FT2232HQInterface,
|
||||
processor: RadarProcessor,
|
||||
packet_parser: RadarPacketParser,
|
||||
settings: RadarSettings,
|
||||
gps_data_ref: GPSData,
|
||||
connection, # FT2232HConnection or ReplayConnection
|
||||
processor: RadarProcessor | None = None,
|
||||
recorder: DataRecorder | None = None,
|
||||
gps_data_ref: GPSData | None = None,
|
||||
settings: RadarSettings | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._ft2232hq = ft2232hq
|
||||
self._connection = connection
|
||||
self._processor = processor
|
||||
self._parser = packet_parser
|
||||
self._settings = settings
|
||||
self._recorder = recorder
|
||||
self._gps = gps_data_ref
|
||||
self._settings = settings or RadarSettings()
|
||||
self._running = False
|
||||
|
||||
# Frame queue for production RadarAcquisition → this thread
|
||||
self._frame_queue: queue.Queue = queue.Queue(maxsize=4)
|
||||
|
||||
# Production acquisition thread (does the actual parsing)
|
||||
self._acquisition: RadarAcquisition | None = None
|
||||
|
||||
# Counters
|
||||
self._packet_count = 0
|
||||
self._frame_count = 0
|
||||
self._byte_count = 0
|
||||
self._error_count = 0
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._acquisition:
|
||||
self._acquisition.stop()
|
||||
|
||||
def run(self):
|
||||
"""Main loop: read → parse → process → emit."""
|
||||
"""
|
||||
Start production RadarAcquisition thread, then poll its frame queue
|
||||
and emit PyQt signals for each complete frame.
|
||||
"""
|
||||
self._running = True
|
||||
buffer = bytearray()
|
||||
|
||||
# Create and start the production acquisition thread
|
||||
self._acquisition = RadarAcquisition(
|
||||
connection=self._connection,
|
||||
frame_queue=self._frame_queue,
|
||||
recorder=self._recorder,
|
||||
status_callback=self._on_status,
|
||||
)
|
||||
self._acquisition.start()
|
||||
logger.info("RadarDataWorker started (production protocol)")
|
||||
|
||||
while self._running:
|
||||
# Use FT2232HQ interface
|
||||
iface = None
|
||||
if self._ft2232hq and self._ft2232hq.is_open:
|
||||
iface = self._ft2232hq
|
||||
|
||||
if iface is None:
|
||||
self.msleep(100)
|
||||
continue
|
||||
|
||||
try:
|
||||
data = iface.read_data(4096)
|
||||
if data:
|
||||
buffer.extend(data)
|
||||
self._byte_count += len(data)
|
||||
# Poll for complete frames from production acquisition
|
||||
frame: RadarFrame = self._frame_queue.get(timeout=0.1)
|
||||
self._frame_count += 1
|
||||
|
||||
# Parse as many packets as possible
|
||||
while len(buffer) >= 6:
|
||||
result = self._parser.parse_packet(bytes(buffer))
|
||||
if result is None:
|
||||
# No valid packet at current position — skip one byte
|
||||
if len(buffer) > 1:
|
||||
buffer = buffer[1:]
|
||||
else:
|
||||
break
|
||||
continue
|
||||
# Emit raw frame
|
||||
self.frameReady.emit(frame)
|
||||
|
||||
pkt, consumed = result
|
||||
buffer = buffer[consumed:]
|
||||
self._packet_count += 1
|
||||
# Run host-side DSP if processor is configured
|
||||
if self._processor is not None:
|
||||
targets = self._run_host_dsp(frame)
|
||||
if targets:
|
||||
self.targetsUpdated.emit(targets)
|
||||
|
||||
# Process the packet
|
||||
self._process_packet(pkt)
|
||||
self.packetReceived.emit(pkt)
|
||||
# Emit stats
|
||||
self.statsUpdated.emit({
|
||||
"frames": self._frame_count,
|
||||
"detection_count": frame.detection_count,
|
||||
"errors": self._error_count,
|
||||
})
|
||||
|
||||
# Emit stats periodically
|
||||
self.statsUpdated.emit({
|
||||
"packets": self._packet_count,
|
||||
"bytes": self._byte_count,
|
||||
"errors": self._error_count,
|
||||
"active_tracks": len(self._processor.tracks),
|
||||
"targets": len(self._processor.detected_targets),
|
||||
})
|
||||
else:
|
||||
self.msleep(10)
|
||||
except Exception as e:
|
||||
except queue.Empty:
|
||||
continue
|
||||
except (ValueError, IndexError) as e:
|
||||
self._error_count += 1
|
||||
self.errorOccurred.emit(str(e))
|
||||
logger.error(f"RadarDataWorker error: {e}")
|
||||
self.msleep(100)
|
||||
|
||||
# ---- internal packet handling ------------------------------------------
|
||||
# Stop acquisition thread
|
||||
if self._acquisition:
|
||||
self._acquisition.stop()
|
||||
self._acquisition.join(timeout=2.0)
|
||||
self._acquisition = None
|
||||
|
||||
def _process_packet(self, pkt: dict):
|
||||
"""Route a parsed packet through the processing pipeline."""
|
||||
try:
|
||||
if pkt["type"] == "range":
|
||||
range_m = pkt["range"] * 0.1
|
||||
raw_elev = pkt["elevation"]
|
||||
logger.info("RadarDataWorker stopped")
|
||||
|
||||
def _on_status(self, status: StatusResponse):
|
||||
"""Callback from production RadarAcquisition on status packet."""
|
||||
self.statusReceived.emit(status)
|
||||
|
||||
def _run_host_dsp(self, frame: RadarFrame) -> list[RadarTarget]:
|
||||
"""
|
||||
Run host-side DSP on a complete frame.
|
||||
This is where DBSCAN clustering, Kalman tracking, and other
|
||||
non-timing-critical processing happens.
|
||||
|
||||
The FPGA already does: FFT, MTI, CFAR, DC notch.
|
||||
Host-side DSP adds: clustering, tracking, geo-coordinate mapping.
|
||||
|
||||
Bin-to-physical conversion uses RadarSettings.range_resolution
|
||||
and velocity_resolution (should be calibrated to actual waveform).
|
||||
"""
|
||||
targets: list[RadarTarget] = []
|
||||
|
||||
cfg = self._processor.config
|
||||
if not (cfg.clustering_enabled or cfg.tracking_enabled):
|
||||
return targets
|
||||
|
||||
# Extract detections from FPGA CFAR flags
|
||||
det_indices = np.argwhere(frame.detections > 0)
|
||||
r_res = self._settings.range_resolution
|
||||
v_res = self._settings.velocity_resolution
|
||||
|
||||
for idx in det_indices:
|
||||
rbin, dbin = idx
|
||||
mag = frame.magnitude[rbin, dbin]
|
||||
snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0
|
||||
|
||||
# Convert bin indices to physical units
|
||||
range_m = float(rbin) * r_res
|
||||
# Doppler: centre bin (16) = 0 m/s; positive bins = approaching
|
||||
velocity_ms = float(dbin - 16) * v_res
|
||||
|
||||
# Apply pitch correction if GPS data available
|
||||
raw_elev = 0.0 # FPGA doesn't send elevation per-detection
|
||||
corr_elev = raw_elev
|
||||
if self._gps:
|
||||
corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
|
||||
|
||||
target = RadarTarget(
|
||||
id=pkt["chirp"],
|
||||
range=range_m,
|
||||
velocity=0,
|
||||
azimuth=pkt["azimuth"],
|
||||
elevation=corr_elev,
|
||||
snr=20.0,
|
||||
timestamp=pkt["timestamp"],
|
||||
# Compute geographic position if GPS available
|
||||
lat, lon = 0.0, 0.0
|
||||
azimuth = 0.0 # No azimuth from single-beam; set to heading
|
||||
if self._gps:
|
||||
azimuth = self._gps.heading
|
||||
lat, lon = polar_to_geographic(
|
||||
self._gps.latitude, self._gps.longitude,
|
||||
range_m, azimuth,
|
||||
)
|
||||
self._update_rdm(target)
|
||||
|
||||
elif pkt["type"] == "doppler":
|
||||
lam = 3e8 / self._settings.system_frequency
|
||||
velocity = (pkt["doppler_real"] / 32767.0) * (
|
||||
self._settings.prf1 * lam / 2
|
||||
)
|
||||
self._update_velocity(pkt, velocity)
|
||||
target = RadarTarget(
|
||||
id=len(targets),
|
||||
range=range_m,
|
||||
velocity=velocity_ms,
|
||||
azimuth=azimuth,
|
||||
elevation=corr_elev,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
snr=snr,
|
||||
timestamp=frame.timestamp,
|
||||
)
|
||||
targets.append(target)
|
||||
|
||||
elif pkt["type"] == "detection":
|
||||
if pkt["detected"]:
|
||||
raw_elev = pkt["elevation"]
|
||||
corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
|
||||
logger.info(
|
||||
f"CFAR Detection: raw={raw_elev}, corr={corr_elev:.1f}, "
|
||||
f"pitch={self._gps.pitch:.1f}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing packet: {e}")
|
||||
# DBSCAN clustering
|
||||
if cfg.clustering_enabled and len(targets) > 0:
|
||||
clusters = self._processor.clustering(
|
||||
targets, cfg.clustering_eps, cfg.clustering_min_samples)
|
||||
# Associate and track
|
||||
if cfg.tracking_enabled:
|
||||
targets = self._processor.association(targets, clusters)
|
||||
self._processor.tracking(targets)
|
||||
|
||||
def _update_rdm(self, target: RadarTarget):
|
||||
range_bin = min(int(target.range / 50), 1023)
|
||||
doppler_bin = min(abs(int(target.velocity)), 31)
|
||||
self._processor.range_doppler_map[range_bin, doppler_bin] += 1
|
||||
self._processor.detected_targets.append(target)
|
||||
if len(self._processor.detected_targets) > 100:
|
||||
self._processor.detected_targets = self._processor.detected_targets[-100:]
|
||||
|
||||
def _update_velocity(self, pkt: dict, velocity: float):
|
||||
for t in self._processor.detected_targets:
|
||||
if (t.azimuth == pkt["azimuth"]
|
||||
and t.elevation == pkt["elevation"]
|
||||
and t.id == pkt["chirp"]):
|
||||
t.velocity = velocity
|
||||
break
|
||||
return targets
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -269,7 +318,7 @@ class GPSDataWorker(QThread):
|
||||
if gps:
|
||||
self._gps_count += 1
|
||||
self.gpsReceived.emit(gps)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
self.errorOccurred.emit(str(e))
|
||||
logger.error(f"GPSDataWorker error: {e}")
|
||||
self.msleep(100)
|
||||
@@ -292,7 +341,7 @@ class TargetSimulator(QObject):
|
||||
def __init__(self, radar_position: GPSData, parent=None):
|
||||
super().__init__(parent)
|
||||
self._radar_pos = radar_position
|
||||
self._targets: List[RadarTarget] = []
|
||||
self._targets: list[RadarTarget] = []
|
||||
self._next_id = 1
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
@@ -349,7 +398,7 @@ class TargetSimulator(QObject):
|
||||
|
||||
def _tick(self):
|
||||
"""Update all simulated targets and emit."""
|
||||
updated: List[RadarTarget] = []
|
||||
updated: list[RadarTarget] = []
|
||||
|
||||
for t in self._targets:
|
||||
new_range = t.range - t.velocity * 0.5
|
||||
|
||||
Reference in New Issue
Block a user