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)