fix: full-repo ruff lint cleanup and CI migration to uv

Resolve all 374 ruff errors across 36 Python files (E501, E702, E722,
E741, F821, F841, invalid-syntax) bringing `ruff check .` to zero
errors repo-wide with line-length=100.

Rewrite CI workflow to use uv for dependency management, whole-repo
`ruff check .`, py_compile syntax gate, and merged python-tests job.
Add pyproject.toml with ruff config and uv dependency groups.

CI structure proposed by hcm444.
This commit is contained in:
Jason
2026-04-09 02:05:34 +03:00
parent 57de32b172
commit 11aa590cf2
31 changed files with 3633 additions and 2789 deletions
+4 -1
View File
@@ -358,7 +358,10 @@ def compare_scenario(scenario_name):
# ---- First/last sample comparison ----
print("\nFirst 10 samples (after alignment):")
print(f" {'idx':>4s} {'RTL_I':>8s} {'Py_I':>8s} {'Err_I':>6s} {'RTL_Q':>8s} {'Py_Q':>8s} {'Err_Q':>6s}")
print(
f" {'idx':>4s} {'RTL_I':>8s} {'Py_I':>8s} {'Err_I':>6s} "
f"{'RTL_Q':>8s} {'Py_Q':>8s} {'Err_Q':>6s}"
)
for k in range(min(10, aligned_len)):
ei = aligned_rtl_i[k] - aligned_py_i[k]
eq = aligned_rtl_q[k] - aligned_py_q[k]
+17 -5
View File
@@ -199,14 +199,17 @@ class NCO:
# Wait - let me re-derive. The Verilog is:
# phase_accumulator <= phase_accumulator + frequency_tuning_word;
# phase_accum_reg <= phase_accumulator; // OLD value (NBA)
# phase_with_offset <= phase_accum_reg + {phase_offset, 16'b0}; // OLD phase_accum_reg
# phase_with_offset <= phase_accum_reg + {phase_offset, 16'b0};
# // OLD phase_accum_reg
# Since all are NBA (<=), they all read the values from BEFORE this edge.
# So: new_phase_accumulator = old_phase_accumulator + ftw
# new_phase_accum_reg = old_phase_accumulator
# new_phase_with_offset = old_phase_accum_reg + offset
old_phase_accumulator = (self.phase_accumulator - ftw) & 0xFFFFFFFF # reconstruct
self.phase_accum_reg = old_phase_accumulator
self.phase_with_offset = (old_phase_accum_reg + ((phase_offset << 16) & 0xFFFFFFFF)) & 0xFFFFFFFF
self.phase_with_offset = (
old_phase_accum_reg + ((phase_offset << 16) & 0xFFFFFFFF)
) & 0xFFFFFFFF
# phase_accumulator was already updated above
# ---- Stage 3a: Register LUT address + quadrant ----
@@ -607,8 +610,14 @@ class FIRFilter:
if (old_valid_pipe >> 0) & 1:
for i in range(16):
# Sign-extend products to ACCUM_WIDTH
a = sign_extend(mult_results[2*i] & ((1 << self.PRODUCT_WIDTH) - 1), self.PRODUCT_WIDTH)
b = sign_extend(mult_results[2*i+1] & ((1 << self.PRODUCT_WIDTH) - 1), self.PRODUCT_WIDTH)
a = sign_extend(
mult_results[2 * i] & ((1 << self.PRODUCT_WIDTH) - 1),
self.PRODUCT_WIDTH,
)
b = sign_extend(
mult_results[2 * i + 1] & ((1 << self.PRODUCT_WIDTH) - 1),
self.PRODUCT_WIDTH,
)
self.add_l0[i] = a + b
# ---- Stage 2 (Level 1): 8 pairwise sums ----
@@ -1365,7 +1374,10 @@ def _self_test():
mag_sq = s * s + c * c
expected = 32767 * 32767
error_pct = abs(mag_sq - expected) / expected * 100
print(f" Quadrature check: sin^2+cos^2={mag_sq}, expected~{expected}, error={error_pct:.2f}%")
print(
f" Quadrature check: sin^2+cos^2={mag_sq}, "
f"expected~{expected}, error={error_pct:.2f}%"
)
print(" NCO: OK")
# --- Mixer test ---
@@ -218,7 +218,8 @@ def generate_long_chirp_test():
if seg == 0:
buffer_write_ptr = 0
else:
# Overlap-save: copy buffer[SEGMENT_ADVANCE:SEGMENT_ADVANCE+OVERLAP] -> buffer[0:OVERLAP]
# Overlap-save: copy
# buffer[SEGMENT_ADVANCE:SEGMENT_ADVANCE+OVERLAP] -> buffer[0:OVERLAP]
for i in range(OVERLAP_SAMPLES):
input_buffer_i[i] = input_buffer_i[i + SEGMENT_ADVANCE]
input_buffer_q[i] = input_buffer_q[i + SEGMENT_ADVANCE]
@@ -335,7 +335,9 @@ def run_ddc(adc_samples):
for n in range(n_samples):
integrators[0][n + 1] = (integrators[0][n] + mixed_i[n]) & ((1 << CIC_ACC_WIDTH) - 1)
for s in range(1, CIC_STAGES):
integrators[s][n + 1] = (integrators[s][n] + integrators[s - 1][n + 1]) & ((1 << CIC_ACC_WIDTH) - 1)
integrators[s][n + 1] = (
integrators[s][n] + integrators[s - 1][n + 1]
) & ((1 << CIC_ACC_WIDTH) - 1)
# Downsample by 4
n_decimated = n_samples // CIC_DECIMATION
@@ -580,8 +582,11 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64)
decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64)
print(f"[DECIM] Decimating {n_in}{output_bins} bins, mode={'peak' if mode==1 else 'avg' if mode==2 else 'simple'}, "
f"start_bin={start_bin}, {n_chirps} chirps")
mode_str = 'peak' if mode == 1 else 'avg' if mode == 2 else 'simple'
print(
f"[DECIM] Decimating {n_in}{output_bins} bins, mode={mode_str}, "
f"start_bin={start_bin}, {n_chirps} chirps"
)
for c in range(n_chirps):
# Index into input, skip start_bin
@@ -678,7 +683,9 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
if twiddle_file_16 and os.path.exists(twiddle_file_16):
cos_rom_16 = load_twiddle_rom(twiddle_file_16)
else:
cos_rom_16 = np.round(32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)).astype(np.int64)
cos_rom_16 = np.round(
32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)
).astype(np.int64)
LOG2N_16 = 4
doppler_map_i = np.zeros((n_range, n_total), dtype=np.int64)
@@ -835,7 +842,10 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
notched_i = doppler_i.copy()
notched_q = doppler_q.copy()
print(f"[DC NOTCH] width={width}, {n_range} range bins x {n_doppler} Doppler bins (dual sub-frame)")
print(
f"[DC NOTCH] width={width}, {n_range} range bins x "
f"{n_doppler} Doppler bins (dual sub-frame)"
)
if width == 0:
print(" Pass-through (width=0)")
@@ -1167,7 +1177,12 @@ def main():
parser = argparse.ArgumentParser(description="AERIS-10 FPGA golden reference model")
parser.add_argument('--frame', type=int, default=0, help='Frame index to process')
parser.add_argument('--plot', action='store_true', help='Show plots')
parser.add_argument('--threshold', type=int, default=10000, help='Detection threshold (L1 magnitude)')
parser.add_argument(
'--threshold',
type=int,
default=10000,
help='Detection threshold (L1 magnitude)'
)
args = parser.parse_args()
# Paths
@@ -1175,7 +1190,11 @@ def main():
fpga_dir = os.path.abspath(os.path.join(script_dir, '..', '..', '..'))
data_base = os.path.expanduser("~/Downloads/adi_radar_data")
amp_data = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB.npy")
amp_config = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy")
amp_config = os.path.join(
data_base,
"amp_radar",
"phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy"
)
twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem")
output_dir = os.path.join(script_dir, "hex")
@@ -1290,7 +1309,10 @@ def main():
q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n")
print(f" Wrote {fc_doppler_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)")
print(
f" Wrote {fc_doppler_packed_file} ("
f"{DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)"
)
# Save numpy arrays for the full-chain path
np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i)
@@ -1336,7 +1358,10 @@ def main():
q_val = int(notched_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n")
print(f" Wrote {fc_notched_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)")
print(
f" Wrote {fc_notched_packed_file} ("
f"{DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)"
)
# CFAR on DC-notched data
CFAR_GUARD = 2
@@ -1385,7 +1410,10 @@ def main():
with open(cfar_det_list_file, 'w') as f:
f.write("# AERIS-10 Full-Chain CFAR Detection List\n")
f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n")
f.write(f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n")
f.write(
f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, "
f"alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n"
)
f.write("# Format: range_bin doppler_bin magnitude threshold\n")
for det in cfar_detections:
r, d = det
@@ -1481,12 +1509,18 @@ def main():
print(f" Chirps processed: {DOPPLER_CHIRPS}")
print(f" Samples/chirp: {FFT_SIZE}")
print(f" Range FFT: {FFT_SIZE}-point → {snr_range:.1f} dB vs float")
print(f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming → {snr_doppler:.1f} dB vs float")
print(
f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming "
f"{snr_doppler:.1f} dB vs float"
)
print(f" Detections (direct): {len(detections)} (threshold={args.threshold})")
print(" Full-chain decimator: 1024→64 peak detection")
print(f" Full-chain detections: {len(fc_detections)} (threshold={args.threshold})")
print(f" MTI+CFAR chain: decim → MTI → Doppler → DC notch(w={DC_NOTCH_WIDTH}) → CA-CFAR")
print(f" CFAR detections: {len(cfar_detections)} (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})")
print(
f" CFAR detections: {len(cfar_detections)} "
f"(guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})"
)
print(f" Hex stimulus files: {output_dir}/")
print(" Ready for RTL co-simulation with Icarus Verilog")
@@ -199,7 +199,10 @@ def test_long_chirp():
avg_mag = sum(magnitudes) / len(magnitudes)
print(f" Magnitude: min={min_mag:.1f}, max={max_mag:.1f}, avg={avg_mag:.1f}")
print(f" Max magnitude as fraction of Q15 range: {max_mag/32767:.4f} ({max_mag/32767*100:.2f}%)")
print(
f" Max magnitude as fraction of Q15 range: "
f"{max_mag/32767:.4f} ({max_mag/32767*100:.2f}%)"
)
# Check if this looks like it came from generate_reference_chirp_q15
# That function uses 32767 * 0.9 scaling => max magnitude ~29490
@@ -262,7 +265,10 @@ def test_long_chirp():
# Check if bandwidth roughly matches expected
bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50%
if bw_match:
print(f" Bandwidth {f_range/1e6:.2f} MHz roughly matches expected {CHIRP_BW/1e6:.2f} MHz")
print(
f" Bandwidth {f_range/1e6:.2f} MHz roughly matches expected "
f"{CHIRP_BW/1e6:.2f} MHz"
)
else:
warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz")
@@ -415,8 +421,11 @@ def test_chirp_vs_model():
print(f" Max phase diff: {max_phase_diff:.4f} rad ({math.degrees(max_phase_diff):.2f} deg)")
phase_match = max_phase_diff < 0.5 # within 0.5 rad
check(phase_match,
f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg (tolerance: 28.6 deg)")
check(
phase_match,
f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg "
f"(tolerance: 28.6 deg)",
)
# ============================================================================
@@ -521,8 +530,11 @@ def test_memory_addressing():
addr_from_concat = (seg << 10) | 0 # {seg[1:0], 10'b0}
addr_end = (seg << 10) | 1023
check(addr_from_concat == base,
f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} (expected {base})")
check(
addr_from_concat == base,
f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} "
f"(expected {base})",
)
check(addr_end == end,
f"Seg {seg} end address: {{{seg}[1:0], 10'h3FF}} = {addr_end} (expected {end})")
+66 -17
View File
@@ -33,7 +33,8 @@ from enum import Enum
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTabWidget, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox,
QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox, QTableWidget, QTableWidgetItem,
QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox,
QTableWidget, QTableWidgetItem,
QHeaderView
)
from PyQt6.QtCore import (
@@ -554,11 +555,20 @@ class RadarMapWidget(QWidget):
if (!radarMarker) return;
var content = '<div class="popup-title">Radar System</div>' +
'<div class="popup-row"><span class="popup-label">Latitude:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Latitude:</span>' +
'<span class="popup-value">'
) +
radarMarker.getLatLng().lat.toFixed(6) + '</span></div>' +
'<div class="popup-row"><span class="popup-label">Longitude:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Longitude:</span>' +
'<span class="popup-value">'
) +
radarMarker.getLatLng().lng.toFixed(6) + '</span></div>' +
'<div class="popup-row"><span class="popup-label">Status:</span><span class="popup-value status-approaching">Active</span></div>';
(
'<div class="popup-row"><span class="popup-label">Status:</span>' +
'<span class="popup-value status-approaching">Active</span></div>'
);
radarMarker.bindPopup(content);
}}
@@ -570,10 +580,22 @@ class RadarMapWidget(QWidget):
var div = L.DomUtil.create('div', 'legend');
div.innerHTML =
'<div class="legend-title">Target Legend</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#F44336"></div>Approaching</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#2196F3"></div>Receding</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#9E9E9E"></div>Stationary</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#FF5252"></div>Radar</div>';
(
'<div class="legend-item"><div class="legend-color" ' +
'style="background:#F44336"></div>Approaching</div>'
) +
(
'<div class="legend-item"><div class="legend-color" ' +
'style="background:#2196F3"></div>Receding</div>'
) +
(
'<div class="legend-item"><div class="legend-color" ' +
'style="background:#9E9E9E"></div>Stationary</div>'
) +
(
'<div class="legend-item"><div class="legend-color" ' +
'style="background:#FF5252"></div>Radar</div>'
);
return div;
}};
@@ -590,7 +612,9 @@ class RadarMapWidget(QWidget):
updateRadarPopup();
if (bridge) {{
bridge.logFromJS('Radar position updated: ' + lat.toFixed(4) + ', ' + lon.toFixed(4));
bridge.logFromJS(
'Radar position updated: ' + lat.toFixed(4) + ', ' + lon.toFixed(4)
);
}}
}}
@@ -717,19 +741,40 @@ class RadarMapWidget(QWidget):
(target.velocity < -1 ? 'Receding' : 'Stationary');
var content = '<div class="popup-title">Target #' + target.id + '</div>' +
'<div class="popup-row"><span class="popup-label">Range:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Range:</span>' +
'<span class="popup-value">'
) +
target.range.toFixed(1) + ' m</span></div>' +
'<div class="popup-row"><span class="popup-label">Velocity:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Velocity:</span>' +
'<span class="popup-value">'
) +
target.velocity.toFixed(1) + ' m/s</span></div>' +
'<div class="popup-row"><span class="popup-label">Azimuth:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Azimuth:</span>' +
'<span class="popup-value">'
) +
target.azimuth.toFixed(1) + '&deg;</span></div>' +
'<div class="popup-row"><span class="popup-label">Elevation:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Elevation:</span>' +
'<span class="popup-value">'
) +
target.elevation.toFixed(1) + '&deg;</span></div>' +
'<div class="popup-row"><span class="popup-label">SNR:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">SNR:</span>' +
'<span class="popup-value">'
) +
target.snr.toFixed(1) + ' dB</span></div>' +
'<div class="popup-row"><span class="popup-label">Track ID:</span><span class="popup-value">' +
(
'<div class="popup-row"><span class="popup-label">Track ID:</span>' +
'<span class="popup-value">'
) +
target.track_id + '</span></div>' +
'<div class="popup-row"><span class="popup-label">Status:</span><span class="popup-value ' +
(
'<div class="popup-row"><span class="popup-label">Status:</span>' +
'<span class="popup-value '
) +
statusClass + '">' + statusText + '</span></div>';
targetMarkers[target.id].bindPopup(content);
@@ -770,7 +815,11 @@ class RadarMapWidget(QWidget):
if (visible) {{
// Create trails for all existing markers using stored history
for (var id in targetMarkers) {{
if (!targetTrails[id] && targetTrailHistory[id] && targetTrailHistory[id].length > 1) {{
if (
!targetTrails[id] &&
targetTrailHistory[id] &&
targetTrailHistory[id].length > 1
) {{
// Get color from current marker position (approximate)
var color = '#4CAF50'; // Default green
targetTrails[id] = L.polyline(targetTrailHistory[id], {{
+45 -30
View File
@@ -1,37 +1,52 @@
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}, Lon {gps_data.longitude:.6f}, 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}")
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():
def main():
"""Main application entry point"""
try:
root = tk.Tk()
app = RadarGUI(root)
_app = RadarGUI(root)
root.mainloop()
except Exception as e:
logging.error(f"Application error: {e}")
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
+312 -275
View File
@@ -2,13 +2,9 @@ import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
import matplotlib.patches as patches
from scipy import signal
from scipy.fft import fft, fftshift
from scipy.signal import butter, filtfilt
import logging
from dataclasses import dataclass
from typing import List, Dict, Tuple
@@ -17,7 +13,8 @@ import queue
import time
# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
@dataclass
class RadarTarget:
@@ -29,12 +26,13 @@ class RadarTarget:
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
@@ -42,101 +40,111 @@ class SignalProcessor:
"""
# Window function for FFT
window = np.hanning(len(iq_data))
windowed_data = (iq_data['I_value'].values + 1j * iq_data['Q_value'].values) * window
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)
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:
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':
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]
filtered[i - 1] = complex_data[i] - complex_data[i - 1]
return filtered
elif filter_type == 'double_canceler':
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]
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]]:
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
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]:
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
@@ -144,54 +152,55 @@ class SignalProcessor:
# 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:
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]
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()
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]
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]
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
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):
@@ -204,178 +213,184 @@ class SignalProcessor:
# Handle shape mismatch by padding or truncating
if len(filtered) < num_chirps:
padded = np.zeros(num_chirps, dtype=complex)
padded[:len(filtered)] = filtered
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
"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)
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)
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)
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)
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)
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)
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)
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)
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)
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.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.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.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", "*.*")]
title="Select CSV file", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]
)
# Add magnitude and phase calculations after loading CSV
# 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'])
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 "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:
@@ -383,296 +398,318 @@ class RadarGUI:
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))
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))
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':
if task_type == "process":
self._process_data_background(data)
elif task_type == 'cfar':
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}"
))
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')
long_chirp_data = self.processor.process_chirp_sequence(df, "LONG")
# Process short chirps
short_chirp_data = self.processor.process_chirp_sequence(df, 'SHORT')
short_chirp_data = self.processor.process_chirp_sequence(df, "SHORT")
# Store results
self.processed_data = {
'long': long_chirp_data,
'short': short_chirp_data
}
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}"
))
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()]
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
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
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()
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))
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}"
))
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]
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
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.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')
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
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.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')
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):
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']
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')
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')
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
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.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')
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')
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.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")
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
))
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)
_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
+130 -30
View File
@@ -194,7 +194,9 @@ class MapGenerator:
var targetMarker = new google.maps.Marker({{
position: {{lat: target.lat, lng: target.lng}},
map: map,
title: `Target: ${{target.range:.1f}}m, ${{target.velocity:.1f}}m/s`,
title: (
`Target: ${{target.range:.1f}}m, ${{target.velocity:.1f}}m/s`
),
icon: {{
path: google.maps.SymbolPath.CIRCLE,
scale: 6,
@@ -276,8 +278,14 @@ class FT601Interface:
found_devices = usb.core.find(find_all=True, idVendor=vid, idProduct=pid)
for dev in found_devices:
try:
product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else "FT601 USB3.0"
serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else "Unknown"
product = (
usb.util.get_string(dev, dev.iProduct)
if dev.iProduct else "FT601 USB3.0"
)
serial = (
usb.util.get_string(dev, dev.iSerialNumber)
if dev.iSerialNumber else "Unknown"
)
# Create FTDI URL for the device
url = f"ftdi://{vid:04x}:{pid:04x}:{serial}/1"
@@ -541,8 +549,14 @@ class STM32USBInterface:
found_devices = usb.core.find(find_all=True, idVendor=vid, idProduct=pid)
for dev in found_devices:
try:
product = usb.util.get_string(dev, dev.iProduct) if dev.iProduct else "STM32 CDC"
serial = usb.util.get_string(dev, dev.iSerialNumber) if dev.iSerialNumber else "Unknown"
product = (
usb.util.get_string(dev, dev.iProduct)
if dev.iProduct else "STM32 CDC"
)
serial = (
usb.util.get_string(dev, dev.iSerialNumber)
if dev.iSerialNumber else "Unknown"
)
devices.append({
'description': f"{product} ({serial})",
'vendor_id': vid,
@@ -561,7 +575,11 @@ class STM32USBInterface:
except Exception as e:
logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing
return [{'description': 'STM32 Virtual COM Port', 'vendor_id': 0x0483, 'product_id': 0x5740}]
return [{
'description': 'STM32 Virtual COM Port',
'vendor_id': 0x0483,
'product_id': 0x5740,
}]
def open_device(self, device_info):
"""Open STM32 USB CDC device"""
@@ -586,12 +604,18 @@ class STM32USBInterface:
# Find bulk endpoints (CDC data interface)
self.ep_out = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_OUT
custom_match=lambda e: (
usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_OUT
)
)
self.ep_in = usb.util.find_descriptor(
intf,
custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN
custom_match=lambda e: (
usb.util.endpoint_direction(e.bEndpointAddress)
== usb.util.ENDPOINT_IN
)
)
if self.ep_out is None or self.ep_in is None:
@@ -826,7 +850,13 @@ class USBPacketParser:
lon = float(parts[1])
alt = float(parts[2])
pitch = float(parts[3]) # Pitch angle in degrees
return GPSData(latitude=lat, longitude=lon, altitude=alt, pitch=pitch, timestamp=time.time())
return GPSData(
latitude=lat,
longitude=lon,
altitude=alt,
pitch=pitch,
timestamp=time.time(),
)
# Try binary format (30 bytes with pitch)
if len(data) >= 30 and data[0:4] == b'GPSB':
@@ -918,7 +948,10 @@ class RadarPacketParser:
crc_calculated = self.calculate_crc(packet[0:4+length])
if crc_calculated != crc_received:
logging.warning(f"CRC mismatch: got {crc_received:04X}, calculated {crc_calculated:04X}")
logging.warning(
f"CRC mismatch: got {crc_received:04X}, "
f"calculated {crc_calculated:04X}"
)
return None
if packet_type == 0x01:
@@ -1037,7 +1070,13 @@ class RadarGUI:
# Counters
self.received_packets = 0
self.current_gps = GPSData(latitude=41.9028, longitude=12.4964, altitude=0, pitch=0.0, timestamp=0)
self.current_gps = GPSData(
latitude=41.9028,
longitude=12.4964,
altitude=0,
pitch=0.0,
timestamp=0,
)
self.corrected_elevations = []
self.map_file_path = None
self.google_maps_api_key = "YOUR_GOOGLE_MAPS_API_KEY"
@@ -1219,9 +1258,20 @@ class RadarGUI:
targets_frame = ttk.LabelFrame(display_frame, text="Detected Targets (Pitch Corrected)")
targets_frame.pack(side='right', fill='y', padx=5)
self.targets_tree = ttk.Treeview(targets_frame,
columns=('ID', 'Range', 'Velocity', 'Azimuth', 'Elevation', 'Corrected Elev', 'SNR'),
show='headings', height=20)
self.targets_tree = ttk.Treeview(
targets_frame,
columns=(
'ID',
'Range',
'Velocity',
'Azimuth',
'Elevation',
'Corrected Elev',
'SNR',
),
show='headings',
height=20,
)
self.targets_tree.heading('ID', text='Track ID')
self.targets_tree.heading('Range', text='Range (m)')
self.targets_tree.heading('Velocity', text='Velocity (m/s)')
@@ -1239,7 +1289,11 @@ class RadarGUI:
self.targets_tree.column('SNR', width=70)
# Add scrollbar to targets tree
tree_scroll = ttk.Scrollbar(targets_frame, orient="vertical", command=self.targets_tree.yview)
tree_scroll = ttk.Scrollbar(
targets_frame,
orient="vertical",
command=self.targets_tree.yview,
)
self.targets_tree.configure(yscrollcommand=tree_scroll.set)
self.targets_tree.pack(side='left', fill='both', expand=True, padx=5, pady=5)
tree_scroll.pack(side='right', fill='y', padx=(0, 5), pady=5)
@@ -1288,7 +1342,9 @@ class RadarGUI:
if not self.ft601_interface.open_device_direct(ft601_devices[ft601_index]):
device_url = ft601_devices[ft601_index]['url']
if not self.ft601_interface.open_device(device_url):
logging.warning("Failed to open FT601 device, continuing without radar data")
logging.warning(
"Failed to open FT601 device, continuing without radar data"
)
messagebox.showwarning("Warning", "Failed to open FT601 device")
else:
# Configure burst mode if enabled
@@ -1382,7 +1438,11 @@ class RadarGUI:
gps_data = self.usb_packet_parser.parse_gps_data(data)
if gps_data:
self.gps_data_queue.put(gps_data)
logging.info(f"GPS Data received via USB: Lat {gps_data.latitude:.6f}, Lon {gps_data.longitude:.6f}, Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°")
logging.info(
f"GPS Data received via USB: Lat {gps_data.latitude:.6f}, "
f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°"
)
except Exception as e:
logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1)
@@ -1395,7 +1455,10 @@ class RadarGUI:
# Apply pitch correction to elevation
raw_elevation = packet['elevation']
corrected_elevation = self.apply_pitch_correction(raw_elevation, self.current_gps.pitch)
corrected_elevation = self.apply_pitch_correction(
raw_elevation,
self.current_gps.pitch,
)
# Store correction for display
self.corrected_elevations.append({
@@ -1423,16 +1486,25 @@ class RadarGUI:
elif packet['type'] == 'doppler':
lambda_wavelength = 3e8 / self.settings.system_frequency
velocity = (packet['doppler_real'] / 32767.0) * (self.settings.prf1 * lambda_wavelength / 2)
velocity = (packet['doppler_real'] / 32767.0) * (
self.settings.prf1 * lambda_wavelength / 2
)
self.update_target_velocity(packet, velocity)
elif packet['type'] == 'detection':
if packet['detected']:
# Apply pitch correction to detection elevation
raw_elevation = packet['elevation']
corrected_elevation = self.apply_pitch_correction(raw_elevation, self.current_gps.pitch)
corrected_elevation = self.apply_pitch_correction(
raw_elevation,
self.current_gps.pitch,
)
logging.info(f"CFAR Detection: Raw Elev {raw_elevation}°, Corrected Elev {corrected_elevation:.1f}°, Pitch {self.current_gps.pitch:.1f}°")
logging.info(
f"CFAR Detection: Raw Elev {raw_elevation}°, "
f"Corrected Elev {corrected_elevation:.1f}°, "
f"Pitch {self.current_gps.pitch:.1f}°"
)
except Exception as e:
logging.error(f"Error processing radar packet: {e}")
@@ -1480,7 +1552,11 @@ class RadarGUI:
info_frame = ttk.Frame(map_frame)
info_frame.pack(fill='x', pady=5)
self.map_info_label = ttk.Label(info_frame, text="No GPS data received yet", font=('Arial', 10))
self.map_info_label = ttk.Label(
info_frame,
text="No GPS data received yet",
font=('Arial', 10),
)
self.map_info_label.pack()
def open_map_in_browser(self):
@@ -1488,7 +1564,10 @@ class RadarGUI:
if self.map_file_path and os.path.exists(self.map_file_path):
webbrowser.open('file://' + os.path.abspath(self.map_file_path))
else:
messagebox.showwarning("Warning", "No map file available. Generate map first by receiving GPS data.")
messagebox.showwarning(
"Warning",
"No map file available. Generate map first by receiving GPS data.",
)
def refresh_map(self):
"""Refresh the map with current data"""
@@ -1502,7 +1581,12 @@ class RadarGUI:
try:
# Create temporary HTML file
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f:
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.html',
delete=False,
encoding='utf-8',
) as f:
map_html = self.map_generator.generate_map(
self.current_gps,
self.radar_processor.detected_targets,
@@ -1533,7 +1617,12 @@ class RadarGUI:
# Update GPS label
self.gps_label.config(
text=f"GPS: Lat {gps_data.latitude:.6f}, Lon {gps_data.longitude:.6f}, Alt {gps_data.altitude:.1f}m")
text=(
f"GPS: Lat {gps_data.latitude:.6f}, "
f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m"
)
)
# Update pitch label with color coding
pitch_text = f"Pitch: {gps_data.pitch:+.1f}°"
@@ -1581,8 +1670,11 @@ class RadarGUI:
entry.grid(row=i, column=1, padx=5, pady=5)
self.settings_vars[attr] = var
ttk.Button(settings_frame, text="Apply Settings",
command=self.apply_settings).grid(row=len(entries), column=0, columnspan=2, pady=10)
ttk.Button(
settings_frame,
text="Apply Settings",
command=self.apply_settings,
).grid(row=len(entries), column=0, columnspan=2, pady=10)
def apply_settings(self):
"""Step 13: Apply and send radar settings via USB"""
@@ -1678,7 +1770,11 @@ class RadarGUI:
gps_data = self.usb_packet_parser.parse_gps_data(data)
if gps_data:
self.gps_data_queue.put(gps_data)
logging.info(f"GPS Data received via USB: Lat {gps_data.latitude:.6f}, Lon {gps_data.longitude:.6f}, Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°")
logging.info(
f"GPS Data received via USB: Lat {gps_data.latitude:.6f}, "
f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°"
)
except Exception as e:
logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1)
@@ -1688,8 +1784,12 @@ class RadarGUI:
try:
# Update status with pitch information
if self.running:
self.status_label.config(
text=f"Status: Running - Packets: {self.received_packets} - Pitch: {self.current_gps.pitch:+.1f}°")
self.status_label.config(
text=(
f"Status: Running - Packets: {self.received_packets} - "
f"Pitch: {self.current_gps.pitch:+.1f}°"
)
)
# Update range-Doppler map
if hasattr(self, 'range_doppler_plot'):
+12 -4
View File
@@ -621,7 +621,9 @@ class RadarDemoGUI:
self.update_rate.grid(row=0, column=1, padx=10, pady=5)
self.update_rate_value = ttk.Label(frame, text="20")
self.update_rate_value.grid(row=0, column=2, sticky='w')
self.update_rate.configure(command=lambda v: self.update_rate_value.config(text=f"{float(v):.0f}"))
self.update_rate.configure(
command=lambda v: self.update_rate_value.config(text=f"{float(v):.0f}")
)
# Color map
ttk.Label(frame, text="Color Map:").grid(row=1, column=0, sticky='w', pady=5)
@@ -658,7 +660,9 @@ class RadarDemoGUI:
self.noise_floor.grid(row=0, column=1, padx=10, pady=5)
self.noise_value = ttk.Label(frame, text="10")
self.noise_value.grid(row=0, column=2, sticky='w')
self.noise_floor.configure(command=lambda v: self.noise_value.config(text=f"{float(v):.1f}"))
self.noise_floor.configure(
command=lambda v: self.noise_value.config(text=f"{float(v):.1f}")
)
# Clutter level
ttk.Label(frame, text="Clutter Level:").grid(row=1, column=0, sticky='w', pady=5)
@@ -668,7 +672,9 @@ class RadarDemoGUI:
self.clutter_level.grid(row=1, column=1, padx=10, pady=5)
self.clutter_value = ttk.Label(frame, text="5")
self.clutter_value.grid(row=1, column=2, sticky='w')
self.clutter_level.configure(command=lambda v: self.clutter_value.config(text=f"{float(v):.1f}"))
self.clutter_level.configure(
command=lambda v: self.clutter_value.config(text=f"{float(v):.1f}")
)
# Number of targets
ttk.Label(frame, text="Number of Targets:").grid(row=2, column=0, sticky='w', pady=5)
@@ -678,7 +684,9 @@ class RadarDemoGUI:
self.num_targets.grid(row=2, column=1, padx=10, pady=5)
self.targets_value = ttk.Label(frame, text="5")
self.targets_value.grid(row=2, column=2, sticky='w')
self.num_targets.configure(command=lambda v: self.targets_value.config(text=f"{float(v):.0f}"))
self.num_targets.configure(
command=lambda v: self.targets_value.config(text=f"{float(v):.0f}")
)
# Reset button
ttk.Button(frame, text="Reset Simulation",
+4 -1
View File
@@ -321,7 +321,10 @@ class TestDataRecorder(unittest.TestCase):
os.rmdir(self.tmpdir)
@unittest.skipUnless(
(lambda: (__import__("importlib.util") and __import__("importlib").util.find_spec("h5py") is not None))(),
(lambda: (
__import__("importlib.util")
and __import__("importlib").util.find_spec("h5py") is not None
))(),
"h5py not installed"
)
def test_record_and_stop(self):
+8 -3
View File
@@ -755,7 +755,9 @@ class RadarDashboard(QMainWindow):
self._det_thresh_spin.setValue(self._processing_config.detection_threshold_db)
self._det_thresh_spin.setSuffix(" dB")
self._det_thresh_spin.setSingleStep(1.0)
self._det_thresh_spin.setToolTip("SNR threshold above noise floor (used when CFAR is disabled)")
self._det_thresh_spin.setToolTip(
"SNR threshold above noise floor (used when CFAR is disabled)"
)
p_layout.addWidget(self._det_thresh_spin, row, 1)
row += 1
@@ -906,8 +908,11 @@ class RadarDashboard(QMainWindow):
if idx2 >= 0 and idx2 < len(self._ft2232hq_devices):
url = self._ft2232hq_devices[idx2]["url"]
if not self._ft2232hq.open_device(url):
QMessageBox.warning(self, "Warning",
"Failed to open FT2232HQ device. Radar data may not be available.")
QMessageBox.warning(
self,
"Warning",
"Failed to open FT2232HQ device. Radar data may not be available.",
)
# Send start flag + settings
if not self._stm32.send_start_flag():
+73 -18
View File
@@ -305,7 +305,10 @@ function initMap() {{
function setTileServer(id) {{
var cfg = tileServers[id]; if(!cfg) return;
if(currentTileLayer) map.removeLayer(currentTileLayer);
currentTileLayer = L.tileLayer(cfg.url, {{ attribution:cfg.attribution, maxZoom:cfg.maxZoom }}).addTo(map);
currentTileLayer = L.tileLayer(
cfg.url,
{{ attribution:cfg.attribution, maxZoom:cfg.maxZoom }}
).addTo(map);
}}
function updateRadarPopup() {{
@@ -313,9 +316,18 @@ function updateRadarPopup() {{
var ll = radarMarker.getLatLng();
radarMarker.bindPopup(
'<div class="popup-title">Radar System</div>'+
'<div class="popup-row"><span class="popup-label">Lat:</span><span class="popup-value">'+ll.lat.toFixed(6)+'</span></div>'+
'<div class="popup-row"><span class="popup-label">Lon:</span><span class="popup-value">'+ll.lng.toFixed(6)+'</span></div>'+
'<div class="popup-row"><span class="popup-label">Status:</span><span class="popup-value status-approaching">Active</span></div>'
(
'<div class="popup-row"><span class="popup-label">Lat:</span>'+
'<span class="popup-value">'+ll.lat.toFixed(6)+'</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Lon:</span>'+
'<span class="popup-value">'+ll.lng.toFixed(6)+'</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Status:</span>'+
'<span class="popup-value status-approaching">Active</span></div>'
)
);
}}
@@ -325,10 +337,22 @@ function addLegend() {{
var d = L.DomUtil.create('div','legend');
d.innerHTML =
'<div class="legend-title">Target Legend</div>'+
'<div class="legend-item"><div class="legend-color" style="background:#F44336"></div>Approaching</div>'+
'<div class="legend-item"><div class="legend-color" style="background:#2196F3"></div>Receding</div>'+
'<div class="legend-item"><div class="legend-color" style="background:#9E9E9E"></div>Stationary</div>'+
'<div class="legend-item"><div class="legend-color" style="background:#FF5252"></div>Radar</div>';
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#F44336"></div>Approaching</div>'
)+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#2196F3"></div>Receding</div>'
)+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#9E9E9E"></div>Stationary</div>'
)+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#FF5252"></div>Radar</div>'
);
return d;
}};
legend.addTo(map);
@@ -365,7 +389,12 @@ function updateTargets(targetsJson) {{
}}
}} 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));
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], {{
@@ -389,24 +418,50 @@ 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>',
(
'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 sc = t.velocity>1
? 'status-approaching'
: (t.velocity<-1 ? 'status-receding' : 'status-stationary');
var st = t.velocity>1?'Approaching':(t.velocity<-1?'Receding':'Stationary');
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)+'&deg;</span></div>'+
'<div class="popup-row"><span class="popup-label">Elevation:</span><span class="popup-value">'+t.elevation.toFixed(1)+'&deg;</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">'+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)+'&deg;</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
'<span class="popup-value">'+t.elevation.toFixed(1)+'&deg;</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>'
)
);
}}