mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-04-19 11:36:01 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76cfc71b19 | |||
| 161e9a66e4 | |||
| 7a35f42e61 | |||
| a03dd1329a | |||
| 6a11d33ef7 | |||
| b22cadb429 | |||
| f393e96d69 | |||
| 8609e455a0 | |||
| bcbbfabbdb | |||
| 35539ea934 | |||
| f67440ee9a | |||
| 513e0b9a69 | |||
| 78dff2fd3d | |||
| 0b25db08b5 | |||
| 4900282042 | |||
| 3f4513fec2 | |||
| a2686b7424 | |||
| cf3d288268 | |||
| 1c7861bb0d | |||
| d8d30a6315 | |||
| 34ecaf360b | |||
| 24b8442e40 | |||
| 2387f7f29f | |||
| 609589349d | |||
| a16472480a | |||
| a12ea90cdf | |||
| 2cb56e8b13 | |||
| 6bde91298d | |||
| 77496ccc88 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ extern "C" {
|
|||||||
* "BF" -- ADAR1000 beamformer
|
* "BF" -- ADAR1000 beamformer
|
||||||
* "PA" -- Power amplifier bias/monitoring
|
* "PA" -- Power amplifier bias/monitoring
|
||||||
* "FPGA" -- FPGA communication and handshake
|
* "FPGA" -- FPGA communication and handshake
|
||||||
* "USB" -- FT601 USB data path
|
* "USB" -- USB data path (FT2232H production / FT601 premium)
|
||||||
* "PWR" -- Power sequencing and rail monitoring
|
* "PWR" -- Power sequencing and rail monitoring
|
||||||
* "IMU" -- IMU/GPS/barometer sensors
|
* "IMU" -- IMU/GPS/barometer sensors
|
||||||
* "MOT" -- Stepper motor/scan mechanics
|
* "MOT" -- Stepper motor/scan mechanics
|
||||||
|
|||||||
@@ -620,7 +620,8 @@ typedef enum {
|
|||||||
ERROR_POWER_SUPPLY,
|
ERROR_POWER_SUPPLY,
|
||||||
ERROR_TEMPERATURE_HIGH,
|
ERROR_TEMPERATURE_HIGH,
|
||||||
ERROR_MEMORY_ALLOC,
|
ERROR_MEMORY_ALLOC,
|
||||||
ERROR_WATCHDOG_TIMEOUT
|
ERROR_WATCHDOG_TIMEOUT,
|
||||||
|
ERROR_COUNT // must be last — used for bounds checking error_strings[]
|
||||||
} SystemError_t;
|
} SystemError_t;
|
||||||
|
|
||||||
static SystemError_t last_error = ERROR_NONE;
|
static SystemError_t last_error = ERROR_NONE;
|
||||||
@@ -631,6 +632,27 @@ static bool system_emergency_state = false;
|
|||||||
SystemError_t checkSystemHealth(void) {
|
SystemError_t checkSystemHealth(void) {
|
||||||
SystemError_t current_error = ERROR_NONE;
|
SystemError_t current_error = ERROR_NONE;
|
||||||
|
|
||||||
|
// 0. Watchdog: detect main-loop stall (checkSystemHealth not called for >60 s).
|
||||||
|
// Timestamp is captured at function ENTRY and updated unconditionally, so
|
||||||
|
// any early return from a sub-check below cannot leave a stale value that
|
||||||
|
// would later trip a spurious ERROR_WATCHDOG_TIMEOUT. A dedicated cold-start
|
||||||
|
// branch ensures the first call after boot never trips (last_health_check==0
|
||||||
|
// would otherwise make `HAL_GetTick() - 0 > 60000` true forever after the
|
||||||
|
// 60-s mark of the init sequence).
|
||||||
|
static uint32_t last_health_check = 0;
|
||||||
|
uint32_t now_tick = HAL_GetTick();
|
||||||
|
if (last_health_check == 0) {
|
||||||
|
last_health_check = now_tick; // cold start: seed only
|
||||||
|
} else {
|
||||||
|
uint32_t elapsed = now_tick - last_health_check;
|
||||||
|
last_health_check = now_tick; // update BEFORE any early return
|
||||||
|
if (elapsed > 60000) {
|
||||||
|
current_error = ERROR_WATCHDOG_TIMEOUT;
|
||||||
|
DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)");
|
||||||
|
return current_error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check AD9523 Clock Generator
|
// 1. Check AD9523 Clock Generator
|
||||||
static uint32_t last_clock_check = 0;
|
static uint32_t last_clock_check = 0;
|
||||||
if (HAL_GetTick() - last_clock_check > 5000) {
|
if (HAL_GetTick() - last_clock_check > 5000) {
|
||||||
@@ -734,14 +756,7 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
return current_error;
|
return current_error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Simple watchdog check
|
// 9. Watchdog check is performed at function entry (see step 0).
|
||||||
static uint32_t last_health_check = 0;
|
|
||||||
if (HAL_GetTick() - last_health_check > 60000) {
|
|
||||||
current_error = ERROR_WATCHDOG_TIMEOUT;
|
|
||||||
DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)");
|
|
||||||
return current_error;
|
|
||||||
}
|
|
||||||
last_health_check = HAL_GetTick();
|
|
||||||
|
|
||||||
if (current_error != ERROR_NONE) {
|
if (current_error != ERROR_NONE) {
|
||||||
DIAG_ERR("SYS", "checkSystemHealth returning error code %d", current_error);
|
DIAG_ERR("SYS", "checkSystemHealth returning error code %d", current_error);
|
||||||
@@ -853,7 +868,7 @@ void handleSystemError(SystemError_t error) {
|
|||||||
DIAG_ERR("SYS", "handleSystemError: error=%d error_count=%lu", error, error_count);
|
DIAG_ERR("SYS", "handleSystemError: error=%d error_count=%lu", error, error_count);
|
||||||
|
|
||||||
char error_msg[100];
|
char error_msg[100];
|
||||||
const char* error_strings[] = {
|
static const char* const error_strings[] = {
|
||||||
"No error",
|
"No error",
|
||||||
"AD9523 Clock failure",
|
"AD9523 Clock failure",
|
||||||
"ADF4382 TX LO unlocked",
|
"ADF4382 TX LO unlocked",
|
||||||
@@ -873,9 +888,16 @@ void handleSystemError(SystemError_t error) {
|
|||||||
"Watchdog timeout"
|
"Watchdog timeout"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static_assert(sizeof(error_strings) / sizeof(error_strings[0]) == ERROR_COUNT,
|
||||||
|
"error_strings[] and SystemError_t enum are out of sync");
|
||||||
|
|
||||||
|
const char* err_name = (error >= 0 && error < (int)(sizeof(error_strings) / sizeof(error_strings[0])))
|
||||||
|
? error_strings[error]
|
||||||
|
: "Unknown error";
|
||||||
|
|
||||||
snprintf(error_msg, sizeof(error_msg),
|
snprintf(error_msg, sizeof(error_msg),
|
||||||
"ERROR #%d: %s (Count: %lu)\r\n",
|
"ERROR #%d: %s (Count: %lu)\r\n",
|
||||||
error, error_strings[error], error_count);
|
error, err_name, error_count);
|
||||||
HAL_UART_Transmit(&huart3, (uint8_t*)error_msg, strlen(error_msg), 1000);
|
HAL_UART_Transmit(&huart3, (uint8_t*)error_msg, strlen(error_msg), 1000);
|
||||||
|
|
||||||
// Blink LED pattern based on error code
|
// Blink LED pattern based on error code
|
||||||
@@ -885,9 +907,23 @@ void handleSystemError(SystemError_t error) {
|
|||||||
HAL_Delay(200);
|
HAL_Delay(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Critical errors trigger emergency shutdown
|
// Critical errors trigger emergency shutdown.
|
||||||
if (error >= ERROR_RF_PA_OVERCURRENT && error <= ERROR_POWER_SUPPLY) {
|
//
|
||||||
DIAG_ERR("SYS", "CRITICAL ERROR (code %d: %s) -- initiating Emergency_Stop()", error, error_strings[error]);
|
// Safety-critical range: any fault that can damage the PAs or leave the
|
||||||
|
// system in an undefined state must cut the RF rails via Emergency_Stop().
|
||||||
|
// This covers:
|
||||||
|
// ERROR_RF_PA_OVERCURRENT .. ERROR_POWER_SUPPLY (9..13) -- PA/supply faults
|
||||||
|
// ERROR_TEMPERATURE_HIGH (14) -- >75 C on the PA thermal sensors;
|
||||||
|
// without cutting bias + 5V/5V5/RFPA rails
|
||||||
|
// the GaN QPA2962 stage can thermal-runaway.
|
||||||
|
// ERROR_WATCHDOG_TIMEOUT (16) -- health-check loop has stalled (>60 s);
|
||||||
|
// transmitter state is unknown, safest to
|
||||||
|
// latch Emergency_Stop rather than rely on
|
||||||
|
// IWDG reset (which re-energises the rails).
|
||||||
|
if ((error >= ERROR_RF_PA_OVERCURRENT && error <= ERROR_POWER_SUPPLY) ||
|
||||||
|
error == ERROR_TEMPERATURE_HIGH ||
|
||||||
|
error == ERROR_WATCHDOG_TIMEOUT) {
|
||||||
|
DIAG_ERR("SYS", "CRITICAL ERROR (code %d: %s) -- initiating Emergency_Stop()", error, err_name);
|
||||||
snprintf(error_msg, sizeof(error_msg),
|
snprintf(error_msg, sizeof(error_msg),
|
||||||
"CRITICAL ERROR! Initiating emergency shutdown.\r\n");
|
"CRITICAL ERROR! Initiating emergency shutdown.\r\n");
|
||||||
HAL_UART_Transmit(&huart3, (uint8_t*)error_msg, strlen(error_msg), 1000);
|
HAL_UART_Transmit(&huart3, (uint8_t*)error_msg, strlen(error_msg), 1000);
|
||||||
|
|||||||
@@ -3,18 +3,38 @@
|
|||||||
*.dSYM/
|
*.dSYM/
|
||||||
|
|
||||||
# Test binaries (built by Makefile)
|
# Test binaries (built by Makefile)
|
||||||
|
# TESTS_WITH_REAL
|
||||||
test_bug1_timed_sync_init_ordering
|
test_bug1_timed_sync_init_ordering
|
||||||
test_bug2_ad9523_double_setup
|
|
||||||
test_bug3_timed_sync_noop
|
test_bug3_timed_sync_noop
|
||||||
test_bug4_phase_shift_before_check
|
test_bug4_phase_shift_before_check
|
||||||
test_bug5_fine_phase_gpio_only
|
test_bug5_fine_phase_gpio_only
|
||||||
|
test_bug9_platform_ops_null
|
||||||
|
test_bug10_spi_cs_not_toggled
|
||||||
|
test_bug15_htim3_dangling_extern
|
||||||
|
|
||||||
|
# TESTS_MOCK_ONLY
|
||||||
|
test_bug2_ad9523_double_setup
|
||||||
test_bug6_timer_variable_collision
|
test_bug6_timer_variable_collision
|
||||||
test_bug7_gpio_pin_conflict
|
test_bug7_gpio_pin_conflict
|
||||||
test_bug8_uart_commented_out
|
test_bug8_uart_commented_out
|
||||||
test_bug9_platform_ops_null
|
test_bug14_diag_section_args
|
||||||
test_bug10_spi_cs_not_toggled
|
test_gap3_emergency_stop_rails
|
||||||
test_bug11_platform_spi_transmit_only
|
|
||||||
|
# TESTS_STANDALONE
|
||||||
test_bug12_pa_cal_loop_inverted
|
test_bug12_pa_cal_loop_inverted
|
||||||
test_bug13_dac2_adc_buffer_mismatch
|
test_bug13_dac2_adc_buffer_mismatch
|
||||||
test_bug14_diag_section_args
|
test_gap3_iwdg_config
|
||||||
test_bug15_htim3_dangling_extern
|
test_gap3_temperature_max
|
||||||
|
test_gap3_idq_periodic_reread
|
||||||
|
test_gap3_emergency_state_ordering
|
||||||
|
test_gap3_overtemp_emergency_stop
|
||||||
|
test_gap3_health_watchdog_cold_start
|
||||||
|
|
||||||
|
# TESTS_WITH_PLATFORM
|
||||||
|
test_bug11_platform_spi_transmit_only
|
||||||
|
|
||||||
|
# TESTS_WITH_CXX
|
||||||
|
test_agc_outer_loop
|
||||||
|
|
||||||
|
# Manual / one-off test builds
|
||||||
|
test_um982_gps
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ TESTS_STANDALONE := test_bug12_pa_cal_loop_inverted \
|
|||||||
test_gap3_iwdg_config \
|
test_gap3_iwdg_config \
|
||||||
test_gap3_temperature_max \
|
test_gap3_temperature_max \
|
||||||
test_gap3_idq_periodic_reread \
|
test_gap3_idq_periodic_reread \
|
||||||
test_gap3_emergency_state_ordering
|
test_gap3_emergency_state_ordering \
|
||||||
|
test_gap3_overtemp_emergency_stop \
|
||||||
|
test_gap3_health_watchdog_cold_start
|
||||||
|
|
||||||
# Tests that need platform_noos_stm32.o + mocks
|
# Tests that need platform_noos_stm32.o + mocks
|
||||||
TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only
|
TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only
|
||||||
@@ -76,7 +78,8 @@ ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_W
|
|||||||
|
|
||||||
.PHONY: all build test clean \
|
.PHONY: all build test clean \
|
||||||
$(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \
|
$(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \
|
||||||
test_gap3_estop test_gap3_iwdg test_gap3_temp test_gap3_idq test_gap3_order
|
test_gap3_estop test_gap3_iwdg test_gap3_temp test_gap3_idq test_gap3_order \
|
||||||
|
test_gap3_overtemp test_gap3_wdog
|
||||||
|
|
||||||
all: build test
|
all: build test
|
||||||
|
|
||||||
@@ -162,6 +165,12 @@ test_gap3_idq_periodic_reread: test_gap3_idq_periodic_reread.c
|
|||||||
test_gap3_emergency_state_ordering: test_gap3_emergency_state_ordering.c
|
test_gap3_emergency_state_ordering: test_gap3_emergency_state_ordering.c
|
||||||
$(CC) $(CFLAGS) $< -o $@
|
$(CC) $(CFLAGS) $< -o $@
|
||||||
|
|
||||||
|
test_gap3_overtemp_emergency_stop: test_gap3_overtemp_emergency_stop.c
|
||||||
|
$(CC) $(CFLAGS) $< -o $@
|
||||||
|
|
||||||
|
test_gap3_health_watchdog_cold_start: test_gap3_health_watchdog_cold_start.c
|
||||||
|
$(CC) $(CFLAGS) $< -o $@
|
||||||
|
|
||||||
# Tests that need platform_noos_stm32.o + mocks
|
# Tests that need platform_noos_stm32.o + mocks
|
||||||
$(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ)
|
$(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ)
|
||||||
$(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@
|
$(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@
|
||||||
@@ -246,6 +255,12 @@ test_gap3_idq: test_gap3_idq_periodic_reread
|
|||||||
test_gap3_order: test_gap3_emergency_state_ordering
|
test_gap3_order: test_gap3_emergency_state_ordering
|
||||||
./test_gap3_emergency_state_ordering
|
./test_gap3_emergency_state_ordering
|
||||||
|
|
||||||
|
test_gap3_overtemp: test_gap3_overtemp_emergency_stop
|
||||||
|
./test_gap3_overtemp_emergency_stop
|
||||||
|
|
||||||
|
test_gap3_wdog: test_gap3_health_watchdog_cold_start
|
||||||
|
./test_gap3_health_watchdog_cold_start
|
||||||
|
|
||||||
# --- Clean ---
|
# --- Clean ---
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
|
|||||||
@@ -34,22 +34,25 @@ static void Mock_Emergency_Stop(void)
|
|||||||
state_was_true_when_estop_called = system_emergency_state;
|
state_was_true_when_estop_called = system_emergency_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error codes (subset matching main.cpp) */
|
/* Error codes (subset matching main.cpp SystemError_t) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
ERROR_NONE = 0,
|
ERROR_NONE = 0,
|
||||||
ERROR_RF_PA_OVERCURRENT = 9,
|
ERROR_RF_PA_OVERCURRENT = 9,
|
||||||
ERROR_RF_PA_BIAS = 10,
|
ERROR_RF_PA_BIAS = 10,
|
||||||
ERROR_STEPPER_FAULT = 11,
|
ERROR_STEPPER_MOTOR = 11,
|
||||||
ERROR_FPGA_COMM = 12,
|
ERROR_FPGA_COMM = 12,
|
||||||
ERROR_POWER_SUPPLY = 13,
|
ERROR_POWER_SUPPLY = 13,
|
||||||
ERROR_TEMPERATURE_HIGH = 14,
|
ERROR_TEMPERATURE_HIGH = 14,
|
||||||
|
ERROR_MEMORY_ALLOC = 15,
|
||||||
|
ERROR_WATCHDOG_TIMEOUT = 16,
|
||||||
} SystemError_t;
|
} SystemError_t;
|
||||||
|
|
||||||
/* Extracted critical-error handling logic (post-fix ordering) */
|
/* Extracted critical-error handling logic (matches post-fix main.cpp predicate) */
|
||||||
static void simulate_handleSystemError_critical(SystemError_t error)
|
static void simulate_handleSystemError_critical(SystemError_t error)
|
||||||
{
|
{
|
||||||
/* Only critical errors (PA overcurrent through power supply) trigger e-stop */
|
if ((error >= ERROR_RF_PA_OVERCURRENT && error <= ERROR_POWER_SUPPLY) ||
|
||||||
if (error >= ERROR_RF_PA_OVERCURRENT && error <= ERROR_POWER_SUPPLY) {
|
error == ERROR_TEMPERATURE_HIGH ||
|
||||||
|
error == ERROR_WATCHDOG_TIMEOUT) {
|
||||||
/* FIX 5: set flag BEFORE calling Emergency_Stop */
|
/* FIX 5: set flag BEFORE calling Emergency_Stop */
|
||||||
system_emergency_state = true;
|
system_emergency_state = true;
|
||||||
Mock_Emergency_Stop();
|
Mock_Emergency_Stop();
|
||||||
@@ -93,17 +96,39 @@ int main(void)
|
|||||||
assert(state_was_true_when_estop_called == true);
|
assert(state_was_true_when_estop_called == true);
|
||||||
printf("PASS\n");
|
printf("PASS\n");
|
||||||
|
|
||||||
/* Test 4: Non-critical error → no e-stop, flag stays false */
|
/* Test 4: Overtemp → MUST trigger e-stop (was incorrectly non-critical before fix) */
|
||||||
printf(" Test 4: Non-critical error (no e-stop)... ");
|
printf(" Test 4: Overtemp triggers e-stop... ");
|
||||||
system_emergency_state = false;
|
system_emergency_state = false;
|
||||||
emergency_stop_called = false;
|
emergency_stop_called = false;
|
||||||
|
state_was_true_when_estop_called = false;
|
||||||
simulate_handleSystemError_critical(ERROR_TEMPERATURE_HIGH);
|
simulate_handleSystemError_critical(ERROR_TEMPERATURE_HIGH);
|
||||||
|
assert(emergency_stop_called == true);
|
||||||
|
assert(system_emergency_state == true);
|
||||||
|
assert(state_was_true_when_estop_called == true);
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* Test 5: Watchdog timeout → MUST trigger e-stop */
|
||||||
|
printf(" Test 5: Watchdog timeout triggers e-stop... ");
|
||||||
|
system_emergency_state = false;
|
||||||
|
emergency_stop_called = false;
|
||||||
|
state_was_true_when_estop_called = false;
|
||||||
|
simulate_handleSystemError_critical(ERROR_WATCHDOG_TIMEOUT);
|
||||||
|
assert(emergency_stop_called == true);
|
||||||
|
assert(system_emergency_state == true);
|
||||||
|
assert(state_was_true_when_estop_called == true);
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* Test 6: Non-critical error (memory alloc) → no e-stop */
|
||||||
|
printf(" Test 6: Non-critical error (no e-stop)... ");
|
||||||
|
system_emergency_state = false;
|
||||||
|
emergency_stop_called = false;
|
||||||
|
simulate_handleSystemError_critical(ERROR_MEMORY_ALLOC);
|
||||||
assert(emergency_stop_called == false);
|
assert(emergency_stop_called == false);
|
||||||
assert(system_emergency_state == false);
|
assert(system_emergency_state == false);
|
||||||
printf("PASS\n");
|
printf("PASS\n");
|
||||||
|
|
||||||
/* Test 5: ERROR_NONE → no e-stop */
|
/* Test 7: ERROR_NONE → no e-stop */
|
||||||
printf(" Test 5: ERROR_NONE (no action)... ");
|
printf(" Test 7: ERROR_NONE (no action)... ");
|
||||||
system_emergency_state = false;
|
system_emergency_state = false;
|
||||||
emergency_stop_called = false;
|
emergency_stop_called = false;
|
||||||
simulate_handleSystemError_critical(ERROR_NONE);
|
simulate_handleSystemError_critical(ERROR_NONE);
|
||||||
@@ -111,6 +136,6 @@ int main(void)
|
|||||||
assert(system_emergency_state == false);
|
assert(system_emergency_state == false);
|
||||||
printf("PASS\n");
|
printf("PASS\n");
|
||||||
|
|
||||||
printf("\n=== Gap-3 Fix 5: ALL TESTS PASSED ===\n\n");
|
printf("\n=== Gap-3 Fix 5: ALL 7 TESTS PASSED ===\n\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* test_gap3_health_watchdog_cold_start.c
|
||||||
|
*
|
||||||
|
* Safety bug: checkSystemHealth()'s internal watchdog (step 9, pre-fix) had two
|
||||||
|
* linked defects that, once ERROR_WATCHDOG_TIMEOUT was escalated to
|
||||||
|
* Emergency_Stop() by the overtemp/watchdog PR, would false-latch the radar:
|
||||||
|
*
|
||||||
|
* (1) Cold-start false trip:
|
||||||
|
* static uint32_t last_health_check = 0;
|
||||||
|
* if (HAL_GetTick() - last_health_check > 60000) { ... }
|
||||||
|
* On the very first call, last_health_check == 0, so once the MCU has
|
||||||
|
* been up >60 s (which is typical after the ADAR1000 / AD9523 / ADF4382
|
||||||
|
* init sequence) the subtraction `now - 0` exceeds 60 000 ms and the
|
||||||
|
* watchdog trips spuriously.
|
||||||
|
*
|
||||||
|
* (2) Stale-timestamp after early returns:
|
||||||
|
* last_health_check = HAL_GetTick(); // at END of function
|
||||||
|
* Every earlier sub-check (IMU, BMP180, GPS, PA Idq, temperature) has an
|
||||||
|
* `if (fault) return current_error;` path that skips the update. After a
|
||||||
|
* cumulative 60 s of transient faults, the next clean call compares
|
||||||
|
* `now` against the long-stale `last_health_check` and trips.
|
||||||
|
*
|
||||||
|
* After fix: Watchdog logic moved to function ENTRY. A dedicated cold-start
|
||||||
|
* branch seeds the timestamp on the first call without checking.
|
||||||
|
* On every subsequent call, the elapsed delta is captured FIRST
|
||||||
|
* and last_health_check is updated BEFORE any sub-check runs, so
|
||||||
|
* early returns no longer leave a stale value.
|
||||||
|
*
|
||||||
|
* Test strategy:
|
||||||
|
* Extract the post-fix watchdog predicate into a standalone function that
|
||||||
|
* takes a simulated HAL_GetTick() value and returns whether the watchdog
|
||||||
|
* should trip. Walk through boot + fault sequences that would have tripped
|
||||||
|
* the pre-fix code and assert the post-fix code does NOT trip.
|
||||||
|
******************************************************************************/
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* --- Post-fix watchdog state + predicate, extracted verbatim --- */
|
||||||
|
static uint32_t last_health_check = 0;
|
||||||
|
|
||||||
|
/* Returns 1 iff this call should raise ERROR_WATCHDOG_TIMEOUT.
|
||||||
|
Updates last_health_check BEFORE returning (matches post-fix behaviour). */
|
||||||
|
static int health_watchdog_step(uint32_t now_tick)
|
||||||
|
{
|
||||||
|
if (last_health_check == 0) {
|
||||||
|
last_health_check = now_tick; /* cold start: seed only, never trip */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
uint32_t elapsed = now_tick - last_health_check;
|
||||||
|
last_health_check = now_tick; /* update BEFORE any early return */
|
||||||
|
return (elapsed > 60000) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test helper: reset the static state between scenarios. */
|
||||||
|
static void reset_state(void) { last_health_check = 0; }
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
printf("=== Safety fix: checkSystemHealth() watchdog cold-start + stale-ts ===\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 1: cold-start after 60 s of init must NOT trip ---- */
|
||||||
|
printf(" Test 1: first call at t=75000 ms (post-init) does not trip... ");
|
||||||
|
reset_state();
|
||||||
|
assert(health_watchdog_step(75000) == 0);
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 2: first call far beyond 60 s (PRE-FIX BUG) ------- */
|
||||||
|
printf(" Test 2: first call at t=600000 ms still does not trip... ");
|
||||||
|
reset_state();
|
||||||
|
assert(health_watchdog_step(600000) == 0);
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 3: healthy main-loop pacing (10 ms period) -------- */
|
||||||
|
printf(" Test 3: 1000 calls at 10 ms intervals never trip... ");
|
||||||
|
reset_state();
|
||||||
|
(void)health_watchdog_step(1000); /* seed */
|
||||||
|
for (int i = 1; i <= 1000; i++) {
|
||||||
|
assert(health_watchdog_step(1000 + i * 10) == 0);
|
||||||
|
}
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 4: stale-timestamp after a burst of early returns -
|
||||||
|
Pre-fix bug: many early returns skipped the timestamp update, so a
|
||||||
|
later clean call would compare `now` against a 60+ s old value. Post-fix,
|
||||||
|
every call (including ones that would have early-returned in the real
|
||||||
|
function) updates the timestamp at the top, so this scenario is modelled
|
||||||
|
by calling health_watchdog_step() on every iteration of the main loop. */
|
||||||
|
printf(" Test 4: 70 s of 100 ms-spaced calls after seed do not trip... ");
|
||||||
|
reset_state();
|
||||||
|
(void)health_watchdog_step(50000); /* seed mid-run */
|
||||||
|
for (int i = 1; i <= 700; i++) { /* 70 s @ 100 ms */
|
||||||
|
int tripped = health_watchdog_step(50000 + i * 100);
|
||||||
|
assert(tripped == 0);
|
||||||
|
}
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 5: genuine stall MUST trip ------------------------ */
|
||||||
|
printf(" Test 5: real 60+ s gap between calls does trip... ");
|
||||||
|
reset_state();
|
||||||
|
(void)health_watchdog_step(10000); /* seed */
|
||||||
|
assert(health_watchdog_step(10000 + 60001) == 1);
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 6: exactly 60 s gap is the boundary -- do NOT trip
|
||||||
|
Post-fix predicate uses strict >60000, matching the pre-fix comparator. */
|
||||||
|
printf(" Test 6: exactly 60000 ms gap does not trip (boundary)... ");
|
||||||
|
reset_state();
|
||||||
|
(void)health_watchdog_step(10000);
|
||||||
|
assert(health_watchdog_step(10000 + 60000) == 0);
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 7: trip, then recover on next paced call ---------- */
|
||||||
|
printf(" Test 7: after a genuine stall+trip, next paced call does not re-trip... ");
|
||||||
|
reset_state();
|
||||||
|
(void)health_watchdog_step(5000); /* seed */
|
||||||
|
assert(health_watchdog_step(5000 + 70000) == 1); /* stall -> trip */
|
||||||
|
assert(health_watchdog_step(5000 + 70000 + 10) == 0); /* resume paced */
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* ---------- Scenario 8: HAL_GetTick() 32-bit wrap (~49.7 days) ---------
|
||||||
|
Because we subtract unsigned 32-bit values, wrap is handled correctly as
|
||||||
|
long as the true elapsed time is < 2^32 ms. */
|
||||||
|
printf(" Test 8: tick wrap from 0xFFFFFF00 -> 0x00000064 (200 ms span) does not trip... ");
|
||||||
|
reset_state();
|
||||||
|
(void)health_watchdog_step(0xFFFFFF00u);
|
||||||
|
assert(health_watchdog_step(0x00000064u) == 0); /* elapsed = 0x164 = 356 ms */
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf("\n=== Safety fix: ALL TESTS PASSED ===\n\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/*******************************************************************************
|
||||||
|
* test_gap3_overtemp_emergency_stop.c
|
||||||
|
*
|
||||||
|
* Safety bug: handleSystemError() did not escalate ERROR_TEMPERATURE_HIGH
|
||||||
|
* (or ERROR_WATCHDOG_TIMEOUT) to Emergency_Stop().
|
||||||
|
*
|
||||||
|
* Before fix: The critical-error gate was
|
||||||
|
* if (error >= ERROR_RF_PA_OVERCURRENT &&
|
||||||
|
* error <= ERROR_POWER_SUPPLY) { Emergency_Stop(); }
|
||||||
|
* So overtemp (code 14) and watchdog timeout (code 16) fell
|
||||||
|
* through to attemptErrorRecovery()'s default branch (log and
|
||||||
|
* continue), leaving the 10 W GaN PAs biased at >75 °C.
|
||||||
|
*
|
||||||
|
* After fix: The gate also matches ERROR_TEMPERATURE_HIGH and
|
||||||
|
* ERROR_WATCHDOG_TIMEOUT, so thermal and watchdog faults
|
||||||
|
* latch Emergency_Stop() exactly like PA overcurrent.
|
||||||
|
*
|
||||||
|
* Test strategy:
|
||||||
|
* Replicate the critical-error predicate and assert that every error
|
||||||
|
* enum value which threatens RF/power safety is accepted, and that the
|
||||||
|
* non-critical ones (comm, sensor, memory) are not.
|
||||||
|
******************************************************************************/
|
||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
|
||||||
|
/* Mirror of SystemError_t from main.cpp (keep in lockstep). */
|
||||||
|
typedef enum {
|
||||||
|
ERROR_NONE = 0,
|
||||||
|
ERROR_AD9523_CLOCK,
|
||||||
|
ERROR_ADF4382_TX_UNLOCK,
|
||||||
|
ERROR_ADF4382_RX_UNLOCK,
|
||||||
|
ERROR_ADAR1000_COMM,
|
||||||
|
ERROR_ADAR1000_TEMP,
|
||||||
|
ERROR_IMU_COMM,
|
||||||
|
ERROR_BMP180_COMM,
|
||||||
|
ERROR_GPS_COMM,
|
||||||
|
ERROR_RF_PA_OVERCURRENT,
|
||||||
|
ERROR_RF_PA_BIAS,
|
||||||
|
ERROR_STEPPER_MOTOR,
|
||||||
|
ERROR_FPGA_COMM,
|
||||||
|
ERROR_POWER_SUPPLY,
|
||||||
|
ERROR_TEMPERATURE_HIGH,
|
||||||
|
ERROR_MEMORY_ALLOC,
|
||||||
|
ERROR_WATCHDOG_TIMEOUT
|
||||||
|
} SystemError_t;
|
||||||
|
|
||||||
|
/* Extracted post-fix predicate: returns 1 when Emergency_Stop() must fire. */
|
||||||
|
static int triggers_emergency_stop(SystemError_t e)
|
||||||
|
{
|
||||||
|
return ((e >= ERROR_RF_PA_OVERCURRENT && e <= ERROR_POWER_SUPPLY) ||
|
||||||
|
e == ERROR_TEMPERATURE_HIGH ||
|
||||||
|
e == ERROR_WATCHDOG_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void)
|
||||||
|
{
|
||||||
|
printf("=== Safety fix: overtemp / watchdog -> Emergency_Stop() ===\n");
|
||||||
|
|
||||||
|
/* --- Errors that MUST latch Emergency_Stop --- */
|
||||||
|
printf(" Test 1: ERROR_RF_PA_OVERCURRENT triggers... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_RF_PA_OVERCURRENT));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 2: ERROR_RF_PA_BIAS triggers... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_RF_PA_BIAS));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 3: ERROR_STEPPER_MOTOR triggers... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_STEPPER_MOTOR));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 4: ERROR_FPGA_COMM triggers... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_FPGA_COMM));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 5: ERROR_POWER_SUPPLY triggers... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_POWER_SUPPLY));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 6: ERROR_TEMPERATURE_HIGH triggers (regression)... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_TEMPERATURE_HIGH));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 7: ERROR_WATCHDOG_TIMEOUT triggers (regression)... ");
|
||||||
|
assert(triggers_emergency_stop(ERROR_WATCHDOG_TIMEOUT));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
/* --- Errors that MUST NOT escalate (recoverable / informational) --- */
|
||||||
|
printf(" Test 8: ERROR_NONE does not trigger... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_NONE));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 9: ERROR_AD9523_CLOCK does not trigger... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_AD9523_CLOCK));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 10: ERROR_ADF4382_TX_UNLOCK does not trigger (recoverable)... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_ADF4382_TX_UNLOCK));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 11: ERROR_ADAR1000_COMM does not trigger... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_ADAR1000_COMM));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 12: ERROR_IMU_COMM does not trigger... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_IMU_COMM));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 13: ERROR_GPS_COMM does not trigger... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_GPS_COMM));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf(" Test 14: ERROR_MEMORY_ALLOC does not trigger... ");
|
||||||
|
assert(!triggers_emergency_stop(ERROR_MEMORY_ALLOC));
|
||||||
|
printf("PASS\n");
|
||||||
|
|
||||||
|
printf("\n=== Safety fix: ALL TESTS PASSED ===\n\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -32,8 +32,8 @@ the `USB_MODE` parameter in `radar_system_top.v`:
|
|||||||
|
|
||||||
| USB_MODE | Interface | Bus Width | Speed | Board Target |
|
| USB_MODE | Interface | Bus Width | Speed | Board Target |
|
||||||
|----------|-----------|-----------|-------|--------------|
|
|----------|-----------|-----------|-------|--------------|
|
||||||
| 0 (default) | FT601 (USB 3.0) | 32-bit | 100 MHz | 200T premium dev board |
|
| 0 | FT601 (USB 3.0) | 32-bit | 100 MHz | 200T premium dev board |
|
||||||
| 1 | FT2232H (USB 2.0) | 8-bit | 60 MHz | 50T production board |
|
| 1 (default) | FT2232H (USB 2.0) | 8-bit | 60 MHz | 50T production board |
|
||||||
|
|
||||||
### How USB_MODE Works
|
### How USB_MODE Works
|
||||||
|
|
||||||
@@ -72,7 +72,8 @@ The parameter is set via a **wrapper module** that overrides the default:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- **200T dev board**: `radar_system_top` is used directly as the top module.
|
- **200T dev board**: `radar_system_top` is used directly as the top module.
|
||||||
`USB_MODE` defaults to `0` (FT601). No wrapper needed.
|
`USB_MODE` defaults to `1` (FT2232H) since production is the primary target.
|
||||||
|
Override with `.USB_MODE(0)` for FT601 builds.
|
||||||
|
|
||||||
### RTL Files by USB Interface
|
### RTL Files by USB Interface
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ The build scripts automatically select the correct top module and constraints:
|
|||||||
|
|
||||||
You do NOT need to set `USB_MODE` manually. The top module selection handles it:
|
You do NOT need to set `USB_MODE` manually. The top module selection handles it:
|
||||||
- `radar_system_top_50t` forces `USB_MODE=1` internally
|
- `radar_system_top_50t` forces `USB_MODE=1` internally
|
||||||
- `radar_system_top` defaults to `USB_MODE=0`
|
- `radar_system_top` defaults to `USB_MODE=1` (FT2232H, production default)
|
||||||
|
|
||||||
## How to Select Constraints in Vivado
|
## How to Select Constraints in Vivado
|
||||||
|
|
||||||
@@ -190,9 +191,9 @@ read_xdc constraints/te0713_te0701_minimal.xdc
|
|||||||
| Target | Top module | USB_MODE | USB Interface | Notes |
|
| Target | Top module | USB_MODE | USB Interface | Notes |
|
||||||
|--------|------------|----------|---------------|-------|
|
|--------|------------|----------|---------------|-------|
|
||||||
| 50T Production (FTG256) | `radar_system_top_50t` | 1 | FT2232H (8-bit) | Wrapper sets USB_MODE=1, ties off FT601 |
|
| 50T Production (FTG256) | `radar_system_top_50t` | 1 | FT2232H (8-bit) | Wrapper sets USB_MODE=1, ties off FT601 |
|
||||||
| 200T Dev (FBG484) | `radar_system_top` | 0 (default) | FT601 (32-bit) | No wrapper needed |
|
| 200T Dev (FBG484) | `radar_system_top` | 0 (override) | FT601 (32-bit) | Build script overrides default USB_MODE=1 |
|
||||||
| Trenz TE0712/TE0701 | `radar_system_top_te0712_dev` | 0 (default) | FT601 (32-bit) | Minimal bring-up wrapper |
|
| Trenz TE0712/TE0701 | `radar_system_top_te0712_dev` | 0 (override) | FT601 (32-bit) | Minimal bring-up wrapper |
|
||||||
| Trenz TE0713/TE0701 | `radar_system_top_te0713_dev` | 0 (default) | FT601 (32-bit) | Alternate SoM wrapper |
|
| Trenz TE0713/TE0701 | `radar_system_top_te0713_dev` | 0 (override) | FT601 (32-bit) | Alternate SoM wrapper |
|
||||||
|
|
||||||
## Trenz Split Status
|
## Trenz Split Status
|
||||||
|
|
||||||
|
|||||||
@@ -70,9 +70,10 @@ set_input_jitter [get_clocks clk_100m] 0.1
|
|||||||
# NOTE: The physical DAC (U3, AD9708) receives its clock directly from the
|
# NOTE: The physical DAC (U3, AD9708) receives its clock directly from the
|
||||||
# AD9523 via a separate net (DAC_CLOCK), NOT from the FPGA. The FPGA
|
# AD9523 via a separate net (DAC_CLOCK), NOT from the FPGA. The FPGA
|
||||||
# uses this clock input for internal DAC data timing only. The RTL port
|
# uses this clock input for internal DAC data timing only. The RTL port
|
||||||
# `dac_clk` is an output that assigns clk_120m directly — it has no
|
# `dac_clk` is an RTL output that assigns clk_120m directly. It has no
|
||||||
# separate physical pin on this board and should be removed from the
|
# physical pin on the 50T board and is left unconnected here. The port
|
||||||
# RTL or left unconnected.
|
# CANNOT be removed from the RTL because the 200T board uses it with
|
||||||
|
# ODDR clock forwarding (pin H17, see xc7a200t_fbg484.xdc).
|
||||||
# FIX: Moved from C13 (IO_L12N = N-type) to D13 (IO_L12P = P-type MRCC).
|
# FIX: Moved from C13 (IO_L12N = N-type) to D13 (IO_L12P = P-type MRCC).
|
||||||
# Clock inputs must use the P-type pin of an MRCC pair (PLIO-9 DRC).
|
# Clock inputs must use the P-type pin of an MRCC pair (PLIO-9 DRC).
|
||||||
set_property PACKAGE_PIN D13 [get_ports {clk_120m_dac}]
|
set_property PACKAGE_PIN D13 [get_ports {clk_120m_dac}]
|
||||||
@@ -332,6 +333,44 @@ set_property DRIVE 8 [get_ports {ft_data[*]}]
|
|||||||
|
|
||||||
# ft_clkout constrained above in CLOCK CONSTRAINTS section (C4, 60 MHz)
|
# ft_clkout constrained above in CLOCK CONSTRAINTS section (C4, 60 MHz)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# FT2232H Source-Synchronous Timing Constraints
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# FT2232H 245 Synchronous FIFO mode timing (60 MHz, period = 16.667 ns):
|
||||||
|
#
|
||||||
|
# FPGA Read Path (FT2232H drives data, FPGA samples):
|
||||||
|
# - Data valid before CLKOUT rising edge: t_vr(max) = 7.0 ns
|
||||||
|
# - Data hold after CLKOUT rising edge: t_hr(min) = 0.0 ns
|
||||||
|
# - Input delay max = period - t_vr = 16.667 - 7.0 = 9.667 ns
|
||||||
|
# - Input delay min = t_hr = 0.0 ns
|
||||||
|
#
|
||||||
|
# FPGA Write Path (FPGA drives data, FT2232H samples):
|
||||||
|
# - Data setup before next CLKOUT rising: t_su = 5.0 ns
|
||||||
|
# - Data hold after CLKOUT rising: t_hd = 0.0 ns
|
||||||
|
# - Output delay max = period - t_su = 16.667 - 5.0 = 11.667 ns
|
||||||
|
# - Output delay min = t_hd = 0.0 ns
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Input delays: FT2232H → FPGA (data bus and status signals)
|
||||||
|
set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_data[*]}]
|
||||||
|
set_input_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_data[*]}]
|
||||||
|
set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_rxf_n}]
|
||||||
|
set_input_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_rxf_n}]
|
||||||
|
set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_txe_n}]
|
||||||
|
set_input_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_txe_n}]
|
||||||
|
|
||||||
|
# Output delays: FPGA → FT2232H (control strobes and data bus when writing)
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_data[*]}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_data[*]}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_rd_n}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_rd_n}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_wr_n}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_wr_n}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_oe_n}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_oe_n}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_siwu}]
|
||||||
|
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_siwu}]
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STATUS / DEBUG OUTPUTS — NO PHYSICAL CONNECTIONS
|
# STATUS / DEBUG OUTPUTS — NO PHYSICAL CONNECTIONS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -418,10 +457,10 @@ set_property BITSTREAM.CONFIG.UNUSEDPIN Pullup [current_design]
|
|||||||
# 4. JTAG: FPGA_TCK (L7), FPGA_TDI (N7), FPGA_TDO (N8), FPGA_TMS (M7).
|
# 4. JTAG: FPGA_TCK (L7), FPGA_TDI (N7), FPGA_TDO (N8), FPGA_TMS (M7).
|
||||||
# Dedicated pins — no XDC constraints needed.
|
# Dedicated pins — no XDC constraints needed.
|
||||||
#
|
#
|
||||||
# 5. dac_clk port: The RTL top module declares `dac_clk` as an output, but
|
# 5. dac_clk port: Not connected on the 50T board (DAC clocked directly from
|
||||||
# the physical board wires the DAC clock (AD9708 CLOCK pin) directly from
|
# AD9523). The RTL port exists for 200T board compatibility, where the FPGA
|
||||||
# the AD9523, not from the FPGA. This port should be removed from the RTL
|
# forwards the DAC clock via ODDR to pin H17 with generated clock and
|
||||||
# or left unconnected. It currently just assigns clk_120m_dac passthrough.
|
# timing constraints (see xc7a200t_fbg484.xdc). Do NOT remove from RTL.
|
||||||
#
|
#
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# END OF CONSTRAINTS
|
# END OF CONSTRAINTS
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ module radar_system_top (
|
|||||||
parameter USE_LONG_CHIRP = 1'b1; // Default to long chirp
|
parameter USE_LONG_CHIRP = 1'b1; // Default to long chirp
|
||||||
parameter DOPPLER_ENABLE = 1'b1; // Enable Doppler processing
|
parameter DOPPLER_ENABLE = 1'b1; // Enable Doppler processing
|
||||||
parameter USB_ENABLE = 1'b1; // Enable USB data transfer
|
parameter USB_ENABLE = 1'b1; // Enable USB data transfer
|
||||||
parameter USB_MODE = 0; // 0=FT601 (32-bit, 200T), 1=FT2232H (8-bit, 50T)
|
parameter USB_MODE = 1; // 0=FT601 (32-bit, 200T), 1=FT2232H (8-bit, 50T production default)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// INTERNAL SIGNALS
|
// INTERNAL SIGNALS
|
||||||
|
|||||||
@@ -138,7 +138,12 @@ usb_data_interface usb_inst (
|
|||||||
.status_range_mode(2'b01),
|
.status_range_mode(2'b01),
|
||||||
.status_self_test_flags(5'b11111),
|
.status_self_test_flags(5'b11111),
|
||||||
.status_self_test_detail(8'hA5),
|
.status_self_test_detail(8'hA5),
|
||||||
.status_self_test_busy(1'b0)
|
.status_self_test_busy(1'b0),
|
||||||
|
// AGC status: tie off with benign defaults (no AGC on dev board)
|
||||||
|
.status_agc_current_gain(4'd0),
|
||||||
|
.status_agc_peak_magnitude(8'd0),
|
||||||
|
.status_agc_saturation_count(8'd0),
|
||||||
|
.status_agc_enable(1'b0)
|
||||||
);
|
);
|
||||||
|
|
||||||
endmodule
|
endmodule
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ PROD_RTL=(
|
|||||||
xfft_16.v
|
xfft_16.v
|
||||||
fft_engine.v
|
fft_engine.v
|
||||||
usb_data_interface.v
|
usb_data_interface.v
|
||||||
|
usb_data_interface_ft2232h.v
|
||||||
edge_detector.v
|
edge_detector.v
|
||||||
radar_mode_controller.v
|
radar_mode_controller.v
|
||||||
rx_gain_control.v
|
rx_gain_control.v
|
||||||
@@ -86,6 +87,33 @@ EXTRA_RTL=(
|
|||||||
frequency_matched_filter.v
|
frequency_matched_filter.v
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared RTL file lists for integration / system tests
|
||||||
|
# Centralised here so a new module only needs adding once.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Receiver chain (used by golden generate/compare tests)
|
||||||
|
RECEIVER_RTL=(
|
||||||
|
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 (receiver chain + TX + USB + detection + self-test)
|
||||||
|
SYSTEM_RTL=(
|
||||||
|
radar_system_top.v
|
||||||
|
radar_transmitter.v dac_interface_single.v plfm_chirp_controller.v
|
||||||
|
"${RECEIVER_RTL[@]}"
|
||||||
|
usb_data_interface.v usb_data_interface_ft2232h.v edge_detector.v
|
||||||
|
cfar_ca.v fpga_self_test.v
|
||||||
|
)
|
||||||
|
|
||||||
# ---- Layer A: iverilog -Wall compilation ----
|
# ---- Layer A: iverilog -Wall compilation ----
|
||||||
run_lint_iverilog() {
|
run_lint_iverilog() {
|
||||||
local label="$1"
|
local label="$1"
|
||||||
@@ -219,26 +247,9 @@ run_lint_static() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- Single-line regex checks across all production RTL ---
|
# CHECK 5 ($readmemh in synth code) and CHECK 6 (unused includes)
|
||||||
for f in "$@"; do
|
# require multi-line ifdef tracking / cross-file analysis. Not feasible
|
||||||
[[ -f "$f" ]] || continue
|
# with line-by-line regex. Omitted — use Vivado lint instead.
|
||||||
case "$f" in tb/*) continue ;; esac
|
|
||||||
|
|
||||||
local linenum=0
|
|
||||||
while IFS= read -r line; do
|
|
||||||
linenum=$((linenum + 1))
|
|
||||||
|
|
||||||
# CHECK 5: $readmemh / $readmemb in synthesizable code
|
|
||||||
# (Only valid in simulation blocks — flag if outside `ifdef SIMULATION)
|
|
||||||
# This is hard to check line-by-line without tracking ifdefs.
|
|
||||||
# Skip for v1.
|
|
||||||
|
|
||||||
# CHECK 6: Unused `include files (informational only)
|
|
||||||
# Skip for v1.
|
|
||||||
|
|
||||||
: # placeholder — prevents empty loop body
|
|
||||||
done < "$f"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [[ "$err_count" -gt 0 ]]; then
|
if [[ "$err_count" -gt 0 ]]; then
|
||||||
echo -e "${RED}FAIL${NC} ($err_count errors, $warn_count warnings)"
|
echo -e "${RED}FAIL${NC} ($err_count errors, $warn_count warnings)"
|
||||||
@@ -420,57 +431,36 @@ if [[ "$QUICK" -eq 0 ]]; then
|
|||||||
run_test "Receiver (golden generate)" \
|
run_test "Receiver (golden generate)" \
|
||||||
tb/tb_rx_golden_reg.vvp \
|
tb/tb_rx_golden_reg.vvp \
|
||||||
-DGOLDEN_GENERATE \
|
-DGOLDEN_GENERATE \
|
||||||
tb/tb_radar_receiver_final.v radar_receiver_final.v \
|
tb/tb_radar_receiver_final.v "${RECEIVER_RTL[@]}"
|
||||||
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
|
|
||||||
|
|
||||||
# Golden compare
|
# Golden compare
|
||||||
run_test "Receiver (golden compare)" \
|
run_test "Receiver (golden compare)" \
|
||||||
tb/tb_rx_compare_reg.vvp \
|
tb/tb_rx_compare_reg.vvp \
|
||||||
tb/tb_radar_receiver_final.v radar_receiver_final.v \
|
tb/tb_radar_receiver_final.v "${RECEIVER_RTL[@]}"
|
||||||
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)" \
|
||||||
tb/tb_system_reg.vvp \
|
tb/tb_system_reg.vvp \
|
||||||
tb/radar_system_tb.v radar_system_top.v \
|
tb/radar_system_tb.v "${SYSTEM_RTL[@]}"
|
||||||
radar_transmitter.v dac_interface_single.v plfm_chirp_controller.v \
|
|
||||||
radar_receiver_final.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 \
|
|
||||||
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
|
|
||||||
|
|
||||||
# E2E integration (46 strict checks: TX, RX, USB R/W, CDC, safety, reset)
|
# E2E integration (46 strict checks: TX, RX, USB R/W, CDC, safety, reset)
|
||||||
run_test "System E2E (tb_system_e2e)" \
|
run_test "System E2E (tb_system_e2e)" \
|
||||||
tb/tb_system_e2e_reg.vvp \
|
tb/tb_system_e2e_reg.vvp \
|
||||||
tb/tb_system_e2e.v radar_system_top.v \
|
tb/tb_system_e2e.v "${SYSTEM_RTL[@]}"
|
||||||
radar_transmitter.v dac_interface_single.v plfm_chirp_controller.v \
|
|
||||||
radar_receiver_final.v tb/ad9484_interface_400m_stub.v \
|
# USB_MODE=1 (FT2232H production) variants of system tests
|
||||||
ddc_400m.v nco_400m_enhanced.v cic_decimator_4x_enhanced.v \
|
run_test "System Top USB_MODE=1 (FT2232H)" \
|
||||||
cdc_modules.v fir_lowpass.v ddc_input_interface.v \
|
tb/tb_system_ft2232h_reg.vvp \
|
||||||
chirp_memory_loader_param.v latency_buffer.v \
|
-DUSB_MODE_1 \
|
||||||
matched_filter_multi_segment.v matched_filter_processing_chain.v \
|
tb/radar_system_tb.v "${SYSTEM_RTL[@]}"
|
||||||
range_bin_decimator.v doppler_processor.v xfft_16.v fft_engine.v \
|
|
||||||
usb_data_interface.v edge_detector.v radar_mode_controller.v \
|
run_test "System E2E USB_MODE=1 (FT2232H)" \
|
||||||
rx_gain_control.v cfar_ca.v mti_canceller.v fpga_self_test.v
|
tb/tb_system_e2e_ft2232h_reg.vvp \
|
||||||
|
-DUSB_MODE_1 \
|
||||||
|
tb/tb_system_e2e.v "${SYSTEM_RTL[@]}"
|
||||||
else
|
else
|
||||||
echo " (skipped receiver golden + system top + E2E — use without --quick)"
|
echo " (skipped receiver golden + system top + E2E — use without --quick)"
|
||||||
SKIP=$((SKIP + 4))
|
SKIP=$((SKIP + 6))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ add_files -fileset constrs_1 -norecurse [file join $project_root "constraints" "
|
|||||||
|
|
||||||
set_property top $top_module [current_fileset]
|
set_property top $top_module [current_fileset]
|
||||||
set_property verilog_define {FFT_XPM_BRAM} [current_fileset]
|
set_property verilog_define {FFT_XPM_BRAM} [current_fileset]
|
||||||
|
# Override USB_MODE to 0 (FT601) for 200T premium board.
|
||||||
|
# The RTL default is USB_MODE=1 (FT2232H, production 50T).
|
||||||
|
set_property generic {USB_MODE=0} [current_fileset]
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 2. Synthesis
|
# 2. Synthesis
|
||||||
|
|||||||
@@ -430,7 +430,13 @@ end
|
|||||||
// DUT INSTANTIATION
|
// DUT INSTANTIATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
radar_system_top dut (
|
radar_system_top #(
|
||||||
|
`ifdef USB_MODE_1
|
||||||
|
.USB_MODE(1) // FT2232H interface (production 50T board)
|
||||||
|
`else
|
||||||
|
.USB_MODE(0) // FT601 interface (200T dev board)
|
||||||
|
`endif
|
||||||
|
) dut (
|
||||||
// System Clocks
|
// System Clocks
|
||||||
.clk_100m(clk_100m),
|
.clk_100m(clk_100m),
|
||||||
.clk_120m_dac(clk_120m_dac),
|
.clk_120m_dac(clk_120m_dac),
|
||||||
@@ -619,7 +625,11 @@ initial begin
|
|||||||
// Optional: dump specific signals for debugging
|
// Optional: dump specific signals for debugging
|
||||||
$dumpvars(1, dut.tx_inst);
|
$dumpvars(1, dut.tx_inst);
|
||||||
$dumpvars(1, dut.rx_inst);
|
$dumpvars(1, dut.rx_inst);
|
||||||
|
`ifdef USB_MODE_1
|
||||||
|
$dumpvars(1, dut.gen_ft2232h.usb_inst);
|
||||||
|
`else
|
||||||
$dumpvars(1, dut.gen_ft601.usb_inst);
|
$dumpvars(1, dut.gen_ft601.usb_inst);
|
||||||
|
`endif
|
||||||
end
|
end
|
||||||
|
|
||||||
endmodule
|
endmodule
|
||||||
|
|||||||
@@ -382,7 +382,13 @@ end
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DUT INSTANTIATION
|
// DUT INSTANTIATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
radar_system_top dut (
|
radar_system_top #(
|
||||||
|
`ifdef USB_MODE_1
|
||||||
|
.USB_MODE(1) // FT2232H interface (production 50T board)
|
||||||
|
`else
|
||||||
|
.USB_MODE(0) // FT601 interface (200T dev board)
|
||||||
|
`endif
|
||||||
|
) dut (
|
||||||
.clk_100m(clk_100m),
|
.clk_100m(clk_100m),
|
||||||
.clk_120m_dac(clk_120m_dac),
|
.clk_120m_dac(clk_120m_dac),
|
||||||
.ft601_clk_in(ft601_clk_in),
|
.ft601_clk_in(ft601_clk_in),
|
||||||
@@ -554,10 +560,10 @@ initial begin
|
|||||||
do_reset;
|
do_reset;
|
||||||
|
|
||||||
// CRITICAL: Configure stream control to range-only BEFORE any chirps
|
// CRITICAL: Configure stream control to range-only BEFORE any chirps
|
||||||
// fire. The USB write FSM blocks on doppler_valid_ft if doppler stream
|
// fire. The USB write FSM gates on pending flags: if doppler stream is
|
||||||
// is enabled but no Doppler data arrives (needs 32 chirps/frame).
|
// enabled but no Doppler data arrives (needs 32 chirps/frame), the FSM
|
||||||
// Without this, the write FSM deadlocks and the read FSM can never
|
// stays in IDLE waiting for doppler_data_pending. With the write FSM
|
||||||
// activate (it requires write FSM == IDLE).
|
// not in IDLE, the read FSM cannot activate (bus arbitration rule).
|
||||||
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // stream_control = range only
|
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // stream_control = range only
|
||||||
// Wait for stream_control CDC to propagate (2-stage sync in ft601_clk)
|
// Wait for stream_control CDC to propagate (2-stage sync in ft601_clk)
|
||||||
// Must be long enough that stream_ctrl_sync_1 is updated before any
|
// Must be long enough that stream_ctrl_sync_1 is updated before any
|
||||||
@@ -778,7 +784,7 @@ initial begin
|
|||||||
|
|
||||||
// Restore defaults for subsequent tests
|
// Restore defaults for subsequent tests
|
||||||
bfm_send_cmd(8'h01, 8'h00, 16'h0001); // mode = auto-scan
|
bfm_send_cmd(8'h01, 8'h00, 16'h0001); // mode = auto-scan
|
||||||
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // keep range-only (prevents write FSM deadlock)
|
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // keep range-only (TB lacks 32-chirp doppler data)
|
||||||
bfm_send_cmd(8'h10, 8'h00, 16'd3000); // restore long chirp cycles
|
bfm_send_cmd(8'h10, 8'h00, 16'd3000); // restore long chirp cycles
|
||||||
|
|
||||||
$display("");
|
$display("");
|
||||||
@@ -913,7 +919,7 @@ initial begin
|
|||||||
// Need to re-send configuration since reset clears all registers
|
// Need to re-send configuration since reset clears all registers
|
||||||
stm32_mixers_enable = 1;
|
stm32_mixers_enable = 1;
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // stream_control = range only (prevent deadlock)
|
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // stream_control = range only (TB lacks doppler data)
|
||||||
#500; // Wait for stream_control CDC
|
#500; // Wait for stream_control CDC
|
||||||
bfm_send_cmd(8'h01, 8'h00, 16'h0001); // auto-scan
|
bfm_send_cmd(8'h01, 8'h00, 16'h0001); // auto-scan
|
||||||
bfm_send_cmd(8'h10, 8'h00, 16'd100); // short timing
|
bfm_send_cmd(8'h10, 8'h00, 16'd100); // short timing
|
||||||
@@ -947,7 +953,7 @@ initial begin
|
|||||||
check(dut.host_stream_control == 3'b000,
|
check(dut.host_stream_control == 3'b000,
|
||||||
"G10.2: All streams disabled (stream_control = 3'b000)");
|
"G10.2: All streams disabled (stream_control = 3'b000)");
|
||||||
|
|
||||||
// G10.3: Re-enable range only (keep range-only to prevent write FSM deadlock)
|
// G10.3: Re-enable range only (TB uses range-only — no doppler processing)
|
||||||
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // stream_control = 3'b001
|
bfm_send_cmd(8'h04, 8'h00, 16'h0001); // stream_control = 3'b001
|
||||||
check(dut.host_stream_control == 3'b001,
|
check(dut.host_stream_control == 3'b001,
|
||||||
"G10.3: Range stream re-enabled (stream_control = 3'b001)");
|
"G10.3: Range stream re-enabled (stream_control = 3'b001)");
|
||||||
|
|||||||
@@ -6,15 +6,11 @@ module tb_usb_data_interface;
|
|||||||
localparam CLK_PERIOD = 10.0; // 100 MHz main clock
|
localparam CLK_PERIOD = 10.0; // 100 MHz main clock
|
||||||
localparam FT_CLK_PERIOD = 10.0; // 100 MHz FT601 clock (asynchronous)
|
localparam FT_CLK_PERIOD = 10.0; // 100 MHz FT601 clock (asynchronous)
|
||||||
|
|
||||||
// State definitions (mirror the DUT)
|
// State definitions (mirror the DUT — 4-state packed-word FSM)
|
||||||
localparam [2:0] S_IDLE = 3'd0,
|
localparam [3:0] S_IDLE = 4'd0,
|
||||||
S_SEND_HEADER = 3'd1,
|
S_SEND_DATA_WORD = 4'd1,
|
||||||
S_SEND_RANGE = 3'd2,
|
S_SEND_STATUS = 4'd2,
|
||||||
S_SEND_DOPPLER = 3'd3,
|
S_WAIT_ACK = 4'd3;
|
||||||
S_SEND_DETECT = 3'd4,
|
|
||||||
S_SEND_FOOTER = 3'd5,
|
|
||||||
S_WAIT_ACK = 3'd6,
|
|
||||||
S_SEND_STATUS = 3'd7; // Gap 2: status readback
|
|
||||||
|
|
||||||
// ── Signals ────────────────────────────────────────────────
|
// ── Signals ────────────────────────────────────────────────
|
||||||
reg clk;
|
reg clk;
|
||||||
@@ -219,9 +215,9 @@ module tb_usb_data_interface;
|
|||||||
end
|
end
|
||||||
endtask
|
endtask
|
||||||
|
|
||||||
// ── Helper: wait for DUT to reach a specific state ─────────
|
// ── Helper: wait for DUT to reach a specific write FSM state ──
|
||||||
task wait_for_state;
|
task wait_for_state;
|
||||||
input [2:0] target;
|
input [3:0] target;
|
||||||
input integer max_cyc;
|
input integer max_cyc;
|
||||||
integer cnt;
|
integer cnt;
|
||||||
begin
|
begin
|
||||||
@@ -280,7 +276,7 @@ module tb_usb_data_interface;
|
|||||||
// Set data_pending flags directly via hierarchical access.
|
// Set data_pending flags directly via hierarchical access.
|
||||||
// This is the standard TB technique for internal state setup —
|
// This is the standard TB technique for internal state setup —
|
||||||
// bypasses the CDC path for immediate, reliable flag setting.
|
// bypasses the CDC path for immediate, reliable flag setting.
|
||||||
// Call BEFORE assert_range_valid in tests that need SEND_DOPPLER/DETECT.
|
// Call BEFORE assert_range_valid in tests that need doppler/cfar data.
|
||||||
task preload_pending_data;
|
task preload_pending_data;
|
||||||
begin
|
begin
|
||||||
@(posedge ft601_clk_in);
|
@(posedge ft601_clk_in);
|
||||||
@@ -354,24 +350,26 @@ module tb_usb_data_interface;
|
|||||||
end
|
end
|
||||||
endtask
|
endtask
|
||||||
|
|
||||||
// Drive a complete packet through the FSM by sequentially providing
|
// Drive a complete data packet through the new 3-word packed FSM.
|
||||||
// range, doppler (4x), and cfar valid pulses.
|
// Pre-loads pending flags, triggers range_valid, and waits for IDLE.
|
||||||
|
// With the new FSM, all data is pre-packed in IDLE then sent as 3 words.
|
||||||
task drive_full_packet;
|
task drive_full_packet;
|
||||||
input [31:0] rng;
|
input [31:0] rng;
|
||||||
input [15:0] dr;
|
input [15:0] dr;
|
||||||
input [15:0] di;
|
input [15:0] di;
|
||||||
input det;
|
input det;
|
||||||
begin
|
begin
|
||||||
// Pre-load pending flags so FSM enters doppler/cfar states
|
// Set doppler/cfar captured values via CDC inputs
|
||||||
|
@(posedge clk);
|
||||||
|
doppler_real = dr;
|
||||||
|
doppler_imag = di;
|
||||||
|
cfar_detection = det;
|
||||||
|
@(posedge clk);
|
||||||
|
// Pre-load pending flags so FSM includes doppler/cfar in packet
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
|
// Trigger the packet
|
||||||
assert_range_valid(rng);
|
assert_range_valid(rng);
|
||||||
wait_for_state(S_SEND_DOPPLER, 100);
|
// Wait for complete packet cycle: IDLE → SEND_DATA_WORD(×3) → WAIT_ACK → IDLE
|
||||||
pulse_doppler_once(dr, di);
|
|
||||||
pulse_doppler_once(dr, di);
|
|
||||||
pulse_doppler_once(dr, di);
|
|
||||||
pulse_doppler_once(dr, di);
|
|
||||||
wait_for_state(S_SEND_DETECT, 100);
|
|
||||||
pulse_cfar_once(det);
|
|
||||||
wait_for_state(S_IDLE, 100);
|
wait_for_state(S_IDLE, 100);
|
||||||
end
|
end
|
||||||
endtask
|
endtask
|
||||||
@@ -414,101 +412,138 @@ module tb_usb_data_interface;
|
|||||||
"ft601_siwu_n=1 after reset");
|
"ft601_siwu_n=1 after reset");
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 2: Range data packet
|
// TEST GROUP 2: Data packet word packing
|
||||||
//
|
//
|
||||||
// Use backpressure to freeze the FSM at specific states
|
// New FSM packs 11-byte data into 3 × 32-bit words:
|
||||||
// so we can reliably sample outputs.
|
// Word 0: {HEADER, range[31:24], range[23:16], range[15:8]}
|
||||||
|
// Word 1: {range[7:0], dop_re_hi, dop_re_lo, dop_im_hi}
|
||||||
|
// Word 2: {dop_im_lo, detection, FOOTER, 0x00} BE=1110
|
||||||
|
//
|
||||||
|
// The DUT uses range_data_ready (1-cycle delayed range_valid_ft)
|
||||||
|
// to trigger packing. Doppler/CFAR _cap registers must be
|
||||||
|
// pre-loaded via hierarchical access because no valid pulse is
|
||||||
|
// given in this test (we only want to verify packing, not CDC).
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 2: Range Data Packet ---");
|
$display("\n--- Test Group 2: Data Packet Word Packing ---");
|
||||||
apply_reset;
|
apply_reset;
|
||||||
|
ft601_txe = 1; // Stall so we can inspect packed words
|
||||||
|
|
||||||
// Stall at SEND_HEADER so we can verify first range word later
|
// Set known doppler/cfar values on clk-domain inputs
|
||||||
ft601_txe = 1;
|
@(posedge clk);
|
||||||
|
doppler_real = 16'hABCD;
|
||||||
|
doppler_imag = 16'hEF01;
|
||||||
|
cfar_detection = 1'b1;
|
||||||
|
@(posedge clk);
|
||||||
|
|
||||||
|
// Pre-load pending flags AND captured-data registers directly.
|
||||||
|
// No doppler/cfar valid pulses are given, so the CDC capture path
|
||||||
|
// never fires — we must set the _cap registers via hierarchical
|
||||||
|
// access for the word-packing checks to be meaningful.
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
|
@(posedge ft601_clk_in);
|
||||||
|
uut.doppler_real_cap = 16'hABCD;
|
||||||
|
uut.doppler_imag_cap = 16'hEF01;
|
||||||
|
uut.cfar_detection_cap = 1'b1;
|
||||||
|
@(posedge ft601_clk_in);
|
||||||
|
|
||||||
assert_range_valid(32'hDEAD_BEEF);
|
assert_range_valid(32'hDEAD_BEEF);
|
||||||
wait_for_state(S_SEND_HEADER, 50);
|
|
||||||
repeat (2) @(posedge ft601_clk_in); #1;
|
|
||||||
check(uut.current_state === S_SEND_HEADER,
|
|
||||||
"Stalled in SEND_HEADER (backpressure)");
|
|
||||||
|
|
||||||
// Release: FSM drives header then moves to SEND_RANGE_DATA
|
// FSM should be in SEND_DATA_WORD, stalled on ft601_txe=1
|
||||||
|
wait_for_state(S_SEND_DATA_WORD, 50);
|
||||||
|
repeat (2) @(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
|
check(uut.current_state === S_SEND_DATA_WORD,
|
||||||
|
"Stalled in SEND_DATA_WORD (backpressure)");
|
||||||
|
|
||||||
|
// Verify pre-packed words
|
||||||
|
// range_profile = 0xDEAD_BEEF → range[31:24]=0xDE, [23:16]=0xAD, [15:8]=0xBE, [7:0]=0xEF
|
||||||
|
// Word 0: {0xAA, 0xDE, 0xAD, 0xBE}
|
||||||
|
check(uut.data_pkt_word0 === {8'hAA, 8'hDE, 8'hAD, 8'hBE},
|
||||||
|
"Word 0: {HEADER=AA, range[31:8]}");
|
||||||
|
// Word 1: {0xEF, 0xAB, 0xCD, 0xEF}
|
||||||
|
check(uut.data_pkt_word1 === {8'hEF, 8'hAB, 8'hCD, 8'hEF},
|
||||||
|
"Word 1: {range[7:0], dop_re, dop_im_hi}");
|
||||||
|
// Word 2: {0x01, detection_byte, 0x55, 0x00}
|
||||||
|
// detection_byte bit 7 = frame_start (sample_counter==0 → 1), bit 0 = cfar=1
|
||||||
|
// so detection_byte = 8'b1000_0001 = 8'h81
|
||||||
|
check(uut.data_pkt_word2 === {8'h01, 8'h81, 8'h55, 8'h00},
|
||||||
|
"Word 2: {dop_im_lo, det=81, FOOTER=55, pad=00}");
|
||||||
|
check(uut.data_pkt_be2 === 4'b1110,
|
||||||
|
"Word 2 BE=1110 (3 valid bytes + 1 pad)");
|
||||||
|
|
||||||
|
// Release backpressure and verify word 0 appears on bus.
|
||||||
|
// On the first posedge with !ft601_txe the FSM drives word 0 and
|
||||||
|
// advances data_word_idx 0→1 via NBA. After #1 the NBA has
|
||||||
|
// resolved, so we see idx=1 and ft601_data_out=word0.
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
@(posedge ft601_clk_in); #1;
|
@(posedge ft601_clk_in); #1;
|
||||||
// Now the FSM registered the header output and will transition
|
|
||||||
// At the NEXT posedge the state becomes SEND_RANGE_DATA
|
|
||||||
@(posedge ft601_clk_in); #1;
|
|
||||||
|
|
||||||
check(uut.current_state === S_SEND_RANGE,
|
|
||||||
"Entered SEND_RANGE_DATA after header");
|
|
||||||
|
|
||||||
// The first range word should be on the data bus (byte_counter=0 just
|
|
||||||
// drove range_profile_cap, byte_counter incremented to 1)
|
|
||||||
check(uut.ft601_data_out === 32'hDEAD_BEEF || uut.byte_counter <= 8'd1,
|
|
||||||
"Range data word 0 driven (range_profile_cap)");
|
|
||||||
|
|
||||||
|
check(uut.ft601_data_out === {8'hAA, 8'hDE, 8'hAD, 8'hBE},
|
||||||
|
"Word 0 driven on data bus after backpressure release");
|
||||||
check(ft601_wr_n === 1'b0,
|
check(ft601_wr_n === 1'b0,
|
||||||
"Write strobe active during range data");
|
"Write strobe active during SEND_DATA_WORD");
|
||||||
|
|
||||||
check(ft601_be === 4'b1111,
|
check(ft601_be === 4'b1111,
|
||||||
"Byte enable=1111 for range data");
|
"Byte enable=1111 for word 0");
|
||||||
|
check(uut.ft601_data_oe === 1'b1,
|
||||||
|
"Data bus output enabled during SEND_DATA_WORD");
|
||||||
|
|
||||||
// Wait for all 4 range words to complete
|
// Next posedge: FSM drives word 1, advances idx 1→2.
|
||||||
wait_for_state(S_SEND_DOPPLER, 50);
|
// After NBA: idx=2, ft601_data_out=word1.
|
||||||
#1;
|
@(posedge ft601_clk_in); #1;
|
||||||
check(uut.current_state === S_SEND_DOPPLER,
|
check(uut.data_word_idx === 2'd2,
|
||||||
"Advanced to SEND_DOPPLER_DATA after 4 range words");
|
"data_word_idx advanced past word 1 (now 2)");
|
||||||
|
check(uut.ft601_data_out === {8'hEF, 8'hAB, 8'hCD, 8'hEF},
|
||||||
|
"Word 1 driven on data bus");
|
||||||
|
check(ft601_be === 4'b1111,
|
||||||
|
"Byte enable=1111 for word 1");
|
||||||
|
|
||||||
|
// Next posedge: FSM drives word 2, idx resets 2→0,
|
||||||
|
// and current_state transitions to WAIT_ACK.
|
||||||
|
@(posedge ft601_clk_in); #1;
|
||||||
|
check(uut.current_state === S_WAIT_ACK,
|
||||||
|
"Transitioned to WAIT_ACK after 3 data words");
|
||||||
|
check(uut.ft601_data_out === {8'h01, 8'h81, 8'h55, 8'h00},
|
||||||
|
"Word 2 driven on data bus");
|
||||||
|
check(ft601_be === 4'b1110,
|
||||||
|
"Byte enable=1110 for word 2 (last byte is pad)");
|
||||||
|
|
||||||
|
// Then back to IDLE
|
||||||
|
@(posedge ft601_clk_in); #1;
|
||||||
|
check(uut.current_state === S_IDLE,
|
||||||
|
"Returned to IDLE after WAIT_ACK");
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 3: Header verification (stall to observe)
|
// TEST GROUP 3: Header and footer verification
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 3: Header Verification ---");
|
$display("\n--- Test Group 3: Header and Footer Verification ---");
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 1; // Stall at SEND_HEADER
|
ft601_txe = 1; // Stall to inspect
|
||||||
|
|
||||||
@(posedge clk);
|
@(posedge clk);
|
||||||
range_profile = 32'hCAFE_BABE;
|
doppler_real = 16'h0000;
|
||||||
range_valid = 1;
|
doppler_imag = 16'h0000;
|
||||||
repeat (4) @(posedge ft601_clk_in);
|
cfar_detection = 1'b0;
|
||||||
@(posedge clk);
|
@(posedge clk);
|
||||||
range_valid = 0;
|
preload_pending_data;
|
||||||
repeat (3) @(posedge ft601_clk_in);
|
assert_range_valid(32'hCAFE_BABE);
|
||||||
|
|
||||||
wait_for_state(S_SEND_HEADER, 50);
|
wait_for_state(S_SEND_DATA_WORD, 50);
|
||||||
repeat (2) @(posedge ft601_clk_in); #1;
|
repeat (2) @(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
check(uut.current_state === S_SEND_HEADER,
|
// Header is in byte 3 (MSB) of word 0
|
||||||
"Stalled in SEND_HEADER with backpressure");
|
check(uut.data_pkt_word0[31:24] === 8'hAA,
|
||||||
|
"Header byte 0xAA in word 0 MSB");
|
||||||
// Release backpressure - header will be latched at next posedge
|
// Footer is in byte 1 (bits [15:8]) of word 2
|
||||||
ft601_txe = 0;
|
check(uut.data_pkt_word2[15:8] === 8'h55,
|
||||||
@(posedge ft601_clk_in); #1;
|
"Footer byte 0x55 in word 2");
|
||||||
|
|
||||||
check(uut.ft601_data_out[7:0] === 8'hAA,
|
|
||||||
"Header byte 0xAA on data bus");
|
|
||||||
check(ft601_be === 4'b0001,
|
|
||||||
"Byte enable=0001 for header (lower byte only)");
|
|
||||||
check(ft601_wr_n === 1'b0,
|
|
||||||
"Write strobe active during header");
|
|
||||||
check(uut.ft601_data_oe === 1'b1,
|
|
||||||
"Data bus output enabled during header");
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 4: Doppler data verification
|
// TEST GROUP 4: Doppler data capture verification
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 4: Doppler Data Verification ---");
|
$display("\n--- Test Group 4: Doppler Data Capture ---");
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
|
|
||||||
// Preload only doppler pending (not cfar) so the FSM sends
|
|
||||||
// doppler data. After doppler, SEND_DETECT sees cfar_data_pending=0
|
|
||||||
// and skips to SEND_FOOTER, then WAIT_ACK, then IDLE.
|
|
||||||
preload_doppler_pending;
|
|
||||||
assert_range_valid(32'h0000_0001);
|
|
||||||
wait_for_state(S_SEND_DOPPLER, 100);
|
|
||||||
#1;
|
|
||||||
check(uut.current_state === S_SEND_DOPPLER,
|
|
||||||
"Reached SEND_DOPPLER_DATA");
|
|
||||||
|
|
||||||
// Provide doppler data via valid pulse (updates captured values)
|
// Provide doppler data via valid pulse (updates captured values)
|
||||||
@(posedge clk);
|
@(posedge clk);
|
||||||
doppler_real = 16'hAAAA;
|
doppler_real = 16'hAAAA;
|
||||||
@@ -524,110 +559,70 @@ module tb_usb_data_interface;
|
|||||||
check(uut.doppler_imag_cap === 16'h5555,
|
check(uut.doppler_imag_cap === 16'h5555,
|
||||||
"doppler_imag captured correctly");
|
"doppler_imag captured correctly");
|
||||||
|
|
||||||
// The FSM has doppler_data_pending set and sends 4 bytes, then
|
// Drive a packet with pending doppler + cfar (both needed for gating
|
||||||
// transitions past SEND_DETECT (cfar_data_pending=0) to IDLE.
|
// since all streams are enabled after reset/apply_reset).
|
||||||
|
preload_pending_data;
|
||||||
|
assert_range_valid(32'h0000_0001);
|
||||||
wait_for_state(S_IDLE, 100);
|
wait_for_state(S_IDLE, 100);
|
||||||
#1;
|
#1;
|
||||||
check(uut.current_state === S_IDLE,
|
check(uut.current_state === S_IDLE,
|
||||||
"Doppler done, packet completed");
|
"Packet completed with doppler data");
|
||||||
|
check(uut.doppler_data_pending === 1'b0,
|
||||||
|
"doppler_data_pending cleared after packet");
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 5: CFAR detection data
|
// TEST GROUP 5: CFAR detection data
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 5: CFAR Detection Data ---");
|
$display("\n--- Test Group 5: CFAR Detection Data ---");
|
||||||
// Start a new packet with both doppler and cfar pending to verify
|
|
||||||
// cfar data is properly sent in SEND_DETECTION_DATA.
|
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
assert_range_valid(32'h0000_0002);
|
assert_range_valid(32'h0000_0002);
|
||||||
// FSM races through: HEADER -> RANGE -> DOPPLER -> DETECT -> FOOTER -> IDLE
|
|
||||||
// All pending flags consumed proves SEND_DETECT was entered.
|
|
||||||
wait_for_state(S_IDLE, 200);
|
wait_for_state(S_IDLE, 200);
|
||||||
#1;
|
#1;
|
||||||
check(uut.cfar_data_pending === 1'b0,
|
check(uut.cfar_data_pending === 1'b0,
|
||||||
"Starting in SEND_DETECTION_DATA");
|
"cfar_data_pending cleared after packet");
|
||||||
|
|
||||||
// Verify the full packet completed with cfar data consumed
|
|
||||||
check(uut.current_state === S_IDLE &&
|
check(uut.current_state === S_IDLE &&
|
||||||
uut.doppler_data_pending === 1'b0 &&
|
uut.doppler_data_pending === 1'b0 &&
|
||||||
uut.cfar_data_pending === 1'b0,
|
uut.cfar_data_pending === 1'b0,
|
||||||
"CFAR detection sent, FSM advanced past SEND_DETECTION_DATA");
|
"CFAR detection sent, all pending flags cleared");
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 6: Footer check
|
// TEST GROUP 6: Footer retained after packet
|
||||||
//
|
|
||||||
// Strategy: drive packet with ft601_txe=0 all the way through.
|
|
||||||
// The SEND_FOOTER state is only active for 1 cycle, but we can
|
|
||||||
// poll the state machine at each ft601_clk_in edge to observe
|
|
||||||
// it. We use a monitor-style approach: run the packet and
|
|
||||||
// capture what ft601_data_out contains when we see SEND_FOOTER.
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 6: Footer Check ---");
|
$display("\n--- Test Group 6: Footer Retention ---");
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
|
|
||||||
// Drive packet through range data
|
@(posedge clk);
|
||||||
|
cfar_detection = 1'b1;
|
||||||
|
@(posedge clk);
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
assert_range_valid(32'hFACE_FEED);
|
assert_range_valid(32'hFACE_FEED);
|
||||||
wait_for_state(S_SEND_DOPPLER, 100);
|
|
||||||
// Feed doppler data (need 4 pulses)
|
|
||||||
pulse_doppler_once(16'h1111, 16'h2222);
|
|
||||||
pulse_doppler_once(16'h1111, 16'h2222);
|
|
||||||
pulse_doppler_once(16'h1111, 16'h2222);
|
|
||||||
pulse_doppler_once(16'h1111, 16'h2222);
|
|
||||||
wait_for_state(S_SEND_DETECT, 100);
|
|
||||||
// Feed cfar data, but keep ft601_txe=0 so it flows through
|
|
||||||
pulse_cfar_once(1'b1);
|
|
||||||
|
|
||||||
// Now the FSM should pass through SEND_FOOTER quickly.
|
|
||||||
// Use wait_for_state to reach SEND_FOOTER, or it may already
|
|
||||||
// be at WAIT_ACK/IDLE. Let's catch WAIT_ACK or IDLE.
|
|
||||||
// The footer values are latched into registers, so we can
|
|
||||||
// verify them even after the state transitions.
|
|
||||||
// Key verification: the FOOTER constant (0x55) must have been
|
|
||||||
// driven. We check this by looking at the constant definition.
|
|
||||||
// Since we can't easily freeze the FSM at SEND_FOOTER without
|
|
||||||
// also stalling SEND_DETECTION_DATA (both check ft601_txe),
|
|
||||||
// we verify the footer indirectly:
|
|
||||||
// 1. The packet completed (reached IDLE/WAIT_ACK)
|
|
||||||
// 2. ft601_data_out last held 0x55 during SEND_FOOTER
|
|
||||||
|
|
||||||
wait_for_state(S_IDLE, 100);
|
wait_for_state(S_IDLE, 100);
|
||||||
#1;
|
#1;
|
||||||
// If we reached IDLE, the full sequence ran including footer
|
|
||||||
check(uut.current_state === S_IDLE,
|
check(uut.current_state === S_IDLE,
|
||||||
"Full packet incl. footer completed, back in IDLE");
|
"Full packet incl. footer completed, back in IDLE");
|
||||||
|
|
||||||
// The registered ft601_data_out should still hold 0x55 from
|
// The last word driven was word 2 which contains footer 0x55.
|
||||||
// SEND_FOOTER (WAIT_ACK and IDLE don't overwrite ft601_data_out).
|
// WAIT_ACK and IDLE don't overwrite ft601_data_out, so it retains
|
||||||
// Actually, looking at the DUT: WAIT_ACK only sets wr_n=1 and
|
// the last driven value.
|
||||||
// data_oe=0, it doesn't change ft601_data_out. So it retains 0x55.
|
check(uut.ft601_data_out[15:8] === 8'h55,
|
||||||
check(uut.ft601_data_out[7:0] === 8'h55,
|
"ft601_data_out retains footer 0x55 in word 2 position");
|
||||||
"ft601_data_out retains footer 0x55 after packet");
|
|
||||||
|
|
||||||
// Verify WAIT_ACK behavior by doing another packet and catching it
|
// Verify WAIT_ACK → IDLE transition
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
assert_range_valid(32'h1234_5678);
|
assert_range_valid(32'h1234_5678);
|
||||||
wait_for_state(S_SEND_DOPPLER, 100);
|
|
||||||
pulse_doppler_once(16'hABCD, 16'hEF01);
|
|
||||||
pulse_doppler_once(16'hABCD, 16'hEF01);
|
|
||||||
pulse_doppler_once(16'hABCD, 16'hEF01);
|
|
||||||
pulse_doppler_once(16'hABCD, 16'hEF01);
|
|
||||||
wait_for_state(S_SEND_DETECT, 100);
|
|
||||||
pulse_cfar_once(1'b0);
|
|
||||||
// WAIT_ACK lasts exactly 1 ft601_clk_in cycle then goes IDLE.
|
|
||||||
// Poll for IDLE (which means WAIT_ACK already happened).
|
|
||||||
wait_for_state(S_IDLE, 100);
|
wait_for_state(S_IDLE, 100);
|
||||||
#1;
|
#1;
|
||||||
check(uut.current_state === S_IDLE,
|
check(uut.current_state === S_IDLE,
|
||||||
"Returned to IDLE after WAIT_ACK");
|
"Returned to IDLE after WAIT_ACK");
|
||||||
check(ft601_wr_n === 1'b1,
|
check(ft601_wr_n === 1'b1,
|
||||||
"ft601_wr_n deasserted in IDLE (was deasserted in WAIT_ACK)");
|
"ft601_wr_n deasserted in IDLE");
|
||||||
check(uut.ft601_data_oe === 1'b0,
|
check(uut.ft601_data_oe === 1'b0,
|
||||||
"Data bus released in IDLE (was released in WAIT_ACK)");
|
"Data bus released in IDLE");
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 7: Full packet sequence (end-to-end)
|
// TEST GROUP 7: Full packet sequence (end-to-end)
|
||||||
@@ -646,23 +641,24 @@ module tb_usb_data_interface;
|
|||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 8: FIFO Backpressure ---");
|
$display("\n--- Test Group 8: FIFO Backpressure ---");
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 1;
|
ft601_txe = 1; // FIFO full — stall
|
||||||
|
|
||||||
|
preload_pending_data;
|
||||||
assert_range_valid(32'hBBBB_CCCC);
|
assert_range_valid(32'hBBBB_CCCC);
|
||||||
|
|
||||||
wait_for_state(S_SEND_HEADER, 50);
|
wait_for_state(S_SEND_DATA_WORD, 50);
|
||||||
repeat (10) @(posedge ft601_clk_in); #1;
|
repeat (10) @(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
check(uut.current_state === S_SEND_HEADER,
|
check(uut.current_state === S_SEND_DATA_WORD,
|
||||||
"Stalled in SEND_HEADER when ft601_txe=1 (FIFO full)");
|
"Stalled in SEND_DATA_WORD when ft601_txe=1 (FIFO full)");
|
||||||
check(ft601_wr_n === 1'b1,
|
check(ft601_wr_n === 1'b1,
|
||||||
"ft601_wr_n not asserted during backpressure stall");
|
"ft601_wr_n not asserted during backpressure stall");
|
||||||
|
|
||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
repeat (2) @(posedge ft601_clk_in); #1;
|
repeat (6) @(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
check(uut.current_state !== S_SEND_HEADER,
|
check(uut.current_state === S_IDLE || uut.current_state === S_WAIT_ACK,
|
||||||
"Resumed from SEND_HEADER after backpressure released");
|
"Resumed and completed after backpressure released");
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 9: Clock divider
|
// TEST GROUP 9: Clock divider
|
||||||
@@ -705,13 +701,6 @@ module tb_usb_data_interface;
|
|||||||
ft601_txe = 0;
|
ft601_txe = 0;
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
assert_range_valid(32'h1111_2222);
|
assert_range_valid(32'h1111_2222);
|
||||||
wait_for_state(S_SEND_DOPPLER, 100);
|
|
||||||
pulse_doppler_once(16'h3333, 16'h4444);
|
|
||||||
pulse_doppler_once(16'h3333, 16'h4444);
|
|
||||||
pulse_doppler_once(16'h3333, 16'h4444);
|
|
||||||
pulse_doppler_once(16'h3333, 16'h4444);
|
|
||||||
wait_for_state(S_SEND_DETECT, 100);
|
|
||||||
pulse_cfar_once(1'b0);
|
|
||||||
wait_for_state(S_WAIT_ACK, 50);
|
wait_for_state(S_WAIT_ACK, 50);
|
||||||
#1;
|
#1;
|
||||||
|
|
||||||
@@ -805,7 +794,7 @@ module tb_usb_data_interface;
|
|||||||
// Start a write packet
|
// Start a write packet
|
||||||
preload_pending_data;
|
preload_pending_data;
|
||||||
assert_range_valid(32'hFACE_FEED);
|
assert_range_valid(32'hFACE_FEED);
|
||||||
wait_for_state(S_SEND_HEADER, 50);
|
wait_for_state(S_SEND_DATA_WORD, 50);
|
||||||
@(posedge ft601_clk_in); #1;
|
@(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
// While write FSM is active, assert RXF=0 (host has data)
|
// While write FSM is active, assert RXF=0 (host has data)
|
||||||
@@ -818,13 +807,6 @@ module tb_usb_data_interface;
|
|||||||
|
|
||||||
// Deassert RXF, complete the write packet
|
// Deassert RXF, complete the write packet
|
||||||
ft601_rxf = 1;
|
ft601_rxf = 1;
|
||||||
wait_for_state(S_SEND_DOPPLER, 100);
|
|
||||||
pulse_doppler_once(16'hAAAA, 16'hBBBB);
|
|
||||||
pulse_doppler_once(16'hAAAA, 16'hBBBB);
|
|
||||||
pulse_doppler_once(16'hAAAA, 16'hBBBB);
|
|
||||||
pulse_doppler_once(16'hAAAA, 16'hBBBB);
|
|
||||||
wait_for_state(S_SEND_DETECT, 100);
|
|
||||||
pulse_cfar_once(1'b1);
|
|
||||||
wait_for_state(S_IDLE, 100);
|
wait_for_state(S_IDLE, 100);
|
||||||
@(posedge ft601_clk_in); #1;
|
@(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
@@ -841,32 +823,42 @@ module tb_usb_data_interface;
|
|||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
// TEST GROUP 15: Stream Control Gating (Gap 2)
|
// TEST GROUP 15: Stream Control Gating (Gap 2)
|
||||||
// Verify that disabling individual streams causes the write
|
// Verify that disabling individual streams causes the write
|
||||||
// FSM to skip those data phases.
|
// FSM to zero those fields in the packed words.
|
||||||
// ════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════
|
||||||
$display("\n--- Test Group 15: Stream Control Gating (Gap 2) ---");
|
$display("\n--- Test Group 15: Stream Control Gating (Gap 2) ---");
|
||||||
|
|
||||||
// 15a: Disable doppler stream (stream_control = 3'b101 = range + cfar only)
|
// 15a: Disable doppler stream (stream_control = 3'b101 = range + cfar only)
|
||||||
apply_reset;
|
apply_reset;
|
||||||
ft601_txe = 0;
|
ft601_txe = 1; // Stall to inspect packed words
|
||||||
stream_control = 3'b101; // range + cfar, no doppler
|
stream_control = 3'b101; // range + cfar, no doppler
|
||||||
// Wait for CDC propagation (2-stage sync)
|
// Wait for CDC propagation (2-stage sync)
|
||||||
repeat (6) @(posedge ft601_clk_in);
|
repeat (6) @(posedge ft601_clk_in);
|
||||||
|
|
||||||
// Preload cfar pending so the FSM enters the SEND_DETECT data path
|
@(posedge clk);
|
||||||
// (without it, SEND_DETECT skips immediately on !cfar_data_pending).
|
doppler_real = 16'hAAAA;
|
||||||
preload_cfar_pending;
|
doppler_imag = 16'hBBBB;
|
||||||
// Drive range valid — triggers write FSM
|
cfar_detection = 1'b1;
|
||||||
assert_range_valid(32'hAA11_BB22);
|
@(posedge clk);
|
||||||
// FSM: IDLE -> SEND_HEADER -> SEND_RANGE (doppler disabled) -> SEND_DETECT -> FOOTER
|
|
||||||
// The FSM races through SEND_DETECT in 1 cycle (cfar_data_pending is consumed).
|
|
||||||
// Verify the packet completed correctly (doppler was skipped).
|
|
||||||
wait_for_state(S_IDLE, 200);
|
|
||||||
#1;
|
|
||||||
// Reaching IDLE proves: HEADER -> RANGE -> (skip DOPPLER) -> DETECT -> FOOTER -> ACK -> IDLE.
|
|
||||||
// cfar_data_pending consumed confirms SEND_DETECT was entered.
|
|
||||||
check(uut.current_state === S_IDLE && uut.cfar_data_pending === 1'b0,
|
|
||||||
"Stream gate: reached SEND_DETECT (range sent, doppler skipped)");
|
|
||||||
|
|
||||||
|
preload_cfar_pending;
|
||||||
|
assert_range_valid(32'hAA11_BB22);
|
||||||
|
|
||||||
|
wait_for_state(S_SEND_DATA_WORD, 200);
|
||||||
|
repeat (2) @(posedge ft601_clk_in); #1;
|
||||||
|
|
||||||
|
// With doppler disabled, doppler fields in words 1 and 2 should be zero
|
||||||
|
// Word 1: {range[7:0], 0x00, 0x00, 0x00} (doppler zeroed)
|
||||||
|
check(uut.data_pkt_word1[23:0] === 24'h000000,
|
||||||
|
"Stream gate: doppler bytes zeroed in word 1 when disabled");
|
||||||
|
|
||||||
|
// Word 2 byte 3 (dop_im_lo) should also be zero
|
||||||
|
check(uut.data_pkt_word2[31:24] === 8'h00,
|
||||||
|
"Stream gate: dop_im_lo zeroed in word 2 when disabled");
|
||||||
|
|
||||||
|
// Let it complete
|
||||||
|
ft601_txe = 0;
|
||||||
|
wait_for_state(S_IDLE, 100);
|
||||||
|
#1;
|
||||||
check(uut.current_state === S_IDLE,
|
check(uut.current_state === S_IDLE,
|
||||||
"Stream gate: packet completed without doppler");
|
"Stream gate: packet completed without doppler");
|
||||||
|
|
||||||
@@ -951,28 +943,6 @@ module tb_usb_data_interface;
|
|||||||
"Status readback: returned to IDLE after 8-word response");
|
"Status readback: returned to IDLE after 8-word response");
|
||||||
|
|
||||||
// Verify the status snapshot was captured correctly.
|
// Verify the status snapshot was captured correctly.
|
||||||
// status_words[0] = {0xFF, 3'b000, mode[1:0], 5'b0, stream_ctrl[2:0], cfar_threshold[15:0]}
|
|
||||||
// = {8'hFF, 3'b000, 2'b01, 5'b00000, 3'b101, 16'hABCD}
|
|
||||||
// = 0xFF_09_05_ABCD... let's compute:
|
|
||||||
// Byte 3: 0xFF = 8'hFF
|
|
||||||
// Byte 2: {3'b000, 2'b01} = 5'b00001 + 3 high bits of next field...
|
|
||||||
// Actually the packing is: {8'hFF, 3'b000, status_radar_mode[1:0], 5'b00000, status_stream_ctrl[2:0], status_cfar_threshold[15:0]}
|
|
||||||
// = {8'hFF, 3'b000, 2'b01, 5'b00000, 3'b101, 16'hABCD}
|
|
||||||
// = 8'hFF, 5'b00001, 8'b00000101, 16'hABCD
|
|
||||||
// = FF_09_05_ABCD? Let me compute carefully:
|
|
||||||
// Bits [31:24] = 8'hFF = 0xFF
|
|
||||||
// Bits [23:21] = 3'b000
|
|
||||||
// Bits [20:19] = 2'b01 (mode)
|
|
||||||
// Bits [18:14] = 5'b00000
|
|
||||||
// Bits [13:11] = 3'b101 (stream_ctrl)
|
|
||||||
// Bits [10:0] = ... wait, cfar_threshold is 16 bits → [15:0]
|
|
||||||
// Total bits = 8+3+2+5+3+16 = 37 bits — won't fit in 32!
|
|
||||||
// Re-reading the RTL: the packing at line 241 is:
|
|
||||||
// {8'hFF, 3'b000, status_radar_mode, 5'b00000, status_stream_ctrl, status_cfar_threshold}
|
|
||||||
// = 8 + 3 + 2 + 5 + 3 + 16 = 37 bits
|
|
||||||
// This would be truncated to 32 bits. Let me re-read the actual RTL to check.
|
|
||||||
// For now, just verify status_words[1] (word index 1 in the packet = idx 2 in FSM)
|
|
||||||
// status_words[1] = {status_long_chirp, status_long_listen} = {16'd3000, 16'd13700}
|
|
||||||
check(uut.status_words[1] === {16'd3000, 16'd13700},
|
check(uut.status_words[1] === {16'd3000, 16'd13700},
|
||||||
"Status readback: word 1 = {long_chirp, long_listen}");
|
"Status readback: word 1 = {long_chirp, long_listen}");
|
||||||
check(uut.status_words[2] === {16'd17540, 16'd50},
|
check(uut.status_words[2] === {16'd17540, 16'd50},
|
||||||
|
|||||||
@@ -1,3 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* usb_data_interface.v
|
||||||
|
*
|
||||||
|
* FT601 USB 3.0 SuperSpeed FIFO Interface (32-bit bus, 100 MHz ft601_clk).
|
||||||
|
* Used on the 200T premium dev board. Production 50T board uses
|
||||||
|
* usb_data_interface_ft2232h.v (FT2232H, 8-bit, 60 MHz) instead.
|
||||||
|
*
|
||||||
|
* USB disconnect recovery:
|
||||||
|
* A clock-activity watchdog in the clk domain detects when ft601_clk_in
|
||||||
|
* stops (USB cable unplugged). After ~0.65 ms of silence (65536 system
|
||||||
|
* clocks) it asserts ft601_clk_lost, which is OR'd into the FT-domain
|
||||||
|
* reset so FSMs and FIFOs return to a clean state. When ft601_clk_in
|
||||||
|
* resumes, a 2-stage reset synchronizer deasserts the reset cleanly.
|
||||||
|
*/
|
||||||
module usb_data_interface (
|
module usb_data_interface (
|
||||||
input wire clk, // Main clock (100MHz recommended)
|
input wire clk, // Main clock (100MHz recommended)
|
||||||
input wire reset_n,
|
input wire reset_n,
|
||||||
@@ -15,13 +29,18 @@ module usb_data_interface (
|
|||||||
// FT601 Interface (Slave FIFO mode)
|
// FT601 Interface (Slave FIFO mode)
|
||||||
// Data bus
|
// Data bus
|
||||||
inout wire [31:0] ft601_data, // 32-bit bidirectional data bus
|
inout wire [31:0] ft601_data, // 32-bit bidirectional data bus
|
||||||
output reg [3:0] ft601_be, // Byte enable (4 lanes for 32-bit mode)
|
output reg [3:0] ft601_be, // Byte enable (active-HIGH per DS_FT600Q-FT601Q Table 3.2)
|
||||||
|
|
||||||
// Control signals
|
// Control signals
|
||||||
output reg ft601_txe_n, // Transmit enable (active low)
|
// VESTIGIAL OUTPUTS — kept for 200T board port compatibility.
|
||||||
output reg ft601_rxf_n, // Receive enable (active low)
|
// On the 200T, these are constrained to physical pins G21 (TXE) and
|
||||||
input wire ft601_txe, // TXE: Transmit FIFO Not Full (high = space available to write)
|
// G22 (RXF) in xc7a200t_fbg484.xdc. Removing them from the RTL would
|
||||||
input wire ft601_rxf, // RXF: Receive FIFO Not Empty (high = data available to read)
|
// break the 200T build. They are reset to 1 and never driven; the
|
||||||
|
// actual FT601 flow-control inputs are ft601_txe and ft601_rxf below.
|
||||||
|
output reg ft601_txe_n, // VESTIGIAL: unused output, always 1
|
||||||
|
output reg ft601_rxf_n, // VESTIGIAL: unused output, always 1
|
||||||
|
input wire ft601_txe, // TXE: Transmit FIFO Not Full (active-low: 0 = space available)
|
||||||
|
input wire ft601_rxf, // RXF: Receive FIFO Not Empty (active-low: 0 = data available)
|
||||||
output reg ft601_wr_n, // Write strobe (active low)
|
output reg ft601_wr_n, // Write strobe (active low)
|
||||||
output reg ft601_rd_n, // Read strobe (active low)
|
output reg ft601_rd_n, // Read strobe (active low)
|
||||||
output reg ft601_oe_n, // Output enable (active low)
|
output reg ft601_oe_n, // Output enable (active low)
|
||||||
@@ -97,21 +116,26 @@ localparam FT601_BURST_SIZE = 512; // Max burst size in bytes
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WRITE FSM State definitions (Verilog-2001 compatible)
|
// WRITE FSM State definitions (Verilog-2001 compatible)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
localparam [2:0] IDLE = 3'd0,
|
// Rewritten: data packet is now 3 x 32-bit writes (11 payload bytes + 1 pad).
|
||||||
SEND_HEADER = 3'd1,
|
// Word 0: {HEADER, range[31:24], range[23:16], range[15:8]} BE=1111
|
||||||
SEND_RANGE_DATA = 3'd2,
|
// Word 1: {range[7:0], doppler_real[15:8], doppler_real[7:0], doppler_imag[15:8]} BE=1111
|
||||||
SEND_DOPPLER_DATA = 3'd3,
|
// Word 2: {doppler_imag[7:0], detection, FOOTER, 8'h00} BE=1110
|
||||||
SEND_DETECTION_DATA = 3'd4,
|
localparam [3:0] IDLE = 4'd0,
|
||||||
SEND_FOOTER = 3'd5,
|
SEND_DATA_WORD = 4'd1,
|
||||||
WAIT_ACK = 3'd6,
|
SEND_STATUS = 4'd2,
|
||||||
SEND_STATUS = 3'd7; // Gap 2: status readback
|
WAIT_ACK = 4'd3;
|
||||||
|
|
||||||
reg [2:0] current_state;
|
reg [3:0] current_state;
|
||||||
reg [7:0] byte_counter;
|
reg [1:0] data_word_idx; // 0..2 for 3-word data packet
|
||||||
reg [31:0] data_buffer;
|
|
||||||
reg [31:0] ft601_data_out;
|
reg [31:0] ft601_data_out;
|
||||||
reg ft601_data_oe; // Output enable for bidirectional data bus
|
reg ft601_data_oe; // Output enable for bidirectional data bus
|
||||||
|
|
||||||
|
// Pre-packed data words (registered snapshot of CDC'd data)
|
||||||
|
reg [31:0] data_pkt_word0;
|
||||||
|
reg [31:0] data_pkt_word1;
|
||||||
|
reg [31:0] data_pkt_word2;
|
||||||
|
reg [3:0] data_pkt_be2; // BE for last word (BE=1110 since byte 3 is pad)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// READ FSM State definitions (Gap 4: USB Read Path)
|
// READ FSM State definitions (Gap 4: USB Read Path)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -184,6 +208,67 @@ always @(posedge clk or negedge reset_n) begin
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLOCK-ACTIVITY WATCHDOG (clk domain)
|
||||||
|
// ============================================================================
|
||||||
|
// Detects when ft601_clk_in stops (USB cable unplugged). A toggle register
|
||||||
|
// in the ft601_clk domain flips every edge. The clk domain synchronizes it
|
||||||
|
// and checks for transitions. If no transition is seen for 2^16 = 65536
|
||||||
|
// clk cycles (~0.65 ms at 100 MHz), ft601_clk_lost asserts.
|
||||||
|
|
||||||
|
// Toggle register: flips every ft601_clk edge (ft601_clk domain)
|
||||||
|
reg ft601_heartbeat;
|
||||||
|
always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
||||||
|
if (!ft601_reset_n)
|
||||||
|
ft601_heartbeat <= 1'b0;
|
||||||
|
else
|
||||||
|
ft601_heartbeat <= ~ft601_heartbeat;
|
||||||
|
end
|
||||||
|
|
||||||
|
// Synchronize heartbeat into clk domain (2-stage)
|
||||||
|
(* ASYNC_REG = "TRUE" *) reg [1:0] ft601_hb_sync;
|
||||||
|
reg ft601_hb_prev;
|
||||||
|
reg [15:0] ft601_clk_timeout;
|
||||||
|
reg ft601_clk_lost;
|
||||||
|
|
||||||
|
always @(posedge clk or negedge reset_n) begin
|
||||||
|
if (!reset_n) begin
|
||||||
|
ft601_hb_sync <= 2'b00;
|
||||||
|
ft601_hb_prev <= 1'b0;
|
||||||
|
ft601_clk_timeout <= 16'd0;
|
||||||
|
ft601_clk_lost <= 1'b0;
|
||||||
|
end else begin
|
||||||
|
ft601_hb_sync <= {ft601_hb_sync[0], ft601_heartbeat};
|
||||||
|
ft601_hb_prev <= ft601_hb_sync[1];
|
||||||
|
|
||||||
|
if (ft601_hb_sync[1] != ft601_hb_prev) begin
|
||||||
|
// ft601_clk is alive — reset counter, clear lost flag
|
||||||
|
ft601_clk_timeout <= 16'd0;
|
||||||
|
ft601_clk_lost <= 1'b0;
|
||||||
|
end else if (!ft601_clk_lost) begin
|
||||||
|
if (ft601_clk_timeout == 16'hFFFF)
|
||||||
|
ft601_clk_lost <= 1'b1;
|
||||||
|
else
|
||||||
|
ft601_clk_timeout <= ft601_clk_timeout + 16'd1;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
// Effective FT601-domain reset: asserted by global reset OR clock loss.
|
||||||
|
// Deassertion synchronized to ft601_clk via 2-stage sync to avoid
|
||||||
|
// metastability on the recovery edge.
|
||||||
|
(* ASYNC_REG = "TRUE" *) reg [1:0] ft601_reset_sync;
|
||||||
|
wire ft601_reset_raw_n = ft601_reset_n & ~ft601_clk_lost;
|
||||||
|
|
||||||
|
always @(posedge ft601_clk_in or negedge ft601_reset_raw_n) begin
|
||||||
|
if (!ft601_reset_raw_n)
|
||||||
|
ft601_reset_sync <= 2'b00;
|
||||||
|
else
|
||||||
|
ft601_reset_sync <= {ft601_reset_sync[0], 1'b1};
|
||||||
|
end
|
||||||
|
|
||||||
|
wire ft601_effective_reset_n = ft601_reset_sync[1];
|
||||||
|
|
||||||
// FT601-domain captured data (sampled from holding regs on sync'd edge)
|
// FT601-domain captured data (sampled from holding regs on sync'd edge)
|
||||||
reg [31:0] range_profile_cap;
|
reg [31:0] range_profile_cap;
|
||||||
reg [15:0] doppler_real_cap;
|
reg [15:0] doppler_real_cap;
|
||||||
@@ -197,6 +282,18 @@ reg cfar_detection_cap;
|
|||||||
reg doppler_data_pending;
|
reg doppler_data_pending;
|
||||||
reg cfar_data_pending;
|
reg cfar_data_pending;
|
||||||
|
|
||||||
|
// 1-cycle delayed range trigger. range_valid_ft fires on the same clock
|
||||||
|
// edge that range_profile_cap is captured (non-blocking). If the FSM
|
||||||
|
// reads range_profile_cap on that same edge it sees the STALE value.
|
||||||
|
// Delaying the trigger by one cycle guarantees the capture register has
|
||||||
|
// settled before the FSM packs the data words.
|
||||||
|
reg range_data_ready;
|
||||||
|
|
||||||
|
// Frame sync: sample counter (ft601_clk domain, wraps at NUM_CELLS)
|
||||||
|
// Bit 7 of detection byte is set when sample_counter == 0 (frame start).
|
||||||
|
localparam [11:0] NUM_CELLS = 12'd2048; // 64 range x 32 doppler
|
||||||
|
reg [11:0] sample_counter;
|
||||||
|
|
||||||
// Gap 2: CDC for stream_control (clk_100m -> ft601_clk_in)
|
// Gap 2: CDC for stream_control (clk_100m -> ft601_clk_in)
|
||||||
// stream_control changes infrequently (only on host USB command), so
|
// stream_control changes infrequently (only on host USB command), so
|
||||||
// per-bit 2-stage synchronizers are sufficient. No Gray coding needed
|
// per-bit 2-stage synchronizers are sufficient. No Gray coding needed
|
||||||
@@ -228,8 +325,8 @@ wire range_valid_ft;
|
|||||||
wire doppler_valid_ft;
|
wire doppler_valid_ft;
|
||||||
wire cfar_valid_ft;
|
wire cfar_valid_ft;
|
||||||
|
|
||||||
always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
always @(posedge ft601_clk_in or negedge ft601_effective_reset_n) begin
|
||||||
if (!ft601_reset_n) begin
|
if (!ft601_effective_reset_n) begin
|
||||||
range_valid_sync <= 2'b00;
|
range_valid_sync <= 2'b00;
|
||||||
doppler_valid_sync <= 2'b00;
|
doppler_valid_sync <= 2'b00;
|
||||||
cfar_valid_sync <= 2'b00;
|
cfar_valid_sync <= 2'b00;
|
||||||
@@ -240,6 +337,7 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
doppler_real_cap <= 16'd0;
|
doppler_real_cap <= 16'd0;
|
||||||
doppler_imag_cap <= 16'd0;
|
doppler_imag_cap <= 16'd0;
|
||||||
cfar_detection_cap <= 1'b0;
|
cfar_detection_cap <= 1'b0;
|
||||||
|
range_data_ready <= 1'b0;
|
||||||
// Fix #5: Default to range-only on reset (prevents write FSM deadlock)
|
// Fix #5: Default to range-only on reset (prevents write FSM deadlock)
|
||||||
stream_ctrl_sync_0 <= 3'b001;
|
stream_ctrl_sync_0 <= 3'b001;
|
||||||
stream_ctrl_sync_1 <= 3'b001;
|
stream_ctrl_sync_1 <= 3'b001;
|
||||||
@@ -276,7 +374,7 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
// Word 4: AGC metrics + range_mode
|
// Word 4: AGC metrics + range_mode
|
||||||
status_words[4] <= {status_agc_current_gain, // [31:28]
|
status_words[4] <= {status_agc_current_gain, // [31:28]
|
||||||
status_agc_peak_magnitude, // [27:20]
|
status_agc_peak_magnitude, // [27:20]
|
||||||
status_agc_saturation_count, // [19:12]
|
status_agc_saturation_count, // [19:12] 8-bit saturation count
|
||||||
status_agc_enable, // [11]
|
status_agc_enable, // [11]
|
||||||
9'd0, // [10:2] reserved
|
9'd0, // [10:2] reserved
|
||||||
status_range_mode}; // [1:0]
|
status_range_mode}; // [1:0]
|
||||||
@@ -302,6 +400,10 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
if (cfar_valid_sync[1] && !cfar_valid_sync_d) begin
|
if (cfar_valid_sync[1] && !cfar_valid_sync_d) begin
|
||||||
cfar_detection_cap <= cfar_detection_hold;
|
cfar_detection_cap <= cfar_detection_hold;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
// 1-cycle delayed trigger: ensures range_profile_cap has settled
|
||||||
|
// before the FSM reads it for word packing.
|
||||||
|
range_data_ready <= range_valid_ft;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -314,11 +416,11 @@ assign cfar_valid_ft = cfar_valid_sync[1] && !cfar_valid_sync_d;
|
|||||||
// FT601 data bus direction control
|
// FT601 data bus direction control
|
||||||
assign ft601_data = ft601_data_oe ? ft601_data_out : 32'hzzzz_zzzz;
|
assign ft601_data = ft601_data_oe ? ft601_data_out : 32'hzzzz_zzzz;
|
||||||
|
|
||||||
always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
always @(posedge ft601_clk_in or negedge ft601_effective_reset_n) begin
|
||||||
if (!ft601_reset_n) begin
|
if (!ft601_effective_reset_n) begin
|
||||||
current_state <= IDLE;
|
current_state <= IDLE;
|
||||||
read_state <= RD_IDLE;
|
read_state <= RD_IDLE;
|
||||||
byte_counter <= 0;
|
data_word_idx <= 2'd0;
|
||||||
ft601_data_out <= 0;
|
ft601_data_out <= 0;
|
||||||
ft601_data_oe <= 0;
|
ft601_data_oe <= 0;
|
||||||
ft601_be <= 4'b1111; // All bytes enabled for 32-bit mode
|
ft601_be <= 4'b1111; // All bytes enabled for 32-bit mode
|
||||||
@@ -336,6 +438,11 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
cmd_value <= 16'd0;
|
cmd_value <= 16'd0;
|
||||||
doppler_data_pending <= 1'b0;
|
doppler_data_pending <= 1'b0;
|
||||||
cfar_data_pending <= 1'b0;
|
cfar_data_pending <= 1'b0;
|
||||||
|
data_pkt_word0 <= 32'd0;
|
||||||
|
data_pkt_word1 <= 32'd0;
|
||||||
|
data_pkt_word2 <= 32'd0;
|
||||||
|
data_pkt_be2 <= 4'b1110;
|
||||||
|
sample_counter <= 12'd0;
|
||||||
// NOTE: ft601_clk_out is driven by the clk-domain always block below.
|
// NOTE: ft601_clk_out is driven by the clk-domain always block below.
|
||||||
// Do NOT assign it here (ft601_clk_in domain) — causes multi-driven net.
|
// Do NOT assign it here (ft601_clk_in domain) — causes multi-driven net.
|
||||||
end else begin
|
end else begin
|
||||||
@@ -424,122 +531,64 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
current_state <= SEND_STATUS;
|
current_state <= SEND_STATUS;
|
||||||
status_word_idx <= 3'd0;
|
status_word_idx <= 3'd0;
|
||||||
end
|
end
|
||||||
// Trigger write FSM on range_valid edge (primary data source).
|
// Trigger on range_data_ready (1 cycle after range_valid_ft)
|
||||||
// Doppler/cfar data_pending flags are checked inside
|
// so that range_profile_cap has settled from the CDC block.
|
||||||
// SEND_DOPPLER_DATA and SEND_DETECTION_DATA to skip or send.
|
// Gate on pending flags: only send when all enabled
|
||||||
// Do NOT trigger on pending flags alone — they're sticky and
|
// streams have fresh data (avoids stale doppler/CFAR)
|
||||||
// would cause repeated packet starts without new range data.
|
else if (range_data_ready && stream_range_en
|
||||||
else if (range_valid_ft && stream_range_en) begin
|
&& (!stream_doppler_en || doppler_data_pending)
|
||||||
|
&& (!stream_cfar_en || cfar_data_pending)) begin
|
||||||
// Don't start write if a read is about to begin
|
// Don't start write if a read is about to begin
|
||||||
if (ft601_rxf) begin // rxf=1 means no host data pending
|
if (ft601_rxf) begin // rxf=1 means no host data pending
|
||||||
current_state <= SEND_HEADER;
|
// Pack 11-byte data packet into 3 x 32-bit words
|
||||||
byte_counter <= 0;
|
// Doppler fields zeroed when stream disabled
|
||||||
|
// CFAR field zeroed when stream disabled
|
||||||
|
data_pkt_word0 <= {HEADER,
|
||||||
|
range_profile_cap[31:24],
|
||||||
|
range_profile_cap[23:16],
|
||||||
|
range_profile_cap[15:8]};
|
||||||
|
data_pkt_word1 <= {range_profile_cap[7:0],
|
||||||
|
stream_doppler_en ? doppler_real_cap[15:8] : 8'd0,
|
||||||
|
stream_doppler_en ? doppler_real_cap[7:0] : 8'd0,
|
||||||
|
stream_doppler_en ? doppler_imag_cap[15:8] : 8'd0};
|
||||||
|
data_pkt_word2 <= {stream_doppler_en ? doppler_imag_cap[7:0] : 8'd0,
|
||||||
|
stream_cfar_en
|
||||||
|
? {(sample_counter == 12'd0), 6'b0, cfar_detection_cap}
|
||||||
|
: {(sample_counter == 12'd0), 7'd0},
|
||||||
|
FOOTER,
|
||||||
|
8'h00}; // pad byte
|
||||||
|
data_pkt_be2 <= 4'b1110; // 3 valid bytes + 1 pad
|
||||||
|
data_word_idx <= 2'd0;
|
||||||
|
current_state <= SEND_DATA_WORD;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
SEND_HEADER: begin
|
SEND_DATA_WORD: begin
|
||||||
if (!ft601_txe) begin // FT601 TX FIFO not empty
|
|
||||||
ft601_data_oe <= 1;
|
|
||||||
ft601_data_out <= {24'b0, HEADER};
|
|
||||||
ft601_be <= 4'b0001; // Only lower byte valid
|
|
||||||
ft601_wr_n <= 0; // Assert write strobe
|
|
||||||
// Gap 2: skip to first enabled stream
|
|
||||||
if (stream_range_en)
|
|
||||||
current_state <= SEND_RANGE_DATA;
|
|
||||||
else if (stream_doppler_en)
|
|
||||||
current_state <= SEND_DOPPLER_DATA;
|
|
||||||
else if (stream_cfar_en)
|
|
||||||
current_state <= SEND_DETECTION_DATA;
|
|
||||||
else
|
|
||||||
current_state <= SEND_FOOTER; // No streams — send footer only
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
SEND_RANGE_DATA: begin
|
|
||||||
if (!ft601_txe) begin
|
if (!ft601_txe) begin
|
||||||
ft601_data_oe <= 1;
|
ft601_data_oe <= 1;
|
||||||
ft601_be <= 4'b1111; // All bytes valid for 32-bit word
|
|
||||||
|
|
||||||
case (byte_counter)
|
|
||||||
0: ft601_data_out <= range_profile_cap;
|
|
||||||
1: ft601_data_out <= {range_profile_cap[23:0], 8'h00};
|
|
||||||
2: ft601_data_out <= {range_profile_cap[15:0], 16'h0000};
|
|
||||||
3: ft601_data_out <= {range_profile_cap[7:0], 24'h000000};
|
|
||||||
endcase
|
|
||||||
|
|
||||||
ft601_wr_n <= 0;
|
ft601_wr_n <= 0;
|
||||||
|
case (data_word_idx)
|
||||||
if (byte_counter == 3) begin
|
2'd0: begin
|
||||||
byte_counter <= 0;
|
ft601_data_out <= data_pkt_word0;
|
||||||
// Gap 2: skip disabled streams
|
|
||||||
if (stream_doppler_en)
|
|
||||||
current_state <= SEND_DOPPLER_DATA;
|
|
||||||
else if (stream_cfar_en)
|
|
||||||
current_state <= SEND_DETECTION_DATA;
|
|
||||||
else
|
|
||||||
current_state <= SEND_FOOTER;
|
|
||||||
end else begin
|
|
||||||
byte_counter <= byte_counter + 1;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
SEND_DOPPLER_DATA: begin
|
|
||||||
if (!ft601_txe && doppler_data_pending) begin
|
|
||||||
ft601_data_oe <= 1;
|
|
||||||
ft601_be <= 4'b1111;
|
ft601_be <= 4'b1111;
|
||||||
|
end
|
||||||
case (byte_counter)
|
2'd1: begin
|
||||||
0: ft601_data_out <= {doppler_real_cap, doppler_imag_cap};
|
ft601_data_out <= data_pkt_word1;
|
||||||
1: ft601_data_out <= {doppler_imag_cap, doppler_real_cap[15:8], 8'h00};
|
ft601_be <= 4'b1111;
|
||||||
2: ft601_data_out <= {doppler_real_cap[7:0], doppler_imag_cap[15:8], 16'h0000};
|
end
|
||||||
3: ft601_data_out <= {doppler_imag_cap[7:0], 24'h000000};
|
2'd2: begin
|
||||||
|
ft601_data_out <= data_pkt_word2;
|
||||||
|
ft601_be <= data_pkt_be2;
|
||||||
|
end
|
||||||
|
default: ;
|
||||||
endcase
|
endcase
|
||||||
|
if (data_word_idx == 2'd2) begin
|
||||||
ft601_wr_n <= 0;
|
data_word_idx <= 2'd0;
|
||||||
|
|
||||||
if (byte_counter == 3) begin
|
|
||||||
byte_counter <= 0;
|
|
||||||
doppler_data_pending <= 1'b0;
|
|
||||||
if (stream_cfar_en)
|
|
||||||
current_state <= SEND_DETECTION_DATA;
|
|
||||||
else
|
|
||||||
current_state <= SEND_FOOTER;
|
|
||||||
end else begin
|
|
||||||
byte_counter <= byte_counter + 1;
|
|
||||||
end
|
|
||||||
end else if (!doppler_data_pending) begin
|
|
||||||
// No doppler data available yet — skip to next stream
|
|
||||||
byte_counter <= 0;
|
|
||||||
if (stream_cfar_en)
|
|
||||||
current_state <= SEND_DETECTION_DATA;
|
|
||||||
else
|
|
||||||
current_state <= SEND_FOOTER;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
SEND_DETECTION_DATA: begin
|
|
||||||
if (!ft601_txe && cfar_data_pending) begin
|
|
||||||
ft601_data_oe <= 1;
|
|
||||||
ft601_be <= 4'b0001;
|
|
||||||
ft601_data_out <= {24'b0, 7'b0, cfar_detection_cap};
|
|
||||||
ft601_wr_n <= 0;
|
|
||||||
cfar_data_pending <= 1'b0;
|
|
||||||
current_state <= SEND_FOOTER;
|
|
||||||
end else if (!cfar_data_pending) begin
|
|
||||||
// No CFAR data available yet — skip to footer
|
|
||||||
current_state <= SEND_FOOTER;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
SEND_FOOTER: begin
|
|
||||||
if (!ft601_txe) begin
|
|
||||||
ft601_data_oe <= 1;
|
|
||||||
ft601_be <= 4'b0001;
|
|
||||||
ft601_data_out <= {24'b0, FOOTER};
|
|
||||||
ft601_wr_n <= 0;
|
|
||||||
current_state <= WAIT_ACK;
|
current_state <= WAIT_ACK;
|
||||||
|
end else begin
|
||||||
|
data_word_idx <= data_word_idx + 2'd1;
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -581,6 +630,14 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
WAIT_ACK: begin
|
WAIT_ACK: begin
|
||||||
ft601_wr_n <= 1;
|
ft601_wr_n <= 1;
|
||||||
ft601_data_oe <= 0; // Release data bus
|
ft601_data_oe <= 0; // Release data bus
|
||||||
|
// Clear pending flags — data consumed
|
||||||
|
doppler_data_pending <= 1'b0;
|
||||||
|
cfar_data_pending <= 1'b0;
|
||||||
|
// Advance frame sync counter
|
||||||
|
if (sample_counter == NUM_CELLS - 12'd1)
|
||||||
|
sample_counter <= 12'd0;
|
||||||
|
else
|
||||||
|
sample_counter <= sample_counter + 12'd1;
|
||||||
current_state <= IDLE;
|
current_state <= IDLE;
|
||||||
end
|
end
|
||||||
endcase
|
endcase
|
||||||
@@ -613,8 +670,8 @@ ODDR #(
|
|||||||
`else
|
`else
|
||||||
// Simulation: behavioral clock forwarding
|
// Simulation: behavioral clock forwarding
|
||||||
reg ft601_clk_out_sim;
|
reg ft601_clk_out_sim;
|
||||||
always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
always @(posedge ft601_clk_in or negedge ft601_effective_reset_n) begin
|
||||||
if (!ft601_reset_n)
|
if (!ft601_effective_reset_n)
|
||||||
ft601_clk_out_sim <= 1'b0;
|
ft601_clk_out_sim <= 1'b0;
|
||||||
else
|
else
|
||||||
ft601_clk_out_sim <= 1'b1;
|
ft601_clk_out_sim <= 1'b1;
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
* Clock domains:
|
* Clock domains:
|
||||||
* clk = 100 MHz system clock (radar data domain)
|
* clk = 100 MHz system clock (radar data domain)
|
||||||
* ft_clk = 60 MHz from FT2232H CLKOUT (USB FIFO domain)
|
* ft_clk = 60 MHz from FT2232H CLKOUT (USB FIFO domain)
|
||||||
|
*
|
||||||
|
* USB disconnect recovery:
|
||||||
|
* A clock-activity watchdog in the clk domain detects when ft_clk stops
|
||||||
|
* (USB cable unplugged). After ~0.65 ms of silence (65536 system clocks)
|
||||||
|
* it asserts ft_clk_lost, which is OR'd into the FT-domain reset so
|
||||||
|
* FSMs and FIFOs return to a clean state. When ft_clk resumes, a 2-stage
|
||||||
|
* reset synchronizer deasserts the reset cleanly in the ft_clk domain.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
module usb_data_interface_ft2232h (
|
module usb_data_interface_ft2232h (
|
||||||
@@ -59,7 +66,9 @@ module usb_data_interface_ft2232h (
|
|||||||
output reg ft_rd_n, // Read strobe (active low)
|
output reg ft_rd_n, // Read strobe (active low)
|
||||||
output reg ft_wr_n, // Write strobe (active low)
|
output reg ft_wr_n, // Write strobe (active low)
|
||||||
output reg ft_oe_n, // Output enable (active low) — bus direction
|
output reg ft_oe_n, // Output enable (active low) — bus direction
|
||||||
output reg ft_siwu, // Send Immediate / WakeUp
|
output reg ft_siwu, // Send Immediate / WakeUp — UNUSED: held low.
|
||||||
|
// SIWU could flush the TX FIFO for lower latency
|
||||||
|
// but is not needed at current data rates. Deferred.
|
||||||
|
|
||||||
// Clock from FT2232H (directly used — no ODDR forwarding needed)
|
// Clock from FT2232H (directly used — no ODDR forwarding needed)
|
||||||
input wire ft_clk, // 60 MHz from FT2232H CLKOUT
|
input wire ft_clk, // 60 MHz from FT2232H CLKOUT
|
||||||
@@ -134,6 +143,7 @@ localparam [2:0] RD_IDLE = 3'd0,
|
|||||||
reg [2:0] rd_state;
|
reg [2:0] rd_state;
|
||||||
reg [1:0] rd_byte_cnt; // 0..3 for 4-byte command word
|
reg [1:0] rd_byte_cnt; // 0..3 for 4-byte command word
|
||||||
reg [31:0] rd_shift_reg; // Shift register to assemble 4-byte command
|
reg [31:0] rd_shift_reg; // Shift register to assemble 4-byte command
|
||||||
|
reg rd_cmd_complete; // Set when all 4 bytes received (distinguishes from abort)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DATA BUS DIRECTION CONTROL
|
// DATA BUS DIRECTION CONTROL
|
||||||
@@ -192,6 +202,70 @@ always @(posedge clk or negedge reset_n) begin
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CLOCK-ACTIVITY WATCHDOG (clk domain)
|
||||||
|
// ============================================================================
|
||||||
|
// Detects when ft_clk stops (USB cable unplugged). A toggle register in the
|
||||||
|
// ft_clk domain flips every ft_clk edge. The clk domain synchronizes it and
|
||||||
|
// checks for transitions. If no transition is seen for 2^16 = 65536 clk
|
||||||
|
// cycles (~0.65 ms at 100 MHz), ft_clk_lost asserts.
|
||||||
|
//
|
||||||
|
// ft_clk_lost feeds into the effective reset for the ft_clk domain so that
|
||||||
|
// FSMs and capture registers return to a clean state automatically.
|
||||||
|
|
||||||
|
// Toggle register: flips every ft_clk edge (ft_clk domain)
|
||||||
|
reg ft_heartbeat;
|
||||||
|
always @(posedge ft_clk or negedge ft_reset_n) begin
|
||||||
|
if (!ft_reset_n)
|
||||||
|
ft_heartbeat <= 1'b0;
|
||||||
|
else
|
||||||
|
ft_heartbeat <= ~ft_heartbeat;
|
||||||
|
end
|
||||||
|
|
||||||
|
// Synchronize heartbeat into clk domain (2-stage)
|
||||||
|
(* ASYNC_REG = "TRUE" *) reg [1:0] ft_hb_sync;
|
||||||
|
reg ft_hb_prev;
|
||||||
|
reg [15:0] ft_clk_timeout;
|
||||||
|
reg ft_clk_lost;
|
||||||
|
|
||||||
|
always @(posedge clk or negedge reset_n) begin
|
||||||
|
if (!reset_n) begin
|
||||||
|
ft_hb_sync <= 2'b00;
|
||||||
|
ft_hb_prev <= 1'b0;
|
||||||
|
ft_clk_timeout <= 16'd0;
|
||||||
|
ft_clk_lost <= 1'b0;
|
||||||
|
end else begin
|
||||||
|
ft_hb_sync <= {ft_hb_sync[0], ft_heartbeat};
|
||||||
|
ft_hb_prev <= ft_hb_sync[1];
|
||||||
|
|
||||||
|
if (ft_hb_sync[1] != ft_hb_prev) begin
|
||||||
|
// ft_clk is alive — reset counter, clear lost flag
|
||||||
|
ft_clk_timeout <= 16'd0;
|
||||||
|
ft_clk_lost <= 1'b0;
|
||||||
|
end else if (!ft_clk_lost) begin
|
||||||
|
if (ft_clk_timeout == 16'hFFFF)
|
||||||
|
ft_clk_lost <= 1'b1;
|
||||||
|
else
|
||||||
|
ft_clk_timeout <= ft_clk_timeout + 16'd1;
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
// Effective FT-domain reset: asserted by global reset OR clock loss.
|
||||||
|
// Deassertion synchronized to ft_clk via 2-stage sync to avoid
|
||||||
|
// metastability on the recovery edge.
|
||||||
|
(* ASYNC_REG = "TRUE" *) reg [1:0] ft_reset_sync;
|
||||||
|
wire ft_reset_raw_n = ft_reset_n & ~ft_clk_lost;
|
||||||
|
|
||||||
|
always @(posedge ft_clk or negedge ft_reset_raw_n) begin
|
||||||
|
if (!ft_reset_raw_n)
|
||||||
|
ft_reset_sync <= 2'b00;
|
||||||
|
else
|
||||||
|
ft_reset_sync <= {ft_reset_sync[0], 1'b1};
|
||||||
|
end
|
||||||
|
|
||||||
|
wire ft_effective_reset_n = ft_reset_sync[1];
|
||||||
|
|
||||||
// --- 3-stage synchronizers (ft_clk domain) ---
|
// --- 3-stage synchronizers (ft_clk domain) ---
|
||||||
// 3 stages for better MTBF at 60 MHz
|
// 3 stages for better MTBF at 60 MHz
|
||||||
|
|
||||||
@@ -228,12 +302,25 @@ reg cfar_detection_cap;
|
|||||||
reg doppler_data_pending;
|
reg doppler_data_pending;
|
||||||
reg cfar_data_pending;
|
reg cfar_data_pending;
|
||||||
|
|
||||||
|
// 1-cycle delayed range trigger. range_valid_ft fires on the same clock
|
||||||
|
// edge that range_profile_cap is captured (non-blocking). If the FSM
|
||||||
|
// reads range_profile_cap on that same edge it sees the STALE value.
|
||||||
|
// Delaying the trigger by one cycle guarantees the capture register has
|
||||||
|
// settled before the byte mux reads it.
|
||||||
|
reg range_data_ready;
|
||||||
|
|
||||||
|
// Frame sync: sample counter (ft_clk domain, wraps at NUM_CELLS)
|
||||||
|
// Bit 7 of detection byte is set when sample_counter == 0 (frame start).
|
||||||
|
// This allows the Python host to resynchronize without a protocol change.
|
||||||
|
localparam [11:0] NUM_CELLS = 12'd2048; // 64 range x 32 doppler
|
||||||
|
reg [11:0] sample_counter;
|
||||||
|
|
||||||
// Status snapshot (ft_clk domain)
|
// Status snapshot (ft_clk domain)
|
||||||
reg [31:0] status_words [0:5];
|
reg [31:0] status_words [0:5];
|
||||||
|
|
||||||
integer si; // status_words loop index
|
integer si; // status_words loop index
|
||||||
always @(posedge ft_clk or negedge ft_reset_n) begin
|
always @(posedge ft_clk or negedge ft_effective_reset_n) begin
|
||||||
if (!ft_reset_n) begin
|
if (!ft_effective_reset_n) begin
|
||||||
range_toggle_sync <= 3'b000;
|
range_toggle_sync <= 3'b000;
|
||||||
doppler_toggle_sync <= 3'b000;
|
doppler_toggle_sync <= 3'b000;
|
||||||
cfar_toggle_sync <= 3'b000;
|
cfar_toggle_sync <= 3'b000;
|
||||||
@@ -246,6 +333,7 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
doppler_real_cap <= 16'd0;
|
doppler_real_cap <= 16'd0;
|
||||||
doppler_imag_cap <= 16'd0;
|
doppler_imag_cap <= 16'd0;
|
||||||
cfar_detection_cap <= 1'b0;
|
cfar_detection_cap <= 1'b0;
|
||||||
|
range_data_ready <= 1'b0;
|
||||||
// Default to range-only on reset (prevents write FSM deadlock)
|
// Default to range-only on reset (prevents write FSM deadlock)
|
||||||
stream_ctrl_sync_0 <= 3'b001;
|
stream_ctrl_sync_0 <= 3'b001;
|
||||||
stream_ctrl_sync_1 <= 3'b001;
|
stream_ctrl_sync_1 <= 3'b001;
|
||||||
@@ -279,6 +367,10 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
if (cfar_valid_ft)
|
if (cfar_valid_ft)
|
||||||
cfar_detection_cap <= cfar_detection_hold;
|
cfar_detection_cap <= cfar_detection_hold;
|
||||||
|
|
||||||
|
// 1-cycle delayed trigger: ensures range_profile_cap has settled
|
||||||
|
// before the FSM reads it via the byte mux.
|
||||||
|
range_data_ready <= range_valid_ft;
|
||||||
|
|
||||||
// Status snapshot on request
|
// Status snapshot on request
|
||||||
if (status_req_ft) begin
|
if (status_req_ft) begin
|
||||||
// Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
|
// Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
|
||||||
@@ -315,11 +407,16 @@ always @(*) begin
|
|||||||
5'd2: data_pkt_byte = range_profile_cap[23:16];
|
5'd2: data_pkt_byte = range_profile_cap[23:16];
|
||||||
5'd3: data_pkt_byte = range_profile_cap[15:8];
|
5'd3: data_pkt_byte = range_profile_cap[15:8];
|
||||||
5'd4: data_pkt_byte = range_profile_cap[7:0]; // range LSB
|
5'd4: data_pkt_byte = range_profile_cap[7:0]; // range LSB
|
||||||
5'd5: data_pkt_byte = doppler_real_cap[15:8]; // doppler_real MSB
|
// Doppler fields: zero when stream_doppler_en is off
|
||||||
5'd6: data_pkt_byte = doppler_real_cap[7:0]; // doppler_real LSB
|
5'd5: data_pkt_byte = stream_doppler_en ? doppler_real_cap[15:8] : 8'd0;
|
||||||
5'd7: data_pkt_byte = doppler_imag_cap[15:8]; // doppler_imag MSB
|
5'd6: data_pkt_byte = stream_doppler_en ? doppler_real_cap[7:0] : 8'd0;
|
||||||
5'd8: data_pkt_byte = doppler_imag_cap[7:0]; // doppler_imag LSB
|
5'd7: data_pkt_byte = stream_doppler_en ? doppler_imag_cap[15:8] : 8'd0;
|
||||||
5'd9: data_pkt_byte = {7'b0, cfar_detection_cap}; // detection
|
5'd8: data_pkt_byte = stream_doppler_en ? doppler_imag_cap[7:0] : 8'd0;
|
||||||
|
// Detection field: zero when stream_cfar_en is off
|
||||||
|
// Bit 7 = frame_start flag (sample_counter == 0), bit 0 = cfar_detection
|
||||||
|
5'd9: data_pkt_byte = stream_cfar_en
|
||||||
|
? {(sample_counter == 12'd0), 6'b0, cfar_detection_cap}
|
||||||
|
: {(sample_counter == 12'd0), 7'd0};
|
||||||
5'd10: data_pkt_byte = FOOTER;
|
5'd10: data_pkt_byte = FOOTER;
|
||||||
default: data_pkt_byte = 8'h00;
|
default: data_pkt_byte = 8'h00;
|
||||||
endcase
|
endcase
|
||||||
@@ -376,12 +473,13 @@ end
|
|||||||
// Write FSM and Read FSM share the bus. Write FSM operates when Read FSM
|
// Write FSM and Read FSM share the bus. Write FSM operates when Read FSM
|
||||||
// is idle. Read FSM takes priority when host has data available.
|
// is idle. Read FSM takes priority when host has data available.
|
||||||
|
|
||||||
always @(posedge ft_clk or negedge ft_reset_n) begin
|
always @(posedge ft_clk or negedge ft_effective_reset_n) begin
|
||||||
if (!ft_reset_n) begin
|
if (!ft_effective_reset_n) begin
|
||||||
wr_state <= WR_IDLE;
|
wr_state <= WR_IDLE;
|
||||||
wr_byte_idx <= 5'd0;
|
wr_byte_idx <= 5'd0;
|
||||||
rd_state <= RD_IDLE;
|
rd_state <= RD_IDLE;
|
||||||
rd_byte_cnt <= 2'd0;
|
rd_byte_cnt <= 2'd0;
|
||||||
|
rd_cmd_complete <= 1'b0;
|
||||||
rd_shift_reg <= 32'd0;
|
rd_shift_reg <= 32'd0;
|
||||||
ft_data_out <= 8'd0;
|
ft_data_out <= 8'd0;
|
||||||
ft_data_oe <= 1'b0;
|
ft_data_oe <= 1'b0;
|
||||||
@@ -396,6 +494,7 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
cmd_value <= 16'd0;
|
cmd_value <= 16'd0;
|
||||||
doppler_data_pending <= 1'b0;
|
doppler_data_pending <= 1'b0;
|
||||||
cfar_data_pending <= 1'b0;
|
cfar_data_pending <= 1'b0;
|
||||||
|
sample_counter <= 12'd0;
|
||||||
end else begin
|
end else begin
|
||||||
// Default: clear one-shot signals
|
// Default: clear one-shot signals
|
||||||
cmd_valid <= 1'b0;
|
cmd_valid <= 1'b0;
|
||||||
@@ -439,6 +538,7 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
// All 4 bytes received
|
// All 4 bytes received
|
||||||
ft_rd_n <= 1'b1;
|
ft_rd_n <= 1'b1;
|
||||||
rd_byte_cnt <= 2'd0;
|
rd_byte_cnt <= 2'd0;
|
||||||
|
rd_cmd_complete <= 1'b1;
|
||||||
rd_state <= RD_DEASSERT;
|
rd_state <= RD_DEASSERT;
|
||||||
end else begin
|
end else begin
|
||||||
rd_byte_cnt <= rd_byte_cnt + 2'd1;
|
rd_byte_cnt <= rd_byte_cnt + 2'd1;
|
||||||
@@ -447,6 +547,7 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
// Host ran out of data mid-command — abort
|
// Host ran out of data mid-command — abort
|
||||||
ft_rd_n <= 1'b1;
|
ft_rd_n <= 1'b1;
|
||||||
rd_byte_cnt <= 2'd0;
|
rd_byte_cnt <= 2'd0;
|
||||||
|
rd_cmd_complete <= 1'b0;
|
||||||
rd_state <= RD_DEASSERT;
|
rd_state <= RD_DEASSERT;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -456,7 +557,8 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
// Deassert OE (1 cycle after RD deasserted)
|
// Deassert OE (1 cycle after RD deasserted)
|
||||||
ft_oe_n <= 1'b1;
|
ft_oe_n <= 1'b1;
|
||||||
// Only process if we received a full 4-byte command
|
// Only process if we received a full 4-byte command
|
||||||
if (rd_byte_cnt == 2'd0) begin
|
if (rd_cmd_complete) begin
|
||||||
|
rd_cmd_complete <= 1'b0;
|
||||||
rd_state <= RD_PROCESS;
|
rd_state <= RD_PROCESS;
|
||||||
end else begin
|
end else begin
|
||||||
// Incomplete command — discard
|
// Incomplete command — discard
|
||||||
@@ -491,8 +593,13 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
wr_state <= WR_STATUS_SEND;
|
wr_state <= WR_STATUS_SEND;
|
||||||
wr_byte_idx <= 5'd0;
|
wr_byte_idx <= 5'd0;
|
||||||
end
|
end
|
||||||
// Trigger on range_valid edge (primary data trigger)
|
// Trigger on range_data_ready (1 cycle after range_valid_ft)
|
||||||
else if (range_valid_ft && stream_range_en) begin
|
// so that range_profile_cap has settled from the CDC block.
|
||||||
|
// Gate on pending flags: only send when all enabled
|
||||||
|
// streams have fresh data (avoids stale doppler/CFAR)
|
||||||
|
else if (range_data_ready && stream_range_en
|
||||||
|
&& (!stream_doppler_en || doppler_data_pending)
|
||||||
|
&& (!stream_cfar_en || cfar_data_pending)) begin
|
||||||
if (ft_rxf_n) begin // No host read pending
|
if (ft_rxf_n) begin // No host read pending
|
||||||
wr_state <= WR_DATA_SEND;
|
wr_state <= WR_DATA_SEND;
|
||||||
wr_byte_idx <= 5'd0;
|
wr_byte_idx <= 5'd0;
|
||||||
@@ -538,6 +645,11 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
// Clear pending flags — data consumed
|
// Clear pending flags — data consumed
|
||||||
doppler_data_pending <= 1'b0;
|
doppler_data_pending <= 1'b0;
|
||||||
cfar_data_pending <= 1'b0;
|
cfar_data_pending <= 1'b0;
|
||||||
|
// Advance frame sync counter
|
||||||
|
if (sample_counter == NUM_CELLS - 12'd1)
|
||||||
|
sample_counter <= 12'd0;
|
||||||
|
else
|
||||||
|
sample_counter <= sample_counter + 12'd1;
|
||||||
wr_state <= WR_IDLE;
|
wr_state <= WR_IDLE;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# DEPRECATED: GUI V6 is superseded by GUI_V65_Tk (tkinter) and V7 (PyQt6).
|
||||||
|
# This file is retained for reference only. Do not use for new development.
|
||||||
|
# Removal planned for next major release.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from tkinter import ttk, messagebox
|
from tkinter import ttk, messagebox
|
||||||
import threading
|
import threading
|
||||||
|
|||||||
@@ -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, FT601Connection,
|
||||||
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,19 +89,311 @@ 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: 64 bins x ~24 m/bin = ~1536 m max
|
||||||
|
# Bin spacing = c / (2 * Fs) * decimation, where Fs = 100 MHz DDC output.
|
||||||
|
_RANGE_PER_BIN: float = (3e8 / (2 * 100e6)) * 16 # ~24 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: bin spacing = c/(2*Fs)*decimation
|
||||||
|
range_per_bin = (3e8 / (2 * 100e6)) * 16 # ~24 m/bin
|
||||||
|
max_range = range_per_bin * NUM_RANGE_BINS
|
||||||
|
vel_per_bin = 5.34 # m/s per Doppler bin (radar_scene.py: lam/(2*16*PRI))
|
||||||
|
|
||||||
|
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
|
SAMPLE_RATE = 100e6 # Hz — DDC output I/Q rate (matched filter input)
|
||||||
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, mock: bool,
|
||||||
recorder: DataRecorder, device_index: int = 0):
|
recorder: DataRecorder, device_index: int = 0):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.conn = connection
|
self._mock = mock
|
||||||
|
self.conn: FT2232HConnection | FT601Connection | None = None
|
||||||
self.recorder = recorder
|
self.recorder = recorder
|
||||||
self.device_index = device_index
|
self.device_index = device_index
|
||||||
|
|
||||||
@@ -93,7 +401,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 +434,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()
|
||||||
|
|
||||||
@@ -168,43 +487,59 @@ class RadarDashboard:
|
|||||||
style="Accent.TButton")
|
style="Accent.TButton")
|
||||||
self.btn_connect.pack(side="right", padx=4)
|
self.btn_connect.pack(side="right", padx=4)
|
||||||
|
|
||||||
|
# USB Interface selector (production FT2232H / premium FT601)
|
||||||
|
self._usb_iface_var = tk.StringVar(value="FT2232H (Production)")
|
||||||
|
self.cmb_usb_iface = ttk.Combobox(
|
||||||
|
top, textvariable=self._usb_iface_var,
|
||||||
|
values=["FT2232H (Production)", "FT601 (Premium)"],
|
||||||
|
state="readonly", width=20,
|
||||||
|
)
|
||||||
|
self.cmb_usb_iface.pack(side="right", padx=4)
|
||||||
|
ttk.Label(top, text="USB:", font=("Menlo", 10)).pack(side="right")
|
||||||
|
|
||||||
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
|
||||||
# Range resolution: dR = c / (2 * BW) per range bin
|
# Bin spacing = c / (2 * Fs_ddc) for matched-filter processing.
|
||||||
# But we decimate 1024→64 bins, so each bin spans 16 FFT bins.
|
range_per_bin = self.C / (2.0 * self.SAMPLE_RATE) * 16 # ~24 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 +580,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 +851,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
|
||||||
@@ -590,18 +1029,36 @@ class RadarDashboard:
|
|||||||
|
|
||||||
# ------------------------------------------------------------ Actions
|
# ------------------------------------------------------------ Actions
|
||||||
def _on_connect(self):
|
def _on_connect(self):
|
||||||
if self.conn.is_open:
|
if self.conn is not None and self.conn.is_open:
|
||||||
# Disconnect
|
# Disconnect
|
||||||
if self._acq_thread is not None:
|
if self._acq_thread is not None:
|
||||||
self._acq_thread.stop()
|
self._acq_thread.stop()
|
||||||
self._acq_thread.join(timeout=2)
|
self._acq_thread.join(timeout=2)
|
||||||
self._acq_thread = None
|
self._acq_thread = None
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
self.conn = None
|
||||||
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
|
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
|
||||||
self.btn_connect.config(text="Connect")
|
self.btn_connect.config(text="Connect")
|
||||||
|
self.cmb_usb_iface.config(state="readonly")
|
||||||
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()
|
||||||
|
|
||||||
|
# Create connection based on USB Interface selector
|
||||||
|
iface = self._usb_iface_var.get()
|
||||||
|
if "FT601" in iface:
|
||||||
|
self.conn = FT601Connection(mock=self._mock)
|
||||||
|
else:
|
||||||
|
self.conn = FT2232HConnection(mock=self._mock)
|
||||||
|
|
||||||
|
# Disable interface selector while connecting/connected
|
||||||
|
self.cmb_usb_iface.config(state="disabled")
|
||||||
|
|
||||||
# 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")
|
||||||
@@ -628,6 +1085,8 @@ class RadarDashboard:
|
|||||||
else:
|
else:
|
||||||
self.lbl_status.config(text="CONNECT FAILED", foreground=RED)
|
self.lbl_status.config(text="CONNECT FAILED", foreground=RED)
|
||||||
self.btn_connect.config(text="Connect")
|
self.btn_connect.config(text="Connect")
|
||||||
|
self.cmb_usb_iface.config(state="readonly")
|
||||||
|
self.conn = None
|
||||||
|
|
||||||
def _on_record(self):
|
def _on_record(self):
|
||||||
if self.recorder.recording:
|
if self.recorder.recording:
|
||||||
@@ -644,8 +1103,41 @@ 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)
|
||||||
|
if self.conn is None:
|
||||||
|
log.warning("No connection — command not sent")
|
||||||
|
return
|
||||||
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 +1149,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 not None and 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 +1423,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,35 +1561,31 @@ 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)
|
mock = False
|
||||||
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)
|
|
||||||
mode_str = "LIVE"
|
mode_str = "LIVE"
|
||||||
else:
|
else:
|
||||||
conn = FT2232HConnection(mock=True)
|
mock = True
|
||||||
mode_str = "MOCK"
|
mode_str = "MOCK"
|
||||||
|
|
||||||
recorder = DataRecorder()
|
recorder = DataRecorder()
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
|
|
||||||
dashboard = RadarDashboard(root, conn, recorder, device_index=args.device)
|
dashboard = RadarDashboard(root, mock, recorder, device_index=args.device)
|
||||||
|
|
||||||
if args.record:
|
if args.record:
|
||||||
filepath = os.path.join(
|
filepath = os.path.join(
|
||||||
@@ -939,12 +1594,24 @@ 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)
|
||||||
if conn.is_open:
|
if dashboard.conn is not None and dashboard.conn.is_open:
|
||||||
conn.close()
|
dashboard.conn.close()
|
||||||
if recorder.recording:
|
if recorder.recording:
|
||||||
recorder.stop()
|
recorder.stop()
|
||||||
root.destroy()
|
root.destroy()
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DEPRECATED: GUI V6 Demo is superseded by GUI_V65_Tk and V7.
|
||||||
|
# This file is retained for reference only. Do not use for new development.
|
||||||
|
# Removal planned for next major release.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Radar System GUI - Fully Functional Demo Version
|
Radar System GUI - Fully Functional Demo Version
|
||||||
All buttons work, simulated radar data is generated in real-time
|
All buttons work, simulated radar data is generated in real-time
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ GUI_V4 ==> Added pitch correction
|
|||||||
|
|
||||||
GUI_V5 ==> Added Mercury Color
|
GUI_V5 ==> Added Mercury Color
|
||||||
|
|
||||||
GUI_V6 ==> Added USB3 FT601 support
|
GUI_V6 ==> Added USB3 FT601 support [DEPRECATED — superseded by V65/V7]
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -32,83 +32,24 @@ from pathlib import Path
|
|||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from v7.agc_sim import (
|
||||||
|
encoding_to_signed,
|
||||||
|
apply_gain_shift,
|
||||||
|
quantize_iq,
|
||||||
|
AGCConfig,
|
||||||
|
AGCState,
|
||||||
|
process_agc_frame,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# FPGA AGC parameters (rx_gain_control.v reset defaults)
|
# FPGA AGC parameters (rx_gain_control.v reset defaults)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
AGC_TARGET = 200 # host_agc_target (8-bit, default 200)
|
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
|
ADC_RAIL = 4095 # 12-bit ADC max absolute value
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed)
|
# Per-frame AGC simulation using v7.agc_sim (bit-accurate to RTL)
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
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,
|
def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
|
||||||
@@ -126,79 +67,46 @@ def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
|
|||||||
n_frames = frames.shape[0]
|
n_frames = frames.shape[0]
|
||||||
|
|
||||||
# Output arrays
|
# Output arrays
|
||||||
out_gain_enc = np.zeros(n_frames, dtype=int) # gain_shift encoding [3:0]
|
out_gain_enc = np.zeros(n_frames, dtype=int)
|
||||||
out_gain_signed = np.zeros(n_frames, dtype=int) # signed gain for plotting
|
out_gain_signed = np.zeros(n_frames, dtype=int)
|
||||||
out_peak_mag = np.zeros(n_frames, dtype=int) # peak_magnitude[7:0]
|
out_peak_mag = np.zeros(n_frames, dtype=int)
|
||||||
out_sat_count = np.zeros(n_frames, dtype=int) # saturation_count[7:0]
|
out_sat_count = np.zeros(n_frames, dtype=int)
|
||||||
out_sat_rate = np.zeros(n_frames, dtype=float)
|
out_sat_rate = np.zeros(n_frames, dtype=float)
|
||||||
out_rms_post = np.zeros(n_frames, dtype=float) # RMS after gain shift
|
out_rms_post = np.zeros(n_frames, dtype=float)
|
||||||
|
|
||||||
# AGC internal state
|
# AGC state — managed by process_agc_frame()
|
||||||
agc_gain = 0 # signed, -7..+7
|
state = AGCState(
|
||||||
holdoff_counter = 0
|
gain=encoding_to_signed(initial_gain_enc),
|
||||||
agc_was_enabled = False
|
holdoff_counter=0,
|
||||||
|
was_enabled=False,
|
||||||
|
)
|
||||||
|
|
||||||
for i in range(n_frames):
|
for i in range(n_frames):
|
||||||
frame = frames[i]
|
frame_i, frame_q = quantize_iq(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_active = agc_enabled and (i >= enable_at_frame)
|
||||||
|
|
||||||
# AGC enable transition (RTL lines 250-253)
|
# Build per-frame config (enable toggles at enable_at_frame)
|
||||||
if agc_active and not agc_was_enabled:
|
config = AGCConfig(enabled=agc_active)
|
||||||
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
|
result = process_agc_frame(frame_i, frame_q, config, state)
|
||||||
|
|
||||||
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 of shifted signal
|
||||||
rms = float(np.sqrt(np.mean(
|
rms = float(np.sqrt(np.mean(
|
||||||
shifted_i.astype(np.float64)**2 + shifted_q.astype(np.float64)**2)))
|
result.shifted_i.astype(np.float64)**2
|
||||||
|
+ result.shifted_q.astype(np.float64)**2)))
|
||||||
|
|
||||||
total_samples = frame_i.size + frame_q.size
|
total_samples = frame_i.size + frame_q.size
|
||||||
sat_rate = frame_overflow / total_samples if total_samples > 0 else 0.0
|
sat_rate = result.overflow_raw / total_samples if total_samples > 0 else 0.0
|
||||||
|
|
||||||
# --- Record outputs ---
|
# Record outputs
|
||||||
out_gain_enc[i] = effective_enc
|
out_gain_enc[i] = result.gain_enc
|
||||||
out_gain_signed[i] = agc_gain if agc_active else encoding_to_signed(initial_gain_enc)
|
out_gain_signed[i] = result.gain_signed
|
||||||
out_peak_mag[i] = peak_8bit
|
out_peak_mag[i] = result.peak_mag_8bit
|
||||||
out_sat_count[i] = frame_sat
|
out_sat_count[i] = result.saturation_count
|
||||||
out_sat_rate[i] = sat_rate
|
out_sat_rate[i] = sat_rate
|
||||||
out_rms_post[i] = rms
|
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 {
|
return {
|
||||||
"gain_enc": out_gain_enc,
|
"gain_enc": out_gain_enc,
|
||||||
"gain_signed": out_gain_signed,
|
"gain_signed": out_gain_signed,
|
||||||
@@ -217,8 +125,7 @@ def process_frame_rd(frame: np.ndarray, gain_enc: int,
|
|||||||
n_range: int = 64,
|
n_range: int = 64,
|
||||||
n_doppler: int = 32) -> np.ndarray:
|
n_doppler: int = 32) -> np.ndarray:
|
||||||
"""Range-Doppler magnitude for one frame with gain applied."""
|
"""Range-Doppler magnitude for one frame with gain applied."""
|
||||||
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
|
frame_i, frame_q = quantize_iq(frame)
|
||||||
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
|
|
||||||
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
|
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
|
||||||
|
|
||||||
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
|
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Pure-logic module for USB packet parsing and command building.
|
|||||||
No GUI dependencies — safe to import from tests and headless scripts.
|
No GUI dependencies — safe to import from tests and headless scripts.
|
||||||
|
|
||||||
USB Interface: FT2232H USB 2.0 (8-bit, 50T production board) via pyftdi
|
USB Interface: FT2232H USB 2.0 (8-bit, 50T production board) via pyftdi
|
||||||
|
FT601 USB 3.0 (32-bit, 200T premium board) via ftd3xx
|
||||||
|
|
||||||
USB Packet Protocol (11-byte):
|
USB Packet Protocol (11-byte):
|
||||||
TX (FPGA→Host):
|
TX (FPGA→Host):
|
||||||
@@ -15,7 +16,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
|
||||||
@@ -23,7 +23,7 @@ import queue
|
|||||||
import logging
|
import logging
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any, ClassVar
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
@@ -201,7 +201,9 @@ class RadarProtocol:
|
|||||||
range_i = _to_signed16(struct.unpack_from(">H", raw, 3)[0])
|
range_i = _to_signed16(struct.unpack_from(">H", raw, 3)[0])
|
||||||
doppler_i = _to_signed16(struct.unpack_from(">H", raw, 5)[0])
|
doppler_i = _to_signed16(struct.unpack_from(">H", raw, 5)[0])
|
||||||
doppler_q = _to_signed16(struct.unpack_from(">H", raw, 7)[0])
|
doppler_q = _to_signed16(struct.unpack_from(">H", raw, 7)[0])
|
||||||
detection = raw[9] & 0x01
|
det_byte = raw[9]
|
||||||
|
detection = det_byte & 0x01
|
||||||
|
frame_start = (det_byte >> 7) & 0x01
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"range_i": range_i,
|
"range_i": range_i,
|
||||||
@@ -209,6 +211,7 @@ class RadarProtocol:
|
|||||||
"doppler_i": doppler_i,
|
"doppler_i": doppler_i,
|
||||||
"doppler_q": doppler_q,
|
"doppler_q": doppler_q,
|
||||||
"detection": detection,
|
"detection": detection,
|
||||||
|
"frame_start": frame_start,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -434,7 +437,9 @@ class FT2232HConnection:
|
|||||||
pkt += struct.pack(">h", np.clip(range_i, -32768, 32767))
|
pkt += struct.pack(">h", np.clip(range_i, -32768, 32767))
|
||||||
pkt += struct.pack(">h", np.clip(dop_i, -32768, 32767))
|
pkt += struct.pack(">h", np.clip(dop_i, -32768, 32767))
|
||||||
pkt += struct.pack(">h", np.clip(dop_q, -32768, 32767))
|
pkt += struct.pack(">h", np.clip(dop_q, -32768, 32767))
|
||||||
pkt.append(detection & 0x01)
|
# Bit 7 = frame_start (sample_counter == 0), bit 0 = detection
|
||||||
|
det_byte = (detection & 0x01) | (0x80 if idx == 0 else 0x00)
|
||||||
|
pkt.append(det_byte)
|
||||||
pkt.append(FOOTER_BYTE)
|
pkt.append(FOOTER_BYTE)
|
||||||
|
|
||||||
buf += pkt
|
buf += pkt
|
||||||
@@ -444,392 +449,190 @@ class FT2232HConnection:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Replay Connection — feed real .npy data through the dashboard
|
# FT601 USB 3.0 Connection (premium board only)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Hardware-only opcodes that cannot be adjusted in replay mode
|
# Optional ftd3xx import (FTDI's proprietary driver for FT60x USB 3.0 chips).
|
||||||
# Values must match radar_system_top.v case(usb_cmd_opcode).
|
# pyftdi does NOT support FT601 — it only handles USB 2.0 chips (FT232H, etc.)
|
||||||
_HARDWARE_ONLY_OPCODES = {
|
try:
|
||||||
0x01, # RADAR_MODE
|
import ftd3xx # type: ignore[import-untyped]
|
||||||
0x02, # TRIGGER_PULSE
|
FTD3XX_AVAILABLE = True
|
||||||
# 0x03 (DETECT_THRESHOLD) is NOT hardware-only — it's in _REPLAY_ADJUSTABLE_OPCODES
|
_Ftd3xxError: type = ftd3xx.FTD3XXError # type: ignore[attr-defined]
|
||||||
0x04, # STREAM_CONTROL
|
except ImportError:
|
||||||
0x10, # LONG_CHIRP
|
FTD3XX_AVAILABLE = False
|
||||||
0x11, # LONG_LISTEN
|
_Ftd3xxError = OSError # fallback for type-checking; never raised
|
||||||
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:
|
class FT601Connection:
|
||||||
"""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()
|
FT601 USB 3.0 SuperSpeed FIFO bridge — premium board only.
|
||||||
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
|
|
||||||
|
|
||||||
|
The FT601 has a 32-bit data bus and runs at 100 MHz.
|
||||||
|
VID:PID = 0x0403:0x6030 or 0x6031 (FTDI FT60x).
|
||||||
|
|
||||||
def _replay_cfar(doppler_i: np.ndarray, doppler_q: np.ndarray,
|
Requires the ``ftd3xx`` library (``pip install ftd3xx`` on Windows,
|
||||||
guard: int, train: int, alpha_q44: int,
|
or ``libft60x`` on Linux). This is FTDI's proprietary USB 3.0 driver;
|
||||||
mode: int) -> tuple[np.ndarray, np.ndarray]:
|
``pyftdi`` only supports USB 2.0 and will NOT work with FT601.
|
||||||
"""
|
|
||||||
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)
|
Public contract matches FT2232HConnection so callers can swap freely.
|
||||||
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,
|
VID = 0x0403
|
||||||
replay_fps: float = 5.0):
|
PID_LIST: ClassVar[list[int]] = [0x6030, 0x6031]
|
||||||
self._npy_dir = npy_dir
|
|
||||||
self._use_mti = use_mti
|
def __init__(self, mock: bool = True):
|
||||||
self._replay_fps = max(replay_fps, 0.1)
|
self._mock = mock
|
||||||
|
self._dev = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self.is_open = False
|
self.is_open = False
|
||||||
self._packets: bytes = b""
|
# Mock state (reuses same synthetic data pattern)
|
||||||
self._read_offset = 0
|
self._mock_frame_num = 0
|
||||||
self._frame_len = 0
|
self._mock_rng = np.random.RandomState(42)
|
||||||
# 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:
|
def open(self, device_index: int = 0) -> bool:
|
||||||
try:
|
if self._mock:
|
||||||
self._load_arrays()
|
|
||||||
self._packets = self._build_packets()
|
|
||||||
self._frame_len = len(self._packets)
|
|
||||||
self._read_offset = 0
|
|
||||||
self.is_open = True
|
self.is_open = True
|
||||||
log.info(f"Replay connection opened: {self._npy_dir} "
|
log.info("FT601 mock device opened (no hardware)")
|
||||||
f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
|
|
||||||
f"{self._frame_len} bytes/frame)")
|
|
||||||
return True
|
return True
|
||||||
except (OSError, ValueError, IndexError, struct.error) as e:
|
|
||||||
log.error(f"Replay open failed: {e}")
|
if not FTD3XX_AVAILABLE:
|
||||||
|
log.error(
|
||||||
|
"ftd3xx library required for FT601 hardware — "
|
||||||
|
"install with: pip install ftd3xx"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._dev = ftd3xx.create(device_index, ftd3xx.OPEN_BY_INDEX)
|
||||||
|
if self._dev is None:
|
||||||
|
log.error("No FT601 device found at index %d", device_index)
|
||||||
|
return False
|
||||||
|
# Verify chip configuration — only reconfigure if needed.
|
||||||
|
# setChipConfiguration triggers USB re-enumeration, which
|
||||||
|
# invalidates the device handle and requires a re-open cycle.
|
||||||
|
cfg = self._dev.getChipConfiguration()
|
||||||
|
needs_reconfig = (
|
||||||
|
cfg.FIFOMode != 0 # 245 FIFO mode
|
||||||
|
or cfg.ChannelConfig != 0 # 1 channel, 32-bit
|
||||||
|
or cfg.OptionalFeatureSupport != 0
|
||||||
|
)
|
||||||
|
if needs_reconfig:
|
||||||
|
cfg.FIFOMode = 0
|
||||||
|
cfg.ChannelConfig = 0
|
||||||
|
cfg.OptionalFeatureSupport = 0
|
||||||
|
self._dev.setChipConfiguration(cfg)
|
||||||
|
# Device re-enumerates — close stale handle, wait, re-open
|
||||||
|
self._dev.close()
|
||||||
|
self._dev = None
|
||||||
|
import time
|
||||||
|
time.sleep(2.0) # wait for USB re-enumeration
|
||||||
|
self._dev = ftd3xx.create(device_index, ftd3xx.OPEN_BY_INDEX)
|
||||||
|
if self._dev is None:
|
||||||
|
log.error("FT601 not found after reconfiguration")
|
||||||
|
return False
|
||||||
|
log.info("FT601 reconfigured and re-opened (index %d)", device_index)
|
||||||
|
self.is_open = True
|
||||||
|
log.info("FT601 device opened (index %d)", device_index)
|
||||||
|
return True
|
||||||
|
except (OSError, _Ftd3xxError) as e:
|
||||||
|
log.error("FT601 open failed: %s", e)
|
||||||
|
self._dev = None
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
|
if self._dev is not None:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self._dev.close()
|
||||||
|
self._dev = None
|
||||||
self.is_open = False
|
self.is_open = False
|
||||||
|
|
||||||
def read(self, size: int = 4096) -> bytes | None:
|
def read(self, size: int = 4096) -> bytes | None:
|
||||||
|
"""Read raw bytes from FT601. Returns None on error/timeout."""
|
||||||
if not self.is_open:
|
if not self.is_open:
|
||||||
return None
|
return None
|
||||||
# Pace reads to target FPS (spread across ~64 reads per frame)
|
|
||||||
time.sleep((1.0 / self._replay_fps) / (NUM_CELLS / 32))
|
if self._mock:
|
||||||
|
return self._mock_read(size)
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
# If params changed, rebuild packets
|
try:
|
||||||
if self._needs_rebuild:
|
data = self._dev.readPipe(0x82, size, raw=True)
|
||||||
self._packets = self._build_packets()
|
return bytes(data) if data else None
|
||||||
self._frame_len = len(self._packets)
|
except (OSError, _Ftd3xxError) as e:
|
||||||
self._read_offset = 0
|
log.error("FT601 read error: %s", e)
|
||||||
self._needs_rebuild = False
|
return None
|
||||||
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:
|
def write(self, data: bytes) -> bool:
|
||||||
"""
|
"""Write raw bytes to FT601. Data must be 4-byte aligned for 32-bit bus."""
|
||||||
Handle host commands in replay mode.
|
if not self.is_open:
|
||||||
Signal-processing params (CFAR, MTI, DC notch) trigger re-processing.
|
return False
|
||||||
Hardware-only params are silently ignored.
|
|
||||||
"""
|
if self._mock:
|
||||||
if len(data) < 4:
|
log.info(f"FT601 mock write: {data.hex()}")
|
||||||
return True
|
return True
|
||||||
word = struct.unpack(">I", data[:4])[0]
|
|
||||||
opcode = (word >> 24) & 0xFF
|
# Pad to 4-byte alignment (FT601 32-bit bus requirement).
|
||||||
value = word & 0xFFFF
|
# NOTE: Radar commands are already 4 bytes, so this should be a no-op.
|
||||||
|
remainder = len(data) % 4
|
||||||
|
if remainder:
|
||||||
|
data = data + b"\x00" * (4 - remainder)
|
||||||
|
|
||||||
if opcode in _REPLAY_ADJUSTABLE_OPCODES:
|
|
||||||
changed = False
|
|
||||||
with self._lock:
|
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:
|
try:
|
||||||
range_i_all = np.load(
|
written = self._dev.writePipe(0x02, data, raw=True)
|
||||||
os.path.join(npy, "decimated_range_i.npy")).astype(np.int64)
|
return written == len(data)
|
||||||
range_q_all = np.load(
|
except (OSError, _Ftd3xxError) as e:
|
||||||
os.path.join(npy, "decimated_range_q.npy")).astype(np.int64)
|
log.error("FT601 write error: %s", e)
|
||||||
self._range_i_vec = range_i_all[-1, :] # last chirp
|
return False
|
||||||
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:
|
def _mock_read(self, size: int) -> bytes:
|
||||||
"""Build a full frame of USB data packets from current params."""
|
"""Generate synthetic radar packets (same pattern as FT2232H mock)."""
|
||||||
# Select Doppler data based on MTI
|
time.sleep(0.05)
|
||||||
if self._mti_enable:
|
self._mock_frame_num += 1
|
||||||
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
|
buf = bytearray()
|
||||||
dop_i, dop_q = _replay_dc_notch(dop_i, dop_q, self._dc_notch_width)
|
num_packets = min(NUM_CELLS, size // DATA_PACKET_SIZE)
|
||||||
|
start_idx = getattr(self, "_mock_seq_idx", 0)
|
||||||
|
|
||||||
# Run CFAR
|
for n in range(num_packets):
|
||||||
if self._cfar_enable:
|
idx = (start_idx + n) % NUM_CELLS
|
||||||
det, _mag = _replay_cfar(
|
rbin = idx // NUM_DOPPLER_BINS
|
||||||
dop_i, dop_q,
|
dbin = idx % NUM_DOPPLER_BINS
|
||||||
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())
|
range_i = int(self._mock_rng.normal(0, 100))
|
||||||
log.info(f"Replay: rebuilt {NUM_CELLS} packets ("
|
range_q = int(self._mock_rng.normal(0, 100))
|
||||||
f"MTI={'ON' if self._mti_enable else 'OFF'}, "
|
if abs(rbin - 20) < 3:
|
||||||
f"DC_notch={self._dc_notch_width}, "
|
range_i += 5000
|
||||||
f"CFAR={'ON' if self._cfar_enable else 'OFF'} "
|
range_q += 3000
|
||||||
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
|
dop_i = int(self._mock_rng.normal(0, 50))
|
||||||
range_q = self._range_q_vec
|
dop_q = int(self._mock_rng.normal(0, 50))
|
||||||
|
if abs(rbin - 20) < 3 and abs(dbin - 8) < 2:
|
||||||
|
dop_i += 8000
|
||||||
|
dop_q += 4000
|
||||||
|
|
||||||
return self._build_packets_data(range_i, range_q, dop_i, dop_q, det)
|
detection = 1 if (abs(rbin - 20) < 2 and abs(dbin - 8) < 2) else 0
|
||||||
|
|
||||||
def _build_packets_data(self, range_i, range_q, dop_i, dop_q, det) -> bytes:
|
pkt = bytearray()
|
||||||
"""Build 11-byte data packets for FT2232H interface."""
|
pkt.append(HEADER_BYTE)
|
||||||
buf = bytearray(NUM_CELLS * DATA_PACKET_SIZE)
|
pkt += struct.pack(">h", np.clip(range_q, -32768, 32767))
|
||||||
pos = 0
|
pkt += struct.pack(">h", np.clip(range_i, -32768, 32767))
|
||||||
for rbin in range(NUM_RANGE_BINS):
|
pkt += struct.pack(">h", np.clip(dop_i, -32768, 32767))
|
||||||
ri = int(np.clip(range_i[rbin], -32768, 32767))
|
pkt += struct.pack(">h", np.clip(dop_q, -32768, 32767))
|
||||||
rq = int(np.clip(range_q[rbin], -32768, 32767))
|
# Bit 7 = frame_start (sample_counter == 0), bit 0 = detection
|
||||||
rq_bytes = struct.pack(">h", rq)
|
det_byte = (detection & 0x01) | (0x80 if idx == 0 else 0x00)
|
||||||
ri_bytes = struct.pack(">h", ri)
|
pkt.append(det_byte)
|
||||||
for dbin in range(NUM_DOPPLER_BINS):
|
pkt.append(FOOTER_BYTE)
|
||||||
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
|
buf += pkt
|
||||||
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
|
|
||||||
|
|
||||||
|
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
|
||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Data Recorder (HDF5)
|
# Data Recorder (HDF5)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -985,6 +788,12 @@ class RadarAcquisition(threading.Thread):
|
|||||||
if sample.get("detection", 0):
|
if sample.get("detection", 0):
|
||||||
self._frame.detections[rbin, dbin] = 1
|
self._frame.detections[rbin, dbin] = 1
|
||||||
self._frame.detection_count += 1
|
self._frame.detection_count += 1
|
||||||
|
# Accumulate FPGA range profile data (matched-filter output)
|
||||||
|
# Each sample carries the range_i/range_q for this range bin.
|
||||||
|
# Accumulate magnitude across Doppler bins for the range profile.
|
||||||
|
ri = int(sample.get("range_i", 0))
|
||||||
|
rq = int(sample.get("range_q", 0))
|
||||||
|
self._frame.range_profile[rbin] += abs(ri) + abs(rq)
|
||||||
|
|
||||||
self._sample_idx += 1
|
self._sample_idx += 1
|
||||||
|
|
||||||
@@ -992,11 +801,11 @@ class RadarAcquisition(threading.Thread):
|
|||||||
self._finalize_frame()
|
self._finalize_frame()
|
||||||
|
|
||||||
def _finalize_frame(self):
|
def _finalize_frame(self):
|
||||||
"""Complete frame: compute range profile, push to queue, record."""
|
"""Complete frame: push to queue, record."""
|
||||||
self._frame.timestamp = time.time()
|
self._frame.timestamp = time.time()
|
||||||
self._frame.frame_number = self._frame_num
|
self._frame.frame_number = self._frame_num
|
||||||
# Range profile = sum of magnitude across Doppler bins
|
# range_profile is already accumulated from FPGA range_i/range_q
|
||||||
self._frame.range_profile = np.sum(self._frame.magnitude, axis=1)
|
# data in _ingest_sample(). No need to synthesize from doppler magnitude.
|
||||||
|
|
||||||
# Push to display queue (drop old if backed up)
|
# Push to display queue (drop old if backed up)
|
||||||
try:
|
try:
|
||||||
|
|||||||
+254
-231
@@ -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
|
||||||
@@ -16,13 +16,13 @@ import unittest
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from radar_protocol import (
|
from radar_protocol import (
|
||||||
RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition,
|
RadarProtocol, FT2232HConnection, FT601Connection, 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):
|
||||||
@@ -312,6 +312,61 @@ class TestFT2232HConnection(unittest.TestCase):
|
|||||||
self.assertFalse(conn.write(b"\x00\x00\x00\x00"))
|
self.assertFalse(conn.write(b"\x00\x00\x00\x00"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestFT601Connection(unittest.TestCase):
|
||||||
|
"""Test mock FT601 connection (mirrors FT2232H tests)."""
|
||||||
|
|
||||||
|
def test_mock_open_close(self):
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
self.assertTrue(conn.open())
|
||||||
|
self.assertTrue(conn.is_open)
|
||||||
|
conn.close()
|
||||||
|
self.assertFalse(conn.is_open)
|
||||||
|
|
||||||
|
def test_mock_read_returns_data(self):
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
conn.open()
|
||||||
|
data = conn.read(4096)
|
||||||
|
self.assertIsNotNone(data)
|
||||||
|
self.assertGreater(len(data), 0)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_mock_read_contains_valid_packets(self):
|
||||||
|
"""Mock data should contain parseable data packets."""
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
conn.open()
|
||||||
|
raw = conn.read(4096)
|
||||||
|
packets = RadarProtocol.find_packet_boundaries(raw)
|
||||||
|
self.assertGreater(len(packets), 0)
|
||||||
|
for start, end, ptype in packets:
|
||||||
|
if ptype == "data":
|
||||||
|
result = RadarProtocol.parse_data_packet(raw[start:end])
|
||||||
|
self.assertIsNotNone(result)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_mock_write(self):
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
conn.open()
|
||||||
|
cmd = RadarProtocol.build_command(0x01, 1)
|
||||||
|
self.assertTrue(conn.write(cmd))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_write_pads_to_4_bytes(self):
|
||||||
|
"""FT601 write() should pad data to 4-byte alignment."""
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
conn.open()
|
||||||
|
# 3-byte payload should be padded internally (no error)
|
||||||
|
self.assertTrue(conn.write(b"\x01\x02\x03"))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_read_when_closed(self):
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
self.assertIsNone(conn.read())
|
||||||
|
|
||||||
|
def test_write_when_closed(self):
|
||||||
|
conn = FT601Connection(mock=True)
|
||||||
|
self.assertFalse(conn.write(b"\x00\x00\x00\x00"))
|
||||||
|
|
||||||
|
|
||||||
class TestDataRecorder(unittest.TestCase):
|
class TestDataRecorder(unittest.TestCase):
|
||||||
"""Test HDF5 recording (skipped if h5py not available)."""
|
"""Test HDF5 recording (skipped if h5py not available)."""
|
||||||
|
|
||||||
@@ -459,218 +514,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 +533,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 +551,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 +775,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)
|
||||||
@@ -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
|
||||||
@@ -64,9 +65,9 @@ class TestRadarSettings(unittest.TestCase):
|
|||||||
|
|
||||||
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,10 +336,14 @@ 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",
|
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"]:
|
"RadarDashboard"]:
|
||||||
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
|
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
|
||||||
|
|
||||||
@@ -401,6 +415,561 @@ 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.chirps_per_subframe, 16)
|
||||||
|
self.assertEqual(wc.fft_size, 1024)
|
||||||
|
self.assertEqual(wc.decimation_factor, 16)
|
||||||
|
|
||||||
|
def test_range_resolution(self):
|
||||||
|
"""range_resolution_m should be ~23.98 m/bin (matched filter, 100 MSPS)."""
|
||||||
|
from v7.models import WaveformConfig
|
||||||
|
wc = WaveformConfig()
|
||||||
|
self.assertAlmostEqual(wc.range_resolution_m, 23.983, places=1)
|
||||||
|
|
||||||
|
def test_velocity_resolution(self):
|
||||||
|
"""velocity_resolution_mps should be ~5.34 m/s/bin (PRI=167us, 16 chirps)."""
|
||||||
|
from v7.models import WaveformConfig
|
||||||
|
wc = WaveformConfig()
|
||||||
|
self.assertAlmostEqual(wc.velocity_resolution_mps, 5.343, places=1)
|
||||||
|
|
||||||
|
def test_max_range(self):
|
||||||
|
"""max_range_m = range_resolution * n_range_bins."""
|
||||||
|
from v7.models import WaveformConfig
|
||||||
|
wc = WaveformConfig()
|
||||||
|
self.assertAlmostEqual(wc.max_range_m, wc.range_resolution_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()
|
||||||
|
wc2 = WaveformConfig(sample_rate_hz=200e6) # double Fs → halve range bin
|
||||||
|
self.assertAlmostEqual(wc2.range_resolution_m, wc1.range_resolution_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, range_resolution=23.983)
|
||||||
|
self.assertEqual(len(targets), 1)
|
||||||
|
self.assertAlmostEqual(targets[0].range, 10 * 23.983, 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=1.484)
|
||||||
|
# dbin=10: vel = (10-16)*1.484 = -8.904 (approaching)
|
||||||
|
# dbin=20: vel = (20-16)*1.484 = +5.936 (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, range_resolution=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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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,7 @@ 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,
|
FT601Connection,
|
||||||
RadarProtocol,
|
RadarProtocol,
|
||||||
Opcode,
|
Opcode,
|
||||||
RadarAcquisition,
|
RadarAcquisition,
|
||||||
@@ -40,31 +41,48 @@ from .processing import (
|
|||||||
RadarProcessor,
|
RadarProcessor,
|
||||||
USBPacketParser,
|
USBPacketParser,
|
||||||
apply_pitch_correction,
|
apply_pitch_correction,
|
||||||
|
polar_to_geographic,
|
||||||
|
extract_targets_from_frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Workers and simulator
|
# Software FPGA (depends on golden_reference.py in FPGA cosim tree)
|
||||||
from .workers import (
|
try: # noqa: SIM105
|
||||||
|
from .software_fpga import SoftwareFPGA, quantize_raw_iq
|
||||||
|
except ImportError: # golden_reference.py not available (e.g. deployment without FPGA tree)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Replay engine (no PyQt6 dependency, but needs SoftwareFPGA for raw IQ path)
|
||||||
|
try: # noqa: SIM105
|
||||||
|
from .replay import ReplayEngine, ReplayFormat
|
||||||
|
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,
|
RadarDataWorker,
|
||||||
GPSDataWorker,
|
GPSDataWorker,
|
||||||
TargetSimulator,
|
TargetSimulator,
|
||||||
polar_to_geographic,
|
ReplayWorker,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Map widget
|
from .map_widget import (
|
||||||
from .map_widget import (
|
|
||||||
MapBridge,
|
MapBridge,
|
||||||
RadarMapWidget,
|
RadarMapWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Main dashboard
|
from .dashboard import (
|
||||||
from .dashboard import (
|
|
||||||
RadarDashboard,
|
RadarDashboard,
|
||||||
RangeDopplerCanvas,
|
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 +90,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", "FT601Connection", "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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -13,18 +13,22 @@ RadarDashboard is a QMainWindow with six tabs:
|
|||||||
6. Settings — Host-side DSP parameters + About section
|
6. Settings — Host-side DSP parameters + About section
|
||||||
|
|
||||||
Uses production radar_protocol.py for all FPGA communication:
|
Uses production radar_protocol.py for all FPGA communication:
|
||||||
- FT2232HConnection for real hardware
|
- FT2232HConnection for production board (FT2232H USB 2.0)
|
||||||
- ReplayConnection for offline .npy replay
|
- FT601Connection for premium board (FT601 USB 3.0) — selectable from GUI
|
||||||
|
- 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
|
||||||
are controlled directly via 4-byte {opcode, addr, value_hi, value_lo}
|
are controlled directly via 4-byte {opcode, addr, value_hi, value_lo}
|
||||||
commands sent over FT2232H.
|
commands sent over FT2232H or FT601.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
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 +36,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 +56,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .hardware import (
|
from .hardware import (
|
||||||
FT2232HConnection,
|
FT2232HConnection,
|
||||||
ReplayConnection,
|
FT601Connection,
|
||||||
RadarProtocol,
|
RadarProtocol,
|
||||||
RadarFrame,
|
RadarFrame,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
@@ -60,15 +64,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)
|
||||||
@@ -125,7 +144,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Hardware interfaces — production protocol
|
# Hardware interfaces — production protocol
|
||||||
self._connection: FT2232HConnection | None = None
|
self._connection: FT2232HConnection | FT601Connection | None = None
|
||||||
self._stm32 = STM32USBInterface()
|
self._stm32 = STM32USBInterface()
|
||||||
self._recorder = DataRecorder()
|
self._recorder = DataRecorder()
|
||||||
|
|
||||||
@@ -142,6 +161,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 +366,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", "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)
|
||||||
|
|
||||||
@@ -354,6 +379,13 @@ class RadarDashboard(QMainWindow):
|
|||||||
refresh_btn.clicked.connect(self._refresh_devices)
|
refresh_btn.clicked.connect(self._refresh_devices)
|
||||||
ctrl_layout.addWidget(refresh_btn, 0, 4)
|
ctrl_layout.addWidget(refresh_btn, 0, 4)
|
||||||
|
|
||||||
|
# USB Interface selector (production FT2232H / premium FT601)
|
||||||
|
ctrl_layout.addWidget(QLabel("USB Interface:"), 0, 5)
|
||||||
|
self._usb_iface_combo = QComboBox()
|
||||||
|
self._usb_iface_combo.addItems(["FT2232H (Production)", "FT601 (Premium)"])
|
||||||
|
self._usb_iface_combo.setCurrentIndex(0)
|
||||||
|
ctrl_layout.addWidget(self._usb_iface_combo, 0, 6)
|
||||||
|
|
||||||
self._start_btn = QPushButton("Start Radar")
|
self._start_btn = QPushButton("Start Radar")
|
||||||
self._start_btn.setStyleSheet(
|
self._start_btn.setStyleSheet(
|
||||||
f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}"
|
f"QPushButton {{ background-color: {DARK_SUCCESS}; color: white; font-weight: bold; }}"
|
||||||
@@ -390,6 +422,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 +533,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 +564,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 +980,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,
|
||||||
@@ -929,7 +1010,8 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._conn_ft2232h = self._make_status_label("FT2232H")
|
self._conn_ft2232h = self._make_status_label("FT2232H")
|
||||||
self._conn_stm32 = self._make_status_label("STM32 USB")
|
self._conn_stm32 = self._make_status_label("STM32 USB")
|
||||||
|
|
||||||
conn_layout.addWidget(QLabel("FT2232H:"), 0, 0)
|
self._conn_usb_label = QLabel("USB Data:")
|
||||||
|
conn_layout.addWidget(self._conn_usb_label, 0, 0)
|
||||||
conn_layout.addWidget(self._conn_ft2232h, 0, 1)
|
conn_layout.addWidget(self._conn_ft2232h, 0, 1)
|
||||||
conn_layout.addWidget(QLabel("STM32 USB:"), 1, 0)
|
conn_layout.addWidget(QLabel("STM32 USB:"), 1, 0)
|
||||||
conn_layout.addWidget(self._conn_stm32, 1, 1)
|
conn_layout.addWidget(self._conn_stm32, 1, 1)
|
||||||
@@ -1047,7 +1129,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)
|
||||||
@@ -1095,7 +1177,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
about_lbl = QLabel(
|
about_lbl = QLabel(
|
||||||
"<b>AERIS-10 Radar System V7</b><br>"
|
"<b>AERIS-10 Radar System V7</b><br>"
|
||||||
"PyQt6 Edition with Embedded Leaflet Map<br><br>"
|
"PyQt6 Edition with Embedded Leaflet Map<br><br>"
|
||||||
"<b>Data Interface:</b> FT2232H USB 2.0 (production protocol)<br>"
|
"<b>Data Interface:</b> FT2232H USB 2.0 (production) / FT601 USB 3.0 (premium)<br>"
|
||||||
"<b>FPGA Protocol:</b> 4-byte register commands, 0xAA/0xBB packets<br>"
|
"<b>FPGA Protocol:</b> 4-byte register commands, 0xAA/0xBB packets<br>"
|
||||||
"<b>Map:</b> OpenStreetMap + Leaflet.js<br>"
|
"<b>Map:</b> OpenStreetMap + Leaflet.js<br>"
|
||||||
"<b>Framework:</b> PyQt6 + QWebEngine<br>"
|
"<b>Framework:</b> PyQt6 + QWebEngine<br>"
|
||||||
@@ -1152,7 +1234,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
def _send_fpga_cmd(self, opcode: int, value: int):
|
def _send_fpga_cmd(self, opcode: int, value: int):
|
||||||
"""Send a 4-byte register command to the FPGA via FT2232H."""
|
"""Send a 4-byte register command to the FPGA via USB (FT2232H or FT601)."""
|
||||||
if self._connection is None or not self._connection.is_open:
|
if self._connection is None or not self._connection.is_open:
|
||||||
logger.warning(f"Cannot send 0x{opcode:02X}={value}: no connection")
|
logger.warning(f"Cannot send 0x{opcode:02X}={value}: no connection")
|
||||||
return
|
return
|
||||||
@@ -1164,7 +1246,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 +1260,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)
|
||||||
|
|
||||||
|
# Dispatch to real FPGA (live/mock mode)
|
||||||
|
if not self._replay_mode:
|
||||||
self._send_fpga_cmd(opcode, clamped)
|
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 +1288,123 @@ 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
|
||||||
|
iface = self._usb_iface_combo.currentText()
|
||||||
|
if "FT601" in iface:
|
||||||
|
self._connection = FT601Connection(mock=True)
|
||||||
|
else:
|
||||||
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
|
||||||
|
iface = self._usb_iface_combo.currentText()
|
||||||
|
if "FT601" in iface:
|
||||||
|
self._connection = FT601Connection(mock=False)
|
||||||
|
iface_name = "FT601"
|
||||||
|
else:
|
||||||
self._connection = FT2232HConnection(mock=False)
|
self._connection = FT2232HConnection(mock=False)
|
||||||
|
iface_name = "FT2232H"
|
||||||
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.")
|
f"Failed to open {iface_name}. 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
|
||||||
|
|
||||||
|
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._usb_iface_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
|
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 +1438,9 @@ 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._usb_iface_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 +1458,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 +1484,121 @@ 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._usb_iface_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 +1606,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 +1628,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"
|
||||||
@@ -1664,6 +1977,12 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._set_conn_indicator(self._conn_ft2232h, conn_open)
|
self._set_conn_indicator(self._conn_ft2232h, conn_open)
|
||||||
self._set_conn_indicator(self._conn_stm32, self._stm32.is_open)
|
self._set_conn_indicator(self._conn_stm32, self._stm32.is_open)
|
||||||
|
|
||||||
|
# Update USB label to reflect which interface is active
|
||||||
|
if isinstance(self._connection, FT601Connection):
|
||||||
|
self._conn_usb_label.setText("FT601:")
|
||||||
|
else:
|
||||||
|
self._conn_usb_label.setText("FT2232H:")
|
||||||
|
|
||||||
gps_count = self._gps_packet_count
|
gps_count = self._gps_packet_count
|
||||||
if self._gps_worker:
|
if self._gps_worker:
|
||||||
gps_count = self._gps_worker.gps_count
|
gps_count = self._gps_worker.gps_count
|
||||||
|
|||||||
@@ -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,7 @@ 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,
|
FT601Connection,
|
||||||
RadarProtocol,
|
RadarProtocol,
|
||||||
Opcode,
|
Opcode,
|
||||||
RadarAcquisition,
|
RadarAcquisition,
|
||||||
@@ -50,8 +47,9 @@ class STM32USBInterface:
|
|||||||
|
|
||||||
Used ONLY for receiving GPS data from the MCU.
|
Used ONLY for receiving GPS data from the MCU.
|
||||||
|
|
||||||
FPGA register commands are sent via FT2232H (see FT2232HConnection
|
FPGA register commands are sent via the USB data interface — either
|
||||||
from radar_protocol.py). The old send_start_flag() / send_settings()
|
FT2232HConnection (production) or FT601Connection (premium), both
|
||||||
|
from radar_protocol.py. The old send_start_flag() / send_settings()
|
||||||
methods have been removed — they used an incompatible magic-packet
|
methods have been removed — they used an incompatible magic-packet
|
||||||
protocol that the FPGA does not understand.
|
protocol that the FPGA does not understand.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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/bin)
|
||||||
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")
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ class RadarSettings:
|
|||||||
range_resolution and velocity_resolution should be calibrated to
|
range_resolution 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 (carrier, used for velocity calc)
|
||||||
range_resolution: float = 781.25 # Meters per range bin (default: 50km/64)
|
range_resolution: float = 24.0 # Meters per range bin (c/(2*Fs)*decim)
|
||||||
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform)
|
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform)
|
||||||
max_distance: float = 50000 # Max detection range (m)
|
max_distance: float = 1536 # Max detection range (m)
|
||||||
map_size: float = 50000 # Map display size (m)
|
map_size: float = 2000 # 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,66 @@ 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 radar waveform so that range/velocity resolution
|
||||||
|
can be derived automatically instead of hardcoded in RadarSettings.
|
||||||
|
|
||||||
|
Defaults match the AERIS-10 production system parameters from
|
||||||
|
radar_scene.py / plfm_chirp_controller.v:
|
||||||
|
100 MSPS DDC output, 20 MHz chirp BW, 30 us long chirp,
|
||||||
|
167 us long-chirp PRI, X-band 10.5 GHz carrier.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sample_rate_hz: float = 100e6 # DDC output I/Q rate (matched filter input)
|
||||||
|
bandwidth_hz: float = 20e6 # Chirp bandwidth (not used in range calc;
|
||||||
|
# retained for time-bandwidth product / display)
|
||||||
|
chirp_duration_s: float = 30e-6 # Long chirp ramp time
|
||||||
|
pri_s: float = 167e-6 # Pulse repetition interval (chirp + listen)
|
||||||
|
center_freq_hz: float = 10.5e9 # Carrier frequency (radar_scene.py: F_CARRIER)
|
||||||
|
n_range_bins: int = 64 # After decimation
|
||||||
|
n_doppler_bins: int = 32 # Total Doppler bins (2 sub-frames x 16)
|
||||||
|
chirps_per_subframe: int = 16 # Chirps in one Doppler sub-frame
|
||||||
|
fft_size: int = 1024 # Pre-decimation FFT length
|
||||||
|
decimation_factor: int = 16 # 1024 → 64
|
||||||
|
|
||||||
|
@property
|
||||||
|
def range_resolution_m(self) -> float:
|
||||||
|
"""Meters per decimated range bin (matched-filter pulse compression).
|
||||||
|
|
||||||
|
For FFT-based matched filtering, each IFFT output bin spans
|
||||||
|
c / (2 * Fs) in range, where Fs is the I/Q sample rate at the
|
||||||
|
matched-filter input (DDC output). After decimation the bin
|
||||||
|
spacing grows by *decimation_factor*.
|
||||||
|
"""
|
||||||
|
c = 299_792_458.0
|
||||||
|
raw_bin = c / (2.0 * self.sample_rate_hz)
|
||||||
|
return raw_bin * self.decimation_factor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def velocity_resolution_mps(self) -> float:
|
||||||
|
"""m/s per Doppler bin.
|
||||||
|
|
||||||
|
lambda / (2 * chirps_per_subframe * PRI), matching radar_scene.py.
|
||||||
|
"""
|
||||||
|
c = 299_792_458.0
|
||||||
|
wavelength = c / self.center_freq_hz
|
||||||
|
return wavelength / (2.0 * self.chirps_per_subframe * self.pri_s)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_range_m(self) -> float:
|
||||||
|
"""Maximum unambiguous range in meters."""
|
||||||
|
return self.range_resolution_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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
range_resolution: 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``.
|
||||||
|
range_resolution : float
|
||||||
|
Meters per range bin.
|
||||||
|
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) * range_resolution
|
||||||
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
@@ -368,7 +334,7 @@ class TargetSimulator(QObject):
|
|||||||
self._add_random_target()
|
self._add_random_target()
|
||||||
|
|
||||||
def _add_random_target(self):
|
def _add_random_target(self):
|
||||||
range_m = random.uniform(5000, 40000)
|
range_m = random.uniform(50, 1400)
|
||||||
azimuth = random.uniform(0, 360)
|
azimuth = random.uniform(0, 360)
|
||||||
velocity = random.uniform(-100, 100)
|
velocity = random.uniform(-100, 100)
|
||||||
elevation = random.uniform(-5, 45)
|
elevation = random.uniform(-5, 45)
|
||||||
@@ -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 < 10 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,
|
||||||
|
range_resolution=self._waveform.range_resolution_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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ def parse_python_data_packet_fields(filepath: Path | None = None) -> list[DataPa
|
|||||||
width_bits=size * 8
|
width_bits=size * 8
|
||||||
))
|
))
|
||||||
|
|
||||||
# Match detection = raw[9] & 0x01
|
# Match detection = raw[9] & 0x01 (direct access)
|
||||||
for m in re.finditer(r'(\w+)\s*=\s*raw\[(\d+)\]\s*&\s*(0x[0-9a-fA-F]+|\d+)', body):
|
for m in re.finditer(r'(\w+)\s*=\s*raw\[(\d+)\]\s*&\s*(0x[0-9a-fA-F]+|\d+)', body):
|
||||||
name = m.group(1)
|
name = m.group(1)
|
||||||
offset = int(m.group(2))
|
offset = int(m.group(2))
|
||||||
@@ -196,6 +196,24 @@ def parse_python_data_packet_fields(filepath: Path | None = None) -> list[DataPa
|
|||||||
name=name, byte_start=offset, byte_end=offset, width_bits=1
|
name=name, byte_start=offset, byte_end=offset, width_bits=1
|
||||||
))
|
))
|
||||||
|
|
||||||
|
# Match intermediate variable pattern: var = raw[N], then field = var & MASK
|
||||||
|
for m in re.finditer(r'(\w+)\s*=\s*raw\[(\d+)\]', body):
|
||||||
|
var_name = m.group(1)
|
||||||
|
offset = int(m.group(2))
|
||||||
|
# Find fields derived from this intermediate variable
|
||||||
|
for m2 in re.finditer(
|
||||||
|
rf'(\w+)\s*=\s*(?:\({var_name}\s*>>\s*\d+\)\s*&|{var_name}\s*&)\s*'
|
||||||
|
r'(0x[0-9a-fA-F]+|\d+)',
|
||||||
|
body,
|
||||||
|
):
|
||||||
|
name = m2.group(1)
|
||||||
|
# Skip if already captured by direct raw[] access pattern
|
||||||
|
if not any(f.name == name for f in fields):
|
||||||
|
fields.append(DataPacketField(
|
||||||
|
name=name, byte_start=offset, byte_end=offset,
|
||||||
|
width_bits=1
|
||||||
|
))
|
||||||
|
|
||||||
fields.sort(key=lambda f: f.byte_start)
|
fields.sort(key=lambda f: f.byte_start)
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
@@ -583,12 +601,28 @@ def parse_verilog_data_mux(
|
|||||||
|
|
||||||
for m in re.finditer(
|
for m in re.finditer(
|
||||||
r"5'd(\d+)\s*:\s*data_pkt_byte\s*=\s*(.+?);",
|
r"5'd(\d+)\s*:\s*data_pkt_byte\s*=\s*(.+?);",
|
||||||
mux_body
|
mux_body, re.DOTALL
|
||||||
):
|
):
|
||||||
idx = int(m.group(1))
|
idx = int(m.group(1))
|
||||||
expr = m.group(2).strip()
|
expr = m.group(2).strip()
|
||||||
entries.append((idx, expr))
|
entries.append((idx, expr))
|
||||||
|
|
||||||
|
# Helper: extract the dominant signal name from a mux expression.
|
||||||
|
# Handles direct refs like ``range_profile_cap[31:24]``, ternaries
|
||||||
|
# like ``stream_doppler_en ? doppler_real_cap[15:8] : 8'd0``, and
|
||||||
|
# concat-ternaries like ``stream_cfar_en ? {…, cfar_detection_cap} : …``.
|
||||||
|
def _extract_signal(expr: str) -> str | None:
|
||||||
|
# If it's a ternary, use the true-branch to find the data signal
|
||||||
|
tern = re.match(r'\w+\s*\?\s*(.+?)\s*:\s*.+', expr, re.DOTALL)
|
||||||
|
target = tern.group(1) if tern else expr
|
||||||
|
# Look for a known data signal (xxx_cap pattern or cfar_detection_cap)
|
||||||
|
cap_match = re.search(r'(\w+_cap)\b', target)
|
||||||
|
if cap_match:
|
||||||
|
return cap_match.group(1)
|
||||||
|
# Fall back to first identifier before a bit-select
|
||||||
|
sig_match = re.match(r'(\w+?)(?:\[|$)', target)
|
||||||
|
return sig_match.group(1) if sig_match else None
|
||||||
|
|
||||||
# Group consecutive bytes by signal root name
|
# Group consecutive bytes by signal root name
|
||||||
fields: list[DataPacketField] = []
|
fields: list[DataPacketField] = []
|
||||||
i = 0
|
i = 0
|
||||||
@@ -598,22 +632,21 @@ def parse_verilog_data_mux(
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract signal name (e.g., range_profile_cap from range_profile_cap[31:24])
|
signal = _extract_signal(expr)
|
||||||
sig_match = re.match(r'(\w+?)(?:\[|$)', expr)
|
if not signal:
|
||||||
if not sig_match:
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
signal = sig_match.group(1)
|
|
||||||
start_byte = idx
|
start_byte = idx
|
||||||
end_byte = idx
|
end_byte = idx
|
||||||
|
|
||||||
# Find consecutive bytes of the same signal
|
# Find consecutive bytes of the same signal
|
||||||
j = i + 1
|
j = i + 1
|
||||||
while j < len(entries):
|
while j < len(entries):
|
||||||
next_idx, next_expr = entries[j]
|
_next_idx, next_expr = entries[j]
|
||||||
if next_expr.startswith(signal):
|
next_sig = _extract_signal(next_expr)
|
||||||
end_byte = next_idx
|
if next_sig == signal:
|
||||||
|
end_byte = _next_idx
|
||||||
j += 1
|
j += 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -620,8 +620,10 @@ module tb_cross_layer_ft2232h;
|
|||||||
"Data pkt: byte 7 = 0x56 (doppler_imag MSB)");
|
"Data pkt: byte 7 = 0x56 (doppler_imag MSB)");
|
||||||
check(captured_bytes[8] === 8'h78,
|
check(captured_bytes[8] === 8'h78,
|
||||||
"Data pkt: byte 8 = 0x78 (doppler_imag LSB)");
|
"Data pkt: byte 8 = 0x78 (doppler_imag LSB)");
|
||||||
check(captured_bytes[9] === 8'h01,
|
// Byte 9 = {frame_start, 6'b0, cfar_detection}
|
||||||
"Data pkt: byte 9 = 0x01 (cfar_detection=1)");
|
// After reset sample_counter==0, so frame_start=1 → 0x81
|
||||||
|
check(captured_bytes[9] === 8'h81,
|
||||||
|
"Data pkt: byte 9 = 0x81 (frame_start=1, cfar_detection=1)");
|
||||||
check(captured_bytes[10] === 8'h55,
|
check(captured_bytes[10] === 8'h55,
|
||||||
"Data pkt: byte 10 = 0x55 (footer)");
|
"Data pkt: byte 10 = 0x55 (footer)");
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -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
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ The AERIS-10 main sub-systems are:
|
|||||||
- Map integration
|
- Map integration
|
||||||
- Radar control interface
|
- Radar control interface
|
||||||
|
|
||||||

|

|
||||||
|
<!-- V6 GIF removed — V6 is deprecated. V65 Tk and V7 PyQt6 are the active GUIs. -->
|
||||||
|
|
||||||
## 📊 Technical Specifications
|
## 📊 Technical Specifications
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="stats-grid">
|
<section class="stats-grid">
|
||||||
|
<article class="card stat notice">
|
||||||
|
<h2>Production Board USB</h2>
|
||||||
|
<p class="metric">FT2232H (USB 2.0)</p>
|
||||||
|
<p class="muted">50T production board uses FT2232H. FT601 USB 3.0 is available on 200T premium dev board only.</p>
|
||||||
|
</article>
|
||||||
<article class="card stat">
|
<article class="card stat">
|
||||||
<h2>Tracked Timing Baseline</h2>
|
<h2>Tracked Timing Baseline</h2>
|
||||||
<p class="metric">WNS +0.058 ns</p>
|
<p class="metric">WNS +0.058 ns</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user