From ce391f1ae61789a065f587c8f4116158d6ea4ab5 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:09:12 +0300 Subject: [PATCH 01/57] ci: add GitHub Actions workflow for full regression suite Three parallel jobs covering all AERIS-10 test infrastructure: - Python dashboard tests (58): protocol, connection, replay, opcodes, e2e - MCU firmware tests (20): bug regression (15) + Gap-3 safety (5) - FPGA regression (23 TBs + lint): unit, integration, and system e2e Triggers on push/PR to main and develop branches. --- .github/workflows/ci-tests.yml | 71 ++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/ci-tests.yml diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml new file mode 100644 index 0000000..77bbb99 --- /dev/null +++ b/.github/workflows/ci-tests.yml @@ -0,0 +1,71 @@ +name: AERIS-10 CI + +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + +jobs: + # =========================================================================== + # Job 1: Python Host Software Tests (58 tests) + # radar_protocol, radar_dashboard, FT2232H connection, replay, opcodes, e2e + # =========================================================================== + python-tests: + name: Python Dashboard Tests (58) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest numpy h5py + + - name: Run test suite + run: python -m pytest 9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short + + # =========================================================================== + # Job 2: MCU Firmware Unit Tests (20 tests) + # Bug regression (15) + Gap-3 safety tests (5) + # =========================================================================== + mcu-tests: + name: MCU Firmware Tests (20) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install build tools + run: sudo apt-get update && sudo apt-get install -y build-essential + + - name: Build and run MCU tests + working-directory: 9_Firmware/9_1_Microcontroller/tests + run: make test + + # =========================================================================== + # Job 3: FPGA RTL Regression (23 testbenches + lint) + # Phase 0: Vivado-style lint, Phase 1-4: unit + integration + e2e + # =========================================================================== + fpga-regression: + name: FPGA Regression (23 TBs + lint) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Icarus Verilog + run: sudo apt-get update && sudo apt-get install -y iverilog + + - name: Run full FPGA regression + working-directory: 9_Firmware/9_2_FPGA + run: bash run_regression.sh From 836243ab1849171f04693b97014607089fbb0d1d Mon Sep 17 00:00:00 2001 From: Serhii Date: Wed, 8 Apr 2026 01:03:18 +0300 Subject: [PATCH 02/57] docs(readme): add Alpha disclaimer to Key Features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core hardware works, but GPS/map integration, attitude correction, and beam steering UI are not yet complete — callers deserve to know before they build on those features. Closes gap 10.5 from reality-check review. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d8b3252..f0b667a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ The AERIS-10 project aims to democratize radar technology by providing a fully o ## 🔬 Key Features +> **Alpha Project.** Core hardware (schematics, PCB, FPGA pipeline) works and is in active use. GPS/map integration, full attitude correction, and beam steering UI are not yet complete or validated on hardware. See [issues](https://github.com/NawfalMotii79/PLFM_RADAR/issues) for current status. + - **Open Source Hardware & Software** - Complete schematics, PCB layouts, firmware, and software available - **Dual Version Availability**: - **AERIS-10N (Nexus)**: 3km range with 8x16 patch antenna array From 6a117dd32454f9015eaa931465240b2df4f751a6 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Wed, 8 Apr 2026 17:28:22 +0300 Subject: [PATCH 03/57] fix: resolve ruff lint errors and add lint CI job Remove unused imports (deque, sys, Opcode, struct, _REPLAY_ADJUSTABLE_OPCODES) across 4 active Python files and refactor semicolons to separate statements in radar_protocol.py. Add ruff lint job to CI workflow targeting only the active files (excludes legacy GUI_V*.py and v7/). --- .github/workflows/ci-tests.yml | 28 ++++++++++++++++++++++ 9_Firmware/9_3_GUI/radar_dashboard.py | 3 +-- 9_Firmware/9_3_GUI/radar_protocol.py | 23 +++++++++++------- 9_Firmware/9_3_GUI/smoke_test.py | 1 - 9_Firmware/9_3_GUI/test_radar_dashboard.py | 2 +- 5 files changed, 45 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 77bbb99..d9c2a3c 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -7,6 +7,34 @@ on: branches: [main, develop] jobs: + # =========================================================================== + # Job 0: Ruff Lint (active Python files only) + # Excludes legacy GUI_V*.py files and untracked v7/ directory + # =========================================================================== + lint: + name: Ruff Lint (active files) + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff + + - name: Run ruff on active 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 + # =========================================================================== # Job 1: Python Host Software Tests (58 tests) # radar_protocol, radar_dashboard, FT2232H connection, replay, opcodes, e2e diff --git a/9_Firmware/9_3_GUI/radar_dashboard.py b/9_Firmware/9_3_GUI/radar_dashboard.py index 3c86074..aa7d81d 100644 --- a/9_Firmware/9_3_GUI/radar_dashboard.py +++ b/9_Firmware/9_3_GUI/radar_dashboard.py @@ -21,7 +21,6 @@ Usage: python radar_dashboard.py --record # Launch with HDF5 recording """ -import sys import os import time import queue @@ -45,7 +44,7 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from radar_protocol import ( RadarProtocol, FT2232HConnection, ReplayConnection, DataRecorder, RadarAcquisition, - RadarFrame, StatusResponse, Opcode, + RadarFrame, StatusResponse, NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH, ) diff --git a/9_Firmware/9_3_GUI/radar_protocol.py b/9_Firmware/9_3_GUI/radar_protocol.py index af7adbb..85a5867 100644 --- a/9_Firmware/9_3_GUI/radar_protocol.py +++ b/9_Firmware/9_3_GUI/radar_protocol.py @@ -24,7 +24,7 @@ import logging from dataclasses import dataclass, field from typing import Optional, List, Tuple, Dict, Any from enum import IntEnum -from collections import deque + import numpy as np @@ -764,13 +764,20 @@ class ReplayConnection: dq = int(np.clip(dop_q[rbin, dbin], -32768, 32767)) d = 1 if det[rbin, dbin] else 0 - buf[pos] = HEADER_BYTE; pos += 1 - buf[pos:pos+2] = rq_bytes; pos += 2 - buf[pos:pos+2] = ri_bytes; pos += 2 - buf[pos:pos+2] = struct.pack(">h", di); pos += 2 - buf[pos:pos+2] = struct.pack(">h", dq); pos += 2 - buf[pos] = d; pos += 1 - buf[pos] = FOOTER_BYTE; pos += 1 + buf[pos] = HEADER_BYTE + pos += 1 + buf[pos:pos+2] = rq_bytes + pos += 2 + buf[pos:pos+2] = ri_bytes + pos += 2 + buf[pos:pos+2] = struct.pack(">h", di) + pos += 2 + buf[pos:pos+2] = struct.pack(">h", dq) + pos += 2 + buf[pos] = d + pos += 1 + buf[pos] = FOOTER_BYTE + pos += 1 return bytes(buf) diff --git a/9_Firmware/9_3_GUI/smoke_test.py b/9_Firmware/9_3_GUI/smoke_test.py index e0d2d2f..70e440c 100644 --- a/9_Firmware/9_3_GUI/smoke_test.py +++ b/9_Firmware/9_3_GUI/smoke_test.py @@ -27,7 +27,6 @@ Exit codes: import sys import os import time -import struct import argparse import logging diff --git a/9_Firmware/9_3_GUI/test_radar_dashboard.py b/9_Firmware/9_3_GUI/test_radar_dashboard.py index 790bbef..6fe0410 100644 --- a/9_Firmware/9_3_GUI/test_radar_dashboard.py +++ b/9_Firmware/9_3_GUI/test_radar_dashboard.py @@ -21,7 +21,7 @@ from radar_protocol import ( HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE, NUM_RANGE_BINS, NUM_DOPPLER_BINS, NUM_CELLS, DATA_PACKET_SIZE, - _HARDWARE_ONLY_OPCODES, _REPLAY_ADJUSTABLE_OPCODES, + _HARDWARE_ONLY_OPCODES, ) From 57de32b17253f10d9e7eac327a7dbd92a3786b33 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Wed, 8 Apr 2026 19:11:40 +0300 Subject: [PATCH 04/57] fix: resolve all ruff lint errors across V6+ GUIs, v7 module, and FPGA cosim scripts Fixes 25 remaining manual lint errors after auto-fix pass (94 auto-fixed earlier): - GUI_V6.py: noqa on availability imports, bare except, unused vars, F811 redefs - GUI_V6_Demo.py: unused app variable - v7/models.py: noqa F401 on 8 try/except availability-check imports - FPGA cosim: unused header/status/span vars, ambiguous 'l' renamed to 'line', E701 while-on-one-line split, F841 padding vars annotated Also adds v7/ module, GUI_PyQt_Map.py, and GUI_V7_PyQt.py to version control. Expands CI lint job to cover all 21 maintained Python files (was 4). All 58 Python tests pass. Zero ruff errors on all target files. --- .github/workflows/ci-tests.yml | 18 +- 9_Firmware/9_2_FPGA/tb/cosim/compare.py | 11 +- .../9_2_FPGA/tb/cosim/compare_doppler.py | 14 +- 9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py | 14 +- 9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py | 4 +- 9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py | 10 +- .../9_2_FPGA/tb/cosim/gen_doppler_golden.py | 7 +- .../9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py | 4 +- .../9_2_FPGA/tb/cosim/gen_multiseg_golden.py | 6 +- 9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py | 5 +- .../tb/cosim/real_data/golden_reference.py | 25 +- .../9_2_FPGA/tb/cosim/validate_mem_files.py | 28 +- 9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py | 4 +- 9_Firmware/9_3_GUI/GUI_PyQt_Map.py | 1662 +++++++++++++++++ 9_Firmware/9_3_GUI/GUI_V6.py | 24 +- 9_Firmware/9_3_GUI/GUI_V6_Demo.py | 7 +- 9_Firmware/9_3_GUI/GUI_V7_PyQt.py | 40 + 9_Firmware/9_3_GUI/v7/__init__.py | 80 + 9_Firmware/9_3_GUI/v7/dashboard.py | 1331 +++++++++++++ 9_Firmware/9_3_GUI/v7/hardware.py | 307 +++ 9_Firmware/9_3_GUI/v7/map_widget.py | 532 ++++++ 9_Firmware/9_3_GUI/v7/models.py | 187 ++ 9_Firmware/9_3_GUI/v7/processing.py | 642 +++++++ 9_Firmware/9_3_GUI/v7/workers.py | 389 ++++ 24 files changed, 5260 insertions(+), 91 deletions(-) create mode 100644 9_Firmware/9_3_GUI/GUI_PyQt_Map.py create mode 100644 9_Firmware/9_3_GUI/GUI_V7_PyQt.py create mode 100644 9_Firmware/9_3_GUI/v7/__init__.py create mode 100644 9_Firmware/9_3_GUI/v7/dashboard.py create mode 100644 9_Firmware/9_3_GUI/v7/hardware.py create mode 100644 9_Firmware/9_3_GUI/v7/map_widget.py create mode 100644 9_Firmware/9_3_GUI/v7/models.py create mode 100644 9_Firmware/9_3_GUI/v7/processing.py create mode 100644 9_Firmware/9_3_GUI/v7/workers.py diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index d9c2a3c..db24bea 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -8,11 +8,12 @@ on: jobs: # =========================================================================== - # Job 0: Ruff Lint (active Python files only) - # Excludes legacy GUI_V*.py files and untracked v7/ directory + # Job 0: Ruff Lint (all maintained Python files) + # Covers: active GUI files, v6+ GUIs, v7/ module, FPGA cosim scripts + # Excludes: legacy GUI_V1-V5, schematics, simulation, 8_Utils # =========================================================================== lint: - name: Ruff Lint (active files) + name: Ruff Lint runs-on: ubuntu-latest steps: @@ -27,13 +28,20 @@ jobs: - name: Install ruff run: pip install ruff - - name: Run ruff on active files + - name: Run ruff on maintained files run: | ruff check \ 9_Firmware/9_3_GUI/radar_protocol.py \ 9_Firmware/9_3_GUI/radar_dashboard.py \ 9_Firmware/9_3_GUI/smoke_test.py \ - 9_Firmware/9_3_GUI/test_radar_dashboard.py + 9_Firmware/9_3_GUI/test_radar_dashboard.py \ + 9_Firmware/9_3_GUI/GUI_V6.py \ + 9_Firmware/9_3_GUI/GUI_V6_Demo.py \ + 9_Firmware/9_3_GUI/GUI_PyQt_Map.py \ + 9_Firmware/9_3_GUI/GUI_V7_PyQt.py \ + 9_Firmware/9_3_GUI/v7/ \ + 9_Firmware/9_2_FPGA/tb/cosim/ \ + 9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py # =========================================================================== # Job 1: Python Host Software Tests (58 tests) diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare.py b/9_Firmware/9_2_FPGA/tb/cosim/compare.py index 90ad4ec..9290954 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/compare.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/compare.py @@ -29,7 +29,7 @@ import sys # Add this directory to path for imports sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -from fpga_model import SignalChain, sign_extend +from fpga_model import SignalChain # ============================================================================= @@ -107,7 +107,7 @@ def load_rtl_csv(filepath): bb_i = [] bb_q = [] with open(filepath, 'r') as f: - header = f.readline() # Skip header + f.readline() # Skip header for line in f: line = line.strip() if not line: @@ -280,7 +280,7 @@ def compare_scenario(scenario_name): py_i_stats = compute_signal_stats(py_i) py_q_stats = compute_signal_stats(py_q) - print(f"\nSignal Statistics:") + print("\nSignal Statistics:") print(f" RTL I: mean={rtl_i_stats['mean']:.1f}, rms={rtl_i_stats['rms']:.1f}, " f"range=[{rtl_i_stats['min']}, {rtl_i_stats['max']}]") print(f" RTL Q: mean={rtl_q_stats['mean']:.1f}, rms={rtl_q_stats['rms']:.1f}, " @@ -352,12 +352,12 @@ def compare_scenario(scenario_name): corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i) corr_q_aligned = compute_correlation(aligned_rtl_q, aligned_py_q) - print(f"\nError Metrics (after alignment):") + print("\nError Metrics (after alignment):") print(f" I-channel: RMS={rms_i:.2f} LSB, max={max_err_i} LSB, corr={corr_i_aligned:.6f}") print(f" Q-channel: RMS={rms_q:.2f} LSB, max={max_err_q} LSB, corr={corr_q_aligned:.6f}") # ---- First/last sample comparison ---- - print(f"\nFirst 10 samples (after alignment):") + print("\nFirst 10 samples (after alignment):") print(f" {'idx':>4s} {'RTL_I':>8s} {'Py_I':>8s} {'Err_I':>6s} {'RTL_Q':>8s} {'Py_Q':>8s} {'Err_Q':>6s}") for k in range(min(10, aligned_len)): ei = aligned_rtl_i[k] - aligned_py_i[k] @@ -444,7 +444,6 @@ def compare_scenario(scenario_name): print("PASS/FAIL Results:") all_pass = True for name, ok, detail in results: - status = "PASS" if ok else "FAIL" mark = "[PASS]" if ok else "[FAIL]" print(f" {mark} {name}: {detail}") if not ok: diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py b/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py index 585bc6e..3379ca5 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/compare_doppler.py @@ -74,7 +74,7 @@ def load_doppler_csv(filepath): """ data = {} with open(filepath, 'r') as f: - header = f.readline() + f.readline() # Skip header for line in f: line = line.strip() if not line: @@ -163,11 +163,11 @@ def compare_scenario(name, config, base_dir): if not os.path.exists(golden_path): print(f" ERROR: Golden CSV not found: {golden_path}") - print(f" Run: python3 gen_doppler_golden.py") + print(" Run: python3 gen_doppler_golden.py") return False, {} if not os.path.exists(rtl_path): print(f" ERROR: RTL CSV not found: {rtl_path}") - print(f" Run the Verilog testbench first") + print(" Run the Verilog testbench first") return False, {} py_data = load_doppler_csv(golden_path) @@ -201,7 +201,7 @@ def compare_scenario(name, config, base_dir): else: energy_ratio = 1.0 if rtl_energy == 0 else float('inf') - print(f"\n Global energy:") + print("\n Global energy:") print(f" Python: {py_energy}") print(f" RTL: {rtl_energy}") print(f" Ratio: {energy_ratio:.4f}") @@ -255,7 +255,7 @@ def compare_scenario(name, config, base_dir): avg_corr_i = sum(i_correlations) / len(i_correlations) avg_corr_q = sum(q_correlations) / len(q_correlations) - print(f"\n Per-range-bin metrics:") + print("\n Per-range-bin metrics:") print(f" Peak Doppler bin agreement (+/-1 within sub-frame): {peak_agreements}/{RANGE_BINS} " f"({peak_agreement_frac:.0%})") print(f" Avg magnitude correlation: {avg_mag_corr:.4f}") @@ -263,7 +263,7 @@ def compare_scenario(name, config, base_dir): print(f" Avg Q-channel correlation: {avg_corr_q:.4f}") # Show top 5 range bins by Python energy - print(f"\n Top 5 range bins by Python energy:") + print("\n Top 5 range bins by Python energy:") top_rbins = sorted(peak_details, key=lambda x: -x['py_energy'])[:5] for d in top_rbins: print(f" rbin={d['rbin']:2d}: py_peak={d['py_peak']:2d}, " @@ -291,7 +291,7 @@ def compare_scenario(name, config, base_dir): checks.append((f'High-energy rbin avg mag_corr >= {MAG_CORR_MIN:.2f} ' f'(actual={he_mag_corr:.3f})', he_ok)) - print(f"\n Pass/Fail Checks:") + print("\n Pass/Fail Checks:") all_pass = True for check_name, passed in checks: status = "PASS" if passed else "FAIL" diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py b/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py index f64a578..5269e94 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/compare_mf.py @@ -80,7 +80,7 @@ def load_csv(filepath): vals_i = [] vals_q = [] with open(filepath, 'r') as f: - header = f.readline() + f.readline() # Skip header for line in f: line = line.strip() if not line: @@ -172,11 +172,11 @@ def compare_scenario(scenario_name, config, base_dir): if not os.path.exists(golden_path): print(f" ERROR: Golden CSV not found: {golden_path}") - print(f" Run: python3 gen_mf_cosim_golden.py") + print(" Run: python3 gen_mf_cosim_golden.py") return False, {} if not os.path.exists(rtl_path): print(f" ERROR: RTL CSV not found: {rtl_path}") - print(f" Run the RTL testbench first") + print(" Run the RTL testbench first") return False, {} py_i, py_q = load_csv(golden_path) @@ -205,7 +205,7 @@ def compare_scenario(scenario_name, config, base_dir): energy_ratio = float('inf') if py_energy == 0 else 0.0 rms_ratio = float('inf') if py_rms == 0 else 0.0 - print(f"\n Energy:") + print("\n Energy:") print(f" Python total energy: {py_energy}") print(f" RTL total energy: {rtl_energy}") print(f" Energy ratio (RTL/Py): {energy_ratio:.4f}") @@ -217,7 +217,7 @@ def compare_scenario(scenario_name, config, base_dir): py_peak_bin, py_peak_mag = find_peak(py_i, py_q) rtl_peak_bin, rtl_peak_mag = find_peak(rtl_i, rtl_q) - print(f"\n Peak location:") + print("\n Peak location:") print(f" Python: bin={py_peak_bin}, mag={py_peak_mag}") print(f" RTL: bin={rtl_peak_bin}, mag={rtl_peak_mag}") @@ -242,7 +242,7 @@ def compare_scenario(scenario_name, config, base_dir): corr_i = pearson_correlation(py_i, rtl_i) corr_q = pearson_correlation(py_q, rtl_q) - print(f"\n Channel correlation:") + print("\n Channel correlation:") print(f" I-channel: {corr_i:.6f}") print(f" Q-channel: {corr_q:.6f}") @@ -278,7 +278,7 @@ def compare_scenario(scenario_name, config, base_dir): energy_ok)) # Print checks - print(f"\n Pass/Fail Checks:") + print("\n Pass/Fail Checks:") all_pass = True for name, passed in checks: status = "PASS" if passed else "FAIL" diff --git a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py index e626c6b..876d1f0 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py @@ -19,7 +19,6 @@ Author: Phase 0.5 co-simulation suite for PLFM_RADAR """ import os -import struct # ============================================================================= # Fixed-point utility functions @@ -196,7 +195,7 @@ class NCO: if phase_valid: # Stage 1 NBA: phase_accum_reg <= phase_accumulator (old value) - new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF # old accum before add + _new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF # noqa: F841 — old accum before add (derivation reference) # Wait - let me re-derive. The Verilog is: # phase_accumulator <= phase_accumulator + frequency_tuning_word; # phase_accum_reg <= phase_accumulator; // OLD value (NBA) @@ -812,7 +811,6 @@ class FFTEngine: # COMPUTE: LOG2N stages of butterflies for stage in range(log2n): half = 1 << stage - span = half << 1 tw_stride = (n >> 1) >> stage for bfly in range(n // 2): diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py index b2d7cba..33c76ee 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py @@ -134,7 +134,7 @@ def main(): print("AERIS-10 Chirp .mem File Generator") print("=" * 60) print() - print(f"Parameters:") + print("Parameters:") print(f" CHIRP_BW = {CHIRP_BW/1e6:.1f} MHz") print(f" FS_SYS = {FS_SYS/1e6:.1f} MHz") print(f" T_LONG_CHIRP = {T_LONG_CHIRP*1e6:.1f} us") @@ -212,7 +212,7 @@ def main(): mismatches += 1 if mismatches == 0: - print(f" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()") + print(" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()") else: print(f" [FAIL] Seg0 has {mismatches} mismatches vs generate_reference_chirp_q15()") return 1 @@ -225,13 +225,13 @@ def main(): # Check seg3 zero padding seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem') with open(seg3_i_path, 'r') as f: - seg3_lines = [l.strip() for l in f if l.strip()] - nonzero_seg3 = sum(1 for l in seg3_lines if l != '0000') + seg3_lines = [line.strip() for line in f if line.strip()] + nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000') print(f" Seg3 non-zero entries: {nonzero_seg3}/{len(seg3_lines)} " f"(expected 0 since chirp ends at sample 2999)") if nonzero_seg3 == 0: - print(f" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)") + print(" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)") else: print(f" [WARN] Seg3 has {nonzero_seg3} non-zero entries") diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py index e9668bd..f4fb3e2 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_doppler_golden.py @@ -18,14 +18,13 @@ Usage: Author: Phase 0.5 Doppler co-simulation suite for PLFM_RADAR """ -import math import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fpga_model import ( - DopplerProcessor, sign_extend, HAMMING_WINDOW + DopplerProcessor ) from radar_scene import Target, generate_doppler_frame @@ -121,7 +120,7 @@ def generate_scenario(name, targets, description, base_dir): """Generate input hex + golden output for one scenario.""" print(f"\n{'='*60}") print(f"Scenario: {name} — {description}") - print(f"Model: CLEAN (dual 16-pt FFT)") + print("Model: CLEAN (dual 16-pt FFT)") print(f"{'='*60}") # Generate Doppler frame (32 chirps x 64 range bins) @@ -172,7 +171,7 @@ def generate_scenario(name, targets, description, base_dir): write_hex_32bit(golden_hex, list(zip(flat_i, flat_q))) # ---- Find peak per range bin ---- - print(f"\n Peak Doppler bins per range bin (top 5 by magnitude):") + print("\n Peak Doppler bins per range bin (top 5 by magnitude):") peak_info = [] for rbin in range(RANGE_BINS): mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d]) diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py index 31ef9de..dc5eaea 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_mf_cosim_golden.py @@ -25,8 +25,8 @@ import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fpga_model import ( - FFTEngine, FreqMatchedFilter, MatchedFilterChain, - RangeBinDecimator, sign_extend, saturate + MatchedFilterChain, + sign_extend, saturate ) diff --git a/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py b/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py index ca07502..3f44726 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/gen_multiseg_golden.py @@ -208,7 +208,6 @@ def generate_long_chirp_test(): input_buffer_i = [0] * BUFFER_SIZE input_buffer_q = [0] * BUFFER_SIZE buffer_write_ptr = 0 - current_segment = 0 input_idx = 0 chirp_samples_collected = 0 @@ -342,8 +341,9 @@ def generate_short_chirp_test(): input_q.append(saturate(val_q, 16)) # Zero-pad to 1024 (as RTL does in ST_ZERO_PAD) - padded_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) - padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) + # Note: padding computed here for documentation; actual buffer uses buf_i/buf_q below + _padded_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841 + _padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841 # The buffer truncation: ddc_i[17:2] + ddc_i[1] # For data already 16-bit sign-extended to 18: result is (val >> 2) + bit1 diff --git a/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py b/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py index b786514..c0187e3 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/radar_scene.py @@ -21,7 +21,6 @@ Author: Phase 0.5 co-simulation suite for PLFM_RADAR import math import os -import struct # ============================================================================= @@ -156,7 +155,7 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC): t = n / fs # Instantaneous frequency: f_if - chirp_bw/2 + chirp_rate * t # Phase: integral of 2*pi*f(t)*dt - f_inst = f_if - chirp_bw / 2 + chirp_rate * t + _f_inst = f_if - chirp_bw / 2 + chirp_rate * t # noqa: F841 — documents instantaneous frequency formula phase = 2 * math.pi * (f_if - chirp_bw / 2) * t + math.pi * chirp_rate * t * t chirp_i.append(math.cos(phase)) chirp_q.append(math.sin(phase)) @@ -668,7 +667,7 @@ def generate_all_test_vectors(output_dir=None): f.write(f" ADC: {FS_ADC/1e6:.0f} MSPS, {ADC_BITS}-bit\n") f.write(f" Range resolution: {RANGE_RESOLUTION:.1f} m\n") f.write(f" Wavelength: {WAVELENGTH*1000:.2f} mm\n") - f.write(f"\n") + f.write("\n") f.write("Scenario 1: Single target\n") for t in targets1: diff --git a/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py b/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py index b30e0fb..008d325 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/real_data/golden_reference.py @@ -20,7 +20,6 @@ Usage: import numpy as np import os -import sys import argparse # =========================================================================== @@ -787,7 +786,7 @@ def run_mti_canceller(decim_i, decim_q, enable=True): if not enable: mti_i[:] = decim_i mti_q[:] = decim_q - print(f" Pass-through mode (MTI disabled)") + print(" Pass-through mode (MTI disabled)") return mti_i, mti_q for c in range(n_chirps): @@ -803,7 +802,7 @@ def run_mti_canceller(decim_i, decim_q, enable=True): mti_i[c, r] = saturate(diff_i, 16) mti_q[c, r] = saturate(diff_q, 16) - print(f" Chirp 0: muted (zeros)") + print(" Chirp 0: muted (zeros)") print(f" Chirps 1-{n_chirps-1}: I range [{mti_i[1:].min()}, {mti_i[1:].max()}], " f"Q range [{mti_q[1:].min()}, {mti_q[1:].max()}]") return mti_i, mti_q @@ -839,7 +838,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2): print(f"[DC NOTCH] width={width}, {n_range} range bins x {n_doppler} Doppler bins (dual sub-frame)") if width == 0: - print(f" Pass-through (width=0)") + print(" Pass-through (width=0)") return notched_i, notched_q zeroed_count = 0 @@ -1029,7 +1028,7 @@ def run_float_reference(iq_i, iq_q): Uses the exact same RTL Hamming window coefficients (Q15) to isolate only the FFT fixed-point quantization error. """ - print(f"\n[FLOAT REF] Running floating-point reference pipeline") + print("\n[FLOAT REF] Running floating-point reference pipeline") n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i) @@ -1384,10 +1383,10 @@ def main(): cfar_detections = np.argwhere(cfar_flags) cfar_det_list_file = os.path.join(output_dir, "fullchain_cfar_detections.txt") with open(cfar_det_list_file, 'w') as f: - f.write(f"# AERIS-10 Full-Chain CFAR Detection List\n") + f.write("# AERIS-10 Full-Chain CFAR Detection List\n") f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n") f.write(f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n") - f.write(f"# Format: range_bin doppler_bin magnitude threshold\n") + f.write("# Format: range_bin doppler_bin magnitude threshold\n") for det in cfar_detections: r, d = det f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n") @@ -1406,9 +1405,9 @@ def main(): # Save full-chain detection reference fc_det_file = os.path.join(output_dir, "fullchain_detections.txt") with open(fc_det_file, 'w') as f: - f.write(f"# AERIS-10 Full-Chain Golden Reference Detections\n") + f.write("# AERIS-10 Full-Chain Golden Reference Detections\n") f.write(f"# Threshold: {args.threshold}\n") - f.write(f"# Format: range_bin doppler_bin magnitude\n") + f.write("# Format: range_bin doppler_bin magnitude\n") for d in fc_detections: rbin, dbin = d f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n") @@ -1433,9 +1432,9 @@ def main(): # Save detection list det_file = os.path.join(output_dir, "detections.txt") with open(det_file, 'w') as f: - f.write(f"# AERIS-10 Golden Reference Detections\n") + f.write("# AERIS-10 Golden Reference Detections\n") f.write(f"# Threshold: {args.threshold}\n") - f.write(f"# Format: range_bin doppler_bin magnitude\n") + f.write("# Format: range_bin doppler_bin magnitude\n") for d in detections: rbin, dbin = d f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n") @@ -1484,12 +1483,12 @@ def main(): print(f" Range FFT: {FFT_SIZE}-point → {snr_range:.1f} dB vs float") print(f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming → {snr_doppler:.1f} dB vs float") print(f" Detections (direct): {len(detections)} (threshold={args.threshold})") - print(f" Full-chain decimator: 1024→64 peak detection") + print(" Full-chain decimator: 1024→64 peak detection") print(f" Full-chain detections: {len(fc_detections)} (threshold={args.threshold})") print(f" MTI+CFAR chain: decim → MTI → Doppler → DC notch(w={DC_NOTCH_WIDTH}) → CA-CFAR") print(f" CFAR detections: {len(cfar_detections)} (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})") print(f" Hex stimulus files: {output_dir}/") - print(f" Ready for RTL co-simulation with Icarus Verilog") + print(" Ready for RTL co-simulation with Icarus Verilog") # ----------------------------------------------------------------------- # Optional plots diff --git a/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py b/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py index 160c712..d9e71dc 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/validate_mem_files.py @@ -206,7 +206,7 @@ def test_long_chirp(): expected_max_from_model = 32767 * 0.9 uses_model_scaling = max_mag > expected_max_from_model * 0.8 if uses_model_scaling: - print(f" Scaling: CONSISTENT with radar_scene.py model (0.9 * Q15)") + print(" Scaling: CONSISTENT with radar_scene.py model (0.9 * Q15)") else: warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model " f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.") @@ -246,7 +246,7 @@ def test_long_chirp(): f_max = max(freq_estimates) f_range = f_max - f_min - print(f"\n Instantaneous frequency analysis (post-DDC baseband):") + print("\n Instantaneous frequency analysis (post-DDC baseband):") print(f" Start freq: {f_start/1e6:.3f} MHz") print(f" End freq: {f_end/1e6:.3f} MHz") print(f" Min freq: {f_min/1e6:.3f} MHz") @@ -269,7 +269,7 @@ def test_long_chirp(): # Compare segment boundaries for overlap-save consistency # In proper overlap-save, the chirp data should be segmented at 896-sample boundaries # with segments being 1024-sample FFT blocks - print(f"\n Segment boundary analysis:") + print("\n Segment boundary analysis:") for seg in range(4): seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem') seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem') @@ -290,9 +290,9 @@ def test_long_chirp(): print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}, " f"near-zero={zero_count}/{len(seg_mags)}") if zero_count > 500: - print(f" -> Seg 3 mostly zeros (chirp shorter than 4096 samples)") + print(" -> Seg 3 mostly zeros (chirp shorter than 4096 samples)") else: - print(f" -> Seg 3 has significant data throughout") + print(" -> Seg 3 has significant data throughout") else: print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}") @@ -330,8 +330,10 @@ def test_short_chirp(): freq_est = [] for n in range(1, len(phases)): dp = phases[n] - phases[n-1] - while dp > math.pi: dp -= 2 * math.pi - while dp < -math.pi: dp += 2 * math.pi + while dp > math.pi: + dp -= 2 * math.pi + while dp < -math.pi: + dp += 2 * math.pi freq_est.append(dp * FS_SYS / (2 * math.pi)) if freq_est: @@ -382,9 +384,9 @@ def test_chirp_vs_model(): print(f" Exact I matches: {matches}/{len(model_i)}") if matches > len(model_i) * 0.9: - print(f" -> .mem files MATCH Python model") + print(" -> .mem files MATCH Python model") else: - warn(f".mem files do NOT match Python model. They likely have different provenance.") + warn(".mem files do NOT match Python model. They likely have different provenance.") # Try to detect scaling if mem_max > 0: ratio = model_max / mem_max @@ -399,14 +401,16 @@ def test_chirp_vs_model(): phase_diffs = [] for mp, fp in zip(model_phases, mem_phases): d = mp - fp - while d > math.pi: d -= 2 * math.pi - while d < -math.pi: d += 2 * math.pi + while d > math.pi: + d -= 2 * math.pi + while d < -math.pi: + d += 2 * math.pi phase_diffs.append(d) avg_phase_diff = sum(phase_diffs) / len(phase_diffs) max_phase_diff = max(abs(d) for d in phase_diffs) - print(f"\n Phase comparison (shape regardless of amplitude):") + print("\n Phase comparison (shape regardless of amplitude):") print(f" Avg phase diff: {avg_phase_diff:.4f} rad ({math.degrees(avg_phase_diff):.2f} deg)") print(f" Max phase diff: {max_phase_diff:.4f} rad ({math.degrees(max_phase_diff):.2f} deg)") diff --git a/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py b/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py index 4781949..e3f7d52 100644 --- a/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py +++ b/9_Firmware/9_2_FPGA/tb/gen_mf_golden_ref.py @@ -91,7 +91,7 @@ def generate_case(case_num, sig_i, sig_q, ref_i, ref_q, description, outdir): peak_q_q = out_q_q[peak_bin] # Write hex files - prefix = os.path.join(outdir, f"mf_golden") + prefix = os.path.join(outdir, "mf_golden") write_hex_file(f"{prefix}_sig_i_case{case_num}.hex", sig_i) write_hex_file(f"{prefix}_sig_q_case{case_num}.hex", sig_q) write_hex_file(f"{prefix}_ref_i_case{case_num}.hex", ref_i) @@ -233,7 +233,7 @@ def main(): f.write(f" Peak Q (float): {s['peak_q_float']:.6f}\n") f.write(f" Peak I (quantized): {s['peak_i_quant']}\n") f.write(f" Peak Q (quantized): {s['peak_q_quant']}\n") - f.write(f" Files:\n") + f.write(" Files:\n") for fname in s["files"]: f.write(f" {fname}\n") f.write("\n") diff --git a/9_Firmware/9_3_GUI/GUI_PyQt_Map.py b/9_Firmware/9_3_GUI/GUI_PyQt_Map.py new file mode 100644 index 0000000..703e58c --- /dev/null +++ b/9_Firmware/9_3_GUI/GUI_PyQt_Map.py @@ -0,0 +1,1662 @@ +#!/usr/bin/env python3 +""" +PLFM Radar Dashboard - PyQt6 Edition with Embedded Leaflet Map +=============================================================== +A professional-grade radar tracking GUI using PyQt6 with embedded web-based +Leaflet.js maps for real-time target visualization. + +Features: +- Embedded interactive Leaflet map with OpenStreetMap tiles +- Real-time target tracking and visualization +- Python-to-JavaScript bridge for seamless updates +- Dark theme UI matching existing radar dashboard style +- Support for multiple tile servers (OSM, Google, satellite) +- Marker clustering for dense target environments +- Coverage area visualization +- Target trails/history + +Author: PLFM Radar Team +Version: 1.0.0 +""" + +import sys +import json +import math +import time +import random +import logging +from dataclasses import dataclass, asdict +from typing import List, Dict, Optional, Tuple +from enum import Enum + +# PyQt6 imports +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QTabWidget, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, + QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox, QTableWidget, QTableWidgetItem, + QHeaderView +) +from PyQt6.QtCore import ( + Qt, QTimer, pyqtSignal, pyqtSlot, QObject +) +from PyQt6.QtGui import ( + QFont, QColor, QPalette +) +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWebChannel import QWebChannel + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# ============================================================================= +# Dark Theme Colors (matching existing radar dashboard) +# ============================================================================= +DARK_BG = "#2b2b2b" +DARK_FG = "#e0e0e0" +DARK_ACCENT = "#3c3f41" +DARK_HIGHLIGHT = "#4e5254" +DARK_BORDER = "#555555" +DARK_TEXT = "#cccccc" +DARK_BUTTON = "#3c3f41" +DARK_BUTTON_HOVER = "#4e5254" +DARK_SUCCESS = "#4CAF50" +DARK_WARNING = "#FFC107" +DARK_ERROR = "#F44336" +DARK_INFO = "#2196F3" + +# ============================================================================= +# Data Classes +# ============================================================================= +@dataclass +class RadarTarget: + """Represents a detected radar target""" + id: int + range: float # Range in meters + velocity: float # Velocity in m/s (positive = approaching) + azimuth: float # Azimuth angle in degrees + elevation: float # Elevation angle in degrees + latitude: float = 0.0 + longitude: float = 0.0 + snr: float = 0.0 # Signal-to-noise ratio in dB + timestamp: float = 0.0 + track_id: int = -1 + classification: str = "unknown" + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization""" + return asdict(self) + + +@dataclass +class GPSData: + """GPS position and orientation data""" + latitude: float + longitude: float + altitude: float + pitch: float # Pitch angle in degrees + heading: float = 0.0 # Heading in degrees (0 = North) + timestamp: float = 0.0 + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass +class RadarSettings: + """Radar system configuration""" + system_frequency: float = 10e9 # Hz + chirp_duration_1: float = 30e-6 # Long chirp duration (s) + chirp_duration_2: float = 0.5e-6 # Short chirp duration (s) + chirps_per_position: int = 32 + freq_min: float = 10e6 # Hz + freq_max: float = 30e6 # Hz + prf1: float = 1000 # PRF 1 (Hz) + prf2: float = 2000 # PRF 2 (Hz) + max_distance: float = 50000 # Max detection range (m) + coverage_radius: float = 50000 # Map coverage radius (m) + + +class TileServer(Enum): + """Available map tile servers""" + OPENSTREETMAP = "osm" + GOOGLE_MAPS = "google" + GOOGLE_SATELLITE = "google_sat" + GOOGLE_HYBRID = "google_hybrid" + ESRI_SATELLITE = "esri_sat" + + +# ============================================================================= +# JavaScript Bridge - Enables Python <-> JavaScript communication +# ============================================================================= +class MapBridge(QObject): + """ + Bridge object exposed to JavaScript for bidirectional communication. + This allows Python to call JavaScript functions and vice versa. + """ + + # Signals emitted when JS calls Python + mapClicked = pyqtSignal(float, float) # lat, lon + markerClicked = pyqtSignal(int) # target_id + mapReady = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._map_ready = False + + @pyqtSlot(float, float) + def onMapClick(self, lat: float, lon: float): + """Called from JavaScript when map is clicked""" + logger.debug(f"Map clicked at: {lat}, {lon}") + self.mapClicked.emit(lat, lon) + + @pyqtSlot(int) + def onMarkerClick(self, target_id: int): + """Called from JavaScript when a target marker is clicked""" + logger.debug(f"Marker clicked: Target #{target_id}") + self.markerClicked.emit(target_id) + + @pyqtSlot() + def onMapReady(self): + """Called from JavaScript when map is fully initialized""" + logger.info("Map is ready") + self._map_ready = True + self.mapReady.emit() + + @pyqtSlot(str) + def logFromJS(self, message: str): + """Receive log messages from JavaScript""" + logger.debug(f"[JS] {message}") + + @property + def is_ready(self) -> bool: + return self._map_ready + + +# ============================================================================= +# Map Widget - Embedded Leaflet Map +# ============================================================================= +class RadarMapWidget(QWidget): + """ + Custom widget embedding a Leaflet.js map via QWebEngineView. + Provides methods for updating radar position, targets, and coverage. + """ + + targetSelected = pyqtSignal(int) # Emitted when a target is selected + + def __init__(self, parent=None): + super().__init__(parent) + + # State + self._radar_position = GPSData( + latitude=40.7128, # Default: New York City + longitude=-74.0060, + altitude=100.0, + pitch=0.0 + ) + self._targets: List[RadarTarget] = [] + self._coverage_radius = 50000 # meters + self._tile_server = TileServer.OPENSTREETMAP + self._show_coverage = True + self._show_trails = False + self._target_history: Dict[int, List[Tuple[float, float]]] = {} + + # Setup UI + self._setup_ui() + + # Setup bridge + self._bridge = MapBridge(self) + self._bridge.mapReady.connect(self._on_map_ready) + self._bridge.markerClicked.connect(self._on_marker_clicked) + + # Setup web channel + self._channel = QWebChannel() + self._channel.registerObject("bridge", self._bridge) + self._web_view.page().setWebChannel(self._channel) + + # Load map + self._load_map() + + def _setup_ui(self): + """Setup the widget UI""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Control bar + control_bar = QFrame() + control_bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;") + control_layout = QHBoxLayout(control_bar) + control_layout.setContentsMargins(8, 4, 8, 4) + + # Tile server selector + self._tile_combo = QComboBox() + self._tile_combo.addItem("OpenStreetMap", TileServer.OPENSTREETMAP) + self._tile_combo.addItem("Google Maps", TileServer.GOOGLE_MAPS) + self._tile_combo.addItem("Google Satellite", TileServer.GOOGLE_SATELLITE) + self._tile_combo.addItem("Google Hybrid", TileServer.GOOGLE_HYBRID) + self._tile_combo.addItem("ESRI Satellite", TileServer.ESRI_SATELLITE) + self._tile_combo.currentIndexChanged.connect(self._on_tile_server_changed) + self._tile_combo.setStyleSheet(f""" + QComboBox {{ + background-color: {DARK_BUTTON}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 4px 8px; + border-radius: 4px; + }} + """) + control_layout.addWidget(QLabel("Tiles:")) + control_layout.addWidget(self._tile_combo) + + # Coverage toggle + self._coverage_check = QCheckBox("Show Coverage") + self._coverage_check.setChecked(True) + self._coverage_check.stateChanged.connect(self._on_coverage_toggled) + control_layout.addWidget(self._coverage_check) + + # Trails toggle + self._trails_check = QCheckBox("Show Trails") + self._trails_check.setChecked(False) + self._trails_check.stateChanged.connect(self._on_trails_toggled) + control_layout.addWidget(self._trails_check) + + # Center on radar button + center_btn = QPushButton("Center on Radar") + center_btn.clicked.connect(self._center_on_radar) + center_btn.setStyleSheet(f""" + QPushButton {{ + background-color: {DARK_BUTTON}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 4px 12px; + border-radius: 4px; + }} + QPushButton:hover {{ + background-color: {DARK_BUTTON_HOVER}; + }} + """) + control_layout.addWidget(center_btn) + + # Fit all button + fit_btn = QPushButton("Fit All Targets") + fit_btn.clicked.connect(self._fit_all_targets) + fit_btn.setStyleSheet(center_btn.styleSheet()) + control_layout.addWidget(fit_btn) + + control_layout.addStretch() + + # Status label + self._status_label = QLabel("Initializing map...") + self._status_label.setStyleSheet(f"color: {DARK_INFO};") + control_layout.addWidget(self._status_label) + + layout.addWidget(control_bar) + + # Web view for map + self._web_view = QWebEngineView() + self._web_view.setMinimumSize(400, 300) + layout.addWidget(self._web_view, stretch=1) + + def _get_map_html(self) -> str: + """Generate the complete HTML for the Leaflet map""" + return f''' + + + + + Radar Map + + + + + + + + + + + + + +
+ + + +''' + + def _load_map(self): + """Load the map HTML into the web view""" + html = self._get_map_html() + self._web_view.setHtml(html) + logger.info("Map HTML loaded") + + def _on_map_ready(self): + """Called when the map is fully initialized""" + self._status_label.setText(f"Map ready - {len(self._targets)} targets") + self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};") + logger.info("Map widget ready") + + def _on_marker_clicked(self, target_id: int): + """Handle marker click events""" + self.targetSelected.emit(target_id) + + def _on_tile_server_changed(self, index: int): + """Handle tile server change""" + server = self._tile_combo.currentData() + self._tile_server = server + self._run_js(f"setTileServer('{server.value}')") + + def _on_coverage_toggled(self, state: int): + """Handle coverage visibility toggle""" + visible = state == Qt.CheckState.Checked.value + self._show_coverage = visible + self._run_js(f"setCoverageVisible({str(visible).lower()})") + + def _on_trails_toggled(self, state: int): + """Handle trails visibility toggle""" + visible = state == Qt.CheckState.Checked.value + self._show_trails = visible + self._run_js(f"setTrailsVisible({str(visible).lower()})") + + def _center_on_radar(self): + """Center map view on radar position""" + self._run_js("centerOnRadar()") + + def _fit_all_targets(self): + """Fit map view to show all targets""" + self._run_js("fitAllTargets()") + + def _run_js(self, script: str): + """Execute JavaScript in the web view""" + self._web_view.page().runJavaScript(script) + + # Public API + def set_radar_position(self, gps_data: GPSData): + """Update the radar position on the map""" + self._radar_position = gps_data + self._run_js( + f"updateRadarPosition({gps_data.latitude}, {gps_data.longitude}, " + f"{gps_data.altitude}, {gps_data.pitch}, {gps_data.heading})" + ) + + def set_targets(self, targets: List[RadarTarget]): + """Update all targets on the map""" + self._targets = targets + + # Convert targets to JSON + targets_data = [t.to_dict() for t in targets] + targets_json = json.dumps(targets_data) + + # Update status + self._status_label.setText(f"{len(targets)} targets tracked") + + # Call JavaScript update function + self._run_js(f"updateTargets('{targets_json}')") + + def set_coverage_radius(self, radius: float): + """Set the coverage circle radius in meters""" + self._coverage_radius = radius + self._run_js(f"setCoverageRadius({radius})") + + def set_zoom(self, level: int): + """Set map zoom level (0-19)""" + level = max(0, min(19, level)) + self._run_js(f"setZoom({level})") + + +# ============================================================================= +# Utility Functions +# ============================================================================= +def polar_to_geographic( + radar_lat: float, + radar_lon: float, + range_m: float, + azimuth_deg: float +) -> Tuple[float, float]: + """ + Convert polar coordinates (range, azimuth) relative to radar + to geographic coordinates (latitude, longitude). + + Args: + radar_lat: Radar latitude in degrees + radar_lon: Radar longitude in degrees + range_m: Range from radar in meters + azimuth_deg: Azimuth angle in degrees (0 = North, clockwise) + + Returns: + Tuple of (latitude, longitude) for the target + """ + # Earth's radius in meters + R = 6371000 + + # Convert to radians + lat1 = math.radians(radar_lat) + lon1 = math.radians(radar_lon) + bearing = math.radians(azimuth_deg) + + # Calculate new position + lat2 = math.asin( + math.sin(lat1) * math.cos(range_m / R) + + math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing) + ) + + lon2 = lon1 + math.atan2( + math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1), + math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2) + ) + + return (math.degrees(lat2), math.degrees(lon2)) + + +# ============================================================================= +# Target Simulator (Demo Mode) +# ============================================================================= +class TargetSimulator(QObject): + """Simulates radar targets for demonstration purposes""" + + targetsUpdated = pyqtSignal(list) # Emits list of RadarTarget + + def __init__(self, radar_position: GPSData, parent=None): + super().__init__(parent) + + self._radar_position = radar_position + self._targets: List[RadarTarget] = [] + self._next_id = 1 + self._timer = QTimer() + self._timer.timeout.connect(self._update_targets) + + # Initialize some targets + self._initialize_targets() + + def _initialize_targets(self, count: int = 8): + """Create initial set of simulated targets""" + for _ in range(count): + self._add_random_target() + + def _add_random_target(self): + """Add a new random target""" + # Random range between 5km and 40km + range_m = random.uniform(5000, 40000) + + # Random azimuth + azimuth = random.uniform(0, 360) + + # Random velocity (-100 to +100 m/s) + velocity = random.uniform(-100, 100) + + # Random elevation + elevation = random.uniform(-5, 45) + + # Calculate geographic position + lat, lon = polar_to_geographic( + self._radar_position.latitude, + self._radar_position.longitude, + range_m, + azimuth + ) + + target = RadarTarget( + id=self._next_id, + range=range_m, + velocity=velocity, + azimuth=azimuth, + elevation=elevation, + latitude=lat, + longitude=lon, + snr=random.uniform(10, 35), + timestamp=time.time(), + track_id=self._next_id, + classification=random.choice(["aircraft", "drone", "bird", "unknown"]) + ) + + self._next_id += 1 + self._targets.append(target) + + def _update_targets(self): + """Update target positions (called by timer)""" + updated_targets = [] + + for target in self._targets: + # Update range based on velocity + new_range = target.range - target.velocity * 0.5 # 0.5 second update + + # Check if target is still in range + if new_range < 500 or new_range > 50000: + # Remove this target and add a new one + continue + + # Slightly vary velocity + new_velocity = target.velocity + random.uniform(-2, 2) + new_velocity = max(-150, min(150, new_velocity)) + + # Slightly vary azimuth (simulate turning) + new_azimuth = (target.azimuth + random.uniform(-0.5, 0.5)) % 360 + + # Calculate new geographic position + lat, lon = polar_to_geographic( + self._radar_position.latitude, + self._radar_position.longitude, + new_range, + new_azimuth + ) + + updated_target = RadarTarget( + id=target.id, + range=new_range, + velocity=new_velocity, + azimuth=new_azimuth, + elevation=target.elevation + random.uniform(-0.1, 0.1), + latitude=lat, + longitude=lon, + snr=target.snr + random.uniform(-1, 1), + timestamp=time.time(), + track_id=target.track_id, + classification=target.classification + ) + + updated_targets.append(updated_target) + + # Occasionally add new targets + if len(updated_targets) < 5 or (random.random() < 0.05 and len(updated_targets) < 15): + self._add_random_target() + updated_targets.append(self._targets[-1]) + + self._targets = updated_targets + self.targetsUpdated.emit(updated_targets) + + def start(self, interval_ms: int = 500): + """Start the simulation""" + self._timer.start(interval_ms) + + def stop(self): + """Stop the simulation""" + self._timer.stop() + + def set_radar_position(self, gps_data: GPSData): + """Update radar position""" + self._radar_position = gps_data + + +# ============================================================================= +# Main Dashboard Window +# ============================================================================= +class RadarDashboard(QMainWindow): + """Main application window for the radar dashboard""" + + def __init__(self): + super().__init__() + + # State + self._radar_position = GPSData( + latitude=40.7128, + longitude=-74.0060, + altitude=100.0, + pitch=0.0, + heading=0.0, + timestamp=time.time() + ) + self._settings = RadarSettings() + self._simulator: Optional[TargetSimulator] = None + self._demo_mode = True + + # Setup UI + self._setup_window() + self._setup_dark_theme() + self._setup_ui() + self._setup_statusbar() + + # Start demo mode + self._start_demo_mode() + + def _setup_window(self): + """Configure main window properties""" + self.setWindowTitle("PLFM Radar Dashboard - PyQt6 Edition") + self.setMinimumSize(1200, 800) + self.resize(1400, 900) + + def _setup_dark_theme(self): + """Apply dark theme to the application""" + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(DARK_BG)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(DARK_FG)) + palette.setColor(QPalette.ColorRole.Base, QColor(DARK_ACCENT)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(DARK_HIGHLIGHT)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(DARK_ACCENT)) + palette.setColor(QPalette.ColorRole.ToolTipText, QColor(DARK_FG)) + palette.setColor(QPalette.ColorRole.Text, QColor(DARK_FG)) + palette.setColor(QPalette.ColorRole.Button, QColor(DARK_BUTTON)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(DARK_FG)) + palette.setColor(QPalette.ColorRole.BrightText, QColor(DARK_FG)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(DARK_INFO)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(DARK_FG)) + + self.setPalette(palette) + + # Global stylesheet + self.setStyleSheet(f""" + QMainWindow {{ + background-color: {DARK_BG}; + }} + + QTabWidget::pane {{ + border: 1px solid {DARK_BORDER}; + background-color: {DARK_BG}; + }} + + QTabBar::tab {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + padding: 8px 20px; + margin-right: 2px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + }} + + QTabBar::tab:selected {{ + background-color: {DARK_HIGHLIGHT}; + border-bottom: 2px solid {DARK_INFO}; + }} + + QTabBar::tab:hover {{ + background-color: {DARK_BUTTON_HOVER}; + }} + + QGroupBox {{ + font-weight: bold; + border: 1px solid {DARK_BORDER}; + border-radius: 6px; + margin-top: 12px; + padding-top: 10px; + background-color: {DARK_ACCENT}; + }} + + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 8px; + color: {DARK_INFO}; + }} + + QPushButton {{ + background-color: {DARK_BUTTON}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 6px 16px; + border-radius: 4px; + font-weight: 500; + }} + + QPushButton:hover {{ + background-color: {DARK_BUTTON_HOVER}; + }} + + QPushButton:pressed {{ + background-color: {DARK_HIGHLIGHT}; + }} + + QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 4px 8px; + border-radius: 4px; + }} + + QLabel {{ + color: {DARK_FG}; + }} + + QTableWidget {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + gridline-color: {DARK_BORDER}; + border: 1px solid {DARK_BORDER}; + }} + + QTableWidget::item {{ + padding: 4px; + }} + + QTableWidget::item:selected {{ + background-color: {DARK_INFO}; + }} + + QHeaderView::section {{ + background-color: {DARK_HIGHLIGHT}; + color: {DARK_FG}; + padding: 6px; + border: none; + border-right: 1px solid {DARK_BORDER}; + }} + + QScrollBar:vertical {{ + background-color: {DARK_ACCENT}; + width: 12px; + margin: 0; + }} + + QScrollBar::handle:vertical {{ + background-color: {DARK_HIGHLIGHT}; + border-radius: 6px; + min-height: 20px; + }} + + QStatusBar {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + }} + """) + + def _setup_ui(self): + """Setup the main UI layout""" + # Central widget + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.setSpacing(8) + + # Tab widget + self._tabs = QTabWidget() + main_layout.addWidget(self._tabs) + + # Create tabs + self._create_map_tab() + self._create_targets_tab() + self._create_settings_tab() + + def _create_map_tab(self): + """Create the map visualization tab""" + tab = QWidget() + layout = QHBoxLayout(tab) + layout.setContentsMargins(4, 4, 4, 4) + + # Splitter for map and sidebar + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Map widget (main area) + self._map_widget = RadarMapWidget() + self._map_widget.targetSelected.connect(self._on_target_selected) + splitter.addWidget(self._map_widget) + + # Sidebar + sidebar = QWidget() + sidebar.setMaximumWidth(320) + sidebar.setMinimumWidth(280) + sidebar_layout = QVBoxLayout(sidebar) + sidebar_layout.setContentsMargins(8, 8, 8, 8) + + # Radar Position Group + pos_group = QGroupBox("Radar Position") + pos_layout = QGridLayout(pos_group) + + self._lat_spin = QDoubleSpinBox() + self._lat_spin.setRange(-90, 90) + self._lat_spin.setDecimals(6) + self._lat_spin.setValue(self._radar_position.latitude) + self._lat_spin.valueChanged.connect(self._on_position_changed) + + self._lon_spin = QDoubleSpinBox() + self._lon_spin.setRange(-180, 180) + self._lon_spin.setDecimals(6) + self._lon_spin.setValue(self._radar_position.longitude) + self._lon_spin.valueChanged.connect(self._on_position_changed) + + self._alt_spin = QDoubleSpinBox() + self._alt_spin.setRange(0, 50000) + self._alt_spin.setDecimals(1) + self._alt_spin.setValue(self._radar_position.altitude) + self._alt_spin.setSuffix(" m") + + pos_layout.addWidget(QLabel("Latitude:"), 0, 0) + pos_layout.addWidget(self._lat_spin, 0, 1) + pos_layout.addWidget(QLabel("Longitude:"), 1, 0) + pos_layout.addWidget(self._lon_spin, 1, 1) + pos_layout.addWidget(QLabel("Altitude:"), 2, 0) + pos_layout.addWidget(self._alt_spin, 2, 1) + + sidebar_layout.addWidget(pos_group) + + # Coverage Group + coverage_group = QGroupBox("Coverage") + coverage_layout = QGridLayout(coverage_group) + + self._coverage_spin = QDoubleSpinBox() + self._coverage_spin.setRange(1, 100) + self._coverage_spin.setDecimals(1) + self._coverage_spin.setValue(self._settings.coverage_radius / 1000) + self._coverage_spin.setSuffix(" km") + self._coverage_spin.valueChanged.connect(self._on_coverage_changed) + + coverage_layout.addWidget(QLabel("Radius:"), 0, 0) + coverage_layout.addWidget(self._coverage_spin, 0, 1) + + sidebar_layout.addWidget(coverage_group) + + # Demo Controls + demo_group = QGroupBox("Demo Mode") + demo_layout = QVBoxLayout(demo_group) + + self._demo_btn = QPushButton("Stop Demo") + self._demo_btn.setCheckable(True) + self._demo_btn.setChecked(True) + self._demo_btn.clicked.connect(self._toggle_demo_mode) + demo_layout.addWidget(self._demo_btn) + + add_target_btn = QPushButton("Add Random Target") + add_target_btn.clicked.connect(self._add_demo_target) + demo_layout.addWidget(add_target_btn) + + sidebar_layout.addWidget(demo_group) + + # Target Info + info_group = QGroupBox("Selected Target") + info_layout = QVBoxLayout(info_group) + + self._target_info_label = QLabel("No target selected") + self._target_info_label.setWordWrap(True) + self._target_info_label.setStyleSheet(f"color: {DARK_TEXT}; padding: 8px;") + info_layout.addWidget(self._target_info_label) + + sidebar_layout.addWidget(info_group) + + sidebar_layout.addStretch() + + splitter.addWidget(sidebar) + splitter.setSizes([900, 300]) + + layout.addWidget(splitter) + + self._tabs.addTab(tab, "Map View") + + def _create_targets_tab(self): + """Create the targets table tab""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + + # Targets table + self._targets_table = QTableWidget() + self._targets_table.setColumnCount(9) + self._targets_table.setHorizontalHeaderLabels([ + "ID", "Track", "Range (m)", "Velocity (m/s)", + "Azimuth (°)", "Elevation (°)", "SNR (dB)", + "Classification", "Status" + ]) + + header = self._targets_table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + + self._targets_table.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + self._targets_table.setAlternatingRowColors(True) + + layout.addWidget(self._targets_table) + + self._tabs.addTab(tab, "Targets") + + def _create_settings_tab(self): + """Create the settings tab""" + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + + # Radar Settings Group + radar_group = QGroupBox("Radar Parameters") + radar_layout = QGridLayout(radar_group) + + radar_layout.addWidget(QLabel("System Frequency:"), 0, 0) + freq_spin = QDoubleSpinBox() + freq_spin.setRange(1, 100) + freq_spin.setValue(self._settings.system_frequency / 1e9) + freq_spin.setSuffix(" GHz") + radar_layout.addWidget(freq_spin, 0, 1) + + radar_layout.addWidget(QLabel("Max Range:"), 1, 0) + range_spin = QDoubleSpinBox() + range_spin.setRange(1, 200) + range_spin.setValue(self._settings.max_distance / 1000) + range_spin.setSuffix(" km") + radar_layout.addWidget(range_spin, 1, 1) + + radar_layout.addWidget(QLabel("PRF 1:"), 2, 0) + prf1_spin = QSpinBox() + prf1_spin.setRange(100, 10000) + prf1_spin.setValue(int(self._settings.prf1)) + prf1_spin.setSuffix(" Hz") + radar_layout.addWidget(prf1_spin, 2, 1) + + radar_layout.addWidget(QLabel("PRF 2:"), 3, 0) + prf2_spin = QSpinBox() + prf2_spin.setRange(100, 10000) + prf2_spin.setValue(int(self._settings.prf2)) + prf2_spin.setSuffix(" Hz") + radar_layout.addWidget(prf2_spin, 3, 1) + + layout.addWidget(radar_group) + + # About + about_group = QGroupBox("About") + about_layout = QVBoxLayout(about_group) + about_text = QLabel( + "PLFM Radar Dashboard
" + "PyQt6 Edition with Embedded Leaflet Map

" + "Version: 1.0.0
" + "Map: OpenStreetMap + Leaflet.js
" + "Framework: PyQt6 + QWebEngine" + ) + about_text.setStyleSheet(f"color: {DARK_TEXT}; padding: 12px;") + about_layout.addWidget(about_text) + + layout.addWidget(about_group) + layout.addStretch() + + self._tabs.addTab(tab, "Settings") + + def _setup_statusbar(self): + """Setup the status bar""" + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + + self._status_label = QLabel("Ready") + self._statusbar.addWidget(self._status_label) + + self._target_count_label = QLabel("Targets: 0") + self._statusbar.addPermanentWidget(self._target_count_label) + + self._mode_label = QLabel("Demo Mode") + self._mode_label.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;") + self._statusbar.addPermanentWidget(self._mode_label) + + def _start_demo_mode(self): + """Start the demo mode with simulated targets""" + self._simulator = TargetSimulator(self._radar_position, self) + self._simulator.targetsUpdated.connect(self._on_targets_updated) + self._simulator.start(500) # Update every 500ms + + self._demo_mode = True + self._demo_btn.setChecked(True) + self._demo_btn.setText("Stop Demo") + self._mode_label.setText("Demo Mode") + self._status_label.setText("Demo mode active") + + logger.info("Demo mode started") + + def _toggle_demo_mode(self, checked: bool): + """Toggle demo mode on/off""" + if checked: + self._start_demo_mode() + else: + if self._simulator: + self._simulator.stop() + self._demo_mode = False + self._demo_btn.setText("Start Demo") + self._mode_label.setText("Idle") + self._status_label.setText("Demo mode stopped") + logger.info("Demo mode stopped") + + def _add_demo_target(self): + """Add a random target in demo mode""" + if self._simulator: + self._simulator._add_random_target() + logger.info("Added random target") + + def _on_targets_updated(self, targets: List[RadarTarget]): + """Handle updated target list from simulator""" + # Update map + self._map_widget.set_targets(targets) + + # Update status bar + self._target_count_label.setText(f"Targets: {len(targets)}") + + # Update table + self._update_targets_table(targets) + + def _update_targets_table(self, targets: List[RadarTarget]): + """Update the targets table""" + self._targets_table.setRowCount(len(targets)) + + for row, target in enumerate(targets): + # ID + self._targets_table.setItem(row, 0, QTableWidgetItem(str(target.id))) + + # Track ID + self._targets_table.setItem(row, 1, QTableWidgetItem(str(target.track_id))) + + # Range + self._targets_table.setItem(row, 2, QTableWidgetItem(f"{target.range:.1f}")) + + # Velocity + vel_item = QTableWidgetItem(f"{target.velocity:+.1f}") + if target.velocity > 1: + vel_item.setForeground(QColor(DARK_ERROR)) + elif target.velocity < -1: + vel_item.setForeground(QColor(DARK_INFO)) + self._targets_table.setItem(row, 3, vel_item) + + # Azimuth + self._targets_table.setItem(row, 4, QTableWidgetItem(f"{target.azimuth:.1f}")) + + # Elevation + self._targets_table.setItem(row, 5, QTableWidgetItem(f"{target.elevation:.1f}")) + + # SNR + self._targets_table.setItem(row, 6, QTableWidgetItem(f"{target.snr:.1f}")) + + # Classification + self._targets_table.setItem(row, 7, QTableWidgetItem(target.classification)) + + # Status + status = "Approaching" if target.velocity > 1 else ( + "Receding" if target.velocity < -1 else "Stationary" + ) + status_item = QTableWidgetItem(status) + if status == "Approaching": + status_item.setForeground(QColor(DARK_ERROR)) + elif status == "Receding": + status_item.setForeground(QColor(DARK_INFO)) + self._targets_table.setItem(row, 8, status_item) + + def _on_target_selected(self, target_id: int): + """Handle target selection from map""" + # Find target + if self._simulator: + for target in self._simulator._targets: + if target.id == target_id: + self._show_target_info(target) + break + + def _show_target_info(self, target: RadarTarget): + """Display target information in sidebar""" + status = "Approaching" if target.velocity > 1 else ( + "Receding" if target.velocity < -1 else "Stationary" + ) + + info = f""" + Target #{target.id}

+ Track ID: {target.track_id}
+ Range: {target.range:.1f} m
+ Velocity: {target.velocity:+.1f} m/s
+ Azimuth: {target.azimuth:.1f}°
+ Elevation: {target.elevation:.1f}°
+ SNR: {target.snr:.1f} dB
+ Classification: {target.classification}
+ Status: {status} + """ + + self._target_info_label.setText(info) + + def _on_position_changed(self): + """Handle radar position change from UI""" + self._radar_position.latitude = self._lat_spin.value() + self._radar_position.longitude = self._lon_spin.value() + self._radar_position.altitude = self._alt_spin.value() + + self._map_widget.set_radar_position(self._radar_position) + + if self._simulator: + self._simulator.set_radar_position(self._radar_position) + + def _on_coverage_changed(self, value: float): + """Handle coverage radius change""" + radius_m = value * 1000 + self._settings.coverage_radius = radius_m + self._map_widget.set_coverage_radius(radius_m) + + def closeEvent(self, event): + """Handle window close""" + if self._simulator: + self._simulator.stop() + event.accept() + + +# ============================================================================= +# Main Entry Point +# ============================================================================= +def main(): + """Application entry point""" + # Create application + app = QApplication(sys.argv) + app.setApplicationName("PLFM Radar Dashboard") + app.setApplicationVersion("1.0.0") + + # Set font + font = QFont("Segoe UI", 10) + app.setFont(font) + + # Create and show main window + window = RadarDashboard() + window.show() + + logger.info("Application started") + + # Run event loop + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/9_Firmware/9_3_GUI/GUI_V6.py b/9_Firmware/9_3_GUI/GUI_V6.py index f789462..b005c1c 100644 --- a/9_Firmware/9_3_GUI/GUI_V6.py +++ b/9_Firmware/9_3_GUI/GUI_V6.py @@ -8,15 +8,11 @@ import numpy as np import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure -import matplotlib.patches as patches import logging from dataclasses import dataclass -from typing import Dict, List, Tuple, Optional -from scipy import signal from sklearn.cluster import DBSCAN from filterpy.kalman import KalmanFilter import crcmod -import math import webbrowser import tempfile import os @@ -30,9 +26,9 @@ except ImportError: logging.warning("pyusb not available. USB functionality will be disabled.") try: - from pyftdi.ftdi import Ftdi - from pyftdi.usbtools import UsbTools - from pyftdi.ftdi import FtdiError + from pyftdi.ftdi import Ftdi # noqa: F401 + from pyftdi.usbtools import UsbTools # noqa: F401 + from pyftdi.ftdi import FtdiError # noqa: F401 FTDI_AVAILABLE = True except ImportError: FTDI_AVAILABLE = False @@ -294,7 +290,7 @@ class FT601Interface: 'device': dev, 'serial': serial }) - except Exception as e: + except Exception: devices.append({ 'description': f"FT601 USB3.0 (VID:{vid:04X}, PID:{pid:04X})", 'vendor_id': vid, @@ -553,7 +549,7 @@ class STM32USBInterface: 'product_id': pid, 'device': dev }) - except: + except Exception: devices.append({ 'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})", 'vendor_id': vid, @@ -910,7 +906,7 @@ class RadarPacketParser: if len(packet) < 6: return None - sync = packet[0:2] + _sync = packet[0:2] # noqa: F841 packet_type = packet[2] length = packet[3] @@ -1335,8 +1331,8 @@ class RadarGUI: logging.info("Radar system stopped") - def process_radar_data(self): - """Process incoming radar data from FT601""" + def _process_radar_data_ft601(self): + """Process incoming radar data from FT601 (legacy, superseded by FTDI version).""" buffer = bytearray() while True: if self.running and self.ft601_interface.is_open: @@ -1375,7 +1371,7 @@ class RadarGUI: # This should match your packet structure return 64 # Example: 64-byte packets - def process_gps_data(self): + def _process_gps_data_ft601(self): """Step 16/17: Process GPS data from STM32 via USB CDC""" while True: if self.running and self.stm32_usb_interface.is_open: @@ -1716,7 +1712,7 @@ def main(): """Main application entry point""" try: root = tk.Tk() - app = RadarGUI(root) + _app = RadarGUI(root) # noqa: F841 – must stay alive for mainloop root.mainloop() except Exception as e: logging.error(f"Application error: {e}") diff --git a/9_Firmware/9_3_GUI/GUI_V6_Demo.py b/9_Firmware/9_3_GUI/GUI_V6_Demo.py index d8efd08..904e9ab 100644 --- a/9_Firmware/9_3_GUI/GUI_V6_Demo.py +++ b/9_Firmware/9_3_GUI/GUI_V6_Demo.py @@ -8,8 +8,6 @@ All buttons work, simulated radar data is generated in real-time import tkinter as tk from tkinter import ttk, messagebox -import threading -import queue import time import numpy as np import matplotlib.pyplot as plt @@ -17,10 +15,9 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure import logging from dataclasses import dataclass -from typing import List, Dict, Optional +from typing import List, Dict import random import json -import os from datetime import datetime # Configure logging @@ -1200,7 +1197,7 @@ def main(): root = tk.Tk() # Create application - app = RadarDemoGUI(root) + _app = RadarDemoGUI(root) # noqa: F841 — keeps reference alive # Center window root.update_idletasks() diff --git a/9_Firmware/9_3_GUI/GUI_V7_PyQt.py b/9_Firmware/9_3_GUI/GUI_V7_PyQt.py new file mode 100644 index 0000000..bef92dc --- /dev/null +++ b/9_Firmware/9_3_GUI/GUI_V7_PyQt.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +PLFM Radar System GUI V7 — PyQt6 Edition + +Entry point. Launches the RadarDashboard main window. + +Usage: + python GUI_V7_PyQt.py +""" + +import sys +import logging + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtGui import QFont + +from v7 import RadarDashboard + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(name)s %(message)s", +) +logger = logging.getLogger(__name__) + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("PLFM Radar System V7") + app.setApplicationVersion("7.0.0") + app.setFont(QFont("Segoe UI", 10)) + + window = RadarDashboard() + window.show() + + logger.info("PLFM Radar GUI V7 started") + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() diff --git a/9_Firmware/9_3_GUI/v7/__init__.py b/9_Firmware/9_3_GUI/v7/__init__.py new file mode 100644 index 0000000..dd25c98 --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/__init__.py @@ -0,0 +1,80 @@ +""" +v7 — PLFM Radar GUI V7 (PyQt6 edition). + +Re-exports all public classes and functions from sub-modules for convenient +top-level imports: + + from v7 import RadarDashboard, RadarTarget, RadarSettings, ... +""" + +# Models / constants +from .models import ( + RadarTarget, + RadarSettings, + GPSData, + ProcessingConfig, + TileServer, + DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, + DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, + DARK_TREEVIEW, DARK_TREEVIEW_ALT, + DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO, + USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE, + SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE, +) + +# Hardware interfaces +from .hardware import ( + FT2232HQInterface, + STM32USBInterface, +) + +# Processing pipeline +from .processing import ( + RadarProcessor, + RadarPacketParser, + USBPacketParser, + apply_pitch_correction, +) + +# Workers and simulator +from .workers import ( + RadarDataWorker, + GPSDataWorker, + TargetSimulator, + polar_to_geographic, +) + +# Map widget +from .map_widget import ( + MapBridge, + RadarMapWidget, +) + +# Main dashboard +from .dashboard import ( + RadarDashboard, + RangeDopplerCanvas, +) + +__all__ = [ + # models + "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer", + "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER", + "DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER", + "DARK_TREEVIEW", "DARK_TREEVIEW_ALT", + "DARK_SUCCESS", "DARK_WARNING", "DARK_ERROR", "DARK_INFO", + "USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE", + "SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", "CRCMOD_AVAILABLE", + # hardware + "FT2232HQInterface", "STM32USBInterface", + # processing + "RadarProcessor", "RadarPacketParser", "USBPacketParser", + "apply_pitch_correction", + # workers + "RadarDataWorker", "GPSDataWorker", "TargetSimulator", + "polar_to_geographic", + # map + "MapBridge", "RadarMapWidget", + # dashboard + "RadarDashboard", "RangeDopplerCanvas", +] diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py new file mode 100644 index 0000000..db65027 --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -0,0 +1,1331 @@ +""" +v7.dashboard — Main application window for the PLFM Radar GUI V7. + +RadarDashboard is a QMainWindow with four tabs: + 1. Main View — Range-Doppler matplotlib canvas, device combos, Start/Stop, targets table + 2. Map View — Embedded Leaflet map + sidebar (position, coverage, demo, target info) + 3. Diagnostics — Connection indicators, packet stats, dependency status, log viewer + 4. Settings — All radar parameters + About section + +Integrates: hardware interfaces, QThread workers, TargetSimulator, RadarMapWidget. +""" + +import time +import logging +from typing import List, Optional + +import numpy as np + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, + QTabWidget, QSplitter, QGroupBox, QFrame, + QLabel, QPushButton, QComboBox, QCheckBox, + QDoubleSpinBox, QSpinBox, + QTableWidget, QTableWidgetItem, QHeaderView, + QPlainTextEdit, QStatusBar, QMessageBox, +) +from PyQt6.QtCore import Qt, QTimer, pyqtSlot +from PyQt6.QtGui import QColor + +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg +from matplotlib.figure import Figure + +from .models import ( + RadarTarget, RadarSettings, GPSData, ProcessingConfig, + DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, + DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, + DARK_TREEVIEW, DARK_TREEVIEW_ALT, + DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO, + USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE, + SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE, +) +from .hardware import FT2232HQInterface, STM32USBInterface +from .processing import RadarProcessor, RadarPacketParser, USBPacketParser +from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator +from .map_widget import RadarMapWidget + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Range-Doppler Canvas (matplotlib) +# ============================================================================= + +class RangeDopplerCanvas(FigureCanvasQTAgg): + """Matplotlib canvas showing the Range-Doppler map with dark theme.""" + + def __init__(self, parent=None): + fig = Figure(figsize=(10, 6), facecolor=DARK_BG) + self.ax = fig.add_subplot(111, facecolor=DARK_ACCENT) + + self._data = np.zeros((1024, 32)) + self.im = self.ax.imshow( + self._data, aspect="auto", cmap="hot", + extent=[0, 32, 0, 1024], origin="lower", + ) + + self.ax.set_title("Range-Doppler Map (Pitch Corrected)", color=DARK_FG) + self.ax.set_xlabel("Doppler Bin", color=DARK_FG) + self.ax.set_ylabel("Range Bin", color=DARK_FG) + self.ax.tick_params(colors=DARK_FG) + for spine in self.ax.spines.values(): + spine.set_color(DARK_BORDER) + + fig.tight_layout() + super().__init__(fig) + + def update_map(self, rdm: np.ndarray): + display = np.log10(rdm + 1) + self.im.set_data(display) + self.im.set_clim(vmin=display.min(), vmax=max(display.max(), 0.1)) + self.draw_idle() + + +# ============================================================================= +# RadarDashboard — main window +# ============================================================================= + +class RadarDashboard(QMainWindow): + """Main application window with 4 tabs.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("PLFM Radar System GUI V7 — PyQt6") + self.setGeometry(100, 60, 1500, 950) + + # ---- Core objects -------------------------------------------------- + self._settings = RadarSettings() + self._radar_position = GPSData( + latitude=41.9028, longitude=12.4964, + altitude=0.0, pitch=0.0, heading=0.0, timestamp=0.0, + ) + + # Hardware interfaces + self._stm32 = STM32USBInterface() + self._ft2232hq = FT2232HQInterface() + + # Processing + self._processor = RadarProcessor() + self._radar_parser = RadarPacketParser() + self._usb_parser = USBPacketParser() + self._processing_config = ProcessingConfig() + + # Device lists (cached for index lookup) + self._stm32_devices: list = [] + self._ft2232hq_devices: list = [] + + # Workers (created on demand) + self._radar_worker: Optional[RadarDataWorker] = None + self._gps_worker: Optional[GPSDataWorker] = None + self._simulator: Optional[TargetSimulator] = None + + # State + self._running = False + self._demo_mode = False + self._start_time = time.time() + self._radar_stats: dict = {} + self._gps_packet_count = 0 + self._current_targets: List[RadarTarget] = [] + self._corrected_elevations: list = [] + + # ---- Build UI ------------------------------------------------------ + self._apply_dark_theme() + self._setup_ui() + self._setup_statusbar() + + # GUI refresh timer (100 ms) + self._gui_timer = QTimer(self) + self._gui_timer.timeout.connect(self._refresh_gui) + self._gui_timer.start(100) + + # Log handler for diagnostics + self._log_handler = _QtLogHandler(self._log_append) + self._log_handler.setLevel(logging.INFO) + logging.getLogger().addHandler(self._log_handler) + + logger.info("RadarDashboard initialised") + + # ===================================================================== + # Dark theme + # ===================================================================== + + def _apply_dark_theme(self): + self.setStyleSheet(f""" + QMainWindow, QWidget {{ + background-color: {DARK_BG}; + color: {DARK_FG}; + }} + QTabWidget::pane {{ + border: 1px solid {DARK_BORDER}; + background-color: {DARK_BG}; + }} + QTabBar::tab {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + padding: 8px 18px; + border: 1px solid {DARK_BORDER}; + border-bottom: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + }} + QTabBar::tab:selected {{ + background-color: {DARK_HIGHLIGHT}; + }} + QTabBar::tab:hover {{ + background-color: {DARK_BUTTON_HOVER}; + }} + QGroupBox {{ + border: 1px solid {DARK_BORDER}; + border-radius: 4px; + margin-top: 12px; + padding-top: 12px; + font-weight: bold; + color: {DARK_FG}; + }} + QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 6px; + }} + QPushButton {{ + background-color: {DARK_BUTTON}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 6px 16px; + border-radius: 4px; + }} + QPushButton:hover {{ + background-color: {DARK_BUTTON_HOVER}; + }} + QPushButton:pressed {{ + background-color: {DARK_HIGHLIGHT}; + }} + QPushButton:disabled {{ + color: {DARK_BORDER}; + }} + QComboBox {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 4px 8px; + border-radius: 4px; + }} + QLineEdit, QSpinBox, QDoubleSpinBox {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + padding: 4px 8px; + border-radius: 4px; + }} + QCheckBox {{ + color: {DARK_FG}; + spacing: 6px; + }} + QLabel {{ + color: {DARK_FG}; + }} + QTableWidget {{ + background-color: {DARK_TREEVIEW}; + alternate-background-color: {DARK_TREEVIEW_ALT}; + color: {DARK_FG}; + gridline-color: {DARK_BORDER}; + border: 1px solid {DARK_BORDER}; + }} + QTableWidget::item:selected {{ + background-color: {DARK_INFO}; + }} + QHeaderView::section {{ + background-color: {DARK_HIGHLIGHT}; + color: {DARK_FG}; + padding: 6px; + border: none; + border-right: 1px solid {DARK_BORDER}; + border-bottom: 1px solid {DARK_BORDER}; + }} + QPlainTextEdit {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; + font-family: 'Courier New', monospace; + font-size: 11px; + }} + QScrollBar:vertical {{ + background-color: {DARK_ACCENT}; + width: 12px; + }} + QScrollBar::handle:vertical {{ + background-color: {DARK_HIGHLIGHT}; + border-radius: 6px; + min-height: 20px; + }} + QStatusBar {{ + background-color: {DARK_ACCENT}; + color: {DARK_FG}; + }} + """) + + # ===================================================================== + # UI construction + # ===================================================================== + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(8, 8, 8, 8) + main_layout.setSpacing(8) + + self._tabs = QTabWidget() + main_layout.addWidget(self._tabs) + + self._create_main_tab() + self._create_map_tab() + self._create_diagnostics_tab() + self._create_settings_tab() + + # ----------------------------------------------------------------- + # TAB 1: Main View + # ----------------------------------------------------------------- + + def _create_main_tab(self): + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + + # ---- Control bar --------------------------------------------------- + ctrl = QFrame() + ctrl.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;") + ctrl_layout = QGridLayout(ctrl) + ctrl_layout.setContentsMargins(8, 6, 8, 6) + + # Row 0: device combos & buttons + ctrl_layout.addWidget(QLabel("STM32 USB:"), 0, 0) + self._stm32_combo = QComboBox() + self._stm32_combo.setMinimumWidth(200) + ctrl_layout.addWidget(self._stm32_combo, 0, 1) + + ctrl_layout.addWidget(QLabel("FT2232HQ (Primary):"), 0, 2) + self._ft2232hq_combo = QComboBox() + self._ft2232hq_combo.setMinimumWidth(200) + ctrl_layout.addWidget(self._ft2232hq_combo, 0, 3) + + refresh_btn = QPushButton("Refresh Devices") + refresh_btn.clicked.connect(self._refresh_devices) + ctrl_layout.addWidget(refresh_btn, 0, 4) + + self._start_btn = QPushButton("Start Radar") + self._start_btn.setStyleSheet( + f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}" + f"QPushButton:hover {{ background-color: #66BB6A; }}" + ) + self._start_btn.clicked.connect(self._start_radar) + ctrl_layout.addWidget(self._start_btn, 0, 8) + + self._stop_btn = QPushButton("Stop Radar") + self._stop_btn.setEnabled(False) + self._stop_btn.setStyleSheet( + f"QPushButton {{ background-color: {DARK_ERROR}; color: white; font-weight: bold; }}" + f"QPushButton:hover {{ background-color: #EF5350; }}" + ) + self._stop_btn.clicked.connect(self._stop_radar) + ctrl_layout.addWidget(self._stop_btn, 0, 9) + + self._demo_btn_main = QPushButton("Start Demo") + self._demo_btn_main.setStyleSheet( + f"QPushButton {{ background-color: {DARK_INFO}; color: white; font-weight: bold; }}" + f"QPushButton:hover {{ background-color: #42A5F5; }}" + ) + self._demo_btn_main.clicked.connect(self._toggle_demo_main) + ctrl_layout.addWidget(self._demo_btn_main, 0, 10) + + # Row 1: status labels + self._gps_label = QLabel("GPS: Waiting for data...") + ctrl_layout.addWidget(self._gps_label, 1, 0, 1, 4) + + self._pitch_label = QLabel("Pitch: --.--\u00b0") + ctrl_layout.addWidget(self._pitch_label, 1, 4, 1, 2) + + self._status_label_main = QLabel("Status: Ready") + self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight) + ctrl_layout.addWidget(self._status_label_main, 1, 6, 1, 5) + + layout.addWidget(ctrl) + + # ---- Display area (range-doppler + targets table) ------------------ + display_splitter = QSplitter(Qt.Orientation.Horizontal) + + # Range-Doppler canvas + self._rdm_canvas = RangeDopplerCanvas() + display_splitter.addWidget(self._rdm_canvas) + + # Targets table + targets_group = QGroupBox("Detected Targets (Pitch Corrected)") + tg_layout = QVBoxLayout(targets_group) + + self._targets_table_main = QTableWidget() + self._targets_table_main.setColumnCount(7) + self._targets_table_main.setHorizontalHeaderLabels([ + "Track ID", "Range (m)", "Velocity (m/s)", + "Azimuth", "Raw Elev", "Corr Elev", "SNR (dB)", + ]) + self._targets_table_main.setAlternatingRowColors(True) + self._targets_table_main.setSelectionBehavior( + QTableWidget.SelectionBehavior.SelectRows + ) + header = self._targets_table_main.horizontalHeader() + header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + tg_layout.addWidget(self._targets_table_main) + + display_splitter.addWidget(targets_group) + display_splitter.setSizes([800, 400]) + + layout.addWidget(display_splitter, stretch=1) + self._tabs.addTab(tab, "Main View") + + # ----------------------------------------------------------------- + # TAB 2: Map View + # ----------------------------------------------------------------- + + def _create_map_tab(self): + tab = QWidget() + layout = QHBoxLayout(tab) + layout.setContentsMargins(4, 4, 4, 4) + + splitter = QSplitter(Qt.Orientation.Horizontal) + + # Map widget + self._map_widget = RadarMapWidget( + radar_lat=self._radar_position.latitude, + radar_lon=self._radar_position.longitude, + ) + self._map_widget.targetSelected.connect(self._on_target_selected) + splitter.addWidget(self._map_widget) + + # Sidebar + sidebar = QWidget() + sidebar.setMaximumWidth(320) + sidebar.setMinimumWidth(280) + sb_layout = QVBoxLayout(sidebar) + sb_layout.setContentsMargins(8, 8, 8, 8) + + # Radar position group + pos_group = QGroupBox("Radar Position") + pos_layout = QGridLayout(pos_group) + + self._lat_spin = QDoubleSpinBox() + self._lat_spin.setRange(-90, 90) + self._lat_spin.setDecimals(6) + self._lat_spin.setValue(self._radar_position.latitude) + self._lat_spin.valueChanged.connect(self._on_position_changed) + + self._lon_spin = QDoubleSpinBox() + self._lon_spin.setRange(-180, 180) + self._lon_spin.setDecimals(6) + self._lon_spin.setValue(self._radar_position.longitude) + self._lon_spin.valueChanged.connect(self._on_position_changed) + + self._alt_spin = QDoubleSpinBox() + self._alt_spin.setRange(0, 50000) + self._alt_spin.setDecimals(1) + self._alt_spin.setValue(0.0) + self._alt_spin.setSuffix(" m") + + pos_layout.addWidget(QLabel("Latitude:"), 0, 0) + pos_layout.addWidget(self._lat_spin, 0, 1) + pos_layout.addWidget(QLabel("Longitude:"), 1, 0) + pos_layout.addWidget(self._lon_spin, 1, 1) + pos_layout.addWidget(QLabel("Altitude:"), 2, 0) + pos_layout.addWidget(self._alt_spin, 2, 1) + + sb_layout.addWidget(pos_group) + + # Coverage group + cov_group = QGroupBox("Coverage") + cov_layout = QGridLayout(cov_group) + + self._coverage_spin = QDoubleSpinBox() + self._coverage_spin.setRange(1, 200) + self._coverage_spin.setDecimals(1) + self._coverage_spin.setValue(self._settings.coverage_radius / 1000) + self._coverage_spin.setSuffix(" km") + self._coverage_spin.valueChanged.connect(self._on_coverage_changed) + + cov_layout.addWidget(QLabel("Radius:"), 0, 0) + cov_layout.addWidget(self._coverage_spin, 0, 1) + + sb_layout.addWidget(cov_group) + + # Demo controls group + demo_group = QGroupBox("Demo Mode") + demo_layout = QVBoxLayout(demo_group) + + self._demo_btn_map = QPushButton("Start Demo") + self._demo_btn_map.setCheckable(True) + self._demo_btn_map.clicked.connect(self._toggle_demo_map) + demo_layout.addWidget(self._demo_btn_map) + + add_btn = QPushButton("Add Random Target") + add_btn.clicked.connect(self._add_demo_target) + demo_layout.addWidget(add_btn) + + sb_layout.addWidget(demo_group) + + # Selected target info + info_group = QGroupBox("Selected Target") + info_layout = QVBoxLayout(info_group) + + self._target_info_label = QLabel("No target selected") + self._target_info_label.setWordWrap(True) + self._target_info_label.setStyleSheet(f"color: {DARK_TEXT}; padding: 8px;") + info_layout.addWidget(self._target_info_label) + + sb_layout.addWidget(info_group) + sb_layout.addStretch() + + splitter.addWidget(sidebar) + splitter.setSizes([900, 300]) + + layout.addWidget(splitter) + self._tabs.addTab(tab, "Map View") + + # ----------------------------------------------------------------- + # TAB 3: Diagnostics + # ----------------------------------------------------------------- + + def _create_diagnostics_tab(self): + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(8, 8, 8, 8) + + top_row = QHBoxLayout() + + # Connection status + conn_group = QGroupBox("Connection Status") + conn_layout = QGridLayout(conn_group) + + self._conn_stm32 = self._make_status_label("STM32 USB") + self._conn_ft2232hq = self._make_status_label("FT2232HQ (Primary)") + + conn_layout.addWidget(QLabel("STM32 USB:"), 0, 0) + conn_layout.addWidget(self._conn_stm32, 0, 1) + conn_layout.addWidget(QLabel("FT2232HQ:"), 1, 0) + conn_layout.addWidget(self._conn_ft2232hq, 1, 1) + + top_row.addWidget(conn_group) + + # Packet statistics + stats_group = QGroupBox("Packet Statistics") + stats_layout = QGridLayout(stats_group) + + labels = [ + "Radar Packets:", "Bytes Received:", "GPS Packets:", + "Errors:", "Active Tracks:", "Detected Targets:", + "Uptime:", "Packet Rate:", + ] + self._diag_values: list = [] + for i, text in enumerate(labels): + r, c = divmod(i, 2) + stats_layout.addWidget(QLabel(text), r, c * 2) + val = QLabel("0") + val.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;") + stats_layout.addWidget(val, r, c * 2 + 1) + self._diag_values.append(val) + + top_row.addWidget(stats_group) + + # Dependency status + dep_group = QGroupBox("Optional Dependencies") + dep_layout = QGridLayout(dep_group) + + deps = [ + ("pyusb", USB_AVAILABLE), + ("pyftdi", FTDI_AVAILABLE), + ("scipy", SCIPY_AVAILABLE), + ("sklearn", SKLEARN_AVAILABLE), + ("filterpy", FILTERPY_AVAILABLE), + ("crcmod", CRCMOD_AVAILABLE), + ] + for i, (name, avail) in enumerate(deps): + dep_layout.addWidget(QLabel(name), i, 0) + lbl = QLabel("Available" if avail else "Missing") + lbl.setStyleSheet( + f"color: {DARK_SUCCESS}; font-weight: bold;" + if avail else + f"color: {DARK_WARNING}; font-weight: bold;" + ) + dep_layout.addWidget(lbl, i, 1) + + top_row.addWidget(dep_group) + + layout.addLayout(top_row) + + # Log viewer + log_group = QGroupBox("System Log") + log_layout = QVBoxLayout(log_group) + + self._log_text = QPlainTextEdit() + self._log_text.setReadOnly(True) + self._log_text.setMaximumBlockCount(500) + log_layout.addWidget(self._log_text) + + clear_btn = QPushButton("Clear Log") + clear_btn.clicked.connect(self._log_text.clear) + log_layout.addWidget(clear_btn) + + layout.addWidget(log_group, stretch=1) + + self._tabs.addTab(tab, "Diagnostics") + + # ----------------------------------------------------------------- + # TAB 4: Settings + # ----------------------------------------------------------------- + + def _create_settings_tab(self): + from PyQt6.QtWidgets import QScrollArea + + tab = QWidget() + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + + inner = QWidget() + layout = QVBoxLayout(inner) + layout.setContentsMargins(8, 8, 8, 8) + + # ---- Radar parameters group ---------------------------------------- + radar_group = QGroupBox("Radar Parameters") + r_layout = QGridLayout(radar_group) + + self._setting_spins: dict = {} + param_defs = [ + ("System Frequency (GHz):", "system_frequency", 1, 100, 2, + self._settings.system_frequency / 1e9, " GHz"), + ("Chirp Duration 1 (us):", "chirp_duration_1", 0.01, 10000, 2, + self._settings.chirp_duration_1 * 1e6, " us"), + ("Chirp Duration 2 (us):", "chirp_duration_2", 0.001, 10000, 3, + self._settings.chirp_duration_2 * 1e6, " us"), + ("Chirps per Position:", "chirps_per_position", 1, 1024, 0, + self._settings.chirps_per_position, ""), + ("Freq Min (MHz):", "freq_min", 0.1, 1000, 1, + self._settings.freq_min / 1e6, " MHz"), + ("Freq Max (MHz):", "freq_max", 0.1, 1000, 1, + self._settings.freq_max / 1e6, " MHz"), + ("PRF 1 (Hz):", "prf1", 100, 100000, 0, + self._settings.prf1, " Hz"), + ("PRF 2 (Hz):", "prf2", 100, 100000, 0, + self._settings.prf2, " Hz"), + ("Max Distance (km):", "max_distance", 1, 500, 1, + self._settings.max_distance / 1000, " km"), + ("Map Size (km):", "map_size", 1, 500, 1, + self._settings.map_size / 1000, " km"), + ] + + for i, (label, key, lo, hi, dec, default, suffix) in enumerate(param_defs): + r_layout.addWidget(QLabel(label), i, 0) + if dec == 0: + spin = QSpinBox() + spin.setRange(int(lo), int(hi)) + spin.setValue(int(default)) + if suffix: + spin.setSuffix(suffix) + else: + spin = QDoubleSpinBox() + spin.setRange(lo, hi) + spin.setDecimals(dec) + spin.setValue(default) + if suffix: + spin.setSuffix(suffix) + r_layout.addWidget(spin, i, 1) + self._setting_spins[key] = spin + + apply_btn = QPushButton("Apply Settings") + apply_btn.setStyleSheet( + f"QPushButton {{ background-color: {DARK_INFO}; color: white; font-weight: bold; }}" + ) + apply_btn.clicked.connect(self._apply_settings) + r_layout.addWidget(apply_btn, len(param_defs), 0, 1, 2) + + layout.addWidget(radar_group) + + # ---- Signal Processing group --------------------------------------- + proc_group = QGroupBox("Signal Processing") + p_layout = QGridLayout(proc_group) + row = 0 + + # -- MTI -- + self._mti_check = QCheckBox("MTI (Moving Target Indication)") + self._mti_check.setChecked(self._processing_config.mti_enabled) + p_layout.addWidget(self._mti_check, row, 0, 1, 2) + row += 1 + + p_layout.addWidget(QLabel("MTI Order:"), row, 0) + self._mti_order_spin = QSpinBox() + self._mti_order_spin.setRange(1, 3) + self._mti_order_spin.setValue(self._processing_config.mti_order) + self._mti_order_spin.setToolTip("1 = single canceller, 2 = double, 3 = triple") + p_layout.addWidget(self._mti_order_spin, row, 1) + row += 1 + + # -- Separator -- + sep1 = QFrame() + sep1.setFrameShape(QFrame.Shape.HLine) + sep1.setStyleSheet(f"color: {DARK_BORDER};") + p_layout.addWidget(sep1, row, 0, 1, 2) + row += 1 + + # -- CFAR -- + self._cfar_check = QCheckBox("CFAR (Constant False Alarm Rate)") + self._cfar_check.setChecked(self._processing_config.cfar_enabled) + p_layout.addWidget(self._cfar_check, row, 0, 1, 2) + row += 1 + + p_layout.addWidget(QLabel("CFAR Type:"), row, 0) + self._cfar_type_combo = QComboBox() + self._cfar_type_combo.addItems(["CA-CFAR", "OS-CFAR", "GO-CFAR", "SO-CFAR"]) + self._cfar_type_combo.setCurrentText(self._processing_config.cfar_type) + p_layout.addWidget(self._cfar_type_combo, row, 1) + row += 1 + + p_layout.addWidget(QLabel("Guard Cells:"), row, 0) + self._cfar_guard_spin = QSpinBox() + self._cfar_guard_spin.setRange(1, 20) + self._cfar_guard_spin.setValue(self._processing_config.cfar_guard_cells) + p_layout.addWidget(self._cfar_guard_spin, row, 1) + row += 1 + + p_layout.addWidget(QLabel("Training Cells:"), row, 0) + self._cfar_train_spin = QSpinBox() + self._cfar_train_spin.setRange(1, 50) + self._cfar_train_spin.setValue(self._processing_config.cfar_training_cells) + p_layout.addWidget(self._cfar_train_spin, row, 1) + row += 1 + + p_layout.addWidget(QLabel("Threshold Factor:"), row, 0) + self._cfar_thresh_spin = QDoubleSpinBox() + self._cfar_thresh_spin.setRange(0.1, 50.0) + self._cfar_thresh_spin.setDecimals(1) + self._cfar_thresh_spin.setValue(self._processing_config.cfar_threshold_factor) + self._cfar_thresh_spin.setSingleStep(0.5) + p_layout.addWidget(self._cfar_thresh_spin, row, 1) + row += 1 + + # -- Separator -- + sep2 = QFrame() + sep2.setFrameShape(QFrame.Shape.HLine) + sep2.setStyleSheet(f"color: {DARK_BORDER};") + p_layout.addWidget(sep2, row, 0, 1, 2) + row += 1 + + # -- DC Notch -- + self._dc_notch_check = QCheckBox("DC Notch / Zero-Doppler Removal") + self._dc_notch_check.setChecked(self._processing_config.dc_notch_enabled) + p_layout.addWidget(self._dc_notch_check, row, 0, 1, 2) + row += 1 + + # -- Separator -- + sep3 = QFrame() + sep3.setFrameShape(QFrame.Shape.HLine) + sep3.setStyleSheet(f"color: {DARK_BORDER};") + p_layout.addWidget(sep3, row, 0, 1, 2) + row += 1 + + # -- Windowing -- + p_layout.addWidget(QLabel("Window Function:"), row, 0) + self._window_combo = QComboBox() + self._window_combo.addItems(["None", "Hann", "Hamming", "Blackman", "Kaiser", "Chebyshev"]) + self._window_combo.setCurrentText(self._processing_config.window_type) + if not SCIPY_AVAILABLE: + # Without scipy, only None/Hann/Hamming/Blackman via numpy + self._window_combo.setToolTip("Kaiser and Chebyshev require scipy") + p_layout.addWidget(self._window_combo, row, 1) + row += 1 + + # -- Separator -- + sep4 = QFrame() + sep4.setFrameShape(QFrame.Shape.HLine) + sep4.setStyleSheet(f"color: {DARK_BORDER};") + p_layout.addWidget(sep4, row, 0, 1, 2) + row += 1 + + # -- Detection Threshold -- + p_layout.addWidget(QLabel("Detection Threshold (dB):"), row, 0) + self._det_thresh_spin = QDoubleSpinBox() + self._det_thresh_spin.setRange(0.0, 60.0) + self._det_thresh_spin.setDecimals(1) + self._det_thresh_spin.setValue(self._processing_config.detection_threshold_db) + self._det_thresh_spin.setSuffix(" dB") + self._det_thresh_spin.setSingleStep(1.0) + self._det_thresh_spin.setToolTip("SNR threshold above noise floor (used when CFAR is disabled)") + p_layout.addWidget(self._det_thresh_spin, row, 1) + row += 1 + + # -- Separator -- + sep5 = QFrame() + sep5.setFrameShape(QFrame.Shape.HLine) + sep5.setStyleSheet(f"color: {DARK_BORDER};") + p_layout.addWidget(sep5, row, 0, 1, 2) + row += 1 + + # -- Clustering -- + self._cluster_check = QCheckBox("DBSCAN Clustering") + self._cluster_check.setChecked(self._processing_config.clustering_enabled) + if not SKLEARN_AVAILABLE: + self._cluster_check.setEnabled(False) + self._cluster_check.setToolTip("Requires scikit-learn") + p_layout.addWidget(self._cluster_check, row, 0, 1, 2) + row += 1 + + p_layout.addWidget(QLabel("DBSCAN eps:"), row, 0) + self._cluster_eps_spin = QDoubleSpinBox() + self._cluster_eps_spin.setRange(1.0, 5000.0) + self._cluster_eps_spin.setDecimals(1) + self._cluster_eps_spin.setValue(self._processing_config.clustering_eps) + self._cluster_eps_spin.setSingleStep(10.0) + p_layout.addWidget(self._cluster_eps_spin, row, 1) + row += 1 + + p_layout.addWidget(QLabel("Min Samples:"), row, 0) + self._cluster_min_spin = QSpinBox() + self._cluster_min_spin.setRange(1, 20) + self._cluster_min_spin.setValue(self._processing_config.clustering_min_samples) + p_layout.addWidget(self._cluster_min_spin, row, 1) + row += 1 + + # -- Separator -- + sep6 = QFrame() + sep6.setFrameShape(QFrame.Shape.HLine) + sep6.setStyleSheet(f"color: {DARK_BORDER};") + p_layout.addWidget(sep6, row, 0, 1, 2) + row += 1 + + # -- Kalman Tracking -- + self._tracking_check = QCheckBox("Kalman Tracking") + self._tracking_check.setChecked(self._processing_config.tracking_enabled) + if not FILTERPY_AVAILABLE: + self._tracking_check.setEnabled(False) + self._tracking_check.setToolTip("Requires filterpy") + p_layout.addWidget(self._tracking_check, row, 0, 1, 2) + row += 1 + + # Apply Processing button + apply_proc_btn = QPushButton("Apply Processing Settings") + apply_proc_btn.setStyleSheet( + f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}" + f"QPushButton:hover {{ background-color: #66BB6A; }}" + ) + apply_proc_btn.clicked.connect(self._apply_processing_config) + p_layout.addWidget(apply_proc_btn, row, 0, 1, 2) + + layout.addWidget(proc_group) + + # ---- About group --------------------------------------------------- + about_group = QGroupBox("About") + about_layout = QVBoxLayout(about_group) + about_lbl = QLabel( + "PLFM Radar System GUI V7
" + "PyQt6 Edition with Embedded Leaflet Map

" + "Data Interface: FT2232HQ (USB 2.0)
" + "Map: OpenStreetMap + Leaflet.js
" + "Framework: PyQt6 + QWebEngine
" + "Version: 7.0.0" + ) + about_lbl.setStyleSheet(f"color: {DARK_TEXT}; padding: 12px;") + about_layout.addWidget(about_lbl) + + layout.addWidget(about_group) + layout.addStretch() + + scroll.setWidget(inner) + tab_layout = QVBoxLayout(tab) + tab_layout.setContentsMargins(0, 0, 0, 0) + tab_layout.addWidget(scroll) + + self._tabs.addTab(tab, "Settings") + + # ===================================================================== + # Status bar + # ===================================================================== + + def _setup_statusbar(self): + bar = QStatusBar() + self.setStatusBar(bar) + + self._sb_status = QLabel("Ready") + bar.addWidget(self._sb_status) + + self._sb_targets = QLabel("Targets: 0") + bar.addPermanentWidget(self._sb_targets) + + self._sb_mode = QLabel("Idle") + self._sb_mode.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;") + bar.addPermanentWidget(self._sb_mode) + + # ===================================================================== + # Device management + # ===================================================================== + + def _refresh_devices(self): + # STM32 + self._stm32_devices = self._stm32.list_devices() + self._stm32_combo.clear() + for d in self._stm32_devices: + self._stm32_combo.addItem(d["description"]) + if self._stm32_devices: + self._stm32_combo.setCurrentIndex(0) + + # FT2232HQ (primary) + self._ft2232hq_devices = self._ft2232hq.list_devices() + self._ft2232hq_combo.clear() + for d in self._ft2232hq_devices: + self._ft2232hq_combo.addItem(d["description"]) + if self._ft2232hq_devices: + self._ft2232hq_combo.setCurrentIndex(0) + + logger.info( + f"Devices refreshed: {len(self._stm32_devices)} STM32, " + f"{len(self._ft2232hq_devices)} FT2232HQ" + ) + + # ===================================================================== + # Start / Stop radar + # ===================================================================== + + def _start_radar(self): + try: + # Open STM32 + idx = self._stm32_combo.currentIndex() + if idx < 0 or idx >= len(self._stm32_devices): + QMessageBox.warning(self, "Warning", "Please select an STM32 USB device.") + return + if not self._stm32.open_device(self._stm32_devices[idx]): + QMessageBox.critical(self, "Error", "Failed to open STM32 USB device.") + return + + # Open FT2232HQ (primary) + idx2 = self._ft2232hq_combo.currentIndex() + if idx2 >= 0 and idx2 < len(self._ft2232hq_devices): + url = self._ft2232hq_devices[idx2]["url"] + if not self._ft2232hq.open_device(url): + QMessageBox.warning(self, "Warning", + "Failed to open FT2232HQ device. Radar data may not be available.") + + # Send start flag + settings + if not self._stm32.send_start_flag(): + QMessageBox.critical(self, "Error", "Failed to send start flag to STM32.") + return + self._apply_settings_to_model() + self._stm32.send_settings(self._settings) + + # Start workers + self._radar_worker = RadarDataWorker( + ft2232hq=self._ft2232hq, + processor=self._processor, + packet_parser=self._radar_parser, + settings=self._settings, + gps_data_ref=self._radar_position, + ) + self._radar_worker.targetsUpdated.connect(self._on_radar_targets) + self._radar_worker.statsUpdated.connect(self._on_radar_stats) + self._radar_worker.errorOccurred.connect(self._on_worker_error) + self._radar_worker.start() + + self._gps_worker = GPSDataWorker( + stm32=self._stm32, + usb_parser=self._usb_parser, + ) + self._gps_worker.gpsReceived.connect(self._on_gps_received) + self._gps_worker.errorOccurred.connect(self._on_worker_error) + self._gps_worker.start() + + # UI state + self._running = True + self._start_time = time.time() + self._start_btn.setEnabled(False) + self._stop_btn.setEnabled(True) + self._status_label_main.setText("Status: Radar running") + self._sb_status.setText("Radar running") + self._sb_mode.setText("Live") + logger.info("Radar system started") + + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to start radar: {e}") + logger.error(f"Start radar error: {e}") + + def _stop_radar(self): + self._running = False + + if self._radar_worker: + self._radar_worker.stop() + self._radar_worker.wait(2000) + self._radar_worker = None + + if self._gps_worker: + self._gps_worker.stop() + self._gps_worker.wait(2000) + self._gps_worker = None + + self._stm32.close() + self._ft2232hq.close() + + self._start_btn.setEnabled(True) + self._stop_btn.setEnabled(False) + self._status_label_main.setText("Status: Radar stopped") + self._sb_status.setText("Radar stopped") + self._sb_mode.setText("Idle") + logger.info("Radar system stopped") + + # ===================================================================== + # Demo mode + # ===================================================================== + + def _start_demo(self): + if self._simulator: + return + self._simulator = TargetSimulator(self._radar_position, self) + self._simulator.targetsUpdated.connect(self._on_demo_targets) + self._simulator.start(500) + self._demo_mode = True + self._sb_mode.setText("Demo Mode") + self._sb_status.setText("Demo mode active") + self._demo_btn_main.setText("Stop Demo") + self._demo_btn_map.setText("Stop Demo") + self._demo_btn_map.setChecked(True) + logger.info("Demo mode started") + + def _stop_demo(self): + if self._simulator: + self._simulator.stop() + self._simulator = None + self._demo_mode = False + self._sb_mode.setText("Idle" if not self._running else "Live") + self._sb_status.setText("Demo stopped") + self._demo_btn_main.setText("Start Demo") + self._demo_btn_map.setText("Start Demo") + self._demo_btn_map.setChecked(False) + logger.info("Demo mode stopped") + + def _toggle_demo_main(self): + if self._demo_mode: + self._stop_demo() + else: + self._start_demo() + + def _toggle_demo_map(self, checked: bool): + if checked: + self._start_demo() + else: + self._stop_demo() + + def _add_demo_target(self): + if self._simulator: + self._simulator.add_random_target() + logger.info("Added random demo target") + + # ===================================================================== + # Slots — data from workers / simulator + # ===================================================================== + + @pyqtSlot(list) + def _on_radar_targets(self, targets: list): + self._current_targets = targets + self._map_widget.set_targets(targets) + + @pyqtSlot(dict) + def _on_radar_stats(self, stats: dict): + self._radar_stats = stats + + @pyqtSlot(str) + def _on_worker_error(self, msg: str): + logger.error(f"Worker error: {msg}") + + @pyqtSlot(object) + def _on_gps_received(self, gps: GPSData): + self._gps_packet_count += 1 + self._radar_position.latitude = gps.latitude + self._radar_position.longitude = gps.longitude + self._radar_position.altitude = gps.altitude + self._radar_position.pitch = gps.pitch + self._radar_position.timestamp = gps.timestamp + + self._map_widget.set_radar_position(self._radar_position) + + if self._simulator: + self._simulator.set_radar_position(self._radar_position) + + @pyqtSlot(list) + def _on_demo_targets(self, targets: list): + self._current_targets = targets + self._map_widget.set_targets(targets) + self._sb_targets.setText(f"Targets: {len(targets)}") + + def _on_target_selected(self, target_id: int): + for t in self._current_targets: + if t.id == target_id: + self._show_target_info(t) + break + + def _show_target_info(self, target: RadarTarget): + status = ("Approaching" if target.velocity > 1 + else ("Receding" if target.velocity < -1 else "Stationary")) + color = (DARK_ERROR if status == "Approaching" + else (DARK_INFO if status == "Receding" else DARK_TEXT)) + info = ( + f"Target #{target.id}

" + f"Track ID: {target.track_id}
" + f"Range: {target.range:.1f} m
" + f"Velocity: {target.velocity:+.1f} m/s
" + f"Azimuth: {target.azimuth:.1f}\u00b0
" + f"Elevation: {target.elevation:.1f}\u00b0
" + f"SNR: {target.snr:.1f} dB
" + f"Class: {target.classification}
" + f'Status: {status}' + ) + self._target_info_label.setText(info) + + # ===================================================================== + # Position / coverage callbacks (map sidebar) + # ===================================================================== + + def _on_position_changed(self): + self._radar_position.latitude = self._lat_spin.value() + self._radar_position.longitude = self._lon_spin.value() + self._radar_position.altitude = self._alt_spin.value() + self._map_widget.set_radar_position(self._radar_position) + if self._simulator: + self._simulator.set_radar_position(self._radar_position) + + def _on_coverage_changed(self, value: float): + radius_m = value * 1000 + self._settings.coverage_radius = radius_m + self._map_widget.set_coverage_radius(radius_m) + + # ===================================================================== + # Settings + # ===================================================================== + + def _apply_settings_to_model(self): + """Read spin values into the RadarSettings model.""" + s = self._settings + sp = self._setting_spins + s.system_frequency = sp["system_frequency"].value() * 1e9 + s.chirp_duration_1 = sp["chirp_duration_1"].value() * 1e-6 + s.chirp_duration_2 = sp["chirp_duration_2"].value() * 1e-6 + s.chirps_per_position = int(sp["chirps_per_position"].value()) + s.freq_min = sp["freq_min"].value() * 1e6 + s.freq_max = sp["freq_max"].value() * 1e6 + s.prf1 = sp["prf1"].value() + s.prf2 = sp["prf2"].value() + s.max_distance = sp["max_distance"].value() * 1000 + s.map_size = sp["map_size"].value() * 1000 + + def _apply_settings(self): + try: + self._apply_settings_to_model() + if self._stm32.is_open: + self._stm32.send_settings(self._settings) + logger.info("Radar settings applied") + QMessageBox.information(self, "Settings", "Radar settings applied.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Invalid setting value: {e}") + logger.error(f"Settings error: {e}") + + def _apply_processing_config(self): + """Read signal processing controls into ProcessingConfig and push to processor.""" + try: + cfg = ProcessingConfig( + mti_enabled=self._mti_check.isChecked(), + mti_order=self._mti_order_spin.value(), + cfar_enabled=self._cfar_check.isChecked(), + cfar_type=self._cfar_type_combo.currentText(), + cfar_guard_cells=self._cfar_guard_spin.value(), + cfar_training_cells=self._cfar_train_spin.value(), + cfar_threshold_factor=self._cfar_thresh_spin.value(), + dc_notch_enabled=self._dc_notch_check.isChecked(), + window_type=self._window_combo.currentText(), + detection_threshold_db=self._det_thresh_spin.value(), + clustering_enabled=self._cluster_check.isChecked(), + clustering_eps=self._cluster_eps_spin.value(), + clustering_min_samples=self._cluster_min_spin.value(), + tracking_enabled=self._tracking_check.isChecked(), + ) + self._processing_config = cfg + self._processor.set_config(cfg) + logger.info( + f"Processing config applied: MTI={cfg.mti_enabled}(order {cfg.mti_order}), " + f"CFAR={cfg.cfar_enabled}({cfg.cfar_type}), DC_Notch={cfg.dc_notch_enabled}, " + f"Window={cfg.window_type}, Threshold={cfg.detection_threshold_db} dB, " + f"Clustering={cfg.clustering_enabled}, Tracking={cfg.tracking_enabled}" + ) + QMessageBox.information(self, "Processing", "Signal processing settings applied.") + except Exception as e: + QMessageBox.critical(self, "Error", f"Failed to apply processing settings: {e}") + logger.error(f"Processing config error: {e}") + + # ===================================================================== + # Periodic GUI refresh (100 ms timer) + # ===================================================================== + + def _refresh_gui(self): + try: + # GPS label + gps = self._radar_position + self._gps_label.setText( + f"GPS: Lat {gps.latitude:.6f}, Lon {gps.longitude:.6f}, " + f"Alt {gps.altitude:.1f}m" + ) + + # Pitch label with colour coding + pitch_text = f"Pitch: {gps.pitch:+.1f}\u00b0" + self._pitch_label.setText(pitch_text) + if abs(gps.pitch) > 10: + self._pitch_label.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;") + elif abs(gps.pitch) > 5: + self._pitch_label.setStyleSheet(f"color: {DARK_WARNING}; font-weight: bold;") + else: + self._pitch_label.setStyleSheet(f"color: {DARK_SUCCESS}; font-weight: bold;") + + # Range-Doppler map + self._rdm_canvas.update_map(self._processor.range_doppler_map) + + # Targets table (main tab) + self._update_main_targets_table() + + # Status label (main tab) + if self._running: + pkt = self._radar_stats.get("packets", 0) + self._status_label_main.setText( + f"Status: Running \u2014 Packets: {pkt} \u2014 Pitch: {gps.pitch:+.1f}\u00b0" + ) + + # Diagnostics values + self._update_diagnostics() + + # Status-bar target count + self._sb_targets.setText(f"Targets: {len(self._current_targets)}") + + except Exception as e: + logger.error(f"GUI refresh error: {e}") + + def _update_main_targets_table(self): + targets = self._current_targets[-20:] # last 20 + self._targets_table_main.setRowCount(len(targets)) + + for row, t in enumerate(targets): + self._targets_table_main.setItem( + row, 0, QTableWidgetItem(str(t.track_id))) + self._targets_table_main.setItem( + row, 1, QTableWidgetItem(f"{t.range:.1f}")) + + vel_item = QTableWidgetItem(f"{t.velocity:+.1f}") + if t.velocity > 1: + vel_item.setForeground(QColor(DARK_ERROR)) + elif t.velocity < -1: + vel_item.setForeground(QColor(DARK_INFO)) + self._targets_table_main.setItem(row, 2, vel_item) + + self._targets_table_main.setItem( + row, 3, QTableWidgetItem(f"{t.azimuth:.1f}")) + + # Raw elevation — show stored value from corrections cache + raw_text = "N/A" + for corr in self._corrected_elevations[-20:]: + if abs(corr["corrected"] - t.elevation) < 0.1: + raw_text = f"{corr['raw']}" + break + self._targets_table_main.setItem( + row, 4, QTableWidgetItem(raw_text)) + self._targets_table_main.setItem( + row, 5, QTableWidgetItem(f"{t.elevation:.1f}")) + self._targets_table_main.setItem( + row, 6, QTableWidgetItem(f"{t.snr:.1f}")) + + def _update_diagnostics(self): + # Connection indicators + self._set_conn_indicator(self._conn_stm32, self._stm32.is_open) + self._set_conn_indicator(self._conn_ft2232hq, self._ft2232hq.is_open) + + stats = self._radar_stats + gps_count = self._gps_packet_count + if self._gps_worker: + gps_count = self._gps_worker.gps_count + + uptime = time.time() - self._start_time + pkt = stats.get("packets", 0) + pkt_rate = pkt / max(uptime, 1) + + vals = [ + str(pkt), + f"{stats.get('bytes', 0):,}", + str(gps_count), + str(stats.get("errors", 0)), + str(stats.get("active_tracks", len(self._processor.tracks))), + str(stats.get("targets", len(self._current_targets))), + f"{uptime:.0f}s", + f"{pkt_rate:.1f}/s", + ] + for lbl, v in zip(self._diag_values, vals): + lbl.setText(v) + + # ===================================================================== + # Helpers + # ===================================================================== + + @staticmethod + def _make_status_label(name: str) -> QLabel: + lbl = QLabel("Disconnected") + lbl.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;") + return lbl + + @staticmethod + def _set_conn_indicator(label: QLabel, connected: bool): + if connected: + label.setText("Connected") + label.setStyleSheet(f"color: {DARK_SUCCESS}; font-weight: bold;") + else: + label.setText("Disconnected") + label.setStyleSheet(f"color: {DARK_ERROR}; font-weight: bold;") + + def _log_append(self, message: str): + """Append a log message to the diagnostics log viewer.""" + self._log_text.appendPlainText(message) + + # ===================================================================== + # Close event + # ===================================================================== + + def closeEvent(self, event): + if self._simulator: + self._simulator.stop() + if self._radar_worker: + self._radar_worker.stop() + self._radar_worker.wait(1000) + if self._gps_worker: + self._gps_worker.stop() + self._gps_worker.wait(1000) + self._stm32.close() + self._ft2232hq.close() + logging.getLogger().removeHandler(self._log_handler) + event.accept() + + +# ============================================================================= +# Qt-compatible log handler (routes Python logging → QTextEdit) +# ============================================================================= + +class _QtLogHandler(logging.Handler): + """Sends log records to a callback (called on the thread that emitted).""" + + def __init__(self, callback): + super().__init__() + self._callback = callback + self.setFormatter(logging.Formatter( + "%(asctime)s %(levelname)-8s %(message)s", + datefmt="%H:%M:%S", + )) + + def emit(self, record): + try: + msg = self.format(record) + self._callback(msg) + except Exception: + pass diff --git a/9_Firmware/9_3_GUI/v7/hardware.py b/9_Firmware/9_3_GUI/v7/hardware.py new file mode 100644 index 0000000..2b4b474 --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/hardware.py @@ -0,0 +1,307 @@ +""" +v7.hardware — Hardware interface classes for the PLFM Radar GUI V7. + +Provides two USB hardware interfaces: + - FT2232HQInterface (PRIMARY — USB 2.0, VID 0x0403 / PID 0x6010) + - STM32USBInterface (USB CDC for commands and GPS) +""" + +import struct +import logging +from typing import List, Dict, Optional + +from .models import ( + USB_AVAILABLE, FTDI_AVAILABLE, + RadarSettings, +) + +if USB_AVAILABLE: + import usb.core + import usb.util + +if FTDI_AVAILABLE: + from pyftdi.ftdi import Ftdi + from pyftdi.usbtools import UsbTools + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# FT2232HQ Interface — PRIMARY data path (USB 2.0) +# ============================================================================= + +class FT2232HQInterface: + """ + Interface for FT2232HQ (USB 2.0 Hi-Speed) in synchronous FIFO mode. + + This is the **primary** radar data interface. + VID/PID: 0x0403 / 0x6010 + """ + + VID = 0x0403 + PID = 0x6010 + + def __init__(self): + self.ftdi: Optional[object] = None + self.is_open: bool = False + + # ---- enumeration ------------------------------------------------------- + + def list_devices(self) -> List[Dict]: + """List available FT2232H devices using pyftdi.""" + if not FTDI_AVAILABLE: + logger.warning("pyftdi not available — cannot enumerate FT2232H devices") + return [] + + try: + devices = [] + for device_desc in UsbTools.find_all([(self.VID, self.PID)]): + devices.append({ + "description": f"FT2232H Device {device_desc}", + "url": f"ftdi://{device_desc}/1", + }) + return devices + except Exception as e: + logger.error(f"Error listing FT2232H devices: {e}") + return [] + + # ---- open / close ------------------------------------------------------ + + def open_device(self, device_url: str) -> bool: + """Open FT2232H device in synchronous FIFO mode.""" + if not FTDI_AVAILABLE: + logger.error("pyftdi not available — cannot open device") + return False + + try: + self.ftdi = Ftdi() + self.ftdi.open_from_url(device_url) + + # Synchronous FIFO mode + self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.SYNCFF) + + # Low-latency timer (2 ms) + self.ftdi.set_latency_timer(2) + + # Purge stale data + self.ftdi.purge_buffers() + + self.is_open = True + logger.info(f"FT2232H device opened: {device_url}") + return True + except Exception as e: + logger.error(f"Error opening FT2232H device: {e}") + self.ftdi = None + return False + + def close(self): + """Close FT2232H device.""" + if self.ftdi and self.is_open: + try: + self.ftdi.close() + except Exception as e: + logger.error(f"Error closing FT2232H device: {e}") + finally: + self.is_open = False + self.ftdi = None + + # ---- data I/O ---------------------------------------------------------- + + def read_data(self, bytes_to_read: int = 4096) -> Optional[bytes]: + """Read data from FT2232H.""" + if not self.is_open or self.ftdi is None: + return None + + try: + data = self.ftdi.read_data(bytes_to_read) + if data: + return bytes(data) + return None + except Exception as e: + logger.error(f"Error reading from FT2232H: {e}") + return None + + +# ============================================================================= +# STM32 USB CDC Interface — commands & GPS data +# ============================================================================= + +class STM32USBInterface: + """ + Interface for STM32 USB CDC (Virtual COM Port). + + Used to: + - Send start flag and radar settings to the MCU + - Receive GPS data from the MCU + """ + + STM32_VID_PIDS = [ + (0x0483, 0x5740), # STM32 Virtual COM Port + (0x0483, 0x3748), # STM32 Discovery + (0x0483, 0x374B), + (0x0483, 0x374D), + (0x0483, 0x374E), + (0x0483, 0x3752), + ] + + def __init__(self): + self.device = None + self.is_open: bool = False + self.ep_in = None + self.ep_out = None + + # ---- enumeration ------------------------------------------------------- + + def list_devices(self) -> List[Dict]: + """List available STM32 USB CDC devices.""" + if not USB_AVAILABLE: + logger.warning("pyusb not available — cannot enumerate STM32 devices") + return [] + + devices = [] + try: + for vid, pid in self.STM32_VID_PIDS: + found = usb.core.find(find_all=True, idVendor=vid, idProduct=pid) + for dev in found: + try: + product = (usb.util.get_string(dev, dev.iProduct) + if dev.iProduct else "STM32 CDC") + serial = (usb.util.get_string(dev, dev.iSerialNumber) + if dev.iSerialNumber else "Unknown") + devices.append({ + "description": f"{product} ({serial})", + "vendor_id": vid, + "product_id": pid, + "device": dev, + }) + except Exception: + devices.append({ + "description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})", + "vendor_id": vid, + "product_id": pid, + "device": dev, + }) + except Exception as e: + logger.error(f"Error listing STM32 devices: {e}") + return devices + + # ---- open / close ------------------------------------------------------ + + def open_device(self, device_info: Dict) -> bool: + """Open STM32 USB CDC device.""" + if not USB_AVAILABLE: + logger.error("pyusb not available — cannot open STM32 device") + return False + + try: + self.device = device_info["device"] + + if self.device.is_kernel_driver_active(0): + self.device.detach_kernel_driver(0) + + self.device.set_configuration() + cfg = self.device.get_active_configuration() + intf = cfg[(0, 0)] + + self.ep_out = usb.util.find_descriptor( + intf, + custom_match=lambda e: ( + usb.util.endpoint_direction(e.bEndpointAddress) + == usb.util.ENDPOINT_OUT + ), + ) + self.ep_in = usb.util.find_descriptor( + intf, + custom_match=lambda e: ( + usb.util.endpoint_direction(e.bEndpointAddress) + == usb.util.ENDPOINT_IN + ), + ) + + if self.ep_out is None or self.ep_in is None: + logger.error("Could not find STM32 CDC endpoints") + return False + + self.is_open = True + logger.info(f"STM32 USB device opened: {device_info.get('description', '')}") + return True + except Exception as e: + logger.error(f"Error opening STM32 device: {e}") + return False + + def close(self): + """Close STM32 USB device.""" + if self.device and self.is_open: + try: + usb.util.dispose_resources(self.device) + except Exception as e: + logger.error(f"Error closing STM32 device: {e}") + self.is_open = False + self.device = None + self.ep_in = None + self.ep_out = None + + # ---- commands ---------------------------------------------------------- + + def send_start_flag(self) -> bool: + """Send start flag to STM32 (4-byte magic).""" + start_packet = bytes([23, 46, 158, 237]) + logger.info("Sending start flag to STM32 via USB...") + return self._send_data(start_packet) + + def send_settings(self, settings: RadarSettings) -> bool: + """Send radar settings binary packet to STM32.""" + try: + packet = self._create_settings_packet(settings) + logger.info("Sending radar settings to STM32 via USB...") + return self._send_data(packet) + except Exception as e: + logger.error(f"Error sending settings via USB: {e}") + return False + + # ---- data I/O ---------------------------------------------------------- + + def read_data(self, size: int = 64, timeout: int = 1000) -> Optional[bytes]: + """Read data from STM32 via USB CDC.""" + if not self.is_open or self.ep_in is None: + return None + try: + data = self.ep_in.read(size, timeout=timeout) + return bytes(data) + except Exception: + # Timeout or other USB error + return None + + # ---- internal helpers -------------------------------------------------- + + def _send_data(self, data: bytes) -> bool: + if not self.is_open or self.ep_out is None: + return False + try: + packet_size = 64 + for i in range(0, len(data), packet_size): + chunk = data[i : i + packet_size] + if len(chunk) < packet_size: + chunk += b"\x00" * (packet_size - len(chunk)) + self.ep_out.write(chunk) + return True + except Exception as e: + logger.error(f"Error sending data via USB: {e}") + return False + + @staticmethod + def _create_settings_packet(settings: RadarSettings) -> bytes: + """Create binary settings packet: 'SET' ... 'END'.""" + packet = b"SET" + packet += struct.pack(">d", settings.system_frequency) + packet += struct.pack(">d", settings.chirp_duration_1) + packet += struct.pack(">d", settings.chirp_duration_2) + packet += struct.pack(">I", settings.chirps_per_position) + packet += struct.pack(">d", settings.freq_min) + packet += struct.pack(">d", settings.freq_max) + packet += struct.pack(">d", settings.prf1) + packet += struct.pack(">d", settings.prf2) + packet += struct.pack(">d", settings.max_distance) + packet += struct.pack(">d", settings.map_size) + packet += b"END" + return packet diff --git a/9_Firmware/9_3_GUI/v7/map_widget.py b/9_Firmware/9_3_GUI/v7/map_widget.py new file mode 100644 index 0000000..7a26e0f --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/map_widget.py @@ -0,0 +1,532 @@ +""" +v7.map_widget — Embedded Leaflet.js map widget for the PLFM Radar GUI V7. + +Classes: + - MapBridge — QObject exposed to JavaScript via QWebChannel + - RadarMapWidget — QWidget wrapping QWebEngineView with Leaflet map + +The full HTML/CSS/JS for Leaflet is generated inline (no external files). +Supports: OSM, Google, Google Sat, Google Hybrid, ESRI Sat tile servers; +coverage circle, target trails, velocity-based color coding, popups, legend. +""" + +import json +import logging +from typing import List + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QFrame, + QComboBox, QCheckBox, QPushButton, QLabel, +) +from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot, QObject +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtWebChannel import QWebChannel + +from .models import ( + GPSData, RadarTarget, TileServer, + DARK_BG, DARK_FG, DARK_ACCENT, DARK_BORDER, + DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, + DARK_SUCCESS, DARK_INFO, +) + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# MapBridge — Python <-> JavaScript +# ============================================================================= + +class MapBridge(QObject): + """Bridge object registered with QWebChannel for JS ↔ Python calls.""" + + mapClicked = pyqtSignal(float, float) # lat, lon + markerClicked = pyqtSignal(int) # target_id + mapReady = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._map_ready = False + + @pyqtSlot(float, float) + def onMapClick(self, lat: float, lon: float): + logger.debug(f"Map clicked: {lat}, {lon}") + self.mapClicked.emit(lat, lon) + + @pyqtSlot(int) + def onMarkerClick(self, target_id: int): + logger.debug(f"Marker clicked: #{target_id}") + self.markerClicked.emit(target_id) + + @pyqtSlot() + def onMapReady(self): + logger.info("Leaflet map ready") + self._map_ready = True + self.mapReady.emit() + + @pyqtSlot(str) + def logFromJS(self, message: str): + logger.debug(f"[JS] {message}") + + @property + def is_ready(self) -> bool: + return self._map_ready + + +# ============================================================================= +# RadarMapWidget +# ============================================================================= + +class RadarMapWidget(QWidget): + """ + Embeds a Leaflet.js interactive map inside a QWebEngineView. + + Public methods mirror the V6 map API: + set_radar_position(gps), set_targets(list), set_coverage_radius(r), + set_zoom(level) + """ + + targetSelected = pyqtSignal(int) + + def __init__(self, radar_lat: float = 41.9028, radar_lon: float = 12.4964, + parent=None): + super().__init__(parent) + + # State + self._radar_position = GPSData( + latitude=radar_lat, longitude=radar_lon, + altitude=0.0, pitch=0.0, heading=0.0, + ) + self._targets: List[RadarTarget] = [] + self._coverage_radius = 50_000 # metres + self._tile_server = TileServer.OPENSTREETMAP + self._show_coverage = True + self._show_trails = False + + # Build UI + self._setup_ui() + + # Bridge + channel + self._bridge = MapBridge(self) + self._bridge.mapReady.connect(self._on_map_ready) + self._bridge.markerClicked.connect(self._on_marker_clicked) + + self._channel = QWebChannel() + self._channel.registerObject("bridge", self._bridge) + self._web_view.page().setWebChannel(self._channel) + + # Load the Leaflet map + self._load_map() + + # ---- UI setup ---------------------------------------------------------- + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Control bar + bar = QFrame() + bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;") + bar_layout = QHBoxLayout(bar) + bar_layout.setContentsMargins(8, 4, 8, 4) + + # Tile selector + self._tile_combo = QComboBox() + self._tile_combo.addItem("OpenStreetMap", TileServer.OPENSTREETMAP) + self._tile_combo.addItem("Google Maps", TileServer.GOOGLE_MAPS) + self._tile_combo.addItem("Google Satellite", TileServer.GOOGLE_SATELLITE) + self._tile_combo.addItem("Google Hybrid", TileServer.GOOGLE_HYBRID) + self._tile_combo.addItem("ESRI Satellite", TileServer.ESRI_SATELLITE) + self._tile_combo.currentIndexChanged.connect(self._on_tile_changed) + self._tile_combo.setStyleSheet(f""" + QComboBox {{ + background-color: {DARK_BUTTON}; color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; padding: 4px 8px; border-radius: 4px; + }} + """) + bar_layout.addWidget(QLabel("Tiles:")) + bar_layout.addWidget(self._tile_combo) + + # Toggles + self._coverage_check = QCheckBox("Coverage") + self._coverage_check.setChecked(True) + self._coverage_check.stateChanged.connect(self._on_coverage_toggled) + bar_layout.addWidget(self._coverage_check) + + self._trails_check = QCheckBox("Trails") + self._trails_check.setChecked(False) + self._trails_check.stateChanged.connect(self._on_trails_toggled) + bar_layout.addWidget(self._trails_check) + + btn_style = f""" + QPushButton {{ + background-color: {DARK_BUTTON}; color: {DARK_FG}; + border: 1px solid {DARK_BORDER}; padding: 4px 12px; border-radius: 4px; + }} + QPushButton:hover {{ background-color: {DARK_BUTTON_HOVER}; }} + """ + + center_btn = QPushButton("Center") + center_btn.clicked.connect(self._center_on_radar) + center_btn.setStyleSheet(btn_style) + bar_layout.addWidget(center_btn) + + fit_btn = QPushButton("Fit All") + fit_btn.clicked.connect(self._fit_all) + fit_btn.setStyleSheet(btn_style) + bar_layout.addWidget(fit_btn) + + bar_layout.addStretch() + + self._status_label = QLabel("Loading map...") + self._status_label.setStyleSheet(f"color: {DARK_INFO};") + bar_layout.addWidget(self._status_label) + + layout.addWidget(bar) + + # Web view + self._web_view = QWebEngineView() + self._web_view.setMinimumSize(400, 300) + layout.addWidget(self._web_view, stretch=1) + + # ---- HTML generation --------------------------------------------------- + + def _get_map_html(self) -> str: + lat = self._radar_position.latitude + lon = self._radar_position.longitude + cov = self._coverage_radius + + # Using {{ / }} for literal braces inside the f-string + return f''' + + + + +Radar Map + + + + + + +
+ + +''' + + # ---- load / helpers ---------------------------------------------------- + + def _load_map(self): + self._web_view.setHtml(self._get_map_html()) + logger.info("Leaflet map HTML loaded") + + def _on_map_ready(self): + self._status_label.setText(f"Map ready - {len(self._targets)} targets") + self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};") + + def _on_marker_clicked(self, tid: int): + self.targetSelected.emit(tid) + + def _run_js(self, script: str): + self._web_view.page().runJavaScript(script) + + # ---- control bar callbacks --------------------------------------------- + + def _on_tile_changed(self, _index: int): + server = self._tile_combo.currentData() + if server: + self._tile_server = server + self._run_js(f"setTileServer('{server.value}')") + + def _on_coverage_toggled(self, state: int): + vis = state == Qt.CheckState.Checked.value + self._show_coverage = vis + self._run_js(f"setCoverageVisible({str(vis).lower()})") + + def _on_trails_toggled(self, state: int): + vis = state == Qt.CheckState.Checked.value + self._show_trails = vis + self._run_js(f"setTrailsVisible({str(vis).lower()})") + + def _center_on_radar(self): + self._run_js("centerOnRadar()") + + def _fit_all(self): + self._run_js("fitAllTargets()") + + # ---- public API -------------------------------------------------------- + + def set_radar_position(self, gps: GPSData): + self._radar_position = gps + self._run_js( + f"updateRadarPosition({gps.latitude},{gps.longitude}," + f"{gps.altitude},{gps.pitch},{gps.heading})" + ) + + def set_targets(self, targets: List[RadarTarget]): + self._targets = targets + data = [t.to_dict() for t in targets] + js = json.dumps(data).replace("'", "\\'") + self._status_label.setText(f"{len(targets)} targets tracked") + self._run_js(f"updateTargets('{js}')") + + def set_coverage_radius(self, radius_m: float): + self._coverage_radius = radius_m + self._run_js(f"setCoverageRadius({radius_m})") + + def set_zoom(self, level: int): + level = max(0, min(22, level)) + self._run_js(f"setZoom({level})") diff --git a/9_Firmware/9_3_GUI/v7/models.py b/9_Firmware/9_3_GUI/v7/models.py new file mode 100644 index 0000000..45da35c --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/models.py @@ -0,0 +1,187 @@ +""" +v7.models — Data classes, enums, and theme constants for the PLFM Radar GUI V7. + +This module defines the core data structures used throughout the application: + - RadarTarget, RadarSettings, GPSData (dataclasses) + - TileServer (enum for map tile providers) + - Dark theme color constants + - Optional dependency availability flags +""" + +import logging +from dataclasses import dataclass, asdict +from enum import Enum + + +# --------------------------------------------------------------------------- +# Optional dependency flags (graceful degradation) +# --------------------------------------------------------------------------- +try: + import usb.core + import usb.util # noqa: F401 — availability check + USB_AVAILABLE = True +except ImportError: + USB_AVAILABLE = False + logging.warning("pyusb not available. USB functionality will be disabled.") + +try: + from pyftdi.ftdi import Ftdi # noqa: F401 — availability check + from pyftdi.usbtools import UsbTools # noqa: F401 — availability check + from pyftdi.ftdi import FtdiError # noqa: F401 — availability check + FTDI_AVAILABLE = True +except ImportError: + FTDI_AVAILABLE = False + logging.warning("pyftdi not available. FTDI functionality will be disabled.") + +try: + from scipy import signal as _scipy_signal # noqa: F401 — availability check + SCIPY_AVAILABLE = True +except ImportError: + SCIPY_AVAILABLE = False + logging.warning("scipy not available. Some DSP features will be disabled.") + +try: + from sklearn.cluster import DBSCAN as _DBSCAN # noqa: F401 — availability check + SKLEARN_AVAILABLE = True +except ImportError: + SKLEARN_AVAILABLE = False + logging.warning("sklearn not available. Clustering will be disabled.") + +try: + from filterpy.kalman import KalmanFilter as _KalmanFilter # noqa: F401 — availability check + FILTERPY_AVAILABLE = True +except ImportError: + FILTERPY_AVAILABLE = False + logging.warning("filterpy not available. Kalman tracking will be disabled.") + +try: + import crcmod as _crcmod # noqa: F401 — availability check + CRCMOD_AVAILABLE = True +except ImportError: + CRCMOD_AVAILABLE = False + logging.warning("crcmod not available. CRC validation will use fallback.") + +# --------------------------------------------------------------------------- +# Dark theme color constants (shared by all modules) +# --------------------------------------------------------------------------- +DARK_BG = "#2b2b2b" +DARK_FG = "#e0e0e0" +DARK_ACCENT = "#3c3f41" +DARK_HIGHLIGHT = "#4e5254" +DARK_BORDER = "#555555" +DARK_TEXT = "#cccccc" +DARK_BUTTON = "#3c3f41" +DARK_BUTTON_HOVER = "#4e5254" +DARK_TREEVIEW = "#3c3f41" +DARK_TREEVIEW_ALT = "#404040" +DARK_SUCCESS = "#4CAF50" +DARK_WARNING = "#FFC107" +DARK_ERROR = "#F44336" +DARK_INFO = "#2196F3" + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class RadarTarget: + """Represents a detected radar target.""" + id: int + range: float # Range in meters + velocity: float # Velocity in m/s (positive = approaching) + azimuth: float # Azimuth angle in degrees + elevation: float # Elevation angle in degrees + latitude: float = 0.0 + longitude: float = 0.0 + snr: float = 0.0 # Signal-to-noise ratio in dB + timestamp: float = 0.0 + track_id: int = -1 + classification: str = "unknown" + + def to_dict(self) -> dict: + """Convert to dictionary for JSON serialization.""" + return asdict(self) + + +@dataclass +class RadarSettings: + """Radar system configuration parameters.""" + system_frequency: float = 10e9 # Hz + chirp_duration_1: float = 30e-6 # Long chirp duration (s) + chirp_duration_2: float = 0.5e-6 # Short chirp duration (s) + chirps_per_position: int = 32 + freq_min: float = 10e6 # Hz + freq_max: float = 30e6 # Hz + prf1: float = 1000 # PRF 1 (Hz) + prf2: float = 2000 # PRF 2 (Hz) + max_distance: float = 50000 # Max detection range (m) + map_size: float = 50000 # Map display size (m) + coverage_radius: float = 50000 # Map coverage radius (m) + + +@dataclass +class GPSData: + """GPS position and orientation data.""" + latitude: float + longitude: float + altitude: float + pitch: float # Pitch angle in degrees + heading: float = 0.0 # Heading in degrees (0 = North) + timestamp: float = 0.0 + + def to_dict(self) -> dict: + return asdict(self) + + +# --------------------------------------------------------------------------- +# Tile server enum +# --------------------------------------------------------------------------- + +@dataclass +class ProcessingConfig: + """Signal processing pipeline configuration. + + Controls: MTI filter, CFAR detector, DC notch removal, + windowing, detection threshold, DBSCAN clustering, and Kalman tracking. + """ + + # MTI (Moving Target Indication) + mti_enabled: bool = False + mti_order: int = 2 # 1, 2, or 3 + + # CFAR (Constant False Alarm Rate) + cfar_enabled: bool = False + cfar_type: str = "CA-CFAR" # CA-CFAR, OS-CFAR, GO-CFAR, SO-CFAR + cfar_guard_cells: int = 2 + cfar_training_cells: int = 8 + cfar_threshold_factor: float = 5.0 # PFA-related scalar + + # DC Notch / DC Removal + dc_notch_enabled: bool = False + + # Windowing (applied before FFT) + window_type: str = "Hann" # None, Hann, Hamming, Blackman, Kaiser, Chebyshev + + # Detection threshold (dB above noise floor) + detection_threshold_db: float = 12.0 + + # DBSCAN Clustering + clustering_enabled: bool = True + clustering_eps: float = 100.0 + clustering_min_samples: int = 2 + + # Kalman Tracking + tracking_enabled: bool = True + + +# --------------------------------------------------------------------------- +# Tile server enum +# --------------------------------------------------------------------------- + +class TileServer(Enum): + """Available map tile servers.""" + OPENSTREETMAP = "osm" + GOOGLE_MAPS = "google" + GOOGLE_SATELLITE = "google_sat" + GOOGLE_HYBRID = "google_hybrid" + ESRI_SATELLITE = "esri_sat" diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py new file mode 100644 index 0000000..e417479 --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/processing.py @@ -0,0 +1,642 @@ +""" +v7.processing — Radar signal processing, packet parsing, and GPS parsing. + +Classes: + - RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering, + association, Kalman tracking + - RadarPacketParser — parse raw byte streams into typed radar packets + (FIX: returns (parsed_dict, bytes_consumed) tuple) + - USBPacketParser — parse GPS text/binary frames from STM32 CDC + +Bug fixes vs V6: + 1. RadarPacketParser.parse_packet() now returns (dict, bytes_consumed) tuple + so the caller knows exactly how many bytes to strip from the buffer. + 2. apply_pitch_correction() is a proper standalone function. +""" + +import struct +import time +import logging +import math +from typing import Optional, Tuple, List, Dict + +import numpy as np + +from .models import ( + RadarTarget, GPSData, ProcessingConfig, + SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE, +) + +if SKLEARN_AVAILABLE: + from sklearn.cluster import DBSCAN + +if FILTERPY_AVAILABLE: + from filterpy.kalman import KalmanFilter + +if CRCMOD_AVAILABLE: + import crcmod + +if SCIPY_AVAILABLE: + from scipy.signal import windows as scipy_windows + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Utility: pitch correction (Bug #4 fix — was never defined in V6) +# ============================================================================= + +def apply_pitch_correction(raw_elevation: float, pitch: float) -> float: + """ + Apply platform pitch correction to a raw elevation angle. + + Returns the corrected elevation = raw_elevation - pitch. + """ + return raw_elevation - pitch + + +# ============================================================================= +# Radar Processor — signal-level processing & tracking pipeline +# ============================================================================= + +class RadarProcessor: + """Full radar processing pipeline: fusion, clustering, association, tracking.""" + + def __init__(self): + self.range_doppler_map = np.zeros((1024, 32)) + self.detected_targets: List[RadarTarget] = [] + self.track_id_counter: int = 0 + self.tracks: Dict[int, dict] = {} + self.frame_count: int = 0 + self.config = ProcessingConfig() + + # MTI state: store previous frames for cancellation + self._mti_history: List[np.ndarray] = [] + + # ---- Configuration ----------------------------------------------------- + + def set_config(self, config: ProcessingConfig): + """Update the processing configuration and reset MTI history if needed.""" + old_order = self.config.mti_order + self.config = config + if config.mti_order != old_order: + self._mti_history.clear() + + # ---- Windowing ---------------------------------------------------------- + + @staticmethod + def apply_window(data: np.ndarray, window_type: str) -> np.ndarray: + """Apply a window function along each column (slow-time dimension). + + *data* shape: (range_bins, doppler_bins). Window is applied along + axis-1 (Doppler / slow-time). + """ + if window_type == "None" or not window_type: + return data + + n = data.shape[1] + if n < 2: + return data + + if SCIPY_AVAILABLE: + wtype = window_type.lower() + if wtype == "hann": + w = scipy_windows.hann(n, sym=False) + elif wtype == "hamming": + w = scipy_windows.hamming(n, sym=False) + elif wtype == "blackman": + w = scipy_windows.blackman(n) + elif wtype == "kaiser": + w = scipy_windows.kaiser(n, beta=14) + elif wtype == "chebyshev": + w = scipy_windows.chebwin(n, at=80) + else: + w = np.ones(n) + else: + # Fallback: numpy Hann + wtype = window_type.lower() + if wtype == "hann": + w = np.hanning(n) + elif wtype == "hamming": + w = np.hamming(n) + elif wtype == "blackman": + w = np.blackman(n) + else: + w = np.ones(n) + + return data * w[np.newaxis, :] + + # ---- DC Notch (zero-Doppler removal) ------------------------------------ + + @staticmethod + def dc_notch(data: np.ndarray) -> np.ndarray: + """Remove the DC (zero-Doppler) component by subtracting the + mean along the slow-time axis for each range bin.""" + return data - np.mean(data, axis=1, keepdims=True) + + # ---- MTI (Moving Target Indication) ------------------------------------- + + def mti_filter(self, frame: np.ndarray) -> np.ndarray: + """Apply MTI cancellation of order 1, 2, or 3. + + Order-1: y[n] = x[n] - x[n-1] + Order-2: y[n] = x[n] - 2*x[n-1] + x[n-2] + Order-3: y[n] = x[n] - 3*x[n-1] + 3*x[n-2] - x[n-3] + + The internal history buffer stores up to 3 previous frames. + """ + order = self.config.mti_order + self._mti_history.append(frame.copy()) + + # Trim history to order + 1 frames + max_len = order + 1 + if len(self._mti_history) > max_len: + self._mti_history = self._mti_history[-max_len:] + + if len(self._mti_history) < order + 1: + # Not enough history yet — return zeros (suppress output) + return np.zeros_like(frame) + + h = self._mti_history + if order == 1: + return h[-1] - h[-2] + elif order == 2: + return h[-1] - 2.0 * h[-2] + h[-3] + elif order == 3: + return h[-1] - 3.0 * h[-2] + 3.0 * h[-3] - h[-4] + else: + return h[-1] - h[-2] + + # ---- CFAR (Constant False Alarm Rate) ----------------------------------- + + @staticmethod + def cfar_1d(signal_vec: np.ndarray, guard: int, train: int, + threshold_factor: float, cfar_type: str = "CA-CFAR") -> np.ndarray: + """1-D CFAR detector. + + Parameters + ---------- + signal_vec : 1-D array (power in linear scale) + guard : number of guard cells on each side + train : number of training cells on each side + threshold_factor : multiplier on estimated noise level + cfar_type : CA-CFAR, OS-CFAR, GO-CFAR, or SO-CFAR + + Returns + ------- + detections : boolean array, True where target detected + """ + n = len(signal_vec) + detections = np.zeros(n, dtype=bool) + half = guard + train + + for i in range(half, n - half): + # Leading training cells + lead = signal_vec[i - half: i - guard] + # Lagging training cells + lag = signal_vec[i + guard + 1: i + half + 1] + + if cfar_type == "CA-CFAR": + noise = (np.sum(lead) + np.sum(lag)) / (2 * train) + elif cfar_type == "GO-CFAR": + noise = max(np.mean(lead), np.mean(lag)) + elif cfar_type == "SO-CFAR": + noise = min(np.mean(lead), np.mean(lag)) + elif cfar_type == "OS-CFAR": + all_train = np.concatenate([lead, lag]) + all_train.sort() + k = int(0.75 * len(all_train)) # 75th percentile + noise = all_train[min(k, len(all_train) - 1)] + else: + noise = (np.sum(lead) + np.sum(lag)) / (2 * train) + + threshold = noise * threshold_factor + if signal_vec[i] > threshold: + detections[i] = True + + return detections + + def cfar_2d(self, rdm: np.ndarray) -> np.ndarray: + """Apply 1-D CFAR along each range bin (across Doppler dimension). + + Returns a boolean mask of the same shape as *rdm*. + """ + cfg = self.config + mask = np.zeros_like(rdm, dtype=bool) + for r in range(rdm.shape[0]): + row = rdm[r, :] + if row.max() > 0: + mask[r, :] = self.cfar_1d( + row, cfg.cfar_guard_cells, cfg.cfar_training_cells, + cfg.cfar_threshold_factor, cfg.cfar_type, + ) + return mask + + # ---- Full processing pipeline ------------------------------------------- + + def process_frame(self, raw_frame: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Run the full signal processing chain on a Range x Doppler frame. + + Parameters + ---------- + raw_frame : 2-D array (range_bins x doppler_bins), complex or real + + Returns + ------- + (processed_rdm, detection_mask) + processed_rdm — processed Range-Doppler map (power, linear) + detection_mask — boolean mask of CFAR / threshold detections + """ + cfg = self.config + data = raw_frame.astype(np.float64) + + # 1. DC Notch + if cfg.dc_notch_enabled: + data = self.dc_notch(data) + + # 2. Windowing (before FFT — applied along slow-time axis) + if cfg.window_type and cfg.window_type != "None": + data = self.apply_window(data, cfg.window_type) + + # 3. MTI + if cfg.mti_enabled: + data = self.mti_filter(data) + + # 4. Power (magnitude squared) + power = np.abs(data) ** 2 + power = np.maximum(power, 1e-20) # avoid log(0) + + # 5. CFAR detection or simple threshold + if cfg.cfar_enabled: + detection_mask = self.cfar_2d(power) + else: + # Simple threshold: convert dB threshold to linear + power_db = 10.0 * np.log10(power) + noise_floor = np.median(power_db) + detection_mask = power_db > (noise_floor + cfg.detection_threshold_db) + + # Update stored RDM + self.range_doppler_map = power + self.frame_count += 1 + + return power, detection_mask + + # ---- Dual-CPI fusion --------------------------------------------------- + + @staticmethod + def dual_cpi_fusion(range_profiles_1: np.ndarray, + range_profiles_2: np.ndarray) -> np.ndarray: + """Dual-CPI fusion for better detection.""" + return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0) + + # ---- Multi-PRF velocity unwrapping ------------------------------------- + + def multi_prf_unwrap(self, doppler_measurements, prf1: float, prf2: float): + """Multi-PRF velocity unwrapping (Chinese Remainder Theorem).""" + lam = 3e8 / 10e9 + v_max1 = prf1 * lam / 2 + v_max2 = prf2 * lam / 2 + + unwrapped = [] + for doppler in doppler_measurements: + v1 = doppler * lam / 2 + v2 = doppler * lam / 2 + velocity = self._solve_chinese_remainder(v1, v2, v_max1, v_max2) + unwrapped.append(velocity) + return unwrapped + + @staticmethod + def _solve_chinese_remainder(v1, v2, max1, max2): + for k in range(-5, 6): + candidate = v1 + k * max1 + if abs(candidate - v2) < max2 / 2: + return candidate + return v1 + + # ---- DBSCAN clustering ------------------------------------------------- + + @staticmethod + def clustering(detections: List[RadarTarget], + eps: float = 100, min_samples: int = 2) -> list: + """DBSCAN clustering of detections (requires sklearn).""" + if not SKLEARN_AVAILABLE or len(detections) == 0: + return [] + + points = np.array([[d.range, d.velocity] for d in detections]) + labels = DBSCAN(eps=eps, min_samples=min_samples).fit(points).labels_ + + clusters = [] + for label in set(labels): + if label == -1: + continue + cluster_points = points[labels == label] + clusters.append({ + "center": np.mean(cluster_points, axis=0), + "points": cluster_points, + "size": len(cluster_points), + }) + return clusters + + # ---- Association ------------------------------------------------------- + + def association(self, detections: List[RadarTarget], + clusters: list) -> List[RadarTarget]: + """Associate detections to existing tracks (nearest-neighbour).""" + associated = [] + for det in detections: + best_track = None + min_dist = float("inf") + for tid, track in self.tracks.items(): + dist = math.sqrt( + (det.range - track["state"][0]) ** 2 + + (det.velocity - track["state"][2]) ** 2 + ) + if dist < min_dist and dist < 500: + min_dist = dist + best_track = tid + + if best_track is not None: + det.track_id = best_track + else: + det.track_id = self.track_id_counter + self.track_id_counter += 1 + + associated.append(det) + return associated + + # ---- Kalman tracking --------------------------------------------------- + + def tracking(self, associated_detections: List[RadarTarget]): + """Kalman filter tracking (requires filterpy).""" + if not FILTERPY_AVAILABLE: + return + + now = time.time() + + for det in associated_detections: + if det.track_id not in self.tracks: + kf = KalmanFilter(dim_x=4, dim_z=2) + kf.x = np.array([det.range, 0, det.velocity, 0]) + kf.F = np.array([ + [1, 1, 0, 0], + [0, 1, 0, 0], + [0, 0, 1, 1], + [0, 0, 0, 1], + ]) + kf.H = np.array([ + [1, 0, 0, 0], + [0, 0, 1, 0], + ]) + kf.P *= 1000 + kf.R = np.diag([10, 1]) + kf.Q = np.eye(4) * 0.1 + + self.tracks[det.track_id] = { + "filter": kf, + "state": kf.x, + "last_update": now, + "hits": 1, + } + else: + track = self.tracks[det.track_id] + track["filter"].predict() + track["filter"].update([det.range, det.velocity]) + track["state"] = track["filter"].x + track["last_update"] = now + track["hits"] += 1 + + # Prune stale tracks (> 5 s without update) + stale = [tid for tid, t in self.tracks.items() + if now - t["last_update"] > 5.0] + for tid in stale: + del self.tracks[tid] + + +# ============================================================================= +# Radar Packet Parser +# ============================================================================= + +class RadarPacketParser: + """ + Parse binary radar packets from the raw byte stream. + + Packet format: + [Sync 2][Type 1][Length 1][Payload N][CRC16 2] + Sync pattern: 0xA5 0xC3 + + Bug fix vs V6: + parse_packet() now returns ``(parsed_dict, bytes_consumed)`` so the + caller can correctly advance the read pointer in the buffer. + """ + + SYNC = b"\xA5\xC3" + + def __init__(self): + if CRCMOD_AVAILABLE: + self.crc16_func = crcmod.mkCrcFun( + 0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000 + ) + else: + self.crc16_func = None + + # ---- main entry point -------------------------------------------------- + + def parse_packet(self, data: bytes) -> Optional[Tuple[dict, int]]: + """ + Attempt to parse one radar packet from *data*. + + Returns + ------- + (parsed_dict, bytes_consumed) on success, or None if no valid packet. + """ + if len(data) < 6: + return None + + idx = data.find(self.SYNC) + if idx == -1: + return None + + pkt = data[idx:] + if len(pkt) < 6: + return None + + pkt_type = pkt[2] + length = pkt[3] + total_len = 4 + length + 2 # sync(2) + type(1) + len(1) + payload + crc(2) + + if len(pkt) < total_len: + return None + + payload = pkt[4 : 4 + length] + crc_received = struct.unpack(" Optional[dict]: + if len(payload) < 12: + return None + try: + range_val = struct.unpack(">I", payload[0:4])[0] + elevation = payload[4] & 0x1F + azimuth = payload[5] & 0x3F + chirp = payload[6] & 0x1F + return { + "type": "range", + "range": range_val, + "elevation": elevation, + "azimuth": azimuth, + "chirp": chirp, + "timestamp": time.time(), + } + except Exception as e: + logger.error(f"Error parsing range packet: {e}") + return None + + @staticmethod + def _parse_doppler(payload: bytes) -> Optional[dict]: + if len(payload) < 12: + return None + try: + real = struct.unpack(">h", payload[0:2])[0] + imag = struct.unpack(">h", payload[2:4])[0] + elevation = payload[4] & 0x1F + azimuth = payload[5] & 0x3F + chirp = payload[6] & 0x1F + return { + "type": "doppler", + "doppler_real": real, + "doppler_imag": imag, + "elevation": elevation, + "azimuth": azimuth, + "chirp": chirp, + "timestamp": time.time(), + } + except Exception as e: + logger.error(f"Error parsing doppler packet: {e}") + return None + + @staticmethod + def _parse_detection(payload: bytes) -> Optional[dict]: + if len(payload) < 8: + return None + try: + detected = (payload[0] & 0x01) != 0 + elevation = payload[1] & 0x1F + azimuth = payload[2] & 0x3F + chirp = payload[3] & 0x1F + return { + "type": "detection", + "detected": detected, + "elevation": elevation, + "azimuth": azimuth, + "chirp": chirp, + "timestamp": time.time(), + } + except Exception as e: + logger.error(f"Error parsing detection packet: {e}") + return None + + +# ============================================================================= +# USB / GPS Packet Parser +# ============================================================================= + +class USBPacketParser: + """ + Parse GPS (and general) data arriving from the STM32 via USB CDC. + + Supports: + - Text format: ``GPS:lat,lon,alt,pitch\\r\\n`` + - Binary format: ``GPSB`` header, 30 bytes total + """ + + def __init__(self): + if CRCMOD_AVAILABLE: + self.crc16_func = crcmod.mkCrcFun( + 0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000 + ) + else: + self.crc16_func = None + + def parse_gps_data(self, data: bytes) -> Optional[GPSData]: + """Attempt to parse GPS data from a raw USB CDC frame.""" + if not data: + return None + + try: + # Text format: "GPS:lat,lon,alt,pitch\r\n" + text = data.decode("utf-8", errors="ignore").strip() + if text.startswith("GPS:"): + parts = text.split(":")[1].split(",") + if len(parts) >= 4: + return GPSData( + latitude=float(parts[0]), + longitude=float(parts[1]), + altitude=float(parts[2]), + pitch=float(parts[3]), + timestamp=time.time(), + ) + + # Binary format: [GPSB 4][lat 8][lon 8][alt 4][pitch 4][CRC 2] = 30 bytes + if len(data) >= 30 and data[0:4] == b"GPSB": + return self._parse_binary_gps(data) + except Exception as e: + logger.error(f"Error parsing GPS data: {e}") + return None + + @staticmethod + def _parse_binary_gps(data: bytes) -> Optional[GPSData]: + """Parse 30-byte binary GPS frame.""" + try: + if len(data) < 30: + return None + + # Simple checksum CRC + crc_rcv = (data[28] << 8) | data[29] + crc_calc = sum(data[0:28]) & 0xFFFF + if crc_rcv != crc_calc: + logger.warning("GPS binary CRC mismatch") + return None + + lat = struct.unpack(">d", data[4:12])[0] + lon = struct.unpack(">d", data[12:20])[0] + alt = struct.unpack(">f", data[20:24])[0] + pitch = struct.unpack(">f", data[24:28])[0] + + return GPSData( + latitude=lat, + longitude=lon, + altitude=alt, + pitch=pitch, + timestamp=time.time(), + ) + except Exception as e: + logger.error(f"Error parsing binary GPS: {e}") + return None diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py new file mode 100644 index 0000000..e81616e --- /dev/null +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -0,0 +1,389 @@ +""" +v7.workers — QThread-based workers and demo target simulator. + +Classes: + - RadarDataWorker — reads from FT2232HQ, parses packets, + emits signals with processed data. + - GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals. + - TargetSimulator — QTimer-based demo target generator (from GUI_PyQt_Map.py). +""" + +import math +import time +import random +import logging +from typing import List + +from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal + +from .models import RadarTarget, RadarSettings, GPSData +from .hardware import FT2232HQInterface, STM32USBInterface +from .processing import ( + RadarProcessor, RadarPacketParser, USBPacketParser, + apply_pitch_correction, +) + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# Utility: polar → geographic +# ============================================================================= + +def polar_to_geographic( + radar_lat: float, + radar_lon: float, + range_m: float, + azimuth_deg: float, +) -> tuple: + """ + Convert polar coordinates (range, azimuth) relative to radar + to geographic (latitude, longitude). + + azimuth_deg: 0 = North, clockwise. + Returns (lat, lon). + """ + R = 6_371_000 # Earth radius in meters + + lat1 = math.radians(radar_lat) + lon1 = math.radians(radar_lon) + bearing = math.radians(azimuth_deg) + + lat2 = math.asin( + math.sin(lat1) * math.cos(range_m / R) + + math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing) + ) + lon2 = lon1 + math.atan2( + math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1), + math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2), + ) + return (math.degrees(lat2), math.degrees(lon2)) + + +# ============================================================================= +# Radar Data Worker (QThread) +# ============================================================================= + +class RadarDataWorker(QThread): + """ + Background worker that continuously reads radar data from the primary + FT2232HQ interface, parses packets, runs the processing pipeline, and + emits signals with results. + + Signals: + packetReceived(dict) — a single parsed packet dict + targetsUpdated(list) — list of RadarTarget after processing + errorOccurred(str) — error message + statsUpdated(dict) — packet/byte counters + """ + + packetReceived = pyqtSignal(dict) + targetsUpdated = pyqtSignal(list) + errorOccurred = pyqtSignal(str) + statsUpdated = pyqtSignal(dict) + + def __init__( + self, + ft2232hq: FT2232HQInterface, + processor: RadarProcessor, + packet_parser: RadarPacketParser, + settings: RadarSettings, + gps_data_ref: GPSData, + parent=None, + ): + super().__init__(parent) + self._ft2232hq = ft2232hq + self._processor = processor + self._parser = packet_parser + self._settings = settings + self._gps = gps_data_ref + self._running = False + + # Counters + self._packet_count = 0 + self._byte_count = 0 + self._error_count = 0 + + def stop(self): + self._running = False + + def run(self): + """Main loop: read → parse → process → emit.""" + self._running = True + buffer = bytearray() + + while self._running: + # Use FT2232HQ interface + iface = None + if self._ft2232hq and self._ft2232hq.is_open: + iface = self._ft2232hq + + if iface is None: + self.msleep(100) + continue + + try: + data = iface.read_data(4096) + if data: + buffer.extend(data) + self._byte_count += len(data) + + # Parse as many packets as possible + while len(buffer) >= 6: + result = self._parser.parse_packet(bytes(buffer)) + if result is None: + # No valid packet at current position — skip one byte + if len(buffer) > 1: + buffer = buffer[1:] + else: + break + continue + + pkt, consumed = result + buffer = buffer[consumed:] + self._packet_count += 1 + + # Process the packet + self._process_packet(pkt) + self.packetReceived.emit(pkt) + + # Emit stats periodically + self.statsUpdated.emit({ + "packets": self._packet_count, + "bytes": self._byte_count, + "errors": self._error_count, + "active_tracks": len(self._processor.tracks), + "targets": len(self._processor.detected_targets), + }) + else: + self.msleep(10) + except Exception as e: + self._error_count += 1 + self.errorOccurred.emit(str(e)) + logger.error(f"RadarDataWorker error: {e}") + self.msleep(100) + + # ---- internal packet handling ------------------------------------------ + + def _process_packet(self, pkt: dict): + """Route a parsed packet through the processing pipeline.""" + try: + if pkt["type"] == "range": + range_m = pkt["range"] * 0.1 + raw_elev = pkt["elevation"] + corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch) + + target = RadarTarget( + id=pkt["chirp"], + range=range_m, + velocity=0, + azimuth=pkt["azimuth"], + elevation=corr_elev, + snr=20.0, + timestamp=pkt["timestamp"], + ) + self._update_rdm(target) + + elif pkt["type"] == "doppler": + lam = 3e8 / self._settings.system_frequency + velocity = (pkt["doppler_real"] / 32767.0) * ( + self._settings.prf1 * lam / 2 + ) + self._update_velocity(pkt, velocity) + + elif pkt["type"] == "detection": + if pkt["detected"]: + raw_elev = pkt["elevation"] + corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch) + logger.info( + f"CFAR Detection: raw={raw_elev}, corr={corr_elev:.1f}, " + f"pitch={self._gps.pitch:.1f}" + ) + except Exception as e: + logger.error(f"Error processing packet: {e}") + + def _update_rdm(self, target: RadarTarget): + range_bin = min(int(target.range / 50), 1023) + doppler_bin = min(abs(int(target.velocity)), 31) + self._processor.range_doppler_map[range_bin, doppler_bin] += 1 + self._processor.detected_targets.append(target) + if len(self._processor.detected_targets) > 100: + self._processor.detected_targets = self._processor.detected_targets[-100:] + + def _update_velocity(self, pkt: dict, velocity: float): + for t in self._processor.detected_targets: + if (t.azimuth == pkt["azimuth"] + and t.elevation == pkt["elevation"] + and t.id == pkt["chirp"]): + t.velocity = velocity + break + + +# ============================================================================= +# GPS Data Worker (QThread) +# ============================================================================= + +class GPSDataWorker(QThread): + """ + Background worker that reads GPS frames from the STM32 USB CDC interface + and emits parsed GPSData objects. + + Signals: + gpsReceived(GPSData) + errorOccurred(str) + """ + + gpsReceived = pyqtSignal(object) # GPSData + errorOccurred = pyqtSignal(str) + + def __init__( + self, + stm32: STM32USBInterface, + usb_parser: USBPacketParser, + parent=None, + ): + super().__init__(parent) + self._stm32 = stm32 + self._parser = usb_parser + self._running = False + self._gps_count = 0 + + @property + def gps_count(self) -> int: + return self._gps_count + + def stop(self): + self._running = False + + def run(self): + self._running = True + while self._running: + if not (self._stm32 and self._stm32.is_open): + self.msleep(100) + continue + + try: + data = self._stm32.read_data(64, timeout=100) + if data: + gps = self._parser.parse_gps_data(data) + if gps: + self._gps_count += 1 + self.gpsReceived.emit(gps) + except Exception as e: + self.errorOccurred.emit(str(e)) + logger.error(f"GPSDataWorker error: {e}") + self.msleep(100) + + +# ============================================================================= +# Target Simulator (Demo Mode) — QTimer-based +# ============================================================================= + +class TargetSimulator(QObject): + """ + Generates simulated radar targets for demo/testing. + + Uses a QTimer on the main thread (or whichever thread owns this object). + Emits ``targetsUpdated`` with a list[RadarTarget] on each tick. + """ + + targetsUpdated = pyqtSignal(list) + + def __init__(self, radar_position: GPSData, parent=None): + super().__init__(parent) + self._radar_pos = radar_position + self._targets: List[RadarTarget] = [] + self._next_id = 1 + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + self._initialize_targets(8) + + # ---- public API -------------------------------------------------------- + + def start(self, interval_ms: int = 500): + self._timer.start(interval_ms) + + def stop(self): + self._timer.stop() + + def set_radar_position(self, gps: GPSData): + self._radar_pos = gps + + def add_random_target(self): + self._add_random_target() + + # ---- internals --------------------------------------------------------- + + def _initialize_targets(self, count: int): + for _ in range(count): + self._add_random_target() + + def _add_random_target(self): + range_m = random.uniform(5000, 40000) + azimuth = random.uniform(0, 360) + velocity = random.uniform(-100, 100) + elevation = random.uniform(-5, 45) + + lat, lon = polar_to_geographic( + self._radar_pos.latitude, + self._radar_pos.longitude, + range_m, + azimuth, + ) + + target = RadarTarget( + id=self._next_id, + range=range_m, + velocity=velocity, + azimuth=azimuth, + elevation=elevation, + latitude=lat, + longitude=lon, + snr=random.uniform(10, 35), + timestamp=time.time(), + track_id=self._next_id, + classification=random.choice(["aircraft", "drone", "bird", "unknown"]), + ) + self._next_id += 1 + self._targets.append(target) + + def _tick(self): + """Update all simulated targets and emit.""" + updated: List[RadarTarget] = [] + + for t in self._targets: + new_range = t.range - t.velocity * 0.5 + if new_range < 500 or new_range > 50000: + continue # target exits coverage — drop it + + new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2))) + new_az = (t.azimuth + random.uniform(-0.5, 0.5)) % 360 + + lat, lon = polar_to_geographic( + self._radar_pos.latitude, + self._radar_pos.longitude, + new_range, + new_az, + ) + + updated.append(RadarTarget( + id=t.id, + range=new_range, + velocity=new_vel, + azimuth=new_az, + elevation=t.elevation + random.uniform(-0.1, 0.1), + latitude=lat, + longitude=lon, + snr=t.snr + random.uniform(-1, 1), + timestamp=time.time(), + track_id=t.track_id, + classification=t.classification, + )) + + # Maintain a reasonable target count + if len(updated) < 5 or (random.random() < 0.05 and len(updated) < 15): + self._add_random_target() + updated.append(self._targets[-1]) + + self._targets = updated + self.targetsUpdated.emit(updated) From 11aa590cf2d9d8d7657fa03f026c472ee03ed0fe Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Thu, 9 Apr 2026 02:05:34 +0300 Subject: [PATCH 05/57] fix: full-repo ruff lint cleanup and CI migration to uv Resolve all 374 ruff errors across 36 Python files (E501, E702, E722, E741, F821, F841, invalid-syntax) bringing `ruff check .` to zero errors repo-wide with line-length=100. Rewrite CI workflow to use uv for dependency management, whole-repo `ruff check .`, py_compile syntax gate, and merged python-tests job. Add pyproject.toml with ruff config and uv dependency groups. CI structure proposed by hcm444. --- .github/workflows/ci-tests.yml | 97 +- 5_Simulations/Antenna/Quartz_Waveguide.py | 30 +- .../openems_quartz_slotted_wg_10p5GHz.py | 54 +- .../Generate_ChirpcsvFile.py | 5 +- 5_Simulations/Fencing/Via_fencing.py | 14 +- 5_Simulations/Fencing/Via_fencing2.py | 18 +- .../array_pattern_Kaiser25dB_like.py | 5 +- 8_Utils/Python/CSV_radar.py | 30 +- 8_Utils/Python/Gen_Triangular.py | 11 +- .../Python/Generic_Triangular_Frequency.py | 9 +- 8_Utils/Python/RADAR_eq.py | 14 +- 8_Utils/Python/patch_antenna.py | 22 +- 9_Firmware/9_2_FPGA/tb/cosim/compare.py | 5 +- 9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py | 22 +- .../9_2_FPGA/tb/cosim/gen_multiseg_golden.py | 3 +- .../tb/cosim/real_data/golden_reference.py | 58 +- .../9_2_FPGA/tb/cosim/validate_mem_files.py | 24 +- 9_Firmware/9_3_GUI/GUI_PyQt_Map.py | 83 +- 9_Firmware/9_3_GUI/GUI_V1.py | 75 +- 9_Firmware/9_3_GUI/GUI_V2.py | 813 +++++----- 9_Firmware/9_3_GUI/GUI_V3.py | 901 ++++++------ 9_Firmware/9_3_GUI/GUI_V4.py | 1006 +++++++------ 9_Firmware/9_3_GUI/GUI_V4_2_CSV.py | 587 ++++---- 9_Firmware/9_3_GUI/GUI_V5.py | 1308 +++++++++-------- 9_Firmware/9_3_GUI/GUI_V5_Demo.py | 918 ++++++------ 9_Firmware/9_3_GUI/GUI_V6.py | 160 +- 9_Firmware/9_3_GUI/GUI_V6_Demo.py | 16 +- 9_Firmware/9_3_GUI/test_radar_dashboard.py | 5 +- 9_Firmware/9_3_GUI/v7/dashboard.py | 11 +- 9_Firmware/9_3_GUI/v7/map_widget.py | 91 +- pyproject.toml | 27 + 31 files changed, 3633 insertions(+), 2789 deletions(-) create mode 100644 pyproject.toml diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index db24bea..1a3fd51 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -8,100 +8,77 @@ on: jobs: # =========================================================================== - # 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 - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Python 3.12 - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install ruff - run: pip install ruff - - - 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/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) - # radar_protocol, radar_dashboard, FT2232H connection, replay, opcodes, e2e + # Python: lint (ruff), syntax check (py_compile), unit tests (pytest) + # CI structure proposed by hcm444 — uses uv for dependency management # =========================================================================== python-tests: - name: Python Dashboard Tests (58) + name: Python Lint + Tests runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - - name: Set up Python 3.12 - uses: actions/setup-python@v5 + - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install pytest numpy h5py + - uses: astral-sh/setup-uv@v5 - - name: Run test suite - run: python -m pytest 9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short + - name: Install dependencies + run: uv sync --group dev + + - name: Ruff lint (whole repo) + run: uv run ruff check . + + - name: Syntax check (py_compile) + run: | + uv run python - <<'PY' + import py_compile + from pathlib import Path + + skip = {".git", "__pycache__", ".venv", "venv", "docs"} + for p in Path(".").rglob("*.py"): + if skip & set(p.parts): + continue + py_compile.compile(str(p), doraise=True) + PY + + - name: Unit tests + run: > + uv run pytest + 9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short # =========================================================================== - # Job 2: MCU Firmware Unit Tests (20 tests) + # MCU Firmware Unit Tests (20 tests) # Bug regression (15) + Gap-3 safety tests (5) # =========================================================================== mcu-tests: - name: MCU Firmware Tests (20) + name: MCU Firmware Tests runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Install build tools run: sudo apt-get update && sudo apt-get install -y build-essential - name: Build and run MCU tests - working-directory: 9_Firmware/9_1_Microcontroller/tests run: make test + working-directory: 9_Firmware/9_1_Microcontroller/tests # =========================================================================== - # Job 3: FPGA RTL Regression (23 testbenches + lint) - # Phase 0: Vivado-style lint, Phase 1-4: unit + integration + e2e + # FPGA RTL Regression (23 testbenches + lint) # =========================================================================== fpga-regression: - name: FPGA Regression (23 TBs + lint) + name: FPGA Regression runs-on: ubuntu-latest steps: - - name: Checkout repository - uses: actions/checkout@v4 + - uses: actions/checkout@v4 - name: Install Icarus Verilog run: sudo apt-get update && sudo apt-get install -y iverilog - name: Run full FPGA regression - working-directory: 9_Firmware/9_2_FPGA run: bash run_regression.sh + working-directory: 9_Firmware/9_2_FPGA diff --git a/5_Simulations/Antenna/Quartz_Waveguide.py b/5_Simulations/Antenna/Quartz_Waveguide.py index d38751c..3b37b39 100644 --- a/5_Simulations/Antenna/Quartz_Waveguide.py +++ b/5_Simulations/Antenna/Quartz_Waveguide.py @@ -106,7 +106,8 @@ mesh.SmoothMeshLines('all', mesh_res, ratio=1.4) # Materials # ------------------------- pec = CSX.AddMetal('PEC') -quartz = CSX.AddMaterial('QUARTZ'); quartz.SetMaterialProperty(epsilon=er_quartz) +quartz = CSX.AddMaterial('QUARTZ') +quartz.SetMaterialProperty(epsilon=er_quartz) air = CSX.AddMaterial('AIR') # explicit for slot holes # ------------------------- @@ -191,13 +192,19 @@ Zin = ports[0].uf_tot / ports[0].if_tot plt.figure(figsize=(7.6,4.6)) plt.plot(freq*1e-9, 20*np.log10(np.abs(S11)), lw=2, label='|S11|') plt.plot(freq*1e-9, 20*np.log10(np.abs(S21)), lw=2, ls='--', label='|S21|') -plt.grid(True); plt.legend(); plt.xlabel('Frequency (GHz)'); plt.ylabel('Magnitude (dB)') +plt.grid(True) +plt.legend() +plt.xlabel('Frequency (GHz)') +plt.ylabel('Magnitude (dB)') plt.title('S-Parameters: Slotted Quartz-Filled WG') plt.figure(figsize=(7.6,4.6)) plt.plot(freq*1e-9, np.real(Zin), lw=2, label='Re{Zin}') plt.plot(freq*1e-9, np.imag(Zin), lw=2, ls='--', label='Im{Zin}') -plt.grid(True); plt.legend(); plt.xlabel('Frequency (GHz)'); plt.ylabel('Ohms') +plt.grid(True) +plt.legend() +plt.xlabel('Frequency (GHz)') +plt.ylabel('Ohms') plt.title('Input Impedance (Port 1)') # ------------------------- @@ -237,19 +244,26 @@ ax = fig.add_subplot(111, projection='3d') ax.plot_surface(X, Y, Z, rstride=2, cstride=2, linewidth=0, antialiased=True, alpha=0.92) ax.set_title(f'Normalized 3D Pattern @ {f0/1e9:.2f} GHz\n(peak ≈ {Gmax_dBi:.1f} dBi)') ax.set_box_aspect((1,1,1)) -ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_zlabel('z') +ax.set_xlabel('x') +ax.set_ylabel('y') +ax.set_zlabel('z') plt.tight_layout() # Quick 2D geometry preview (top view at y=b) plt.figure(figsize=(8.4,2.8)) -plt.fill_between([0,a], [0,0], [L,L], color='#dddddd', alpha=0.5, step='pre', label='WG aperture (top)') +plt.fill_between( + [0, a], [0, 0], [L, L], color='#dddddd', alpha=0.5, step='pre', label='WG aperture (top)' +) for zc, xc in zip(z_centers, x_centers): plt.gca().add_patch(plt.Rectangle((xc - slot_w/2.0, zc - slot_L/2.0), slot_w, slot_L, fc='#3355ff', ec='k')) -plt.xlim(-2, a+2); plt.ylim(-5, L+5) +plt.xlim(-2, a + 2) +plt.ylim(-5, L + 5) plt.gca().invert_yaxis() -plt.xlabel('x (mm)'); plt.ylabel('z (mm)') +plt.xlabel('x (mm)') +plt.ylabel('z (mm)') plt.title('Top-view slot layout (y=b plane)') -plt.grid(True); plt.legend() +plt.grid(True) +plt.legend() plt.show() diff --git a/5_Simulations/Antenna/openems_quartz_slotted_wg_10p5GHz.py b/5_Simulations/Antenna/openems_quartz_slotted_wg_10p5GHz.py index fa7d35d..fbdabae 100644 --- a/5_Simulations/Antenna/openems_quartz_slotted_wg_10p5GHz.py +++ b/5_Simulations/Antenna/openems_quartz_slotted_wg_10p5GHz.py @@ -137,7 +137,9 @@ Ncells = Nx*Ny*Nz print(f"[mesh] cells: {Nx} × {Ny} × {Nz} = {Ncells:,}") mem_fields_bytes = Ncells * 6 * 8 # rough ~ (Ex,Ey,Ez,Hx,Hy,Hz) doubles print(f"[mesh] rough field memory: ~{mem_fields_bytes/1e9:.2f} GB (solver overhead extra)") -dx_min = min(np.diff(x_lines)); dy_min = min(np.diff(y_lines)); dz_min = min(np.diff(z_lines)) +dx_min = min(np.diff(x_lines)) +dy_min = min(np.diff(y_lines)) +dz_min = min(np.diff(z_lines)) print(f"[mesh] min steps (mm): dx={dx_min:.3f}, dy={dy_min:.3f}, dz={dz_min:.3f}") # Optional smoothing to limit max cell size @@ -147,7 +149,8 @@ mesh.SmoothMeshLines('all', mesh_res, ratio=1.4) # MATERIALS & SOLIDS # ================= pec = CSX.AddMetal('PEC') -quartzM = CSX.AddMaterial('QUARTZ'); quartzM.SetMaterialProperty(epsilon=er_quartz) +quartzM = CSX.AddMaterial('QUARTZ') +quartzM.SetMaterialProperty(epsilon=er_quartz) airM = CSX.AddMaterial('AIR') # Quartz full block @@ -157,7 +160,9 @@ quartzM.AddBox([0, 0, 0], [a, b, guide_length_mm]) pec.AddBox([-t_metal, 0, 0], [0, b, guide_length_mm]) # left pec.AddBox([a, 0, 0], [a+t_metal,b, guide_length_mm]) # right pec.AddBox([-t_metal,-t_metal,0],[a+t_metal,0, guide_length_mm]) # bottom -pec.AddBox([-t_metal, b, 0], [a+t_metal,b+t_metal,guide_length_mm]) # top (slots will pierce) +pec.AddBox( + [-t_metal, b, 0], [a + t_metal, b + t_metal, guide_length_mm] +) # top (slots will pierce) # Slots (AIR) overriding top metal for zc, xc in zip(z_centers, x_centers): @@ -215,16 +220,16 @@ print(f"[timing] FDTD solve elapsed: {t1 - t0:.2f} s") # ... right before NF2FF (far-field): t2 = time.time() try: - res = nf2ff.CalcNF2FF(Sim_Path, [f0], theta, phi) -except AttributeError: - res = FDTD.CalcNF2FF(nf2ff, Sim_Path, [f0], theta, phi) + res = nf2ff.CalcNF2FF(Sim_Path, [f0], theta, phi) # noqa: F821 +except AttributeError: + res = FDTD.CalcNF2FF(nf2ff, Sim_Path, [f0], theta, phi) # noqa: F821 t3 = time.time() print(f"[timing] NF2FF (far-field) elapsed: {t3 - t2:.2f} s") # ... S-parameters postproc timing (optional): t4 = time.time() -for p in ports: - p.CalcPort(Sim_Path, freq) +for p in ports: # noqa: F821 + p.CalcPort(Sim_Path, freq) # noqa: F821 t5 = time.time() print(f"[timing] Port/S-params postproc elapsed: {t5 - t4:.2f} s") @@ -250,13 +255,19 @@ Zin = ports[0].uf_tot / ports[0].if_tot plt.figure(figsize=(7.6,4.6)) plt.plot(freq*1e-9, 20*np.log10(np.abs(S11)), lw=2, label='|S11|') plt.plot(freq*1e-9, 20*np.log10(np.abs(S21)), lw=2, ls='--', label='|S21|') -plt.grid(True); plt.legend(); plt.xlabel('Frequency (GHz)'); plt.ylabel('Magnitude (dB)') +plt.grid(True) +plt.legend() +plt.xlabel('Frequency (GHz)') +plt.ylabel('Magnitude (dB)') plt.title(f'S-Parameters (profile: {PROFILE})') plt.figure(figsize=(7.6,4.6)) plt.plot(freq*1e-9, np.real(Zin), lw=2, label='Re{Zin}') plt.plot(freq*1e-9, np.imag(Zin), lw=2, ls='--', label='Im{Zin}') -plt.grid(True); plt.legend(); plt.xlabel('Frequency (GHz)'); plt.ylabel('Ohms') +plt.grid(True) +plt.legend() +plt.xlabel('Frequency (GHz)') +plt.ylabel('Ohms') plt.title('Input Impedance (Port 1)') # ========================== @@ -295,22 +306,35 @@ ax = fig.add_subplot(111, projection='3d') ax.plot_surface(X, Y, Z, rstride=2, cstride=2, linewidth=0, antialiased=True, alpha=0.92) ax.set_title(f'Normalized 3D Pattern @ {f0/1e9:.2f} GHz\n(peak ≈ {Gmax_dBi:.1f} dBi)') ax.set_box_aspect((1,1,1)) -ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_zlabel('z') +ax.set_xlabel('x') +ax.set_ylabel('y') +ax.set_zlabel('z') plt.tight_layout() # ========================== # QUICK 2D GEOMETRY PREVIEW # ========================== plt.figure(figsize=(8.4,2.8)) -plt.fill_between([0,a], [0,0], [guide_length_mm, guide_length_mm], color='#dddddd', alpha=0.5, step='pre', label='WG top aperture') +plt.fill_between( + [0, a], + [0, 0], + [guide_length_mm, guide_length_mm], + color='#dddddd', + alpha=0.5, + step='pre', + label='WG top aperture', +) for zc, xc in zip(z_centers, x_centers): plt.gca().add_patch(plt.Rectangle((xc - slot_w/2.0, zc - slot_L/2.0), slot_w, slot_L, fc='#3355ff', ec='k')) -plt.xlim(-2, a+2); plt.ylim(-5, guide_length_mm+5) +plt.xlim(-2, a + 2) +plt.ylim(-5, guide_length_mm + 5) plt.gca().invert_yaxis() -plt.xlabel('x (mm)'); plt.ylabel('z (mm)') +plt.xlabel('x (mm)') +plt.ylabel('z (mm)') plt.title(f'Top-view slot layout (N={Nslots}, profile={PROFILE})') -plt.grid(True); plt.legend() +plt.grid(True) +plt.legend() diff --git a/5_Simulations/DAC_ReconstructionFilter/Generate_ChirpcsvFile.py b/5_Simulations/DAC_ReconstructionFilter/Generate_ChirpcsvFile.py index 6907b6f..c264832 100644 --- a/5_Simulations/DAC_ReconstructionFilter/Generate_ChirpcsvFile.py +++ b/5_Simulations/DAC_ReconstructionFilter/Generate_ChirpcsvFile.py @@ -69,7 +69,10 @@ def generate_multi_ramp_csv(Fs=125e6, Tb=1e-6, Tau=2e-6, fmax=30e6, fmin=10e6, df = pd.DataFrame({"time(s)": t_csv, "voltage(V)": y_csv}) df.to_csv(filename, index=False, header=False) print(f"CSV saved: {filename}") - print(f"Total raw samples: {total_samples} | Ramps inserted: {ramps_inserted} | CSV points: {len(y_csv)}") + print( + f"Total raw samples: {total_samples} | Ramps inserted: {ramps_inserted} " + f"| CSV points: {len(y_csv)}" + ) # --- Plot (staircase) if show_plot or save_plot_png: diff --git a/5_Simulations/Fencing/Via_fencing.py b/5_Simulations/Fencing/Via_fencing.py index 8b7cfdc..6ad51ca 100644 --- a/5_Simulations/Fencing/Via_fencing.py +++ b/5_Simulations/Fencing/Via_fencing.py @@ -27,10 +27,20 @@ ax.axhline(polygon_y2, color="blue", linestyle="--") via_positions = [2, 4, 6, 8] # x positions for visualization for x in via_positions: # Case A - ax.add_patch(plt.Circle((x, polygon_y1), via_pad_A/2, facecolor="green", alpha=0.5, label="Via pad A" if x==2 else "")) + ax.add_patch( + plt.Circle( + (x, polygon_y1), via_pad_A / 2, facecolor="green", alpha=0.5, + label="Via pad A" if x == 2 else "" + ) + ) ax.add_patch(plt.Circle((x, polygon_y2), via_pad_A/2, facecolor="green", alpha=0.5)) # Case B - ax.add_patch(plt.Circle((-x, polygon_y1), via_pad_B/2, facecolor="red", alpha=0.3, label="Via pad B" if x==2 else "")) + ax.add_patch( + plt.Circle( + (-x, polygon_y1), via_pad_B / 2, facecolor="red", alpha=0.3, + label="Via pad B" if x == 2 else "" + ) + ) ax.add_patch(plt.Circle((-x, polygon_y2), via_pad_B/2, facecolor="red", alpha=0.3)) # Add dimensions text diff --git a/5_Simulations/Fencing/Via_fencing2.py b/5_Simulations/Fencing/Via_fencing2.py index 0435bce..0fe75ae 100644 --- a/5_Simulations/Fencing/Via_fencing2.py +++ b/5_Simulations/Fencing/Via_fencing2.py @@ -26,10 +26,20 @@ ax.axhline(polygon_y2, color="blue", linestyle="--") via_positions = [2, 2 + via_pitch] # two vias for showing spacing for x in via_positions: # Case A - ax.add_patch(plt.Circle((x, polygon_y1), via_pad_A/2, facecolor="green", alpha=0.5, label="Via pad A" if x==2 else "")) + ax.add_patch( + plt.Circle( + (x, polygon_y1), via_pad_A / 2, facecolor="green", alpha=0.5, + label="Via pad A" if x == 2 else "" + ) + ) ax.add_patch(plt.Circle((x, polygon_y2), via_pad_A/2, facecolor="green", alpha=0.5)) # Case B - ax.add_patch(plt.Circle((-x, polygon_y1), via_pad_B/2, facecolor="red", alpha=0.3, label="Via pad B" if x==2 else "")) + ax.add_patch( + plt.Circle( + (-x, polygon_y1), via_pad_B / 2, facecolor="red", alpha=0.3, + label="Via pad B" if x == 2 else "" + ) + ) ax.add_patch(plt.Circle((-x, polygon_y2), via_pad_B/2, facecolor="red", alpha=0.3)) # Add text annotations @@ -48,7 +58,9 @@ line_edge_y = rf_line_y + line_width/2 via_center_y = polygon_y1 ax.annotate("", xy=(2.4, line_edge_y), xytext=(2.4, via_center_y), arrowprops=dict(arrowstyle="<->", color="brown")) -ax.text(2.5, (line_edge_y + via_center_y)/2, f"{via_center_offset:.2f} mm", color="brown", va="center") +ax.text( + 2.5, (line_edge_y + via_center_y) / 2, f"{via_center_offset:.2f} mm", color="brown", va="center" +) # Formatting ax.set_xlim(-5, 5) diff --git a/5_Simulations/array_pattern_Kaiser25dB_like.py b/5_Simulations/array_pattern_Kaiser25dB_like.py index 84e3a6c..df06583 100644 --- a/5_Simulations/array_pattern_Kaiser25dB_like.py +++ b/5_Simulations/array_pattern_Kaiser25dB_like.py @@ -106,4 +106,7 @@ plt.tight_layout() plt.savefig('Heatmap_Kaiser25dB_like.png', bbox_inches='tight') plt.show() -print('Saved: E_plane_Kaiser25dB_like.png, H_plane_Kaiser25dB_like.png, Heatmap_Kaiser25dB_like.png') +print( + 'Saved: E_plane_Kaiser25dB_like.png, H_plane_Kaiser25dB_like.png, ' + 'Heatmap_Kaiser25dB_like.png' +) diff --git a/8_Utils/Python/CSV_radar.py b/8_Utils/Python/CSV_radar.py index aa3a1fc..7e85ffb 100644 --- a/8_Utils/Python/CSV_radar.py +++ b/8_Utils/Python/CSV_radar.py @@ -15,12 +15,20 @@ def generate_radar_csv(filename="pulse_compression_output.csv"): timestamp_ns = 0 # Target parameters - targets = [ - {'range': 3000, 'velocity': 25, 'snr': 30, 'azimuth': 10, 'elevation': 5}, # Fast moving target - {'range': 5000, 'velocity': -15, 'snr': 25, 'azimuth': 20, 'elevation': 2}, # Approaching target - {'range': 8000, 'velocity': 5, 'snr': 20, 'azimuth': 30, 'elevation': 8}, # Slow moving target - {'range': 12000, 'velocity': -8, 'snr': 18, 'azimuth': 45, 'elevation': 3}, # Distant target - ] + targets = [ + { + 'range': 3000, 'velocity': 25, 'snr': 30, 'azimuth': 10, 'elevation': 5 + }, # Fast moving target + { + 'range': 5000, 'velocity': -15, 'snr': 25, 'azimuth': 20, 'elevation': 2 + }, # Approaching target + { + 'range': 8000, 'velocity': 5, 'snr': 20, 'azimuth': 30, 'elevation': 8 + }, # Slow moving target + { + 'range': 12000, 'velocity': -8, 'snr': 18, 'azimuth': 45, 'elevation': 3 + }, # Distant target + ] # Noise parameters noise_std = 5 @@ -38,7 +46,7 @@ def generate_radar_csv(filename="pulse_compression_output.csv"): q_val = np.random.normal(0, noise_std) # Add clutter (stationary targets) - clutter_range = 2000 # Fixed clutter at 2km + _clutter_range = 2000 # Fixed clutter at 2km if sample < 100: # Simulate clutter in first 100 samples i_val += np.random.normal(0, clutter_std) q_val += np.random.normal(0, clutter_std) @@ -47,7 +55,9 @@ def generate_radar_csv(filename="pulse_compression_output.csv"): for target in targets: # Calculate range bin (simplified) range_bin = int(target['range'] / 20) # ~20m per bin - doppler_phase = 2 * math.pi * target['velocity'] * chirp / 100 # Doppler phase shift + doppler_phase = ( + 2 * math.pi * target['velocity'] * chirp / 100 + ) # Doppler phase shift # Target appears around its range bin with some spread if abs(sample - range_bin) < 10: @@ -96,7 +106,9 @@ def generate_radar_csv(filename="pulse_compression_output.csv"): for target in targets: # Range bin calculation (different for short chirps) range_bin = int(target['range'] / 40) # Different range resolution - doppler_phase = 2 * math.pi * target['velocity'] * (chirp + 5) / 80 # Different Doppler + doppler_phase = ( + 2 * math.pi * target['velocity'] * (chirp + 5) / 80 + ) # Different Doppler # Target appears around its range bin if abs(sample - range_bin) < 8: diff --git a/8_Utils/Python/Gen_Triangular.py b/8_Utils/Python/Gen_Triangular.py index 5842c21..64d5550 100644 --- a/8_Utils/Python/Gen_Triangular.py +++ b/8_Utils/Python/Gen_Triangular.py @@ -1,5 +1,5 @@ import numpy as np -from numpy.fft import fft, ifft +from numpy.fft import fft import matplotlib.pyplot as plt @@ -15,7 +15,10 @@ theta_n= 2*np.pi*(pow(N,2)*pow(Ts,2)*(fmax-fmin)/(2*Tb)+fmin*N*Ts) # instantaneo y = 1 + np.sin(theta_n) # ramp signal in time domain M = np.arange(n, 2*n, 1) -theta_m= 2*np.pi*(pow(M,2)*pow(Ts,2)*(-fmax+fmin)/(2*Tb)+(-fmin+2*fmax)*M*Ts)-2*np.pi*((fmin-fmax)*Tb/2+(2*fmax-fmin)*Tb) # instantaneous phase +theta_m= ( + 2*np.pi*(pow(M,2)*pow(Ts,2)*(-fmax+fmin)/(2*Tb)+(-fmin+2*fmax)*M*Ts) + - 2*np.pi*((fmin-fmax)*Tb/2+(2*fmax-fmin)*Tb) +) # instantaneous phase z = 1 + np.sin(theta_m) # ramp signal in time domain x = np.concatenate((y, z)) @@ -23,9 +26,9 @@ x = np.concatenate((y, z)) t = Ts*np.arange(0,2*n,1) X = fft(x) L =len(X) -l = np.arange(L) +freq_indices = np.arange(L) T = L*Ts -freq = l/T +freq = freq_indices/T plt.figure(figsize = (12, 6)) diff --git a/8_Utils/Python/Generic_Triangular_Frequency.py b/8_Utils/Python/Generic_Triangular_Frequency.py index d559538..f027403 100644 --- a/8_Utils/Python/Generic_Triangular_Frequency.py +++ b/8_Utils/Python/Generic_Triangular_Frequency.py @@ -15,7 +15,10 @@ theta_n= 2*np.pi*(pow(N,2)*pow(Ts,2)*(fmax-fmin)/(2*Tb)+fmin*N*Ts) # instantaneo y = 1 + np.sin(theta_n) # ramp signal in time domain M = np.arange(n, 2*n, 1) -theta_m= 2*np.pi*(pow(M,2)*pow(Ts,2)*(-fmax+fmin)/(2*Tb)+(-fmin+2*fmax)*M*Ts)-2*np.pi*((fmin-fmax)*Tb/2+(2*fmax-fmin)*Tb) # instantaneous phase +theta_m= ( + 2*np.pi*(pow(M,2)*pow(Ts,2)*(-fmax+fmin)/(2*Tb)+(-fmin+2*fmax)*M*Ts) + - 2*np.pi*((fmin-fmax)*Tb/2+(2*fmax-fmin)*Tb) +) # instantaneous phase z = 1 + np.sin(theta_m) # ramp signal in time domain x = np.concatenate((y, z)) @@ -24,9 +27,9 @@ t = Ts*np.arange(0,2*n,1) plt.plot(t, x) X = fft(x) L =len(X) -l = np.arange(L) +freq_indices = np.arange(L) T = L*Ts -freq = l/T +freq = freq_indices/T print("The Array is: ", x) #printing the array diff --git a/8_Utils/Python/RADAR_eq.py b/8_Utils/Python/RADAR_eq.py index 1020b31..60ce3a7 100644 --- a/8_Utils/Python/RADAR_eq.py +++ b/8_Utils/Python/RADAR_eq.py @@ -221,7 +221,10 @@ class RadarCalculatorGUI: temp = self.get_float_value(self.entries["Temperature (K):"]) # Validate inputs - if None in [f_ghz, pulse_duration_us, prf, p_dbm, g_dbi, sens_dbm, rcs, losses_db, nf_db, temp]: + if None in [ + f_ghz, pulse_duration_us, prf, p_dbm, g_dbi, + sens_dbm, rcs, losses_db, nf_db, temp, + ]: messagebox.showerror("Error", "Please enter valid numeric values for all fields") return @@ -235,7 +238,7 @@ class RadarCalculatorGUI: g_linear = 10 ** (g_dbi / 10) sens_linear = 10 ** ((sens_dbm - 30) / 10) losses_linear = 10 ** (losses_db / 10) - nf_linear = 10 ** (nf_db / 10) + _nf_linear = 10 ** (nf_db / 10) # Calculate receiver noise power if k is None: @@ -298,11 +301,14 @@ class RadarCalculatorGUI: messagebox.showinfo("Success", "Calculation completed successfully!") except Exception as e: - messagebox.showerror("Calculation Error", f"An error occurred during calculation:\n{str(e)}") + messagebox.showerror( + "Calculation Error", + f"An error occurred during calculation:\n{str(e)}", + ) def main(): root = tk.Tk() - app = RadarCalculatorGUI(root) + _app = RadarCalculatorGUI(root) root.mainloop() if __name__ == "__main__": diff --git a/8_Utils/Python/patch_antenna.py b/8_Utils/Python/patch_antenna.py index bc0f094..85dd886 100644 --- a/8_Utils/Python/patch_antenna.py +++ b/8_Utils/Python/patch_antenna.py @@ -12,13 +12,22 @@ def calculate_patch_antenna_parameters(frequency, epsilon_r, h_sub, h_cu, array) lamb = c /(frequency * 1e9) # Calculate the effective dielectric constant - epsilon_eff = (epsilon_r + 1) / 2 + (epsilon_r - 1) / 2 * (1 + 12 * h_sub_m / (array[1] * h_cu_m)) ** (-0.5) + epsilon_eff = ( + (epsilon_r + 1) / 2 + + (epsilon_r - 1) / 2 * (1 + 12 * h_sub_m / (array[1] * h_cu_m)) ** (-0.5) + ) # Calculate the width of the patch W = c / (2 * frequency * 1e9) * np.sqrt(2 / (epsilon_r + 1)) # Calculate the effective length - delta_L = 0.412 * h_sub_m * (epsilon_eff + 0.3) * (W / h_sub_m + 0.264) / ((epsilon_eff - 0.258) * (W / h_sub_m + 0.8)) + delta_L = ( + 0.412 + * h_sub_m + * (epsilon_eff + 0.3) + * (W / h_sub_m + 0.264) + / ((epsilon_eff - 0.258) * (W / h_sub_m + 0.8)) + ) # Calculate the length of the patch L = c / (2 * frequency * 1e9 * np.sqrt(epsilon_eff)) - 2 * delta_L @@ -31,7 +40,10 @@ def calculate_patch_antenna_parameters(frequency, epsilon_r, h_sub, h_cu, array) # Calculate the feeding line width (W_feed) Z0 = 50 # Characteristic impedance of the feeding line (typically 50 ohms) - A = Z0 / 60 * np.sqrt((epsilon_r + 1) / 2) + (epsilon_r - 1) / (epsilon_r + 1) * (0.23 + 0.11 / epsilon_r) + A = ( + Z0 / 60 * np.sqrt((epsilon_r + 1) / 2) + + (epsilon_r - 1) / (epsilon_r + 1) * (0.23 + 0.11 / epsilon_r) + ) W_feed = 8 * h_sub_m / np.exp(A) - 2 * h_cu_m # Convert results back to mm @@ -50,7 +62,9 @@ h_sub = 0.102 # Height of substrate in mm h_cu = 0.07 # Height of copper in mm array = [2, 2] # 2x2 array -W_mm, L_mm, dx_mm, dy_mm, W_feed_mm = calculate_patch_antenna_parameters(frequency, epsilon_r, h_sub, h_cu, array) +W_mm, L_mm, dx_mm, dy_mm, W_feed_mm = calculate_patch_antenna_parameters( + frequency, epsilon_r, h_sub, h_cu, array +) print(f"Width of the patch: {W_mm:.4f} mm") print(f"Length of the patch: {L_mm:.4f} mm") diff --git a/9_Firmware/9_2_FPGA/tb/cosim/compare.py b/9_Firmware/9_2_FPGA/tb/cosim/compare.py index 9290954..7913670 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/compare.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/compare.py @@ -358,7 +358,10 @@ def compare_scenario(scenario_name): # ---- First/last sample comparison ---- print("\nFirst 10 samples (after alignment):") - print(f" {'idx':>4s} {'RTL_I':>8s} {'Py_I':>8s} {'Err_I':>6s} {'RTL_Q':>8s} {'Py_Q':>8s} {'Err_Q':>6s}") + print( + f" {'idx':>4s} {'RTL_I':>8s} {'Py_I':>8s} {'Err_I':>6s} " + f"{'RTL_Q':>8s} {'Py_Q':>8s} {'Err_Q':>6s}" + ) for k in range(min(10, aligned_len)): ei = aligned_rtl_i[k] - aligned_py_i[k] eq = aligned_rtl_q[k] - aligned_py_q[k] 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 876d1f0..ad98042 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py @@ -199,14 +199,17 @@ class NCO: # Wait - let me re-derive. The Verilog is: # phase_accumulator <= phase_accumulator + frequency_tuning_word; # phase_accum_reg <= phase_accumulator; // OLD value (NBA) - # phase_with_offset <= phase_accum_reg + {phase_offset, 16'b0}; // OLD phase_accum_reg + # phase_with_offset <= phase_accum_reg + {phase_offset, 16'b0}; + # // OLD phase_accum_reg # Since all are NBA (<=), they all read the values from BEFORE this edge. # So: new_phase_accumulator = old_phase_accumulator + ftw # new_phase_accum_reg = old_phase_accumulator # new_phase_with_offset = old_phase_accum_reg + offset old_phase_accumulator = (self.phase_accumulator - ftw) & 0xFFFFFFFF # reconstruct self.phase_accum_reg = old_phase_accumulator - self.phase_with_offset = (old_phase_accum_reg + ((phase_offset << 16) & 0xFFFFFFFF)) & 0xFFFFFFFF + self.phase_with_offset = ( + old_phase_accum_reg + ((phase_offset << 16) & 0xFFFFFFFF) + ) & 0xFFFFFFFF # phase_accumulator was already updated above # ---- Stage 3a: Register LUT address + quadrant ---- @@ -607,8 +610,14 @@ class FIRFilter: if (old_valid_pipe >> 0) & 1: for i in range(16): # Sign-extend products to ACCUM_WIDTH - a = sign_extend(mult_results[2*i] & ((1 << self.PRODUCT_WIDTH) - 1), self.PRODUCT_WIDTH) - b = sign_extend(mult_results[2*i+1] & ((1 << self.PRODUCT_WIDTH) - 1), self.PRODUCT_WIDTH) + a = sign_extend( + mult_results[2 * i] & ((1 << self.PRODUCT_WIDTH) - 1), + self.PRODUCT_WIDTH, + ) + b = sign_extend( + mult_results[2 * i + 1] & ((1 << self.PRODUCT_WIDTH) - 1), + self.PRODUCT_WIDTH, + ) self.add_l0[i] = a + b # ---- Stage 2 (Level 1): 8 pairwise sums ---- @@ -1365,7 +1374,10 @@ def _self_test(): mag_sq = s * s + c * c expected = 32767 * 32767 error_pct = abs(mag_sq - expected) / expected * 100 - print(f" Quadrature check: sin^2+cos^2={mag_sq}, expected~{expected}, error={error_pct:.2f}%") + print( + f" Quadrature check: sin^2+cos^2={mag_sq}, " + f"expected~{expected}, error={error_pct:.2f}%" + ) print(" NCO: OK") # --- Mixer test --- 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 3f44726..dc7d732 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 @@ -218,7 +218,8 @@ def generate_long_chirp_test(): if seg == 0: buffer_write_ptr = 0 else: - # Overlap-save: copy buffer[SEGMENT_ADVANCE:SEGMENT_ADVANCE+OVERLAP] -> buffer[0:OVERLAP] + # Overlap-save: copy + # buffer[SEGMENT_ADVANCE:SEGMENT_ADVANCE+OVERLAP] -> buffer[0:OVERLAP] for i in range(OVERLAP_SAMPLES): input_buffer_i[i] = input_buffer_i[i + SEGMENT_ADVANCE] input_buffer_q[i] = input_buffer_q[i + SEGMENT_ADVANCE] 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 008d325..b8b8347 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 @@ -335,7 +335,9 @@ def run_ddc(adc_samples): for n in range(n_samples): integrators[0][n + 1] = (integrators[0][n] + mixed_i[n]) & ((1 << CIC_ACC_WIDTH) - 1) for s in range(1, CIC_STAGES): - integrators[s][n + 1] = (integrators[s][n] + integrators[s - 1][n + 1]) & ((1 << CIC_ACC_WIDTH) - 1) + integrators[s][n + 1] = ( + integrators[s][n] + integrators[s - 1][n + 1] + ) & ((1 << CIC_ACC_WIDTH) - 1) # Downsample by 4 n_decimated = n_samples // CIC_DECIMATION @@ -580,8 +582,11 @@ def run_range_bin_decimator(range_fft_i, range_fft_q, decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64) decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64) - print(f"[DECIM] Decimating {n_in}→{output_bins} bins, mode={'peak' if mode==1 else 'avg' if mode==2 else 'simple'}, " - f"start_bin={start_bin}, {n_chirps} chirps") + mode_str = 'peak' if mode == 1 else 'avg' if mode == 2 else 'simple' + print( + f"[DECIM] Decimating {n_in}→{output_bins} bins, mode={mode_str}, " + f"start_bin={start_bin}, {n_chirps} chirps" + ) for c in range(n_chirps): # Index into input, skip start_bin @@ -678,7 +683,9 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None): if twiddle_file_16 and os.path.exists(twiddle_file_16): cos_rom_16 = load_twiddle_rom(twiddle_file_16) else: - cos_rom_16 = np.round(32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)).astype(np.int64) + cos_rom_16 = np.round( + 32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft) + ).astype(np.int64) LOG2N_16 = 4 doppler_map_i = np.zeros((n_range, n_total), dtype=np.int64) @@ -835,7 +842,10 @@ def run_dc_notch(doppler_i, doppler_q, width=2): notched_i = doppler_i.copy() notched_q = doppler_q.copy() - print(f"[DC NOTCH] width={width}, {n_range} range bins x {n_doppler} Doppler bins (dual sub-frame)") + print( + f"[DC NOTCH] width={width}, {n_range} range bins x " + f"{n_doppler} Doppler bins (dual sub-frame)" + ) if width == 0: print(" Pass-through (width=0)") @@ -1167,7 +1177,12 @@ def main(): parser = argparse.ArgumentParser(description="AERIS-10 FPGA golden reference model") parser.add_argument('--frame', type=int, default=0, help='Frame index to process') parser.add_argument('--plot', action='store_true', help='Show plots') - parser.add_argument('--threshold', type=int, default=10000, help='Detection threshold (L1 magnitude)') + parser.add_argument( + '--threshold', + type=int, + default=10000, + help='Detection threshold (L1 magnitude)' + ) args = parser.parse_args() # Paths @@ -1175,7 +1190,11 @@ def main(): fpga_dir = os.path.abspath(os.path.join(script_dir, '..', '..', '..')) data_base = os.path.expanduser("~/Downloads/adi_radar_data") amp_data = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB.npy") - amp_config = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy") + amp_config = os.path.join( + data_base, + "amp_radar", + "phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy" + ) twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem") output_dir = os.path.join(script_dir, "hex") @@ -1290,7 +1309,10 @@ def main(): q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF packed = (q_val << 16) | i_val f.write(f"{packed:08X}\n") - print(f" Wrote {fc_doppler_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)") + print( + f" Wrote {fc_doppler_packed_file} (" + f"{DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)" + ) # Save numpy arrays for the full-chain path np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i) @@ -1336,7 +1358,10 @@ def main(): q_val = int(notched_q[rbin, dbin]) & 0xFFFF packed = (q_val << 16) | i_val f.write(f"{packed:08X}\n") - print(f" Wrote {fc_notched_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)") + print( + f" Wrote {fc_notched_packed_file} (" + f"{DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)" + ) # CFAR on DC-notched data CFAR_GUARD = 2 @@ -1385,7 +1410,10 @@ def main(): with open(cfar_det_list_file, 'w') as f: f.write("# AERIS-10 Full-Chain CFAR Detection List\n") f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n") - f.write(f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n") + f.write( + f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, " + f"alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n" + ) f.write("# Format: range_bin doppler_bin magnitude threshold\n") for det in cfar_detections: r, d = det @@ -1481,12 +1509,18 @@ def main(): print(f" Chirps processed: {DOPPLER_CHIRPS}") print(f" Samples/chirp: {FFT_SIZE}") print(f" Range FFT: {FFT_SIZE}-point → {snr_range:.1f} dB vs float") - print(f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming → {snr_doppler:.1f} dB vs float") + print( + f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming " + f"→ {snr_doppler:.1f} dB vs float" + ) print(f" Detections (direct): {len(detections)} (threshold={args.threshold})") print(" Full-chain decimator: 1024→64 peak detection") print(f" Full-chain detections: {len(fc_detections)} (threshold={args.threshold})") print(f" MTI+CFAR chain: decim → MTI → Doppler → DC notch(w={DC_NOTCH_WIDTH}) → CA-CFAR") - print(f" CFAR detections: {len(cfar_detections)} (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})") + print( + f" CFAR detections: {len(cfar_detections)} " + f"(guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})" + ) print(f" Hex stimulus files: {output_dir}/") print(" Ready for RTL co-simulation with Icarus Verilog") 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 d9e71dc..4ccd6d0 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 @@ -199,7 +199,10 @@ def test_long_chirp(): avg_mag = sum(magnitudes) / len(magnitudes) print(f" Magnitude: min={min_mag:.1f}, max={max_mag:.1f}, avg={avg_mag:.1f}") - print(f" Max magnitude as fraction of Q15 range: {max_mag/32767:.4f} ({max_mag/32767*100:.2f}%)") + print( + f" Max magnitude as fraction of Q15 range: " + f"{max_mag/32767:.4f} ({max_mag/32767*100:.2f}%)" + ) # Check if this looks like it came from generate_reference_chirp_q15 # That function uses 32767 * 0.9 scaling => max magnitude ~29490 @@ -262,7 +265,10 @@ def test_long_chirp(): # Check if bandwidth roughly matches expected bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50% if bw_match: - print(f" Bandwidth {f_range/1e6:.2f} MHz roughly matches expected {CHIRP_BW/1e6:.2f} MHz") + print( + f" Bandwidth {f_range/1e6:.2f} MHz roughly matches expected " + f"{CHIRP_BW/1e6:.2f} MHz" + ) else: warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz") @@ -415,8 +421,11 @@ def test_chirp_vs_model(): print(f" Max phase diff: {max_phase_diff:.4f} rad ({math.degrees(max_phase_diff):.2f} deg)") phase_match = max_phase_diff < 0.5 # within 0.5 rad - check(phase_match, - f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg (tolerance: 28.6 deg)") + check( + phase_match, + f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg " + f"(tolerance: 28.6 deg)", + ) # ============================================================================ @@ -521,8 +530,11 @@ def test_memory_addressing(): addr_from_concat = (seg << 10) | 0 # {seg[1:0], 10'b0} addr_end = (seg << 10) | 1023 - check(addr_from_concat == base, - f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} (expected {base})") + check( + addr_from_concat == base, + f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} " + f"(expected {base})", + ) check(addr_end == end, f"Seg {seg} end address: {{{seg}[1:0], 10'h3FF}} = {addr_end} (expected {end})") diff --git a/9_Firmware/9_3_GUI/GUI_PyQt_Map.py b/9_Firmware/9_3_GUI/GUI_PyQt_Map.py index 703e58c..798fb33 100644 --- a/9_Firmware/9_3_GUI/GUI_PyQt_Map.py +++ b/9_Firmware/9_3_GUI/GUI_PyQt_Map.py @@ -33,7 +33,8 @@ from enum import Enum from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTabWidget, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox, - QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox, QTableWidget, QTableWidgetItem, + QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox, + QTableWidget, QTableWidgetItem, QHeaderView ) from PyQt6.QtCore import ( @@ -554,11 +555,20 @@ class RadarMapWidget(QWidget): if (!radarMarker) return; var content = '' + - '