From 57de32b17253f10d9e7eac327a7dbd92a3786b33 Mon Sep 17 00:00:00 2001
From: Jason <83615043+JJassonn69@users.noreply.github.com>
Date: Wed, 8 Apr 2026 19:11:40 +0300
Subject: [PATCH] fix: resolve all ruff lint errors across V6+ GUIs, v7 module,
and FPGA cosim scripts
Fixes 25 remaining manual lint errors after auto-fix pass (94 auto-fixed earlier):
- GUI_V6.py: noqa on availability imports, bare except, unused vars, F811 redefs
- GUI_V6_Demo.py: unused app variable
- v7/models.py: noqa F401 on 8 try/except availability-check imports
- FPGA cosim: unused header/status/span vars, ambiguous 'l' renamed to 'line',
E701 while-on-one-line split, F841 padding vars annotated
Also adds v7/ module, GUI_PyQt_Map.py, and GUI_V7_PyQt.py to version control.
Expands CI lint job to cover all 21 maintained Python files (was 4).
All 58 Python tests pass. Zero ruff errors on all target files.
---
.github/workflows/ci-tests.yml | 18 +-
9_Firmware/9_2_FPGA/tb/cosim/compare.py | 11 +-
.../9_2_FPGA/tb/cosim/compare_doppler.py | 14 +-
9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py | 14 +-
9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py | 4 +-
9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py | 10 +-
.../9_2_FPGA/tb/cosim/gen_doppler_golden.py | 7 +-
.../9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py | 4 +-
.../9_2_FPGA/tb/cosim/gen_multiseg_golden.py | 6 +-
9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py | 5 +-
.../tb/cosim/real_data/golden_reference.py | 25 +-
.../9_2_FPGA/tb/cosim/validate_mem_files.py | 28 +-
9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py | 4 +-
9_Firmware/9_3_GUI/GUI_PyQt_Map.py | 1662 +++++++++++++++++
9_Firmware/9_3_GUI/GUI_V6.py | 24 +-
9_Firmware/9_3_GUI/GUI_V6_Demo.py | 7 +-
9_Firmware/9_3_GUI/GUI_V7_PyQt.py | 40 +
9_Firmware/9_3_GUI/v7/__init__.py | 80 +
9_Firmware/9_3_GUI/v7/dashboard.py | 1331 +++++++++++++
9_Firmware/9_3_GUI/v7/hardware.py | 307 +++
9_Firmware/9_3_GUI/v7/map_widget.py | 532 ++++++
9_Firmware/9_3_GUI/v7/models.py | 187 ++
9_Firmware/9_3_GUI/v7/processing.py | 642 +++++++
9_Firmware/9_3_GUI/v7/workers.py | 389 ++++
24 files changed, 5260 insertions(+), 91 deletions(-)
create mode 100644 9_Firmware/9_3_GUI/GUI_PyQt_Map.py
create mode 100644 9_Firmware/9_3_GUI/GUI_V7_PyQt.py
create mode 100644 9_Firmware/9_3_GUI/v7/__init__.py
create mode 100644 9_Firmware/9_3_GUI/v7/dashboard.py
create mode 100644 9_Firmware/9_3_GUI/v7/hardware.py
create mode 100644 9_Firmware/9_3_GUI/v7/map_widget.py
create mode 100644 9_Firmware/9_3_GUI/v7/models.py
create mode 100644 9_Firmware/9_3_GUI/v7/processing.py
create mode 100644 9_Firmware/9_3_GUI/v7/workers.py
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index d9c2a3c..db24bea 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -8,11 +8,12 @@ on:
jobs:
# ===========================================================================
- # Job 0: Ruff Lint (active Python files only)
- # Excludes legacy GUI_V*.py files and untracked v7/ directory
+ # Job 0: Ruff Lint (all maintained Python files)
+ # Covers: active GUI files, v6+ GUIs, v7/ module, FPGA cosim scripts
+ # Excludes: legacy GUI_V1-V5, schematics, simulation, 8_Utils
# ===========================================================================
lint:
- name: Ruff Lint (active files)
+ name: Ruff Lint
runs-on: ubuntu-latest
steps:
@@ -27,13 +28,20 @@ jobs:
- name: Install ruff
run: pip install ruff
- - name: Run ruff on active files
+ - name: Run ruff on maintained files
run: |
ruff check \
9_Firmware/9_3_GUI/radar_protocol.py \
9_Firmware/9_3_GUI/radar_dashboard.py \
9_Firmware/9_3_GUI/smoke_test.py \
- 9_Firmware/9_3_GUI/test_radar_dashboard.py
+ 9_Firmware/9_3_GUI/test_radar_dashboard.py \
+ 9_Firmware/9_3_GUI/GUI_V6.py \
+ 9_Firmware/9_3_GUI/GUI_V6_Demo.py \
+ 9_Firmware/9_3_GUI/GUI_PyQt_Map.py \
+ 9_Firmware/9_3_GUI/GUI_V7_PyQt.py \
+ 9_Firmware/9_3_GUI/v7/ \
+ 9_Firmware/9_2_FPGA/tb/cosim/ \
+ 9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py
# ===========================================================================
# Job 1: Python Host Software Tests (58 tests)
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare.py b/9_Firmware/9_2_FPGA/tb/cosim/compare.py
index 90ad4ec..9290954 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/compare.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/compare.py
@@ -29,7 +29,7 @@ import sys
# Add this directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
-from fpga_model import SignalChain, sign_extend
+from fpga_model import SignalChain
# =============================================================================
@@ -107,7 +107,7 @@ def load_rtl_csv(filepath):
bb_i = []
bb_q = []
with open(filepath, 'r') as f:
- header = f.readline() # Skip header
+ f.readline() # Skip header
for line in f:
line = line.strip()
if not line:
@@ -280,7 +280,7 @@ def compare_scenario(scenario_name):
py_i_stats = compute_signal_stats(py_i)
py_q_stats = compute_signal_stats(py_q)
- print(f"\nSignal Statistics:")
+ print("\nSignal Statistics:")
print(f" RTL I: mean={rtl_i_stats['mean']:.1f}, rms={rtl_i_stats['rms']:.1f}, "
f"range=[{rtl_i_stats['min']}, {rtl_i_stats['max']}]")
print(f" RTL Q: mean={rtl_q_stats['mean']:.1f}, rms={rtl_q_stats['rms']:.1f}, "
@@ -352,12 +352,12 @@ def compare_scenario(scenario_name):
corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i)
corr_q_aligned = compute_correlation(aligned_rtl_q, aligned_py_q)
- print(f"\nError Metrics (after alignment):")
+ print("\nError Metrics (after alignment):")
print(f" I-channel: RMS={rms_i:.2f} LSB, max={max_err_i} LSB, corr={corr_i_aligned:.6f}")
print(f" Q-channel: RMS={rms_q:.2f} LSB, max={max_err_q} LSB, corr={corr_q_aligned:.6f}")
# ---- First/last sample comparison ----
- print(f"\nFirst 10 samples (after alignment):")
+ 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}")
for k in range(min(10, aligned_len)):
ei = aligned_rtl_i[k] - aligned_py_i[k]
@@ -444,7 +444,6 @@ def compare_scenario(scenario_name):
print("PASS/FAIL Results:")
all_pass = True
for name, ok, detail in results:
- status = "PASS" if ok else "FAIL"
mark = "[PASS]" if ok else "[FAIL]"
print(f" {mark} {name}: {detail}")
if not ok:
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py b/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py
index 585bc6e..3379ca5 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py
@@ -74,7 +74,7 @@ def load_doppler_csv(filepath):
"""
data = {}
with open(filepath, 'r') as f:
- header = f.readline()
+ f.readline() # Skip header
for line in f:
line = line.strip()
if not line:
@@ -163,11 +163,11 @@ def compare_scenario(name, config, base_dir):
if not os.path.exists(golden_path):
print(f" ERROR: Golden CSV not found: {golden_path}")
- print(f" Run: python3 gen_doppler_golden.py")
+ print(" Run: python3 gen_doppler_golden.py")
return False, {}
if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
- print(f" Run the Verilog testbench first")
+ print(" Run the Verilog testbench first")
return False, {}
py_data = load_doppler_csv(golden_path)
@@ -201,7 +201,7 @@ def compare_scenario(name, config, base_dir):
else:
energy_ratio = 1.0 if rtl_energy == 0 else float('inf')
- print(f"\n Global energy:")
+ print("\n Global energy:")
print(f" Python: {py_energy}")
print(f" RTL: {rtl_energy}")
print(f" Ratio: {energy_ratio:.4f}")
@@ -255,7 +255,7 @@ def compare_scenario(name, config, base_dir):
avg_corr_i = sum(i_correlations) / len(i_correlations)
avg_corr_q = sum(q_correlations) / len(q_correlations)
- print(f"\n Per-range-bin metrics:")
+ print("\n Per-range-bin metrics:")
print(f" Peak Doppler bin agreement (+/-1 within sub-frame): {peak_agreements}/{RANGE_BINS} "
f"({peak_agreement_frac:.0%})")
print(f" Avg magnitude correlation: {avg_mag_corr:.4f}")
@@ -263,7 +263,7 @@ def compare_scenario(name, config, base_dir):
print(f" Avg Q-channel correlation: {avg_corr_q:.4f}")
# Show top 5 range bins by Python energy
- print(f"\n Top 5 range bins by Python energy:")
+ print("\n Top 5 range bins by Python energy:")
top_rbins = sorted(peak_details, key=lambda x: -x['py_energy'])[:5]
for d in top_rbins:
print(f" rbin={d['rbin']:2d}: py_peak={d['py_peak']:2d}, "
@@ -291,7 +291,7 @@ def compare_scenario(name, config, base_dir):
checks.append((f'High-energy rbin avg mag_corr >= {MAG_CORR_MIN:.2f} '
f'(actual={he_mag_corr:.3f})', he_ok))
- print(f"\n Pass/Fail Checks:")
+ print("\n Pass/Fail Checks:")
all_pass = True
for check_name, passed in checks:
status = "PASS" if passed else "FAIL"
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py b/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py
index f64a578..5269e94 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py
@@ -80,7 +80,7 @@ def load_csv(filepath):
vals_i = []
vals_q = []
with open(filepath, 'r') as f:
- header = f.readline()
+ f.readline() # Skip header
for line in f:
line = line.strip()
if not line:
@@ -172,11 +172,11 @@ def compare_scenario(scenario_name, config, base_dir):
if not os.path.exists(golden_path):
print(f" ERROR: Golden CSV not found: {golden_path}")
- print(f" Run: python3 gen_mf_cosim_golden.py")
+ print(" Run: python3 gen_mf_cosim_golden.py")
return False, {}
if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
- print(f" Run the RTL testbench first")
+ print(" Run the RTL testbench first")
return False, {}
py_i, py_q = load_csv(golden_path)
@@ -205,7 +205,7 @@ def compare_scenario(scenario_name, config, base_dir):
energy_ratio = float('inf') if py_energy == 0 else 0.0
rms_ratio = float('inf') if py_rms == 0 else 0.0
- print(f"\n Energy:")
+ print("\n Energy:")
print(f" Python total energy: {py_energy}")
print(f" RTL total energy: {rtl_energy}")
print(f" Energy ratio (RTL/Py): {energy_ratio:.4f}")
@@ -217,7 +217,7 @@ def compare_scenario(scenario_name, config, base_dir):
py_peak_bin, py_peak_mag = find_peak(py_i, py_q)
rtl_peak_bin, rtl_peak_mag = find_peak(rtl_i, rtl_q)
- print(f"\n Peak location:")
+ print("\n Peak location:")
print(f" Python: bin={py_peak_bin}, mag={py_peak_mag}")
print(f" RTL: bin={rtl_peak_bin}, mag={rtl_peak_mag}")
@@ -242,7 +242,7 @@ def compare_scenario(scenario_name, config, base_dir):
corr_i = pearson_correlation(py_i, rtl_i)
corr_q = pearson_correlation(py_q, rtl_q)
- print(f"\n Channel correlation:")
+ print("\n Channel correlation:")
print(f" I-channel: {corr_i:.6f}")
print(f" Q-channel: {corr_q:.6f}")
@@ -278,7 +278,7 @@ def compare_scenario(scenario_name, config, base_dir):
energy_ok))
# Print checks
- print(f"\n Pass/Fail Checks:")
+ print("\n Pass/Fail Checks:")
all_pass = True
for name, passed in checks:
status = "PASS" if passed else "FAIL"
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py
index e626c6b..876d1f0 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py
@@ -19,7 +19,6 @@ Author: Phase 0.5 co-simulation suite for PLFM_RADAR
"""
import os
-import struct
# =============================================================================
# Fixed-point utility functions
@@ -196,7 +195,7 @@ class NCO:
if phase_valid:
# Stage 1 NBA: phase_accum_reg <= phase_accumulator (old value)
- new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF # old accum before add
+ _new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF # noqa: F841 — old accum before add (derivation reference)
# Wait - let me re-derive. The Verilog is:
# phase_accumulator <= phase_accumulator + frequency_tuning_word;
# phase_accum_reg <= phase_accumulator; // OLD value (NBA)
@@ -812,7 +811,6 @@ class FFTEngine:
# COMPUTE: LOG2N stages of butterflies
for stage in range(log2n):
half = 1 << stage
- span = half << 1
tw_stride = (n >> 1) >> stage
for bfly in range(n // 2):
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py
index b2d7cba..33c76ee 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py
@@ -134,7 +134,7 @@ def main():
print("AERIS-10 Chirp .mem File Generator")
print("=" * 60)
print()
- print(f"Parameters:")
+ print("Parameters:")
print(f" CHIRP_BW = {CHIRP_BW/1e6:.1f} MHz")
print(f" FS_SYS = {FS_SYS/1e6:.1f} MHz")
print(f" T_LONG_CHIRP = {T_LONG_CHIRP*1e6:.1f} us")
@@ -212,7 +212,7 @@ def main():
mismatches += 1
if mismatches == 0:
- print(f" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()")
+ print(" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()")
else:
print(f" [FAIL] Seg0 has {mismatches} mismatches vs generate_reference_chirp_q15()")
return 1
@@ -225,13 +225,13 @@ def main():
# Check seg3 zero padding
seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem')
with open(seg3_i_path, 'r') as f:
- seg3_lines = [l.strip() for l in f if l.strip()]
- nonzero_seg3 = sum(1 for l in seg3_lines if l != '0000')
+ seg3_lines = [line.strip() for line in f if line.strip()]
+ nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000')
print(f" Seg3 non-zero entries: {nonzero_seg3}/{len(seg3_lines)} "
f"(expected 0 since chirp ends at sample 2999)")
if nonzero_seg3 == 0:
- print(f" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)")
+ print(" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)")
else:
print(f" [WARN] Seg3 has {nonzero_seg3} non-zero entries")
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py
index e9668bd..f4fb3e2 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py
@@ -18,14 +18,13 @@ Usage:
Author: Phase 0.5 Doppler co-simulation suite for PLFM_RADAR
"""
-import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fpga_model import (
- DopplerProcessor, sign_extend, HAMMING_WINDOW
+ DopplerProcessor
)
from radar_scene import Target, generate_doppler_frame
@@ -121,7 +120,7 @@ def generate_scenario(name, targets, description, base_dir):
"""Generate input hex + golden output for one scenario."""
print(f"\n{'='*60}")
print(f"Scenario: {name} — {description}")
- print(f"Model: CLEAN (dual 16-pt FFT)")
+ print("Model: CLEAN (dual 16-pt FFT)")
print(f"{'='*60}")
# Generate Doppler frame (32 chirps x 64 range bins)
@@ -172,7 +171,7 @@ def generate_scenario(name, targets, description, base_dir):
write_hex_32bit(golden_hex, list(zip(flat_i, flat_q)))
# ---- Find peak per range bin ----
- print(f"\n Peak Doppler bins per range bin (top 5 by magnitude):")
+ print("\n Peak Doppler bins per range bin (top 5 by magnitude):")
peak_info = []
for rbin in range(RANGE_BINS):
mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d])
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py
index 31ef9de..dc5eaea 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py
@@ -25,8 +25,8 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fpga_model import (
- FFTEngine, FreqMatchedFilter, MatchedFilterChain,
- RangeBinDecimator, sign_extend, saturate
+ MatchedFilterChain,
+ sign_extend, saturate
)
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py
index ca07502..3f44726 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py
@@ -208,7 +208,6 @@ def generate_long_chirp_test():
input_buffer_i = [0] * BUFFER_SIZE
input_buffer_q = [0] * BUFFER_SIZE
buffer_write_ptr = 0
- current_segment = 0
input_idx = 0
chirp_samples_collected = 0
@@ -342,8 +341,9 @@ def generate_short_chirp_test():
input_q.append(saturate(val_q, 16))
# Zero-pad to 1024 (as RTL does in ST_ZERO_PAD)
- padded_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES)
- padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES)
+ # Note: padding computed here for documentation; actual buffer uses buf_i/buf_q below
+ _padded_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841
+ _padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841
# The buffer truncation: ddc_i[17:2] + ddc_i[1]
# For data already 16-bit sign-extended to 18: result is (val >> 2) + bit1
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py b/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py
index b786514..c0187e3 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py
@@ -21,7 +21,6 @@ Author: Phase 0.5 co-simulation suite for PLFM_RADAR
import math
import os
-import struct
# =============================================================================
@@ -156,7 +155,7 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
t = n / fs
# Instantaneous frequency: f_if - chirp_bw/2 + chirp_rate * t
# Phase: integral of 2*pi*f(t)*dt
- f_inst = f_if - chirp_bw / 2 + chirp_rate * t
+ _f_inst = f_if - chirp_bw / 2 + chirp_rate * t # noqa: F841 — documents instantaneous frequency formula
phase = 2 * math.pi * (f_if - chirp_bw / 2) * t + math.pi * chirp_rate * t * t
chirp_i.append(math.cos(phase))
chirp_q.append(math.sin(phase))
@@ -668,7 +667,7 @@ def generate_all_test_vectors(output_dir=None):
f.write(f" ADC: {FS_ADC/1e6:.0f} MSPS, {ADC_BITS}-bit\n")
f.write(f" Range resolution: {RANGE_RESOLUTION:.1f} m\n")
f.write(f" Wavelength: {WAVELENGTH*1000:.2f} mm\n")
- f.write(f"\n")
+ f.write("\n")
f.write("Scenario 1: Single target\n")
for t in targets1:
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py b/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py
index b30e0fb..008d325 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py
@@ -20,7 +20,6 @@ Usage:
import numpy as np
import os
-import sys
import argparse
# ===========================================================================
@@ -787,7 +786,7 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
if not enable:
mti_i[:] = decim_i
mti_q[:] = decim_q
- print(f" Pass-through mode (MTI disabled)")
+ print(" Pass-through mode (MTI disabled)")
return mti_i, mti_q
for c in range(n_chirps):
@@ -803,7 +802,7 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
mti_i[c, r] = saturate(diff_i, 16)
mti_q[c, r] = saturate(diff_q, 16)
- print(f" Chirp 0: muted (zeros)")
+ print(" Chirp 0: muted (zeros)")
print(f" Chirps 1-{n_chirps-1}: I range [{mti_i[1:].min()}, {mti_i[1:].max()}], "
f"Q range [{mti_q[1:].min()}, {mti_q[1:].max()}]")
return mti_i, mti_q
@@ -839,7 +838,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
print(f"[DC NOTCH] width={width}, {n_range} range bins x {n_doppler} Doppler bins (dual sub-frame)")
if width == 0:
- print(f" Pass-through (width=0)")
+ print(" Pass-through (width=0)")
return notched_i, notched_q
zeroed_count = 0
@@ -1029,7 +1028,7 @@ def run_float_reference(iq_i, iq_q):
Uses the exact same RTL Hamming window coefficients (Q15) to isolate
only the FFT fixed-point quantization error.
"""
- print(f"\n[FLOAT REF] Running floating-point reference pipeline")
+ print("\n[FLOAT REF] Running floating-point reference pipeline")
n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i)
@@ -1384,10 +1383,10 @@ def main():
cfar_detections = np.argwhere(cfar_flags)
cfar_det_list_file = os.path.join(output_dir, "fullchain_cfar_detections.txt")
with open(cfar_det_list_file, 'w') as f:
- f.write(f"# AERIS-10 Full-Chain CFAR Detection List\n")
+ 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"# Format: range_bin doppler_bin magnitude threshold\n")
+ f.write("# Format: range_bin doppler_bin magnitude threshold\n")
for det in cfar_detections:
r, d = det
f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n")
@@ -1406,9 +1405,9 @@ def main():
# Save full-chain detection reference
fc_det_file = os.path.join(output_dir, "fullchain_detections.txt")
with open(fc_det_file, 'w') as f:
- f.write(f"# AERIS-10 Full-Chain Golden Reference Detections\n")
+ f.write("# AERIS-10 Full-Chain Golden Reference Detections\n")
f.write(f"# Threshold: {args.threshold}\n")
- f.write(f"# Format: range_bin doppler_bin magnitude\n")
+ f.write("# Format: range_bin doppler_bin magnitude\n")
for d in fc_detections:
rbin, dbin = d
f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n")
@@ -1433,9 +1432,9 @@ def main():
# Save detection list
det_file = os.path.join(output_dir, "detections.txt")
with open(det_file, 'w') as f:
- f.write(f"# AERIS-10 Golden Reference Detections\n")
+ f.write("# AERIS-10 Golden Reference Detections\n")
f.write(f"# Threshold: {args.threshold}\n")
- f.write(f"# Format: range_bin doppler_bin magnitude\n")
+ f.write("# Format: range_bin doppler_bin magnitude\n")
for d in detections:
rbin, dbin = d
f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n")
@@ -1484,12 +1483,12 @@ def main():
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" Detections (direct): {len(detections)} (threshold={args.threshold})")
- print(f" Full-chain decimator: 1024→64 peak detection")
+ 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" Hex stimulus files: {output_dir}/")
- print(f" Ready for RTL co-simulation with Icarus Verilog")
+ print(" Ready for RTL co-simulation with Icarus Verilog")
# -----------------------------------------------------------------------
# Optional plots
diff --git a/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py b/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py
index 160c712..d9e71dc 100644
--- a/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py
+++ b/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py
@@ -206,7 +206,7 @@ def test_long_chirp():
expected_max_from_model = 32767 * 0.9
uses_model_scaling = max_mag > expected_max_from_model * 0.8
if uses_model_scaling:
- print(f" Scaling: CONSISTENT with radar_scene.py model (0.9 * Q15)")
+ print(" Scaling: CONSISTENT with radar_scene.py model (0.9 * Q15)")
else:
warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model "
f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.")
@@ -246,7 +246,7 @@ def test_long_chirp():
f_max = max(freq_estimates)
f_range = f_max - f_min
- print(f"\n Instantaneous frequency analysis (post-DDC baseband):")
+ print("\n Instantaneous frequency analysis (post-DDC baseband):")
print(f" Start freq: {f_start/1e6:.3f} MHz")
print(f" End freq: {f_end/1e6:.3f} MHz")
print(f" Min freq: {f_min/1e6:.3f} MHz")
@@ -269,7 +269,7 @@ def test_long_chirp():
# Compare segment boundaries for overlap-save consistency
# In proper overlap-save, the chirp data should be segmented at 896-sample boundaries
# with segments being 1024-sample FFT blocks
- print(f"\n Segment boundary analysis:")
+ print("\n Segment boundary analysis:")
for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
@@ -290,9 +290,9 @@ def test_long_chirp():
print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}, "
f"near-zero={zero_count}/{len(seg_mags)}")
if zero_count > 500:
- print(f" -> Seg 3 mostly zeros (chirp shorter than 4096 samples)")
+ print(" -> Seg 3 mostly zeros (chirp shorter than 4096 samples)")
else:
- print(f" -> Seg 3 has significant data throughout")
+ print(" -> Seg 3 has significant data throughout")
else:
print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}")
@@ -330,8 +330,10 @@ def test_short_chirp():
freq_est = []
for n in range(1, len(phases)):
dp = phases[n] - phases[n-1]
- while dp > math.pi: dp -= 2 * math.pi
- while dp < -math.pi: dp += 2 * math.pi
+ while dp > math.pi:
+ dp -= 2 * math.pi
+ while dp < -math.pi:
+ dp += 2 * math.pi
freq_est.append(dp * FS_SYS / (2 * math.pi))
if freq_est:
@@ -382,9 +384,9 @@ def test_chirp_vs_model():
print(f" Exact I matches: {matches}/{len(model_i)}")
if matches > len(model_i) * 0.9:
- print(f" -> .mem files MATCH Python model")
+ print(" -> .mem files MATCH Python model")
else:
- warn(f".mem files do NOT match Python model. They likely have different provenance.")
+ warn(".mem files do NOT match Python model. They likely have different provenance.")
# Try to detect scaling
if mem_max > 0:
ratio = model_max / mem_max
@@ -399,14 +401,16 @@ def test_chirp_vs_model():
phase_diffs = []
for mp, fp in zip(model_phases, mem_phases):
d = mp - fp
- while d > math.pi: d -= 2 * math.pi
- while d < -math.pi: d += 2 * math.pi
+ while d > math.pi:
+ d -= 2 * math.pi
+ while d < -math.pi:
+ d += 2 * math.pi
phase_diffs.append(d)
avg_phase_diff = sum(phase_diffs) / len(phase_diffs)
max_phase_diff = max(abs(d) for d in phase_diffs)
- print(f"\n Phase comparison (shape regardless of amplitude):")
+ print("\n Phase comparison (shape regardless of amplitude):")
print(f" Avg phase diff: {avg_phase_diff:.4f} rad ({math.degrees(avg_phase_diff):.2f} deg)")
print(f" Max phase diff: {max_phase_diff:.4f} rad ({math.degrees(max_phase_diff):.2f} deg)")
diff --git a/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py b/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py
index 4781949..e3f7d52 100644
--- a/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py
+++ b/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py
@@ -91,7 +91,7 @@ def generate_case(case_num, sig_i, sig_q, ref_i, ref_q, description, outdir):
peak_q_q = out_q_q[peak_bin]
# Write hex files
- prefix = os.path.join(outdir, f"mf_golden")
+ prefix = os.path.join(outdir, "mf_golden")
write_hex_file(f"{prefix}_sig_i_case{case_num}.hex", sig_i)
write_hex_file(f"{prefix}_sig_q_case{case_num}.hex", sig_q)
write_hex_file(f"{prefix}_ref_i_case{case_num}.hex", ref_i)
@@ -233,7 +233,7 @@ def main():
f.write(f" Peak Q (float): {s['peak_q_float']:.6f}\n")
f.write(f" Peak I (quantized): {s['peak_i_quant']}\n")
f.write(f" Peak Q (quantized): {s['peak_q_quant']}\n")
- f.write(f" Files:\n")
+ f.write(" Files:\n")
for fname in s["files"]:
f.write(f" {fname}\n")
f.write("\n")
diff --git a/9_Firmware/9_3_GUI/GUI_PyQt_Map.py b/9_Firmware/9_3_GUI/GUI_PyQt_Map.py
new file mode 100644
index 0000000..703e58c
--- /dev/null
+++ b/9_Firmware/9_3_GUI/GUI_PyQt_Map.py
@@ -0,0 +1,1662 @@
+#!/usr/bin/env python3
+"""
+PLFM Radar Dashboard - PyQt6 Edition with Embedded Leaflet Map
+===============================================================
+A professional-grade radar tracking GUI using PyQt6 with embedded web-based
+Leaflet.js maps for real-time target visualization.
+
+Features:
+- Embedded interactive Leaflet map with OpenStreetMap tiles
+- Real-time target tracking and visualization
+- Python-to-JavaScript bridge for seamless updates
+- Dark theme UI matching existing radar dashboard style
+- Support for multiple tile servers (OSM, Google, satellite)
+- Marker clustering for dense target environments
+- Coverage area visualization
+- Target trails/history
+
+Author: PLFM Radar Team
+Version: 1.0.0
+"""
+
+import sys
+import json
+import math
+import time
+import random
+import logging
+from dataclasses import dataclass, asdict
+from typing import List, Dict, Optional, Tuple
+from enum import Enum
+
+# PyQt6 imports
+from PyQt6.QtWidgets import (
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
+ QTabWidget, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox,
+ QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox, QTableWidget, QTableWidgetItem,
+ QHeaderView
+)
+from PyQt6.QtCore import (
+ Qt, QTimer, pyqtSignal, pyqtSlot, QObject
+)
+from PyQt6.QtGui import (
+ QFont, QColor, QPalette
+)
+from PyQt6.QtWebEngineWidgets import QWebEngineView
+from PyQt6.QtWebChannel import QWebChannel
+
+# Configure logging
+logging.basicConfig(
+ level=logging.INFO,
+ format='%(asctime)s - %(levelname)s - %(message)s'
+)
+logger = logging.getLogger(__name__)
+
+# =============================================================================
+# Dark Theme Colors (matching existing radar dashboard)
+# =============================================================================
+DARK_BG = "#2b2b2b"
+DARK_FG = "#e0e0e0"
+DARK_ACCENT = "#3c3f41"
+DARK_HIGHLIGHT = "#4e5254"
+DARK_BORDER = "#555555"
+DARK_TEXT = "#cccccc"
+DARK_BUTTON = "#3c3f41"
+DARK_BUTTON_HOVER = "#4e5254"
+DARK_SUCCESS = "#4CAF50"
+DARK_WARNING = "#FFC107"
+DARK_ERROR = "#F44336"
+DARK_INFO = "#2196F3"
+
+# =============================================================================
+# Data Classes
+# =============================================================================
+@dataclass
+class RadarTarget:
+ """Represents a detected radar target"""
+ id: int
+ range: float # Range in meters
+ velocity: float # Velocity in m/s (positive = approaching)
+ azimuth: float # Azimuth angle in degrees
+ elevation: float # Elevation angle in degrees
+ latitude: float = 0.0
+ longitude: float = 0.0
+ snr: float = 0.0 # Signal-to-noise ratio in dB
+ timestamp: float = 0.0
+ track_id: int = -1
+ classification: str = "unknown"
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for JSON serialization"""
+ return asdict(self)
+
+
+@dataclass
+class GPSData:
+ """GPS position and orientation data"""
+ latitude: float
+ longitude: float
+ altitude: float
+ pitch: float # Pitch angle in degrees
+ heading: float = 0.0 # Heading in degrees (0 = North)
+ timestamp: float = 0.0
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+@dataclass
+class RadarSettings:
+ """Radar system configuration"""
+ 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)
+ max_distance: float = 50000 # Max detection range (m)
+ coverage_radius: float = 50000 # Map coverage radius (m)
+
+
+class TileServer(Enum):
+ """Available map tile servers"""
+ OPENSTREETMAP = "osm"
+ GOOGLE_MAPS = "google"
+ GOOGLE_SATELLITE = "google_sat"
+ GOOGLE_HYBRID = "google_hybrid"
+ ESRI_SATELLITE = "esri_sat"
+
+
+# =============================================================================
+# JavaScript Bridge - Enables Python <-> JavaScript communication
+# =============================================================================
+class MapBridge(QObject):
+ """
+ Bridge object exposed to JavaScript for bidirectional communication.
+ This allows Python to call JavaScript functions and vice versa.
+ """
+
+ # Signals emitted when JS calls Python
+ mapClicked = pyqtSignal(float, float) # lat, lon
+ markerClicked = pyqtSignal(int) # target_id
+ mapReady = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._map_ready = False
+
+ @pyqtSlot(float, float)
+ def onMapClick(self, lat: float, lon: float):
+ """Called from JavaScript when map is clicked"""
+ logger.debug(f"Map clicked at: {lat}, {lon}")
+ self.mapClicked.emit(lat, lon)
+
+ @pyqtSlot(int)
+ def onMarkerClick(self, target_id: int):
+ """Called from JavaScript when a target marker is clicked"""
+ logger.debug(f"Marker clicked: Target #{target_id}")
+ self.markerClicked.emit(target_id)
+
+ @pyqtSlot()
+ def onMapReady(self):
+ """Called from JavaScript when map is fully initialized"""
+ logger.info("Map is ready")
+ self._map_ready = True
+ self.mapReady.emit()
+
+ @pyqtSlot(str)
+ def logFromJS(self, message: str):
+ """Receive log messages from JavaScript"""
+ logger.debug(f"[JS] {message}")
+
+ @property
+ def is_ready(self) -> bool:
+ return self._map_ready
+
+
+# =============================================================================
+# Map Widget - Embedded Leaflet Map
+# =============================================================================
+class RadarMapWidget(QWidget):
+ """
+ Custom widget embedding a Leaflet.js map via QWebEngineView.
+ Provides methods for updating radar position, targets, and coverage.
+ """
+
+ targetSelected = pyqtSignal(int) # Emitted when a target is selected
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+
+ # State
+ self._radar_position = GPSData(
+ latitude=40.7128, # Default: New York City
+ longitude=-74.0060,
+ altitude=100.0,
+ pitch=0.0
+ )
+ 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]]] = {}
+
+ # Setup UI
+ self._setup_ui()
+
+ # Setup bridge
+ self._bridge = MapBridge(self)
+ self._bridge.mapReady.connect(self._on_map_ready)
+ self._bridge.markerClicked.connect(self._on_marker_clicked)
+
+ # Setup web channel
+ self._channel = QWebChannel()
+ self._channel.registerObject("bridge", self._bridge)
+ self._web_view.page().setWebChannel(self._channel)
+
+ # Load map
+ self._load_map()
+
+ def _setup_ui(self):
+ """Setup the widget UI"""
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Control bar
+ control_bar = QFrame()
+ control_bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;")
+ control_layout = QHBoxLayout(control_bar)
+ control_layout.setContentsMargins(8, 4, 8, 4)
+
+ # Tile server selector
+ self._tile_combo = QComboBox()
+ self._tile_combo.addItem("OpenStreetMap", TileServer.OPENSTREETMAP)
+ self._tile_combo.addItem("Google Maps", TileServer.GOOGLE_MAPS)
+ self._tile_combo.addItem("Google Satellite", TileServer.GOOGLE_SATELLITE)
+ self._tile_combo.addItem("Google Hybrid", TileServer.GOOGLE_HYBRID)
+ self._tile_combo.addItem("ESRI Satellite", TileServer.ESRI_SATELLITE)
+ self._tile_combo.currentIndexChanged.connect(self._on_tile_server_changed)
+ self._tile_combo.setStyleSheet(f"""
+ QComboBox {{
+ background-color: {DARK_BUTTON};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 4px 8px;
+ border-radius: 4px;
+ }}
+ """)
+ control_layout.addWidget(QLabel("Tiles:"))
+ control_layout.addWidget(self._tile_combo)
+
+ # Coverage toggle
+ self._coverage_check = QCheckBox("Show Coverage")
+ self._coverage_check.setChecked(True)
+ self._coverage_check.stateChanged.connect(self._on_coverage_toggled)
+ control_layout.addWidget(self._coverage_check)
+
+ # Trails toggle
+ self._trails_check = QCheckBox("Show Trails")
+ self._trails_check.setChecked(False)
+ self._trails_check.stateChanged.connect(self._on_trails_toggled)
+ control_layout.addWidget(self._trails_check)
+
+ # Center on radar button
+ center_btn = QPushButton("Center on Radar")
+ center_btn.clicked.connect(self._center_on_radar)
+ center_btn.setStyleSheet(f"""
+ QPushButton {{
+ background-color: {DARK_BUTTON};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 4px 12px;
+ border-radius: 4px;
+ }}
+ QPushButton:hover {{
+ background-color: {DARK_BUTTON_HOVER};
+ }}
+ """)
+ control_layout.addWidget(center_btn)
+
+ # Fit all button
+ fit_btn = QPushButton("Fit All Targets")
+ fit_btn.clicked.connect(self._fit_all_targets)
+ fit_btn.setStyleSheet(center_btn.styleSheet())
+ control_layout.addWidget(fit_btn)
+
+ control_layout.addStretch()
+
+ # Status label
+ self._status_label = QLabel("Initializing map...")
+ self._status_label.setStyleSheet(f"color: {DARK_INFO};")
+ control_layout.addWidget(self._status_label)
+
+ layout.addWidget(control_bar)
+
+ # Web view for map
+ self._web_view = QWebEngineView()
+ self._web_view.setMinimumSize(400, 300)
+ layout.addWidget(self._web_view, stretch=1)
+
+ def _get_map_html(self) -> str:
+ """Generate the complete HTML for the Leaflet map"""
+ return f'''
+
+
+
+
+ Radar Map
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+'''
+
+ def _load_map(self):
+ """Load the map HTML into the web view"""
+ html = self._get_map_html()
+ self._web_view.setHtml(html)
+ logger.info("Map HTML loaded")
+
+ def _on_map_ready(self):
+ """Called when the map is fully initialized"""
+ self._status_label.setText(f"Map ready - {len(self._targets)} targets")
+ self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
+ logger.info("Map widget ready")
+
+ def _on_marker_clicked(self, target_id: int):
+ """Handle marker click events"""
+ self.targetSelected.emit(target_id)
+
+ def _on_tile_server_changed(self, index: int):
+ """Handle tile server change"""
+ server = self._tile_combo.currentData()
+ self._tile_server = server
+ self._run_js(f"setTileServer('{server.value}')")
+
+ def _on_coverage_toggled(self, state: int):
+ """Handle coverage visibility toggle"""
+ visible = state == Qt.CheckState.Checked.value
+ self._show_coverage = visible
+ self._run_js(f"setCoverageVisible({str(visible).lower()})")
+
+ def _on_trails_toggled(self, state: int):
+ """Handle trails visibility toggle"""
+ visible = state == Qt.CheckState.Checked.value
+ self._show_trails = visible
+ self._run_js(f"setTrailsVisible({str(visible).lower()})")
+
+ def _center_on_radar(self):
+ """Center map view on radar position"""
+ self._run_js("centerOnRadar()")
+
+ def _fit_all_targets(self):
+ """Fit map view to show all targets"""
+ self._run_js("fitAllTargets()")
+
+ def _run_js(self, script: str):
+ """Execute JavaScript in the web view"""
+ self._web_view.page().runJavaScript(script)
+
+ # Public API
+ def set_radar_position(self, gps_data: GPSData):
+ """Update the radar position on the map"""
+ self._radar_position = gps_data
+ self._run_js(
+ f"updateRadarPosition({gps_data.latitude}, {gps_data.longitude}, "
+ f"{gps_data.altitude}, {gps_data.pitch}, {gps_data.heading})"
+ )
+
+ def set_targets(self, targets: List[RadarTarget]):
+ """Update all targets on the map"""
+ self._targets = targets
+
+ # Convert targets to JSON
+ targets_data = [t.to_dict() for t in targets]
+ targets_json = json.dumps(targets_data)
+
+ # Update status
+ self._status_label.setText(f"{len(targets)} targets tracked")
+
+ # Call JavaScript update function
+ self._run_js(f"updateTargets('{targets_json}')")
+
+ def set_coverage_radius(self, radius: float):
+ """Set the coverage circle radius in meters"""
+ self._coverage_radius = radius
+ self._run_js(f"setCoverageRadius({radius})")
+
+ def set_zoom(self, level: int):
+ """Set map zoom level (0-19)"""
+ level = max(0, min(19, level))
+ self._run_js(f"setZoom({level})")
+
+
+# =============================================================================
+# Utility Functions
+# =============================================================================
+def polar_to_geographic(
+ radar_lat: float,
+ radar_lon: float,
+ range_m: float,
+ azimuth_deg: float
+) -> Tuple[float, float]:
+ """
+ Convert polar coordinates (range, azimuth) relative to radar
+ to geographic coordinates (latitude, longitude).
+
+ Args:
+ radar_lat: Radar latitude in degrees
+ radar_lon: Radar longitude in degrees
+ range_m: Range from radar in meters
+ azimuth_deg: Azimuth angle in degrees (0 = North, clockwise)
+
+ Returns:
+ Tuple of (latitude, longitude) for the target
+ """
+ # Earth's radius in meters
+ R = 6371000
+
+ # Convert to radians
+ lat1 = math.radians(radar_lat)
+ lon1 = math.radians(radar_lon)
+ bearing = math.radians(azimuth_deg)
+
+ # Calculate new position
+ lat2 = math.asin(
+ math.sin(lat1) * math.cos(range_m / R) +
+ math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing)
+ )
+
+ lon2 = lon1 + math.atan2(
+ math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1),
+ math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2)
+ )
+
+ return (math.degrees(lat2), math.degrees(lon2))
+
+
+# =============================================================================
+# Target Simulator (Demo Mode)
+# =============================================================================
+class TargetSimulator(QObject):
+ """Simulates radar targets for demonstration purposes"""
+
+ targetsUpdated = pyqtSignal(list) # Emits list of RadarTarget
+
+ def __init__(self, radar_position: GPSData, parent=None):
+ super().__init__(parent)
+
+ self._radar_position = radar_position
+ self._targets: List[RadarTarget] = []
+ self._next_id = 1
+ self._timer = QTimer()
+ self._timer.timeout.connect(self._update_targets)
+
+ # Initialize some targets
+ self._initialize_targets()
+
+ def _initialize_targets(self, count: int = 8):
+ """Create initial set of simulated targets"""
+ for _ in range(count):
+ self._add_random_target()
+
+ def _add_random_target(self):
+ """Add a new random target"""
+ # Random range between 5km and 40km
+ range_m = random.uniform(5000, 40000)
+
+ # Random azimuth
+ azimuth = random.uniform(0, 360)
+
+ # Random velocity (-100 to +100 m/s)
+ velocity = random.uniform(-100, 100)
+
+ # Random elevation
+ elevation = random.uniform(-5, 45)
+
+ # Calculate geographic position
+ lat, lon = polar_to_geographic(
+ self._radar_position.latitude,
+ self._radar_position.longitude,
+ range_m,
+ azimuth
+ )
+
+ target = RadarTarget(
+ id=self._next_id,
+ range=range_m,
+ velocity=velocity,
+ azimuth=azimuth,
+ elevation=elevation,
+ latitude=lat,
+ longitude=lon,
+ snr=random.uniform(10, 35),
+ timestamp=time.time(),
+ track_id=self._next_id,
+ classification=random.choice(["aircraft", "drone", "bird", "unknown"])
+ )
+
+ self._next_id += 1
+ self._targets.append(target)
+
+ def _update_targets(self):
+ """Update target positions (called by timer)"""
+ updated_targets = []
+
+ for target in self._targets:
+ # Update range based on velocity
+ new_range = target.range - target.velocity * 0.5 # 0.5 second update
+
+ # Check if target is still in range
+ if new_range < 500 or new_range > 50000:
+ # Remove this target and add a new one
+ continue
+
+ # Slightly vary velocity
+ new_velocity = target.velocity + random.uniform(-2, 2)
+ new_velocity = max(-150, min(150, new_velocity))
+
+ # Slightly vary azimuth (simulate turning)
+ new_azimuth = (target.azimuth + random.uniform(-0.5, 0.5)) % 360
+
+ # Calculate new geographic position
+ lat, lon = polar_to_geographic(
+ self._radar_position.latitude,
+ self._radar_position.longitude,
+ new_range,
+ new_azimuth
+ )
+
+ updated_target = RadarTarget(
+ id=target.id,
+ range=new_range,
+ velocity=new_velocity,
+ azimuth=new_azimuth,
+ elevation=target.elevation + random.uniform(-0.1, 0.1),
+ latitude=lat,
+ longitude=lon,
+ snr=target.snr + random.uniform(-1, 1),
+ timestamp=time.time(),
+ track_id=target.track_id,
+ classification=target.classification
+ )
+
+ updated_targets.append(updated_target)
+
+ # Occasionally add new targets
+ if len(updated_targets) < 5 or (random.random() < 0.05 and len(updated_targets) < 15):
+ self._add_random_target()
+ updated_targets.append(self._targets[-1])
+
+ self._targets = updated_targets
+ self.targetsUpdated.emit(updated_targets)
+
+ def start(self, interval_ms: int = 500):
+ """Start the simulation"""
+ self._timer.start(interval_ms)
+
+ def stop(self):
+ """Stop the simulation"""
+ self._timer.stop()
+
+ def set_radar_position(self, gps_data: GPSData):
+ """Update radar position"""
+ self._radar_position = gps_data
+
+
+# =============================================================================
+# Main Dashboard Window
+# =============================================================================
+class RadarDashboard(QMainWindow):
+ """Main application window for the radar dashboard"""
+
+ def __init__(self):
+ super().__init__()
+
+ # State
+ self._radar_position = GPSData(
+ latitude=40.7128,
+ longitude=-74.0060,
+ altitude=100.0,
+ pitch=0.0,
+ heading=0.0,
+ timestamp=time.time()
+ )
+ self._settings = RadarSettings()
+ self._simulator: Optional[TargetSimulator] = None
+ self._demo_mode = True
+
+ # Setup UI
+ self._setup_window()
+ self._setup_dark_theme()
+ self._setup_ui()
+ self._setup_statusbar()
+
+ # Start demo mode
+ self._start_demo_mode()
+
+ def _setup_window(self):
+ """Configure main window properties"""
+ self.setWindowTitle("PLFM Radar Dashboard - PyQt6 Edition")
+ self.setMinimumSize(1200, 800)
+ self.resize(1400, 900)
+
+ def _setup_dark_theme(self):
+ """Apply dark theme to the application"""
+ palette = QPalette()
+ palette.setColor(QPalette.ColorRole.Window, QColor(DARK_BG))
+ palette.setColor(QPalette.ColorRole.WindowText, QColor(DARK_FG))
+ palette.setColor(QPalette.ColorRole.Base, QColor(DARK_ACCENT))
+ palette.setColor(QPalette.ColorRole.AlternateBase, QColor(DARK_HIGHLIGHT))
+ palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(DARK_ACCENT))
+ palette.setColor(QPalette.ColorRole.ToolTipText, QColor(DARK_FG))
+ palette.setColor(QPalette.ColorRole.Text, QColor(DARK_FG))
+ palette.setColor(QPalette.ColorRole.Button, QColor(DARK_BUTTON))
+ palette.setColor(QPalette.ColorRole.ButtonText, QColor(DARK_FG))
+ palette.setColor(QPalette.ColorRole.BrightText, QColor(DARK_FG))
+ palette.setColor(QPalette.ColorRole.Highlight, QColor(DARK_INFO))
+ palette.setColor(QPalette.ColorRole.HighlightedText, QColor(DARK_FG))
+
+ self.setPalette(palette)
+
+ # Global stylesheet
+ self.setStyleSheet(f"""
+ QMainWindow {{
+ background-color: {DARK_BG};
+ }}
+
+ QTabWidget::pane {{
+ border: 1px solid {DARK_BORDER};
+ background-color: {DARK_BG};
+ }}
+
+ QTabBar::tab {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ padding: 8px 20px;
+ margin-right: 2px;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }}
+
+ QTabBar::tab:selected {{
+ background-color: {DARK_HIGHLIGHT};
+ border-bottom: 2px solid {DARK_INFO};
+ }}
+
+ QTabBar::tab:hover {{
+ background-color: {DARK_BUTTON_HOVER};
+ }}
+
+ QGroupBox {{
+ font-weight: bold;
+ border: 1px solid {DARK_BORDER};
+ border-radius: 6px;
+ margin-top: 12px;
+ padding-top: 10px;
+ background-color: {DARK_ACCENT};
+ }}
+
+ QGroupBox::title {{
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 8px;
+ color: {DARK_INFO};
+ }}
+
+ QPushButton {{
+ background-color: {DARK_BUTTON};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 6px 16px;
+ border-radius: 4px;
+ font-weight: 500;
+ }}
+
+ QPushButton:hover {{
+ background-color: {DARK_BUTTON_HOVER};
+ }}
+
+ QPushButton:pressed {{
+ background-color: {DARK_HIGHLIGHT};
+ }}
+
+ QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 4px 8px;
+ border-radius: 4px;
+ }}
+
+ QLabel {{
+ color: {DARK_FG};
+ }}
+
+ QTableWidget {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ gridline-color: {DARK_BORDER};
+ border: 1px solid {DARK_BORDER};
+ }}
+
+ QTableWidget::item {{
+ padding: 4px;
+ }}
+
+ QTableWidget::item:selected {{
+ background-color: {DARK_INFO};
+ }}
+
+ QHeaderView::section {{
+ background-color: {DARK_HIGHLIGHT};
+ color: {DARK_FG};
+ padding: 6px;
+ border: none;
+ border-right: 1px solid {DARK_BORDER};
+ }}
+
+ QScrollBar:vertical {{
+ background-color: {DARK_ACCENT};
+ width: 12px;
+ margin: 0;
+ }}
+
+ QScrollBar::handle:vertical {{
+ background-color: {DARK_HIGHLIGHT};
+ border-radius: 6px;
+ min-height: 20px;
+ }}
+
+ QStatusBar {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ }}
+ """)
+
+ def _setup_ui(self):
+ """Setup the main UI layout"""
+ # Central widget
+ central_widget = QWidget()
+ self.setCentralWidget(central_widget)
+
+ main_layout = QVBoxLayout(central_widget)
+ main_layout.setContentsMargins(8, 8, 8, 8)
+ main_layout.setSpacing(8)
+
+ # Tab widget
+ self._tabs = QTabWidget()
+ main_layout.addWidget(self._tabs)
+
+ # Create tabs
+ self._create_map_tab()
+ self._create_targets_tab()
+ self._create_settings_tab()
+
+ def _create_map_tab(self):
+ """Create the map visualization tab"""
+ tab = QWidget()
+ layout = QHBoxLayout(tab)
+ layout.setContentsMargins(4, 4, 4, 4)
+
+ # Splitter for map and sidebar
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+
+ # Map widget (main area)
+ self._map_widget = RadarMapWidget()
+ self._map_widget.targetSelected.connect(self._on_target_selected)
+ splitter.addWidget(self._map_widget)
+
+ # Sidebar
+ sidebar = QWidget()
+ sidebar.setMaximumWidth(320)
+ sidebar.setMinimumWidth(280)
+ sidebar_layout = QVBoxLayout(sidebar)
+ sidebar_layout.setContentsMargins(8, 8, 8, 8)
+
+ # Radar Position Group
+ pos_group = QGroupBox("Radar Position")
+ pos_layout = QGridLayout(pos_group)
+
+ self._lat_spin = QDoubleSpinBox()
+ self._lat_spin.setRange(-90, 90)
+ self._lat_spin.setDecimals(6)
+ self._lat_spin.setValue(self._radar_position.latitude)
+ self._lat_spin.valueChanged.connect(self._on_position_changed)
+
+ self._lon_spin = QDoubleSpinBox()
+ self._lon_spin.setRange(-180, 180)
+ self._lon_spin.setDecimals(6)
+ self._lon_spin.setValue(self._radar_position.longitude)
+ self._lon_spin.valueChanged.connect(self._on_position_changed)
+
+ self._alt_spin = QDoubleSpinBox()
+ self._alt_spin.setRange(0, 50000)
+ self._alt_spin.setDecimals(1)
+ self._alt_spin.setValue(self._radar_position.altitude)
+ self._alt_spin.setSuffix(" m")
+
+ pos_layout.addWidget(QLabel("Latitude:"), 0, 0)
+ pos_layout.addWidget(self._lat_spin, 0, 1)
+ pos_layout.addWidget(QLabel("Longitude:"), 1, 0)
+ pos_layout.addWidget(self._lon_spin, 1, 1)
+ pos_layout.addWidget(QLabel("Altitude:"), 2, 0)
+ pos_layout.addWidget(self._alt_spin, 2, 1)
+
+ sidebar_layout.addWidget(pos_group)
+
+ # Coverage Group
+ coverage_group = QGroupBox("Coverage")
+ coverage_layout = QGridLayout(coverage_group)
+
+ self._coverage_spin = QDoubleSpinBox()
+ self._coverage_spin.setRange(1, 100)
+ self._coverage_spin.setDecimals(1)
+ self._coverage_spin.setValue(self._settings.coverage_radius / 1000)
+ self._coverage_spin.setSuffix(" km")
+ self._coverage_spin.valueChanged.connect(self._on_coverage_changed)
+
+ coverage_layout.addWidget(QLabel("Radius:"), 0, 0)
+ coverage_layout.addWidget(self._coverage_spin, 0, 1)
+
+ sidebar_layout.addWidget(coverage_group)
+
+ # Demo Controls
+ demo_group = QGroupBox("Demo Mode")
+ demo_layout = QVBoxLayout(demo_group)
+
+ self._demo_btn = QPushButton("Stop Demo")
+ self._demo_btn.setCheckable(True)
+ self._demo_btn.setChecked(True)
+ self._demo_btn.clicked.connect(self._toggle_demo_mode)
+ demo_layout.addWidget(self._demo_btn)
+
+ add_target_btn = QPushButton("Add Random Target")
+ add_target_btn.clicked.connect(self._add_demo_target)
+ demo_layout.addWidget(add_target_btn)
+
+ sidebar_layout.addWidget(demo_group)
+
+ # Target Info
+ info_group = QGroupBox("Selected Target")
+ info_layout = QVBoxLayout(info_group)
+
+ self._target_info_label = QLabel("No target selected")
+ self._target_info_label.setWordWrap(True)
+ self._target_info_label.setStyleSheet(f"color: {DARK_TEXT}; padding: 8px;")
+ info_layout.addWidget(self._target_info_label)
+
+ sidebar_layout.addWidget(info_group)
+
+ sidebar_layout.addStretch()
+
+ splitter.addWidget(sidebar)
+ splitter.setSizes([900, 300])
+
+ layout.addWidget(splitter)
+
+ self._tabs.addTab(tab, "Map View")
+
+ def _create_targets_tab(self):
+ """Create the targets table tab"""
+ tab = QWidget()
+ layout = QVBoxLayout(tab)
+ layout.setContentsMargins(8, 8, 8, 8)
+
+ # Targets table
+ self._targets_table = QTableWidget()
+ self._targets_table.setColumnCount(9)
+ self._targets_table.setHorizontalHeaderLabels([
+ "ID", "Track", "Range (m)", "Velocity (m/s)",
+ "Azimuth (°)", "Elevation (°)", "SNR (dB)",
+ "Classification", "Status"
+ ])
+
+ header = self._targets_table.horizontalHeader()
+ header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+
+ self._targets_table.setSelectionBehavior(
+ QTableWidget.SelectionBehavior.SelectRows
+ )
+ self._targets_table.setAlternatingRowColors(True)
+
+ layout.addWidget(self._targets_table)
+
+ self._tabs.addTab(tab, "Targets")
+
+ def _create_settings_tab(self):
+ """Create the settings tab"""
+ tab = QWidget()
+ layout = QVBoxLayout(tab)
+ layout.setContentsMargins(8, 8, 8, 8)
+
+ # Radar Settings Group
+ radar_group = QGroupBox("Radar Parameters")
+ radar_layout = QGridLayout(radar_group)
+
+ radar_layout.addWidget(QLabel("System Frequency:"), 0, 0)
+ freq_spin = QDoubleSpinBox()
+ freq_spin.setRange(1, 100)
+ freq_spin.setValue(self._settings.system_frequency / 1e9)
+ freq_spin.setSuffix(" GHz")
+ radar_layout.addWidget(freq_spin, 0, 1)
+
+ radar_layout.addWidget(QLabel("Max Range:"), 1, 0)
+ range_spin = QDoubleSpinBox()
+ range_spin.setRange(1, 200)
+ range_spin.setValue(self._settings.max_distance / 1000)
+ range_spin.setSuffix(" km")
+ radar_layout.addWidget(range_spin, 1, 1)
+
+ radar_layout.addWidget(QLabel("PRF 1:"), 2, 0)
+ prf1_spin = QSpinBox()
+ prf1_spin.setRange(100, 10000)
+ prf1_spin.setValue(int(self._settings.prf1))
+ prf1_spin.setSuffix(" Hz")
+ radar_layout.addWidget(prf1_spin, 2, 1)
+
+ radar_layout.addWidget(QLabel("PRF 2:"), 3, 0)
+ prf2_spin = QSpinBox()
+ prf2_spin.setRange(100, 10000)
+ prf2_spin.setValue(int(self._settings.prf2))
+ prf2_spin.setSuffix(" Hz")
+ radar_layout.addWidget(prf2_spin, 3, 1)
+
+ layout.addWidget(radar_group)
+
+ # About
+ about_group = QGroupBox("About")
+ about_layout = QVBoxLayout(about_group)
+ about_text = QLabel(
+ "PLFM Radar Dashboard
"
+ "PyQt6 Edition with Embedded Leaflet Map
"
+ "Version: 1.0.0
"
+ "Map: OpenStreetMap + Leaflet.js
"
+ "Framework: PyQt6 + QWebEngine"
+ )
+ about_text.setStyleSheet(f"color: {DARK_TEXT}; padding: 12px;")
+ about_layout.addWidget(about_text)
+
+ layout.addWidget(about_group)
+ layout.addStretch()
+
+ self._tabs.addTab(tab, "Settings")
+
+ def _setup_statusbar(self):
+ """Setup the status bar"""
+ self._statusbar = QStatusBar()
+ self.setStatusBar(self._statusbar)
+
+ self._status_label = QLabel("Ready")
+ self._statusbar.addWidget(self._status_label)
+
+ self._target_count_label = QLabel("Targets: 0")
+ self._statusbar.addPermanentWidget(self._target_count_label)
+
+ self._mode_label = QLabel("Demo Mode")
+ self._mode_label.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;")
+ self._statusbar.addPermanentWidget(self._mode_label)
+
+ def _start_demo_mode(self):
+ """Start the demo mode with simulated targets"""
+ self._simulator = TargetSimulator(self._radar_position, self)
+ self._simulator.targetsUpdated.connect(self._on_targets_updated)
+ self._simulator.start(500) # Update every 500ms
+
+ self._demo_mode = True
+ self._demo_btn.setChecked(True)
+ self._demo_btn.setText("Stop Demo")
+ self._mode_label.setText("Demo Mode")
+ self._status_label.setText("Demo mode active")
+
+ logger.info("Demo mode started")
+
+ def _toggle_demo_mode(self, checked: bool):
+ """Toggle demo mode on/off"""
+ if checked:
+ self._start_demo_mode()
+ else:
+ if self._simulator:
+ self._simulator.stop()
+ self._demo_mode = False
+ self._demo_btn.setText("Start Demo")
+ self._mode_label.setText("Idle")
+ self._status_label.setText("Demo mode stopped")
+ logger.info("Demo mode stopped")
+
+ def _add_demo_target(self):
+ """Add a random target in demo mode"""
+ if self._simulator:
+ self._simulator._add_random_target()
+ logger.info("Added random target")
+
+ def _on_targets_updated(self, targets: List[RadarTarget]):
+ """Handle updated target list from simulator"""
+ # Update map
+ self._map_widget.set_targets(targets)
+
+ # Update status bar
+ self._target_count_label.setText(f"Targets: {len(targets)}")
+
+ # Update table
+ self._update_targets_table(targets)
+
+ def _update_targets_table(self, targets: List[RadarTarget]):
+ """Update the targets table"""
+ self._targets_table.setRowCount(len(targets))
+
+ for row, target in enumerate(targets):
+ # ID
+ self._targets_table.setItem(row, 0, QTableWidgetItem(str(target.id)))
+
+ # Track ID
+ self._targets_table.setItem(row, 1, QTableWidgetItem(str(target.track_id)))
+
+ # Range
+ self._targets_table.setItem(row, 2, QTableWidgetItem(f"{target.range:.1f}"))
+
+ # Velocity
+ vel_item = QTableWidgetItem(f"{target.velocity:+.1f}")
+ if target.velocity > 1:
+ vel_item.setForeground(QColor(DARK_ERROR))
+ elif target.velocity < -1:
+ vel_item.setForeground(QColor(DARK_INFO))
+ self._targets_table.setItem(row, 3, vel_item)
+
+ # Azimuth
+ self._targets_table.setItem(row, 4, QTableWidgetItem(f"{target.azimuth:.1f}"))
+
+ # Elevation
+ self._targets_table.setItem(row, 5, QTableWidgetItem(f"{target.elevation:.1f}"))
+
+ # SNR
+ self._targets_table.setItem(row, 6, QTableWidgetItem(f"{target.snr:.1f}"))
+
+ # Classification
+ self._targets_table.setItem(row, 7, QTableWidgetItem(target.classification))
+
+ # Status
+ status = "Approaching" if target.velocity > 1 else (
+ "Receding" if target.velocity < -1 else "Stationary"
+ )
+ status_item = QTableWidgetItem(status)
+ if status == "Approaching":
+ status_item.setForeground(QColor(DARK_ERROR))
+ elif status == "Receding":
+ status_item.setForeground(QColor(DARK_INFO))
+ self._targets_table.setItem(row, 8, status_item)
+
+ def _on_target_selected(self, target_id: int):
+ """Handle target selection from map"""
+ # Find target
+ if self._simulator:
+ for target in self._simulator._targets:
+ if target.id == target_id:
+ self._show_target_info(target)
+ break
+
+ def _show_target_info(self, target: RadarTarget):
+ """Display target information in sidebar"""
+ status = "Approaching" if target.velocity > 1 else (
+ "Receding" if target.velocity < -1 else "Stationary"
+ )
+
+ info = f"""
+ Target #{target.id}
+ Track ID: {target.track_id}
+ Range: {target.range:.1f} m
+ Velocity: {target.velocity:+.1f} m/s
+ Azimuth: {target.azimuth:.1f}°
+ Elevation: {target.elevation:.1f}°
+ SNR: {target.snr:.1f} dB
+ Classification: {target.classification}
+ Status: {status}
+ """
+
+ self._target_info_label.setText(info)
+
+ def _on_position_changed(self):
+ """Handle radar position change from UI"""
+ self._radar_position.latitude = self._lat_spin.value()
+ self._radar_position.longitude = self._lon_spin.value()
+ self._radar_position.altitude = self._alt_spin.value()
+
+ self._map_widget.set_radar_position(self._radar_position)
+
+ if self._simulator:
+ self._simulator.set_radar_position(self._radar_position)
+
+ def _on_coverage_changed(self, value: float):
+ """Handle coverage radius change"""
+ radius_m = value * 1000
+ self._settings.coverage_radius = radius_m
+ self._map_widget.set_coverage_radius(radius_m)
+
+ def closeEvent(self, event):
+ """Handle window close"""
+ if self._simulator:
+ self._simulator.stop()
+ event.accept()
+
+
+# =============================================================================
+# Main Entry Point
+# =============================================================================
+def main():
+ """Application entry point"""
+ # Create application
+ app = QApplication(sys.argv)
+ app.setApplicationName("PLFM Radar Dashboard")
+ app.setApplicationVersion("1.0.0")
+
+ # Set font
+ font = QFont("Segoe UI", 10)
+ app.setFont(font)
+
+ # Create and show main window
+ window = RadarDashboard()
+ window.show()
+
+ logger.info("Application started")
+
+ # Run event loop
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/9_Firmware/9_3_GUI/GUI_V6.py b/9_Firmware/9_3_GUI/GUI_V6.py
index f789462..b005c1c 100644
--- a/9_Firmware/9_3_GUI/GUI_V6.py
+++ b/9_Firmware/9_3_GUI/GUI_V6.py
@@ -8,15 +8,11 @@ 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
import logging
from dataclasses import dataclass
-from typing import Dict, List, Tuple, Optional
-from scipy import signal
from sklearn.cluster import DBSCAN
from filterpy.kalman import KalmanFilter
import crcmod
-import math
import webbrowser
import tempfile
import os
@@ -30,9 +26,9 @@ except ImportError:
logging.warning("pyusb not available. USB functionality will be disabled.")
try:
- from pyftdi.ftdi import Ftdi
- from pyftdi.usbtools import UsbTools
- from pyftdi.ftdi import FtdiError
+ from pyftdi.ftdi import Ftdi # noqa: F401
+ from pyftdi.usbtools import UsbTools # noqa: F401
+ from pyftdi.ftdi import FtdiError # noqa: F401
FTDI_AVAILABLE = True
except ImportError:
FTDI_AVAILABLE = False
@@ -294,7 +290,7 @@ class FT601Interface:
'device': dev,
'serial': serial
})
- except Exception as e:
+ except Exception:
devices.append({
'description': f"FT601 USB3.0 (VID:{vid:04X}, PID:{pid:04X})",
'vendor_id': vid,
@@ -553,7 +549,7 @@ class STM32USBInterface:
'product_id': pid,
'device': dev
})
- except:
+ except Exception:
devices.append({
'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
'vendor_id': vid,
@@ -910,7 +906,7 @@ class RadarPacketParser:
if len(packet) < 6:
return None
- sync = packet[0:2]
+ _sync = packet[0:2] # noqa: F841
packet_type = packet[2]
length = packet[3]
@@ -1335,8 +1331,8 @@ class RadarGUI:
logging.info("Radar system stopped")
- def process_radar_data(self):
- """Process incoming radar data from FT601"""
+ def _process_radar_data_ft601(self):
+ """Process incoming radar data from FT601 (legacy, superseded by FTDI version)."""
buffer = bytearray()
while True:
if self.running and self.ft601_interface.is_open:
@@ -1375,7 +1371,7 @@ class RadarGUI:
# This should match your packet structure
return 64 # Example: 64-byte packets
- def process_gps_data(self):
+ def _process_gps_data_ft601(self):
"""Step 16/17: Process GPS data from STM32 via USB CDC"""
while True:
if self.running and self.stm32_usb_interface.is_open:
@@ -1716,7 +1712,7 @@ def main():
"""Main application entry point"""
try:
root = tk.Tk()
- app = RadarGUI(root)
+ _app = RadarGUI(root) # noqa: F841 – must stay alive for mainloop
root.mainloop()
except Exception as e:
logging.error(f"Application error: {e}")
diff --git a/9_Firmware/9_3_GUI/GUI_V6_Demo.py b/9_Firmware/9_3_GUI/GUI_V6_Demo.py
index d8efd08..904e9ab 100644
--- a/9_Firmware/9_3_GUI/GUI_V6_Demo.py
+++ b/9_Firmware/9_3_GUI/GUI_V6_Demo.py
@@ -8,8 +8,6 @@ All buttons work, simulated radar data is generated in real-time
import tkinter as tk
from tkinter import ttk, messagebox
-import threading
-import queue
import time
import numpy as np
import matplotlib.pyplot as plt
@@ -17,10 +15,9 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
import logging
from dataclasses import dataclass
-from typing import List, Dict, Optional
+from typing import List, Dict
import random
import json
-import os
from datetime import datetime
# Configure logging
@@ -1200,7 +1197,7 @@ def main():
root = tk.Tk()
# Create application
- app = RadarDemoGUI(root)
+ _app = RadarDemoGUI(root) # noqa: F841 — keeps reference alive
# Center window
root.update_idletasks()
diff --git a/9_Firmware/9_3_GUI/GUI_V7_PyQt.py b/9_Firmware/9_3_GUI/GUI_V7_PyQt.py
new file mode 100644
index 0000000..bef92dc
--- /dev/null
+++ b/9_Firmware/9_3_GUI/GUI_V7_PyQt.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""
+PLFM Radar System GUI V7 — PyQt6 Edition
+
+Entry point. Launches the RadarDashboard main window.
+
+Usage:
+ python GUI_V7_PyQt.py
+"""
+
+import sys
+import logging
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtGui import QFont
+
+from v7 import RadarDashboard
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
+)
+logger = logging.getLogger(__name__)
+
+
+def main():
+ app = QApplication(sys.argv)
+ app.setApplicationName("PLFM Radar System V7")
+ app.setApplicationVersion("7.0.0")
+ app.setFont(QFont("Segoe UI", 10))
+
+ window = RadarDashboard()
+ window.show()
+
+ logger.info("PLFM Radar GUI V7 started")
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__":
+ main()
diff --git a/9_Firmware/9_3_GUI/v7/__init__.py b/9_Firmware/9_3_GUI/v7/__init__.py
new file mode 100644
index 0000000..dd25c98
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/__init__.py
@@ -0,0 +1,80 @@
+"""
+v7 — PLFM Radar GUI V7 (PyQt6 edition).
+
+Re-exports all public classes and functions from sub-modules for convenient
+top-level imports:
+
+ from v7 import RadarDashboard, RadarTarget, RadarSettings, ...
+"""
+
+# Models / constants
+from .models import (
+ RadarTarget,
+ RadarSettings,
+ GPSData,
+ ProcessingConfig,
+ TileServer,
+ DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER,
+ DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER,
+ DARK_TREEVIEW, DARK_TREEVIEW_ALT,
+ DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO,
+ USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE,
+ SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
+)
+
+# Hardware interfaces
+from .hardware import (
+ FT2232HQInterface,
+ STM32USBInterface,
+)
+
+# Processing pipeline
+from .processing import (
+ RadarProcessor,
+ RadarPacketParser,
+ USBPacketParser,
+ apply_pitch_correction,
+)
+
+# Workers and simulator
+from .workers import (
+ RadarDataWorker,
+ GPSDataWorker,
+ TargetSimulator,
+ polar_to_geographic,
+)
+
+# Map widget
+from .map_widget import (
+ MapBridge,
+ RadarMapWidget,
+)
+
+# Main dashboard
+from .dashboard import (
+ RadarDashboard,
+ RangeDopplerCanvas,
+)
+
+__all__ = [
+ # models
+ "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
+ "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
+ "DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER",
+ "DARK_TREEVIEW", "DARK_TREEVIEW_ALT",
+ "DARK_SUCCESS", "DARK_WARNING", "DARK_ERROR", "DARK_INFO",
+ "USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE",
+ "SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", "CRCMOD_AVAILABLE",
+ # hardware
+ "FT2232HQInterface", "STM32USBInterface",
+ # processing
+ "RadarProcessor", "RadarPacketParser", "USBPacketParser",
+ "apply_pitch_correction",
+ # workers
+ "RadarDataWorker", "GPSDataWorker", "TargetSimulator",
+ "polar_to_geographic",
+ # map
+ "MapBridge", "RadarMapWidget",
+ # dashboard
+ "RadarDashboard", "RangeDopplerCanvas",
+]
diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py
new file mode 100644
index 0000000..db65027
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/dashboard.py
@@ -0,0 +1,1331 @@
+"""
+v7.dashboard — Main application window for the PLFM Radar GUI V7.
+
+RadarDashboard is a QMainWindow with four tabs:
+ 1. Main View — Range-Doppler matplotlib canvas, device combos, Start/Stop, targets table
+ 2. Map View — Embedded Leaflet map + sidebar (position, coverage, demo, target info)
+ 3. Diagnostics — Connection indicators, packet stats, dependency status, log viewer
+ 4. Settings — All radar parameters + About section
+
+Integrates: hardware interfaces, QThread workers, TargetSimulator, RadarMapWidget.
+"""
+
+import time
+import logging
+from typing import List, Optional
+
+import numpy as np
+
+from PyQt6.QtWidgets import (
+ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
+ QTabWidget, QSplitter, QGroupBox, QFrame,
+ QLabel, QPushButton, QComboBox, QCheckBox,
+ QDoubleSpinBox, QSpinBox,
+ QTableWidget, QTableWidgetItem, QHeaderView,
+ QPlainTextEdit, QStatusBar, QMessageBox,
+)
+from PyQt6.QtCore import Qt, QTimer, pyqtSlot
+from PyQt6.QtGui import QColor
+
+from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
+from matplotlib.figure import Figure
+
+from .models import (
+ RadarTarget, RadarSettings, GPSData, ProcessingConfig,
+ DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER,
+ DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER,
+ DARK_TREEVIEW, DARK_TREEVIEW_ALT,
+ DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO,
+ USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE,
+ SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
+)
+from .hardware import FT2232HQInterface, STM32USBInterface
+from .processing import RadarProcessor, RadarPacketParser, USBPacketParser
+from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator
+from .map_widget import RadarMapWidget
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Range-Doppler Canvas (matplotlib)
+# =============================================================================
+
+class RangeDopplerCanvas(FigureCanvasQTAgg):
+ """Matplotlib canvas showing the Range-Doppler map with dark theme."""
+
+ def __init__(self, parent=None):
+ fig = Figure(figsize=(10, 6), facecolor=DARK_BG)
+ self.ax = fig.add_subplot(111, facecolor=DARK_ACCENT)
+
+ self._data = np.zeros((1024, 32))
+ self.im = self.ax.imshow(
+ self._data, aspect="auto", cmap="hot",
+ extent=[0, 32, 0, 1024], origin="lower",
+ )
+
+ self.ax.set_title("Range-Doppler Map (Pitch Corrected)", color=DARK_FG)
+ self.ax.set_xlabel("Doppler Bin", color=DARK_FG)
+ self.ax.set_ylabel("Range Bin", color=DARK_FG)
+ self.ax.tick_params(colors=DARK_FG)
+ for spine in self.ax.spines.values():
+ spine.set_color(DARK_BORDER)
+
+ fig.tight_layout()
+ super().__init__(fig)
+
+ def update_map(self, rdm: np.ndarray):
+ display = np.log10(rdm + 1)
+ self.im.set_data(display)
+ self.im.set_clim(vmin=display.min(), vmax=max(display.max(), 0.1))
+ self.draw_idle()
+
+
+# =============================================================================
+# RadarDashboard — main window
+# =============================================================================
+
+class RadarDashboard(QMainWindow):
+ """Main application window with 4 tabs."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setWindowTitle("PLFM Radar System GUI V7 — PyQt6")
+ self.setGeometry(100, 60, 1500, 950)
+
+ # ---- Core objects --------------------------------------------------
+ self._settings = RadarSettings()
+ self._radar_position = GPSData(
+ latitude=41.9028, longitude=12.4964,
+ altitude=0.0, pitch=0.0, heading=0.0, timestamp=0.0,
+ )
+
+ # Hardware interfaces
+ self._stm32 = STM32USBInterface()
+ self._ft2232hq = FT2232HQInterface()
+
+ # Processing
+ self._processor = RadarProcessor()
+ self._radar_parser = RadarPacketParser()
+ self._usb_parser = USBPacketParser()
+ self._processing_config = ProcessingConfig()
+
+ # Device lists (cached for index lookup)
+ self._stm32_devices: list = []
+ self._ft2232hq_devices: list = []
+
+ # Workers (created on demand)
+ self._radar_worker: Optional[RadarDataWorker] = None
+ self._gps_worker: Optional[GPSDataWorker] = None
+ self._simulator: Optional[TargetSimulator] = None
+
+ # State
+ self._running = False
+ self._demo_mode = False
+ self._start_time = time.time()
+ self._radar_stats: dict = {}
+ self._gps_packet_count = 0
+ self._current_targets: List[RadarTarget] = []
+ self._corrected_elevations: list = []
+
+ # ---- Build UI ------------------------------------------------------
+ self._apply_dark_theme()
+ self._setup_ui()
+ self._setup_statusbar()
+
+ # GUI refresh timer (100 ms)
+ self._gui_timer = QTimer(self)
+ self._gui_timer.timeout.connect(self._refresh_gui)
+ self._gui_timer.start(100)
+
+ # Log handler for diagnostics
+ self._log_handler = _QtLogHandler(self._log_append)
+ self._log_handler.setLevel(logging.INFO)
+ logging.getLogger().addHandler(self._log_handler)
+
+ logger.info("RadarDashboard initialised")
+
+ # =====================================================================
+ # Dark theme
+ # =====================================================================
+
+ def _apply_dark_theme(self):
+ self.setStyleSheet(f"""
+ QMainWindow, QWidget {{
+ background-color: {DARK_BG};
+ color: {DARK_FG};
+ }}
+ QTabWidget::pane {{
+ border: 1px solid {DARK_BORDER};
+ background-color: {DARK_BG};
+ }}
+ QTabBar::tab {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ padding: 8px 18px;
+ border: 1px solid {DARK_BORDER};
+ border-bottom: none;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+ }}
+ QTabBar::tab:selected {{
+ background-color: {DARK_HIGHLIGHT};
+ }}
+ QTabBar::tab:hover {{
+ background-color: {DARK_BUTTON_HOVER};
+ }}
+ QGroupBox {{
+ border: 1px solid {DARK_BORDER};
+ border-radius: 4px;
+ margin-top: 12px;
+ padding-top: 12px;
+ font-weight: bold;
+ color: {DARK_FG};
+ }}
+ QGroupBox::title {{
+ subcontrol-origin: margin;
+ left: 10px;
+ padding: 0 6px;
+ }}
+ QPushButton {{
+ background-color: {DARK_BUTTON};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 6px 16px;
+ border-radius: 4px;
+ }}
+ QPushButton:hover {{
+ background-color: {DARK_BUTTON_HOVER};
+ }}
+ QPushButton:pressed {{
+ background-color: {DARK_HIGHLIGHT};
+ }}
+ QPushButton:disabled {{
+ color: {DARK_BORDER};
+ }}
+ QComboBox {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 4px 8px;
+ border-radius: 4px;
+ }}
+ QLineEdit, QSpinBox, QDoubleSpinBox {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ padding: 4px 8px;
+ border-radius: 4px;
+ }}
+ QCheckBox {{
+ color: {DARK_FG};
+ spacing: 6px;
+ }}
+ QLabel {{
+ color: {DARK_FG};
+ }}
+ QTableWidget {{
+ background-color: {DARK_TREEVIEW};
+ alternate-background-color: {DARK_TREEVIEW_ALT};
+ color: {DARK_FG};
+ gridline-color: {DARK_BORDER};
+ border: 1px solid {DARK_BORDER};
+ }}
+ QTableWidget::item:selected {{
+ background-color: {DARK_INFO};
+ }}
+ QHeaderView::section {{
+ background-color: {DARK_HIGHLIGHT};
+ color: {DARK_FG};
+ padding: 6px;
+ border: none;
+ border-right: 1px solid {DARK_BORDER};
+ border-bottom: 1px solid {DARK_BORDER};
+ }}
+ QPlainTextEdit {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ border: 1px solid {DARK_BORDER};
+ font-family: 'Courier New', monospace;
+ font-size: 11px;
+ }}
+ QScrollBar:vertical {{
+ background-color: {DARK_ACCENT};
+ width: 12px;
+ }}
+ QScrollBar::handle:vertical {{
+ background-color: {DARK_HIGHLIGHT};
+ border-radius: 6px;
+ min-height: 20px;
+ }}
+ QStatusBar {{
+ background-color: {DARK_ACCENT};
+ color: {DARK_FG};
+ }}
+ """)
+
+ # =====================================================================
+ # UI construction
+ # =====================================================================
+
+ def _setup_ui(self):
+ central = QWidget()
+ self.setCentralWidget(central)
+ main_layout = QVBoxLayout(central)
+ main_layout.setContentsMargins(8, 8, 8, 8)
+ main_layout.setSpacing(8)
+
+ self._tabs = QTabWidget()
+ main_layout.addWidget(self._tabs)
+
+ self._create_main_tab()
+ self._create_map_tab()
+ self._create_diagnostics_tab()
+ self._create_settings_tab()
+
+ # -----------------------------------------------------------------
+ # TAB 1: Main View
+ # -----------------------------------------------------------------
+
+ def _create_main_tab(self):
+ tab = QWidget()
+ layout = QVBoxLayout(tab)
+ layout.setContentsMargins(8, 8, 8, 8)
+
+ # ---- Control bar ---------------------------------------------------
+ ctrl = QFrame()
+ ctrl.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;")
+ ctrl_layout = QGridLayout(ctrl)
+ ctrl_layout.setContentsMargins(8, 6, 8, 6)
+
+ # Row 0: device combos & buttons
+ ctrl_layout.addWidget(QLabel("STM32 USB:"), 0, 0)
+ self._stm32_combo = QComboBox()
+ self._stm32_combo.setMinimumWidth(200)
+ ctrl_layout.addWidget(self._stm32_combo, 0, 1)
+
+ ctrl_layout.addWidget(QLabel("FT2232HQ (Primary):"), 0, 2)
+ self._ft2232hq_combo = QComboBox()
+ self._ft2232hq_combo.setMinimumWidth(200)
+ ctrl_layout.addWidget(self._ft2232hq_combo, 0, 3)
+
+ refresh_btn = QPushButton("Refresh Devices")
+ refresh_btn.clicked.connect(self._refresh_devices)
+ ctrl_layout.addWidget(refresh_btn, 0, 4)
+
+ self._start_btn = QPushButton("Start Radar")
+ self._start_btn.setStyleSheet(
+ f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}"
+ f"QPushButton:hover {{ background-color: #66BB6A; }}"
+ )
+ self._start_btn.clicked.connect(self._start_radar)
+ ctrl_layout.addWidget(self._start_btn, 0, 8)
+
+ self._stop_btn = QPushButton("Stop Radar")
+ self._stop_btn.setEnabled(False)
+ self._stop_btn.setStyleSheet(
+ f"QPushButton {{ background-color: {DARK_ERROR}; color: white; font-weight: bold; }}"
+ f"QPushButton:hover {{ background-color: #EF5350; }}"
+ )
+ self._stop_btn.clicked.connect(self._stop_radar)
+ ctrl_layout.addWidget(self._stop_btn, 0, 9)
+
+ self._demo_btn_main = QPushButton("Start Demo")
+ self._demo_btn_main.setStyleSheet(
+ f"QPushButton {{ background-color: {DARK_INFO}; color: white; font-weight: bold; }}"
+ f"QPushButton:hover {{ background-color: #42A5F5; }}"
+ )
+ self._demo_btn_main.clicked.connect(self._toggle_demo_main)
+ ctrl_layout.addWidget(self._demo_btn_main, 0, 10)
+
+ # Row 1: status labels
+ self._gps_label = QLabel("GPS: Waiting for data...")
+ ctrl_layout.addWidget(self._gps_label, 1, 0, 1, 4)
+
+ self._pitch_label = QLabel("Pitch: --.--\u00b0")
+ ctrl_layout.addWidget(self._pitch_label, 1, 4, 1, 2)
+
+ self._status_label_main = QLabel("Status: Ready")
+ self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight)
+ ctrl_layout.addWidget(self._status_label_main, 1, 6, 1, 5)
+
+ layout.addWidget(ctrl)
+
+ # ---- Display area (range-doppler + targets table) ------------------
+ display_splitter = QSplitter(Qt.Orientation.Horizontal)
+
+ # Range-Doppler canvas
+ self._rdm_canvas = RangeDopplerCanvas()
+ display_splitter.addWidget(self._rdm_canvas)
+
+ # Targets table
+ targets_group = QGroupBox("Detected Targets (Pitch Corrected)")
+ tg_layout = QVBoxLayout(targets_group)
+
+ self._targets_table_main = QTableWidget()
+ self._targets_table_main.setColumnCount(7)
+ self._targets_table_main.setHorizontalHeaderLabels([
+ "Track ID", "Range (m)", "Velocity (m/s)",
+ "Azimuth", "Raw Elev", "Corr Elev", "SNR (dB)",
+ ])
+ self._targets_table_main.setAlternatingRowColors(True)
+ self._targets_table_main.setSelectionBehavior(
+ QTableWidget.SelectionBehavior.SelectRows
+ )
+ header = self._targets_table_main.horizontalHeader()
+ header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
+ tg_layout.addWidget(self._targets_table_main)
+
+ display_splitter.addWidget(targets_group)
+ display_splitter.setSizes([800, 400])
+
+ layout.addWidget(display_splitter, stretch=1)
+ self._tabs.addTab(tab, "Main View")
+
+ # -----------------------------------------------------------------
+ # TAB 2: Map View
+ # -----------------------------------------------------------------
+
+ def _create_map_tab(self):
+ tab = QWidget()
+ layout = QHBoxLayout(tab)
+ layout.setContentsMargins(4, 4, 4, 4)
+
+ splitter = QSplitter(Qt.Orientation.Horizontal)
+
+ # Map widget
+ self._map_widget = RadarMapWidget(
+ radar_lat=self._radar_position.latitude,
+ radar_lon=self._radar_position.longitude,
+ )
+ self._map_widget.targetSelected.connect(self._on_target_selected)
+ splitter.addWidget(self._map_widget)
+
+ # Sidebar
+ sidebar = QWidget()
+ sidebar.setMaximumWidth(320)
+ sidebar.setMinimumWidth(280)
+ sb_layout = QVBoxLayout(sidebar)
+ sb_layout.setContentsMargins(8, 8, 8, 8)
+
+ # Radar position group
+ pos_group = QGroupBox("Radar Position")
+ pos_layout = QGridLayout(pos_group)
+
+ self._lat_spin = QDoubleSpinBox()
+ self._lat_spin.setRange(-90, 90)
+ self._lat_spin.setDecimals(6)
+ self._lat_spin.setValue(self._radar_position.latitude)
+ self._lat_spin.valueChanged.connect(self._on_position_changed)
+
+ self._lon_spin = QDoubleSpinBox()
+ self._lon_spin.setRange(-180, 180)
+ self._lon_spin.setDecimals(6)
+ self._lon_spin.setValue(self._radar_position.longitude)
+ self._lon_spin.valueChanged.connect(self._on_position_changed)
+
+ self._alt_spin = QDoubleSpinBox()
+ self._alt_spin.setRange(0, 50000)
+ self._alt_spin.setDecimals(1)
+ self._alt_spin.setValue(0.0)
+ self._alt_spin.setSuffix(" m")
+
+ pos_layout.addWidget(QLabel("Latitude:"), 0, 0)
+ pos_layout.addWidget(self._lat_spin, 0, 1)
+ pos_layout.addWidget(QLabel("Longitude:"), 1, 0)
+ pos_layout.addWidget(self._lon_spin, 1, 1)
+ pos_layout.addWidget(QLabel("Altitude:"), 2, 0)
+ pos_layout.addWidget(self._alt_spin, 2, 1)
+
+ sb_layout.addWidget(pos_group)
+
+ # Coverage group
+ cov_group = QGroupBox("Coverage")
+ cov_layout = QGridLayout(cov_group)
+
+ self._coverage_spin = QDoubleSpinBox()
+ self._coverage_spin.setRange(1, 200)
+ self._coverage_spin.setDecimals(1)
+ self._coverage_spin.setValue(self._settings.coverage_radius / 1000)
+ self._coverage_spin.setSuffix(" km")
+ self._coverage_spin.valueChanged.connect(self._on_coverage_changed)
+
+ cov_layout.addWidget(QLabel("Radius:"), 0, 0)
+ cov_layout.addWidget(self._coverage_spin, 0, 1)
+
+ sb_layout.addWidget(cov_group)
+
+ # Demo controls group
+ demo_group = QGroupBox("Demo Mode")
+ demo_layout = QVBoxLayout(demo_group)
+
+ self._demo_btn_map = QPushButton("Start Demo")
+ self._demo_btn_map.setCheckable(True)
+ self._demo_btn_map.clicked.connect(self._toggle_demo_map)
+ demo_layout.addWidget(self._demo_btn_map)
+
+ add_btn = QPushButton("Add Random Target")
+ add_btn.clicked.connect(self._add_demo_target)
+ demo_layout.addWidget(add_btn)
+
+ sb_layout.addWidget(demo_group)
+
+ # Selected target info
+ info_group = QGroupBox("Selected Target")
+ info_layout = QVBoxLayout(info_group)
+
+ self._target_info_label = QLabel("No target selected")
+ self._target_info_label.setWordWrap(True)
+ self._target_info_label.setStyleSheet(f"color: {DARK_TEXT}; padding: 8px;")
+ info_layout.addWidget(self._target_info_label)
+
+ sb_layout.addWidget(info_group)
+ sb_layout.addStretch()
+
+ splitter.addWidget(sidebar)
+ splitter.setSizes([900, 300])
+
+ layout.addWidget(splitter)
+ self._tabs.addTab(tab, "Map View")
+
+ # -----------------------------------------------------------------
+ # TAB 3: Diagnostics
+ # -----------------------------------------------------------------
+
+ def _create_diagnostics_tab(self):
+ tab = QWidget()
+ layout = QVBoxLayout(tab)
+ layout.setContentsMargins(8, 8, 8, 8)
+
+ top_row = QHBoxLayout()
+
+ # Connection status
+ conn_group = QGroupBox("Connection Status")
+ conn_layout = QGridLayout(conn_group)
+
+ self._conn_stm32 = self._make_status_label("STM32 USB")
+ self._conn_ft2232hq = self._make_status_label("FT2232HQ (Primary)")
+
+ conn_layout.addWidget(QLabel("STM32 USB:"), 0, 0)
+ conn_layout.addWidget(self._conn_stm32, 0, 1)
+ conn_layout.addWidget(QLabel("FT2232HQ:"), 1, 0)
+ conn_layout.addWidget(self._conn_ft2232hq, 1, 1)
+
+ top_row.addWidget(conn_group)
+
+ # Packet statistics
+ stats_group = QGroupBox("Packet Statistics")
+ stats_layout = QGridLayout(stats_group)
+
+ labels = [
+ "Radar Packets:", "Bytes Received:", "GPS Packets:",
+ "Errors:", "Active Tracks:", "Detected Targets:",
+ "Uptime:", "Packet Rate:",
+ ]
+ self._diag_values: list = []
+ for i, text in enumerate(labels):
+ r, c = divmod(i, 2)
+ stats_layout.addWidget(QLabel(text), r, c * 2)
+ val = QLabel("0")
+ val.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;")
+ stats_layout.addWidget(val, r, c * 2 + 1)
+ self._diag_values.append(val)
+
+ top_row.addWidget(stats_group)
+
+ # Dependency status
+ dep_group = QGroupBox("Optional Dependencies")
+ dep_layout = QGridLayout(dep_group)
+
+ deps = [
+ ("pyusb", USB_AVAILABLE),
+ ("pyftdi", FTDI_AVAILABLE),
+ ("scipy", SCIPY_AVAILABLE),
+ ("sklearn", SKLEARN_AVAILABLE),
+ ("filterpy", FILTERPY_AVAILABLE),
+ ("crcmod", CRCMOD_AVAILABLE),
+ ]
+ for i, (name, avail) in enumerate(deps):
+ dep_layout.addWidget(QLabel(name), i, 0)
+ lbl = QLabel("Available" if avail else "Missing")
+ lbl.setStyleSheet(
+ f"color: {DARK_SUCCESS}; font-weight: bold;"
+ if avail else
+ f"color: {DARK_WARNING}; font-weight: bold;"
+ )
+ dep_layout.addWidget(lbl, i, 1)
+
+ top_row.addWidget(dep_group)
+
+ layout.addLayout(top_row)
+
+ # Log viewer
+ log_group = QGroupBox("System Log")
+ log_layout = QVBoxLayout(log_group)
+
+ self._log_text = QPlainTextEdit()
+ self._log_text.setReadOnly(True)
+ self._log_text.setMaximumBlockCount(500)
+ log_layout.addWidget(self._log_text)
+
+ clear_btn = QPushButton("Clear Log")
+ clear_btn.clicked.connect(self._log_text.clear)
+ log_layout.addWidget(clear_btn)
+
+ layout.addWidget(log_group, stretch=1)
+
+ self._tabs.addTab(tab, "Diagnostics")
+
+ # -----------------------------------------------------------------
+ # TAB 4: Settings
+ # -----------------------------------------------------------------
+
+ def _create_settings_tab(self):
+ from PyQt6.QtWidgets import QScrollArea
+
+ tab = QWidget()
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
+
+ inner = QWidget()
+ layout = QVBoxLayout(inner)
+ layout.setContentsMargins(8, 8, 8, 8)
+
+ # ---- Radar parameters group ----------------------------------------
+ radar_group = QGroupBox("Radar Parameters")
+ r_layout = QGridLayout(radar_group)
+
+ self._setting_spins: dict = {}
+ param_defs = [
+ ("System Frequency (GHz):", "system_frequency", 1, 100, 2,
+ self._settings.system_frequency / 1e9, " GHz"),
+ ("Chirp Duration 1 (us):", "chirp_duration_1", 0.01, 10000, 2,
+ self._settings.chirp_duration_1 * 1e6, " us"),
+ ("Chirp Duration 2 (us):", "chirp_duration_2", 0.001, 10000, 3,
+ self._settings.chirp_duration_2 * 1e6, " us"),
+ ("Chirps per Position:", "chirps_per_position", 1, 1024, 0,
+ self._settings.chirps_per_position, ""),
+ ("Freq Min (MHz):", "freq_min", 0.1, 1000, 1,
+ self._settings.freq_min / 1e6, " MHz"),
+ ("Freq Max (MHz):", "freq_max", 0.1, 1000, 1,
+ self._settings.freq_max / 1e6, " MHz"),
+ ("PRF 1 (Hz):", "prf1", 100, 100000, 0,
+ self._settings.prf1, " Hz"),
+ ("PRF 2 (Hz):", "prf2", 100, 100000, 0,
+ self._settings.prf2, " Hz"),
+ ("Max Distance (km):", "max_distance", 1, 500, 1,
+ self._settings.max_distance / 1000, " km"),
+ ("Map Size (km):", "map_size", 1, 500, 1,
+ self._settings.map_size / 1000, " km"),
+ ]
+
+ for i, (label, key, lo, hi, dec, default, suffix) in enumerate(param_defs):
+ r_layout.addWidget(QLabel(label), i, 0)
+ if dec == 0:
+ spin = QSpinBox()
+ spin.setRange(int(lo), int(hi))
+ spin.setValue(int(default))
+ if suffix:
+ spin.setSuffix(suffix)
+ else:
+ spin = QDoubleSpinBox()
+ spin.setRange(lo, hi)
+ spin.setDecimals(dec)
+ spin.setValue(default)
+ if suffix:
+ spin.setSuffix(suffix)
+ r_layout.addWidget(spin, i, 1)
+ self._setting_spins[key] = spin
+
+ apply_btn = QPushButton("Apply Settings")
+ apply_btn.setStyleSheet(
+ f"QPushButton {{ background-color: {DARK_INFO}; color: white; font-weight: bold; }}"
+ )
+ apply_btn.clicked.connect(self._apply_settings)
+ r_layout.addWidget(apply_btn, len(param_defs), 0, 1, 2)
+
+ layout.addWidget(radar_group)
+
+ # ---- Signal Processing group ---------------------------------------
+ proc_group = QGroupBox("Signal Processing")
+ p_layout = QGridLayout(proc_group)
+ row = 0
+
+ # -- MTI --
+ self._mti_check = QCheckBox("MTI (Moving Target Indication)")
+ self._mti_check.setChecked(self._processing_config.mti_enabled)
+ p_layout.addWidget(self._mti_check, row, 0, 1, 2)
+ row += 1
+
+ p_layout.addWidget(QLabel("MTI Order:"), row, 0)
+ self._mti_order_spin = QSpinBox()
+ self._mti_order_spin.setRange(1, 3)
+ self._mti_order_spin.setValue(self._processing_config.mti_order)
+ self._mti_order_spin.setToolTip("1 = single canceller, 2 = double, 3 = triple")
+ p_layout.addWidget(self._mti_order_spin, row, 1)
+ row += 1
+
+ # -- Separator --
+ sep1 = QFrame()
+ sep1.setFrameShape(QFrame.Shape.HLine)
+ sep1.setStyleSheet(f"color: {DARK_BORDER};")
+ p_layout.addWidget(sep1, row, 0, 1, 2)
+ row += 1
+
+ # -- CFAR --
+ self._cfar_check = QCheckBox("CFAR (Constant False Alarm Rate)")
+ self._cfar_check.setChecked(self._processing_config.cfar_enabled)
+ p_layout.addWidget(self._cfar_check, row, 0, 1, 2)
+ row += 1
+
+ p_layout.addWidget(QLabel("CFAR Type:"), row, 0)
+ self._cfar_type_combo = QComboBox()
+ self._cfar_type_combo.addItems(["CA-CFAR", "OS-CFAR", "GO-CFAR", "SO-CFAR"])
+ self._cfar_type_combo.setCurrentText(self._processing_config.cfar_type)
+ p_layout.addWidget(self._cfar_type_combo, row, 1)
+ row += 1
+
+ p_layout.addWidget(QLabel("Guard Cells:"), row, 0)
+ self._cfar_guard_spin = QSpinBox()
+ self._cfar_guard_spin.setRange(1, 20)
+ self._cfar_guard_spin.setValue(self._processing_config.cfar_guard_cells)
+ p_layout.addWidget(self._cfar_guard_spin, row, 1)
+ row += 1
+
+ p_layout.addWidget(QLabel("Training Cells:"), row, 0)
+ self._cfar_train_spin = QSpinBox()
+ self._cfar_train_spin.setRange(1, 50)
+ self._cfar_train_spin.setValue(self._processing_config.cfar_training_cells)
+ p_layout.addWidget(self._cfar_train_spin, row, 1)
+ row += 1
+
+ p_layout.addWidget(QLabel("Threshold Factor:"), row, 0)
+ self._cfar_thresh_spin = QDoubleSpinBox()
+ self._cfar_thresh_spin.setRange(0.1, 50.0)
+ self._cfar_thresh_spin.setDecimals(1)
+ self._cfar_thresh_spin.setValue(self._processing_config.cfar_threshold_factor)
+ self._cfar_thresh_spin.setSingleStep(0.5)
+ p_layout.addWidget(self._cfar_thresh_spin, row, 1)
+ row += 1
+
+ # -- Separator --
+ sep2 = QFrame()
+ sep2.setFrameShape(QFrame.Shape.HLine)
+ sep2.setStyleSheet(f"color: {DARK_BORDER};")
+ p_layout.addWidget(sep2, row, 0, 1, 2)
+ row += 1
+
+ # -- DC Notch --
+ self._dc_notch_check = QCheckBox("DC Notch / Zero-Doppler Removal")
+ self._dc_notch_check.setChecked(self._processing_config.dc_notch_enabled)
+ p_layout.addWidget(self._dc_notch_check, row, 0, 1, 2)
+ row += 1
+
+ # -- Separator --
+ sep3 = QFrame()
+ sep3.setFrameShape(QFrame.Shape.HLine)
+ sep3.setStyleSheet(f"color: {DARK_BORDER};")
+ p_layout.addWidget(sep3, row, 0, 1, 2)
+ row += 1
+
+ # -- Windowing --
+ p_layout.addWidget(QLabel("Window Function:"), row, 0)
+ self._window_combo = QComboBox()
+ self._window_combo.addItems(["None", "Hann", "Hamming", "Blackman", "Kaiser", "Chebyshev"])
+ self._window_combo.setCurrentText(self._processing_config.window_type)
+ if not SCIPY_AVAILABLE:
+ # Without scipy, only None/Hann/Hamming/Blackman via numpy
+ self._window_combo.setToolTip("Kaiser and Chebyshev require scipy")
+ p_layout.addWidget(self._window_combo, row, 1)
+ row += 1
+
+ # -- Separator --
+ sep4 = QFrame()
+ sep4.setFrameShape(QFrame.Shape.HLine)
+ sep4.setStyleSheet(f"color: {DARK_BORDER};")
+ p_layout.addWidget(sep4, row, 0, 1, 2)
+ row += 1
+
+ # -- Detection Threshold --
+ p_layout.addWidget(QLabel("Detection Threshold (dB):"), row, 0)
+ self._det_thresh_spin = QDoubleSpinBox()
+ self._det_thresh_spin.setRange(0.0, 60.0)
+ self._det_thresh_spin.setDecimals(1)
+ self._det_thresh_spin.setValue(self._processing_config.detection_threshold_db)
+ self._det_thresh_spin.setSuffix(" dB")
+ self._det_thresh_spin.setSingleStep(1.0)
+ self._det_thresh_spin.setToolTip("SNR threshold above noise floor (used when CFAR is disabled)")
+ p_layout.addWidget(self._det_thresh_spin, row, 1)
+ row += 1
+
+ # -- Separator --
+ sep5 = QFrame()
+ sep5.setFrameShape(QFrame.Shape.HLine)
+ sep5.setStyleSheet(f"color: {DARK_BORDER};")
+ p_layout.addWidget(sep5, row, 0, 1, 2)
+ row += 1
+
+ # -- Clustering --
+ self._cluster_check = QCheckBox("DBSCAN Clustering")
+ self._cluster_check.setChecked(self._processing_config.clustering_enabled)
+ if not SKLEARN_AVAILABLE:
+ self._cluster_check.setEnabled(False)
+ self._cluster_check.setToolTip("Requires scikit-learn")
+ p_layout.addWidget(self._cluster_check, row, 0, 1, 2)
+ row += 1
+
+ p_layout.addWidget(QLabel("DBSCAN eps:"), row, 0)
+ self._cluster_eps_spin = QDoubleSpinBox()
+ self._cluster_eps_spin.setRange(1.0, 5000.0)
+ self._cluster_eps_spin.setDecimals(1)
+ self._cluster_eps_spin.setValue(self._processing_config.clustering_eps)
+ self._cluster_eps_spin.setSingleStep(10.0)
+ p_layout.addWidget(self._cluster_eps_spin, row, 1)
+ row += 1
+
+ p_layout.addWidget(QLabel("Min Samples:"), row, 0)
+ self._cluster_min_spin = QSpinBox()
+ self._cluster_min_spin.setRange(1, 20)
+ self._cluster_min_spin.setValue(self._processing_config.clustering_min_samples)
+ p_layout.addWidget(self._cluster_min_spin, row, 1)
+ row += 1
+
+ # -- Separator --
+ sep6 = QFrame()
+ sep6.setFrameShape(QFrame.Shape.HLine)
+ sep6.setStyleSheet(f"color: {DARK_BORDER};")
+ p_layout.addWidget(sep6, row, 0, 1, 2)
+ row += 1
+
+ # -- Kalman Tracking --
+ self._tracking_check = QCheckBox("Kalman Tracking")
+ self._tracking_check.setChecked(self._processing_config.tracking_enabled)
+ if not FILTERPY_AVAILABLE:
+ self._tracking_check.setEnabled(False)
+ self._tracking_check.setToolTip("Requires filterpy")
+ p_layout.addWidget(self._tracking_check, row, 0, 1, 2)
+ row += 1
+
+ # Apply Processing button
+ apply_proc_btn = QPushButton("Apply Processing Settings")
+ apply_proc_btn.setStyleSheet(
+ f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}"
+ f"QPushButton:hover {{ background-color: #66BB6A; }}"
+ )
+ apply_proc_btn.clicked.connect(self._apply_processing_config)
+ p_layout.addWidget(apply_proc_btn, row, 0, 1, 2)
+
+ layout.addWidget(proc_group)
+
+ # ---- About group ---------------------------------------------------
+ about_group = QGroupBox("About")
+ about_layout = QVBoxLayout(about_group)
+ about_lbl = QLabel(
+ "PLFM Radar System GUI V7
"
+ "PyQt6 Edition with Embedded Leaflet Map
"
+ "Data Interface: FT2232HQ (USB 2.0)
"
+ "Map: OpenStreetMap + Leaflet.js
"
+ "Framework: PyQt6 + QWebEngine
"
+ "Version: 7.0.0"
+ )
+ about_lbl.setStyleSheet(f"color: {DARK_TEXT}; padding: 12px;")
+ about_layout.addWidget(about_lbl)
+
+ layout.addWidget(about_group)
+ layout.addStretch()
+
+ scroll.setWidget(inner)
+ tab_layout = QVBoxLayout(tab)
+ tab_layout.setContentsMargins(0, 0, 0, 0)
+ tab_layout.addWidget(scroll)
+
+ self._tabs.addTab(tab, "Settings")
+
+ # =====================================================================
+ # Status bar
+ # =====================================================================
+
+ def _setup_statusbar(self):
+ bar = QStatusBar()
+ self.setStatusBar(bar)
+
+ self._sb_status = QLabel("Ready")
+ bar.addWidget(self._sb_status)
+
+ self._sb_targets = QLabel("Targets: 0")
+ bar.addPermanentWidget(self._sb_targets)
+
+ self._sb_mode = QLabel("Idle")
+ self._sb_mode.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;")
+ bar.addPermanentWidget(self._sb_mode)
+
+ # =====================================================================
+ # Device management
+ # =====================================================================
+
+ def _refresh_devices(self):
+ # STM32
+ self._stm32_devices = self._stm32.list_devices()
+ self._stm32_combo.clear()
+ for d in self._stm32_devices:
+ self._stm32_combo.addItem(d["description"])
+ if self._stm32_devices:
+ self._stm32_combo.setCurrentIndex(0)
+
+ # FT2232HQ (primary)
+ self._ft2232hq_devices = self._ft2232hq.list_devices()
+ self._ft2232hq_combo.clear()
+ for d in self._ft2232hq_devices:
+ self._ft2232hq_combo.addItem(d["description"])
+ if self._ft2232hq_devices:
+ self._ft2232hq_combo.setCurrentIndex(0)
+
+ logger.info(
+ f"Devices refreshed: {len(self._stm32_devices)} STM32, "
+ f"{len(self._ft2232hq_devices)} FT2232HQ"
+ )
+
+ # =====================================================================
+ # Start / Stop radar
+ # =====================================================================
+
+ def _start_radar(self):
+ try:
+ # Open STM32
+ idx = self._stm32_combo.currentIndex()
+ if idx < 0 or idx >= len(self._stm32_devices):
+ QMessageBox.warning(self, "Warning", "Please select an STM32 USB device.")
+ return
+ if not self._stm32.open_device(self._stm32_devices[idx]):
+ QMessageBox.critical(self, "Error", "Failed to open STM32 USB device.")
+ return
+
+ # Open FT2232HQ (primary)
+ idx2 = self._ft2232hq_combo.currentIndex()
+ if idx2 >= 0 and idx2 < len(self._ft2232hq_devices):
+ url = self._ft2232hq_devices[idx2]["url"]
+ if not self._ft2232hq.open_device(url):
+ QMessageBox.warning(self, "Warning",
+ "Failed to open FT2232HQ device. Radar data may not be available.")
+
+ # Send start flag + settings
+ if not self._stm32.send_start_flag():
+ QMessageBox.critical(self, "Error", "Failed to send start flag to STM32.")
+ return
+ self._apply_settings_to_model()
+ self._stm32.send_settings(self._settings)
+
+ # Start workers
+ self._radar_worker = RadarDataWorker(
+ ft2232hq=self._ft2232hq,
+ processor=self._processor,
+ packet_parser=self._radar_parser,
+ settings=self._settings,
+ gps_data_ref=self._radar_position,
+ )
+ self._radar_worker.targetsUpdated.connect(self._on_radar_targets)
+ self._radar_worker.statsUpdated.connect(self._on_radar_stats)
+ self._radar_worker.errorOccurred.connect(self._on_worker_error)
+ self._radar_worker.start()
+
+ self._gps_worker = GPSDataWorker(
+ stm32=self._stm32,
+ usb_parser=self._usb_parser,
+ )
+ self._gps_worker.gpsReceived.connect(self._on_gps_received)
+ self._gps_worker.errorOccurred.connect(self._on_worker_error)
+ self._gps_worker.start()
+
+ # UI state
+ self._running = True
+ self._start_time = time.time()
+ self._start_btn.setEnabled(False)
+ self._stop_btn.setEnabled(True)
+ self._status_label_main.setText("Status: Radar running")
+ self._sb_status.setText("Radar running")
+ self._sb_mode.setText("Live")
+ logger.info("Radar system started")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to start radar: {e}")
+ logger.error(f"Start radar error: {e}")
+
+ def _stop_radar(self):
+ self._running = False
+
+ if self._radar_worker:
+ self._radar_worker.stop()
+ self._radar_worker.wait(2000)
+ self._radar_worker = None
+
+ if self._gps_worker:
+ self._gps_worker.stop()
+ self._gps_worker.wait(2000)
+ self._gps_worker = None
+
+ self._stm32.close()
+ self._ft2232hq.close()
+
+ self._start_btn.setEnabled(True)
+ self._stop_btn.setEnabled(False)
+ self._status_label_main.setText("Status: Radar stopped")
+ self._sb_status.setText("Radar stopped")
+ self._sb_mode.setText("Idle")
+ logger.info("Radar system stopped")
+
+ # =====================================================================
+ # Demo mode
+ # =====================================================================
+
+ def _start_demo(self):
+ if self._simulator:
+ return
+ self._simulator = TargetSimulator(self._radar_position, self)
+ self._simulator.targetsUpdated.connect(self._on_demo_targets)
+ self._simulator.start(500)
+ self._demo_mode = True
+ self._sb_mode.setText("Demo Mode")
+ self._sb_status.setText("Demo mode active")
+ self._demo_btn_main.setText("Stop Demo")
+ self._demo_btn_map.setText("Stop Demo")
+ self._demo_btn_map.setChecked(True)
+ logger.info("Demo mode started")
+
+ def _stop_demo(self):
+ if self._simulator:
+ self._simulator.stop()
+ self._simulator = None
+ self._demo_mode = False
+ self._sb_mode.setText("Idle" if not self._running else "Live")
+ self._sb_status.setText("Demo stopped")
+ self._demo_btn_main.setText("Start Demo")
+ self._demo_btn_map.setText("Start Demo")
+ self._demo_btn_map.setChecked(False)
+ logger.info("Demo mode stopped")
+
+ def _toggle_demo_main(self):
+ if self._demo_mode:
+ self._stop_demo()
+ else:
+ self._start_demo()
+
+ def _toggle_demo_map(self, checked: bool):
+ if checked:
+ self._start_demo()
+ else:
+ self._stop_demo()
+
+ def _add_demo_target(self):
+ if self._simulator:
+ self._simulator.add_random_target()
+ logger.info("Added random demo target")
+
+ # =====================================================================
+ # Slots — data from workers / simulator
+ # =====================================================================
+
+ @pyqtSlot(list)
+ def _on_radar_targets(self, targets: list):
+ self._current_targets = targets
+ self._map_widget.set_targets(targets)
+
+ @pyqtSlot(dict)
+ def _on_radar_stats(self, stats: dict):
+ self._radar_stats = stats
+
+ @pyqtSlot(str)
+ def _on_worker_error(self, msg: str):
+ logger.error(f"Worker error: {msg}")
+
+ @pyqtSlot(object)
+ def _on_gps_received(self, gps: GPSData):
+ self._gps_packet_count += 1
+ self._radar_position.latitude = gps.latitude
+ self._radar_position.longitude = gps.longitude
+ self._radar_position.altitude = gps.altitude
+ self._radar_position.pitch = gps.pitch
+ self._radar_position.timestamp = gps.timestamp
+
+ self._map_widget.set_radar_position(self._radar_position)
+
+ if self._simulator:
+ self._simulator.set_radar_position(self._radar_position)
+
+ @pyqtSlot(list)
+ def _on_demo_targets(self, targets: list):
+ self._current_targets = targets
+ self._map_widget.set_targets(targets)
+ self._sb_targets.setText(f"Targets: {len(targets)}")
+
+ def _on_target_selected(self, target_id: int):
+ for t in self._current_targets:
+ if t.id == target_id:
+ self._show_target_info(t)
+ break
+
+ def _show_target_info(self, target: RadarTarget):
+ status = ("Approaching" if target.velocity > 1
+ else ("Receding" if target.velocity < -1 else "Stationary"))
+ color = (DARK_ERROR if status == "Approaching"
+ else (DARK_INFO if status == "Receding" else DARK_TEXT))
+ info = (
+ f"Target #{target.id}
"
+ f"Track ID: {target.track_id}
"
+ f"Range: {target.range:.1f} m
"
+ f"Velocity: {target.velocity:+.1f} m/s
"
+ f"Azimuth: {target.azimuth:.1f}\u00b0
"
+ f"Elevation: {target.elevation:.1f}\u00b0
"
+ f"SNR: {target.snr:.1f} dB
"
+ f"Class: {target.classification}
"
+ f'Status: {status}'
+ )
+ self._target_info_label.setText(info)
+
+ # =====================================================================
+ # Position / coverage callbacks (map sidebar)
+ # =====================================================================
+
+ def _on_position_changed(self):
+ self._radar_position.latitude = self._lat_spin.value()
+ self._radar_position.longitude = self._lon_spin.value()
+ self._radar_position.altitude = self._alt_spin.value()
+ self._map_widget.set_radar_position(self._radar_position)
+ if self._simulator:
+ self._simulator.set_radar_position(self._radar_position)
+
+ def _on_coverage_changed(self, value: float):
+ radius_m = value * 1000
+ self._settings.coverage_radius = radius_m
+ self._map_widget.set_coverage_radius(radius_m)
+
+ # =====================================================================
+ # Settings
+ # =====================================================================
+
+ def _apply_settings_to_model(self):
+ """Read spin values into the RadarSettings model."""
+ s = self._settings
+ sp = self._setting_spins
+ s.system_frequency = sp["system_frequency"].value() * 1e9
+ s.chirp_duration_1 = sp["chirp_duration_1"].value() * 1e-6
+ s.chirp_duration_2 = sp["chirp_duration_2"].value() * 1e-6
+ s.chirps_per_position = int(sp["chirps_per_position"].value())
+ s.freq_min = sp["freq_min"].value() * 1e6
+ s.freq_max = sp["freq_max"].value() * 1e6
+ s.prf1 = sp["prf1"].value()
+ s.prf2 = sp["prf2"].value()
+ s.max_distance = sp["max_distance"].value() * 1000
+ s.map_size = sp["map_size"].value() * 1000
+
+ def _apply_settings(self):
+ try:
+ self._apply_settings_to_model()
+ if self._stm32.is_open:
+ self._stm32.send_settings(self._settings)
+ logger.info("Radar settings applied")
+ QMessageBox.information(self, "Settings", "Radar settings applied.")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Invalid setting value: {e}")
+ logger.error(f"Settings error: {e}")
+
+ def _apply_processing_config(self):
+ """Read signal processing controls into ProcessingConfig and push to processor."""
+ try:
+ cfg = ProcessingConfig(
+ mti_enabled=self._mti_check.isChecked(),
+ mti_order=self._mti_order_spin.value(),
+ cfar_enabled=self._cfar_check.isChecked(),
+ cfar_type=self._cfar_type_combo.currentText(),
+ cfar_guard_cells=self._cfar_guard_spin.value(),
+ cfar_training_cells=self._cfar_train_spin.value(),
+ cfar_threshold_factor=self._cfar_thresh_spin.value(),
+ dc_notch_enabled=self._dc_notch_check.isChecked(),
+ window_type=self._window_combo.currentText(),
+ detection_threshold_db=self._det_thresh_spin.value(),
+ clustering_enabled=self._cluster_check.isChecked(),
+ clustering_eps=self._cluster_eps_spin.value(),
+ clustering_min_samples=self._cluster_min_spin.value(),
+ tracking_enabled=self._tracking_check.isChecked(),
+ )
+ self._processing_config = cfg
+ self._processor.set_config(cfg)
+ logger.info(
+ f"Processing config applied: MTI={cfg.mti_enabled}(order {cfg.mti_order}), "
+ f"CFAR={cfg.cfar_enabled}({cfg.cfar_type}), DC_Notch={cfg.dc_notch_enabled}, "
+ f"Window={cfg.window_type}, Threshold={cfg.detection_threshold_db} dB, "
+ f"Clustering={cfg.clustering_enabled}, Tracking={cfg.tracking_enabled}"
+ )
+ QMessageBox.information(self, "Processing", "Signal processing settings applied.")
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to apply processing settings: {e}")
+ logger.error(f"Processing config error: {e}")
+
+ # =====================================================================
+ # Periodic GUI refresh (100 ms timer)
+ # =====================================================================
+
+ def _refresh_gui(self):
+ try:
+ # GPS label
+ gps = self._radar_position
+ self._gps_label.setText(
+ f"GPS: Lat {gps.latitude:.6f}, Lon {gps.longitude:.6f}, "
+ f"Alt {gps.altitude:.1f}m"
+ )
+
+ # Pitch label with colour coding
+ pitch_text = f"Pitch: {gps.pitch:+.1f}\u00b0"
+ self._pitch_label.setText(pitch_text)
+ if abs(gps.pitch) > 10:
+ self._pitch_label.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;")
+ elif abs(gps.pitch) > 5:
+ self._pitch_label.setStyleSheet(f"color: {DARK_WARNING}; font-weight: bold;")
+ else:
+ self._pitch_label.setStyleSheet(f"color: {DARK_SUCCESS}; font-weight: bold;")
+
+ # Range-Doppler map
+ self._rdm_canvas.update_map(self._processor.range_doppler_map)
+
+ # Targets table (main tab)
+ self._update_main_targets_table()
+
+ # Status label (main tab)
+ if self._running:
+ pkt = self._radar_stats.get("packets", 0)
+ self._status_label_main.setText(
+ f"Status: Running \u2014 Packets: {pkt} \u2014 Pitch: {gps.pitch:+.1f}\u00b0"
+ )
+
+ # Diagnostics values
+ self._update_diagnostics()
+
+ # Status-bar target count
+ self._sb_targets.setText(f"Targets: {len(self._current_targets)}")
+
+ except Exception as e:
+ logger.error(f"GUI refresh error: {e}")
+
+ def _update_main_targets_table(self):
+ targets = self._current_targets[-20:] # last 20
+ self._targets_table_main.setRowCount(len(targets))
+
+ for row, t in enumerate(targets):
+ self._targets_table_main.setItem(
+ row, 0, QTableWidgetItem(str(t.track_id)))
+ self._targets_table_main.setItem(
+ row, 1, QTableWidgetItem(f"{t.range:.1f}"))
+
+ vel_item = QTableWidgetItem(f"{t.velocity:+.1f}")
+ if t.velocity > 1:
+ vel_item.setForeground(QColor(DARK_ERROR))
+ elif t.velocity < -1:
+ vel_item.setForeground(QColor(DARK_INFO))
+ self._targets_table_main.setItem(row, 2, vel_item)
+
+ self._targets_table_main.setItem(
+ row, 3, QTableWidgetItem(f"{t.azimuth:.1f}"))
+
+ # Raw elevation — show stored value from corrections cache
+ raw_text = "N/A"
+ for corr in self._corrected_elevations[-20:]:
+ if abs(corr["corrected"] - t.elevation) < 0.1:
+ raw_text = f"{corr['raw']}"
+ break
+ self._targets_table_main.setItem(
+ row, 4, QTableWidgetItem(raw_text))
+ self._targets_table_main.setItem(
+ row, 5, QTableWidgetItem(f"{t.elevation:.1f}"))
+ self._targets_table_main.setItem(
+ row, 6, QTableWidgetItem(f"{t.snr:.1f}"))
+
+ def _update_diagnostics(self):
+ # Connection indicators
+ self._set_conn_indicator(self._conn_stm32, self._stm32.is_open)
+ self._set_conn_indicator(self._conn_ft2232hq, self._ft2232hq.is_open)
+
+ stats = self._radar_stats
+ gps_count = self._gps_packet_count
+ if self._gps_worker:
+ gps_count = self._gps_worker.gps_count
+
+ uptime = time.time() - self._start_time
+ pkt = stats.get("packets", 0)
+ pkt_rate = pkt / max(uptime, 1)
+
+ vals = [
+ str(pkt),
+ f"{stats.get('bytes', 0):,}",
+ str(gps_count),
+ str(stats.get("errors", 0)),
+ str(stats.get("active_tracks", len(self._processor.tracks))),
+ str(stats.get("targets", len(self._current_targets))),
+ f"{uptime:.0f}s",
+ f"{pkt_rate:.1f}/s",
+ ]
+ for lbl, v in zip(self._diag_values, vals):
+ lbl.setText(v)
+
+ # =====================================================================
+ # Helpers
+ # =====================================================================
+
+ @staticmethod
+ def _make_status_label(name: str) -> QLabel:
+ lbl = QLabel("Disconnected")
+ lbl.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;")
+ return lbl
+
+ @staticmethod
+ def _set_conn_indicator(label: QLabel, connected: bool):
+ if connected:
+ label.setText("Connected")
+ label.setStyleSheet(f"color: {DARK_SUCCESS}; font-weight: bold;")
+ else:
+ label.setText("Disconnected")
+ label.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;")
+
+ def _log_append(self, message: str):
+ """Append a log message to the diagnostics log viewer."""
+ self._log_text.appendPlainText(message)
+
+ # =====================================================================
+ # Close event
+ # =====================================================================
+
+ def closeEvent(self, event):
+ if self._simulator:
+ self._simulator.stop()
+ if self._radar_worker:
+ self._radar_worker.stop()
+ self._radar_worker.wait(1000)
+ if self._gps_worker:
+ self._gps_worker.stop()
+ self._gps_worker.wait(1000)
+ self._stm32.close()
+ self._ft2232hq.close()
+ logging.getLogger().removeHandler(self._log_handler)
+ event.accept()
+
+
+# =============================================================================
+# Qt-compatible log handler (routes Python logging → QTextEdit)
+# =============================================================================
+
+class _QtLogHandler(logging.Handler):
+ """Sends log records to a callback (called on the thread that emitted)."""
+
+ def __init__(self, callback):
+ super().__init__()
+ self._callback = callback
+ self.setFormatter(logging.Formatter(
+ "%(asctime)s %(levelname)-8s %(message)s",
+ datefmt="%H:%M:%S",
+ ))
+
+ def emit(self, record):
+ try:
+ msg = self.format(record)
+ self._callback(msg)
+ except Exception:
+ pass
diff --git a/9_Firmware/9_3_GUI/v7/hardware.py b/9_Firmware/9_3_GUI/v7/hardware.py
new file mode 100644
index 0000000..2b4b474
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/hardware.py
@@ -0,0 +1,307 @@
+"""
+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)
+"""
+
+import struct
+import logging
+from typing import List, Dict, Optional
+
+from .models import (
+ USB_AVAILABLE, FTDI_AVAILABLE,
+ RadarSettings,
+)
+
+if USB_AVAILABLE:
+ import usb.core
+ import usb.util
+
+if FTDI_AVAILABLE:
+ from pyftdi.ftdi import Ftdi
+ from pyftdi.usbtools import UsbTools
+
+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
+# =============================================================================
+
+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
+ """
+
+ STM32_VID_PIDS = [
+ (0x0483, 0x5740), # STM32 Virtual COM Port
+ (0x0483, 0x3748), # STM32 Discovery
+ (0x0483, 0x374B),
+ (0x0483, 0x374D),
+ (0x0483, 0x374E),
+ (0x0483, 0x3752),
+ ]
+
+ def __init__(self):
+ self.device = None
+ self.is_open: bool = False
+ self.ep_in = None
+ self.ep_out = None
+
+ # ---- enumeration -------------------------------------------------------
+
+ 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")
+ return []
+
+ devices = []
+ try:
+ for vid, pid in self.STM32_VID_PIDS:
+ found = usb.core.find(find_all=True, idVendor=vid, idProduct=pid)
+ for dev in found:
+ 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")
+ devices.append({
+ "description": f"{product} ({serial})",
+ "vendor_id": vid,
+ "product_id": pid,
+ "device": dev,
+ })
+ except Exception:
+ devices.append({
+ "description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
+ "vendor_id": vid,
+ "product_id": pid,
+ "device": dev,
+ })
+ except Exception as e:
+ logger.error(f"Error listing STM32 devices: {e}")
+ return devices
+
+ # ---- open / close ------------------------------------------------------
+
+ 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")
+ return False
+
+ try:
+ self.device = device_info["device"]
+
+ if self.device.is_kernel_driver_active(0):
+ self.device.detach_kernel_driver(0)
+
+ self.device.set_configuration()
+ cfg = self.device.get_active_configuration()
+ intf = cfg[(0, 0)]
+
+ self.ep_out = usb.util.find_descriptor(
+ intf,
+ 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
+ ),
+ )
+
+ if self.ep_out is None or self.ep_in is None:
+ logger.error("Could not find STM32 CDC endpoints")
+ return False
+
+ self.is_open = True
+ logger.info(f"STM32 USB device opened: {device_info.get('description', '')}")
+ return True
+ except Exception as e:
+ logger.error(f"Error opening STM32 device: {e}")
+ return False
+
+ def close(self):
+ """Close STM32 USB device."""
+ if self.device and self.is_open:
+ try:
+ usb.util.dispose_resources(self.device)
+ except Exception 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 ----------------------------------------------------------
+
+ 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."""
+ 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:
+ # 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
diff --git a/9_Firmware/9_3_GUI/v7/map_widget.py b/9_Firmware/9_3_GUI/v7/map_widget.py
new file mode 100644
index 0000000..7a26e0f
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/map_widget.py
@@ -0,0 +1,532 @@
+"""
+v7.map_widget — Embedded Leaflet.js map widget for the PLFM Radar GUI V7.
+
+Classes:
+ - MapBridge — QObject exposed to JavaScript via QWebChannel
+ - RadarMapWidget — QWidget wrapping QWebEngineView with Leaflet map
+
+The full HTML/CSS/JS for Leaflet is generated inline (no external files).
+Supports: OSM, Google, Google Sat, Google Hybrid, ESRI Sat tile servers;
+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,
+ QComboBox, QCheckBox, QPushButton, QLabel,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot, QObject
+from PyQt6.QtWebEngineWidgets import QWebEngineView
+from PyQt6.QtWebChannel import QWebChannel
+
+from .models import (
+ GPSData, RadarTarget, TileServer,
+ DARK_BG, DARK_FG, DARK_ACCENT, DARK_BORDER,
+ DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER,
+ DARK_SUCCESS, DARK_INFO,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# MapBridge — Python <-> JavaScript
+# =============================================================================
+
+class MapBridge(QObject):
+ """Bridge object registered with QWebChannel for JS ↔ Python calls."""
+
+ mapClicked = pyqtSignal(float, float) # lat, lon
+ markerClicked = pyqtSignal(int) # target_id
+ mapReady = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self._map_ready = False
+
+ @pyqtSlot(float, float)
+ def onMapClick(self, lat: float, lon: float):
+ logger.debug(f"Map clicked: {lat}, {lon}")
+ self.mapClicked.emit(lat, lon)
+
+ @pyqtSlot(int)
+ def onMarkerClick(self, target_id: int):
+ logger.debug(f"Marker clicked: #{target_id}")
+ self.markerClicked.emit(target_id)
+
+ @pyqtSlot()
+ def onMapReady(self):
+ logger.info("Leaflet map ready")
+ self._map_ready = True
+ self.mapReady.emit()
+
+ @pyqtSlot(str)
+ def logFromJS(self, message: str):
+ logger.debug(f"[JS] {message}")
+
+ @property
+ def is_ready(self) -> bool:
+ return self._map_ready
+
+
+# =============================================================================
+# RadarMapWidget
+# =============================================================================
+
+class RadarMapWidget(QWidget):
+ """
+ Embeds a Leaflet.js interactive map inside a QWebEngineView.
+
+ Public methods mirror the V6 map API:
+ set_radar_position(gps), set_targets(list), set_coverage_radius(r),
+ set_zoom(level)
+ """
+
+ targetSelected = pyqtSignal(int)
+
+ def __init__(self, radar_lat: float = 41.9028, radar_lon: float = 12.4964,
+ parent=None):
+ super().__init__(parent)
+
+ # State
+ self._radar_position = GPSData(
+ latitude=radar_lat, longitude=radar_lon,
+ altitude=0.0, pitch=0.0, heading=0.0,
+ )
+ self._targets: List[RadarTarget] = []
+ self._coverage_radius = 50_000 # metres
+ self._tile_server = TileServer.OPENSTREETMAP
+ self._show_coverage = True
+ self._show_trails = False
+
+ # Build UI
+ self._setup_ui()
+
+ # Bridge + channel
+ self._bridge = MapBridge(self)
+ self._bridge.mapReady.connect(self._on_map_ready)
+ self._bridge.markerClicked.connect(self._on_marker_clicked)
+
+ self._channel = QWebChannel()
+ self._channel.registerObject("bridge", self._bridge)
+ self._web_view.page().setWebChannel(self._channel)
+
+ # Load the Leaflet map
+ self._load_map()
+
+ # ---- UI setup ----------------------------------------------------------
+
+ def _setup_ui(self):
+ layout = QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ # Control bar
+ bar = QFrame()
+ bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;")
+ bar_layout = QHBoxLayout(bar)
+ bar_layout.setContentsMargins(8, 4, 8, 4)
+
+ # Tile selector
+ self._tile_combo = QComboBox()
+ self._tile_combo.addItem("OpenStreetMap", TileServer.OPENSTREETMAP)
+ self._tile_combo.addItem("Google Maps", TileServer.GOOGLE_MAPS)
+ self._tile_combo.addItem("Google Satellite", TileServer.GOOGLE_SATELLITE)
+ self._tile_combo.addItem("Google Hybrid", TileServer.GOOGLE_HYBRID)
+ self._tile_combo.addItem("ESRI Satellite", TileServer.ESRI_SATELLITE)
+ self._tile_combo.currentIndexChanged.connect(self._on_tile_changed)
+ self._tile_combo.setStyleSheet(f"""
+ QComboBox {{
+ background-color: {DARK_BUTTON}; color: {DARK_FG};
+ border: 1px solid {DARK_BORDER}; padding: 4px 8px; border-radius: 4px;
+ }}
+ """)
+ bar_layout.addWidget(QLabel("Tiles:"))
+ bar_layout.addWidget(self._tile_combo)
+
+ # Toggles
+ self._coverage_check = QCheckBox("Coverage")
+ self._coverage_check.setChecked(True)
+ self._coverage_check.stateChanged.connect(self._on_coverage_toggled)
+ bar_layout.addWidget(self._coverage_check)
+
+ self._trails_check = QCheckBox("Trails")
+ self._trails_check.setChecked(False)
+ self._trails_check.stateChanged.connect(self._on_trails_toggled)
+ bar_layout.addWidget(self._trails_check)
+
+ btn_style = f"""
+ QPushButton {{
+ background-color: {DARK_BUTTON}; color: {DARK_FG};
+ border: 1px solid {DARK_BORDER}; padding: 4px 12px; border-radius: 4px;
+ }}
+ QPushButton:hover {{ background-color: {DARK_BUTTON_HOVER}; }}
+ """
+
+ center_btn = QPushButton("Center")
+ center_btn.clicked.connect(self._center_on_radar)
+ center_btn.setStyleSheet(btn_style)
+ bar_layout.addWidget(center_btn)
+
+ fit_btn = QPushButton("Fit All")
+ fit_btn.clicked.connect(self._fit_all)
+ fit_btn.setStyleSheet(btn_style)
+ bar_layout.addWidget(fit_btn)
+
+ bar_layout.addStretch()
+
+ self._status_label = QLabel("Loading map...")
+ self._status_label.setStyleSheet(f"color: {DARK_INFO};")
+ bar_layout.addWidget(self._status_label)
+
+ layout.addWidget(bar)
+
+ # Web view
+ self._web_view = QWebEngineView()
+ self._web_view.setMinimumSize(400, 300)
+ layout.addWidget(self._web_view, stretch=1)
+
+ # ---- HTML generation ---------------------------------------------------
+
+ def _get_map_html(self) -> str:
+ lat = self._radar_position.latitude
+ lon = self._radar_position.longitude
+ cov = self._coverage_radius
+
+ # Using {{ / }} for literal braces inside the f-string
+ return f'''
+
+
+
+
+Radar Map
+
+
+
+
+
+
+
+
+
+'''
+
+ # ---- load / helpers ----------------------------------------------------
+
+ def _load_map(self):
+ self._web_view.setHtml(self._get_map_html())
+ logger.info("Leaflet map HTML loaded")
+
+ def _on_map_ready(self):
+ self._status_label.setText(f"Map ready - {len(self._targets)} targets")
+ self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
+
+ def _on_marker_clicked(self, tid: int):
+ self.targetSelected.emit(tid)
+
+ def _run_js(self, script: str):
+ self._web_view.page().runJavaScript(script)
+
+ # ---- control bar callbacks ---------------------------------------------
+
+ def _on_tile_changed(self, _index: int):
+ server = self._tile_combo.currentData()
+ if server:
+ self._tile_server = server
+ self._run_js(f"setTileServer('{server.value}')")
+
+ def _on_coverage_toggled(self, state: int):
+ vis = state == Qt.CheckState.Checked.value
+ self._show_coverage = vis
+ self._run_js(f"setCoverageVisible({str(vis).lower()})")
+
+ def _on_trails_toggled(self, state: int):
+ vis = state == Qt.CheckState.Checked.value
+ self._show_trails = vis
+ self._run_js(f"setTrailsVisible({str(vis).lower()})")
+
+ def _center_on_radar(self):
+ self._run_js("centerOnRadar()")
+
+ def _fit_all(self):
+ self._run_js("fitAllTargets()")
+
+ # ---- public API --------------------------------------------------------
+
+ def set_radar_position(self, gps: GPSData):
+ self._radar_position = gps
+ self._run_js(
+ f"updateRadarPosition({gps.latitude},{gps.longitude},"
+ f"{gps.altitude},{gps.pitch},{gps.heading})"
+ )
+
+ def set_targets(self, targets: List[RadarTarget]):
+ self._targets = targets
+ data = [t.to_dict() for t in targets]
+ js = json.dumps(data).replace("'", "\\'")
+ self._status_label.setText(f"{len(targets)} targets tracked")
+ self._run_js(f"updateTargets('{js}')")
+
+ def set_coverage_radius(self, radius_m: float):
+ self._coverage_radius = radius_m
+ self._run_js(f"setCoverageRadius({radius_m})")
+
+ def set_zoom(self, level: int):
+ level = max(0, min(22, level))
+ self._run_js(f"setZoom({level})")
diff --git a/9_Firmware/9_3_GUI/v7/models.py b/9_Firmware/9_3_GUI/v7/models.py
new file mode 100644
index 0000000..45da35c
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/models.py
@@ -0,0 +1,187 @@
+"""
+v7.models — Data classes, enums, and theme constants for the PLFM Radar GUI V7.
+
+This module defines the core data structures used throughout the application:
+ - RadarTarget, RadarSettings, GPSData (dataclasses)
+ - TileServer (enum for map tile providers)
+ - Dark theme color constants
+ - Optional dependency availability flags
+"""
+
+import logging
+from dataclasses import dataclass, asdict
+from enum import Enum
+
+
+# ---------------------------------------------------------------------------
+# Optional dependency flags (graceful degradation)
+# ---------------------------------------------------------------------------
+try:
+ import usb.core
+ import usb.util # noqa: F401 — availability check
+ USB_AVAILABLE = True
+except ImportError:
+ USB_AVAILABLE = False
+ logging.warning("pyusb not available. USB functionality will be disabled.")
+
+try:
+ from pyftdi.ftdi import Ftdi # noqa: F401 — availability check
+ from pyftdi.usbtools import UsbTools # noqa: F401 — availability check
+ from pyftdi.ftdi import FtdiError # noqa: F401 — availability check
+ FTDI_AVAILABLE = True
+except ImportError:
+ FTDI_AVAILABLE = False
+ logging.warning("pyftdi not available. FTDI functionality will be disabled.")
+
+try:
+ from scipy import signal as _scipy_signal # noqa: F401 — availability check
+ SCIPY_AVAILABLE = True
+except ImportError:
+ SCIPY_AVAILABLE = False
+ logging.warning("scipy not available. Some DSP features will be disabled.")
+
+try:
+ from sklearn.cluster import DBSCAN as _DBSCAN # noqa: F401 — availability check
+ SKLEARN_AVAILABLE = True
+except ImportError:
+ SKLEARN_AVAILABLE = False
+ logging.warning("sklearn not available. Clustering will be disabled.")
+
+try:
+ from filterpy.kalman import KalmanFilter as _KalmanFilter # noqa: F401 — availability check
+ FILTERPY_AVAILABLE = True
+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)
+# ---------------------------------------------------------------------------
+DARK_BG = "#2b2b2b"
+DARK_FG = "#e0e0e0"
+DARK_ACCENT = "#3c3f41"
+DARK_HIGHLIGHT = "#4e5254"
+DARK_BORDER = "#555555"
+DARK_TEXT = "#cccccc"
+DARK_BUTTON = "#3c3f41"
+DARK_BUTTON_HOVER = "#4e5254"
+DARK_TREEVIEW = "#3c3f41"
+DARK_TREEVIEW_ALT = "#404040"
+DARK_SUCCESS = "#4CAF50"
+DARK_WARNING = "#FFC107"
+DARK_ERROR = "#F44336"
+DARK_INFO = "#2196F3"
+
+# ---------------------------------------------------------------------------
+# Data classes
+# ---------------------------------------------------------------------------
+
+@dataclass
+class RadarTarget:
+ """Represents a detected radar target."""
+ id: int
+ range: float # Range in meters
+ velocity: float # Velocity in m/s (positive = approaching)
+ azimuth: float # Azimuth angle in degrees
+ elevation: float # Elevation angle in degrees
+ latitude: float = 0.0
+ longitude: float = 0.0
+ snr: float = 0.0 # Signal-to-noise ratio in dB
+ timestamp: float = 0.0
+ track_id: int = -1
+ classification: str = "unknown"
+
+ def to_dict(self) -> dict:
+ """Convert to dictionary for JSON serialization."""
+ return asdict(self)
+
+
+@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)
+ max_distance: float = 50000 # Max detection range (m)
+ map_size: float = 50000 # Map display size (m)
+ coverage_radius: float = 50000 # Map coverage radius (m)
+
+
+@dataclass
+class GPSData:
+ """GPS position and orientation data."""
+ latitude: float
+ longitude: float
+ altitude: float
+ pitch: float # Pitch angle in degrees
+ heading: float = 0.0 # Heading in degrees (0 = North)
+ timestamp: float = 0.0
+
+ def to_dict(self) -> dict:
+ return asdict(self)
+
+
+# ---------------------------------------------------------------------------
+# Tile server enum
+# ---------------------------------------------------------------------------
+
+@dataclass
+class ProcessingConfig:
+ """Signal processing pipeline configuration.
+
+ Controls: MTI filter, CFAR detector, DC notch removal,
+ windowing, detection threshold, DBSCAN clustering, and Kalman tracking.
+ """
+
+ # MTI (Moving Target Indication)
+ mti_enabled: bool = False
+ mti_order: int = 2 # 1, 2, or 3
+
+ # CFAR (Constant False Alarm Rate)
+ cfar_enabled: bool = False
+ cfar_type: str = "CA-CFAR" # CA-CFAR, OS-CFAR, GO-CFAR, SO-CFAR
+ cfar_guard_cells: int = 2
+ cfar_training_cells: int = 8
+ cfar_threshold_factor: float = 5.0 # PFA-related scalar
+
+ # DC Notch / DC Removal
+ dc_notch_enabled: bool = False
+
+ # Windowing (applied before FFT)
+ window_type: str = "Hann" # None, Hann, Hamming, Blackman, Kaiser, Chebyshev
+
+ # Detection threshold (dB above noise floor)
+ detection_threshold_db: float = 12.0
+
+ # DBSCAN Clustering
+ clustering_enabled: bool = True
+ clustering_eps: float = 100.0
+ clustering_min_samples: int = 2
+
+ # Kalman Tracking
+ tracking_enabled: bool = True
+
+
+# ---------------------------------------------------------------------------
+# Tile server enum
+# ---------------------------------------------------------------------------
+
+class TileServer(Enum):
+ """Available map tile servers."""
+ OPENSTREETMAP = "osm"
+ GOOGLE_MAPS = "google"
+ GOOGLE_SATELLITE = "google_sat"
+ GOOGLE_HYBRID = "google_hybrid"
+ ESRI_SATELLITE = "esri_sat"
diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py
new file mode 100644
index 0000000..e417479
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/processing.py
@@ -0,0 +1,642 @@
+"""
+v7.processing — Radar signal processing, packet parsing, 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.
+"""
+
+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,
+)
+
+if SKLEARN_AVAILABLE:
+ from sklearn.cluster import DBSCAN
+
+if FILTERPY_AVAILABLE:
+ from filterpy.kalman import KalmanFilter
+
+if CRCMOD_AVAILABLE:
+ import crcmod
+
+if SCIPY_AVAILABLE:
+ from scipy.signal import windows as scipy_windows
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Utility: pitch correction (Bug #4 fix — was never defined in V6)
+# =============================================================================
+
+def apply_pitch_correction(raw_elevation: float, pitch: float) -> float:
+ """
+ Apply platform pitch correction to a raw elevation angle.
+
+ Returns the corrected elevation = raw_elevation - pitch.
+ """
+ return raw_elevation - pitch
+
+
+# =============================================================================
+# Radar Processor — signal-level processing & tracking pipeline
+# =============================================================================
+
+class RadarProcessor:
+ """Full radar processing pipeline: fusion, clustering, association, tracking."""
+
+ def __init__(self):
+ self.range_doppler_map = np.zeros((1024, 32))
+ self.detected_targets: List[RadarTarget] = []
+ self.track_id_counter: int = 0
+ 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] = []
+
+ # ---- Configuration -----------------------------------------------------
+
+ def set_config(self, config: ProcessingConfig):
+ """Update the processing configuration and reset MTI history if needed."""
+ old_order = self.config.mti_order
+ self.config = config
+ if config.mti_order != old_order:
+ self._mti_history.clear()
+
+ # ---- Windowing ----------------------------------------------------------
+
+ @staticmethod
+ def apply_window(data: np.ndarray, window_type: str) -> np.ndarray:
+ """Apply a window function along each column (slow-time dimension).
+
+ *data* shape: (range_bins, doppler_bins). Window is applied along
+ axis-1 (Doppler / slow-time).
+ """
+ if window_type == "None" or not window_type:
+ return data
+
+ n = data.shape[1]
+ if n < 2:
+ return data
+
+ if SCIPY_AVAILABLE:
+ wtype = window_type.lower()
+ if wtype == "hann":
+ w = scipy_windows.hann(n, sym=False)
+ elif wtype == "hamming":
+ w = scipy_windows.hamming(n, sym=False)
+ elif wtype == "blackman":
+ w = scipy_windows.blackman(n)
+ elif wtype == "kaiser":
+ w = scipy_windows.kaiser(n, beta=14)
+ elif wtype == "chebyshev":
+ w = scipy_windows.chebwin(n, at=80)
+ else:
+ w = np.ones(n)
+ else:
+ # Fallback: numpy Hann
+ wtype = window_type.lower()
+ if wtype == "hann":
+ w = np.hanning(n)
+ elif wtype == "hamming":
+ w = np.hamming(n)
+ elif wtype == "blackman":
+ w = np.blackman(n)
+ else:
+ w = np.ones(n)
+
+ return data * w[np.newaxis, :]
+
+ # ---- DC Notch (zero-Doppler removal) ------------------------------------
+
+ @staticmethod
+ def dc_notch(data: np.ndarray) -> np.ndarray:
+ """Remove the DC (zero-Doppler) component by subtracting the
+ mean along the slow-time axis for each range bin."""
+ return data - np.mean(data, axis=1, keepdims=True)
+
+ # ---- MTI (Moving Target Indication) -------------------------------------
+
+ def mti_filter(self, frame: np.ndarray) -> np.ndarray:
+ """Apply MTI cancellation of order 1, 2, or 3.
+
+ Order-1: y[n] = x[n] - x[n-1]
+ Order-2: y[n] = x[n] - 2*x[n-1] + x[n-2]
+ Order-3: y[n] = x[n] - 3*x[n-1] + 3*x[n-2] - x[n-3]
+
+ The internal history buffer stores up to 3 previous frames.
+ """
+ order = self.config.mti_order
+ self._mti_history.append(frame.copy())
+
+ # Trim history to order + 1 frames
+ max_len = order + 1
+ if len(self._mti_history) > max_len:
+ self._mti_history = self._mti_history[-max_len:]
+
+ if len(self._mti_history) < order + 1:
+ # Not enough history yet — return zeros (suppress output)
+ return np.zeros_like(frame)
+
+ h = self._mti_history
+ if order == 1:
+ return h[-1] - h[-2]
+ elif order == 2:
+ return h[-1] - 2.0 * h[-2] + h[-3]
+ elif order == 3:
+ return h[-1] - 3.0 * h[-2] + 3.0 * h[-3] - h[-4]
+ else:
+ return h[-1] - h[-2]
+
+ # ---- CFAR (Constant False Alarm Rate) -----------------------------------
+
+ @staticmethod
+ def cfar_1d(signal_vec: np.ndarray, guard: int, train: int,
+ threshold_factor: float, cfar_type: str = "CA-CFAR") -> np.ndarray:
+ """1-D CFAR detector.
+
+ Parameters
+ ----------
+ signal_vec : 1-D array (power in linear scale)
+ guard : number of guard cells on each side
+ train : number of training cells on each side
+ threshold_factor : multiplier on estimated noise level
+ cfar_type : CA-CFAR, OS-CFAR, GO-CFAR, or SO-CFAR
+
+ Returns
+ -------
+ detections : boolean array, True where target detected
+ """
+ n = len(signal_vec)
+ detections = np.zeros(n, dtype=bool)
+ half = guard + train
+
+ for i in range(half, n - half):
+ # Leading training cells
+ lead = signal_vec[i - half: i - guard]
+ # Lagging training cells
+ lag = signal_vec[i + guard + 1: i + half + 1]
+
+ if cfar_type == "CA-CFAR":
+ noise = (np.sum(lead) + np.sum(lag)) / (2 * train)
+ elif cfar_type == "GO-CFAR":
+ noise = max(np.mean(lead), np.mean(lag))
+ elif cfar_type == "SO-CFAR":
+ noise = min(np.mean(lead), np.mean(lag))
+ elif cfar_type == "OS-CFAR":
+ all_train = np.concatenate([lead, lag])
+ all_train.sort()
+ k = int(0.75 * len(all_train)) # 75th percentile
+ noise = all_train[min(k, len(all_train) - 1)]
+ else:
+ noise = (np.sum(lead) + np.sum(lag)) / (2 * train)
+
+ threshold = noise * threshold_factor
+ if signal_vec[i] > threshold:
+ detections[i] = True
+
+ return detections
+
+ def cfar_2d(self, rdm: np.ndarray) -> np.ndarray:
+ """Apply 1-D CFAR along each range bin (across Doppler dimension).
+
+ Returns a boolean mask of the same shape as *rdm*.
+ """
+ cfg = self.config
+ mask = np.zeros_like(rdm, dtype=bool)
+ for r in range(rdm.shape[0]):
+ row = rdm[r, :]
+ if row.max() > 0:
+ mask[r, :] = self.cfar_1d(
+ row, cfg.cfar_guard_cells, cfg.cfar_training_cells,
+ cfg.cfar_threshold_factor, cfg.cfar_type,
+ )
+ return mask
+
+ # ---- Full processing pipeline -------------------------------------------
+
+ 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
+ ----------
+ raw_frame : 2-D array (range_bins x doppler_bins), complex or real
+
+ Returns
+ -------
+ (processed_rdm, detection_mask)
+ processed_rdm — processed Range-Doppler map (power, linear)
+ detection_mask — boolean mask of CFAR / threshold detections
+ """
+ cfg = self.config
+ data = raw_frame.astype(np.float64)
+
+ # 1. DC Notch
+ if cfg.dc_notch_enabled:
+ data = self.dc_notch(data)
+
+ # 2. Windowing (before FFT — applied along slow-time axis)
+ if cfg.window_type and cfg.window_type != "None":
+ data = self.apply_window(data, cfg.window_type)
+
+ # 3. MTI
+ if cfg.mti_enabled:
+ data = self.mti_filter(data)
+
+ # 4. Power (magnitude squared)
+ power = np.abs(data) ** 2
+ power = np.maximum(power, 1e-20) # avoid log(0)
+
+ # 5. CFAR detection or simple threshold
+ if cfg.cfar_enabled:
+ detection_mask = self.cfar_2d(power)
+ else:
+ # Simple threshold: convert dB threshold to linear
+ power_db = 10.0 * np.log10(power)
+ noise_floor = np.median(power_db)
+ detection_mask = power_db > (noise_floor + cfg.detection_threshold_db)
+
+ # Update stored RDM
+ self.range_doppler_map = power
+ self.frame_count += 1
+
+ return power, detection_mask
+
+ # ---- Dual-CPI fusion ---------------------------------------------------
+
+ @staticmethod
+ def dual_cpi_fusion(range_profiles_1: np.ndarray,
+ range_profiles_2: np.ndarray) -> np.ndarray:
+ """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],
+ eps: float = 100, min_samples: int = 2) -> list:
+ """DBSCAN clustering of detections (requires sklearn)."""
+ if not SKLEARN_AVAILABLE or len(detections) == 0:
+ return []
+
+ points = np.array([[d.range, d.velocity] for d in detections])
+ labels = DBSCAN(eps=eps, min_samples=min_samples).fit(points).labels_
+
+ clusters = []
+ for label in set(labels):
+ if label == -1:
+ continue
+ cluster_points = points[labels == label]
+ clusters.append({
+ "center": np.mean(cluster_points, axis=0),
+ "points": cluster_points,
+ "size": len(cluster_points),
+ })
+ return clusters
+
+ # ---- Association -------------------------------------------------------
+
+ def association(self, detections: List[RadarTarget],
+ clusters: list) -> List[RadarTarget]:
+ """Associate detections to existing tracks (nearest-neighbour)."""
+ associated = []
+ for det in detections:
+ best_track = None
+ min_dist = float("inf")
+ for tid, track in self.tracks.items():
+ dist = math.sqrt(
+ (det.range - track["state"][0]) ** 2
+ + (det.velocity - track["state"][2]) ** 2
+ )
+ if dist < min_dist and dist < 500:
+ min_dist = dist
+ best_track = tid
+
+ if best_track is not None:
+ det.track_id = best_track
+ else:
+ det.track_id = self.track_id_counter
+ self.track_id_counter += 1
+
+ associated.append(det)
+ return associated
+
+ # ---- Kalman tracking ---------------------------------------------------
+
+ def tracking(self, associated_detections: List[RadarTarget]):
+ """Kalman filter tracking (requires filterpy)."""
+ if not FILTERPY_AVAILABLE:
+ return
+
+ now = time.time()
+
+ for det in associated_detections:
+ if det.track_id not in self.tracks:
+ kf = KalmanFilter(dim_x=4, dim_z=2)
+ kf.x = np.array([det.range, 0, det.velocity, 0])
+ kf.F = np.array([
+ [1, 1, 0, 0],
+ [0, 1, 0, 0],
+ [0, 0, 1, 1],
+ [0, 0, 0, 1],
+ ])
+ kf.H = np.array([
+ [1, 0, 0, 0],
+ [0, 0, 1, 0],
+ ])
+ kf.P *= 1000
+ kf.R = np.diag([10, 1])
+ kf.Q = np.eye(4) * 0.1
+
+ self.tracks[det.track_id] = {
+ "filter": kf,
+ "state": kf.x,
+ "last_update": now,
+ "hits": 1,
+ }
+ else:
+ track = self.tracks[det.track_id]
+ track["filter"].predict()
+ track["filter"].update([det.range, det.velocity])
+ track["state"] = track["filter"].x
+ track["last_update"] = now
+ track["hits"] += 1
+
+ # Prune stale tracks (> 5 s without update)
+ stale = [tid for tid, t in self.tracks.items()
+ if now - t["last_update"] > 5.0]
+ for tid in stale:
+ 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(" 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
+# =============================================================================
+
+class USBPacketParser:
+ """
+ Parse GPS (and general) data arriving from the STM32 via USB CDC.
+
+ Supports:
+ - Text format: ``GPS:lat,lon,alt,pitch\\r\\n``
+ - Binary format: ``GPSB`` header, 30 bytes total
+ """
+
+ def __init__(self):
+ if CRCMOD_AVAILABLE:
+ self.crc16_func = crcmod.mkCrcFun(
+ 0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
+ )
+ else:
+ self.crc16_func = None
+
+ def parse_gps_data(self, data: bytes) -> Optional[GPSData]:
+ """Attempt to parse GPS data from a raw USB CDC frame."""
+ if not data:
+ return None
+
+ try:
+ # Text format: "GPS:lat,lon,alt,pitch\r\n"
+ text = data.decode("utf-8", errors="ignore").strip()
+ if text.startswith("GPS:"):
+ parts = text.split(":")[1].split(",")
+ if len(parts) >= 4:
+ return GPSData(
+ latitude=float(parts[0]),
+ longitude=float(parts[1]),
+ altitude=float(parts[2]),
+ pitch=float(parts[3]),
+ timestamp=time.time(),
+ )
+
+ # 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:
+ logger.error(f"Error parsing GPS data: {e}")
+ return None
+
+ @staticmethod
+ def _parse_binary_gps(data: bytes) -> Optional[GPSData]:
+ """Parse 30-byte binary GPS frame."""
+ try:
+ if len(data) < 30:
+ return None
+
+ # Simple checksum CRC
+ crc_rcv = (data[28] << 8) | data[29]
+ crc_calc = sum(data[0:28]) & 0xFFFF
+ if crc_rcv != crc_calc:
+ logger.warning("GPS binary CRC mismatch")
+ return None
+
+ lat = struct.unpack(">d", data[4:12])[0]
+ lon = struct.unpack(">d", data[12:20])[0]
+ alt = struct.unpack(">f", data[20:24])[0]
+ pitch = struct.unpack(">f", data[24:28])[0]
+
+ return GPSData(
+ latitude=lat,
+ longitude=lon,
+ altitude=alt,
+ pitch=pitch,
+ timestamp=time.time(),
+ )
+ except Exception as e:
+ logger.error(f"Error parsing binary GPS: {e}")
+ return None
diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py
new file mode 100644
index 0000000..e81616e
--- /dev/null
+++ b/9_Firmware/9_3_GUI/v7/workers.py
@@ -0,0 +1,389 @@
+"""
+v7.workers — QThread-based workers and demo target simulator.
+
+Classes:
+ - RadarDataWorker — reads from FT2232HQ, parses packets,
+ emits signals with processed data.
+ - GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals.
+ - TargetSimulator — QTimer-based demo target generator (from GUI_PyQt_Map.py).
+"""
+
+import math
+import time
+import random
+import logging
+from typing import List
+
+from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
+
+from .models import RadarTarget, RadarSettings, GPSData
+from .hardware import FT2232HQInterface, STM32USBInterface
+from .processing import (
+ RadarProcessor, RadarPacketParser, USBPacketParser,
+ apply_pitch_correction,
+)
+
+logger = logging.getLogger(__name__)
+
+
+# =============================================================================
+# Utility: polar → geographic
+# =============================================================================
+
+def polar_to_geographic(
+ radar_lat: float,
+ radar_lon: float,
+ range_m: float,
+ azimuth_deg: float,
+) -> tuple:
+ """
+ Convert polar coordinates (range, azimuth) relative to radar
+ to geographic (latitude, longitude).
+
+ azimuth_deg: 0 = North, clockwise.
+ Returns (lat, lon).
+ """
+ R = 6_371_000 # Earth radius in meters
+
+ lat1 = math.radians(radar_lat)
+ lon1 = math.radians(radar_lon)
+ bearing = math.radians(azimuth_deg)
+
+ lat2 = math.asin(
+ math.sin(lat1) * math.cos(range_m / R)
+ + math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing)
+ )
+ lon2 = lon1 + math.atan2(
+ math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1),
+ math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2),
+ )
+ return (math.degrees(lat2), math.degrees(lon2))
+
+
+# =============================================================================
+# Radar Data Worker (QThread)
+# =============================================================================
+
+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.
+
+ Signals:
+ packetReceived(dict) — a single parsed packet dict
+ targetsUpdated(list) — list of RadarTarget after processing
+ errorOccurred(str) — error message
+ statsUpdated(dict) — packet/byte counters
+ """
+
+ packetReceived = pyqtSignal(dict)
+ targetsUpdated = pyqtSignal(list)
+ errorOccurred = pyqtSignal(str)
+ statsUpdated = pyqtSignal(dict)
+
+ def __init__(
+ self,
+ ft2232hq: FT2232HQInterface,
+ processor: RadarProcessor,
+ packet_parser: RadarPacketParser,
+ settings: RadarSettings,
+ gps_data_ref: GPSData,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self._ft2232hq = ft2232hq
+ self._processor = processor
+ self._parser = packet_parser
+ self._settings = settings
+ self._gps = gps_data_ref
+ self._running = False
+
+ # Counters
+ self._packet_count = 0
+ self._byte_count = 0
+ self._error_count = 0
+
+ def stop(self):
+ self._running = False
+
+ def run(self):
+ """Main loop: read → parse → process → emit."""
+ self._running = True
+ buffer = bytearray()
+
+ 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)
+
+ # 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
+
+ pkt, consumed = result
+ buffer = buffer[consumed:]
+ self._packet_count += 1
+
+ # Process the packet
+ self._process_packet(pkt)
+ self.packetReceived.emit(pkt)
+
+ # 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:
+ self._error_count += 1
+ self.errorOccurred.emit(str(e))
+ logger.error(f"RadarDataWorker error: {e}")
+ self.msleep(100)
+
+ # ---- internal packet handling ------------------------------------------
+
+ 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"]
+ 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"],
+ )
+ 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)
+
+ 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}")
+
+ 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
+
+
+# =============================================================================
+# GPS Data Worker (QThread)
+# =============================================================================
+
+class GPSDataWorker(QThread):
+ """
+ Background worker that reads GPS frames from the STM32 USB CDC interface
+ and emits parsed GPSData objects.
+
+ Signals:
+ gpsReceived(GPSData)
+ errorOccurred(str)
+ """
+
+ gpsReceived = pyqtSignal(object) # GPSData
+ errorOccurred = pyqtSignal(str)
+
+ def __init__(
+ self,
+ stm32: STM32USBInterface,
+ usb_parser: USBPacketParser,
+ parent=None,
+ ):
+ super().__init__(parent)
+ self._stm32 = stm32
+ self._parser = usb_parser
+ self._running = False
+ self._gps_count = 0
+
+ @property
+ def gps_count(self) -> int:
+ return self._gps_count
+
+ def stop(self):
+ self._running = False
+
+ def run(self):
+ self._running = True
+ while self._running:
+ if not (self._stm32 and self._stm32.is_open):
+ self.msleep(100)
+ continue
+
+ try:
+ data = self._stm32.read_data(64, timeout=100)
+ if data:
+ gps = self._parser.parse_gps_data(data)
+ if gps:
+ self._gps_count += 1
+ self.gpsReceived.emit(gps)
+ except Exception as e:
+ self.errorOccurred.emit(str(e))
+ logger.error(f"GPSDataWorker error: {e}")
+ self.msleep(100)
+
+
+# =============================================================================
+# Target Simulator (Demo Mode) — QTimer-based
+# =============================================================================
+
+class TargetSimulator(QObject):
+ """
+ Generates simulated radar targets for demo/testing.
+
+ Uses a QTimer on the main thread (or whichever thread owns this object).
+ Emits ``targetsUpdated`` with a list[RadarTarget] on each tick.
+ """
+
+ targetsUpdated = pyqtSignal(list)
+
+ def __init__(self, radar_position: GPSData, parent=None):
+ super().__init__(parent)
+ self._radar_pos = radar_position
+ self._targets: List[RadarTarget] = []
+ self._next_id = 1
+ self._timer = QTimer(self)
+ self._timer.timeout.connect(self._tick)
+ self._initialize_targets(8)
+
+ # ---- public API --------------------------------------------------------
+
+ def start(self, interval_ms: int = 500):
+ self._timer.start(interval_ms)
+
+ def stop(self):
+ self._timer.stop()
+
+ def set_radar_position(self, gps: GPSData):
+ self._radar_pos = gps
+
+ def add_random_target(self):
+ self._add_random_target()
+
+ # ---- internals ---------------------------------------------------------
+
+ def _initialize_targets(self, count: int):
+ for _ in range(count):
+ self._add_random_target()
+
+ def _add_random_target(self):
+ range_m = random.uniform(5000, 40000)
+ azimuth = random.uniform(0, 360)
+ velocity = random.uniform(-100, 100)
+ elevation = random.uniform(-5, 45)
+
+ lat, lon = polar_to_geographic(
+ self._radar_pos.latitude,
+ self._radar_pos.longitude,
+ range_m,
+ azimuth,
+ )
+
+ target = RadarTarget(
+ id=self._next_id,
+ range=range_m,
+ velocity=velocity,
+ azimuth=azimuth,
+ elevation=elevation,
+ latitude=lat,
+ longitude=lon,
+ snr=random.uniform(10, 35),
+ timestamp=time.time(),
+ track_id=self._next_id,
+ classification=random.choice(["aircraft", "drone", "bird", "unknown"]),
+ )
+ self._next_id += 1
+ self._targets.append(target)
+
+ def _tick(self):
+ """Update all simulated targets and emit."""
+ updated: List[RadarTarget] = []
+
+ for t in self._targets:
+ new_range = t.range - t.velocity * 0.5
+ if new_range < 500 or new_range > 50000:
+ continue # target exits coverage — drop it
+
+ new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2)))
+ new_az = (t.azimuth + random.uniform(-0.5, 0.5)) % 360
+
+ lat, lon = polar_to_geographic(
+ self._radar_pos.latitude,
+ self._radar_pos.longitude,
+ new_range,
+ new_az,
+ )
+
+ updated.append(RadarTarget(
+ id=t.id,
+ range=new_range,
+ velocity=new_vel,
+ azimuth=new_az,
+ elevation=t.elevation + random.uniform(-0.1, 0.1),
+ latitude=lat,
+ longitude=lon,
+ snr=t.snr + random.uniform(-1, 1),
+ timestamp=time.time(),
+ track_id=t.track_id,
+ classification=t.classification,
+ ))
+
+ # Maintain a reasonable target count
+ if len(updated) < 5 or (random.random() < 0.05 and len(updated) < 15):
+ self._add_random_target()
+ updated.append(self._targets[-1])
+
+ self._targets = updated
+ self.targetsUpdated.emit(updated)