Compare commits

..

13 Commits

Author SHA1 Message Date
Jason 7cb7688814 chore: add .gitattributes to enforce LF line endings for new files 2026-04-15 13:03:54 +05:45
Jason 86b493a780 feat: CI test suite phases A+B, WaveformConfig separation, dead golden code cleanup
- Phase A: Remove self-blessing golden test from FPGA regression, wire
  MF co-sim (4 scenarios) into run_regression.sh, add opcode count guards
  to cross-layer tests (+3 tests)
- Phase B: Add radar_params.vh parser and architectural param consistency
  tests (+7 tests), add banned stale-value pattern scanner (+1 test)
- Separate WaveformConfig.range_resolution_m (physical, bandwidth-dependent)
  from bin_spacing_m (sample-rate dependent); rename all callers
- Remove 151 lines of dead golden generate/compare code from
  tb_radar_receiver_final.v; testbench now runs structural + bounds only
- Untrack generated MF co-sim CSV files, gitignore tb/golden/ directory

CI: 256 tests total (168 python + 40 cross-layer + 27 FPGA + 21 MCU), all green
2026-04-15 12:45:41 +05:45
Jason c023337949 chore: gitignore sim artifacts (doppler CSV/mem) and MCU test binaries
- Untrack rx_final_doppler_out.csv and golden_doppler.mem (regenerated by tests)
- Add 6 missing MCU test binaries to tests/.gitignore
2026-04-15 10:39:05 +05:45
Jason d259e5c106 fix: align all range/carrier/velocity values to PLFM hardware + FPGA bug fixes
- Correct carrier from 10.525/10 GHz to 10.5 GHz (verified ADF4382 config)
- Correct range-per-bin from 4.8/5.6/781.25 m to 24.0 m (matched-filter)
- Correct velocity resolution from 1.484 to 2.67 m/s/bin (PRI-based)
- Correct processing rate from 4 MSPS to 100 MSPS (post-DDC)
- Correct max range from 307/5000/50000 m to 1536 m (64 bins x 24 m)
- Add WaveformConfig.pri_s field (167 us PRI for velocity calculation)
- Fix short chirp chirp_complete deadlock (Bug A)
- Remove dead short_chirp ports, rename long_chirp to ref_chirp (Bug B)
- Fix stale latency comment 2159 -> 3187 cycles (Bug C)
- Create radar_params.vh as single source of truth for FPGA parameters
- Lower RadarSettings.cpp map_size validation bound from 1000 to 100
- Add PLFM hardware constants to golden_reference.py
- Update all GUI versions, tests, and cross-layer contracts

All 244 tests passing (167 Python + 21 MCU + 29 cross-layer + 27 FPGA)
2026-04-15 10:38:59 +05:45
Jason d8d30a6315 fix: guard tkinter/matplotlib imports for headless CI environments 2026-04-14 23:04:57 +05:45
Jason 34ecaf360b feat: rename Tkinter dashboard to GUI_V65_Tk, add replay/demo/targets parity
Rename radar_dashboard.py -> GUI_V65_Tk.py and add core feature parity
with the v7 PyQt dashboard while keeping Tkinter as the framework:

Replay mode:
- _ReplayController with threading.Event-based play/pause/stop
- Reuses v7.ReplayEngine and v7.SoftwareFPGA for all 3 input formats
- Dual dispatch routes FPGA control opcodes to SoftwareFPGA during
  raw IQ replay; non-routable opcodes show user-visible status message
- Seek slider with re-emit guard, speed combo, loop checkbox
- close() properly releases engine file handles on stop/reload

Demo mode:
- DemoTarget kinematics scaled to physical range grid (~307m max)
- DemoSimulator generates synthetic RadarFrames with Gaussian blobs
- Targets table (ttk.Treeview) updates from demo target list

Mode exclusion (bidirectional):
- Connect stops active demo/replay before starting acquisition
- Replay load stops previous controller and demo before loading
- Demo start stops active replay; refuses if live-connected
- --live/--replay/--demo in mutually exclusive CLI arg group

Bug fixes:
- seek() now increments past emitted frame to prevent re-emit on resume
- Failed replay load nulls controller ref to prevent dangling state

Tests: 17 new tests for DemoTarget, DemoSimulator, _ReplayController
CI: all 4 jobs pass (167+21+25+29 = 242 tests)
2026-04-14 22:54:00 +05:45
Jason 24b8442e40 feat: unified replay with SoftwareFPGA bit-accurate signal chain
Add SoftwareFPGA class that imports golden_reference functions to
replicate the FPGA pipeline in software, enabling bit-accurate replay
of raw IQ, FPGA co-sim, and HDF5 recordings through the same
dashboard path as live data.

New modules: software_fpga.py, replay.py (ReplayEngine + 3 loaders)
Enhanced: WaveformConfig model, extract_targets_from_frame() in
processing, ReplayWorker with thread-safe playback controls,
dashboard replay UI with transport controls and dual-dispatch
FPGA parameter routing.

Removed: ReplayConnection (from radar_protocol, hardware, dashboard,
tests) — replaced by the unified replay architecture.

150/150 tests pass, ruff clean.
2026-04-14 11:14:00 +05:45
Jason 2387f7f29f refactor: revert replay code, preserve non-replay fixes
Revert raw IQ replay (commits 2cb56e8..6095893) to prepare
for unified SoftwareFPGA replay architecture.

Preserved: C-locale spinboxes, AGC chart label, demo/radar
mutual exclusion.

Delete v7/raw_iq_replay.py
Restore workers.py, processing.py, models.py, __init__.py, test_v7.py
2026-04-14 09:57:25 +05:45
Jason 609589349d fix: range calibration, demo/radar mutual exclusion, AGC analysis refactor
Bug #1 — Range calibration for Raw IQ Replay:
- Add WaveformConfig dataclass (models.py) with FMCW waveform params
  (fs, BW, T_chirp, fc) and methods to compute range/velocity resolution
- Add waveform parameter spinboxes to playback controls (dashboard.py)
- Auto-parse waveform params from ADI phaser filename convention
- Create replay-specific RadarSettings with correct calibration instead
  of using FPGA defaults (781.25 m/bin → 0.334 m/bin for ADI phaser)
- Add 4 unit tests validating WaveformConfig math

Bug #2 — Demo + radar mutual exclusion:
- _start_demo() now refuses if radar is running (_running=True)
- _start_radar() stops demo first if _demo_mode is active
- Demo buttons disabled while radar/replay is running, re-enabled on stop

Bug #3 — Refactor adi_agc_analysis.py:
- Remove 60+ lines of duplicated AGC functions (signed_to_encoding,
  encoding_to_signed, clamp_gain, apply_gain_shift)
- Import from v7.agc_sim canonical implementation
- Rewrite simulate_agc() to use process_agc_frame() in a loop
- Rewrite process_frame_rd() to use quantize_iq() from agc_sim
2026-04-14 03:19:58 +05:45
Jason a16472480a fix: playback state race condition, C-locale spinboxes, and Leaflet CDN loading
- workers.py: Only emit playbackStateChanged on state transitions to
  prevent stale 'playing' signal from overwriting pause button text
- dashboard.py: Force C locale on all QDoubleSpinBox instances so
  comma-decimal locales don't break numeric input; add missing
  'Saturation' legend label to AGC chart
- map_widget.py: Enable LocalContentCanAccessRemoteUrls and set HTTP
  base URL so Leaflet CDN tiles/scripts load correctly in QtWebEngine
2026-04-14 03:09:39 +05:45
Jason a12ea90cdf fix: 8 button-state bugs + wire radar position into replay for map display
State machine fixes:
1. Raw IQ replay EOF now calls _stop_radar() to fully restore UI
2. Worker thread finished signal triggers UI recovery on crash/exit
3. _stop_radar() stops demo simulator to prevent cross-mode interference
4. _stop_demo() correctly identifies Mock mode via combo text
5. Demo start no longer clobbers status bar when acquisition is running
6. _stop_radar() resets playback button text, frame counter, file label
7. _start_raw_iq_replay() error path cleans up stale controller/worker
8. _refresh_gui() preserves Raw IQ paused status instead of overwriting

Map/location:
- RawIQReplayWorker now receives _radar_position (GPSData ref) so
  targets get real lat/lon projected from the virtual radar position
- Added heading control to Map tab sidebar (0-360 deg, wrapping)
- Manual lat/lon/heading changes in Map tab apply to replay targets

Ruff clean, 120/120 tests pass.
2026-04-14 01:49:34 +05:45
Jason 2cb56e8b13 feat: Raw IQ Replay mode — software FPGA signal chain with playback controls
Add a 4th connection mode to the V7 dashboard that loads raw complex IQ
captures (.npy) and runs the full FPGA signal processing chain in software:
quantize → AGC → Range FFT → Doppler FFT → MTI → DC notch → CFAR.

Implementation (7 steps):
- v7/agc_sim.py: bit-accurate AGC runtime extracted from adi_agc_analysis.py
- v7/processing.py: RawIQFrameProcessor (full signal chain) + shared
  extract_targets_from_frame() for bin-to-physical conversion
- v7/raw_iq_replay.py: RawIQReplayController with thread-safe playback
  state machine (play/pause/stop/step/seek/loop/FPS)
- v7/workers.py: RawIQReplayWorker (QThread) emitting same signals as
  RadarDataWorker + playback state/index signals
- v7/dashboard.py: mode combo entry, playback controls UI, dynamic
  RangeDopplerCanvas that adapts to any frame size

Bug fixes included:
- RangeDopplerCanvas no longer hardcodes 64x32; resizes dynamically
- Doppler centre bin uses n_doppler//2 instead of hardcoded 16
- Shared target extraction eliminates duplicate code between workers

Ruff clean, 120/120 tests pass.
2026-04-14 01:25:25 +05:45
Jason 77496ccc88 fix: guard PyQt6 imports in v7 package for headless CI environments
v7/__init__.py: wrap workers/map_widget/dashboard imports in try/except
so CI runners without PyQt6 can still test models, processing, hardware.

test_v7.py: skip TestPolarToGeographic when PyQt6 unavailable, split
TestV7Init.test_key_exports into core vs PyQt6-dependent assertions.
2026-04-14 00:27:22 +05:45
52 changed files with 3801 additions and 14022 deletions
+21
View File
@@ -0,0 +1,21 @@
# Enforce LF line endings for all text files going forward.
# Existing CRLF files are left as-is to avoid polluting git blame.
* text=auto eol=lf
# Binary files — ensure git doesn't mangle these
*.npy binary
*.h5 binary
*.hdf5 binary
*.png binary
*.jpg binary
*.pdf binary
*.zip binary
*.bin binary
*.mem binary
*.hex binary
*.vvp binary
*.s2p binary
*.s3p binary
*.step binary
*.FCStd binary
*.FCBak binary
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- name: Unit tests - name: Unit tests
run: > run: >
uv run pytest uv run pytest
9_Firmware/9_3_GUI/test_radar_dashboard.py 9_Firmware/9_3_GUI/test_GUI_V65_Tk.py
9_Firmware/9_3_GUI/test_v7.py 9_Firmware/9_3_GUI/test_v7.py
-v --tb=short -v --tb=short
+6
View File
@@ -32,6 +32,12 @@
9_Firmware/9_2_FPGA/tb/cosim/rtl_doppler_*.csv 9_Firmware/9_2_FPGA/tb/cosim/rtl_doppler_*.csv
9_Firmware/9_2_FPGA/tb/cosim/compare_doppler_*.csv 9_Firmware/9_2_FPGA/tb/cosim/compare_doppler_*.csv
9_Firmware/9_2_FPGA/tb/cosim/rtl_multiseg_*.csv 9_Firmware/9_2_FPGA/tb/cosim/rtl_multiseg_*.csv
9_Firmware/9_2_FPGA/tb/cosim/rx_final_doppler_out.csv
9_Firmware/9_2_FPGA/tb/cosim/rtl_mf_*.csv
9_Firmware/9_2_FPGA/tb/cosim/compare_mf_*.csv
# Golden reference outputs (regenerated by testbenches)
9_Firmware/9_2_FPGA/tb/golden/
# macOS # macOS
.DS_Store .DS_Store
+4 -1
View File
@@ -1,7 +1,10 @@
import numpy as np import numpy as np
# Define parameters # Define parameters
fs = 120e6 # Sampling frequency # NOTE: This is a standalone LUT generation utility. The production chirp LUT
# is generated by 9_Firmware/9_2_FPGA/tb/cosim/gen_chirp_mem.py with
# CHIRP_BW=20e6 (target: 30e6 Phase 1) and DAC_CLK=120e6.
fs = 120e6 # Sampling frequency (DAC clock from AD9523 OUT10)
Ts = 1 / fs # Sampling time Ts = 1 / fs # Sampling time
Tb = 1e-6 # Burst time Tb = 1e-6 # Burst time
Tau = 30e-6 # Pulse repetition time Tau = 30e-6 # Pulse repetition time
@@ -6,16 +6,16 @@ RadarSettings::RadarSettings() {
} }
void RadarSettings::resetToDefaults() { void RadarSettings::resetToDefaults() {
system_frequency = 10.0e9; // 10 GHz system_frequency = 10.5e9; // 10.5 GHz (PLFM TX LO, ADF4382 config)
chirp_duration_1 = 30.0e-6; // 30 s chirp_duration_1 = 30.0e-6; // 30 µs
chirp_duration_2 = 0.5e-6; // 0.5 s chirp_duration_2 = 0.5e-6; // 0.5 µs
chirps_per_position = 32; chirps_per_position = 32;
freq_min = 10.0e6; // 10 MHz freq_min = 10.0e6; // 10 MHz
freq_max = 30.0e6; // 30 MHz freq_max = 30.0e6; // 30 MHz
prf1 = 1000.0; // 1 kHz prf1 = 1000.0; // 1 kHz
prf2 = 2000.0; // 2 kHz prf2 = 2000.0; // 2 kHz
max_distance = 50000.0; // 50 km max_distance = 1536.0; // 1536 m (64 bins × 24 m, 3 km mode)
map_size = 50000.0; // 50 km map_size = 1536.0; // 1536 m
settings_valid = true; settings_valid = true;
} }
@@ -88,7 +88,7 @@ bool RadarSettings::validateSettings() {
if (prf1 < 100 || prf1 > 10000) return false; if (prf1 < 100 || prf1 > 10000) return false;
if (prf2 < 100 || prf2 > 10000) return false; if (prf2 < 100 || prf2 > 10000) return false;
if (max_distance < 100 || max_distance > 100000) return false; if (max_distance < 100 || max_distance > 100000) return false;
if (map_size < 1000 || map_size > 200000) return false; if (map_size < 100 || map_size > 200000) return false;
return true; return true;
} }
@@ -18,3 +18,9 @@ test_bug12_pa_cal_loop_inverted
test_bug13_dac2_adc_buffer_mismatch test_bug13_dac2_adc_buffer_mismatch
test_bug14_diag_section_args test_bug14_diag_section_args
test_bug15_htim3_dangling_extern test_bug15_htim3_dangling_extern
test_agc_outer_loop
test_gap3_emergency_state_ordering
test_gap3_emergency_stop_rails
test_gap3_idq_periodic_reread
test_gap3_iwdg_config
test_gap3_temperature_max
@@ -18,10 +18,9 @@ module matched_filter_multi_segment (
input wire mc_new_elevation, // Toggle for new elevation (32) input wire mc_new_elevation, // Toggle for new elevation (32)
input wire mc_new_azimuth, // Toggle for new azimuth (50) input wire mc_new_azimuth, // Toggle for new azimuth (50)
input wire [15:0] long_chirp_real, // Reference chirp (upstream memory loader selects long/short via use_long_chirp)
input wire [15:0] long_chirp_imag, input wire [15:0] ref_chirp_real,
input wire [15:0] short_chirp_real, input wire [15:0] ref_chirp_imag,
input wire [15:0] short_chirp_imag,
// Memory system interface // Memory system interface
output reg [1:0] segment_request, output reg [1:0] segment_request,
@@ -244,6 +243,7 @@ always @(posedge clk or negedge reset_n) begin
if (!use_long_chirp) begin if (!use_long_chirp) begin
if (chirp_samples_collected >= SHORT_CHIRP_SAMPLES - 1) begin if (chirp_samples_collected >= SHORT_CHIRP_SAMPLES - 1) begin
state <= ST_ZERO_PAD; state <= ST_ZERO_PAD;
chirp_complete <= 1; // Bug A fix: mark chirp done so ST_OUTPUT exits to IDLE
`ifdef SIMULATION `ifdef SIMULATION
$display("[MULTI_SEG_FIXED] Short chirp: collected %d samples, starting zero-pad", $display("[MULTI_SEG_FIXED] Short chirp: collected %d samples, starting zero-pad",
chirp_samples_collected + 1); chirp_samples_collected + 1);
@@ -500,11 +500,9 @@ matched_filter_processing_chain m_f_p_c(
// Chirp Selection // Chirp Selection
.chirp_counter(chirp_counter), .chirp_counter(chirp_counter),
// Reference Chirp Memory Interfaces // Reference Chirp Memory Interface (single pair upstream selects long/short)
.long_chirp_real(long_chirp_real), .ref_chirp_real(ref_chirp_real),
.long_chirp_imag(long_chirp_imag), .ref_chirp_imag(ref_chirp_imag),
.short_chirp_real(short_chirp_real),
.short_chirp_imag(short_chirp_imag),
// Output // Output
.range_profile_i(fft_pc_i), .range_profile_i(fft_pc_i),
@@ -15,7 +15,7 @@
* .clk, .reset_n * .clk, .reset_n
* .adc_data_i, .adc_data_q, .adc_valid <- from input buffer * .adc_data_i, .adc_data_q, .adc_valid <- from input buffer
* .chirp_counter <- 6-bit frame counter * .chirp_counter <- 6-bit frame counter
* .long_chirp_real/imag, .short_chirp_real/imag <- reference (time-domain) * .ref_chirp_real/imag <- reference (time-domain)
* .range_profile_i, .range_profile_q, .range_profile_valid -> output * .range_profile_i, .range_profile_q, .range_profile_valid -> output
* .chain_state -> 4-bit status * .chain_state -> 4-bit status
* *
@@ -48,10 +48,10 @@ module matched_filter_processing_chain (
input wire [5:0] chirp_counter, input wire [5:0] chirp_counter,
// Reference chirp (time-domain, latency-aligned by upstream buffer) // Reference chirp (time-domain, latency-aligned by upstream buffer)
input wire [15:0] long_chirp_real, // Upstream chirp_memory_loader_param selects long/short reference
input wire [15:0] long_chirp_imag, // via use_long_chirp this single pair carries whichever is active.
input wire [15:0] short_chirp_real, input wire [15:0] ref_chirp_real,
input wire [15:0] short_chirp_imag, input wire [15:0] ref_chirp_imag,
// Output: range profile (pulse-compressed) // Output: range profile (pulse-compressed)
output wire signed [15:0] range_profile_i, output wire signed [15:0] range_profile_i,
@@ -189,8 +189,8 @@ always @(posedge clk or negedge reset_n) begin
// Store first sample (signal + reference) // Store first sample (signal + reference)
fwd_buf_i[0] <= $signed(adc_data_i); fwd_buf_i[0] <= $signed(adc_data_i);
fwd_buf_q[0] <= $signed(adc_data_q); fwd_buf_q[0] <= $signed(adc_data_q);
ref_buf_i[0] <= $signed(long_chirp_real); ref_buf_i[0] <= $signed(ref_chirp_real);
ref_buf_q[0] <= $signed(long_chirp_imag); ref_buf_q[0] <= $signed(ref_chirp_imag);
fwd_in_count <= 1; fwd_in_count <= 1;
state <= ST_FWD_FFT; state <= ST_FWD_FFT;
end end
@@ -205,8 +205,8 @@ always @(posedge clk or negedge reset_n) begin
if (adc_valid && fwd_in_count < FFT_SIZE) begin if (adc_valid && fwd_in_count < FFT_SIZE) begin
fwd_buf_i[fwd_in_count] <= $signed(adc_data_i); fwd_buf_i[fwd_in_count] <= $signed(adc_data_i);
fwd_buf_q[fwd_in_count] <= $signed(adc_data_q); fwd_buf_q[fwd_in_count] <= $signed(adc_data_q);
ref_buf_i[fwd_in_count] <= $signed(long_chirp_real); ref_buf_i[fwd_in_count] <= $signed(ref_chirp_real);
ref_buf_q[fwd_in_count] <= $signed(long_chirp_imag); ref_buf_q[fwd_in_count] <= $signed(ref_chirp_imag);
fwd_in_count <= fwd_in_count + 1; fwd_in_count <= fwd_in_count + 1;
end end
@@ -775,16 +775,16 @@ always @(posedge clk) begin : ref_bram_port
if (adc_valid) begin if (adc_valid) begin
we = 1'b1; we = 1'b1;
addr = 0; addr = 0;
wdata_i = $signed(long_chirp_real); wdata_i = $signed(ref_chirp_real);
wdata_q = $signed(long_chirp_imag); wdata_q = $signed(ref_chirp_imag);
end end
end end
ST_COLLECT: begin ST_COLLECT: begin
if (adc_valid && collect_count < FFT_SIZE) begin if (adc_valid && collect_count < FFT_SIZE) begin
we = 1'b1; we = 1'b1;
addr = collect_count[ADDR_BITS-1:0]; addr = collect_count[ADDR_BITS-1:0];
wdata_i = $signed(long_chirp_real); wdata_i = $signed(ref_chirp_real);
wdata_q = $signed(long_chirp_imag); wdata_q = $signed(ref_chirp_imag);
end end
end end
ST_REF_FFT: begin ST_REF_FFT: begin
+200
View File
@@ -0,0 +1,200 @@
// ============================================================================
// radar_params.vh — Single Source of Truth for AERIS-10 FPGA Parameters
// ============================================================================
//
// ALL modules in the FPGA processing chain MUST `include this file instead of
// hardcoding range bins, segment counts, chirp samples, or timing values.
//
// This file uses `define macros (not localparam) so it can be included at any
// scope. Each consuming module should include this file inside its body and
// optionally alias macros to localparams for readability.
//
// BOARD VARIANTS:
// SUPPORT_LONG_RANGE = 0 (50T, USB_MODE=1) — 3 km mode only, 64 range bins
// SUPPORT_LONG_RANGE = 1 (200T, USB_MODE=0) — 3 km + 20 km modes, up to 1024 bins
//
// RANGE MODES (runtime, via host_range_mode register, opcode 0x20):
// 2'b00 = 3 km (default on both boards)
// 2'b01 = 20 km (200T only; clamped to 3 km on 50T)
// 2'b10 = Reserved
// 2'b11 = Reserved
//
// USAGE:
// `include "radar_params.vh"
// Then reference `RP_FFT_SIZE, `RP_MAX_OUTPUT_BINS, etc.
//
// PHYSICAL CONSTANTS (derived from hardware):
// ADC clock: 400 MSPS
// CIC decimation: 4x
// Processing rate: 100 MSPS (post-DDC)
// Range per sample: c / (2 * 100e6) = 1.5 m
// Decimation factor: 16 (1024 FFT bins -> 64 output bins per segment)
// Range per dec. bin: 1.5 m * 16 = 24.0 m
// Carrier frequency: 10.5 GHz
//
// CHIRP BANDWIDTH (Phase 1 target — currently 20 MHz, planned 30 MHz):
// Range resolution: c / (2 * BW)
// 20 MHz -> 7.5 m
// 30 MHz -> 5.0 m
// NOTE: Range resolution is independent of range-per-bin. Resolution
// determines the minimum separation between two targets; range-per-bin
// determines the spatial sampling grid.
// ============================================================================
`ifndef RADAR_PARAMS_VH
`define RADAR_PARAMS_VH
// ============================================================================
// BOARD VARIANT — set at synthesis time, NOT runtime
// ============================================================================
// Default to 50T (conservative). Override in top-level or synthesis script:
// +define+SUPPORT_LONG_RANGE
// or via Vivado: set_property verilog_define {SUPPORT_LONG_RANGE} [current_fileset]
// Note: SUPPORT_LONG_RANGE is a flag define (ifdef/ifndef), not a value.
// `ifndef SUPPORT_LONG_RANGE means 50T (no long range).
// `ifdef SUPPORT_LONG_RANGE means 200T (long range supported).
// ============================================================================
// FFT AND PROCESSING CONSTANTS (fixed, both modes)
// ============================================================================
`define RP_FFT_SIZE 1024 // Range FFT points per segment
`define RP_OVERLAP_SAMPLES 128 // Overlap between adjacent segments
`define RP_SEGMENT_ADVANCE 896 // FFT_SIZE - OVERLAP = 1024 - 128
`define RP_DECIMATION_FACTOR 16 // Range bin decimation (1024 -> 64)
`define RP_BINS_PER_SEGMENT 64 // FFT_SIZE / DECIMATION_FACTOR
`define RP_DOPPLER_FFT_SIZE 16 // Per sub-frame Doppler FFT
`define RP_CHIRPS_PER_FRAME 32 // Total chirps (16 long + 16 short)
`define RP_CHIRPS_PER_SUBFRAME 16 // Chirps per Doppler sub-frame
`define RP_NUM_DOPPLER_BINS 32 // 2 sub-frames * 16 = 32
`define RP_DATA_WIDTH 16 // ADC/processing data width
// ============================================================================
// 3 KM MODE PARAMETERS (both 50T and 200T)
// ============================================================================
`define RP_LONG_CHIRP_SAMPLES_3KM 3000 // 30 us at 100 MSPS
`define RP_LONG_SEGMENTS_3KM 4 // ceil((3000-1024)/896) + 1 = 4
`define RP_OUTPUT_RANGE_BINS_3KM 64 // Downstream pipeline expects 64 range bins (NOTE: will become 128 after 2048-pt FFT upgrade)
`define RP_SHORT_CHIRP_SAMPLES 50 // 0.5 us at 100 MSPS (same both modes)
`define RP_SHORT_SEGMENTS 1 // Single segment for short chirp
// Derived 3 km limits
`define RP_MAX_RANGE_3KM 1536 // 64 bins * 24 m = 1536 m
// ============================================================================
// 20 KM MODE PARAMETERS (200T only)
// ============================================================================
`define RP_LONG_CHIRP_SAMPLES_20KM 13700 // 137 us at 100 MSPS (= listen window)
`define RP_LONG_SEGMENTS_20KM 16 // ceil((13700-1024)/896) + 1 = 16
`define RP_OUTPUT_RANGE_BINS_20KM 1024 // 16 segments * 64 dec. bins each
// Derived 20 km limits
`define RP_MAX_RANGE_20KM 24576 // 1024 bins * 24 m = 24576 m
// ============================================================================
// MAX VALUES (for sizing buffers — compile-time, based on board variant)
// ============================================================================
`ifdef SUPPORT_LONG_RANGE
`define RP_MAX_SEGMENTS 16
`define RP_MAX_OUTPUT_BINS 1024
`define RP_MAX_CHIRP_SAMPLES 13700
`else
`define RP_MAX_SEGMENTS 4
`define RP_MAX_OUTPUT_BINS 64
`define RP_MAX_CHIRP_SAMPLES 3000
`endif
// ============================================================================
// BIT WIDTHS (derived from MAX values)
// ============================================================================
// Segment index: ceil(log2(MAX_SEGMENTS))
// 50T: log2(4) = 2 bits
// 200T: log2(16) = 4 bits
`ifdef SUPPORT_LONG_RANGE
`define RP_SEGMENT_IDX_WIDTH 4
`define RP_RANGE_BIN_WIDTH 10
`define RP_CHIRP_MEM_ADDR_W 14 // log2(16*1024) = 14
`define RP_DOPPLER_MEM_ADDR_W 15 // log2(1024*32) = 15
`define RP_CFAR_MAG_ADDR_W 15 // log2(1024*32) = 15
`else
`define RP_SEGMENT_IDX_WIDTH 2
`define RP_RANGE_BIN_WIDTH 6
`define RP_CHIRP_MEM_ADDR_W 12 // log2(4*1024) = 12
`define RP_DOPPLER_MEM_ADDR_W 11 // log2(64*32) = 11
`define RP_CFAR_MAG_ADDR_W 11 // log2(64*32) = 11
`endif
// Derived depths (for memory declarations)
// Usage: reg [15:0] mem [0:`RP_CHIRP_MEM_DEPTH-1];
`define RP_CHIRP_MEM_DEPTH (`RP_MAX_SEGMENTS * `RP_FFT_SIZE)
`define RP_DOPPLER_MEM_DEPTH (`RP_MAX_OUTPUT_BINS * `RP_CHIRPS_PER_FRAME)
`define RP_CFAR_MAG_DEPTH (`RP_MAX_OUTPUT_BINS * `RP_NUM_DOPPLER_BINS)
// ============================================================================
// CHIRP TIMING DEFAULTS (100 MHz clock cycles)
// ============================================================================
// Reset defaults for host-configurable timing registers.
// Match radar_mode_controller.v parameters and main.cpp STM32 defaults.
`define RP_DEF_LONG_CHIRP_CYCLES 3000 // 30 us
`define RP_DEF_LONG_LISTEN_CYCLES 13700 // 137 us
`define RP_DEF_GUARD_CYCLES 17540 // 175.4 us
`define RP_DEF_SHORT_CHIRP_CYCLES 50 // 0.5 us
`define RP_DEF_SHORT_LISTEN_CYCLES 17450 // 174.5 us
`define RP_DEF_CHIRPS_PER_ELEV 32
// ============================================================================
// BLIND ZONE CONSTANTS (informational, for comments and GUI)
// ============================================================================
// Long chirp blind zone: c * 30 us / 2 = 4500 m
// Short chirp blind zone: c * 0.5 us / 2 = 75 m
`define RP_LONG_BLIND_ZONE_M 4500
`define RP_SHORT_BLIND_ZONE_M 75
// ============================================================================
// PHYSICAL CONSTANTS (integer-scaled for Verilog — use in comments/assertions)
// ============================================================================
// Range per ADC sample: 1.5 m (stored as 15 in units of 0.1 m)
// Range per decimated bin: 24.0 m (stored as 240 in units of 0.1 m)
// Processing rate: 100 MSPS
`define RP_RANGE_PER_SAMPLE_DM 15 // 1.5 m in decimeters
`define RP_RANGE_PER_BIN_DM 240 // 24.0 m in decimeters
`define RP_PROCESSING_RATE_MHZ 100
// ============================================================================
// AGC DEFAULTS
// ============================================================================
`define RP_DEF_AGC_TARGET 200
`define RP_DEF_AGC_ATTACK 1
`define RP_DEF_AGC_DECAY 1
`define RP_DEF_AGC_HOLDOFF 4
// ============================================================================
// CFAR DEFAULTS
// ============================================================================
`define RP_DEF_CFAR_GUARD 2
`define RP_DEF_CFAR_TRAIN 8
`define RP_DEF_CFAR_ALPHA 8'h30 // 3.0 in Q4.4
`define RP_DEF_CFAR_MODE 2'b00 // CA-CFAR
// ============================================================================
// DETECTION DEFAULTS
// ============================================================================
`define RP_DEF_DETECT_THRESHOLD 10000
// ============================================================================
// RANGE MODE ENCODING
// ============================================================================
`define RP_RANGE_MODE_3KM 2'b00
`define RP_RANGE_MODE_20KM 2'b01
`define RP_RANGE_MODE_RSVD2 2'b10
`define RP_RANGE_MODE_RSVD3 2'b11
`endif // RADAR_PARAMS_VH
+11 -13
View File
@@ -102,9 +102,9 @@ wire [7:0] gc_saturation_count; // Diagnostic: per-frame clipped sample counter
wire [7:0] gc_peak_magnitude; // Diagnostic: per-frame peak magnitude wire [7:0] gc_peak_magnitude; // Diagnostic: per-frame peak magnitude
wire [3:0] gc_current_gain; // Diagnostic: effective gain_shift wire [3:0] gc_current_gain; // Diagnostic: effective gain_shift
// Reference signals for the processing chain // Reference signal for the processing chain (carries long OR short ref
wire [15:0] long_chirp_real, long_chirp_imag; // depending on use_long_chirp selected by chirp_memory_loader_param)
wire [15:0] short_chirp_real, short_chirp_imag; wire [15:0] ref_chirp_real, ref_chirp_imag;
// ========== DOPPLER PROCESSING SIGNALS ========== // ========== DOPPLER PROCESSING SIGNALS ==========
wire [31:0] range_data_32bit; wire [31:0] range_data_32bit;
@@ -292,7 +292,8 @@ end
// sample_addr_wire removed was unused implicit wire (synthesis warning) // sample_addr_wire removed was unused implicit wire (synthesis warning)
// 4. CRITICAL: Reference Chirp Latency Buffer // 4. CRITICAL: Reference Chirp Latency Buffer
// This aligns reference data with FFT output (2159 cycle delay) // This aligns reference data with FFT output (3187 cycle delay)
// TODO: verify empirically during hardware bring-up with correlation test
wire [15:0] delayed_ref_i, delayed_ref_q; wire [15:0] delayed_ref_i, delayed_ref_q;
wire mem_ready_delayed; wire mem_ready_delayed;
@@ -308,11 +309,10 @@ latency_buffer #(
.valid_out(mem_ready_delayed) .valid_out(mem_ready_delayed)
); );
// Assign delayed reference signals // Assign delayed reference signals (single pair chirp_memory_loader_param
assign long_chirp_real = delayed_ref_i; // selects long/short reference upstream via use_long_chirp)
assign long_chirp_imag = delayed_ref_q; assign ref_chirp_real = delayed_ref_i;
assign short_chirp_real = delayed_ref_i; assign ref_chirp_imag = delayed_ref_q;
assign short_chirp_imag = delayed_ref_q;
// 5. Dual Chirp Matched Filter // 5. Dual Chirp Matched Filter
@@ -336,10 +336,8 @@ matched_filter_multi_segment mf_dual (
.mc_new_chirp(mc_new_chirp), .mc_new_chirp(mc_new_chirp),
.mc_new_elevation(mc_new_elevation), .mc_new_elevation(mc_new_elevation),
.mc_new_azimuth(mc_new_azimuth), .mc_new_azimuth(mc_new_azimuth),
.long_chirp_real(delayed_ref_i), // From latency buffer .ref_chirp_real(delayed_ref_i), // From latency buffer (long or short ref)
.long_chirp_imag(delayed_ref_q), .ref_chirp_imag(delayed_ref_q),
.short_chirp_real(delayed_ref_i), // Same for short chirp
.short_chirp_imag(delayed_ref_q),
.segment_request(segment_request), .segment_request(segment_request),
.mem_request(mem_request), .mem_request(mem_request),
.sample_addr_out(sample_addr_from_chain), .sample_addr_out(sample_addr_from_chain),
+88 -26
View File
@@ -253,6 +253,68 @@ run_lint_static() {
fi fi
} }
# ---------------------------------------------------------------------------
# Helper: compile, run, and compare a matched-filter co-sim scenario
# run_mf_cosim <scenario_name> <define_flag>
# ---------------------------------------------------------------------------
run_mf_cosim() {
local name="$1"
local define="$2"
local vvp="tb/tb_mf_cosim_${name}.vvp"
local scenario_lower="$name"
printf " %-45s " "MF Co-Sim ($name)"
# Compile — build command as string to handle optional define
local cmd="iverilog -g2001 -DSIMULATION"
if [[ -n "$define" ]]; then
cmd="$cmd $define"
fi
cmd="$cmd -o $vvp tb/tb_mf_cosim.v matched_filter_processing_chain.v fft_engine.v chirp_memory_loader_param.v"
if ! eval "$cmd" 2>/tmp/iverilog_err_$$; then
echo -e "${RED}COMPILE FAIL${NC}"
ERRORS="$ERRORS\n MF Co-Sim ($name): compile error ($(head -1 /tmp/iverilog_err_$$))"
FAIL=$((FAIL + 1))
return
fi
# Run TB
local output
output=$(timeout 120 vvp "$vvp" 2>&1) || true
rm -f "$vvp"
# Check TB internal pass/fail
local tb_fail
tb_fail=$(echo "$output" | grep -Ec '^\[FAIL' || true)
if [[ "$tb_fail" -gt 0 ]]; then
echo -e "${RED}FAIL${NC} (TB internal failure)"
ERRORS="$ERRORS\n MF Co-Sim ($name): TB internal failure"
FAIL=$((FAIL + 1))
return
fi
# Run Python compare
if command -v python3 >/dev/null 2>&1; then
local compare_out
local compare_rc=0
compare_out=$(python3 tb/cosim/compare_mf.py "$scenario_lower" 2>&1) || compare_rc=$?
if [[ "$compare_rc" -ne 0 ]]; then
echo -e "${RED}FAIL${NC} (compare_mf.py mismatch)"
ERRORS="$ERRORS\n MF Co-Sim ($name): Python compare failed"
FAIL=$((FAIL + 1))
return
fi
else
echo -e "${YELLOW}SKIP${NC} (RTL passed, python3 not found — compare skipped)"
SKIP=$((SKIP + 1))
return
fi
echo -e "${GREEN}PASS${NC} (RTL + Python compare)"
PASS=$((PASS + 1))
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper: compile and run a single testbench # Helper: compile and run a single testbench
# run_test <name> <vvp_path> <iverilog_args...> # run_test <name> <vvp_path> <iverilog_args...>
@@ -416,30 +478,14 @@ run_test "Full-Chain Real-Data (decim→Doppler, exact match)" \
doppler_processor.v xfft_16.v fft_engine.v doppler_processor.v xfft_16.v fft_engine.v
if [[ "$QUICK" -eq 0 ]]; then if [[ "$QUICK" -eq 0 ]]; then
# Golden generate # NOTE: The "Receiver golden generate/compare" pair was REMOVED because
run_test "Receiver (golden generate)" \ # it was self-blessing: both passes ran the same RTL with the same
tb/tb_rx_golden_reg.vvp \ # deterministic stimulus, so the test always passed regardless of bugs.
-DGOLDEN_GENERATE \ # Real co-sim coverage is provided by:
tb/tb_radar_receiver_final.v radar_receiver_final.v \ # - tb_doppler_realdata.v (committed Python golden hex, exact match)
radar_mode_controller.v tb/ad9484_interface_400m_stub.v \ # - tb_fullchain_realdata.v (committed Python golden hex, exact match)
ddc_400m.v nco_400m_enhanced.v cic_decimator_4x_enhanced.v \ # A proper full-pipeline co-sim (DDC→MF→Decim→Doppler vs Python) is
cdc_modules.v fir_lowpass.v ddc_input_interface.v \ # planned as a replacement (Phase C of CI test plan).
chirp_memory_loader_param.v latency_buffer.v \
matched_filter_multi_segment.v matched_filter_processing_chain.v \
range_bin_decimator.v doppler_processor.v xfft_16.v fft_engine.v \
rx_gain_control.v mti_canceller.v
# Golden compare
run_test "Receiver (golden compare)" \
tb/tb_rx_compare_reg.vvp \
tb/tb_radar_receiver_final.v radar_receiver_final.v \
radar_mode_controller.v tb/ad9484_interface_400m_stub.v \
ddc_400m.v nco_400m_enhanced.v cic_decimator_4x_enhanced.v \
cdc_modules.v fir_lowpass.v ddc_input_interface.v \
chirp_memory_loader_param.v latency_buffer.v \
matched_filter_multi_segment.v matched_filter_processing_chain.v \
range_bin_decimator.v doppler_processor.v xfft_16.v fft_engine.v \
rx_gain_control.v mti_canceller.v
# Full system top (monitoring-only, legacy) # Full system top (monitoring-only, legacy)
run_test "System Top (radar_system_tb)" \ run_test "System Top (radar_system_tb)" \
@@ -469,12 +515,28 @@ if [[ "$QUICK" -eq 0 ]]; then
usb_data_interface.v edge_detector.v radar_mode_controller.v \ usb_data_interface.v edge_detector.v radar_mode_controller.v \
rx_gain_control.v cfar_ca.v mti_canceller.v fpga_self_test.v rx_gain_control.v cfar_ca.v mti_canceller.v fpga_self_test.v
else else
echo " (skipped receiver golden + system top + E2E — use without --quick)" echo " (skipped system top + E2E — use without --quick)"
SKIP=$((SKIP + 4)) SKIP=$((SKIP + 2))
fi fi
echo "" echo ""
# ===========================================================================
# PHASE 2b: MATCHED FILTER CO-SIMULATION (RTL vs Python golden reference)
# Runs tb_mf_cosim.v for 4 scenarios, then compare_mf.py validates output
# against committed Python golden CSV files. In SIMULATION mode, thresholds
# are generous (behavioral vs fixed-point twiddles differ) — validates
# state machine mechanics, output count, and energy sanity.
# ===========================================================================
echo "--- PHASE 2b: Matched Filter Co-Sim ---"
run_mf_cosim "chirp" ""
run_mf_cosim "dc" "-DSCENARIO_DC"
run_mf_cosim "impulse" "-DSCENARIO_IMPULSE"
run_mf_cosim "tone5" "-DSCENARIO_TONE5"
echo ""
# =========================================================================== # ===========================================================================
# PHASE 3: UNIT TESTS — Signal Processing # PHASE 3: UNIT TESTS — Signal Processing
# =========================================================================== # ===========================================================================
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,8 @@
""" """
golden_reference.py — AERIS-10 FPGA bit-accurate golden reference model golden_reference.py — AERIS-10 FPGA bit-accurate golden reference model
Uses ADI CN0566 Phaser radar data (10.525 GHz X-band FMCW) to validate Uses ADI CN0566 Phaser radar data (10.525 GHz, used as test stimulus only) to
the FPGA signal processing pipeline stage by stage: validate the FPGA signal processing pipeline stage by stage:
ADC → DDC (NCO+mixer+CIC+FIR) → Range FFT → Doppler FFT → Detection ADC → DDC (NCO+mixer+CIC+FIR) → Range FFT → Doppler FFT → Detection
@@ -90,7 +90,8 @@ HAMMING_Q15 = [
0x3088, 0x1B6D, 0x0E5C, 0x0A3D, 0x3088, 0x1B6D, 0x0E5C, 0x0A3D,
] ]
# ADI dataset parameters # ADI dataset parameters — used ONLY for loading/requantizing ADI Phaser test data.
# These are NOT PLFM hardware parameters. See AERIS-10 constants below.
ADI_SAMPLE_RATE = 4e6 # 4 MSPS ADI_SAMPLE_RATE = 4e6 # 4 MSPS
ADI_IF_FREQ = 100e3 # 100 kHz IF ADI_IF_FREQ = 100e3 # 100 kHz IF
ADI_RF_FREQ = 9.9e9 # 9.9 GHz ADI_RF_FREQ = 9.9e9 # 9.9 GHz
@@ -99,9 +100,17 @@ ADI_RAMP_TIME = 300e-6 # 300 us
ADI_NUM_CHIRPS = 256 ADI_NUM_CHIRPS = 256
ADI_SAMPLES_PER_CHIRP = 1079 ADI_SAMPLES_PER_CHIRP = 1079
# AERIS-10 parameters # AERIS-10 hardware parameters (from ADF4382/AD9523/main.cpp configuration)
AERIS_FS = 400e6 # 400 MHz ADC clock AERIS_FS = 400e6 # 400 MHz ADC clock (AD9523 OUT4)
AERIS_IF = 120e6 # 120 MHz IF AERIS_IF = 120e6 # 120 MHz IF (TX 10.5 GHz - RX 10.38 GHz)
AERIS_FS_PROCESSING = 100e6 # Post-DDC rate (400 MSPS / 4x CIC)
AERIS_CARRIER_HZ = 10.5e9 # TX LO (ADF4382, verified)
AERIS_RX_LO_HZ = 10.38e9 # RX LO (ADF4382)
AERIS_CHIRP_BW = 20e6 # Chirp bandwidth (target: 30 MHz Phase 1)
AERIS_LONG_CHIRP_S = 30e-6 # Long chirp duration
AERIS_PRI_S = 167e-6 # Pulse repetition interval
AERIS_DECIMATION = 16 # Range bin decimation (1024 → 64)
AERIS_RANGE_PER_BIN = 24.0 # Meters per decimated bin
# =========================================================================== # ===========================================================================
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -421,13 +421,13 @@ def test_latency_buffer():
# #
# For synthesis: the latency_buffer feeds ref data to the chain via # For synthesis: the latency_buffer feeds ref data to the chain via
# chirp_memory_loader_param → latency_buffer → chain. # chirp_memory_loader_param → latency_buffer → chain.
# But wait — looking at radar_receiver_final.v: # Looking at radar_receiver_final.v:
# - mem_request drives valid_in on the latency buffer # - mem_request drives valid_in on the latency buffer
# - The buffer delays {ref_i, ref_q} by LATENCY valid_in cycles # - The buffer delays {ref_i, ref_q} by LATENCY valid_in cycles
# - The delayed output feeds long_chirp_real/imag → chain # - The delayed output feeds ref_chirp_real/imag → chain
# #
# The purpose: the chain in the SYNTHESIS branch reads reference data # The purpose: the chain in the SYNTHESIS branch reads reference data
# via the long_chirp_real/imag ports DURING ST_FWD_FFT (while collecting # via the ref_chirp_real/imag ports DURING ST_FWD_FFT (while collecting
# input samples). The reference data needs to arrive LATENCY cycles # input samples). The reference data needs to arrive LATENCY cycles
# after the first mem_request, where LATENCY accounts for: # after the first mem_request, where LATENCY accounts for:
# - The fft_engine pipeline latency from input to output # - The fft_engine pipeline latency from input to output
File diff suppressed because it is too large Load Diff
@@ -18,10 +18,8 @@ module tb_matched_filter_processing_chain;
reg [15:0] adc_data_q; reg [15:0] adc_data_q;
reg adc_valid; reg adc_valid;
reg [5:0] chirp_counter; reg [5:0] chirp_counter;
reg [15:0] long_chirp_real; reg [15:0] ref_chirp_real;
reg [15:0] long_chirp_imag; reg [15:0] ref_chirp_imag;
reg [15:0] short_chirp_real;
reg [15:0] short_chirp_imag;
wire signed [15:0] range_profile_i; wire signed [15:0] range_profile_i;
wire signed [15:0] range_profile_q; wire signed [15:0] range_profile_q;
wire range_profile_valid; wire range_profile_valid;
@@ -83,10 +81,8 @@ module tb_matched_filter_processing_chain;
.adc_data_q (adc_data_q), .adc_data_q (adc_data_q),
.adc_valid (adc_valid), .adc_valid (adc_valid),
.chirp_counter (chirp_counter), .chirp_counter (chirp_counter),
.long_chirp_real (long_chirp_real), .ref_chirp_real (ref_chirp_real),
.long_chirp_imag (long_chirp_imag), .ref_chirp_imag (ref_chirp_imag),
.short_chirp_real (short_chirp_real),
.short_chirp_imag (short_chirp_imag),
.range_profile_i (range_profile_i), .range_profile_i (range_profile_i),
.range_profile_q (range_profile_q), .range_profile_q (range_profile_q),
.range_profile_valid (range_profile_valid), .range_profile_valid (range_profile_valid),
@@ -133,10 +129,8 @@ module tb_matched_filter_processing_chain;
adc_data_i = 16'd0; adc_data_i = 16'd0;
adc_data_q = 16'd0; adc_data_q = 16'd0;
chirp_counter = 6'd0; chirp_counter = 6'd0;
long_chirp_real = 16'd0; ref_chirp_real = 16'd0;
long_chirp_imag = 16'd0; ref_chirp_imag = 16'd0;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
cap_enable = 0; cap_enable = 0;
cap_count = 0; cap_count = 0;
cap_max_abs = 0; cap_max_abs = 0;
@@ -168,10 +162,8 @@ module tb_matched_filter_processing_chain;
angle = 6.28318530718 * tone_bin * k / (1.0 * FFT_SIZE); angle = 6.28318530718 * tone_bin * k / (1.0 * FFT_SIZE);
adc_data_i = $rtoi(8000.0 * $cos(angle)); adc_data_i = $rtoi(8000.0 * $cos(angle));
adc_data_q = $rtoi(8000.0 * $sin(angle)); adc_data_q = $rtoi(8000.0 * $sin(angle));
long_chirp_real = $rtoi(8000.0 * $cos(angle)); ref_chirp_real = $rtoi(8000.0 * $cos(angle));
long_chirp_imag = $rtoi(8000.0 * $sin(angle)); ref_chirp_imag = $rtoi(8000.0 * $sin(angle));
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); @(posedge clk);
#1; #1;
@@ -187,10 +179,8 @@ module tb_matched_filter_processing_chain;
for (k = 0; k < FFT_SIZE; k = k + 1) begin for (k = 0; k < FFT_SIZE; k = k + 1) begin
adc_data_i = 16'sh1000; adc_data_i = 16'sh1000;
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh1000; ref_chirp_real = 16'sh1000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); @(posedge clk);
#1; #1;
@@ -233,10 +223,8 @@ module tb_matched_filter_processing_chain;
for (k = 0; k < FFT_SIZE; k = k + 1) begin for (k = 0; k < FFT_SIZE; k = k + 1) begin
adc_data_i = gold_sig_i[k]; adc_data_i = gold_sig_i[k];
adc_data_q = gold_sig_q[k]; adc_data_q = gold_sig_q[k];
long_chirp_real = gold_ref_i[k]; ref_chirp_real = gold_ref_i[k];
long_chirp_imag = gold_ref_q[k]; ref_chirp_imag = gold_ref_q[k];
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); @(posedge clk);
#1; #1;
@@ -374,10 +362,8 @@ module tb_matched_filter_processing_chain;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'd0; adc_data_i = 16'd0;
adc_data_q = 16'd0; adc_data_q = 16'd0;
long_chirp_real = 16'd0; ref_chirp_real = 16'd0;
long_chirp_imag = 16'd0; ref_chirp_imag = 16'd0;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -449,10 +435,8 @@ module tb_matched_filter_processing_chain;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = $rtoi(8000.0 * $cos(6.28318530718 * 5 * i / 1024.0)); adc_data_i = $rtoi(8000.0 * $cos(6.28318530718 * 5 * i / 1024.0));
adc_data_q = $rtoi(8000.0 * $sin(6.28318530718 * 5 * i / 1024.0)); adc_data_q = $rtoi(8000.0 * $sin(6.28318530718 * 5 * i / 1024.0));
long_chirp_real = $rtoi(8000.0 * $cos(6.28318530718 * 10 * i / 1024.0)); ref_chirp_real = $rtoi(8000.0 * $cos(6.28318530718 * 10 * i / 1024.0));
long_chirp_imag = $rtoi(8000.0 * $sin(6.28318530718 * 10 * i / 1024.0)); ref_chirp_imag = $rtoi(8000.0 * $sin(6.28318530718 * 10 * i / 1024.0));
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -568,10 +552,8 @@ module tb_matched_filter_processing_chain;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'sh7FFF; adc_data_i = 16'sh7FFF;
adc_data_q = 16'sh7FFF; adc_data_q = 16'sh7FFF;
long_chirp_real = 16'sh7FFF; ref_chirp_real = 16'sh7FFF;
long_chirp_imag = 16'sh7FFF; ref_chirp_imag = 16'sh7FFF;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -589,10 +571,8 @@ module tb_matched_filter_processing_chain;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'sh8000; adc_data_i = 16'sh8000;
adc_data_q = 16'sh8000; adc_data_q = 16'sh8000;
long_chirp_real = 16'sh8000; ref_chirp_real = 16'sh8000;
long_chirp_imag = 16'sh8000; ref_chirp_imag = 16'sh8000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -611,16 +591,14 @@ module tb_matched_filter_processing_chain;
if (i % 2 == 0) begin if (i % 2 == 0) begin
adc_data_i = 16'sh7FFF; adc_data_i = 16'sh7FFF;
adc_data_q = 16'sh7FFF; adc_data_q = 16'sh7FFF;
long_chirp_real = 16'sh7FFF; ref_chirp_real = 16'sh7FFF;
long_chirp_imag = 16'sh7FFF; ref_chirp_imag = 16'sh7FFF;
end else begin end else begin
adc_data_i = 16'sh8000; adc_data_i = 16'sh8000;
adc_data_q = 16'sh8000; adc_data_q = 16'sh8000;
long_chirp_real = 16'sh8000; ref_chirp_real = 16'sh8000;
long_chirp_imag = 16'sh8000; ref_chirp_imag = 16'sh8000;
end end
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -641,10 +619,8 @@ module tb_matched_filter_processing_chain;
for (i = 0; i < 512; i = i + 1) begin for (i = 0; i < 512; i = i + 1) begin
adc_data_i = 16'sh1000; adc_data_i = 16'sh1000;
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh1000; ref_chirp_real = 16'sh1000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -683,10 +659,8 @@ module tb_matched_filter_processing_chain;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'sh1000; adc_data_i = 16'sh1000;
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh1000; ref_chirp_real = 16'sh1000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
+24 -46
View File
@@ -28,10 +28,8 @@ module tb_mf_chain_synth;
reg [15:0] adc_data_q; reg [15:0] adc_data_q;
reg adc_valid; reg adc_valid;
reg [5:0] chirp_counter; reg [5:0] chirp_counter;
reg [15:0] long_chirp_real; reg [15:0] ref_chirp_real;
reg [15:0] long_chirp_imag; reg [15:0] ref_chirp_imag;
reg [15:0] short_chirp_real;
reg [15:0] short_chirp_imag;
wire signed [15:0] range_profile_i; wire signed [15:0] range_profile_i;
wire signed [15:0] range_profile_q; wire signed [15:0] range_profile_q;
wire range_profile_valid; wire range_profile_valid;
@@ -78,10 +76,8 @@ module tb_mf_chain_synth;
.adc_data_q (adc_data_q), .adc_data_q (adc_data_q),
.adc_valid (adc_valid), .adc_valid (adc_valid),
.chirp_counter (chirp_counter), .chirp_counter (chirp_counter),
.long_chirp_real (long_chirp_real), .ref_chirp_real (ref_chirp_real),
.long_chirp_imag (long_chirp_imag), .ref_chirp_imag (ref_chirp_imag),
.short_chirp_real (short_chirp_real),
.short_chirp_imag (short_chirp_imag),
.range_profile_i (range_profile_i), .range_profile_i (range_profile_i),
.range_profile_q (range_profile_q), .range_profile_q (range_profile_q),
.range_profile_valid (range_profile_valid), .range_profile_valid (range_profile_valid),
@@ -130,10 +126,8 @@ module tb_mf_chain_synth;
adc_data_i = 16'd0; adc_data_i = 16'd0;
adc_data_q = 16'd0; adc_data_q = 16'd0;
chirp_counter = 6'd0; chirp_counter = 6'd0;
long_chirp_real = 16'd0; ref_chirp_real = 16'd0;
long_chirp_imag = 16'd0; ref_chirp_imag = 16'd0;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
cap_enable = 0; cap_enable = 0;
cap_count = 0; cap_count = 0;
cap_max_abs = 0; cap_max_abs = 0;
@@ -177,10 +171,8 @@ module tb_mf_chain_synth;
for (k = 0; k < FFT_SIZE; k = k + 1) begin for (k = 0; k < FFT_SIZE; k = k + 1) begin
adc_data_i = 16'sh1000; // +4096 adc_data_i = 16'sh1000; // +4096
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh1000; ref_chirp_real = 16'sh1000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); @(posedge clk);
#1; #1;
@@ -199,10 +191,8 @@ module tb_mf_chain_synth;
angle = 6.28318530718 * tone_bin * k / (1.0 * FFT_SIZE); angle = 6.28318530718 * tone_bin * k / (1.0 * FFT_SIZE);
adc_data_i = $rtoi(8000.0 * $cos(angle)); adc_data_i = $rtoi(8000.0 * $cos(angle));
adc_data_q = $rtoi(8000.0 * $sin(angle)); adc_data_q = $rtoi(8000.0 * $sin(angle));
long_chirp_real = $rtoi(8000.0 * $cos(angle)); ref_chirp_real = $rtoi(8000.0 * $cos(angle));
long_chirp_imag = $rtoi(8000.0 * $sin(angle)); ref_chirp_imag = $rtoi(8000.0 * $sin(angle));
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); @(posedge clk);
#1; #1;
@@ -219,16 +209,14 @@ module tb_mf_chain_synth;
if (k == 0) begin if (k == 0) begin
adc_data_i = 16'sh4000; // 0.5 in Q15 adc_data_i = 16'sh4000; // 0.5 in Q15
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh4000; ref_chirp_real = 16'sh4000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
end else begin end else begin
adc_data_i = 16'sh0000; adc_data_i = 16'sh0000;
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh0000; ref_chirp_real = 16'sh0000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
end end
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); @(posedge clk);
#1; #1;
@@ -309,10 +297,8 @@ module tb_mf_chain_synth;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'd0; adc_data_i = 16'd0;
adc_data_q = 16'd0; adc_data_q = 16'd0;
long_chirp_real = 16'd0; ref_chirp_real = 16'd0;
long_chirp_imag = 16'd0; ref_chirp_imag = 16'd0;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -379,10 +365,8 @@ module tb_mf_chain_synth;
for (i = 0; i < 512; i = i + 1) begin for (i = 0; i < 512; i = i + 1) begin
adc_data_i = 16'sh1000; adc_data_i = 16'sh1000;
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh1000; ref_chirp_real = 16'sh1000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -439,10 +423,8 @@ module tb_mf_chain_synth;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = $rtoi(8000.0 * $cos(6.28318530718 * 5 * i / 1024.0)); adc_data_i = $rtoi(8000.0 * $cos(6.28318530718 * 5 * i / 1024.0));
adc_data_q = $rtoi(8000.0 * $sin(6.28318530718 * 5 * i / 1024.0)); adc_data_q = $rtoi(8000.0 * $sin(6.28318530718 * 5 * i / 1024.0));
long_chirp_real = $rtoi(8000.0 * $cos(6.28318530718 * 10 * i / 1024.0)); ref_chirp_real = $rtoi(8000.0 * $cos(6.28318530718 * 10 * i / 1024.0));
long_chirp_imag = $rtoi(8000.0 * $sin(6.28318530718 * 10 * i / 1024.0)); ref_chirp_imag = $rtoi(8000.0 * $sin(6.28318530718 * 10 * i / 1024.0));
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -469,10 +451,8 @@ module tb_mf_chain_synth;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'sh7FFF; adc_data_i = 16'sh7FFF;
adc_data_q = 16'sh7FFF; adc_data_q = 16'sh7FFF;
long_chirp_real = 16'sh7FFF; ref_chirp_real = 16'sh7FFF;
long_chirp_imag = 16'sh7FFF; ref_chirp_imag = 16'sh7FFF;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
end end
@@ -495,10 +475,8 @@ module tb_mf_chain_synth;
for (i = 0; i < FFT_SIZE; i = i + 1) begin for (i = 0; i < FFT_SIZE; i = i + 1) begin
adc_data_i = 16'sh1000; adc_data_i = 16'sh1000;
adc_data_q = 16'sh0000; adc_data_q = 16'sh0000;
long_chirp_real = 16'sh1000; ref_chirp_real = 16'sh1000;
long_chirp_imag = 16'sh0000; ref_chirp_imag = 16'sh0000;
short_chirp_real = 16'd0;
short_chirp_imag = 16'd0;
adc_valid = 1'b1; adc_valid = 1'b1;
@(posedge clk); #1; @(posedge clk); #1;
+10 -18
View File
@@ -88,10 +88,8 @@ reg [15:0] adc_data_i;
reg [15:0] adc_data_q; reg [15:0] adc_data_q;
reg adc_valid; reg adc_valid;
reg [5:0] chirp_counter; reg [5:0] chirp_counter;
reg [15:0] long_chirp_real; reg [15:0] ref_chirp_real;
reg [15:0] long_chirp_imag; reg [15:0] ref_chirp_imag;
reg [15:0] short_chirp_real;
reg [15:0] short_chirp_imag;
wire signed [15:0] range_profile_i; wire signed [15:0] range_profile_i;
wire signed [15:0] range_profile_q; wire signed [15:0] range_profile_q;
@@ -108,10 +106,8 @@ matched_filter_processing_chain dut (
.adc_data_q(adc_data_q), .adc_data_q(adc_data_q),
.adc_valid(adc_valid), .adc_valid(adc_valid),
.chirp_counter(chirp_counter), .chirp_counter(chirp_counter),
.long_chirp_real(long_chirp_real), .ref_chirp_real(ref_chirp_real),
.long_chirp_imag(long_chirp_imag), .ref_chirp_imag(ref_chirp_imag),
.short_chirp_real(short_chirp_real),
.short_chirp_imag(short_chirp_imag),
.range_profile_i(range_profile_i), .range_profile_i(range_profile_i),
.range_profile_q(range_profile_q), .range_profile_q(range_profile_q),
.range_profile_valid(range_profile_valid), .range_profile_valid(range_profile_valid),
@@ -157,10 +153,8 @@ task apply_reset;
adc_data_q <= 16'd0; adc_data_q <= 16'd0;
adc_valid <= 1'b0; adc_valid <= 1'b0;
chirp_counter <= 6'd0; chirp_counter <= 6'd0;
long_chirp_real <= 16'd0; ref_chirp_real <= 16'd0;
long_chirp_imag <= 16'd0; ref_chirp_imag <= 16'd0;
short_chirp_real <= 16'd0;
short_chirp_imag <= 16'd0;
repeat(4) @(posedge clk); repeat(4) @(posedge clk);
reset_n <= 1'b1; reset_n <= 1'b1;
@(posedge clk); @(posedge clk);
@@ -201,18 +195,16 @@ initial begin
@(posedge clk); @(posedge clk);
adc_data_i <= sig_mem_i[i]; adc_data_i <= sig_mem_i[i];
adc_data_q <= sig_mem_q[i]; adc_data_q <= sig_mem_q[i];
long_chirp_real <= ref_mem_i[i]; ref_chirp_real <= ref_mem_i[i];
long_chirp_imag <= ref_mem_q[i]; ref_chirp_imag <= ref_mem_q[i];
short_chirp_real <= 16'd0;
short_chirp_imag <= 16'd0;
adc_valid <= 1'b1; adc_valid <= 1'b1;
end end
@(posedge clk); @(posedge clk);
adc_valid <= 1'b0; adc_valid <= 1'b0;
adc_data_i <= 16'd0; adc_data_i <= 16'd0;
adc_data_q <= 16'd0; adc_data_q <= 16'd0;
long_chirp_real <= 16'd0; ref_chirp_real <= 16'd0;
long_chirp_imag <= 16'd0; ref_chirp_imag <= 16'd0;
$display("All samples fed. Waiting for processing..."); $display("All samples fed. Waiting for processing...");
+10 -16
View File
@@ -56,10 +56,8 @@ reg [5:0] chirp_counter;
reg mc_new_chirp; reg mc_new_chirp;
reg mc_new_elevation; reg mc_new_elevation;
reg mc_new_azimuth; reg mc_new_azimuth;
reg [15:0] long_chirp_real; reg [15:0] ref_chirp_real;
reg [15:0] long_chirp_imag; reg [15:0] ref_chirp_imag;
reg [15:0] short_chirp_real;
reg [15:0] short_chirp_imag;
reg mem_ready; reg mem_ready;
wire signed [15:0] pc_i_w; wire signed [15:0] pc_i_w;
@@ -84,10 +82,8 @@ matched_filter_multi_segment dut (
.mc_new_chirp(mc_new_chirp), .mc_new_chirp(mc_new_chirp),
.mc_new_elevation(mc_new_elevation), .mc_new_elevation(mc_new_elevation),
.mc_new_azimuth(mc_new_azimuth), .mc_new_azimuth(mc_new_azimuth),
.long_chirp_real(long_chirp_real), .ref_chirp_real(ref_chirp_real),
.long_chirp_imag(long_chirp_imag), .ref_chirp_imag(ref_chirp_imag),
.short_chirp_real(short_chirp_real),
.short_chirp_imag(short_chirp_imag),
.segment_request(segment_request), .segment_request(segment_request),
.sample_addr_out(sample_addr_out), .sample_addr_out(sample_addr_out),
.mem_request(mem_request), .mem_request(mem_request),
@@ -123,11 +119,11 @@ end
always @(posedge clk) begin always @(posedge clk) begin
if (mem_request) begin if (mem_request) begin
if (use_long_chirp) begin if (use_long_chirp) begin
long_chirp_real <= ref_mem_i[{segment_request, sample_addr_out}]; ref_chirp_real <= ref_mem_i[{segment_request, sample_addr_out}];
long_chirp_imag <= ref_mem_q[{segment_request, sample_addr_out}]; ref_chirp_imag <= ref_mem_q[{segment_request, sample_addr_out}];
end else begin end else begin
short_chirp_real <= ref_mem_i[sample_addr_out]; ref_chirp_real <= ref_mem_i[sample_addr_out];
short_chirp_imag <= ref_mem_q[sample_addr_out]; ref_chirp_imag <= ref_mem_q[sample_addr_out];
end end
mem_ready <= 1'b1; mem_ready <= 1'b1;
end else begin end else begin
@@ -176,10 +172,8 @@ task apply_reset;
mc_new_chirp <= 1'b0; mc_new_chirp <= 1'b0;
mc_new_elevation <= 1'b0; mc_new_elevation <= 1'b0;
mc_new_azimuth <= 1'b0; mc_new_azimuth <= 1'b0;
long_chirp_real <= 16'd0; ref_chirp_real <= 16'd0;
long_chirp_imag <= 16'd0; ref_chirp_imag <= 16'd0;
short_chirp_real <= 16'd0;
short_chirp_imag <= 16'd0;
mem_ready <= 1'b0; mem_ready <= 1'b0;
repeat(10) @(posedge clk); repeat(10) @(posedge clk);
reset_n <= 1'b1; reset_n <= 1'b1;
@@ -7,43 +7,21 @@
// -> matched_filter_multi_segment -> range_bin_decimator // -> matched_filter_multi_segment -> range_bin_decimator
// -> doppler_processor_optimized -> doppler_output // -> doppler_processor_optimized -> doppler_output
// //
// ============================================================================
// TWO MODES (compile-time define):
//
// 1. GOLDEN_GENERATE mode (-DGOLDEN_GENERATE):
// Dumps all Doppler output samples to golden reference files.
// Run once on known-good RTL:
// iverilog -g2001 -DSIMULATION -DGOLDEN_GENERATE -o tb_golden_gen.vvp \
// <src files> tb/tb_radar_receiver_final.v
// mkdir -p tb/golden
// vvp tb_golden_gen.vvp
//
// 2. Default mode (no GOLDEN_GENERATE):
// Loads golden files, compares each Doppler output against reference,
// and runs physics-based bounds checks.
// iverilog -g2001 -DSIMULATION -o tb_radar_receiver_final.vvp \
// <src files> tb/tb_radar_receiver_final.v
// vvp tb_radar_receiver_final.vvp
//
// PREREQUISITES:
// - The directory tb/golden/ must exist before running either mode.
// Create it with: mkdir -p tb/golden
//
// TAP POINTS: // TAP POINTS:
// Tap 1 (DDC output) - bounds checking only (CDC jitter -> non-deterministic) // Tap 1 (DDC output) - bounds checking only (CDC jitter -> non-deterministic)
// Signals: dut.ddc_out_i [17:0], dut.ddc_out_q [17:0], dut.ddc_valid_i // Signals: dut.ddc_out_i [17:0], dut.ddc_out_q [17:0], dut.ddc_valid_i
// Tap 2 (Doppler output) - golden compared (deterministic after MF buffering) // Tap 2 (Doppler output) - structural + bounds checks (deterministic after MF)
// Signals: doppler_output[31:0], doppler_valid, doppler_bin[4:0], // Signals: doppler_output[31:0], doppler_valid, doppler_bin[4:0],
// range_bin_out[5:0] // range_bin_out[5:0]
// //
// Golden file: tb/golden/golden_doppler.mem
// 2048 entries of 32-bit hex, indexed by range_bin*32 + doppler_bin
//
// Strategy: // Strategy:
// - Uses behavioral stub for ad9484_interface_400m (no Xilinx primitives) // - Uses behavioral stub for ad9484_interface_400m (no Xilinx primitives)
// - Overrides radar_mode_controller timing params for fast simulation // - Overrides radar_mode_controller timing params for fast simulation
// - Feeds 120 MHz tone at ADC input (IF frequency -> DDC passband) // - Feeds 120 MHz tone at ADC input (IF frequency -> DDC passband)
// - Verifies structural correctness + golden comparison + bounds checks // - Verifies structural correctness (S1-S10) + physics bounds checks (B1-B5)
// - Bit-accurate golden comparison is done by the MF co-sim tests
// (tb_mf_cosim.v + compare_mf.py) and full-chain co-sim tests
// (tb_doppler_realdata.v, tb_fullchain_realdata.v), not here.
// //
// Convention: check task, VCD dump, CSV output, pass/fail summary // Convention: check task, VCD dump, CSV output, pass/fail summary
// ============================================================================ // ============================================================================
@@ -194,46 +172,6 @@ task check;
end end
endtask endtask
// ============================================================================
// GOLDEN MEMORY DECLARATIONS AND LOAD/STORE LOGIC
// ============================================================================
localparam GOLDEN_ENTRIES = 2048; // 64 range bins * 32 Doppler bins
localparam GOLDEN_TOLERANCE = 2; // +/- 2 LSB tolerance for comparison
reg [31:0] golden_doppler [0:2047];
// -- Golden comparison tracking --
integer golden_match_count;
integer golden_mismatch_count;
integer golden_max_err_i;
integer golden_max_err_q;
integer golden_compare_count;
`ifdef GOLDEN_GENERATE
// In generate mode, we just initialize the array to X/0
// and fill it as outputs arrive
integer gi;
initial begin
for (gi = 0; gi < GOLDEN_ENTRIES; gi = gi + 1)
golden_doppler[gi] = 32'd0;
golden_match_count = 0;
golden_mismatch_count = 0;
golden_max_err_i = 0;
golden_max_err_q = 0;
golden_compare_count = 0;
end
`else
// In comparison mode, load the golden reference
initial begin
$readmemh("tb/golden/golden_doppler.mem", golden_doppler);
golden_match_count = 0;
golden_mismatch_count = 0;
golden_max_err_i = 0;
golden_max_err_q = 0;
golden_compare_count = 0;
end
`endif
// ============================================================================ // ============================================================================
// DDC ENERGY ACCUMULATOR (Bounds Check B1) // DDC ENERGY ACCUMULATOR (Bounds Check B1)
// ============================================================================ // ============================================================================
@@ -257,7 +195,7 @@ always @(posedge clk_100m) begin
end end
// ============================================================================ // ============================================================================
// DOPPLER OUTPUT CAPTURE, GOLDEN COMPARISON, AND DUPLICATE DETECTION // DOPPLER OUTPUT CAPTURE AND DUPLICATE DETECTION
// ============================================================================ // ============================================================================
integer doppler_output_count; integer doppler_output_count;
integer doppler_frame_count; integer doppler_frame_count;
@@ -311,13 +249,6 @@ end
// Monitor doppler outputs -- only after reset released // Monitor doppler outputs -- only after reset released
always @(posedge clk_100m) begin always @(posedge clk_100m) begin
if (reset_n && doppler_valid) begin : doppler_capture_block if (reset_n && doppler_valid) begin : doppler_capture_block
// ---- Signed intermediates for golden comparison ----
reg signed [16:0] actual_i, actual_q;
reg signed [16:0] expected_i, expected_q;
reg signed [16:0] err_i_signed, err_q_signed;
integer abs_err_i, abs_err_q;
integer gidx;
reg [31:0] expected_val;
// ---- Magnitude intermediates for B2 ---- // ---- Magnitude intermediates for B2 ----
reg signed [16:0] mag_i_signed, mag_q_signed; reg signed [16:0] mag_i_signed, mag_q_signed;
integer mag_i, mag_q, mag_sum; integer mag_i, mag_q, mag_sum;
@@ -350,9 +281,6 @@ always @(posedge clk_100m) begin
if ((doppler_output_count % 256) == 0) if ((doppler_output_count % 256) == 0)
$display("[INFO] %0d doppler outputs so far (t=%0t)", doppler_output_count, $time); $display("[INFO] %0d doppler outputs so far (t=%0t)", doppler_output_count, $time);
// ---- Golden index computation ----
gidx = range_bin_out * 32 + doppler_bin;
// ---- Duplicate detection (B5) ---- // ---- Duplicate detection (B5) ----
if (range_bin_out < 64 && doppler_bin < 32) begin if (range_bin_out < 64 && doppler_bin < 32) begin
if (index_seen[range_bin_out][doppler_bin]) begin if (index_seen[range_bin_out][doppler_bin]) begin
@@ -376,44 +304,6 @@ always @(posedge clk_100m) begin
if (mag_sum > peak_dbin_mag[range_bin_out]) if (mag_sum > peak_dbin_mag[range_bin_out])
peak_dbin_mag[range_bin_out] = mag_sum; peak_dbin_mag[range_bin_out] = mag_sum;
end end
`ifdef GOLDEN_GENERATE
// ---- GOLDEN GENERATE: store output ----
if (gidx < GOLDEN_ENTRIES)
golden_doppler[gidx] = doppler_output;
`else
// ---- GOLDEN COMPARE: check against reference ----
if (gidx < GOLDEN_ENTRIES) begin
expected_val = golden_doppler[gidx];
actual_i = $signed(doppler_output[15:0]);
actual_q = $signed(doppler_output[31:16]);
expected_i = $signed(expected_val[15:0]);
expected_q = $signed(expected_val[31:16]);
err_i_signed = actual_i - expected_i;
err_q_signed = actual_q - expected_q;
abs_err_i = (err_i_signed < 0) ? -err_i_signed : err_i_signed;
abs_err_q = (err_q_signed < 0) ? -err_q_signed : err_q_signed;
golden_compare_count = golden_compare_count + 1;
if (abs_err_i > golden_max_err_i) golden_max_err_i = abs_err_i;
if (abs_err_q > golden_max_err_q) golden_max_err_q = abs_err_q;
if (abs_err_i <= GOLDEN_TOLERANCE && abs_err_q <= GOLDEN_TOLERANCE) begin
golden_match_count = golden_match_count + 1;
end else begin
golden_mismatch_count = golden_mismatch_count + 1;
if (golden_mismatch_count <= 20)
$display("[MISMATCH] idx=%0d rbin=%0d dbin=%0d actual=%08h expected=%08h err_i=%0d err_q=%0d",
gidx, range_bin_out, doppler_bin,
doppler_output, expected_val,
abs_err_i, abs_err_q);
end
end
`endif
end end
// Track frame completions via doppler_proc -- only after reset // Track frame completions via doppler_proc -- only after reset
@@ -556,13 +446,6 @@ initial begin
end end
end end
// ---- DUMP GOLDEN FILE (generate mode only) ----
`ifdef GOLDEN_GENERATE
$writememh("tb/golden/golden_doppler.mem", golden_doppler);
$display("[GOLDEN_GENERATE] Wrote tb/golden/golden_doppler.mem (%0d entries captured)",
doppler_output_count);
`endif
// ================================================================ // ================================================================
// RUN CHECKS // RUN CHECKS
// ================================================================ // ================================================================
@@ -649,33 +532,7 @@ initial begin
"S10: DDC produced substantial output (>100 valid samples)"); "S10: DDC produced substantial output (>100 valid samples)");
// ================================================================ // ================================================================
// GOLDEN COMPARISON REPORT // BOUNDS CHECKS
// ================================================================
`ifdef GOLDEN_GENERATE
$display("");
$display("Golden comparison: SKIPPED (GOLDEN_GENERATE mode)");
$display(" Wrote golden reference with %0d Doppler samples", doppler_output_count);
`else
$display("");
$display("------------------------------------------------------------");
$display("GOLDEN COMPARISON (tolerance=%0d LSB)", GOLDEN_TOLERANCE);
$display("------------------------------------------------------------");
$display("Golden comparison: %0d/%0d match (tolerance=%0d LSB)",
golden_match_count, golden_compare_count, GOLDEN_TOLERANCE);
$display(" Mismatches: %0d (I-ch max_err=%0d, Q-ch max_err=%0d)",
golden_mismatch_count, golden_max_err_i, golden_max_err_q);
// CHECK G1: All golden comparisons match
if (golden_compare_count > 0) begin
check(golden_mismatch_count == 0,
"G1: All Doppler outputs match golden reference within tolerance");
end else begin
check(0, "G1: All Doppler outputs match golden reference (NO COMPARISONS)");
end
`endif
// ================================================================
// BOUNDS CHECKS (active in both modes)
// ================================================================ // ================================================================
$display(""); $display("");
$display("------------------------------------------------------------"); $display("------------------------------------------------------------");
@@ -748,16 +605,8 @@ initial begin
// ================================================================ // ================================================================
$display(""); $display("");
$display("============================================================"); $display("============================================================");
$display("INTEGRATION TEST -- GOLDEN COMPARISON + BOUNDS"); $display("INTEGRATION TEST -- STRUCTURAL + BOUNDS");
$display("============================================================"); $display("============================================================");
`ifdef GOLDEN_GENERATE
$display("Mode: GOLDEN_GENERATE (reference dump, comparison skipped)");
`else
$display("Golden comparison: %0d/%0d match (tolerance=%0d LSB)",
golden_match_count, golden_compare_count, GOLDEN_TOLERANCE);
$display(" Mismatches: %0d (I-ch max_err=%0d, Q-ch max_err=%0d)",
golden_mismatch_count, golden_max_err_i, golden_max_err_q);
`endif
$display("Bounds checks:"); $display("Bounds checks:");
$display(" B1: DDC RMS energy in range [%0d, %0d]", $display(" B1: DDC RMS energy in range [%0d, %0d]",
(ddc_energy_acc > 0) ? 1 : 0, DDC_MAX_ENERGY); (ddc_energy_acc > 0) ? 1 : 0, DDC_MAX_ENERGY);
+5 -5
View File
@@ -108,7 +108,7 @@ class GPSData:
@dataclass @dataclass
class RadarSettings: class RadarSettings:
"""Radar system configuration""" """Radar system configuration"""
system_frequency: float = 10e9 # Hz system_frequency: float = 10.5e9 # Hz (PLFM TX LO)
chirp_duration_1: float = 30e-6 # Long chirp duration (s) chirp_duration_1: float = 30e-6 # Long chirp duration (s)
chirp_duration_2: float = 0.5e-6 # Short chirp duration (s) chirp_duration_2: float = 0.5e-6 # Short chirp duration (s)
chirps_per_position: int = 32 chirps_per_position: int = 32
@@ -116,8 +116,8 @@ class RadarSettings:
freq_max: float = 30e6 # Hz freq_max: float = 30e6 # Hz
prf1: float = 1000 # PRF 1 (Hz) prf1: float = 1000 # PRF 1 (Hz)
prf2: float = 2000 # PRF 2 (Hz) prf2: float = 2000 # PRF 2 (Hz)
max_distance: float = 50000 # Max detection range (m) max_distance: float = 1536 # Max detection range (m) -- 64 bins x 24 m
coverage_radius: float = 50000 # Map coverage radius (m) coverage_radius: float = 1536 # Map coverage radius (m)
class TileServer(Enum): class TileServer(Enum):
@@ -198,7 +198,7 @@ class RadarMapWidget(QWidget):
pitch=0.0 pitch=0.0
) )
self._targets: list[RadarTarget] = [] self._targets: list[RadarTarget] = []
self._coverage_radius = 50000 # meters self._coverage_radius = 1536 # meters (64 bins x 24 m, 3 km mode)
self._tile_server = TileServer.OPENSTREETMAP self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True self._show_coverage = True
self._show_trails = False self._show_trails = False
@@ -1088,7 +1088,7 @@ class TargetSimulator(QObject):
new_range = target.range - target.velocity * 0.5 # 0.5 second update new_range = target.range - target.velocity * 0.5 # 0.5 second update
# Check if target is still in range # Check if target is still in range
if new_range < 500 or new_range > 50000: if new_range < 50 or new_range > 1536:
# Remove this target and add a new one # Remove this target and add a new one
continue continue
+5 -5
View File
@@ -81,7 +81,7 @@ class RadarTarget:
@dataclass @dataclass
class RadarSettings: class RadarSettings:
system_frequency: float = 10e9 system_frequency: float = 10.5e9
chirp_duration_1: float = 30e-6 # Long chirp duration chirp_duration_1: float = 30e-6 # Long chirp duration
chirp_duration_2: float = 0.5e-6 # Short chirp duration chirp_duration_2: float = 0.5e-6 # Short chirp duration
chirps_per_position: int = 32 chirps_per_position: int = 32
@@ -89,8 +89,8 @@ class RadarSettings:
freq_max: float = 30e6 freq_max: float = 30e6
prf1: float = 1000 prf1: float = 1000
prf2: float = 2000 prf2: float = 2000
max_distance: float = 50000 max_distance: float = 1536
map_size: float = 50000 # Map size in meters map_size: float = 1536 # Map size in meters (64 bins x 24 m)
@dataclass @dataclass
@@ -1196,8 +1196,8 @@ class RadarGUI:
("Frequency Max (Hz):", "freq_max", 30e6), ("Frequency Max (Hz):", "freq_max", 30e6),
("PRF1 (Hz):", "prf1", 1000), ("PRF1 (Hz):", "prf1", 1000),
("PRF2 (Hz):", "prf2", 2000), ("PRF2 (Hz):", "prf2", 2000),
("Max Distance (m):", "max_distance", 50000), ("Max Distance (m):", "max_distance", 1536),
("Map Size (m):", "map_size", 50000), ("Map Size (m):", "map_size", 1536),
("Google Maps API Key:", "google_maps_api_key", "YOUR_GOOGLE_MAPS_API_KEY"), ("Google Maps API Key:", "google_maps_api_key", "YOUR_GOOGLE_MAPS_API_KEY"),
] ]
+5 -5
View File
@@ -77,7 +77,7 @@ class RadarTarget:
@dataclass @dataclass
class RadarSettings: class RadarSettings:
system_frequency: float = 10e9 system_frequency: float = 10.5e9
chirp_duration_1: float = 30e-6 # Long chirp duration chirp_duration_1: float = 30e-6 # Long chirp duration
chirp_duration_2: float = 0.5e-6 # Short chirp duration chirp_duration_2: float = 0.5e-6 # Short chirp duration
chirps_per_position: int = 32 chirps_per_position: int = 32
@@ -85,8 +85,8 @@ class RadarSettings:
freq_max: float = 30e6 freq_max: float = 30e6
prf1: float = 1000 prf1: float = 1000
prf2: float = 2000 prf2: float = 2000
max_distance: float = 50000 max_distance: float = 1536
map_size: float = 50000 # Map size in meters map_size: float = 1536 # Map size in meters (64 bins x 24 m)
@dataclass @dataclass
@@ -1254,8 +1254,8 @@ class RadarGUI:
("Frequency Max (Hz):", "freq_max", 30e6), ("Frequency Max (Hz):", "freq_max", 30e6),
("PRF1 (Hz):", "prf1", 1000), ("PRF1 (Hz):", "prf1", 1000),
("PRF2 (Hz):", "prf2", 2000), ("PRF2 (Hz):", "prf2", 2000),
("Max Distance (m):", "max_distance", 50000), ("Max Distance (m):", "max_distance", 1536),
("Map Size (m):", "map_size", 50000), ("Map Size (m):", "map_size", 1536),
] ]
self.settings_vars = {} self.settings_vars = {}
+5 -5
View File
@@ -64,7 +64,7 @@ class RadarTarget:
@dataclass @dataclass
class RadarSettings: class RadarSettings:
system_frequency: float = 10e9 system_frequency: float = 10.5e9
chirp_duration_1: float = 30e-6 # Long chirp duration chirp_duration_1: float = 30e-6 # Long chirp duration
chirp_duration_2: float = 0.5e-6 # Short chirp duration chirp_duration_2: float = 0.5e-6 # Short chirp duration
chirps_per_position: int = 32 chirps_per_position: int = 32
@@ -72,8 +72,8 @@ class RadarSettings:
freq_max: float = 30e6 freq_max: float = 30e6
prf1: float = 1000 prf1: float = 1000
prf2: float = 2000 prf2: float = 2000
max_distance: float = 50000 max_distance: float = 1536
map_size: float = 50000 # Map size in meters map_size: float = 1536 # Map size in meters (64 bins x 24 m)
@dataclass @dataclass
class GPSData: class GPSData:
@@ -1653,8 +1653,8 @@ class RadarGUI:
('Frequency Max (Hz):', 'freq_max', 30e6), ('Frequency Max (Hz):', 'freq_max', 30e6),
('PRF1 (Hz):', 'prf1', 1000), ('PRF1 (Hz):', 'prf1', 1000),
('PRF2 (Hz):', 'prf2', 2000), ('PRF2 (Hz):', 'prf2', 2000),
('Max Distance (m):', 'max_distance', 50000), ('Max Distance (m):', 'max_distance', 1536),
('Map Size (m):', 'map_size', 50000), ('Map Size (m):', 'map_size', 1536),
('Google Maps API Key:', 'google_maps_api_key', 'YOUR_GOOGLE_MAPS_API_KEY') ('Google Maps API Key:', 'google_maps_api_key', 'YOUR_GOOGLE_MAPS_API_KEY')
] ]
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
AERIS-10 Radar Dashboard AERIS-10 Radar Dashboard (Tkinter)
=================================================== ===================================================
Real-time visualization and control for the AERIS-10 phased-array radar Real-time visualization and control for the AERIS-10 phased-array radar
via FT2232H USB 2.0 interface. via FT2232H USB 2.0 interface.
@@ -14,36 +14,52 @@ Features:
0x01-0x04, 0x10-0x16, 0x20-0x27, 0x30-0x31, 0xFF) 0x01-0x04, 0x10-0x16, 0x20-0x27, 0x30-0x31, 0xFF)
- Configuration panel for all radar parameters - Configuration panel for all radar parameters
- HDF5 data recording for offline analysis - HDF5 data recording for offline analysis
- Replay mode (co-sim dirs, raw IQ .npy, HDF5) with transport controls
- Demo mode with synthetic moving targets
- Detected targets table
- Dual dispatch: FPGA controls route to SoftwareFPGA during replay
- Mock mode for development/testing without hardware - Mock mode for development/testing without hardware
Usage: Usage:
python radar_dashboard.py # Launch with mock data python GUI_V65_Tk.py # Launch with mock data
python radar_dashboard.py --live # Launch with FT2232H hardware python GUI_V65_Tk.py --live # Launch with FT2232H hardware
python radar_dashboard.py --record # Launch with HDF5 recording python GUI_V65_Tk.py --record # Launch with HDF5 recording
python GUI_V65_Tk.py --replay path/to/data # Auto-load replay
python GUI_V65_Tk.py --demo # Start in demo mode
""" """
import os import os
import math
import time import time
import copy
import queue import queue
import random
import logging import logging
import argparse import argparse
import threading import threading
import contextlib import contextlib
from collections import deque from collections import deque
from pathlib import Path
from typing import ClassVar
import numpy as np import numpy as np
import tkinter as tk try:
from tkinter import ttk, filedialog import tkinter as tk
from tkinter import ttk, filedialog
import matplotlib import matplotlib
matplotlib.use("TkAgg") matplotlib.use("TkAgg")
from matplotlib.figure import Figure from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
_HAS_GUI = True
except (ModuleNotFoundError, ImportError):
_HAS_GUI = False
# Import protocol layer (no GUI deps) # Import protocol layer (no GUI deps)
from radar_protocol import ( from radar_protocol import (
RadarProtocol, FT2232HConnection, ReplayConnection, RadarProtocol, FT2232HConnection,
DataRecorder, RadarAcquisition, DataRecorder, RadarAcquisition,
RadarFrame, StatusResponse, RadarFrame, StatusResponse,
NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH, NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH,
@@ -54,7 +70,7 @@ logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(message)s", format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
log = logging.getLogger("radar_dashboard") log = logging.getLogger("GUI_V65_Tk")
@@ -73,13 +89,306 @@ YELLOW = "#f9e2af"
SURFACE = "#313244" SURFACE = "#313244"
# ============================================================================
# Demo Target Simulator (Tkinter timer-based)
# ============================================================================
class DemoTarget:
"""Single simulated target with kinematics."""
__slots__ = ("azimuth", "classification", "id", "range_m", "snr", "velocity")
# Physical range grid: matched-filter receiver, 100 MSPS post-DDC, 16:1 decimation
# range_per_bin = c / (2 * 100e6) * 16 = 24.0 m
_RANGE_PER_BIN: float = (3e8 / (2 * 100e6)) * 16 # 24.0 m
_MAX_RANGE: float = _RANGE_PER_BIN * NUM_RANGE_BINS # 1536 m
def __init__(self, tid: int):
self.id = tid
self.range_m = random.uniform(20, self._MAX_RANGE - 20)
self.velocity = random.uniform(-10, 10)
self.azimuth = random.uniform(0, 360)
self.snr = random.uniform(10, 35)
self.classification = random.choice(
["aircraft", "drone", "bird", "unknown"])
def step(self) -> bool:
"""Advance one tick. Return False if target exits coverage."""
self.range_m -= self.velocity * 0.1
if self.range_m < 5 or self.range_m > self._MAX_RANGE:
return False
self.velocity = max(-20, min(20, self.velocity + random.uniform(-1, 1)))
self.azimuth = (self.azimuth + random.uniform(-0.5, 0.5)) % 360
self.snr = max(0, min(50, self.snr + random.uniform(-1, 1)))
return True
class DemoSimulator:
"""Timer-driven demo target generator for the Tkinter dashboard.
Produces synthetic ``RadarFrame`` objects and a target list each tick,
pushing them into the dashboard's ``frame_queue`` and ``_ui_queue``.
"""
def __init__(self, frame_queue: queue.Queue, ui_queue: queue.Queue,
root: tk.Tk, interval_ms: int = 500):
self._frame_queue = frame_queue
self._ui_queue = ui_queue
self._root = root
self._interval_ms = interval_ms
self._targets: list[DemoTarget] = []
self._next_id = 1
self._frame_number = 0
self._after_id: str | None = None
# Seed initial targets
for _ in range(8):
self._add_target()
def start(self):
self._tick()
def stop(self):
if self._after_id is not None:
self._root.after_cancel(self._after_id)
self._after_id = None
def add_random_target(self):
self._add_target()
def _add_target(self):
t = DemoTarget(self._next_id)
self._next_id += 1
self._targets.append(t)
def _tick(self):
updated: list[DemoTarget] = [t for t in self._targets if t.step()]
if len(updated) < 5 or (random.random() < 0.05 and len(updated) < 15):
self._add_target()
updated.append(self._targets[-1])
self._targets = updated
# Synthesize a RadarFrame with Gaussian blobs for each target
frame = self._make_frame(updated)
with contextlib.suppress(queue.Full):
self._frame_queue.put_nowait(frame)
# Post target info for the detected-targets treeview
target_dicts = [
{"id": t.id, "range_m": t.range_m, "velocity": t.velocity,
"azimuth": t.azimuth, "snr": t.snr, "class": t.classification}
for t in updated
]
self._ui_queue.put(("demo_targets", target_dicts))
self._after_id = self._root.after(self._interval_ms, self._tick)
def _make_frame(self, targets: list[DemoTarget]) -> RadarFrame:
"""Build a synthetic RadarFrame from target list."""
mag = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.float64)
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8)
# Range/Doppler scaling -- matched-filter receiver, 100 MSPS, 16:1 decimation
range_per_bin = (3e8 / (2 * 100e6)) * 16 # 24.0 m/bin
max_range = range_per_bin * NUM_RANGE_BINS
vel_per_bin = 2.67 # m/s per Doppler bin (lam/(2*32*167us))
for t in targets:
if t.range_m > max_range or t.range_m < 0:
continue
r_bin = int(t.range_m / range_per_bin)
d_bin = int((t.velocity / vel_per_bin) + NUM_DOPPLER_BINS / 2)
r_bin = max(0, min(NUM_RANGE_BINS - 1, r_bin))
d_bin = max(0, min(NUM_DOPPLER_BINS - 1, d_bin))
# Gaussian-ish blob
amplitude = 500 + t.snr * 200
for dr in range(-2, 3):
for dd in range(-1, 2):
ri = r_bin + dr
di = d_bin + dd
if 0 <= ri < NUM_RANGE_BINS and 0 <= di < NUM_DOPPLER_BINS:
w = math.exp(-0.5 * (dr**2 + dd**2))
mag[ri, di] += amplitude * w
if w > 0.5:
det[ri, di] = 1
rd_i = (mag * 0.5).astype(np.int16)
rd_q = np.zeros_like(rd_i)
rp = mag.max(axis=1)
self._frame_number += 1
return RadarFrame(
timestamp=time.time(),
range_doppler_i=rd_i,
range_doppler_q=rd_q,
magnitude=mag,
detections=det,
range_profile=rp,
detection_count=int(det.sum()),
frame_number=self._frame_number,
)
# ============================================================================
# Replay Controller (threading-based, reuses v7.ReplayEngine)
# ============================================================================
class _ReplayController:
"""Manages replay playback in a background thread for the Tkinter dashboard.
Imports ``ReplayEngine`` and ``SoftwareFPGA`` from ``v7`` lazily so
they are only required when replay is actually used.
"""
# Speed multiplier → frame interval in seconds
SPEED_MAP: ClassVar[dict[str, float]] = {
"0.25x": 0.400,
"0.5x": 0.200,
"1x": 0.100,
"2x": 0.050,
"5x": 0.020,
"10x": 0.010,
}
def __init__(self, frame_queue: queue.Queue, ui_queue: queue.Queue):
self._frame_queue = frame_queue
self._ui_queue = ui_queue
self._engine = None # lazy
self._software_fpga = None # lazy
self._thread: threading.Thread | None = None
self._play_event = threading.Event()
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._current_index = 0
self._last_emitted_index = -1
self._loop = False
self._frame_interval = 0.100 # 1x speed
def load(self, path: str) -> int:
"""Load replay data from path. Returns total frames or raises."""
from v7.replay import ReplayEngine, ReplayFormat, detect_format
from v7.software_fpga import SoftwareFPGA
fmt = detect_format(path)
if fmt == ReplayFormat.RAW_IQ_NPY:
self._software_fpga = SoftwareFPGA()
self._engine = ReplayEngine(path, software_fpga=self._software_fpga)
else:
self._engine = ReplayEngine(path)
self._current_index = 0
self._last_emitted_index = -1
self._stop_event.clear()
self._play_event.clear()
return self._engine.total_frames
@property
def total_frames(self) -> int:
return self._engine.total_frames if self._engine else 0
@property
def current_index(self) -> int:
return self._last_emitted_index if self._last_emitted_index >= 0 else 0
@property
def is_playing(self) -> bool:
return self._play_event.is_set()
@property
def software_fpga(self):
return self._software_fpga
def set_speed(self, label: str):
self._frame_interval = self.SPEED_MAP.get(label, 0.100)
def set_loop(self, loop: bool):
self._loop = loop
def play(self):
self._play_event.set()
with self._lock:
if self._current_index >= self.total_frames:
self._current_index = 0
self._ui_queue.put(("replay_state", "playing"))
if self._thread is None or not self._thread.is_alive():
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def pause(self):
self._play_event.clear()
self._ui_queue.put(("replay_state", "paused"))
def stop(self):
self._stop_event.set()
self._play_event.set() # unblock wait so thread exits promptly
with self._lock:
self._current_index = 0
self._last_emitted_index = -1
if self._thread is not None:
self._thread.join(timeout=2)
self._thread = None
self._play_event.clear()
self._ui_queue.put(("replay_state", "stopped"))
def close(self):
"""Stop playback and release underlying engine resources."""
self.stop()
if self._engine is not None:
self._engine.close()
self._engine = None
self._software_fpga = None
def seek(self, index: int):
with self._lock:
self._current_index = max(0, min(index, self.total_frames - 1))
self._emit_frame()
self._last_emitted_index = self._current_index
# Advance past the emitted frame so _run doesn't re-emit it
self._current_index += 1
def _run(self):
while not self._stop_event.is_set():
# Block until play or stop is signalled — no busy-sleep
self._play_event.wait()
if self._stop_event.is_set():
break
with self._lock:
if self._current_index >= self.total_frames:
if self._loop:
self._current_index = 0
else:
self._play_event.clear()
self._ui_queue.put(("replay_state", "paused"))
continue
self._emit_frame()
self._last_emitted_index = self._current_index
idx = self._current_index
self._current_index += 1
self._ui_queue.put(("replay_index", (idx, self.total_frames)))
time.sleep(self._frame_interval)
def _emit_frame(self):
"""Get current frame and push to queue. Must be called with lock held."""
if self._engine is None:
return
frame = self._engine.get_frame(self._current_index)
if frame is not None:
frame = copy.deepcopy(frame)
with contextlib.suppress(queue.Full):
self._frame_queue.put_nowait(frame)
class RadarDashboard: class RadarDashboard:
"""Main tkinter application: real-time radar visualization and control.""" """Main tkinter application: real-time radar visualization and control."""
UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh
# Radar parameters used for range-axis scaling. # Radar parameters used for range-axis scaling.
BANDWIDTH = 500e6 # Hz — chirp bandwidth # Matched-filter receiver: range_per_bin = c / (2 * fs_processing) * decimation
# = 3e8 / (2 * 100e6) * 16 = 24.0 m/bin
BANDWIDTH = 20e6 # Hz — chirp bandwidth (for display/info only)
C = 3e8 # m/s — speed of light C = 3e8 # m/s — speed of light
def __init__(self, root: tk.Tk, connection: FT2232HConnection, def __init__(self, root: tk.Tk, connection: FT2232HConnection,
@@ -93,7 +402,7 @@ class RadarDashboard:
self.root.geometry("1600x950") self.root.geometry("1600x950")
self.root.configure(bg=BG) self.root.configure(bg=BG)
# Frame queue (acquisition → display) # Frame queue (acquisition / replay / demo → display)
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8) self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
self._acq_thread: RadarAcquisition | None = None self._acq_thread: RadarAcquisition | None = None
@@ -126,6 +435,17 @@ class RadarDashboard:
self._agc_last_redraw: float = 0.0 # throttle chart redraws self._agc_last_redraw: float = 0.0 # throttle chart redraws
self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws
# Replay state
self._replay_ctrl: _ReplayController | None = None
self._replay_active = False
# Demo state
self._demo_sim: DemoSimulator | None = None
self._demo_active = False
# Detected targets (from demo or replay host-DSP)
self._detected_targets: list[dict] = []
self._build_ui() self._build_ui()
self._schedule_update() self._schedule_update()
@@ -171,40 +491,46 @@ class RadarDashboard:
self.btn_record = ttk.Button(top, text="Record", command=self._on_record) self.btn_record = ttk.Button(top, text="Record", command=self._on_record)
self.btn_record.pack(side="right", padx=4) self.btn_record.pack(side="right", padx=4)
self.btn_demo = ttk.Button(top, text="Start Demo",
command=self._toggle_demo)
self.btn_demo.pack(side="right", padx=4)
# -- Tabbed notebook layout -- # -- Tabbed notebook layout --
nb = ttk.Notebook(self.root) nb = ttk.Notebook(self.root)
nb.pack(fill="both", expand=True, padx=8, pady=8) nb.pack(fill="both", expand=True, padx=8, pady=8)
tab_display = ttk.Frame(nb) tab_display = ttk.Frame(nb)
tab_control = ttk.Frame(nb) tab_control = ttk.Frame(nb)
tab_replay = ttk.Frame(nb)
tab_agc = ttk.Frame(nb) tab_agc = ttk.Frame(nb)
tab_log = ttk.Frame(nb) tab_log = ttk.Frame(nb)
nb.add(tab_display, text=" Display ") nb.add(tab_display, text=" Display ")
nb.add(tab_control, text=" Control ") nb.add(tab_control, text=" Control ")
nb.add(tab_replay, text=" Replay ")
nb.add(tab_agc, text=" AGC Monitor ") nb.add(tab_agc, text=" AGC Monitor ")
nb.add(tab_log, text=" Log ") nb.add(tab_log, text=" Log ")
self._build_display_tab(tab_display) self._build_display_tab(tab_display)
self._build_control_tab(tab_control) self._build_control_tab(tab_control)
self._build_replay_tab(tab_replay)
self._build_agc_tab(tab_agc) self._build_agc_tab(tab_agc)
self._build_log_tab(tab_log) self._build_log_tab(tab_log)
def _build_display_tab(self, parent): def _build_display_tab(self, parent):
# Compute physical axis limits # Compute physical axis limits -- matched-filter receiver
# Range resolution: dR = c / (2 * BW) per range bin # Range per bin: c / (2 * fs_processing) * decimation_factor = 24.0 m
# But we decimate 1024→64 bins, so each bin spans 16 FFT bins. range_per_bin = self.C / (2.0 * 100e6) * 16 # 24.0 m
# Range resolution derivation: c/(2*BW) gives ~0.3 m per FFT bin.
# After 1024-to-64 decimation each displayed range bin spans 16 FFT bins.
range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin
# After decimation 1024→64, each range bin = 16 FFT bins
range_per_bin = range_res * 16
max_range = range_per_bin * NUM_RANGE_BINS max_range = range_per_bin * NUM_RANGE_BINS
doppler_bin_lo = 0 doppler_bin_lo = 0
doppler_bin_hi = NUM_DOPPLER_BINS doppler_bin_hi = NUM_DOPPLER_BINS
# Top pane: plots
plot_frame = ttk.Frame(parent)
plot_frame.pack(fill="both", expand=True)
# Matplotlib figure with 3 subplots # Matplotlib figure with 3 subplots
self.fig = Figure(figsize=(14, 7), facecolor=BG) self.fig = Figure(figsize=(14, 5), facecolor=BG)
self.fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.10, self.fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.10,
wspace=0.30, hspace=0.35) wspace=0.30, hspace=0.35)
@@ -245,11 +571,35 @@ class RadarDashboard:
self.ax_wf.set_ylabel("Frame", color=FG) self.ax_wf.set_ylabel("Frame", color=FG)
self.ax_wf.tick_params(colors=FG) self.ax_wf.tick_params(colors=FG)
canvas = FigureCanvasTkAgg(self.fig, master=parent) canvas = FigureCanvasTkAgg(self.fig, master=plot_frame)
canvas.draw() canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True) canvas.get_tk_widget().pack(fill="both", expand=True)
self._canvas = canvas self._canvas = canvas
# Bottom pane: detected targets table
tgt_frame = ttk.LabelFrame(parent, text="Detected Targets", padding=4)
tgt_frame.pack(fill="x", padx=8, pady=(0, 4))
cols = ("id", "range_m", "velocity", "azimuth", "snr", "class")
self._tgt_tree = ttk.Treeview(
tgt_frame, columns=cols, show="headings", height=5)
for col, heading, width in [
("id", "ID", 50),
("range_m", "Range (m)", 100),
("velocity", "Vel (m/s)", 90),
("azimuth", "Az (deg)", 90),
("snr", "SNR (dB)", 80),
("class", "Class", 100),
]:
self._tgt_tree.heading(col, text=heading)
self._tgt_tree.column(col, width=width, anchor="center")
scrollbar = ttk.Scrollbar(
tgt_frame, orient="vertical", command=self._tgt_tree.yview)
self._tgt_tree.configure(yscrollcommand=scrollbar.set)
self._tgt_tree.pack(side="left", fill="x", expand=True)
scrollbar.pack(side="right", fill="y")
def _build_control_tab(self, parent): def _build_control_tab(self, parent):
"""Host command sender — organized by FPGA register groups. """Host command sender — organized by FPGA register groups.
@@ -492,6 +842,86 @@ class RadarDashboard:
var.set(str(clamped)) var.set(str(clamped))
self._send_cmd(opcode, clamped) self._send_cmd(opcode, clamped)
def _build_replay_tab(self, parent):
"""Replay tab — load file, transport controls, seek slider."""
# File selection
file_frame = ttk.LabelFrame(parent, text="Replay Source", padding=10)
file_frame.pack(fill="x", padx=8, pady=(8, 4))
self._replay_path_var = tk.StringVar(value="(none)")
ttk.Label(file_frame, textvariable=self._replay_path_var,
font=("Menlo", 9)).pack(side="left", fill="x", expand=True)
ttk.Button(file_frame, text="Browse File...",
command=self._replay_browse_file).pack(side="right", padx=(4, 0))
ttk.Button(file_frame, text="Browse Dir...",
command=self._replay_browse_dir).pack(side="right", padx=(4, 0))
# Transport controls
ctrl_frame = ttk.LabelFrame(parent, text="Transport", padding=10)
ctrl_frame.pack(fill="x", padx=8, pady=4)
btn_row = ttk.Frame(ctrl_frame)
btn_row.pack(fill="x", pady=(0, 6))
self._rp_play_btn = ttk.Button(
btn_row, text="Play", command=self._replay_play, state="disabled")
self._rp_play_btn.pack(side="left", padx=2)
self._rp_pause_btn = ttk.Button(
btn_row, text="Pause", command=self._replay_pause, state="disabled")
self._rp_pause_btn.pack(side="left", padx=2)
self._rp_stop_btn = ttk.Button(
btn_row, text="Stop", command=self._replay_stop, state="disabled")
self._rp_stop_btn.pack(side="left", padx=2)
# Speed selector
ttk.Label(btn_row, text="Speed:").pack(side="left", padx=(16, 4))
self._rp_speed_var = tk.StringVar(value="1x")
speed_combo = ttk.Combobox(
btn_row, textvariable=self._rp_speed_var,
values=list(_ReplayController.SPEED_MAP.keys()),
state="readonly", width=6)
speed_combo.pack(side="left", padx=2)
speed_combo.bind("<<ComboboxSelected>>", self._replay_speed_changed)
# Loop checkbox
self._rp_loop_var = tk.BooleanVar(value=False)
ttk.Checkbutton(btn_row, text="Loop",
variable=self._rp_loop_var,
command=self._replay_loop_changed).pack(side="left", padx=8)
# Seek slider
slider_row = ttk.Frame(ctrl_frame)
slider_row.pack(fill="x")
self._rp_slider = tk.Scale(
slider_row, from_=0, to=0, orient="horizontal",
bg=SURFACE, fg=FG, highlightthickness=0,
troughcolor=BG2, command=self._replay_seek)
self._rp_slider.pack(side="left", fill="x", expand=True)
self._rp_frame_label = ttk.Label(
slider_row, text="0 / 0", font=("Menlo", 10))
self._rp_frame_label.pack(side="right", padx=8)
# Status
self._rp_status_label = ttk.Label(
parent, text="No replay loaded", font=("Menlo", 10))
self._rp_status_label.pack(padx=8, pady=4, anchor="w")
# Info frame for FPGA controls during replay
info = ttk.LabelFrame(parent, text="Replay FPGA Controls", padding=10)
info.pack(fill="x", padx=8, pady=4)
ttk.Label(
info,
text=("When replaying Raw IQ data, FPGA Control tab "
"parameters are routed to the SoftwareFPGA.\n"
"Changes take effect on the next frame."),
font=("Menlo", 9), foreground=ACCENT,
).pack(anchor="w")
def _build_agc_tab(self, parent): def _build_agc_tab(self, parent):
"""AGC Monitor tab — real-time strip charts for gain, peak, and saturation.""" """AGC Monitor tab — real-time strip charts for gain, peak, and saturation."""
# Top row: AGC status badge + saturation indicator # Top row: AGC status badge + saturation indicator
@@ -602,6 +1032,12 @@ class RadarDashboard:
log.info("Disconnected") log.info("Disconnected")
return return
# Stop any active demo or replay before going live
if self._demo_active:
self._stop_demo()
if self._replay_active:
self._replay_stop()
# Open connection in a background thread to avoid blocking the GUI # Open connection in a background thread to avoid blocking the GUI
self.lbl_status.config(text="CONNECTING...", foreground=YELLOW) self.lbl_status.config(text="CONNECTING...", foreground=YELLOW)
self.btn_connect.config(state="disabled") self.btn_connect.config(state="disabled")
@@ -644,7 +1080,37 @@ class RadarDashboard:
self.recorder.start(filepath) self.recorder.start(filepath)
self.btn_record.config(text="Stop Rec") self.btn_record.config(text="Stop Rec")
# Opcode → SoftwareFPGA setter method name for dual dispatch during replay
_SFPGA_SETTER_NAMES: ClassVar[dict[int, str]] = {
0x03: "set_detect_threshold",
0x16: "set_gain_shift",
0x21: "set_cfar_guard",
0x22: "set_cfar_train",
0x23: "set_cfar_alpha",
0x24: "set_cfar_mode",
0x25: "set_cfar_enable",
0x26: "set_mti_enable",
0x27: "set_dc_notch_width",
0x28: "set_agc_enable",
}
def _send_cmd(self, opcode: int, value: int): def _send_cmd(self, opcode: int, value: int):
"""Send command — routes to SoftwareFPGA when replaying raw IQ."""
if (self._replay_active and self._replay_ctrl is not None
and self._replay_ctrl.software_fpga is not None):
sfpga = self._replay_ctrl.software_fpga
setter_name = self._SFPGA_SETTER_NAMES.get(opcode)
if setter_name is not None:
getattr(sfpga, setter_name)(value)
log.info(
f"SoftwareFPGA 0x{opcode:02X} val={value}")
return
log.warning(
f"Opcode 0x{opcode:02X} not routable in replay mode")
self._ui_queue.put(
("status_msg",
f"Opcode 0x{opcode:02X} is hardware-only (ignored in replay)"))
return
cmd = RadarProtocol.build_command(opcode, value) cmd = RadarProtocol.build_command(opcode, value)
ok = self.conn.write(cmd) ok = self.conn.write(cmd)
log.info(f"CMD 0x{opcode:02X} val={value} ({'OK' if ok else 'FAIL'})") log.info(f"CMD 0x{opcode:02X} val={value} ({'OK' if ok else 'FAIL'})")
@@ -657,6 +1123,133 @@ class RadarDashboard:
except ValueError: except ValueError:
log.error("Invalid custom command values") log.error("Invalid custom command values")
# -------------------------------------------------------- Replay actions
def _replay_browse_file(self):
path = filedialog.askopenfilename(
title="Select replay file",
filetypes=[
("NumPy files", "*.npy"),
("HDF5 files", "*.h5"),
("All files", "*.*"),
],
)
if path:
self._replay_load(path)
def _replay_browse_dir(self):
path = filedialog.askdirectory(title="Select co-sim directory")
if path:
self._replay_load(path)
def _replay_load(self, path: str):
"""Load replay data and enable transport controls."""
# Stop any running mode
if self._demo_active:
self._stop_demo()
# Safely shutdown and disable UI controls before loading the new file
if self._replay_active or self._replay_ctrl is not None:
self._replay_stop()
if self._acq_thread is not None:
if self.conn.is_open:
self._on_connect() # disconnect
else:
# Connection dropped unexpectedly — just clean up the thread
self._acq_thread.stop()
self._acq_thread.join(timeout=2)
self._acq_thread = None
try:
self._replay_ctrl = _ReplayController(
self.frame_queue, self._ui_queue)
total = self._replay_ctrl.load(path)
except Exception as exc: # noqa: BLE001
log.error(f"Failed to load replay: {exc}")
self._rp_status_label.config(
text=f"Load failed: {exc}", foreground=RED)
self._replay_ctrl = None
return
short_path = Path(path).name
self._replay_path_var.set(short_path)
self._rp_slider.config(to=max(0, total - 1))
self._rp_frame_label.config(text=f"0 / {total}")
self._rp_status_label.config(
text=f"Loaded: {total} frames from {short_path}",
foreground=GREEN)
# Enable transport buttons
for btn in (self._rp_play_btn, self._rp_pause_btn, self._rp_stop_btn):
btn.config(state="normal")
self._replay_active = True
self.lbl_status.config(text="REPLAY", foreground=ACCENT)
log.info(f"Replay loaded: {total} frames from {path}")
def _replay_play(self):
if self._replay_ctrl:
self._replay_ctrl.play()
def _replay_pause(self):
if self._replay_ctrl:
self._replay_ctrl.pause()
def _replay_stop(self):
if self._replay_ctrl:
self._replay_ctrl.close()
self._replay_ctrl = None
self._replay_active = False
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
self._rp_slider.set(0)
self._rp_frame_label.config(text="0 / 0")
for btn in (self._rp_play_btn, self._rp_pause_btn, self._rp_stop_btn):
btn.config(state="disabled")
def _replay_seek(self, value):
if (self._replay_ctrl and self._replay_active
and not self._replay_ctrl.is_playing):
self._replay_ctrl.seek(int(value))
def _replay_speed_changed(self, _event=None):
if self._replay_ctrl:
self._replay_ctrl.set_speed(self._rp_speed_var.get())
def _replay_loop_changed(self):
if self._replay_ctrl:
self._replay_ctrl.set_loop(self._rp_loop_var.get())
# ---------------------------------------------------------- Demo actions
def _toggle_demo(self):
if self._demo_active:
self._stop_demo()
else:
self._start_demo()
def _start_demo(self):
"""Start demo mode with synthetic targets."""
# Mutual exclusion
if self._replay_active:
self._replay_stop()
if self._acq_thread is not None:
log.warning("Cannot start demo while radar is connected")
return
self._demo_sim = DemoSimulator(
self.frame_queue, self._ui_queue, self.root, interval_ms=500)
self._demo_sim.start()
self._demo_active = True
self.lbl_status.config(text="DEMO", foreground=YELLOW)
self.btn_demo.config(text="Stop Demo")
log.info("Demo mode started")
def _stop_demo(self):
if self._demo_sim is not None:
self._demo_sim.stop()
self._demo_sim = None
self._demo_active = False
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
self.btn_demo.config(text="Start Demo")
log.info("Demo mode stopped")
def _on_status_received(self, status: StatusResponse): def _on_status_received(self, status: StatusResponse):
"""Called from acquisition thread — post to UI queue for main thread.""" """Called from acquisition thread — post to UI queue for main thread."""
self._ui_queue.put(("status", status)) self._ui_queue.put(("status", status))
@@ -804,6 +1397,46 @@ class RadarDashboard:
self._update_self_test_labels(payload) self._update_self_test_labels(payload)
elif tag == "log": elif tag == "log":
self._log_handler_append(payload) self._log_handler_append(payload)
elif tag == "replay_state":
self._on_replay_state(payload)
elif tag == "replay_index":
self._on_replay_index(*payload)
elif tag == "demo_targets":
self._on_demo_targets(payload)
elif tag == "status_msg":
self.lbl_status.config(text=str(payload), foreground=YELLOW)
def _on_replay_state(self, state: str):
if state == "playing":
self._rp_status_label.config(text="Playing", foreground=GREEN)
elif state == "paused":
self._rp_status_label.config(text="Paused", foreground=YELLOW)
elif state == "stopped":
self._rp_status_label.config(text="Stopped", foreground=FG)
def _on_replay_index(self, index: int, total: int):
self._rp_frame_label.config(text=f"{index} / {total}")
self._rp_slider.set(index)
def _on_demo_targets(self, targets: list[dict]):
"""Update the detected targets treeview from demo data."""
self._update_targets_table(targets)
def _update_targets_table(self, targets: list[dict]):
"""Refresh the detected targets treeview."""
# Clear existing rows
for item in self._tgt_tree.get_children():
self._tgt_tree.delete(item)
# Insert new rows
for t in targets:
self._tgt_tree.insert("", "end", values=(
t.get("id", ""),
f"{t.get('range_m', 0):.0f}",
f"{t.get('velocity', 0):.1f}",
f"{t.get('azimuth', 0):.1f}",
f"{t.get('snr', 0):.1f}",
t.get("class", ""),
))
def _log_handler_append(self, msg: str): def _log_handler_append(self, msg: str):
"""Append a log message to the log Text widget (main thread only).""" """Append a log message to the log Text widget (main thread only)."""
@@ -902,24 +1535,20 @@ class _TextHandler(logging.Handler):
def main(): def main():
parser = argparse.ArgumentParser(description="AERIS-10 Radar Dashboard") parser = argparse.ArgumentParser(description="AERIS-10 Radar Dashboard")
parser.add_argument("--live", action="store_true",
help="Use real FT2232H hardware (default: mock mode)")
parser.add_argument("--replay", type=str, metavar="NPY_DIR",
help="Replay real data from .npy directory "
"(e.g. tb/cosim/real_data/hex/)")
parser.add_argument("--no-mti", action="store_true",
help="With --replay, use non-MTI Doppler data")
parser.add_argument("--record", action="store_true", parser.add_argument("--record", action="store_true",
help="Start HDF5 recording immediately") help="Start HDF5 recording immediately")
parser.add_argument("--device", type=int, default=0, parser.add_argument("--device", type=int, default=0,
help="FT2232H device index (default: 0)") help="FT2232H device index (default: 0)")
mode_group = parser.add_mutually_exclusive_group()
mode_group.add_argument("--live", action="store_true",
help="Use real FT2232H hardware (default: mock mode)")
mode_group.add_argument("--replay", type=str, default=None,
help="Auto-load replay file or directory on startup")
mode_group.add_argument("--demo", action="store_true",
help="Start in demo mode with synthetic targets")
args = parser.parse_args() args = parser.parse_args()
if args.replay: if args.live:
npy_dir = os.path.abspath(args.replay)
conn = ReplayConnection(npy_dir, use_mti=not args.no_mti)
mode_str = f"REPLAY ({npy_dir}, MTI={'OFF' if args.no_mti else 'ON'})"
elif args.live:
conn = FT2232HConnection(mock=False) conn = FT2232HConnection(mock=False)
mode_str = "LIVE" mode_str = "LIVE"
else: else:
@@ -939,7 +1568,19 @@ def main():
) )
recorder.start(filepath) recorder.start(filepath)
if args.replay:
dashboard._replay_load(args.replay)
if args.demo:
dashboard._start_demo()
def on_closing(): def on_closing():
# Stop demo if active
if dashboard._demo_active:
dashboard._stop_demo()
# Stop replay if active
if dashboard._replay_ctrl is not None:
dashboard._replay_ctrl.close()
if dashboard._acq_thread is not None: if dashboard._acq_thread is not None:
dashboard._acq_thread.stop() dashboard._acq_thread.stop()
dashboard._acq_thread.join(timeout=2) dashboard._acq_thread.join(timeout=2)
+2 -2
View File
@@ -45,7 +45,7 @@ class RadarSettings:
range_bins: int = 1024 range_bins: int = 1024
doppler_bins: int = 32 doppler_bins: int = 32
prf: float = 1000 prf: float = 1000
max_range: float = 5000 max_range: float = 1536
max_velocity: float = 100 max_velocity: float = 100
cfar_threshold: float = 13.0 cfar_threshold: float = 13.0
@@ -577,7 +577,7 @@ class RadarDemoGUI:
('Range Bins:', 'range_bins', 1024, 256, 2048), ('Range Bins:', 'range_bins', 1024, 256, 2048),
('Doppler Bins:', 'doppler_bins', 32, 8, 128), ('Doppler Bins:', 'doppler_bins', 32, 8, 128),
('PRF (Hz):', 'prf', 1000, 100, 10000), ('PRF (Hz):', 'prf', 1000, 100, 10000),
('Max Range (m):', 'max_range', 5000, 100, 50000), ('Max Range (m):', 'max_range', 1536, 100, 25000),
('Max Velocity (m/s):', 'max_vel', 100, 10, 500), ('Max Velocity (m/s):', 'max_vel', 100, 10, 500),
('CFAR Threshold (dB):', 'cfar', 13.0, 5.0, 30.0) ('CFAR Threshold (dB):', 'cfar', 13.0, 5.0, 30.0)
] ]
+1 -1
View File
@@ -8,6 +8,6 @@ GUI_V5 ==> Added Mercury Color
GUI_V6 ==> Added USB3 FT601 support GUI_V6 ==> Added USB3 FT601 support
radar_dashboard ==> Board bring-up dashboard (FT2232H reader, real-time R-D heatmap, CFAR overlay, waterfall, host commands, HDF5 recording) GUI_V65_Tk ==> Board bring-up dashboard (FT2232H reader, real-time R-D heatmap, CFAR overlay, waterfall, host commands, HDF5 recording, replay, demo mode)
radar_protocol ==> Protocol layer (packet parsing, command building, FT2232H connection, data recorder, acquisition thread) radar_protocol ==> Protocol layer (packet parsing, command building, FT2232H connection, data recorder, acquisition thread)
smoke_test ==> Board bring-up smoke test host script (triggers FPGA self-test via opcode 0x30) smoke_test ==> Board bring-up smoke test host script (triggers FPGA self-test via opcode 0x30)
-431
View File
@@ -1,431 +0,0 @@
# ruff: noqa: T201
#!/usr/bin/env python3
"""
One-off AGC saturation analysis for ADI CN0566 raw IQ captures.
Bit-accurate simulation of rx_gain_control.v AGC inner loop applied
to real captured IQ data. Three scenarios per dataset:
Row 1 AGC OFF: Fixed gain_shift=0 (pass-through). Shows raw clipping.
Row 2 AGC ON: Auto-adjusts from gain_shift=0. Clipping clears.
Row 3 AGC delayed: OFF for first half, ON at midpoint.
Shows the transition: clipping AGC activates clears.
Key RTL details modelled exactly:
- gain_shift[3]=direction (0=amplify/left, 1=attenuate/right), [2:0]=amount
- Internal agc_gain is signed -7..+7
- Peak is measured PRE-gain (raw input |sample|, upper 8 of 15 bits)
- Saturation is measured POST-gain (overflow from shift)
- Attack: gain -= agc_attack when any sample clips (immediate)
- Decay: gain += agc_decay when peak < target AND holdoff expired
- Hold: when peak >= target AND no saturation, hold gain, reset holdoff
Usage:
python adi_agc_analysis.py
python adi_agc_analysis.py --data /path/to/file.npy --label "my capture"
"""
import argparse
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
# ---------------------------------------------------------------------------
# FPGA AGC parameters (rx_gain_control.v reset defaults)
# ---------------------------------------------------------------------------
AGC_TARGET = 200 # host_agc_target (8-bit, default 200)
AGC_ATTACK = 1 # host_agc_attack (4-bit, default 1)
AGC_DECAY = 1 # host_agc_decay (4-bit, default 1)
AGC_HOLDOFF = 4 # host_agc_holdoff (4-bit, default 4)
ADC_RAIL = 4095 # 12-bit ADC max absolute value
# ---------------------------------------------------------------------------
# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed)
# ---------------------------------------------------------------------------
def signed_to_encoding(g: int) -> int:
"""Convert signed gain (-7..+7) to gain_shift[3:0] encoding.
[3]=0, [2:0]=N amplify (left shift) by N
[3]=1, [2:0]=N attenuate (right shift) by N
"""
if g >= 0:
return g & 0x07
return 0x08 | ((-g) & 0x07)
def encoding_to_signed(enc: int) -> int:
"""Convert gain_shift[3:0] encoding to signed gain."""
if (enc & 0x08) == 0:
return enc & 0x07
return -(enc & 0x07)
def clamp_gain(val: int) -> int:
"""Clamp to [-7, +7] (matches RTL clamp_gain function)."""
return max(-7, min(7, val))
# ---------------------------------------------------------------------------
# Apply gain shift to IQ data (matches RTL combinational logic)
# ---------------------------------------------------------------------------
def apply_gain_shift(frame_i: np.ndarray, frame_q: np.ndarray,
gain_enc: int) -> tuple[np.ndarray, np.ndarray, int]:
"""Apply gain_shift encoding to 16-bit signed IQ arrays.
Returns (shifted_i, shifted_q, overflow_count).
Matches the RTL: left shift = amplify, right shift = attenuate,
saturate to ±32767 on overflow.
"""
direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate
amount = gain_enc & 0x07
if amount == 0:
return frame_i.copy(), frame_q.copy(), 0
if direction == 0:
# Left shift (amplify)
si = frame_i.astype(np.int64) * (1 << amount)
sq = frame_q.astype(np.int64) * (1 << amount)
else:
# Arithmetic right shift (attenuate)
si = frame_i.astype(np.int64) >> amount
sq = frame_q.astype(np.int64) >> amount
# Count overflows (post-shift values outside 16-bit signed range)
overflow_i = (si > 32767) | (si < -32768)
overflow_q = (sq > 32767) | (sq < -32768)
overflow_count = int((overflow_i | overflow_q).sum())
# Saturate to ±32767
si = np.clip(si, -32768, 32767).astype(np.int16)
sq = np.clip(sq, -32768, 32767).astype(np.int16)
return si, sq, overflow_count
# ---------------------------------------------------------------------------
# Per-frame AGC simulation (bit-accurate to rx_gain_control.v)
# ---------------------------------------------------------------------------
def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
enable_at_frame: int = 0,
initial_gain_enc: int = 0x00) -> dict:
"""Simulate FPGA inner-loop AGC across all frames.
Parameters
----------
frames : (N, chirps, samples) complex raw ADC captures (12-bit range)
agc_enabled : if False, gain stays fixed
enable_at_frame : frame index where AGC activates
initial_gain_enc : gain_shift[3:0] encoding when AGC enables (default 0x00 = pass-through)
"""
n_frames = frames.shape[0]
# Output arrays
out_gain_enc = np.zeros(n_frames, dtype=int) # gain_shift encoding [3:0]
out_gain_signed = np.zeros(n_frames, dtype=int) # signed gain for plotting
out_peak_mag = np.zeros(n_frames, dtype=int) # peak_magnitude[7:0]
out_sat_count = np.zeros(n_frames, dtype=int) # saturation_count[7:0]
out_sat_rate = np.zeros(n_frames, dtype=float)
out_rms_post = np.zeros(n_frames, dtype=float) # RMS after gain shift
# AGC internal state
agc_gain = 0 # signed, -7..+7
holdoff_counter = 0
agc_was_enabled = False
for i in range(n_frames):
frame = frames[i]
# Quantize to 16-bit signed (ADC is 12-bit, sign-extended to 16)
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
# --- PRE-gain peak measurement (RTL lines 133-135, 211-213) ---
abs_i = np.abs(frame_i.astype(np.int32))
abs_q = np.abs(frame_q.astype(np.int32))
max_iq = np.maximum(abs_i, abs_q)
frame_peak_15bit = int(max_iq.max()) # 15-bit unsigned
peak_8bit = (frame_peak_15bit >> 7) & 0xFF # Upper 8 bits
# --- Determine effective gain ---
agc_active = agc_enabled and (i >= enable_at_frame)
# AGC enable transition (RTL lines 250-253)
if agc_active and not agc_was_enabled:
agc_gain = encoding_to_signed(initial_gain_enc)
holdoff_counter = AGC_HOLDOFF
effective_enc = signed_to_encoding(agc_gain) if agc_active else initial_gain_enc
agc_was_enabled = agc_active
# --- Apply gain shift + count POST-gain overflow (RTL lines 114-126, 207-209) ---
shifted_i, shifted_q, frame_overflow = apply_gain_shift(
frame_i, frame_q, effective_enc)
frame_sat = min(255, frame_overflow)
# RMS of shifted signal
rms = float(np.sqrt(np.mean(
shifted_i.astype(np.float64)**2 + shifted_q.astype(np.float64)**2)))
total_samples = frame_i.size + frame_q.size
sat_rate = frame_overflow / total_samples if total_samples > 0 else 0.0
# --- Record outputs ---
out_gain_enc[i] = effective_enc
out_gain_signed[i] = agc_gain if agc_active else encoding_to_signed(initial_gain_enc)
out_peak_mag[i] = peak_8bit
out_sat_count[i] = frame_sat
out_sat_rate[i] = sat_rate
out_rms_post[i] = rms
# --- AGC update at frame boundary (RTL lines 226-246) ---
if agc_active:
if frame_sat > 0:
# Clipping: reduce gain immediately (attack)
agc_gain = clamp_gain(agc_gain - AGC_ATTACK)
holdoff_counter = AGC_HOLDOFF
elif peak_8bit < AGC_TARGET:
# Signal too weak: increase gain after holdoff
if holdoff_counter == 0:
agc_gain = clamp_gain(agc_gain + AGC_DECAY)
else:
holdoff_counter -= 1
else:
# Good range (peak >= target, no sat): hold, reset holdoff
holdoff_counter = AGC_HOLDOFF
return {
"gain_enc": out_gain_enc,
"gain_signed": out_gain_signed,
"peak_mag": out_peak_mag,
"sat_count": out_sat_count,
"sat_rate": out_sat_rate,
"rms_post": out_rms_post,
}
# ---------------------------------------------------------------------------
# Range-Doppler processing for heatmap display
# ---------------------------------------------------------------------------
def process_frame_rd(frame: np.ndarray, gain_enc: int,
n_range: int = 64,
n_doppler: int = 32) -> np.ndarray:
"""Range-Doppler magnitude for one frame with gain applied."""
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
n_chirps, _ = iq.shape
range_fft = np.fft.fft(iq, axis=1)[:, :n_range]
doppler_fft = np.fft.fftshift(np.fft.fft(range_fft, axis=0), axes=0)
center = n_chirps // 2
half_d = n_doppler // 2
doppler_fft = doppler_fft[center - half_d:center + half_d, :]
rd_mag = np.abs(doppler_fft.real) + np.abs(doppler_fft.imag)
return rd_mag.T # (n_range, n_doppler)
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
def plot_scenario(axes, data: np.ndarray, agc: dict, title: str,
enable_frame: int = 0):
"""Plot one AGC scenario across 5 axes."""
n = data.shape[0]
xs = np.arange(n)
# Range-Doppler heatmap
if enable_frame > 0 and enable_frame < n:
f_before = max(0, enable_frame - 1)
f_after = min(n - 1, n - 2)
rd_before = process_frame_rd(data[f_before], int(agc["gain_enc"][f_before]))
rd_after = process_frame_rd(data[f_after], int(agc["gain_enc"][f_after]))
combined = np.hstack([rd_before, rd_after])
im = axes[0].imshow(
20 * np.log10(combined + 1), aspect="auto", origin="lower",
cmap="inferno", interpolation="nearest")
axes[0].axvline(x=rd_before.shape[1] - 0.5, color="cyan",
linewidth=2, linestyle="--")
axes[0].set_title(f"{title}\nL: f{f_before} (pre) | R: f{f_after} (post)")
else:
worst = int(np.argmax(agc["sat_count"]))
best = int(np.argmin(agc["sat_count"]))
f_show = worst if agc["sat_count"][worst] > 0 else best
rd = process_frame_rd(data[f_show], int(agc["gain_enc"][f_show]))
im = axes[0].imshow(
20 * np.log10(rd + 1), aspect="auto", origin="lower",
cmap="inferno", interpolation="nearest")
axes[0].set_title(f"{title}\nFrame {f_show}")
axes[0].set_xlabel("Doppler bin")
axes[0].set_ylabel("Range bin")
plt.colorbar(im, ax=axes[0], label="dB", shrink=0.8)
# Signed gain history (the real AGC state)
axes[1].plot(xs, agc["gain_signed"], color="#00ff88", linewidth=1.5)
axes[1].axhline(y=0, color="gray", linestyle=":", alpha=0.5,
label="Pass-through")
if enable_frame > 0:
axes[1].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", label="AGC ON")
axes[1].set_ylim(-8, 8)
axes[1].set_ylabel("Gain (signed)")
axes[1].set_title("AGC Internal Gain (-7=max atten, +7=max amp)")
axes[1].legend(fontsize=7, loc="upper right")
axes[1].grid(True, alpha=0.3)
# Peak magnitude (PRE-gain, 8-bit)
axes[2].plot(xs, agc["peak_mag"], color="#ffaa00", linewidth=1.0)
axes[2].axhline(y=AGC_TARGET, color="cyan", linestyle="--",
alpha=0.7, label=f"Target ({AGC_TARGET})")
axes[2].axhspan(240, 255, color="red", alpha=0.15, label="Clip zone")
if enable_frame > 0:
axes[2].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[2].set_ylim(0, 260)
axes[2].set_ylabel("Peak (8-bit)")
axes[2].set_title("Peak Magnitude (pre-gain, raw input)")
axes[2].legend(fontsize=7, loc="upper right")
axes[2].grid(True, alpha=0.3)
# Saturation count (POST-gain overflow)
axes[3].fill_between(xs, agc["sat_count"], color="red", alpha=0.4)
axes[3].plot(xs, agc["sat_count"], color="red", linewidth=0.8)
if enable_frame > 0:
axes[3].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[3].set_ylabel("Overflow Count")
total = int(agc["sat_count"].sum())
axes[3].set_title(f"Post-Gain Overflow (total={total})")
axes[3].grid(True, alpha=0.3)
# RMS signal level (post-gain)
axes[4].plot(xs, agc["rms_post"], color="#44aaff", linewidth=1.0)
if enable_frame > 0:
axes[4].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[4].set_ylabel("RMS")
axes[4].set_xlabel("Frame")
axes[4].set_title("Post-Gain RMS Level")
axes[4].grid(True, alpha=0.3)
def analyze_dataset(data: np.ndarray, label: str):
"""Run 3-scenario analysis for one dataset."""
n_frames = data.shape[0]
mid = n_frames // 2
print(f"\n{'='*60}")
print(f" {label} — shape {data.shape}")
print(f"{'='*60}")
# Raw ADC stats
raw_sat = np.sum((np.abs(data.real) >= ADC_RAIL) |
(np.abs(data.imag) >= ADC_RAIL))
print(f" Raw ADC saturation: {raw_sat} samples "
f"({100*raw_sat/(2*data.size):.2f}%)")
# Scenario 1: AGC OFF — pass-through (gain_shift=0x00)
print(" [1/3] AGC OFF (gain=0, pass-through) ...")
agc_off = simulate_agc(data, agc_enabled=False, initial_gain_enc=0x00)
print(f" Post-gain overflow: {agc_off['sat_count'].sum()} "
f"(should be 0 — no amplification)")
# Scenario 2: AGC ON from frame 0
print(" [2/3] AGC ON (from start) ...")
agc_on = simulate_agc(data, agc_enabled=True, enable_at_frame=0,
initial_gain_enc=0x00)
print(f" Final gain: {agc_on['gain_signed'][-1]} "
f"(enc=0x{agc_on['gain_enc'][-1]:X})")
print(f" Post-gain overflow: {agc_on['sat_count'].sum()}")
# Scenario 3: AGC delayed
print(f" [3/3] AGC delayed (ON at frame {mid}) ...")
agc_delayed = simulate_agc(data, agc_enabled=True,
enable_at_frame=mid,
initial_gain_enc=0x00)
pre_sat = int(agc_delayed["sat_count"][:mid].sum())
post_sat = int(agc_delayed["sat_count"][mid:].sum())
print(f" Pre-AGC overflow: {pre_sat} "
f"Post-AGC overflow: {post_sat}")
# Plot
fig, axes = plt.subplots(3, 5, figsize=(28, 14))
fig.suptitle(f"AERIS-10 AGC Analysis — {label}\n"
f"({n_frames} frames, {data.shape[1]} chirps, "
f"{data.shape[2]} samples/chirp, "
f"raw ADC sat={100*raw_sat/(2*data.size):.2f}%)",
fontsize=13, fontweight="bold", y=0.99)
plot_scenario(axes[0], data, agc_off, "AGC OFF (pass-through)")
plot_scenario(axes[1], data, agc_on, "AGC ON (from start)")
plot_scenario(axes[2], data, agc_delayed,
f"AGC delayed (ON at frame {mid})", enable_frame=mid)
for ax, lbl in zip(axes[:, 0],
["AGC OFF", "AGC ON", "AGC DELAYED"],
strict=True):
ax.annotate(lbl, xy=(-0.35, 0.5), xycoords="axes fraction",
fontsize=13, fontweight="bold", color="white",
ha="center", va="center", rotation=90)
plt.tight_layout(rect=[0.03, 0, 1, 0.95])
return fig
def main():
parser = argparse.ArgumentParser(
description="AGC analysis for ADI raw IQ captures "
"(bit-accurate rx_gain_control.v simulation)")
parser.add_argument("--amp", type=str,
default=str(Path.home() / "Downloads/adi_radar_data"
"/amp_radar"
"/phaser_amp_4MSPS_500M_300u_256_m3dB.npy"),
help="Path to amplified radar .npy")
parser.add_argument("--noamp", type=str,
default=str(Path.home() / "Downloads/adi_radar_data"
"/no_amp_radar"
"/phaser_NOamp_4MSPS_500M_300u_256.npy"),
help="Path to non-amplified radar .npy")
parser.add_argument("--data", type=str, default=None,
help="Single dataset mode")
parser.add_argument("--label", type=str, default="Custom Data")
args = parser.parse_args()
plt.style.use("dark_background")
if args.data:
data = np.load(args.data)
analyze_dataset(data, args.label)
plt.show()
return
figs = []
for path, label in [(args.amp, "With Amplifier (-3 dB)"),
(args.noamp, "No Amplifier")]:
if not Path(path).exists():
print(f"WARNING: {path} not found, skipping")
continue
data = np.load(path)
fig = analyze_dataset(data, label)
figs.append(fig)
if not figs:
print("No data found. Use --amp/--noamp or --data.")
sys.exit(1)
plt.show()
if __name__ == "__main__":
main()
-385
View File
@@ -15,7 +15,6 @@ USB Packet Protocol (11-byte):
Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo} Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo}
""" """
import os
import struct import struct
import time import time
import threading import threading
@@ -443,391 +442,7 @@ class FT2232HConnection:
return bytes(buf) return bytes(buf)
# ============================================================================
# Replay Connection — feed real .npy data through the dashboard
# ============================================================================
# Hardware-only opcodes that cannot be adjusted in replay mode
# Values must match radar_system_top.v case(usb_cmd_opcode).
_HARDWARE_ONLY_OPCODES = {
0x01, # RADAR_MODE
0x02, # TRIGGER_PULSE
# 0x03 (DETECT_THRESHOLD) is NOT hardware-only — it's in _REPLAY_ADJUSTABLE_OPCODES
0x04, # STREAM_CONTROL
0x10, # LONG_CHIRP
0x11, # LONG_LISTEN
0x12, # GUARD
0x13, # SHORT_CHIRP
0x14, # SHORT_LISTEN
0x15, # CHIRPS_PER_ELEV
0x16, # GAIN_SHIFT
0x20, # RANGE_MODE
0x28, # AGC_ENABLE
0x29, # AGC_TARGET
0x2A, # AGC_ATTACK
0x2B, # AGC_DECAY
0x2C, # AGC_HOLDOFF
0x30, # SELF_TEST_TRIGGER
0x31, # SELF_TEST_STATUS
0xFF, # STATUS_REQUEST
}
# Replay-adjustable opcodes (re-run signal processing)
_REPLAY_ADJUSTABLE_OPCODES = {
0x03, # DETECT_THRESHOLD
0x21, # CFAR_GUARD
0x22, # CFAR_TRAIN
0x23, # CFAR_ALPHA
0x24, # CFAR_MODE
0x25, # CFAR_ENABLE
0x26, # MTI_ENABLE
0x27, # DC_NOTCH_WIDTH
}
def _saturate(val: int, bits: int) -> int:
"""Saturate signed value to fit in 'bits' width."""
max_pos = (1 << (bits - 1)) - 1
max_neg = -(1 << (bits - 1))
return max(max_neg, min(max_pos, int(val)))
def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray,
width: int) -> tuple[np.ndarray, np.ndarray]:
"""Bit-accurate DC notch filter (matches radar_system_top.v inline).
Dual sub-frame notch: doppler_bin[4:0] = {sub_frame, bin[3:0]}.
Each 16-bin sub-frame has its own DC at bin 0, so we zero bins
where ``bin_within_sf < width`` or ``bin_within_sf > (15 - width + 1)``.
"""
out_i = doppler_i.copy()
out_q = doppler_q.copy()
if width == 0:
return out_i, out_q
n_doppler = doppler_i.shape[1]
for dbin in range(n_doppler):
bin_within_sf = dbin & 0xF
if bin_within_sf < width or bin_within_sf > (15 - width + 1):
out_i[:, dbin] = 0
out_q[:, dbin] = 0
return out_i, out_q
def _replay_cfar(doppler_i: np.ndarray, doppler_q: np.ndarray,
guard: int, train: int, alpha_q44: int,
mode: int) -> tuple[np.ndarray, np.ndarray]:
"""
Bit-accurate CA-CFAR detector (matches cfar_ca.v).
Returns (detect_flags, magnitudes) both (64, 32).
"""
ALPHA_FRAC_BITS = 4
n_range, n_doppler = doppler_i.shape
if train == 0:
train = 1
# Compute magnitudes: |I| + |Q| (17-bit unsigned L1 norm)
magnitudes = np.zeros((n_range, n_doppler), dtype=np.int64)
for r in range(n_range):
for d in range(n_doppler):
i_val = int(doppler_i[r, d])
q_val = int(doppler_q[r, d])
abs_i = (-i_val) & 0xFFFF if i_val < 0 else i_val & 0xFFFF
abs_q = (-q_val) & 0xFFFF if q_val < 0 else q_val & 0xFFFF
magnitudes[r, d] = abs_i + abs_q
detect_flags = np.zeros((n_range, n_doppler), dtype=np.bool_)
MAX_MAG = (1 << 17) - 1
mode_names = {0: 'CA', 1: 'GO', 2: 'SO'}
mode_str = mode_names.get(mode, 'CA')
for dbin in range(n_doppler):
col = magnitudes[:, dbin]
for cut in range(n_range):
lead_sum, lead_cnt = 0, 0
for t in range(1, train + 1):
idx = cut - guard - t
if 0 <= idx < n_range:
lead_sum += int(col[idx])
lead_cnt += 1
lag_sum, lag_cnt = 0, 0
for t in range(1, train + 1):
idx = cut + guard + t
if 0 <= idx < n_range:
lag_sum += int(col[idx])
lag_cnt += 1
if mode_str == 'CA':
noise = lead_sum + lag_sum
elif mode_str == 'GO':
if lead_cnt > 0 and lag_cnt > 0:
noise = lead_sum if lead_sum * lag_cnt > lag_sum * lead_cnt else lag_sum
else:
noise = lead_sum if lead_cnt > 0 else lag_sum
elif mode_str == 'SO':
if lead_cnt > 0 and lag_cnt > 0:
noise = lead_sum if lead_sum * lag_cnt < lag_sum * lead_cnt else lag_sum
else:
noise = lead_sum if lead_cnt > 0 else lag_sum
else:
noise = lead_sum + lag_sum
thr = min((alpha_q44 * noise) >> ALPHA_FRAC_BITS, MAX_MAG)
if int(col[cut]) > thr:
detect_flags[cut, dbin] = True
return detect_flags, magnitudes
class ReplayConnection:
"""
Loads pre-computed .npy arrays (from golden_reference.py co-sim output)
and serves them as USB data packets to the dashboard, exercising the full
parsing pipeline with real ADI CN0566 radar data.
Signal processing parameters (CFAR guard/train/alpha/mode, MTI enable,
DC notch width) can be adjusted at runtime via write() the connection
re-runs the bit-accurate processing pipeline and rebuilds packets.
Required npy directory layout (e.g. tb/cosim/real_data/hex/):
decimated_range_i.npy (32, 64) int pre-Doppler range I
decimated_range_q.npy (32, 64) int pre-Doppler range Q
doppler_map_i.npy (64, 32) int Doppler I (no MTI)
doppler_map_q.npy (64, 32) int Doppler Q (no MTI)
fullchain_mti_doppler_i.npy (64, 32) int Doppler I (with MTI)
fullchain_mti_doppler_q.npy (64, 32) int Doppler Q (with MTI)
fullchain_cfar_flags.npy (64, 32) bool CFAR detections
fullchain_cfar_mag.npy (64, 32) int CFAR |I|+|Q| magnitude
"""
def __init__(self, npy_dir: str, use_mti: bool = True,
replay_fps: float = 5.0):
self._npy_dir = npy_dir
self._use_mti = use_mti
self._replay_fps = max(replay_fps, 0.1)
self._lock = threading.Lock()
self.is_open = False
self._packets: bytes = b""
self._read_offset = 0
self._frame_len = 0
# Current signal-processing parameters
self._mti_enable: bool = use_mti
self._dc_notch_width: int = 2
self._cfar_guard: int = 2
self._cfar_train: int = 8
self._cfar_alpha: int = 0x30
self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO
self._cfar_enable: bool = True
self._detect_threshold: int = 10000 # RTL default (host_detect_threshold)
# Raw source arrays (loaded once, reprocessed on param change)
self._dop_mti_i: np.ndarray | None = None
self._dop_mti_q: np.ndarray | None = None
self._dop_nomti_i: np.ndarray | None = None
self._dop_nomti_q: np.ndarray | None = None
self._range_i_vec: np.ndarray | None = None
self._range_q_vec: np.ndarray | None = None
# Rebuild flag
self._needs_rebuild = False
def open(self, _device_index: int = 0) -> bool:
try:
self._load_arrays()
self._packets = self._build_packets()
self._frame_len = len(self._packets)
self._read_offset = 0
self.is_open = True
log.info(f"Replay connection opened: {self._npy_dir} "
f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
f"{self._frame_len} bytes/frame)")
return True
except (OSError, ValueError, IndexError, struct.error) as e:
log.error(f"Replay open failed: {e}")
return False
def close(self):
self.is_open = False
def read(self, size: int = 4096) -> bytes | None:
if not self.is_open:
return None
# Pace reads to target FPS (spread across ~64 reads per frame)
time.sleep((1.0 / self._replay_fps) / (NUM_CELLS / 32))
with self._lock:
# If params changed, rebuild packets
if self._needs_rebuild:
self._packets = self._build_packets()
self._frame_len = len(self._packets)
self._read_offset = 0
self._needs_rebuild = False
end = self._read_offset + size
if end <= self._frame_len:
chunk = self._packets[self._read_offset:end]
self._read_offset = end
else:
chunk = self._packets[self._read_offset:]
self._read_offset = 0
return chunk
def write(self, data: bytes) -> bool:
"""
Handle host commands in replay mode.
Signal-processing params (CFAR, MTI, DC notch) trigger re-processing.
Hardware-only params are silently ignored.
"""
if len(data) < 4:
return True
word = struct.unpack(">I", data[:4])[0]
opcode = (word >> 24) & 0xFF
value = word & 0xFFFF
if opcode in _REPLAY_ADJUSTABLE_OPCODES:
changed = False
with self._lock:
if opcode == 0x03: # DETECT_THRESHOLD
if self._detect_threshold != value:
self._detect_threshold = value
changed = True
elif opcode == 0x21: # CFAR_GUARD
if self._cfar_guard != value:
self._cfar_guard = value
changed = True
elif opcode == 0x22: # CFAR_TRAIN
if self._cfar_train != value:
self._cfar_train = value
changed = True
elif opcode == 0x23: # CFAR_ALPHA
if self._cfar_alpha != value:
self._cfar_alpha = value
changed = True
elif opcode == 0x24: # CFAR_MODE
if self._cfar_mode != value:
self._cfar_mode = value
changed = True
elif opcode == 0x25: # CFAR_ENABLE
new_en = bool(value)
if self._cfar_enable != new_en:
self._cfar_enable = new_en
changed = True
elif opcode == 0x26: # MTI_ENABLE
new_en = bool(value)
if self._mti_enable != new_en:
self._mti_enable = new_en
changed = True
elif opcode == 0x27 and self._dc_notch_width != value: # DC_NOTCH_WIDTH
self._dc_notch_width = value
changed = True
if changed:
self._needs_rebuild = True
if changed:
log.info(f"Replay param updated: opcode=0x{opcode:02X} "
f"value={value} — will re-process")
else:
log.debug(f"Replay param unchanged: opcode=0x{opcode:02X} "
f"value={value}")
elif opcode in _HARDWARE_ONLY_OPCODES:
log.debug(f"Replay: hardware-only opcode 0x{opcode:02X} "
f"(ignored in replay mode)")
else:
log.debug(f"Replay: unknown opcode 0x{opcode:02X} (ignored)")
return True
def _load_arrays(self):
"""Load source npy arrays once."""
npy = self._npy_dir
# MTI Doppler
self._dop_mti_i = np.load(
os.path.join(npy, "fullchain_mti_doppler_i.npy")).astype(np.int64)
self._dop_mti_q = np.load(
os.path.join(npy, "fullchain_mti_doppler_q.npy")).astype(np.int64)
# Non-MTI Doppler
self._dop_nomti_i = np.load(
os.path.join(npy, "doppler_map_i.npy")).astype(np.int64)
self._dop_nomti_q = np.load(
os.path.join(npy, "doppler_map_q.npy")).astype(np.int64)
# Range data
try:
range_i_all = np.load(
os.path.join(npy, "decimated_range_i.npy")).astype(np.int64)
range_q_all = np.load(
os.path.join(npy, "decimated_range_q.npy")).astype(np.int64)
self._range_i_vec = range_i_all[-1, :] # last chirp
self._range_q_vec = range_q_all[-1, :]
except FileNotFoundError:
self._range_i_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64)
self._range_q_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64)
def _build_packets(self) -> bytes:
"""Build a full frame of USB data packets from current params."""
# Select Doppler data based on MTI
if self._mti_enable:
dop_i = self._dop_mti_i
dop_q = self._dop_mti_q
else:
dop_i = self._dop_nomti_i
dop_q = self._dop_nomti_q
# Apply DC notch
dop_i, dop_q = _replay_dc_notch(dop_i, dop_q, self._dc_notch_width)
# Run CFAR
if self._cfar_enable:
det, _mag = _replay_cfar(
dop_i, dop_q,
guard=self._cfar_guard,
train=self._cfar_train,
alpha_q44=self._cfar_alpha,
mode=self._cfar_mode,
)
else:
# Simple threshold fallback matching RTL cfar_ca.v:
# detect = (|I| + |Q|) > detect_threshold (L1 norm)
mag = np.abs(dop_i) + np.abs(dop_q)
det = mag > self._detect_threshold
det_count = int(det.sum())
log.info(f"Replay: rebuilt {NUM_CELLS} packets ("
f"MTI={'ON' if self._mti_enable else 'OFF'}, "
f"DC_notch={self._dc_notch_width}, "
f"CFAR={'ON' if self._cfar_enable else 'OFF'} "
f"G={self._cfar_guard} T={self._cfar_train} "
f"a=0x{self._cfar_alpha:02X} m={self._cfar_mode}, "
f"{det_count} detections)")
range_i = self._range_i_vec
range_q = self._range_q_vec
return self._build_packets_data(range_i, range_q, dop_i, dop_q, det)
def _build_packets_data(self, range_i, range_q, dop_i, dop_q, det) -> bytes:
"""Build 11-byte data packets for FT2232H interface."""
buf = bytearray(NUM_CELLS * DATA_PACKET_SIZE)
pos = 0
for rbin in range(NUM_RANGE_BINS):
ri = int(np.clip(range_i[rbin], -32768, 32767))
rq = int(np.clip(range_q[rbin], -32768, 32767))
rq_bytes = struct.pack(">h", rq)
ri_bytes = struct.pack(">h", ri)
for dbin in range(NUM_DOPPLER_BINS):
di = int(np.clip(dop_i[rbin, dbin], -32768, 32767))
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
return bytes(buf)
# ============================================================================ # ============================================================================
@@ -3,8 +3,8 @@
Tests for AERIS-10 Radar Dashboard protocol parsing, command building, Tests for AERIS-10 Radar Dashboard protocol parsing, command building,
data recording, and acquisition logic. data recording, and acquisition logic.
Run: python -m pytest test_radar_dashboard.py -v Run: python -m pytest test_GUI_V65_Tk.py -v
or: python test_radar_dashboard.py or: python test_GUI_V65_Tk.py
""" """
import struct import struct
@@ -19,10 +19,10 @@ from radar_protocol import (
RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition, RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition,
RadarFrame, StatusResponse, Opcode, RadarFrame, StatusResponse, Opcode,
HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE, HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE,
NUM_RANGE_BINS, NUM_DOPPLER_BINS, NUM_CELLS, NUM_RANGE_BINS, NUM_DOPPLER_BINS,
DATA_PACKET_SIZE, DATA_PACKET_SIZE,
_HARDWARE_ONLY_OPCODES,
) )
from GUI_V65_Tk import DemoTarget, DemoSimulator, _ReplayController
class TestRadarProtocol(unittest.TestCase): class TestRadarProtocol(unittest.TestCase):
@@ -459,218 +459,6 @@ class TestEndToEnd(unittest.TestCase):
self.assertEqual(result["detection"], 1) self.assertEqual(result["detection"], 1)
class TestReplayConnection(unittest.TestCase):
"""Test ReplayConnection with real .npy data files."""
NPY_DIR = os.path.join(
os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim",
"real_data", "hex"
)
def _npy_available(self):
"""Check if the npy data files exist."""
return os.path.isfile(os.path.join(self.NPY_DIR,
"fullchain_mti_doppler_i.npy"))
def test_replay_open_close(self):
"""ReplayConnection opens and closes without error."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
self.assertTrue(conn.open())
self.assertTrue(conn.is_open)
conn.close()
self.assertFalse(conn.is_open)
def test_replay_packet_count(self):
"""Replay builds exactly NUM_CELLS (2048) packets."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Each packet is 11 bytes, total = 2048 * 11
expected_bytes = NUM_CELLS * DATA_PACKET_SIZE
self.assertEqual(conn._frame_len, expected_bytes)
conn.close()
def test_replay_packets_parseable(self):
"""Every packet from replay can be parsed by RadarProtocol."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
raw = conn._packets
boundaries = RadarProtocol.find_packet_boundaries(raw)
self.assertEqual(len(boundaries), NUM_CELLS)
parsed_count = 0
det_count = 0
for start, end, ptype in boundaries:
self.assertEqual(ptype, "data")
result = RadarProtocol.parse_data_packet(raw[start:end])
self.assertIsNotNone(result)
parsed_count += 1
if result["detection"]:
det_count += 1
self.assertEqual(parsed_count, NUM_CELLS)
# Default: MTI=ON, DC_notch=2, CFAR CA g=2 t=8 a=0x30 → 4 detections
self.assertEqual(det_count, 4)
conn.close()
def test_replay_read_loops(self):
"""Read returns data and loops back around."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True, replay_fps=1000)
conn.open()
total_read = 0
for _ in range(100):
chunk = conn.read(1024)
self.assertIsNotNone(chunk)
total_read += len(chunk)
self.assertGreater(total_read, 0)
conn.close()
def test_replay_no_mti(self):
"""ReplayConnection works with use_mti=False (CFAR still runs)."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=False)
conn.open()
self.assertEqual(conn._frame_len, NUM_CELLS * DATA_PACKET_SIZE)
# No-MTI with DC notch=2 and default CFAR → 0 detections
raw = conn._packets
boundaries = RadarProtocol.find_packet_boundaries(raw)
det_count = sum(1 for s, e, t in boundaries
if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0))
self.assertEqual(det_count, 0)
conn.close()
def test_replay_write_returns_true(self):
"""Write on replay connection returns True."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR)
conn.open()
self.assertTrue(conn.write(b"\x01\x00\x00\x01"))
conn.close()
def test_replay_adjustable_param_cfar_guard(self):
"""Changing CFAR guard via write() triggers re-processing."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Initial: guard=2 → 4 detections
self.assertFalse(conn._needs_rebuild)
# Send CFAR_GUARD=4
cmd = RadarProtocol.build_command(0x21, 4)
conn.write(cmd)
self.assertTrue(conn._needs_rebuild)
self.assertEqual(conn._cfar_guard, 4)
# Read triggers rebuild
conn.read(1024)
self.assertFalse(conn._needs_rebuild)
conn.close()
def test_replay_adjustable_param_mti_toggle(self):
"""Toggling MTI via write() triggers re-processing."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Disable MTI
cmd = RadarProtocol.build_command(0x26, 0)
conn.write(cmd)
self.assertTrue(conn._needs_rebuild)
self.assertFalse(conn._mti_enable)
# Read to trigger rebuild, then count detections
# Drain all packets after rebuild
conn.read(1024) # triggers rebuild
raw = conn._packets
boundaries = RadarProtocol.find_packet_boundaries(raw)
det_count = sum(1 for s, e, t in boundaries
if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0))
# No-MTI with default CFAR → 0 detections
self.assertEqual(det_count, 0)
conn.close()
def test_replay_adjustable_param_dc_notch(self):
"""Changing DC notch width via write() triggers re-processing."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Change DC notch to 0 (no notch)
cmd = RadarProtocol.build_command(0x27, 0)
conn.write(cmd)
self.assertTrue(conn._needs_rebuild)
self.assertEqual(conn._dc_notch_width, 0)
conn.read(1024) # triggers rebuild
raw = conn._packets
boundaries = RadarProtocol.find_packet_boundaries(raw)
det_count = sum(1 for s, e, t in boundaries
if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0))
# DC notch=0 with MTI → 6 detections (more noise passes through)
self.assertEqual(det_count, 6)
conn.close()
def test_replay_hardware_opcode_ignored(self):
"""Hardware-only opcodes don't trigger rebuild."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Send TRIGGER (hardware-only)
cmd = RadarProtocol.build_command(0x01, 1)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
# Send STREAM_CONTROL (hardware-only, opcode 0x04)
cmd = RadarProtocol.build_command(0x04, 7)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
conn.close()
def test_replay_same_value_no_rebuild(self):
"""Setting same value as current doesn't trigger rebuild."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# CFAR guard already 2
cmd = RadarProtocol.build_command(0x21, 2)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
conn.close()
def test_replay_self_test_opcodes_are_hardware_only(self):
"""Self-test opcodes 0x30/0x31 are hardware-only (ignored in replay)."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Send self-test trigger
cmd = RadarProtocol.build_command(0x30, 1)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
# Send self-test status request
cmd = RadarProtocol.build_command(0x31, 0)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
conn.close()
class TestOpcodeEnum(unittest.TestCase): class TestOpcodeEnum(unittest.TestCase):
"""Verify Opcode enum matches RTL host register map (radar_system_top.v).""" """Verify Opcode enum matches RTL host register map (radar_system_top.v)."""
@@ -690,15 +478,6 @@ class TestOpcodeEnum(unittest.TestCase):
"""SELF_TEST_STATUS opcode must be 0x31.""" """SELF_TEST_STATUS opcode must be 0x31."""
self.assertEqual(Opcode.SELF_TEST_STATUS, 0x31) self.assertEqual(Opcode.SELF_TEST_STATUS, 0x31)
def test_self_test_in_hardware_only(self):
"""Self-test opcodes must be in _HARDWARE_ONLY_OPCODES."""
self.assertIn(0x30, _HARDWARE_ONLY_OPCODES)
self.assertIn(0x31, _HARDWARE_ONLY_OPCODES)
def test_0x16_in_hardware_only(self):
"""GAIN_SHIFT 0x16 must be in _HARDWARE_ONLY_OPCODES."""
self.assertIn(0x16, _HARDWARE_ONLY_OPCODES)
def test_stream_control_is_0x04(self): def test_stream_control_is_0x04(self):
"""STREAM_CONTROL must be 0x04 (matches radar_system_top.v:906).""" """STREAM_CONTROL must be 0x04 (matches radar_system_top.v:906)."""
self.assertEqual(Opcode.STREAM_CONTROL, 0x04) self.assertEqual(Opcode.STREAM_CONTROL, 0x04)
@@ -717,11 +496,6 @@ class TestOpcodeEnum(unittest.TestCase):
self.assertEqual(Opcode.DETECT_THRESHOLD, 0x03) self.assertEqual(Opcode.DETECT_THRESHOLD, 0x03)
self.assertEqual(Opcode.STREAM_CONTROL, 0x04) self.assertEqual(Opcode.STREAM_CONTROL, 0x04)
def test_stale_opcodes_not_in_hardware_only(self):
"""Old wrong opcode values must not be in _HARDWARE_ONLY_OPCODES."""
self.assertNotIn(0x05, _HARDWARE_ONLY_OPCODES) # was wrong STREAM_ENABLE
self.assertNotIn(0x06, _HARDWARE_ONLY_OPCODES) # was wrong GAIN_SHIFT
def test_all_rtl_opcodes_present(self): def test_all_rtl_opcodes_present(self):
"""Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member.""" """Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member."""
expected = {0x01, 0x02, 0x03, 0x04, expected = {0x01, 0x02, 0x03, 0x04,
@@ -946,5 +720,199 @@ class TestAGCVisualizationHistory(unittest.TestCase):
self.assertAlmostEqual(max(200 * 1.5, 5), 300.0) self.assertAlmostEqual(max(200 * 1.5, 5), 300.0)
# =====================================================================
# Tests for DemoTarget, DemoSimulator, and _ReplayController
# =====================================================================
class TestDemoTarget(unittest.TestCase):
"""Unit tests for DemoTarget kinematics."""
def test_initial_values_in_range(self):
t = DemoTarget(1)
self.assertEqual(t.id, 1)
self.assertGreaterEqual(t.range_m, 20)
self.assertLessEqual(t.range_m, DemoTarget._MAX_RANGE)
self.assertIn(t.classification, ["aircraft", "drone", "bird", "unknown"])
def test_step_returns_true_in_normal_range(self):
t = DemoTarget(2)
t.range_m = 150.0
t.velocity = 0.0
self.assertTrue(t.step())
def test_step_returns_false_when_out_of_range_high(self):
t = DemoTarget(3)
t.range_m = DemoTarget._MAX_RANGE + 1
t.velocity = -1.0 # moving away
self.assertFalse(t.step())
def test_step_returns_false_when_out_of_range_low(self):
t = DemoTarget(4)
t.range_m = 2.0
t.velocity = 1.0 # moving closer
self.assertFalse(t.step())
def test_velocity_clamped(self):
t = DemoTarget(5)
t.velocity = 19.0
t.range_m = 150.0
# Step many times — velocity should stay within [-20, 20]
for _ in range(100):
t.range_m = 150.0 # keep in range
t.step()
self.assertGreaterEqual(t.velocity, -20)
self.assertLessEqual(t.velocity, 20)
def test_snr_clamped(self):
t = DemoTarget(6)
t.snr = 49.5
t.range_m = 150.0
for _ in range(100):
t.range_m = 150.0
t.step()
self.assertGreaterEqual(t.snr, 0)
self.assertLessEqual(t.snr, 50)
class TestDemoSimulatorNoTk(unittest.TestCase):
"""Test DemoSimulator logic without a real Tk event loop.
We replace ``root.after`` with a mock to avoid needing a display.
"""
def _make_simulator(self):
from unittest.mock import MagicMock
fq = queue.Queue(maxsize=100)
uq = queue.Queue(maxsize=100)
mock_root = MagicMock()
# root.after(ms, fn) should return an id (str)
mock_root.after.return_value = "mock_after_id"
sim = DemoSimulator(fq, uq, mock_root, interval_ms=100)
return sim, fq, uq, mock_root
def test_initial_targets_created(self):
sim, _fq, _uq, _root = self._make_simulator()
# Should seed 8 initial targets
self.assertEqual(len(sim._targets), 8)
def test_tick_produces_frame_and_targets(self):
sim, fq, uq, _root = self._make_simulator()
sim._tick()
# Should have a frame
self.assertFalse(fq.empty())
frame = fq.get_nowait()
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.frame_number, 1)
# Should have demo_targets in ui_queue
tag, payload = uq.get_nowait()
self.assertEqual(tag, "demo_targets")
self.assertIsInstance(payload, list)
def test_tick_produces_nonzero_detections(self):
"""Demo targets should actually render into the range-Doppler grid."""
sim, fq, _uq, _root = self._make_simulator()
sim._tick()
frame = fq.get_nowait()
# At least some targets should produce magnitude > 0 and detections
self.assertGreater(frame.magnitude.sum(), 0,
"Demo targets should render into range-Doppler grid")
self.assertGreater(frame.detection_count, 0,
"Demo targets should produce detections")
def test_stop_cancels_after(self):
sim, _fq, _uq, mock_root = self._make_simulator()
sim._tick() # sets _after_id
sim.stop()
mock_root.after_cancel.assert_called_once_with("mock_after_id")
self.assertIsNone(sim._after_id)
class TestReplayController(unittest.TestCase):
"""Unit tests for _ReplayController (no GUI required)."""
def test_initial_state(self):
fq = queue.Queue()
uq = queue.Queue()
ctrl = _ReplayController(fq, uq)
self.assertEqual(ctrl.total_frames, 0)
self.assertEqual(ctrl.current_index, 0)
self.assertFalse(ctrl.is_playing)
self.assertIsNone(ctrl.software_fpga)
def test_set_speed(self):
ctrl = _ReplayController(queue.Queue(), queue.Queue())
ctrl.set_speed("2x")
self.assertAlmostEqual(ctrl._frame_interval, 0.050)
def test_set_speed_unknown_falls_back(self):
ctrl = _ReplayController(queue.Queue(), queue.Queue())
ctrl.set_speed("99x")
self.assertAlmostEqual(ctrl._frame_interval, 0.100)
def test_set_loop(self):
ctrl = _ReplayController(queue.Queue(), queue.Queue())
ctrl.set_loop(True)
self.assertTrue(ctrl._loop)
ctrl.set_loop(False)
self.assertFalse(ctrl._loop)
def test_seek_increments_past_emitted(self):
"""After seek(), _current_index should be one past the seeked frame."""
fq = queue.Queue(maxsize=100)
uq = queue.Queue(maxsize=100)
ctrl = _ReplayController(fq, uq)
# Manually set engine to a mock to allow seek
from unittest.mock import MagicMock
mock_engine = MagicMock()
mock_engine.total_frames = 10
mock_engine.get_frame.return_value = RadarFrame()
ctrl._engine = mock_engine
ctrl.seek(5)
# _current_index should be 6 (past the emitted frame)
self.assertEqual(ctrl._current_index, 6)
self.assertEqual(ctrl._last_emitted_index, 5)
# Frame should be in the queue
self.assertFalse(fq.empty())
def test_seek_clamps_to_bounds(self):
from unittest.mock import MagicMock
fq = queue.Queue(maxsize=100)
uq = queue.Queue(maxsize=100)
ctrl = _ReplayController(fq, uq)
mock_engine = MagicMock()
mock_engine.total_frames = 5
mock_engine.get_frame.return_value = RadarFrame()
ctrl._engine = mock_engine
ctrl.seek(100)
# Should clamp to last frame (index 4), then _current_index = 5
self.assertEqual(ctrl._last_emitted_index, 4)
self.assertEqual(ctrl._current_index, 5)
ctrl.seek(-10)
# Should clamp to 0, then _current_index = 1
self.assertEqual(ctrl._last_emitted_index, 0)
self.assertEqual(ctrl._current_index, 1)
def test_close_releases_engine(self):
from unittest.mock import MagicMock
fq = queue.Queue(maxsize=100)
uq = queue.Queue(maxsize=100)
ctrl = _ReplayController(fq, uq)
mock_engine = MagicMock()
mock_engine.total_frames = 5
mock_engine.get_frame.return_value = RadarFrame()
ctrl._engine = mock_engine
ctrl.close()
mock_engine.close.assert_called_once()
self.assertIsNone(ctrl._engine)
self.assertIsNone(ctrl.software_fpga)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)
+586 -8
View File
@@ -11,6 +11,7 @@ Does NOT require a running Qt event loop — only unit-testable components.
Run with: python -m unittest test_v7 -v Run with: python -m unittest test_v7 -v
""" """
import os
import struct import struct
import unittest import unittest
from dataclasses import asdict from dataclasses import asdict
@@ -57,16 +58,16 @@ class TestRadarSettings(unittest.TestCase):
def test_has_physical_conversion_fields(self): def test_has_physical_conversion_fields(self):
s = _models().RadarSettings() s = _models().RadarSettings()
self.assertIsInstance(s.range_resolution, float) self.assertIsInstance(s.range_bin_spacing, float)
self.assertIsInstance(s.velocity_resolution, float) self.assertIsInstance(s.velocity_resolution, float)
self.assertGreater(s.range_resolution, 0) self.assertGreater(s.range_bin_spacing, 0)
self.assertGreater(s.velocity_resolution, 0) self.assertGreater(s.velocity_resolution, 0)
def test_defaults(self): def test_defaults(self):
s = _models().RadarSettings() s = _models().RadarSettings()
self.assertEqual(s.system_frequency, 10e9) self.assertEqual(s.system_frequency, 10.5e9)
self.assertEqual(s.coverage_radius, 50000) self.assertEqual(s.coverage_radius, 1536)
self.assertEqual(s.max_distance, 50000) self.assertEqual(s.max_distance, 1536)
class TestGPSData(unittest.TestCase): class TestGPSData(unittest.TestCase):
@@ -264,6 +265,15 @@ class TestUSBPacketParser(unittest.TestCase):
# Test: v7.workers — polar_to_geographic # Test: v7.workers — polar_to_geographic
# ============================================================================= # =============================================================================
def _pyqt6_available():
try:
import PyQt6.QtCore # noqa: F401
return True
except ImportError:
return False
@unittest.skipUnless(_pyqt6_available(), "PyQt6 not installed")
class TestPolarToGeographic(unittest.TestCase): class TestPolarToGeographic(unittest.TestCase):
def test_north_bearing(self): def test_north_bearing(self):
from v7.workers import polar_to_geographic from v7.workers import polar_to_geographic
@@ -326,12 +336,16 @@ class TestV7Init(unittest.TestCase):
def test_key_exports(self): def test_key_exports(self):
import v7 import v7
# Core exports (no PyQt6 required)
for name in ["RadarTarget", "RadarSettings", "GPSData", for name in ["RadarTarget", "RadarSettings", "GPSData",
"ProcessingConfig", "FT2232HConnection", "ProcessingConfig", "FT2232HConnection",
"RadarProtocol", "RadarProcessor", "RadarProtocol", "RadarProcessor"]:
"RadarDataWorker", "RadarMapWidget",
"RadarDashboard"]:
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}") self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
# PyQt6-dependent exports — only present when PyQt6 is installed
if _pyqt6_available():
for name in ["RadarDataWorker", "RadarMapWidget",
"RadarDashboard"]:
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
# ============================================================================= # =============================================================================
@@ -401,6 +415,570 @@ class TestAGCVisualizationV7(unittest.TestCase):
self.assertEqual(pick_color(11), DARK_ERROR) self.assertEqual(pick_color(11), DARK_ERROR)
# =============================================================================
# Test: v7.models.WaveformConfig
# =============================================================================
class TestWaveformConfig(unittest.TestCase):
"""WaveformConfig dataclass and derived physical properties."""
def test_defaults(self):
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertEqual(wc.sample_rate_hz, 100e6)
self.assertEqual(wc.bandwidth_hz, 20e6)
self.assertEqual(wc.chirp_duration_s, 30e-6)
self.assertEqual(wc.pri_s, 167e-6)
self.assertEqual(wc.center_freq_hz, 10.5e9)
self.assertEqual(wc.n_range_bins, 64)
self.assertEqual(wc.n_doppler_bins, 32)
self.assertEqual(wc.fft_size, 1024)
self.assertEqual(wc.decimation_factor, 16)
def test_range_resolution(self):
"""bin_spacing_m should be ~24.0 m/bin with PLFM defaults."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(wc.bin_spacing_m, 23.98, places=1)
def test_range_resolution_physical(self):
"""range_resolution_m = c/(2*BW), ~7.5 m at 20 MHz BW."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(wc.range_resolution_m, 7.49, places=1)
# 30 MHz BW → 5.0 m resolution
wc30 = WaveformConfig(bandwidth_hz=30e6)
self.assertAlmostEqual(wc30.range_resolution_m, 4.996, places=1)
def test_velocity_resolution(self):
"""velocity_resolution_mps should be ~2.67 m/s/bin."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(wc.velocity_resolution_mps, 2.67, places=1)
def test_max_range(self):
"""max_range_m = bin_spacing * n_range_bins."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(wc.max_range_m, wc.bin_spacing_m * 64, places=1)
def test_max_velocity(self):
"""max_velocity_mps = velocity_resolution * n_doppler_bins / 2."""
from v7.models import WaveformConfig
wc = WaveformConfig()
self.assertAlmostEqual(
wc.max_velocity_mps,
wc.velocity_resolution_mps * 16,
places=2,
)
def test_custom_params(self):
"""Non-default parameters correctly change derived values."""
from v7.models import WaveformConfig
wc1 = WaveformConfig()
# Matched-filter: bin_spacing = c/(2*fs)*dec — proportional to 1/fs
wc2 = WaveformConfig(sample_rate_hz=200e6) # double fs → halve bin spacing
self.assertAlmostEqual(wc2.bin_spacing_m, wc1.bin_spacing_m / 2, places=2)
def test_zero_center_freq_velocity(self):
"""Zero center freq should cause ZeroDivisionError in velocity calc."""
from v7.models import WaveformConfig
wc = WaveformConfig(center_freq_hz=0.0)
with self.assertRaises(ZeroDivisionError):
_ = wc.velocity_resolution_mps
# =============================================================================
# Test: v7.software_fpga.SoftwareFPGA
# =============================================================================
class TestSoftwareFPGA(unittest.TestCase):
"""SoftwareFPGA register interface and signal chain."""
def _make_fpga(self):
from v7.software_fpga import SoftwareFPGA
return SoftwareFPGA()
def test_reset_defaults(self):
"""Register reset values match FPGA RTL (radar_system_top.v)."""
fpga = self._make_fpga()
self.assertEqual(fpga.detect_threshold, 10_000)
self.assertEqual(fpga.gain_shift, 0)
self.assertFalse(fpga.cfar_enable)
self.assertEqual(fpga.cfar_guard, 2)
self.assertEqual(fpga.cfar_train, 8)
self.assertEqual(fpga.cfar_alpha, 0x30)
self.assertEqual(fpga.cfar_mode, 0)
self.assertFalse(fpga.mti_enable)
self.assertEqual(fpga.dc_notch_width, 0)
self.assertFalse(fpga.agc_enable)
self.assertEqual(fpga.agc_target, 200)
self.assertEqual(fpga.agc_attack, 1)
self.assertEqual(fpga.agc_decay, 1)
self.assertEqual(fpga.agc_holdoff, 4)
def test_setter_detect_threshold(self):
fpga = self._make_fpga()
fpga.set_detect_threshold(5000)
self.assertEqual(fpga.detect_threshold, 5000)
def test_setter_detect_threshold_clamp_16bit(self):
fpga = self._make_fpga()
fpga.set_detect_threshold(0x1FFFF) # 17-bit
self.assertEqual(fpga.detect_threshold, 0xFFFF)
def test_setter_gain_shift_clamp_4bit(self):
fpga = self._make_fpga()
fpga.set_gain_shift(0xFF)
self.assertEqual(fpga.gain_shift, 0x0F)
def test_setter_cfar_enable(self):
fpga = self._make_fpga()
fpga.set_cfar_enable(True)
self.assertTrue(fpga.cfar_enable)
fpga.set_cfar_enable(False)
self.assertFalse(fpga.cfar_enable)
def test_setter_cfar_guard_clamp_4bit(self):
fpga = self._make_fpga()
fpga.set_cfar_guard(0x1F)
self.assertEqual(fpga.cfar_guard, 0x0F)
def test_setter_cfar_train_min_1(self):
"""CFAR train cells clamped to min 1."""
fpga = self._make_fpga()
fpga.set_cfar_train(0)
self.assertEqual(fpga.cfar_train, 1)
def test_setter_cfar_train_clamp_5bit(self):
fpga = self._make_fpga()
fpga.set_cfar_train(0x3F)
self.assertEqual(fpga.cfar_train, 0x1F)
def test_setter_cfar_alpha_clamp_8bit(self):
fpga = self._make_fpga()
fpga.set_cfar_alpha(0x1FF)
self.assertEqual(fpga.cfar_alpha, 0xFF)
def test_setter_cfar_mode_clamp_2bit(self):
fpga = self._make_fpga()
fpga.set_cfar_mode(7)
self.assertEqual(fpga.cfar_mode, 3)
def test_setter_mti_enable(self):
fpga = self._make_fpga()
fpga.set_mti_enable(True)
self.assertTrue(fpga.mti_enable)
def test_setter_dc_notch_clamp_3bit(self):
fpga = self._make_fpga()
fpga.set_dc_notch_width(0xFF)
self.assertEqual(fpga.dc_notch_width, 7)
def test_setter_agc_params_selective(self):
"""set_agc_params only changes provided fields."""
fpga = self._make_fpga()
fpga.set_agc_params(target=100)
self.assertEqual(fpga.agc_target, 100)
self.assertEqual(fpga.agc_attack, 1) # unchanged
fpga.set_agc_params(attack=3, decay=5)
self.assertEqual(fpga.agc_attack, 3)
self.assertEqual(fpga.agc_decay, 5)
self.assertEqual(fpga.agc_target, 100) # unchanged
def test_setter_agc_params_clamp(self):
fpga = self._make_fpga()
fpga.set_agc_params(target=0xFFF, attack=0xFF, decay=0xFF, holdoff=0xFF)
self.assertEqual(fpga.agc_target, 0xFF)
self.assertEqual(fpga.agc_attack, 0x0F)
self.assertEqual(fpga.agc_decay, 0x0F)
self.assertEqual(fpga.agc_holdoff, 0x0F)
class TestSoftwareFPGASignalChain(unittest.TestCase):
"""SoftwareFPGA.process_chirps with real co-sim data."""
COSIM_DIR = os.path.join(
os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim",
"real_data", "hex"
)
def _cosim_available(self):
return os.path.isfile(os.path.join(self.COSIM_DIR, "doppler_map_i.npy"))
def test_process_chirps_returns_radar_frame(self):
"""process_chirps produces a RadarFrame with correct shapes."""
if not self._cosim_available():
self.skipTest("co-sim data not found")
from v7.software_fpga import SoftwareFPGA
from radar_protocol import RadarFrame
# Load decimated range data as minimal input (32 chirps x 64 bins)
dec_i = np.load(os.path.join(self.COSIM_DIR, "decimated_range_i.npy"))
dec_q = np.load(os.path.join(self.COSIM_DIR, "decimated_range_q.npy"))
# Build fake 1024-sample chirps from decimated data (pad with zeros)
n_chirps = dec_i.shape[0]
iq_i = np.zeros((n_chirps, 1024), dtype=np.int64)
iq_q = np.zeros((n_chirps, 1024), dtype=np.int64)
# Put decimated data into first 64 bins so FFT has something
iq_i[:, :dec_i.shape[1]] = dec_i
iq_q[:, :dec_q.shape[1]] = dec_q
fpga = SoftwareFPGA()
frame = fpga.process_chirps(iq_i, iq_q, frame_number=42, timestamp=1.0)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.frame_number, 42)
self.assertAlmostEqual(frame.timestamp, 1.0)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.range_doppler_q.shape, (64, 32))
self.assertEqual(frame.magnitude.shape, (64, 32))
self.assertEqual(frame.detections.shape, (64, 32))
self.assertEqual(frame.range_profile.shape, (64,))
self.assertEqual(frame.detection_count, int(frame.detections.sum()))
def test_cfar_enable_changes_detections(self):
"""Enabling CFAR vs simple threshold should yield different detection counts."""
if not self._cosim_available():
self.skipTest("co-sim data not found")
from v7.software_fpga import SoftwareFPGA
iq_i = np.zeros((32, 1024), dtype=np.int64)
iq_q = np.zeros((32, 1024), dtype=np.int64)
# Inject a single strong tone in bin 10 of every chirp
iq_i[:, 10] = 5000
iq_q[:, 10] = 3000
fpga_thresh = SoftwareFPGA()
fpga_thresh.set_detect_threshold(1) # very low → many detections
frame_thresh = fpga_thresh.process_chirps(iq_i, iq_q)
fpga_cfar = SoftwareFPGA()
fpga_cfar.set_cfar_enable(True)
fpga_cfar.set_cfar_alpha(0x10) # low alpha → more detections
frame_cfar = fpga_cfar.process_chirps(iq_i, iq_q)
# Just verify both produce valid frames — exact counts depend on chain
self.assertIsNotNone(frame_thresh)
self.assertIsNotNone(frame_cfar)
self.assertEqual(frame_thresh.magnitude.shape, (64, 32))
self.assertEqual(frame_cfar.magnitude.shape, (64, 32))
class TestQuantizeRawIQ(unittest.TestCase):
"""quantize_raw_iq utility function."""
def test_3d_input(self):
"""3-D (frames, chirps, samples) → uses first frame."""
from v7.software_fpga import quantize_raw_iq
raw = np.random.randn(5, 32, 1024) + 1j * np.random.randn(5, 32, 1024)
iq_i, iq_q = quantize_raw_iq(raw)
self.assertEqual(iq_i.shape, (32, 1024))
self.assertEqual(iq_q.shape, (32, 1024))
self.assertTrue(np.all(np.abs(iq_i) <= 32767))
self.assertTrue(np.all(np.abs(iq_q) <= 32767))
def test_2d_input(self):
"""2-D (chirps, samples) → works directly."""
from v7.software_fpga import quantize_raw_iq
raw = np.random.randn(32, 1024) + 1j * np.random.randn(32, 1024)
iq_i, _iq_q = quantize_raw_iq(raw)
self.assertEqual(iq_i.shape, (32, 1024))
def test_zero_input(self):
"""All-zero complex input → all-zero output."""
from v7.software_fpga import quantize_raw_iq
raw = np.zeros((32, 1024), dtype=np.complex128)
iq_i, iq_q = quantize_raw_iq(raw)
self.assertTrue(np.all(iq_i == 0))
self.assertTrue(np.all(iq_q == 0))
def test_peak_target_scaling(self):
"""Peak of output should be near peak_target."""
from v7.software_fpga import quantize_raw_iq
raw = np.zeros((32, 1024), dtype=np.complex128)
raw[0, 0] = 1.0 + 0j # single peak
iq_i, _iq_q = quantize_raw_iq(raw, peak_target=500)
# The peak I value should be exactly 500 (sole max)
self.assertEqual(int(iq_i[0, 0]), 500)
# =============================================================================
# Test: v7.replay (ReplayEngine, detect_format)
# =============================================================================
class TestDetectFormat(unittest.TestCase):
"""detect_format auto-detection logic."""
COSIM_DIR = os.path.join(
os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim",
"real_data", "hex"
)
def test_cosim_dir(self):
if not os.path.isdir(self.COSIM_DIR):
self.skipTest("co-sim dir not found")
from v7.replay import detect_format, ReplayFormat
self.assertEqual(detect_format(self.COSIM_DIR), ReplayFormat.COSIM_DIR)
def test_npy_file(self):
"""A .npy file → RAW_IQ_NPY."""
from v7.replay import detect_format, ReplayFormat
import tempfile
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
np.save(f, np.zeros((2, 32, 1024), dtype=np.complex128))
tmp = f.name
try:
self.assertEqual(detect_format(tmp), ReplayFormat.RAW_IQ_NPY)
finally:
os.unlink(tmp)
def test_h5_file(self):
"""A .h5 file → HDF5."""
from v7.replay import detect_format, ReplayFormat
self.assertEqual(detect_format("/tmp/fake_recording.h5"), ReplayFormat.HDF5)
def test_unknown_extension_raises(self):
from v7.replay import detect_format
with self.assertRaises(ValueError):
detect_format("/tmp/data.csv")
def test_empty_dir_raises(self):
"""Directory without co-sim files → ValueError."""
from v7.replay import detect_format
import tempfile
with tempfile.TemporaryDirectory() as td, self.assertRaises(ValueError):
detect_format(td)
class TestReplayEngineCosim(unittest.TestCase):
"""ReplayEngine loading from FPGA co-sim directory."""
COSIM_DIR = os.path.join(
os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim",
"real_data", "hex"
)
def _available(self):
return os.path.isfile(os.path.join(self.COSIM_DIR, "doppler_map_i.npy"))
def test_load_cosim(self):
if not self._available():
self.skipTest("co-sim data not found")
from v7.replay import ReplayEngine, ReplayFormat
engine = ReplayEngine(self.COSIM_DIR)
self.assertEqual(engine.fmt, ReplayFormat.COSIM_DIR)
self.assertEqual(engine.total_frames, 1)
def test_get_frame_cosim(self):
if not self._available():
self.skipTest("co-sim data not found")
from v7.replay import ReplayEngine
from radar_protocol import RadarFrame
engine = ReplayEngine(self.COSIM_DIR)
frame = engine.get_frame(0)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.magnitude.shape, (64, 32))
def test_get_frame_out_of_range(self):
if not self._available():
self.skipTest("co-sim data not found")
from v7.replay import ReplayEngine
engine = ReplayEngine(self.COSIM_DIR)
with self.assertRaises(IndexError):
engine.get_frame(1)
with self.assertRaises(IndexError):
engine.get_frame(-1)
class TestReplayEngineRawIQ(unittest.TestCase):
"""ReplayEngine loading from raw IQ .npy cube."""
def test_load_raw_iq_synthetic(self):
"""Synthetic raw IQ cube loads and produces correct frame count."""
import tempfile
from v7.replay import ReplayEngine, ReplayFormat
from v7.software_fpga import SoftwareFPGA
raw = np.random.randn(3, 32, 1024) + 1j * np.random.randn(3, 32, 1024)
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
np.save(f, raw)
tmp = f.name
try:
fpga = SoftwareFPGA()
engine = ReplayEngine(tmp, software_fpga=fpga)
self.assertEqual(engine.fmt, ReplayFormat.RAW_IQ_NPY)
self.assertEqual(engine.total_frames, 3)
finally:
os.unlink(tmp)
def test_get_frame_raw_iq_synthetic(self):
"""get_frame on raw IQ runs SoftwareFPGA and returns RadarFrame."""
import tempfile
from v7.replay import ReplayEngine
from v7.software_fpga import SoftwareFPGA
from radar_protocol import RadarFrame
raw = np.random.randn(2, 32, 1024) + 1j * np.random.randn(2, 32, 1024)
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
np.save(f, raw)
tmp = f.name
try:
fpga = SoftwareFPGA()
engine = ReplayEngine(tmp, software_fpga=fpga)
frame = engine.get_frame(0)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
self.assertEqual(frame.frame_number, 0)
finally:
os.unlink(tmp)
def test_raw_iq_no_fpga_raises(self):
"""Raw IQ get_frame without SoftwareFPGA → RuntimeError."""
import tempfile
from v7.replay import ReplayEngine
raw = np.random.randn(1, 32, 1024) + 1j * np.random.randn(1, 32, 1024)
with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f:
np.save(f, raw)
tmp = f.name
try:
engine = ReplayEngine(tmp)
with self.assertRaises(RuntimeError):
engine.get_frame(0)
finally:
os.unlink(tmp)
class TestReplayEngineHDF5(unittest.TestCase):
"""ReplayEngine loading from HDF5 recordings."""
def _skip_no_h5py(self):
try:
import h5py # noqa: F401
except ImportError:
self.skipTest("h5py not installed")
def test_load_hdf5_synthetic(self):
"""Synthetic HDF5 loads and iterates frames."""
self._skip_no_h5py()
import tempfile
import h5py
from v7.replay import ReplayEngine, ReplayFormat
from radar_protocol import RadarFrame
with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f:
tmp = f.name
try:
with h5py.File(tmp, "w") as hf:
hf.attrs["creator"] = "test"
hf.attrs["range_bins"] = 64
hf.attrs["doppler_bins"] = 32
grp = hf.create_group("frames")
for i in range(3):
fg = grp.create_group(f"frame_{i:06d}")
fg.attrs["timestamp"] = float(i)
fg.attrs["frame_number"] = i
fg.attrs["detection_count"] = 0
fg.create_dataset("range_doppler_i",
data=np.zeros((64, 32), dtype=np.int16))
fg.create_dataset("range_doppler_q",
data=np.zeros((64, 32), dtype=np.int16))
fg.create_dataset("magnitude",
data=np.zeros((64, 32), dtype=np.float64))
fg.create_dataset("detections",
data=np.zeros((64, 32), dtype=np.uint8))
fg.create_dataset("range_profile",
data=np.zeros(64, dtype=np.float64))
engine = ReplayEngine(tmp)
self.assertEqual(engine.fmt, ReplayFormat.HDF5)
self.assertEqual(engine.total_frames, 3)
frame = engine.get_frame(1)
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.frame_number, 1)
self.assertEqual(frame.range_doppler_i.shape, (64, 32))
engine.close()
finally:
os.unlink(tmp)
# =============================================================================
# Test: v7.processing.extract_targets_from_frame
# =============================================================================
class TestExtractTargetsFromFrame(unittest.TestCase):
"""extract_targets_from_frame bin-to-physical conversion."""
def _make_frame(self, det_cells=None):
"""Create a minimal RadarFrame with optional detection cells."""
from radar_protocol import RadarFrame
frame = RadarFrame()
if det_cells:
for rbin, dbin in det_cells:
frame.detections[rbin, dbin] = 1
frame.magnitude[rbin, dbin] = 1000.0
frame.detection_count = int(frame.detections.sum())
frame.timestamp = 1.0
return frame
def test_no_detections(self):
from v7.processing import extract_targets_from_frame
frame = self._make_frame()
targets = extract_targets_from_frame(frame)
self.assertEqual(len(targets), 0)
def test_single_detection_range(self):
"""Detection at range bin 10 → range = 10 * range_resolution."""
from v7.processing import extract_targets_from_frame
frame = self._make_frame(det_cells=[(10, 16)]) # dbin=16 = center → vel=0
targets = extract_targets_from_frame(frame, bin_spacing=23.98)
self.assertEqual(len(targets), 1)
self.assertAlmostEqual(targets[0].range, 10 * 23.98, places=1)
self.assertAlmostEqual(targets[0].velocity, 0.0, places=2)
def test_velocity_sign(self):
"""Doppler bin < center → negative velocity, > center → positive."""
from v7.processing import extract_targets_from_frame
frame = self._make_frame(det_cells=[(5, 10), (5, 20)])
targets = extract_targets_from_frame(frame, velocity_resolution=2.67)
# dbin=10: vel = (10-16)*2.67 = -16.02 (approaching)
# dbin=20: vel = (20-16)*2.67 = +10.68 (receding)
self.assertLess(targets[0].velocity, 0)
self.assertGreater(targets[1].velocity, 0)
def test_snr_positive_for_nonzero_mag(self):
from v7.processing import extract_targets_from_frame
frame = self._make_frame(det_cells=[(3, 16)])
targets = extract_targets_from_frame(frame)
self.assertGreater(targets[0].snr, 0)
def test_gps_georef(self):
"""With GPS data, targets get non-zero lat/lon."""
from v7.processing import extract_targets_from_frame
from v7.models import GPSData
gps = GPSData(latitude=41.9, longitude=12.5, altitude=0.0,
pitch=0.0, heading=90.0)
frame = self._make_frame(det_cells=[(10, 16)])
targets = extract_targets_from_frame(
frame, bin_spacing=100.0, gps=gps)
# Should be roughly east of radar position
self.assertAlmostEqual(targets[0].latitude, 41.9, places=2)
self.assertGreater(targets[0].longitude, 12.5)
def test_multiple_detections(self):
from v7.processing import extract_targets_from_frame
frame = self._make_frame(det_cells=[(0, 0), (10, 10), (63, 31)])
targets = extract_targets_from_frame(frame)
self.assertEqual(len(targets), 3)
# IDs should be sequential 0, 1, 2
self.assertEqual([t.id for t in targets], [0, 1, 2])
# ============================================================================= # =============================================================================
# Helper: lazy import of v7.models # Helper: lazy import of v7.models
# ============================================================================= # =============================================================================
+42 -22
View File
@@ -14,6 +14,7 @@ from .models import (
GPSData, GPSData,
ProcessingConfig, ProcessingConfig,
TileServer, TileServer,
WaveformConfig,
DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER, DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER,
DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER,
DARK_TREEVIEW, DARK_TREEVIEW_ALT, DARK_TREEVIEW, DARK_TREEVIEW_ALT,
@@ -25,7 +26,6 @@ from .models import (
# Hardware interfaces — production protocol via radar_protocol.py # Hardware interfaces — production protocol via radar_protocol.py
from .hardware import ( from .hardware import (
FT2232HConnection, FT2232HConnection,
ReplayConnection,
RadarProtocol, RadarProtocol,
Opcode, Opcode,
RadarAcquisition, RadarAcquisition,
@@ -40,31 +40,48 @@ from .processing import (
RadarProcessor, RadarProcessor,
USBPacketParser, USBPacketParser,
apply_pitch_correction, apply_pitch_correction,
)
# Workers and simulator
from .workers import (
RadarDataWorker,
GPSDataWorker,
TargetSimulator,
polar_to_geographic, polar_to_geographic,
extract_targets_from_frame,
) )
# Map widget # Software FPGA (depends on golden_reference.py in FPGA cosim tree)
from .map_widget import ( try: # noqa: SIM105
MapBridge, from .software_fpga import SoftwareFPGA, quantize_raw_iq
RadarMapWidget, except ImportError: # golden_reference.py not available (e.g. deployment without FPGA tree)
) pass
# Main dashboard # Replay engine (no PyQt6 dependency, but needs SoftwareFPGA for raw IQ path)
from .dashboard import ( try: # noqa: SIM105
RadarDashboard, from .replay import ReplayEngine, ReplayFormat
RangeDopplerCanvas, except ImportError: # software_fpga unavailable → replay also unavailable
) pass
# Workers, map widget, and dashboard require PyQt6 — import lazily so that
# tests/CI environments without PyQt6 can still access models/hardware/processing.
try:
from .workers import (
RadarDataWorker,
GPSDataWorker,
TargetSimulator,
ReplayWorker,
)
from .map_widget import (
MapBridge,
RadarMapWidget,
)
from .dashboard import (
RadarDashboard,
RangeDopplerCanvas,
)
except ImportError: # PyQt6 not installed (e.g. CI headless runner)
pass
__all__ = [ # noqa: RUF022 __all__ = [ # noqa: RUF022
# models # models
"RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer", "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
"WaveformConfig",
"DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER", "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
"DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER", "DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER",
"DARK_TREEVIEW", "DARK_TREEVIEW_ALT", "DARK_TREEVIEW", "DARK_TREEVIEW_ALT",
@@ -72,15 +89,18 @@ __all__ = [ # noqa: RUF022
"USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE", "USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE",
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", "SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE",
# hardware — production FPGA protocol # hardware — production FPGA protocol
"FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode", "FT2232HConnection", "RadarProtocol", "Opcode",
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder", "RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
"STM32USBInterface", "STM32USBInterface",
# processing # processing
"RadarProcessor", "USBPacketParser", "RadarProcessor", "USBPacketParser",
"apply_pitch_correction", "apply_pitch_correction", "polar_to_geographic",
"extract_targets_from_frame",
# software FPGA + replay
"SoftwareFPGA", "quantize_raw_iq",
"ReplayEngine", "ReplayFormat",
# workers # workers
"RadarDataWorker", "GPSDataWorker", "TargetSimulator", "RadarDataWorker", "GPSDataWorker", "TargetSimulator", "ReplayWorker",
"polar_to_geographic",
# map # map
"MapBridge", "RadarMapWidget", "MapBridge", "RadarMapWidget",
# dashboard # dashboard
+222
View File
@@ -0,0 +1,222 @@
"""
v7.agc_sim -- Bit-accurate AGC simulation matching rx_gain_control.v.
Provides stateful, frame-by-frame AGC processing for the Raw IQ Replay
mode and offline analysis. All gain encoding, clamping, and attack/decay/
holdoff logic is identical to the FPGA RTL.
Classes:
- AGCState -- mutable internal AGC state (gain, holdoff counter)
- AGCFrameResult -- per-frame AGC metrics after processing
Functions:
- signed_to_encoding -- signed gain (-7..+7) -> 4-bit encoding
- encoding_to_signed -- 4-bit encoding -> signed gain
- clamp_gain -- clamp to [-7, +7]
- apply_gain_shift -- apply gain_shift to 16-bit IQ arrays
- process_agc_frame -- run one frame through AGC, update state
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
# ---------------------------------------------------------------------------
# FPGA AGC parameters (rx_gain_control.v reset defaults)
# ---------------------------------------------------------------------------
AGC_TARGET_DEFAULT = 200 # host_agc_target (8-bit)
AGC_ATTACK_DEFAULT = 1 # host_agc_attack (4-bit)
AGC_DECAY_DEFAULT = 1 # host_agc_decay (4-bit)
AGC_HOLDOFF_DEFAULT = 4 # host_agc_holdoff (4-bit)
# ---------------------------------------------------------------------------
# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed)
# ---------------------------------------------------------------------------
def signed_to_encoding(g: int) -> int:
"""Convert signed gain (-7..+7) to gain_shift[3:0] encoding.
[3]=0, [2:0]=N -> amplify (left shift) by N
[3]=1, [2:0]=N -> attenuate (right shift) by N
"""
if g >= 0:
return g & 0x07
return 0x08 | ((-g) & 0x07)
def encoding_to_signed(enc: int) -> int:
"""Convert gain_shift[3:0] encoding to signed gain."""
if (enc & 0x08) == 0:
return enc & 0x07
return -(enc & 0x07)
def clamp_gain(val: int) -> int:
"""Clamp to [-7, +7] (matches RTL clamp_gain function)."""
return max(-7, min(7, val))
# ---------------------------------------------------------------------------
# Apply gain shift to IQ data (matches RTL combinational logic)
# ---------------------------------------------------------------------------
def apply_gain_shift(
frame_i: np.ndarray,
frame_q: np.ndarray,
gain_enc: int,
) -> tuple[np.ndarray, np.ndarray, int]:
"""Apply gain_shift encoding to 16-bit signed IQ arrays.
Returns (shifted_i, shifted_q, overflow_count).
Matches the RTL: left shift = amplify, right shift = attenuate,
saturate to +/-32767 on overflow.
"""
direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate
amount = gain_enc & 0x07
if amount == 0:
return frame_i.copy(), frame_q.copy(), 0
if direction == 0:
# Left shift (amplify)
si = frame_i.astype(np.int64) * (1 << amount)
sq = frame_q.astype(np.int64) * (1 << amount)
else:
# Arithmetic right shift (attenuate)
si = frame_i.astype(np.int64) >> amount
sq = frame_q.astype(np.int64) >> amount
# Count overflows (post-shift values outside 16-bit signed range)
overflow_i = (si > 32767) | (si < -32768)
overflow_q = (sq > 32767) | (sq < -32768)
overflow_count = int((overflow_i | overflow_q).sum())
# Saturate to +/-32767
si = np.clip(si, -32768, 32767).astype(np.int16)
sq = np.clip(sq, -32768, 32767).astype(np.int16)
return si, sq, overflow_count
# ---------------------------------------------------------------------------
# AGC state and per-frame result dataclasses
# ---------------------------------------------------------------------------
@dataclass
class AGCConfig:
"""AGC tuning parameters (mirrors FPGA host registers 0x28-0x2C)."""
enabled: bool = False
target: int = AGC_TARGET_DEFAULT # 8-bit peak target
attack: int = AGC_ATTACK_DEFAULT # 4-bit attenuation step
decay: int = AGC_DECAY_DEFAULT # 4-bit gain-up step
holdoff: int = AGC_HOLDOFF_DEFAULT # 4-bit frames to hold
@dataclass
class AGCState:
"""Mutable internal AGC state — persists across frames."""
gain: int = 0 # signed gain, -7..+7
holdoff_counter: int = 0 # frames remaining before gain-up allowed
was_enabled: bool = False # tracks enable transitions
@dataclass
class AGCFrameResult:
"""Per-frame AGC metrics returned by process_agc_frame()."""
gain_enc: int = 0 # gain_shift[3:0] encoding applied this frame
gain_signed: int = 0 # signed gain for display
peak_mag_8bit: int = 0 # pre-gain peak magnitude (upper 8 of 15 bits)
saturation_count: int = 0 # post-gain overflow count (clamped to 255)
overflow_raw: int = 0 # raw overflow count (unclamped)
shifted_i: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.int16))
shifted_q: np.ndarray = field(default_factory=lambda: np.array([], dtype=np.int16))
# ---------------------------------------------------------------------------
# Per-frame AGC processing (bit-accurate to rx_gain_control.v)
# ---------------------------------------------------------------------------
def quantize_iq(frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Quantize complex IQ to 16-bit signed I and Q arrays.
Input: 2-D complex array (chirps x samples) any complex dtype.
Output: (frame_i, frame_q) as int16.
"""
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
return frame_i, frame_q
def process_agc_frame(
frame_i: np.ndarray,
frame_q: np.ndarray,
config: AGCConfig,
state: AGCState,
) -> AGCFrameResult:
"""Run one frame through the FPGA AGC inner loop.
Mutates *state* in place (gain and holdoff_counter).
Returns AGCFrameResult with metrics and shifted IQ data.
Parameters
----------
frame_i, frame_q : int16 arrays (any shape, typically chirps x samples)
config : AGC tuning parameters
state : mutable AGC state from previous frame
"""
# --- PRE-gain peak measurement (RTL lines 133-135, 211-213) ---
abs_i = np.abs(frame_i.astype(np.int32))
abs_q = np.abs(frame_q.astype(np.int32))
max_iq = np.maximum(abs_i, abs_q)
frame_peak_15bit = int(max_iq.max()) if max_iq.size > 0 else 0
peak_8bit = (frame_peak_15bit >> 7) & 0xFF
# --- Handle AGC enable transition (RTL lines 250-253) ---
if config.enabled and not state.was_enabled:
state.gain = 0
state.holdoff_counter = config.holdoff
state.was_enabled = config.enabled
# --- Determine effective gain encoding ---
if config.enabled:
effective_enc = signed_to_encoding(state.gain)
else:
effective_enc = signed_to_encoding(state.gain)
# --- Apply gain shift + count POST-gain overflow ---
shifted_i, shifted_q, overflow_raw = apply_gain_shift(
frame_i, frame_q, effective_enc)
sat_count = min(255, overflow_raw)
# --- AGC update at frame boundary (RTL lines 226-246) ---
if config.enabled:
if sat_count > 0:
# Clipping: reduce gain immediately (attack)
state.gain = clamp_gain(state.gain - config.attack)
state.holdoff_counter = config.holdoff
elif peak_8bit < config.target:
# Signal too weak: increase gain after holdoff
if state.holdoff_counter == 0:
state.gain = clamp_gain(state.gain + config.decay)
else:
state.holdoff_counter -= 1
else:
# Good range (peak >= target, no sat): hold, reset holdoff
state.holdoff_counter = config.holdoff
return AGCFrameResult(
gain_enc=effective_enc,
gain_signed=state.gain if config.enabled else encoding_to_signed(effective_enc),
peak_mag_8bit=peak_8bit,
saturation_count=sat_count,
overflow_raw=overflow_raw,
shifted_i=shifted_i,
shifted_q=shifted_q,
)
+314 -24
View File
@@ -14,7 +14,7 @@ RadarDashboard is a QMainWindow with six tabs:
Uses production radar_protocol.py for all FPGA communication: Uses production radar_protocol.py for all FPGA communication:
- FT2232HConnection for real hardware - FT2232HConnection for real hardware
- ReplayConnection for offline .npy replay - Unified replay via SoftwareFPGA + ReplayEngine + ReplayWorker
- Mock mode (FT2232HConnection(mock=True)) for development - Mock mode (FT2232HConnection(mock=True)) for development
The old STM32 magic-packet start flow has been removed. FPGA registers The old STM32 magic-packet start flow has been removed. FPGA registers
@@ -22,9 +22,12 @@ are controlled directly via 4-byte {opcode, addr, value_hi, value_lo}
commands sent over FT2232H. commands sent over FT2232H.
""" """
from __future__ import annotations
import time import time
import logging import logging
from collections import deque from collections import deque
from typing import TYPE_CHECKING
import numpy as np import numpy as np
@@ -32,11 +35,11 @@ from PyQt6.QtWidgets import (
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QTabWidget, QSplitter, QGroupBox, QFrame, QScrollArea, QTabWidget, QSplitter, QGroupBox, QFrame, QScrollArea,
QLabel, QPushButton, QComboBox, QCheckBox, QLabel, QPushButton, QComboBox, QCheckBox,
QDoubleSpinBox, QSpinBox, QLineEdit, QDoubleSpinBox, QSpinBox, QLineEdit, QSlider, QFileDialog,
QTableWidget, QTableWidgetItem, QHeaderView, QTableWidget, QTableWidgetItem, QHeaderView,
QPlainTextEdit, QStatusBar, QMessageBox, QPlainTextEdit, QStatusBar, QMessageBox,
) )
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QObject from PyQt6.QtCore import Qt, QLocale, QTimer, pyqtSignal, pyqtSlot, QObject
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.figure import Figure from matplotlib.figure import Figure
@@ -52,7 +55,6 @@ from .models import (
) )
from .hardware import ( from .hardware import (
FT2232HConnection, FT2232HConnection,
ReplayConnection,
RadarProtocol, RadarProtocol,
RadarFrame, RadarFrame,
StatusResponse, StatusResponse,
@@ -60,15 +62,30 @@ from .hardware import (
STM32USBInterface, STM32USBInterface,
) )
from .processing import RadarProcessor, USBPacketParser from .processing import RadarProcessor, USBPacketParser
from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator, ReplayWorker
from .map_widget import RadarMapWidget from .map_widget import RadarMapWidget
if TYPE_CHECKING:
from .software_fpga import SoftwareFPGA
from .replay import ReplayEngine
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Frame dimensions from FPGA # Frame dimensions from FPGA
NUM_RANGE_BINS = 64 NUM_RANGE_BINS = 64
NUM_DOPPLER_BINS = 32 NUM_DOPPLER_BINS = 32
# Force C locale (period as decimal separator) for all QDoubleSpinBox instances.
_C_LOCALE = QLocale(QLocale.Language.C)
_C_LOCALE.setNumberOptions(QLocale.NumberOption.RejectGroupSeparator)
def _make_dspin() -> QDoubleSpinBox:
"""Create a QDoubleSpinBox with C locale (no comma decimals)."""
sb = QDoubleSpinBox()
sb.setLocale(_C_LOCALE)
return sb
# ============================================================================= # =============================================================================
# Range-Doppler Canvas (matplotlib) # Range-Doppler Canvas (matplotlib)
@@ -142,6 +159,12 @@ class RadarDashboard(QMainWindow):
self._gps_worker: GPSDataWorker | None = None self._gps_worker: GPSDataWorker | None = None
self._simulator: TargetSimulator | None = None self._simulator: TargetSimulator | None = None
# Replay-specific objects (created when entering replay mode)
self._replay_worker: ReplayWorker | None = None
self._replay_engine: ReplayEngine | None = None
self._software_fpga: SoftwareFPGA | None = None
self._replay_mode = False
# State # State
self._running = False self._running = False
self._demo_mode = False self._demo_mode = False
@@ -341,7 +364,7 @@ class RadarDashboard(QMainWindow):
# Row 0: connection mode + device combos + buttons # Row 0: connection mode + device combos + buttons
ctrl_layout.addWidget(QLabel("Mode:"), 0, 0) ctrl_layout.addWidget(QLabel("Mode:"), 0, 0)
self._mode_combo = QComboBox() self._mode_combo = QComboBox()
self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay (.npy)"]) self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay"])
self._mode_combo.setCurrentIndex(0) self._mode_combo.setCurrentIndex(0)
ctrl_layout.addWidget(self._mode_combo, 0, 1) ctrl_layout.addWidget(self._mode_combo, 0, 1)
@@ -390,6 +413,55 @@ class RadarDashboard(QMainWindow):
self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight) self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight)
ctrl_layout.addWidget(self._status_label_main, 1, 5, 1, 5) ctrl_layout.addWidget(self._status_label_main, 1, 5, 1, 5)
# Row 2: replay transport controls (hidden until replay mode)
self._replay_file_label = QLabel("No file loaded")
self._replay_file_label.setMinimumWidth(200)
ctrl_layout.addWidget(self._replay_file_label, 2, 0, 1, 2)
self._replay_browse_btn = QPushButton("Browse...")
self._replay_browse_btn.clicked.connect(self._browse_replay_file)
ctrl_layout.addWidget(self._replay_browse_btn, 2, 2)
self._replay_play_btn = QPushButton("Play")
self._replay_play_btn.clicked.connect(self._replay_play_pause)
ctrl_layout.addWidget(self._replay_play_btn, 2, 3)
self._replay_stop_btn = QPushButton("Stop")
self._replay_stop_btn.clicked.connect(self._replay_stop)
ctrl_layout.addWidget(self._replay_stop_btn, 2, 4)
self._replay_slider = QSlider(Qt.Orientation.Horizontal)
self._replay_slider.setMinimum(0)
self._replay_slider.setMaximum(0)
self._replay_slider.valueChanged.connect(self._replay_seek)
ctrl_layout.addWidget(self._replay_slider, 2, 5, 1, 2)
self._replay_frame_label = QLabel("0 / 0")
ctrl_layout.addWidget(self._replay_frame_label, 2, 7)
self._replay_speed_combo = QComboBox()
self._replay_speed_combo.addItems(["50 ms", "100 ms", "200 ms", "500 ms"])
self._replay_speed_combo.setCurrentIndex(1)
self._replay_speed_combo.currentIndexChanged.connect(self._replay_speed_changed)
ctrl_layout.addWidget(self._replay_speed_combo, 2, 8)
self._replay_loop_cb = QCheckBox("Loop")
self._replay_loop_cb.stateChanged.connect(self._replay_loop_changed)
ctrl_layout.addWidget(self._replay_loop_cb, 2, 9)
# Collect replay widgets to toggle visibility
self._replay_controls = [
self._replay_file_label, self._replay_browse_btn,
self._replay_play_btn, self._replay_stop_btn,
self._replay_slider, self._replay_frame_label,
self._replay_speed_combo, self._replay_loop_cb,
]
for w in self._replay_controls:
w.setVisible(False)
# Show/hide replay row when mode changes
self._mode_combo.currentTextChanged.connect(self._on_mode_changed)
layout.addWidget(ctrl) layout.addWidget(ctrl)
# ---- Display area (range-doppler + targets table) ------------------ # ---- Display area (range-doppler + targets table) ------------------
@@ -452,19 +524,19 @@ class RadarDashboard(QMainWindow):
pos_group = QGroupBox("Radar Position") pos_group = QGroupBox("Radar Position")
pos_layout = QGridLayout(pos_group) pos_layout = QGridLayout(pos_group)
self._lat_spin = QDoubleSpinBox() self._lat_spin = _make_dspin()
self._lat_spin.setRange(-90, 90) self._lat_spin.setRange(-90, 90)
self._lat_spin.setDecimals(6) self._lat_spin.setDecimals(6)
self._lat_spin.setValue(self._radar_position.latitude) self._lat_spin.setValue(self._radar_position.latitude)
self._lat_spin.valueChanged.connect(self._on_position_changed) self._lat_spin.valueChanged.connect(self._on_position_changed)
self._lon_spin = QDoubleSpinBox() self._lon_spin = _make_dspin()
self._lon_spin.setRange(-180, 180) self._lon_spin.setRange(-180, 180)
self._lon_spin.setDecimals(6) self._lon_spin.setDecimals(6)
self._lon_spin.setValue(self._radar_position.longitude) self._lon_spin.setValue(self._radar_position.longitude)
self._lon_spin.valueChanged.connect(self._on_position_changed) self._lon_spin.valueChanged.connect(self._on_position_changed)
self._alt_spin = QDoubleSpinBox() self._alt_spin = _make_dspin()
self._alt_spin.setRange(0, 50000) self._alt_spin.setRange(0, 50000)
self._alt_spin.setDecimals(1) self._alt_spin.setDecimals(1)
self._alt_spin.setValue(0.0) self._alt_spin.setValue(0.0)
@@ -483,7 +555,7 @@ class RadarDashboard(QMainWindow):
cov_group = QGroupBox("Coverage") cov_group = QGroupBox("Coverage")
cov_layout = QGridLayout(cov_group) cov_layout = QGridLayout(cov_group)
self._coverage_spin = QDoubleSpinBox() self._coverage_spin = _make_dspin()
self._coverage_spin.setRange(1, 200) self._coverage_spin.setRange(1, 200)
self._coverage_spin.setDecimals(1) self._coverage_spin.setDecimals(1)
self._coverage_spin.setValue(self._settings.coverage_radius / 1000) self._coverage_spin.setValue(self._settings.coverage_radius / 1000)
@@ -899,7 +971,7 @@ class RadarDashboard(QMainWindow):
for spine in self._agc_ax_sat.spines.values(): for spine in self._agc_ax_sat.spines.values():
spine.set_color(DARK_BORDER) spine.set_color(DARK_BORDER)
self._agc_sat_line, = self._agc_ax_sat.plot( self._agc_sat_line, = self._agc_ax_sat.plot(
[], [], color=DARK_ERROR, linewidth=1.0) [], [], color=DARK_ERROR, linewidth=1.0, label="Saturation")
self._agc_sat_fill_artist = None self._agc_sat_fill_artist = None
self._agc_ax_sat.legend( self._agc_ax_sat.legend(
loc="upper right", fontsize=8, loc="upper right", fontsize=8,
@@ -1047,7 +1119,7 @@ class RadarDashboard(QMainWindow):
row += 1 row += 1
p_layout.addWidget(QLabel("DBSCAN eps:"), row, 0) p_layout.addWidget(QLabel("DBSCAN eps:"), row, 0)
self._cluster_eps_spin = QDoubleSpinBox() self._cluster_eps_spin = _make_dspin()
self._cluster_eps_spin.setRange(1.0, 5000.0) self._cluster_eps_spin.setRange(1.0, 5000.0)
self._cluster_eps_spin.setDecimals(1) self._cluster_eps_spin.setDecimals(1)
self._cluster_eps_spin.setValue(self._processing_config.clustering_eps) self._cluster_eps_spin.setValue(self._processing_config.clustering_eps)
@@ -1164,7 +1236,11 @@ class RadarDashboard(QMainWindow):
logger.error(f"Failed to send FPGA cmd: 0x{opcode:02X}") logger.error(f"Failed to send FPGA cmd: 0x{opcode:02X}")
def _send_fpga_validated(self, opcode: int, value: int, bits: int): def _send_fpga_validated(self, opcode: int, value: int, bits: int):
"""Clamp value to bit-width and send.""" """Clamp value to bit-width and send.
In replay mode, also dispatch to the SoftwareFPGA setter and
re-process the current frame so the user sees immediate effect.
"""
max_val = (1 << bits) - 1 max_val = (1 << bits) - 1
clamped = max(0, min(value, max_val)) clamped = max(0, min(value, max_val))
if clamped != value: if clamped != value:
@@ -1174,7 +1250,18 @@ class RadarDashboard(QMainWindow):
key = f"0x{opcode:02X}" key = f"0x{opcode:02X}"
if key in self._param_spins: if key in self._param_spins:
self._param_spins[key].setValue(clamped) self._param_spins[key].setValue(clamped)
self._send_fpga_cmd(opcode, clamped)
# Dispatch to real FPGA (live/mock mode)
if not self._replay_mode:
self._send_fpga_cmd(opcode, clamped)
return
# Dispatch to SoftwareFPGA (replay mode)
if self._software_fpga is not None:
self._dispatch_to_software_fpga(opcode, clamped)
# Re-process current frame so the effect is visible immediately
if self._replay_worker is not None:
self._replay_worker.seek(self._replay_worker.current_index)
def _send_custom_command(self): def _send_custom_command(self):
"""Send custom opcode + value from the FPGA Control tab.""" """Send custom opcode + value from the FPGA Control tab."""
@@ -1191,36 +1278,112 @@ class RadarDashboard(QMainWindow):
def _start_radar(self): def _start_radar(self):
"""Start radar data acquisition using production protocol.""" """Start radar data acquisition using production protocol."""
# Mutual exclusion: stop demo if running
if self._demo_mode:
self._stop_demo()
try: try:
mode = self._mode_combo.currentText() mode = self._mode_combo.currentText()
if "Mock" in mode: if "Mock" in mode:
self._replay_mode = False
self._connection = FT2232HConnection(mock=True) self._connection = FT2232HConnection(mock=True)
if not self._connection.open(): if not self._connection.open():
QMessageBox.critical(self, "Error", "Failed to open mock connection.") QMessageBox.critical(self, "Error", "Failed to open mock connection.")
return return
elif "Live" in mode: elif "Live" in mode:
self._replay_mode = False
self._connection = FT2232HConnection(mock=False) self._connection = FT2232HConnection(mock=False)
if not self._connection.open(): if not self._connection.open():
QMessageBox.critical(self, "Error", QMessageBox.critical(self, "Error",
"Failed to open FT2232H. Check USB connection.") "Failed to open FT2232H. Check USB connection.")
return return
elif "Replay" in mode: elif "Replay" in mode:
from PyQt6.QtWidgets import QFileDialog self._replay_mode = True
npy_dir = QFileDialog.getExistingDirectory( replay_path = self._replay_file_label.text()
self, "Select .npy replay directory") if replay_path == "No file loaded" or not replay_path:
if not npy_dir: QMessageBox.warning(
self, "Replay",
"Use 'Browse...' to select a replay"
" file or directory first.")
return return
self._connection = ReplayConnection(npy_dir)
if not self._connection.open(): from .software_fpga import SoftwareFPGA
QMessageBox.critical(self, "Error", from .replay import ReplayEngine
"Failed to open replay connection.")
self._software_fpga = SoftwareFPGA()
# Enable CFAR by default for raw IQ replay (avoids 2000+ detections)
self._software_fpga.set_cfar_enable(True)
try:
self._replay_engine = ReplayEngine(
replay_path, self._software_fpga)
except (OSError, ValueError, RuntimeError) as exc:
QMessageBox.critical(self, "Replay Error",
f"Failed to open replay data:\n{exc}")
self._software_fpga = None
return return
if self._replay_engine.total_frames == 0:
QMessageBox.warning(self, "Replay", "No frames found in the selected source.")
self._replay_engine.close()
self._replay_engine = None
self._software_fpga = None
return
speed_map = {0: 50, 1: 100, 2: 200, 3: 500}
interval = speed_map.get(self._replay_speed_combo.currentIndex(), 100)
self._replay_worker = ReplayWorker(
replay_engine=self._replay_engine,
settings=self._settings,
gps=self._radar_position,
frame_interval_ms=interval,
)
self._replay_worker.frameReady.connect(self._on_frame_ready)
self._replay_worker.targetsUpdated.connect(self._on_radar_targets)
self._replay_worker.statsUpdated.connect(self._on_radar_stats)
self._replay_worker.errorOccurred.connect(self._on_worker_error)
self._replay_worker.playbackStateChanged.connect(
self._on_playback_state_changed)
self._replay_worker.frameIndexChanged.connect(
self._on_frame_index_changed)
self._replay_worker.set_loop(self._replay_loop_cb.isChecked())
self._replay_slider.setMaximum(
self._replay_engine.total_frames - 1)
self._replay_slider.setValue(0)
self._replay_frame_label.setText(
f"0 / {self._replay_engine.total_frames}")
self._replay_worker.start()
# Update CFAR enable spinbox to reflect default-on for replay
if "0x25" in self._param_spins:
self._param_spins["0x25"].setValue(1)
# UI state
self._running = True
self._start_time = time.time()
self._frame_count = 0
self._start_btn.setEnabled(False)
self._stop_btn.setEnabled(True)
self._mode_combo.setEnabled(False)
self._demo_btn_main.setEnabled(False)
self._demo_btn_map.setEnabled(False)
n_frames = self._replay_engine.total_frames
self._status_label_main.setText(
f"Status: Replay ({n_frames} frames)")
self._sb_status.setText(f"Replay ({n_frames} frames)")
self._sb_mode.setText("Replay")
logger.info(
"Replay started: %s (%d frames)",
replay_path, n_frames)
return
else: else:
QMessageBox.warning(self, "Warning", "Unknown connection mode.") QMessageBox.warning(self, "Warning", "Unknown connection mode.")
return return
# Start radar worker # Start radar worker (mock / live — NOT replay)
self._radar_worker = RadarDataWorker( self._radar_worker = RadarDataWorker(
connection=self._connection, connection=self._connection,
processor=self._processor, processor=self._processor,
@@ -1254,6 +1417,8 @@ class RadarDashboard(QMainWindow):
self._start_btn.setEnabled(False) self._start_btn.setEnabled(False)
self._stop_btn.setEnabled(True) self._stop_btn.setEnabled(True)
self._mode_combo.setEnabled(False) self._mode_combo.setEnabled(False)
self._demo_btn_main.setEnabled(False)
self._demo_btn_map.setEnabled(False)
self._status_label_main.setText(f"Status: Running ({mode})") self._status_label_main.setText(f"Status: Running ({mode})")
self._sb_status.setText(f"Running ({mode})") self._sb_status.setText(f"Running ({mode})")
self._sb_mode.setText(mode) self._sb_mode.setText(mode)
@@ -1271,6 +1436,18 @@ class RadarDashboard(QMainWindow):
self._radar_worker.wait(2000) self._radar_worker.wait(2000)
self._radar_worker = None self._radar_worker = None
if self._replay_worker:
self._replay_worker.stop()
self._replay_worker.wait(2000)
self._replay_worker = None
if self._replay_engine:
self._replay_engine.close()
self._replay_engine = None
self._software_fpga = None
self._replay_mode = False
if self._gps_worker: if self._gps_worker:
self._gps_worker.stop() self._gps_worker.stop()
self._gps_worker.wait(2000) self._gps_worker.wait(2000)
@@ -1285,11 +1462,120 @@ class RadarDashboard(QMainWindow):
self._start_btn.setEnabled(True) self._start_btn.setEnabled(True)
self._stop_btn.setEnabled(False) self._stop_btn.setEnabled(False)
self._mode_combo.setEnabled(True) self._mode_combo.setEnabled(True)
self._demo_btn_main.setEnabled(True)
self._demo_btn_map.setEnabled(True)
self._status_label_main.setText("Status: Radar stopped") self._status_label_main.setText("Status: Radar stopped")
self._sb_status.setText("Radar stopped") self._sb_status.setText("Radar stopped")
self._sb_mode.setText("Idle") self._sb_mode.setText("Idle")
logger.info("Radar system stopped") logger.info("Radar system stopped")
# =====================================================================
# Replay helpers
# =====================================================================
def _on_mode_changed(self, text: str):
"""Show/hide replay transport controls based on mode selection."""
is_replay = "Replay" in text
for w in self._replay_controls:
w.setVisible(is_replay)
def _browse_replay_file(self):
"""Open file/directory picker for replay source."""
path, _ = QFileDialog.getOpenFileName(
self, "Select replay file",
"",
"All supported (*.npy *.h5);;NumPy files (*.npy);;HDF5 files (*.h5);;All files (*)",
)
if path:
self._replay_file_label.setText(path)
return
# If no file selected, try directory (for co-sim)
dir_path = QFileDialog.getExistingDirectory(
self, "Select co-sim replay directory")
if dir_path:
self._replay_file_label.setText(dir_path)
def _replay_play_pause(self):
"""Toggle play/pause on the replay worker."""
if self._replay_worker is None:
return
if self._replay_worker.is_playing:
self._replay_worker.pause()
self._replay_play_btn.setText("Play")
else:
self._replay_worker.play()
self._replay_play_btn.setText("Pause")
def _replay_stop(self):
"""Stop replay playback (keeps data loaded)."""
if self._replay_worker is not None:
self._replay_worker.pause()
self._replay_worker.seek(0)
self._replay_play_btn.setText("Play")
def _replay_seek(self, value: int):
"""Seek to a specific frame from the slider."""
if self._replay_worker is not None and not self._replay_worker.is_playing:
self._replay_worker.seek(value)
def _replay_speed_changed(self, index: int):
"""Update replay frame interval from speed combo."""
speed_map = {0: 50, 1: 100, 2: 200, 3: 500}
ms = speed_map.get(index, 100)
if self._replay_worker is not None:
self._replay_worker.set_frame_interval(ms)
def _replay_loop_changed(self, state: int):
"""Update replay loop setting."""
if self._replay_worker is not None:
self._replay_worker.set_loop(state == Qt.CheckState.Checked.value)
@pyqtSlot(str)
def _on_playback_state_changed(self, state: str):
"""Update UI when replay playback state changes."""
if state == "playing":
self._replay_play_btn.setText("Pause")
elif state in ("paused", "stopped"):
self._replay_play_btn.setText("Play")
if state == "stopped" and self._replay_worker is not None:
self._status_label_main.setText("Status: Replay finished")
@pyqtSlot(int, int)
def _on_frame_index_changed(self, current: int, total: int):
"""Update slider and frame label from replay worker."""
self._replay_slider.blockSignals(True)
self._replay_slider.setValue(current)
self._replay_slider.blockSignals(False)
self._replay_frame_label.setText(f"{current} / {total}")
def _dispatch_to_software_fpga(self, opcode: int, value: int):
"""Route an FPGA opcode+value to the SoftwareFPGA setter."""
fpga = self._software_fpga
if fpga is None:
return
_opcode_dispatch = {
0x03: lambda v: fpga.set_detect_threshold(v),
0x16: lambda v: fpga.set_gain_shift(v),
0x21: lambda v: fpga.set_cfar_guard(v),
0x22: lambda v: fpga.set_cfar_train(v),
0x23: lambda v: fpga.set_cfar_alpha(v),
0x24: lambda v: fpga.set_cfar_mode(v),
0x25: lambda v: fpga.set_cfar_enable(bool(v)),
0x26: lambda v: fpga.set_mti_enable(bool(v)),
0x27: lambda v: fpga.set_dc_notch_width(v),
0x28: lambda v: fpga.set_agc_enable(bool(v)),
0x29: lambda v: fpga.set_agc_params(target=v),
0x2A: lambda v: fpga.set_agc_params(attack=v),
0x2B: lambda v: fpga.set_agc_params(decay=v),
0x2C: lambda v: fpga.set_agc_params(holdoff=v),
}
handler = _opcode_dispatch.get(opcode)
if handler is not None:
handler(value)
logger.info(f"SoftwareFPGA: 0x{opcode:02X} = {value}")
else:
logger.debug(f"SoftwareFPGA: opcode 0x{opcode:02X} not handled (no-op)")
# ===================================================================== # =====================================================================
# Demo mode # Demo mode
# ===================================================================== # =====================================================================
@@ -1297,6 +1583,10 @@ class RadarDashboard(QMainWindow):
def _start_demo(self): def _start_demo(self):
if self._simulator: if self._simulator:
return return
# Mutual exclusion: do not start demo while radar/replay is running
if self._running:
logger.warning("Cannot start demo while radar is running")
return
self._simulator = TargetSimulator(self._radar_position, self) self._simulator = TargetSimulator(self._radar_position, self)
self._simulator.targetsUpdated.connect(self._on_demo_targets) self._simulator.targetsUpdated.connect(self._on_demo_targets)
self._simulator.start(500) self._simulator.start(500)
@@ -1315,7 +1605,7 @@ class RadarDashboard(QMainWindow):
self._demo_mode = False self._demo_mode = False
if not self._running: if not self._running:
mode = "Idle" mode = "Idle"
elif isinstance(self._connection, ReplayConnection): elif self._replay_mode:
mode = "Replay" mode = "Replay"
else: else:
mode = "Live" mode = "Live"
+1 -5
View File
@@ -3,14 +3,11 @@ v7.hardware — Hardware interface classes for the PLFM Radar GUI V7.
Provides: Provides:
- FT2232H radar data + command interface via production radar_protocol module - FT2232H radar data + command interface via production radar_protocol module
- ReplayConnection for offline .npy replay via production radar_protocol module
- STM32USBInterface for GPS data only (USB CDC) - STM32USBInterface for GPS data only (USB CDC)
The FT2232H interface uses the production protocol layer (radar_protocol.py) The FT2232H interface uses the production protocol layer (radar_protocol.py)
which sends 4-byte {opcode, addr, value_hi, value_lo} register commands and which sends 4-byte {opcode, addr, value_hi, value_lo} register commands and
parses 0xAA data / 0xBB status packets from the FPGA. The old magic-packet parses 0xAA data / 0xBB status packets from the FPGA.
and 'SET'...'END' binary settings protocol has been removed it was
incompatible with the FPGA register interface.
""" """
import sys import sys
@@ -28,7 +25,6 @@ if USB_AVAILABLE:
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from radar_protocol import ( # noqa: F401 — re-exported for v7 package from radar_protocol import ( # noqa: F401 — re-exported for v7 package
FT2232HConnection, FT2232HConnection,
ReplayConnection,
RadarProtocol, RadarProtocol,
Opcode, Opcode,
RadarAcquisition, RadarAcquisition,
+17 -4
View File
@@ -17,7 +17,8 @@ from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFrame, QWidget, QVBoxLayout, QHBoxLayout, QFrame,
QComboBox, QCheckBox, QPushButton, QLabel, QComboBox, QCheckBox, QPushButton, QLabel,
) )
from PyQt6.QtCore import Qt, pyqtSignal, pyqtSlot, QObject from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtWebEngineCore import QWebEngineSettings
from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel from PyQt6.QtWebChannel import QWebChannel
@@ -97,7 +98,7 @@ class RadarMapWidget(QWidget):
) )
self._targets: list[RadarTarget] = [] self._targets: list[RadarTarget] = []
self._pending_targets: list[RadarTarget] | None = None self._pending_targets: list[RadarTarget] | None = None
self._coverage_radius = 50_000 # metres self._coverage_radius = 1_536 # metres (64 bins x 24 m, 3 km mode)
self._tile_server = TileServer.OPENSTREETMAP self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True self._show_coverage = True
self._show_trails = False self._show_trails = False
@@ -517,8 +518,20 @@ document.addEventListener('DOMContentLoaded', function() {{
# ---- load / helpers ---------------------------------------------------- # ---- load / helpers ----------------------------------------------------
def _load_map(self): def _load_map(self):
self._web_view.setHtml(self._get_map_html()) # Enable remote resource access so Leaflet CDN scripts/tiles can load.
logger.info("Leaflet map HTML loaded") settings = self._web_view.page().settings()
settings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
True,
)
# Provide an HTTP base URL so the page has a proper origin;
# without this, setHtml() defaults to about:blank which blocks
# external resource loading in modern Chromium.
self._web_view.setHtml(
self._get_map_html(),
QUrl("http://localhost/radar_map"),
)
logger.info("Leaflet map HTML loaded (with HTTP base URL)")
def _on_map_ready(self): def _on_map_ready(self):
self._status_label.setText(f"Map ready - {len(self._targets)} targets") self._status_label.setText(f"Map ready - {len(self._targets)} targets")
+77 -7
View File
@@ -105,15 +105,15 @@ class RadarSettings:
tab and Opcode enum in radar_protocol.py. This dataclass holds only tab and Opcode enum in radar_protocol.py. This dataclass holds only
host-side display/map settings and physical-unit conversion factors. host-side display/map settings and physical-unit conversion factors.
range_resolution and velocity_resolution should be calibrated to range_bin_spacing and velocity_resolution should be calibrated to
the actual waveform parameters. the actual waveform parameters.
""" """
system_frequency: float = 10e9 # Hz (carrier, used for velocity calc) system_frequency: float = 10.5e9 # Hz (PLFM TX LO, verified from ADF4382 config)
range_resolution: float = 781.25 # Meters per range bin (default: 50km/64) range_bin_spacing: float = 24.0 # Meters per decimated range bin (c/(2*100MSPS)*16)
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform) velocity_resolution: float = 2.67 # m/s per Doppler bin (lam/(2*32*167us))
max_distance: float = 50000 # Max detection range (m) max_distance: float = 1536 # Max detection range (m) -- 64 bins x 24 m (3 km mode)
map_size: float = 50000 # Map display size (m) map_size: float = 1536 # Map display size (m)
coverage_radius: float = 50000 # Map coverage radius (m) coverage_radius: float = 1536 # Map coverage radius (m)
@dataclass @dataclass
@@ -186,3 +186,73 @@ class TileServer(Enum):
GOOGLE_SATELLITE = "google_sat" GOOGLE_SATELLITE = "google_sat"
GOOGLE_HYBRID = "google_hybrid" GOOGLE_HYBRID = "google_hybrid"
ESRI_SATELLITE = "esri_sat" ESRI_SATELLITE = "esri_sat"
# ---------------------------------------------------------------------------
# Waveform configuration (physical parameters for bin→unit conversion)
# ---------------------------------------------------------------------------
@dataclass
class WaveformConfig:
"""Physical waveform parameters for converting bins to SI units.
Encapsulates the PLFM radar waveform so that range/velocity resolution
can be derived automatically instead of hardcoded in RadarSettings.
Defaults match the PLFM hardware: 100 MSPS post-DDC processing rate,
20 MHz chirp bandwidth, 30 us long chirp, 167 us PRI, 10.5 GHz carrier.
The receiver uses matched-filter pulse compression (NOT deramped FMCW),
so range-per-bin = c / (2 * fs_processing) * decimation_factor.
"""
sample_rate_hz: float = 100e6 # Post-DDC processing rate (400 MSPS / 4)
bandwidth_hz: float = 20e6 # Chirp bandwidth (Phase 1 target: 30 MHz)
chirp_duration_s: float = 30e-6 # Long chirp ramp (informational only)
pri_s: float = 167e-6 # Pulse repetition interval (chirp + listen)
center_freq_hz: float = 10.5e9 # TX LO carrier (verified: ADF4382 config)
n_range_bins: int = 64 # After decimation (3 km mode)
n_doppler_bins: int = 32 # After Doppler FFT
fft_size: int = 1024 # Pre-decimation FFT length
decimation_factor: int = 16 # 1024 → 64
@property
def bin_spacing_m(self) -> float:
"""Meters per decimated range bin (matched-filter receiver).
For matched-filter pulse compression: bin spacing = c / (2 * fs).
After decimation the bin spacing grows by *decimation_factor*.
This is independent of chirp bandwidth (BW affects physical
resolution, not bin spacing).
"""
c = 299_792_458.0
raw_bin = c / (2.0 * self.sample_rate_hz)
return raw_bin * self.decimation_factor
@property
def range_resolution_m(self) -> float:
"""Physical range resolution in meters, set by chirp bandwidth.
range_resolution = c / (2 * BW).
At 20 MHz BW 7.5 m; at 30 MHz BW 5.0 m.
This is distinct from bin_spacing_m (which depends on sample rate
and decimation factor, not bandwidth).
"""
c = 299_792_458.0
return c / (2.0 * self.bandwidth_hz)
@property
def velocity_resolution_mps(self) -> float:
"""m/s per Doppler bin. lambda / (2 * n_doppler * PRI)."""
c = 299_792_458.0
wavelength = c / self.center_freq_hz
return wavelength / (2.0 * self.n_doppler_bins * self.pri_s)
@property
def max_range_m(self) -> float:
"""Maximum unambiguous range in meters."""
return self.bin_spacing_m * self.n_range_bins
@property
def max_velocity_mps(self) -> float:
"""Maximum unambiguous velocity (±) in m/s."""
return self.velocity_resolution_mps * self.n_doppler_bins / 2.0
+100
View File
@@ -451,3 +451,103 @@ class USBPacketParser:
except (ValueError, struct.error) as e: except (ValueError, struct.error) as e:
logger.error(f"Error parsing binary GPS: {e}") logger.error(f"Error parsing binary GPS: {e}")
return None return None
# ============================================================================
# Utility: polar → geographic coordinate conversion
# ============================================================================
def polar_to_geographic(
radar_lat: float,
radar_lon: float,
range_m: float,
azimuth_deg: float,
) -> tuple:
"""Convert polar (range, azimuth) relative to radar → (lat, lon).
azimuth_deg: 0 = North, clockwise.
"""
r_earth = 6_371_000.0 # Earth radius in metres
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_earth)
+ math.cos(lat1) * math.sin(range_m / r_earth) * math.cos(bearing)
)
lon2 = lon1 + math.atan2(
math.sin(bearing) * math.sin(range_m / r_earth) * math.cos(lat1),
math.cos(range_m / r_earth) - math.sin(lat1) * math.sin(lat2),
)
return (math.degrees(lat2), math.degrees(lon2))
# ============================================================================
# Shared target extraction (used by both RadarDataWorker and ReplayWorker)
# ============================================================================
def extract_targets_from_frame(
frame,
bin_spacing: float = 1.0,
velocity_resolution: float = 1.0,
gps: GPSData | None = None,
) -> list[RadarTarget]:
"""Extract RadarTarget list from a RadarFrame's detection mask.
This is the bin-to-physical conversion + geo-mapping shared between
the live and replay data paths.
Parameters
----------
frame : RadarFrame
Frame with populated ``detections``, ``magnitude``, ``range_doppler_i/q``.
bin_spacing : float
Meters per range bin (bin spacing, NOT bandwidth-limited resolution).
velocity_resolution : float
m/s per Doppler bin.
gps : GPSData | None
GPS position for geo-mapping (latitude/longitude).
Returns
-------
list[RadarTarget]
One target per detection cell.
"""
det_indices = np.argwhere(frame.detections > 0)
n_doppler = frame.detections.shape[1] if frame.detections.ndim == 2 else 32
doppler_center = n_doppler // 2
targets: list[RadarTarget] = []
for idx in det_indices:
rbin, dbin = int(idx[0]), int(idx[1])
mag = float(frame.magnitude[rbin, dbin])
snr = 10.0 * math.log10(max(mag, 1.0)) if mag > 0 else 0.0
range_m = float(rbin) * bin_spacing
velocity_ms = float(dbin - doppler_center) * velocity_resolution
lat, lon, azimuth, elevation = 0.0, 0.0, 0.0, 0.0
if gps is not None:
azimuth = gps.heading
# Spread detections across ±15° sector for single-beam radar
if len(det_indices) > 1:
spread = (dbin - doppler_center) / max(doppler_center, 1) * 15.0
azimuth = gps.heading + spread
lat, lon = polar_to_geographic(
gps.latitude, gps.longitude, range_m, azimuth,
)
targets.append(RadarTarget(
id=len(targets),
range=range_m,
velocity=velocity_ms,
azimuth=azimuth,
elevation=elevation,
latitude=lat,
longitude=lon,
snr=snr,
timestamp=frame.timestamp,
))
return targets
+288
View File
@@ -0,0 +1,288 @@
"""
v7.replay ReplayEngine: auto-detect format, load, and iterate RadarFrames.
Supports three data sources:
1. **FPGA co-sim directory** pre-computed ``.npy`` files from golden_reference
2. **Raw IQ cube** ``.npy`` complex baseband capture (e.g. ADI Phaser)
3. **HDF5 recording** ``.h5`` frames captured by ``DataRecorder``
For raw IQ data the engine uses :class:`SoftwareFPGA` to run the full
bit-accurate signal chain, so changing FPGA control registers in the
dashboard re-processes the data.
"""
from __future__ import annotations
import logging
import time
from enum import Enum, auto
from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from .software_fpga import SoftwareFPGA
# radar_protocol is a sibling module (not inside v7/)
import sys as _sys
_GUI_DIR = str(Path(__file__).resolve().parent.parent)
if _GUI_DIR not in _sys.path:
_sys.path.insert(0, _GUI_DIR)
from radar_protocol import RadarFrame # noqa: E402
log = logging.getLogger(__name__)
# Lazy import — h5py is optional
try:
import h5py
HDF5_AVAILABLE = True
except ImportError:
HDF5_AVAILABLE = False
class ReplayFormat(Enum):
"""Detected input format."""
COSIM_DIR = auto()
RAW_IQ_NPY = auto()
HDF5 = auto()
# ───────────────────────────────────────────────────────────────────
# Format detection
# ───────────────────────────────────────────────────────────────────
_COSIM_REQUIRED = {"doppler_map_i.npy", "doppler_map_q.npy"}
def detect_format(path: str) -> ReplayFormat:
"""Auto-detect the replay data format from *path*.
Raises
------
ValueError
If the format cannot be determined.
"""
p = Path(path)
if p.is_dir():
children = {f.name for f in p.iterdir()}
if _COSIM_REQUIRED.issubset(children):
return ReplayFormat.COSIM_DIR
msg = f"Directory {p} does not contain required co-sim files: {_COSIM_REQUIRED - children}"
raise ValueError(msg)
if p.suffix == ".h5":
return ReplayFormat.HDF5
if p.suffix == ".npy":
return ReplayFormat.RAW_IQ_NPY
msg = f"Cannot determine replay format for: {p}"
raise ValueError(msg)
# ───────────────────────────────────────────────────────────────────
# ReplayEngine
# ───────────────────────────────────────────────────────────────────
class ReplayEngine:
"""Load replay data and serve RadarFrames on demand.
Parameters
----------
path : str
File or directory path to load.
software_fpga : SoftwareFPGA | None
Required only for ``RAW_IQ_NPY`` format. For other formats the
data is already processed and the FPGA instance is ignored.
"""
def __init__(self, path: str, software_fpga: SoftwareFPGA | None = None) -> None:
self.path = path
self.fmt = detect_format(path)
self.software_fpga = software_fpga
# Populated by _load_*
self._total_frames: int = 0
self._raw_iq: np.ndarray | None = None # for RAW_IQ_NPY
self._h5_file = None
self._h5_keys: list[str] = []
self._cosim_frame = None # single RadarFrame for co-sim
self._load()
# ------------------------------------------------------------------
# Loading
# ------------------------------------------------------------------
def _load(self) -> None:
if self.fmt is ReplayFormat.COSIM_DIR:
self._load_cosim()
elif self.fmt is ReplayFormat.RAW_IQ_NPY:
self._load_raw_iq()
elif self.fmt is ReplayFormat.HDF5:
self._load_hdf5()
def _load_cosim(self) -> None:
"""Load FPGA co-sim directory (already-processed .npy arrays).
Prefers fullchain (MTI-enabled) files when CFAR outputs are present,
so that I/Q data is consistent with the detection mask. Falls back
to the non-MTI ``doppler_map`` files when fullchain data is absent.
"""
d = Path(self.path)
# CFAR outputs (from the MTI→Doppler→DC-notch→CFAR chain)
cfar_flags = d / "fullchain_cfar_flags.npy"
cfar_mag = d / "fullchain_cfar_mag.npy"
has_cfar = cfar_flags.exists() and cfar_mag.exists()
# MTI-consistent I/Q (same chain that produced CFAR outputs)
mti_dop_i = d / "fullchain_mti_doppler_i.npy"
mti_dop_q = d / "fullchain_mti_doppler_q.npy"
has_mti_doppler = mti_dop_i.exists() and mti_dop_q.exists()
# Choose I/Q: prefer MTI-chain when CFAR data comes from that chain
if has_cfar and has_mti_doppler:
dop_i = np.load(mti_dop_i).astype(np.int16)
dop_q = np.load(mti_dop_q).astype(np.int16)
log.info("Co-sim: using fullchain MTI+Doppler I/Q (matches CFAR chain)")
else:
dop_i = np.load(d / "doppler_map_i.npy").astype(np.int16)
dop_q = np.load(d / "doppler_map_q.npy").astype(np.int16)
log.info("Co-sim: using non-MTI doppler_map I/Q")
frame = RadarFrame()
frame.range_doppler_i = dop_i
frame.range_doppler_q = dop_q
if has_cfar:
frame.detections = np.load(cfar_flags).astype(np.uint8)
frame.magnitude = np.load(cfar_mag).astype(np.float64)
else:
frame.magnitude = np.sqrt(
dop_i.astype(np.float64) ** 2 + dop_q.astype(np.float64) ** 2
)
frame.detections = np.zeros_like(dop_i, dtype=np.uint8)
frame.range_profile = frame.magnitude[:, 0]
frame.detection_count = int(frame.detections.sum())
frame.frame_number = 0
frame.timestamp = time.time()
self._cosim_frame = frame
self._total_frames = 1
log.info("Loaded co-sim directory: %s (1 frame)", self.path)
def _load_raw_iq(self) -> None:
"""Load raw complex IQ cube (.npy)."""
data = np.load(self.path, mmap_mode="r")
if data.ndim == 2:
# (chirps, samples) — single frame
data = data[np.newaxis, ...]
if data.ndim != 3:
msg = f"Expected 3-D array (frames, chirps, samples), got shape {data.shape}"
raise ValueError(msg)
self._raw_iq = data
self._total_frames = data.shape[0]
log.info(
"Loaded raw IQ: %s, shape %s (%d frames)",
self.path,
data.shape,
self._total_frames,
)
def _load_hdf5(self) -> None:
"""Load HDF5 recording (.h5)."""
if not HDF5_AVAILABLE:
msg = "h5py is required to load HDF5 recordings"
raise ImportError(msg)
self._h5_file = h5py.File(self.path, "r")
frames_grp = self._h5_file.get("frames")
if frames_grp is None:
msg = f"HDF5 file {self.path} has no 'frames' group"
raise ValueError(msg)
self._h5_keys = sorted(frames_grp.keys())
self._total_frames = len(self._h5_keys)
log.info("Loaded HDF5: %s (%d frames)", self.path, self._total_frames)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
@property
def total_frames(self) -> int:
return self._total_frames
def get_frame(self, index: int) -> RadarFrame:
"""Return the RadarFrame at *index* (0-based).
For ``RAW_IQ_NPY`` format, this runs the SoftwareFPGA chain
on the requested frame's chirps.
"""
if index < 0 or index >= self._total_frames:
msg = f"Frame index {index} out of range [0, {self._total_frames})"
raise IndexError(msg)
if self.fmt is ReplayFormat.COSIM_DIR:
return self._get_cosim(index)
if self.fmt is ReplayFormat.RAW_IQ_NPY:
return self._get_raw_iq(index)
return self._get_hdf5(index)
def close(self) -> None:
"""Release any open file handles."""
if self._h5_file is not None:
self._h5_file.close()
self._h5_file = None
# ------------------------------------------------------------------
# Per-format frame getters
# ------------------------------------------------------------------
def _get_cosim(self, _index: int) -> RadarFrame:
"""Co-sim: single static frame (index ignored).
Uses deepcopy so numpy arrays are not shared with the source,
preventing in-place mutation from corrupting cached data.
"""
import copy
frame = copy.deepcopy(self._cosim_frame)
frame.timestamp = time.time()
return frame
def _get_raw_iq(self, index: int) -> RadarFrame:
"""Raw IQ: quantize one frame and run through SoftwareFPGA."""
if self.software_fpga is None:
msg = "SoftwareFPGA is required for raw IQ replay"
raise RuntimeError(msg)
from .software_fpga import quantize_raw_iq
raw = self._raw_iq[index] # (chirps, samples) complex
iq_i, iq_q = quantize_raw_iq(raw[np.newaxis, ...])
return self.software_fpga.process_chirps(
iq_i, iq_q, frame_number=index, timestamp=time.time()
)
def _get_hdf5(self, index: int) -> RadarFrame:
"""HDF5: reconstruct RadarFrame from stored datasets."""
key = self._h5_keys[index]
grp = self._h5_file["frames"][key]
frame = RadarFrame()
frame.timestamp = float(grp.attrs.get("timestamp", time.time()))
frame.frame_number = int(grp.attrs.get("frame_number", index))
frame.detection_count = int(grp.attrs.get("detection_count", 0))
frame.range_doppler_i = np.array(grp["range_doppler_i"], dtype=np.int16)
frame.range_doppler_q = np.array(grp["range_doppler_q"], dtype=np.int16)
frame.magnitude = np.array(grp["magnitude"], dtype=np.float64)
frame.detections = np.array(grp["detections"], dtype=np.uint8)
frame.range_profile = np.array(grp["range_profile"], dtype=np.float64)
return frame
+287
View File
@@ -0,0 +1,287 @@
"""
v7.software_fpga Bit-accurate software replica of the AERIS-10 FPGA signal chain.
Imports processing functions directly from golden_reference.py (Option A)
to avoid code duplication. Every stage is toggleable via the same host
register interface the real FPGA exposes, so the dashboard spinboxes can
drive either backend transparently.
Signal chain order (matching RTL):
quantize range_fft decimator MTI doppler_fft dc_notch CFAR RadarFrame
Usage:
fpga = SoftwareFPGA()
fpga.set_cfar_enable(True)
frame = fpga.process_chirps(iq_i, iq_q, frame_number=0)
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
import numpy as np
# ---------------------------------------------------------------------------
# Import golden_reference by adding the cosim path to sys.path
# ---------------------------------------------------------------------------
_GOLDEN_REF_DIR = str(
Path(__file__).resolve().parents[2] # 9_Firmware/
/ "9_2_FPGA" / "tb" / "cosim" / "real_data"
)
if _GOLDEN_REF_DIR not in sys.path:
sys.path.insert(0, _GOLDEN_REF_DIR)
from golden_reference import ( # noqa: E402
run_range_fft,
run_range_bin_decimator,
run_mti_canceller,
run_doppler_fft,
run_dc_notch,
run_cfar_ca,
run_detection,
FFT_SIZE,
DOPPLER_CHIRPS,
)
# RadarFrame lives in radar_protocol (no circular dep — protocol has no GUI)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from radar_protocol import RadarFrame # noqa: E402
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Twiddle factor file paths (relative to FPGA root)
# ---------------------------------------------------------------------------
_FPGA_DIR = Path(__file__).resolve().parents[2] / "9_2_FPGA"
TWIDDLE_1024 = str(_FPGA_DIR / "fft_twiddle_1024.mem")
TWIDDLE_16 = str(_FPGA_DIR / "fft_twiddle_16.mem")
# CFAR mode int→string mapping (FPGA register 0x24: 0=CA, 1=GO, 2=SO)
_CFAR_MODE_MAP = {0: "CA", 1: "GO", 2: "SO", 3: "CA"}
class SoftwareFPGA:
"""Bit-accurate replica of the AERIS-10 FPGA signal processing chain.
All registers mirror FPGA reset defaults from ``radar_system_top.v``.
Setters accept the same integer values as the FPGA host commands.
"""
def __init__(self) -> None:
# --- FPGA register mirror (reset defaults) ---
# Detection
self.detect_threshold: int = 10_000 # 0x03
self.gain_shift: int = 0 # 0x16
# CFAR
self.cfar_enable: bool = False # 0x25
self.cfar_guard: int = 2 # 0x21
self.cfar_train: int = 8 # 0x22
self.cfar_alpha: int = 0x30 # 0x23 Q4.4
self.cfar_mode: int = 0 # 0x24 0=CA,1=GO,2=SO
# MTI
self.mti_enable: bool = False # 0x26
# DC notch
self.dc_notch_width: int = 0 # 0x27
# AGC (tracked but not applied in software chain — AGC operates
# on the analog front-end gain, which doesn't exist in replay)
self.agc_enable: bool = False # 0x28
self.agc_target: int = 200 # 0x29
self.agc_attack: int = 1 # 0x2A
self.agc_decay: int = 1 # 0x2B
self.agc_holdoff: int = 4 # 0x2C
# ------------------------------------------------------------------
# Register setters (same interface as UART commands to real FPGA)
# ------------------------------------------------------------------
def set_detect_threshold(self, val: int) -> None:
self.detect_threshold = int(val) & 0xFFFF
def set_gain_shift(self, val: int) -> None:
self.gain_shift = int(val) & 0x0F
def set_cfar_enable(self, val: bool) -> None:
self.cfar_enable = bool(val)
def set_cfar_guard(self, val: int) -> None:
self.cfar_guard = int(val) & 0x0F
def set_cfar_train(self, val: int) -> None:
self.cfar_train = max(1, int(val) & 0x1F)
def set_cfar_alpha(self, val: int) -> None:
self.cfar_alpha = int(val) & 0xFF
def set_cfar_mode(self, val: int) -> None:
self.cfar_mode = int(val) & 0x03
def set_mti_enable(self, val: bool) -> None:
self.mti_enable = bool(val)
def set_dc_notch_width(self, val: int) -> None:
self.dc_notch_width = int(val) & 0x07
def set_agc_enable(self, val: bool) -> None:
self.agc_enable = bool(val)
def set_agc_params(
self,
target: int | None = None,
attack: int | None = None,
decay: int | None = None,
holdoff: int | None = None,
) -> None:
if target is not None:
self.agc_target = int(target) & 0xFF
if attack is not None:
self.agc_attack = int(attack) & 0x0F
if decay is not None:
self.agc_decay = int(decay) & 0x0F
if holdoff is not None:
self.agc_holdoff = int(holdoff) & 0x0F
# ------------------------------------------------------------------
# Core processing: raw IQ chirps → RadarFrame
# ------------------------------------------------------------------
def process_chirps(
self,
iq_i: np.ndarray,
iq_q: np.ndarray,
frame_number: int = 0,
timestamp: float = 0.0,
) -> RadarFrame:
"""Run the full FPGA signal chain on pre-quantized 16-bit I/Q chirps.
Parameters
----------
iq_i, iq_q : ndarray, shape (n_chirps, n_samples), int16/int64
Post-DDC I/Q samples. For ADI phaser data, use
``quantize_raw_iq()`` first.
frame_number : int
Frame counter for the output RadarFrame.
timestamp : float
Timestamp for the output RadarFrame.
Returns
-------
RadarFrame
Populated frame identical to what the real FPGA would produce.
"""
n_chirps = iq_i.shape[0]
n_samples = iq_i.shape[1]
# --- Stage 1: Range FFT (per chirp) ---
range_i = np.zeros((n_chirps, n_samples), dtype=np.int64)
range_q = np.zeros((n_chirps, n_samples), dtype=np.int64)
twiddle_1024 = TWIDDLE_1024 if os.path.exists(TWIDDLE_1024) else None
for c in range(n_chirps):
range_i[c], range_q[c] = run_range_fft(
iq_i[c].astype(np.int64),
iq_q[c].astype(np.int64),
twiddle_file=twiddle_1024,
)
# --- Stage 2: Range bin decimation (1024 → 64) ---
decim_i, decim_q = run_range_bin_decimator(range_i, range_q)
# --- Stage 3: MTI canceller (pre-Doppler, per-chirp) ---
mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=self.mti_enable)
# --- Stage 4: Doppler FFT (dual 16-pt Hamming) ---
twiddle_16 = TWIDDLE_16 if os.path.exists(TWIDDLE_16) else None
doppler_i, doppler_q = run_doppler_fft(mti_i, mti_q, twiddle_file_16=twiddle_16)
# --- Stage 5: DC notch (bin zeroing) ---
notch_i, notch_q = run_dc_notch(doppler_i, doppler_q, width=self.dc_notch_width)
# --- Stage 6: Detection ---
if self.cfar_enable:
mode_str = _CFAR_MODE_MAP.get(self.cfar_mode, "CA")
detect_flags, magnitudes, _thresholds = run_cfar_ca(
notch_i,
notch_q,
guard=self.cfar_guard,
train=self.cfar_train,
alpha_q44=self.cfar_alpha,
mode=mode_str,
)
det_mask = detect_flags.astype(np.uint8)
mag = magnitudes.astype(np.float64)
else:
mag_raw, det_indices = run_detection(
notch_i, notch_q, threshold=self.detect_threshold
)
mag = mag_raw.astype(np.float64)
det_mask = np.zeros_like(mag, dtype=np.uint8)
for idx in det_indices:
det_mask[idx[0], idx[1]] = 1
# --- Assemble RadarFrame ---
frame = RadarFrame()
frame.timestamp = timestamp
frame.frame_number = frame_number
frame.range_doppler_i = np.clip(notch_i, -32768, 32767).astype(np.int16)
frame.range_doppler_q = np.clip(notch_q, -32768, 32767).astype(np.int16)
frame.magnitude = mag
frame.detections = det_mask
frame.range_profile = np.sqrt(
notch_i[:, 0].astype(np.float64) ** 2
+ notch_q[:, 0].astype(np.float64) ** 2
)
frame.detection_count = int(det_mask.sum())
return frame
# ---------------------------------------------------------------------------
# Utility: quantize arbitrary complex IQ to 16-bit post-DDC format
# ---------------------------------------------------------------------------
def quantize_raw_iq(
raw_complex: np.ndarray,
n_chirps: int = DOPPLER_CHIRPS,
n_samples: int = FFT_SIZE,
peak_target: int = 200,
) -> tuple[np.ndarray, np.ndarray]:
"""Quantize complex IQ data to 16-bit signed, matching DDC output level.
Parameters
----------
raw_complex : ndarray, shape (chirps, samples) or (frames, chirps, samples)
Complex64/128 baseband IQ from SDR capture. If 3-D, the first
axis is treated as frame index and only the first frame is used.
n_chirps : int
Number of chirps to keep (default 32, matching FPGA).
n_samples : int
Number of samples per chirp to keep (default 1024, matching FFT).
peak_target : int
Target peak magnitude after scaling (default 200, matching
golden_reference INPUT_PEAK_TARGET).
Returns
-------
iq_i, iq_q : ndarray, each (n_chirps, n_samples) int64
"""
if raw_complex.ndim == 3:
# (frames, chirps, samples) — take first frame
raw_complex = raw_complex[0]
# Truncate to FPGA dimensions
block = raw_complex[:n_chirps, :n_samples]
max_abs = np.max(np.abs(block))
if max_abs == 0:
return (
np.zeros((n_chirps, n_samples), dtype=np.int64),
np.zeros((n_chirps, n_samples), dtype=np.int64),
)
scale = peak_target / max_abs
scaled = block * scale
iq_i = np.clip(np.round(np.real(scaled)).astype(np.int64), -32768, 32767)
iq_q = np.clip(np.round(np.imag(scaled)).astype(np.int64), -32768, 32767)
return iq_i, iq_q
+179 -44
View File
@@ -13,7 +13,6 @@ All packet parsing now uses the production radar_protocol.py which matches
the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte). the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte).
""" """
import math
import time import time
import random import random
import queue import queue
@@ -36,58 +35,25 @@ from .processing import (
RadarProcessor, RadarProcessor,
USBPacketParser, USBPacketParser,
apply_pitch_correction, apply_pitch_correction,
polar_to_geographic,
) )
logger = logging.getLogger(__name__) 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) — production protocol # Radar Data Worker (QThread) — production protocol
# ============================================================================= # =============================================================================
class RadarDataWorker(QThread): class RadarDataWorker(QThread):
""" """
Background worker that reads radar data from FT2232H (or ReplayConnection), Background worker that reads radar data from FT2232H, parses 0xAA/0xBB
parses 0xAA/0xBB packets via production RadarAcquisition, runs optional packets via production RadarAcquisition, runs optional host-side DSP,
host-side DSP, and emits PyQt signals with results. and emits PyQt signals with results.
This replaces the old V7 worker which used an incompatible packet format. Uses production radar_protocol.py for all packet parsing and frame
Now uses production radar_protocol.py for all packet parsing and frame
assembly (11-byte 0xAA data packets 64x32 RadarFrame). assembly (11-byte 0xAA data packets 64x32 RadarFrame).
For replay, use ReplayWorker instead.
Signals: Signals:
frameReady(RadarFrame) a complete 64x32 radar frame frameReady(RadarFrame) a complete 64x32 radar frame
@@ -105,7 +71,7 @@ class RadarDataWorker(QThread):
def __init__( def __init__(
self, self,
connection, # FT2232HConnection or ReplayConnection connection, # FT2232HConnection
processor: RadarProcessor | None = None, processor: RadarProcessor | None = None,
recorder: DataRecorder | None = None, recorder: DataRecorder | None = None,
gps_data_ref: GPSData | None = None, gps_data_ref: GPSData | None = None,
@@ -203,7 +169,7 @@ class RadarDataWorker(QThread):
The FPGA already does: FFT, MTI, CFAR, DC notch. The FPGA already does: FFT, MTI, CFAR, DC notch.
Host-side DSP adds: clustering, tracking, geo-coordinate mapping. Host-side DSP adds: clustering, tracking, geo-coordinate mapping.
Bin-to-physical conversion uses RadarSettings.range_resolution Bin-to-physical conversion uses RadarSettings.range_bin_spacing
and velocity_resolution (should be calibrated to actual waveform). and velocity_resolution (should be calibrated to actual waveform).
""" """
targets: list[RadarTarget] = [] targets: list[RadarTarget] = []
@@ -214,7 +180,7 @@ class RadarDataWorker(QThread):
# Extract detections from FPGA CFAR flags # Extract detections from FPGA CFAR flags
det_indices = np.argwhere(frame.detections > 0) det_indices = np.argwhere(frame.detections > 0)
r_res = self._settings.range_resolution r_res = self._settings.range_bin_spacing
v_res = self._settings.velocity_resolution v_res = self._settings.velocity_resolution
for idx in det_indices: for idx in det_indices:
@@ -402,7 +368,7 @@ class TargetSimulator(QObject):
for t in self._targets: for t in self._targets:
new_range = t.range - t.velocity * 0.5 new_range = t.range - t.velocity * 0.5
if new_range < 500 or new_range > 50000: if new_range < 50 or new_range > 1536:
continue # target exits coverage — drop it continue # target exits coverage — drop it
new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2))) new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2)))
@@ -436,3 +402,172 @@ class TargetSimulator(QObject):
self._targets = updated self._targets = updated
self.targetsUpdated.emit(updated) self.targetsUpdated.emit(updated)
# =============================================================================
# Replay Worker (QThread) — unified replay playback
# =============================================================================
class ReplayWorker(QThread):
"""Background worker for replay data playback.
Emits the same signals as ``RadarDataWorker`` so the dashboard
treats live and replay identically. Additionally emits playback
state and frame-index signals for the transport controls.
Signals
-------
frameReady(object) RadarFrame
targetsUpdated(list) list[RadarTarget]
statsUpdated(dict) processing stats
errorOccurred(str) error message
playbackStateChanged(str) "playing" | "paused" | "stopped"
frameIndexChanged(int, int) (current_index, total_frames)
"""
frameReady = pyqtSignal(object)
targetsUpdated = pyqtSignal(list)
statsUpdated = pyqtSignal(dict)
errorOccurred = pyqtSignal(str)
playbackStateChanged = pyqtSignal(str)
frameIndexChanged = pyqtSignal(int, int)
def __init__(
self,
replay_engine,
settings: RadarSettings | None = None,
gps: GPSData | None = None,
frame_interval_ms: int = 100,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
import threading
from .processing import extract_targets_from_frame
from .models import WaveformConfig
self._engine = replay_engine
self._settings = settings or RadarSettings()
self._gps = gps
self._waveform = WaveformConfig()
self._frame_interval_ms = frame_interval_ms
self._extract_targets = extract_targets_from_frame
self._current_index = 0
self._last_emitted_index = 0
self._playing = False
self._stop_flag = False
self._loop = False
self._lock = threading.Lock() # guards _current_index and _emit_frame
# -- Public control API --
@property
def current_index(self) -> int:
"""Index of the last frame emitted (for re-seek on param change)."""
return self._last_emitted_index
@property
def total_frames(self) -> int:
return self._engine.total_frames
def set_gps(self, gps: GPSData | None) -> None:
self._gps = gps
def set_waveform(self, wf) -> None:
self._waveform = wf
def set_loop(self, loop: bool) -> None:
self._loop = loop
def set_frame_interval(self, ms: int) -> None:
self._frame_interval_ms = max(10, ms)
def play(self) -> None:
self._playing = True
# If at EOF, rewind so play actually does something
with self._lock:
if self._current_index >= self._engine.total_frames:
self._current_index = 0
self.playbackStateChanged.emit("playing")
def pause(self) -> None:
self._playing = False
self.playbackStateChanged.emit("paused")
def stop(self) -> None:
self._playing = False
self._stop_flag = True
self.playbackStateChanged.emit("stopped")
@property
def is_playing(self) -> bool:
"""Thread-safe read of playback state (for GUI queries)."""
return self._playing
def seek(self, index: int) -> None:
"""Jump to a specific frame and emit it (thread-safe)."""
with self._lock:
idx = max(0, min(index, self._engine.total_frames - 1))
self._current_index = idx
self._emit_frame(idx)
self._last_emitted_index = idx
# -- Thread entry --
def run(self) -> None:
self._stop_flag = False
self._playing = True
self.playbackStateChanged.emit("playing")
try:
while not self._stop_flag:
if self._playing:
with self._lock:
if self._current_index < self._engine.total_frames:
self._emit_frame(self._current_index)
self._last_emitted_index = self._current_index
self._current_index += 1
# Loop or pause at end
if self._current_index >= self._engine.total_frames:
if self._loop:
self._current_index = 0
else:
# Pause — keep thread alive for restart
self._playing = False
self.playbackStateChanged.emit("stopped")
self.msleep(self._frame_interval_ms)
except (OSError, ValueError, RuntimeError, IndexError) as exc:
self.errorOccurred.emit(str(exc))
self.playbackStateChanged.emit("stopped")
# -- Internal --
def _emit_frame(self, index: int) -> None:
try:
frame = self._engine.get_frame(index)
except (OSError, ValueError, RuntimeError, IndexError) as exc:
self.errorOccurred.emit(f"Frame {index}: {exc}")
return
self.frameReady.emit(frame)
self.frameIndexChanged.emit(index, self._engine.total_frames)
# Target extraction
targets = self._extract_targets(
frame,
bin_spacing=self._waveform.bin_spacing_m,
velocity_resolution=self._waveform.velocity_resolution_mps,
gps=self._gps,
)
self.targetsUpdated.emit(targets)
self.statsUpdated.emit({
"frame_number": frame.frame_number,
"detection_count": frame.detection_count,
"target_count": len(targets),
"replay_index": index,
"replay_total": self._engine.total_frames,
})
@@ -793,3 +793,51 @@ def parse_stm32_gpio_init(filepath: Path | None = None) -> list[GpioPin]:
)) ))
return pins return pins
# ===================================================================
# FPGA radar_params.vh parser
# ===================================================================
def parse_radar_params_vh() -> dict[str, int]:
"""
Parse `define values from radar_params.vh.
Returns dict like {"RP_FFT_SIZE": 1024, "RP_DECIMATION_FACTOR": 16, ...}.
Only parses defines with simple integer or Verilog literal values.
Skips bit-width prefixed literals (e.g. 2'b00) — returns the numeric value.
"""
path = FPGA_DIR / "radar_params.vh"
text = path.read_text()
params: dict[str, int] = {}
for m in re.finditer(
r'`define\s+(RP_\w+)\s+(\S+)', text
):
name = m.group(1)
val_str = m.group(2).rstrip()
# Skip non-numeric defines (like RADAR_PARAMS_VH guard)
if name == "RADAR_PARAMS_VH":
continue
# Handle Verilog bit-width literals: 2'b00, 8'h30, etc.
verilog_lit = re.match(r"\d+'([bhd])(\w+)", val_str)
if verilog_lit:
base_char = verilog_lit.group(1)
digits = verilog_lit.group(2)
base = {"b": 2, "h": 16, "d": 10}[base_char]
params[name] = int(digits, base)
continue
# Handle parenthesized expressions like (`RP_X * `RP_Y)
if "(" in val_str or "`" in val_str:
continue # Skip computed defines
# Plain integer
try:
params[name] = int(val_str)
except ValueError:
continue
return params
@@ -27,10 +27,12 @@ layers agree (because both could be wrong).
from __future__ import annotations from __future__ import annotations
import os import os
import re
import struct import struct
import subprocess import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import ClassVar
import pytest import pytest
@@ -202,6 +204,58 @@ class TestTier1OpcodeContract:
f"but ground truth says '{expected_reg}'" f"but ground truth says '{expected_reg}'"
) )
def test_opcode_count_exact_match(self):
"""
Total opcode count must match ground truth exactly.
This is a CANARY test: if an LLM agent or developer adds/removes
an opcode in one layer without updating ground truth, this fails
immediately. Update GROUND_TRUTH_OPCODES when intentionally
changing the register map.
"""
expected_count = len(GROUND_TRUTH_OPCODES) # 25
py_count = len(cp.parse_python_opcodes())
v_count = len(cp.parse_verilog_opcodes())
assert py_count == expected_count, (
f"Python has {py_count} opcodes but ground truth has {expected_count}. "
f"If intentional, update GROUND_TRUTH_OPCODES in this test file."
)
assert v_count == expected_count, (
f"Verilog has {v_count} opcodes but ground truth has {expected_count}. "
f"If intentional, update GROUND_TRUTH_OPCODES in this test file."
)
def test_no_extra_verilog_opcodes(self):
"""
Verilog must not have opcodes absent from ground truth.
Catches the case where someone adds a new case entry in
radar_system_top.v but forgets to update the contract.
"""
v_opcodes = cp.parse_verilog_opcodes()
extra = set(v_opcodes.keys()) - set(GROUND_TRUTH_OPCODES.keys())
assert not extra, (
f"Verilog has opcodes not in ground truth: "
f"{[f'0x{x:02X} ({v_opcodes[x].register})' for x in extra]}. "
f"Add them to GROUND_TRUTH_OPCODES if intentional."
)
def test_no_extra_python_opcodes(self):
"""
Python must not have opcodes absent from ground truth.
Catches phantom opcodes (like the 0x06 incident) added by
LLM agents that assume without verifying.
"""
py_opcodes = cp.parse_python_opcodes()
extra = set(py_opcodes.keys()) - set(GROUND_TRUTH_OPCODES.keys())
assert not extra, (
f"Python has opcodes not in ground truth: "
f"{[f'0x{x:02X} ({py_opcodes[x].name})' for x in extra]}. "
f"Remove phantom opcodes or add to GROUND_TRUTH_OPCODES."
)
class TestTier1BitWidths: class TestTier1BitWidths:
"""Verify register widths and opcode bit slices match ground truth.""" """Verify register widths and opcode bit slices match ground truth."""
@@ -300,6 +354,122 @@ class TestTier1StatusFieldPositions:
) )
class TestTier1ArchitecturalParams:
"""
Verify radar_params.vh (FPGA single source of truth) matches Python
WaveformConfig defaults and frozen architectural constants.
These tests catch:
- LLM agents changing FFT size, bin counts, or sample rates in one
layer without updating the other
- Accidental parameter drift between FPGA and GUI
- Unauthorized changes to critical architectural constants
When intentionally changing a parameter (e.g. FFT 10242048),
update BOTH radar_params.vh AND WaveformConfig, then update the
FROZEN_PARAMS dict below.
"""
# Frozen architectural constants — update deliberately when changing arch
FROZEN_PARAMS: ClassVar[dict[str, int]] = {
"RP_FFT_SIZE": 1024,
"RP_DECIMATION_FACTOR": 16,
"RP_BINS_PER_SEGMENT": 64,
"RP_OUTPUT_RANGE_BINS_3KM": 64,
"RP_DOPPLER_FFT_SIZE": 16,
"RP_NUM_DOPPLER_BINS": 32,
"RP_CHIRPS_PER_FRAME": 32,
"RP_CHIRPS_PER_SUBFRAME": 16,
"RP_DATA_WIDTH": 16,
"RP_PROCESSING_RATE_MHZ": 100,
"RP_RANGE_PER_BIN_DM": 240, # 24.0 m in decimeters
}
def test_radar_params_vh_parseable(self):
"""radar_params.vh must exist and parse without error."""
params = cp.parse_radar_params_vh()
assert len(params) > 10, (
f"Only parsed {len(params)} defines from radar_params.vh — "
f"expected > 10. Parser may be broken."
)
def test_frozen_constants_unchanged(self):
"""
Critical architectural constants must match frozen values.
If this test fails, someone changed a fundamental parameter.
Verify the change is intentional, update FROZEN_PARAMS, AND
update all downstream consumers (GUI, testbenches, docs).
"""
params = cp.parse_radar_params_vh()
for name, expected in self.FROZEN_PARAMS.items():
assert name in params, (
f"{name} missing from radar_params.vh! "
f"Was it renamed or removed?"
)
assert params[name] == expected, (
f"ARCHITECTURAL CHANGE DETECTED: {name} = {params[name]}, "
f"expected {expected}. If intentional, update FROZEN_PARAMS "
f"in this test AND all downstream consumers."
)
def test_fpga_python_fft_size_match(self):
"""FPGA FFT size must match Python WaveformConfig.fft_size."""
params = cp.parse_radar_params_vh()
sys.path.insert(0, str(cp.GUI_DIR))
from v7.models import WaveformConfig
wc = WaveformConfig()
assert params["RP_FFT_SIZE"] == wc.fft_size, (
f"FFT size mismatch: radar_params.vh={params['RP_FFT_SIZE']}, "
f"WaveformConfig={wc.fft_size}"
)
def test_fpga_python_decimation_match(self):
"""FPGA decimation factor must match Python WaveformConfig."""
params = cp.parse_radar_params_vh()
sys.path.insert(0, str(cp.GUI_DIR))
from v7.models import WaveformConfig
wc = WaveformConfig()
assert params["RP_DECIMATION_FACTOR"] == wc.decimation_factor, (
f"Decimation mismatch: radar_params.vh={params['RP_DECIMATION_FACTOR']}, "
f"WaveformConfig={wc.decimation_factor}"
)
def test_fpga_python_range_bins_match(self):
"""FPGA 3km output bins must match Python WaveformConfig.n_range_bins."""
params = cp.parse_radar_params_vh()
sys.path.insert(0, str(cp.GUI_DIR))
from v7.models import WaveformConfig
wc = WaveformConfig()
assert params["RP_OUTPUT_RANGE_BINS_3KM"] == wc.n_range_bins, (
f"Range bins mismatch: radar_params.vh={params['RP_OUTPUT_RANGE_BINS_3KM']}, "
f"WaveformConfig={wc.n_range_bins}"
)
def test_fpga_python_doppler_bins_match(self):
"""FPGA Doppler bins must match Python WaveformConfig.n_doppler_bins."""
params = cp.parse_radar_params_vh()
sys.path.insert(0, str(cp.GUI_DIR))
from v7.models import WaveformConfig
wc = WaveformConfig()
assert params["RP_NUM_DOPPLER_BINS"] == wc.n_doppler_bins, (
f"Doppler bins mismatch: radar_params.vh={params['RP_NUM_DOPPLER_BINS']}, "
f"WaveformConfig={wc.n_doppler_bins}"
)
def test_fpga_python_sample_rate_match(self):
"""FPGA processing rate must match Python WaveformConfig.sample_rate_hz."""
params = cp.parse_radar_params_vh()
sys.path.insert(0, str(cp.GUI_DIR))
from v7.models import WaveformConfig
wc = WaveformConfig()
fpga_rate_hz = params["RP_PROCESSING_RATE_MHZ"] * 1e6
assert fpga_rate_hz == wc.sample_rate_hz, (
f"Sample rate mismatch: radar_params.vh={fpga_rate_hz/1e6} MHz, "
f"WaveformConfig={wc.sample_rate_hz/1e6} MHz"
)
class TestTier1PacketConstants: class TestTier1PacketConstants:
"""Verify packet header/footer/size constants match across layers.""" """Verify packet header/footer/size constants match across layers."""
@@ -724,8 +894,8 @@ class TestTier3CStub:
"freq_max": 30.0e6, "freq_max": 30.0e6,
"prf1": 1000.0, "prf1": 1000.0,
"prf2": 2000.0, "prf2": 2000.0,
"max_distance": 50000.0, "max_distance": 1536.0,
"map_size": 50000.0, "map_size": 1536.0,
} }
pkt = self._build_settings_packet(values) pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt) result = self._run_stub(stub_binary, pkt)
@@ -784,11 +954,11 @@ class TestTier3CStub:
def test_bad_markers_rejected(self, stub_binary): def test_bad_markers_rejected(self, stub_binary):
"""Packet with wrong start/end markers must be rejected.""" """Packet with wrong start/end markers must be rejected."""
values = { values = {
"system_frequency": 10.0e9, "chirp_duration_1": 30.0e-6, "system_frequency": 10.5e9, "chirp_duration_1": 30.0e-6,
"chirp_duration_2": 0.5e-6, "chirps_per_position": 32, "chirp_duration_2": 0.5e-6, "chirps_per_position": 32,
"freq_min": 10.0e6, "freq_max": 30.0e6, "freq_min": 10.0e6, "freq_max": 30.0e6,
"prf1": 1000.0, "prf2": 2000.0, "prf1": 1000.0, "prf2": 2000.0,
"max_distance": 50000.0, "map_size": 50000.0, "max_distance": 1536.0, "map_size": 1536.0,
} }
pkt = self._build_settings_packet(values) pkt = self._build_settings_packet(values)
@@ -826,3 +996,107 @@ class TestTier3CStub:
assert result.get("parse_ok") == "true", ( assert result.get("parse_ok") == "true", (
f"Boundary values rejected: {result}" f"Boundary values rejected: {result}"
) )
# ===================================================================
# TIER 4: Stale Value Detection (LLM Agent Guardrails)
# ===================================================================
class TestTier4BannedPatterns:
"""
Scan source files for known-wrong values that have been corrected.
These patterns are stale ADI Phaser defaults, wrong sample rates,
and incorrect range calculations that were cleaned up in commit
d259e5c. If an LLM agent reintroduces them, this test catches it.
IMPORTANT: Allowlist entries exist for legitimate uses (e.g. comments
explaining what was wrong, test files checking for these values).
"""
# (regex_pattern, description, file_extensions_to_scan)
BANNED_PATTERNS: ClassVar[list[tuple[str, str, tuple[str, ...]]]] = [
# Wrong carrier frequency (ADI Phaser default, not PLFM)
(r'10[._]?525\s*e\s*9|10\.525\s*GHz|10525000000',
"Stale ADI Phaser carrier freq — PLFM uses 10.5 GHz",
("*.py", "*.v", "*.vh", "*.cpp", "*.h")),
# Wrong post-DDC sample rate (ADI Phaser uses 4 MSPS)
(r'(?<!\d)4e6(?!\d)|4_?000_?000\.0?\s*#.*(?:sample|samp|rate|fs)',
"Stale ADI 4 MSPS rate — PLFM post-DDC is 100 MSPS",
("*.py",)),
# Wrong range per bin values from old calculations
(r'(?<!\d)4\.8\s*(?:m/bin|m per|meters?\s*per)',
"Stale bin spacing 4.8 m — PLFM is 24.0 m/bin",
("*.py", "*.cpp")),
(r'(?<!\d)5\.6\s*(?:m/bin|m per|meters?\s*per)',
"Stale bin spacing 5.6 m — PLFM is 24.0 m/bin",
("*.py", "*.cpp")),
# Wrong range resolution from deramped FMCW formula
(r'781\.25',
"Stale ADI range value 781.25 — not applicable to PLFM",
("*.py",)),
]
# Files that are allowed to contain banned patterns
# (this test file, historical docs, comparison scripts)
# ADI co-sim files legitimately reference ADI Phaser hardware params
# because they process ADI test stimulus data (10.525 GHz, 4 MSPS).
ALLOWLIST: ClassVar[set[str]] = {
"test_cross_layer_contract.py", # This file — contains the patterns!
"CLAUDE.md", # Context doc may reference old values
"golden_reference.py", # Processes ADI Phaser test data
"tb_fullchain_mti_cfar_realdata.v", # $display of ADI test stimulus info
"tb_doppler_realdata.v", # $display of ADI test stimulus info
"tb_range_fft_realdata.v", # $display of ADI test stimulus info
"tb_fullchain_realdata.v", # $display of ADI test stimulus info
}
def _scan_files(self, pattern_re, extensions):
"""Scan firmware source files for a regex pattern."""
hits = []
firmware_dir = cp.REPO_ROOT / "9_Firmware"
for ext in extensions:
for fpath in firmware_dir.rglob(ext.replace("*", "**/*") if "**" in ext else ext):
# Skip allowlisted files
if fpath.name in self.ALLOWLIST:
continue
# Skip __pycache__, .git, etc.
parts = set(fpath.parts)
if parts & {"__pycache__", ".git", ".venv", "venv", ".ruff_cache"}:
continue
try:
text = fpath.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for i, line in enumerate(text.splitlines(), 1):
if pattern_re.search(line):
hits.append(f"{fpath.relative_to(cp.REPO_ROOT)}:{i}: {line.strip()[:120]}")
return hits
def test_no_banned_stale_values(self):
"""
No source file should contain known-wrong stale values.
If this fails, an LLM agent likely reintroduced a corrected value.
Check the flagged lines and fix them. If a line is a legitimate
use (e.g. a comment explaining history), add the file to ALLOWLIST.
"""
all_hits = []
for pattern_str, description, extensions in self.BANNED_PATTERNS:
pattern_re = re.compile(pattern_str, re.IGNORECASE)
hits = self._scan_files(pattern_re, extensions)
all_hits.extend(f"[{description}] {hit}" for hit in hits)
assert not all_hits, (
f"Found {len(all_hits)} stale/banned value(s) in source files:\n"
+ "\n".join(f" {h}" for h in all_hits[:20])
+ ("\n ... and more" if len(all_hits) > 20 else "")
)
+3 -3
View File
@@ -78,9 +78,9 @@ Every test binary must exit 0.
```bash ```bash
cd 9_Firmware/9_3_GUI cd 9_Firmware/9_3_GUI
python3 -m pytest test_radar_dashboard.py -v python3 -m pytest test_GUI_V65_Tk.py -v
# or without pytest: # or without pytest:
python3 -m unittest test_radar_dashboard -v python3 -m unittest test_GUI_V65_Tk -v
``` ```
57+ protocol and rendering tests. The `test_record_and_stop` test 57+ protocol and rendering tests. The `test_record_and_stop` test
@@ -130,7 +130,7 @@ Before pushing, confirm:
1. `bash run_regression.sh` — all phases pass 1. `bash run_regression.sh` — all phases pass
2. `make all` (MCU tests) — 20/20 pass 2. `make all` (MCU tests) — 20/20 pass
3. `python3 -m unittest test_radar_dashboard -v` — all pass 3. `python3 -m unittest test_GUI_V65_Tk -v` — all pass
4. `python3 validate_mem_files.py` — all checks pass 4. `python3 validate_mem_files.py` — all checks pass
5. `python3 compare.py dc && python3 compare_doppler.py stationary && python3 compare_mf.py all` 5. `python3 compare.py dc && python3 compare_doppler.py stationary && python3 compare_mf.py all`
6. `git diff --check` — no whitespace issues 6. `git diff --check` — no whitespace issues