Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b54c04272f | |||
| ce61b71cf4 | |||
| bbaf1e3436 | |||
| 4578621c75 | |||
| 8901894b6c | |||
| e6e2217b76 | |||
| cc9ab27d44 | |||
| 56d0ea2883 | |||
| b394f6bc49 |
@@ -46,9 +46,7 @@ jobs:
|
|||||||
- name: Unit tests
|
- name: Unit tests
|
||||||
run: >
|
run: >
|
||||||
uv run pytest
|
uv run pytest
|
||||||
9_Firmware/9_3_GUI/test_GUI_V65_Tk.py
|
9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short
|
||||||
9_Firmware/9_3_GUI/test_v7.py
|
|
||||||
-v --tb=short
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# MCU Firmware Unit Tests (20 tests)
|
# MCU Firmware Unit Tests (20 tests)
|
||||||
@@ -113,4 +111,5 @@ jobs:
|
|||||||
run: >
|
run: >
|
||||||
uv run pytest
|
uv run pytest
|
||||||
9_Firmware/tests/cross_layer/test_cross_layer_contract.py
|
9_Firmware/tests/cross_layer/test_cross_layer_contract.py
|
||||||
|
9_Firmware/tests/cross_layer/test_mem_validation.py
|
||||||
-v --tb=short
|
-v --tb=short
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
|
|
||||||
# Define parameters
|
|
||||||
fs = 120e6 # Sampling frequency
|
|
||||||
Ts = 1 / fs # Sampling time
|
|
||||||
Tb = 1e-6 # Burst time
|
|
||||||
Tau = 30e-6 # Pulse repetition time
|
|
||||||
fmax = 15e6 # Maximum frequency on ramp
|
|
||||||
fmin = 1e6 # Minimum frequency on ramp
|
|
||||||
|
|
||||||
# Compute number of samples per ramp
|
|
||||||
n = int(Tb / Ts)
|
|
||||||
N = np.arange(0, n, 1)
|
|
||||||
|
|
||||||
# Compute instantaneous phase
|
|
||||||
theta_n = 2 * np.pi * ((N**2 * Ts**2 * (fmax - fmin) / (2 * Tb)) + fmin * N * Ts)
|
|
||||||
|
|
||||||
# Generate waveform and scale it to 8-bit unsigned values (0 to 255)
|
|
||||||
y = 1 + np.sin(theta_n) # Normalize from 0 to 2
|
|
||||||
y_scaled = np.round(y * 127.5).astype(int) # Scale to 8-bit range (0-255)
|
|
||||||
|
|
||||||
# Print values in Verilog-friendly format
|
|
||||||
for _i in range(n):
|
|
||||||
pass
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
// ADAR1000_AGC.cpp -- STM32 outer-loop AGC implementation
|
|
||||||
//
|
|
||||||
// See ADAR1000_AGC.h for architecture overview.
|
|
||||||
|
|
||||||
#include "ADAR1000_AGC.h"
|
|
||||||
#include "ADAR1000_Manager.h"
|
|
||||||
#include "diag_log.h"
|
|
||||||
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Constructor -- set all config fields to safe defaults
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
ADAR1000_AGC::ADAR1000_AGC()
|
|
||||||
: agc_base_gain(ADAR1000Manager::kDefaultRxVgaGain) // 30
|
|
||||||
, gain_step_down(4)
|
|
||||||
, gain_step_up(1)
|
|
||||||
, min_gain(0)
|
|
||||||
, max_gain(127)
|
|
||||||
, holdoff_frames(4)
|
|
||||||
, enabled(true)
|
|
||||||
, holdoff_counter(0)
|
|
||||||
, last_saturated(false)
|
|
||||||
, saturation_event_count(0)
|
|
||||||
{
|
|
||||||
memset(cal_offset, 0, sizeof(cal_offset));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// update -- called once per frame with the FPGA DIG_5 saturation flag
|
|
||||||
//
|
|
||||||
// Returns true if agc_base_gain changed (caller should then applyGain).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
void ADAR1000_AGC::update(bool fpga_saturation)
|
|
||||||
{
|
|
||||||
if (!enabled)
|
|
||||||
return;
|
|
||||||
|
|
||||||
last_saturated = fpga_saturation;
|
|
||||||
|
|
||||||
if (fpga_saturation) {
|
|
||||||
// Attack: reduce gain immediately
|
|
||||||
saturation_event_count++;
|
|
||||||
holdoff_counter = 0;
|
|
||||||
|
|
||||||
if (agc_base_gain >= gain_step_down + min_gain) {
|
|
||||||
agc_base_gain -= gain_step_down;
|
|
||||||
} else {
|
|
||||||
agc_base_gain = min_gain;
|
|
||||||
}
|
|
||||||
|
|
||||||
DIAG("AGC", "SAT detected -- gain_base -> %u (events=%lu)",
|
|
||||||
(unsigned)agc_base_gain, (unsigned long)saturation_event_count);
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Recovery: wait for holdoff, then increase gain
|
|
||||||
holdoff_counter++;
|
|
||||||
|
|
||||||
if (holdoff_counter >= holdoff_frames) {
|
|
||||||
holdoff_counter = 0;
|
|
||||||
|
|
||||||
if (agc_base_gain + gain_step_up <= max_gain) {
|
|
||||||
agc_base_gain += gain_step_up;
|
|
||||||
} else {
|
|
||||||
agc_base_gain = max_gain;
|
|
||||||
}
|
|
||||||
|
|
||||||
DIAG("AGC", "Recovery step -- gain_base -> %u", (unsigned)agc_base_gain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// applyGain -- write effective gain to all 16 RX VGA channels
|
|
||||||
//
|
|
||||||
// Uses the Manager's adarSetRxVgaGain which takes 1-based channel indices
|
|
||||||
// (matching the convention in setBeamAngle).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
void ADAR1000_AGC::applyGain(ADAR1000Manager &mgr)
|
|
||||||
{
|
|
||||||
for (uint8_t dev = 0; dev < AGC_NUM_DEVICES; ++dev) {
|
|
||||||
for (uint8_t ch = 0; ch < AGC_NUM_CHANNELS; ++ch) {
|
|
||||||
uint8_t gain = effectiveGain(dev * AGC_NUM_CHANNELS + ch);
|
|
||||||
// Channel parameter is 1-based per Manager convention
|
|
||||||
mgr.adarSetRxVgaGain(dev, ch + 1, gain, BROADCAST_OFF);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// resetState -- clear runtime counters, preserve configuration
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
void ADAR1000_AGC::resetState()
|
|
||||||
{
|
|
||||||
holdoff_counter = 0;
|
|
||||||
last_saturated = false;
|
|
||||||
saturation_event_count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// effectiveGain -- compute clamped per-channel gain
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
uint8_t ADAR1000_AGC::effectiveGain(uint8_t channel_index) const
|
|
||||||
{
|
|
||||||
if (channel_index >= AGC_TOTAL_CHANNELS)
|
|
||||||
return min_gain; // safety fallback — OOB channels get minimum gain
|
|
||||||
|
|
||||||
int16_t raw = static_cast<int16_t>(agc_base_gain) + cal_offset[channel_index];
|
|
||||||
|
|
||||||
if (raw < static_cast<int16_t>(min_gain))
|
|
||||||
return min_gain;
|
|
||||||
if (raw > static_cast<int16_t>(max_gain))
|
|
||||||
return max_gain;
|
|
||||||
|
|
||||||
return static_cast<uint8_t>(raw);
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
// ADAR1000_AGC.h -- STM32 outer-loop AGC for ADAR1000 RX VGA gain
|
|
||||||
//
|
|
||||||
// Adjusts the analog VGA common-mode gain on each ADAR1000 RX channel based on
|
|
||||||
// the FPGA's saturation flag (DIG_5 / PD13). Runs once per radar frame
|
|
||||||
// (~258 ms) in the main loop, after runRadarPulseSequence().
|
|
||||||
//
|
|
||||||
// Architecture:
|
|
||||||
// - Inner loop (FPGA, per-sample): rx_gain_control auto-adjusts digital
|
|
||||||
// gain_shift based on peak magnitude / saturation. Range ±42 dB.
|
|
||||||
// - Outer loop (THIS MODULE, per-frame): reads FPGA DIG_5 GPIO. If
|
|
||||||
// saturation detected, reduces agc_base_gain immediately (attack). If no
|
|
||||||
// saturation for holdoff_frames, increases agc_base_gain (decay/recovery).
|
|
||||||
//
|
|
||||||
// Per-channel gain formula:
|
|
||||||
// VGA[dev][ch] = clamp(agc_base_gain + cal_offset[dev*4+ch], min_gain, max_gain)
|
|
||||||
//
|
|
||||||
// The cal_offset array allows per-element calibration to correct inter-channel
|
|
||||||
// gain imbalance. Default is all zeros (uniform gain).
|
|
||||||
|
|
||||||
#ifndef ADAR1000_AGC_H
|
|
||||||
#define ADAR1000_AGC_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
|
|
||||||
// Forward-declare to avoid pulling in the full ADAR1000_Manager header here.
|
|
||||||
// The .cpp includes the real header.
|
|
||||||
class ADAR1000Manager;
|
|
||||||
|
|
||||||
// Number of ADAR1000 devices
|
|
||||||
#define AGC_NUM_DEVICES 4
|
|
||||||
// Number of channels per ADAR1000
|
|
||||||
#define AGC_NUM_CHANNELS 4
|
|
||||||
// Total RX channels
|
|
||||||
#define AGC_TOTAL_CHANNELS (AGC_NUM_DEVICES * AGC_NUM_CHANNELS)
|
|
||||||
|
|
||||||
class ADAR1000_AGC {
|
|
||||||
public:
|
|
||||||
// --- Configuration (public for easy field-testing / GUI override) ---
|
|
||||||
|
|
||||||
// Common-mode base gain (raw ADAR1000 register value, 0-255).
|
|
||||||
// Default matches ADAR1000Manager::kDefaultRxVgaGain = 30.
|
|
||||||
uint8_t agc_base_gain;
|
|
||||||
|
|
||||||
// Per-channel calibration offset (signed, added to agc_base_gain).
|
|
||||||
// Index = device*4 + channel. Default: all 0.
|
|
||||||
int8_t cal_offset[AGC_TOTAL_CHANNELS];
|
|
||||||
|
|
||||||
// How much to decrease agc_base_gain per frame when saturated (attack).
|
|
||||||
uint8_t gain_step_down;
|
|
||||||
|
|
||||||
// How much to increase agc_base_gain per frame when recovering (decay).
|
|
||||||
uint8_t gain_step_up;
|
|
||||||
|
|
||||||
// Minimum allowed agc_base_gain (floor).
|
|
||||||
uint8_t min_gain;
|
|
||||||
|
|
||||||
// Maximum allowed agc_base_gain (ceiling).
|
|
||||||
uint8_t max_gain;
|
|
||||||
|
|
||||||
// Number of consecutive non-saturated frames required before gain-up.
|
|
||||||
uint8_t holdoff_frames;
|
|
||||||
|
|
||||||
// Master enable. When false, update() is a no-op.
|
|
||||||
bool enabled;
|
|
||||||
|
|
||||||
// --- Runtime state (read-only for diagnostics) ---
|
|
||||||
|
|
||||||
// Consecutive non-saturated frame counter (resets on saturation).
|
|
||||||
uint8_t holdoff_counter;
|
|
||||||
|
|
||||||
// True if the last update() saw saturation.
|
|
||||||
bool last_saturated;
|
|
||||||
|
|
||||||
// Total saturation events since reset/construction.
|
|
||||||
uint32_t saturation_event_count;
|
|
||||||
|
|
||||||
// --- Methods ---
|
|
||||||
|
|
||||||
ADAR1000_AGC();
|
|
||||||
|
|
||||||
// Call once per frame after runRadarPulseSequence().
|
|
||||||
// fpga_saturation: result of HAL_GPIO_ReadPin(GPIOD, GPIO_PIN_13) == GPIO_PIN_SET
|
|
||||||
void update(bool fpga_saturation);
|
|
||||||
|
|
||||||
// Apply the current gain to all 16 RX VGA channels via the Manager.
|
|
||||||
void applyGain(ADAR1000Manager &mgr);
|
|
||||||
|
|
||||||
// Reset runtime state (holdoff counter, saturation count) without
|
|
||||||
// changing configuration.
|
|
||||||
void resetState();
|
|
||||||
|
|
||||||
// Compute the effective gain for a specific channel index (0-15),
|
|
||||||
// clamped to [min_gain, max_gain]. Useful for diagnostics.
|
|
||||||
uint8_t effectiveGain(uint8_t channel_index) const;
|
|
||||||
};
|
|
||||||
|
|
||||||
#endif // ADAR1000_AGC_H
|
|
||||||
@@ -7,8 +7,8 @@ RadarSettings::RadarSettings() {
|
|||||||
|
|
||||||
void RadarSettings::resetToDefaults() {
|
void RadarSettings::resetToDefaults() {
|
||||||
system_frequency = 10.0e9; // 10 GHz
|
system_frequency = 10.0e9; // 10 GHz
|
||||||
chirp_duration_1 = 30.0e-6; // 30 �s
|
chirp_duration_1 = 30.0e-6; // 30 us
|
||||||
chirp_duration_2 = 0.5e-6; // 0.5 �s
|
chirp_duration_2 = 0.5e-6; // 0.5 us
|
||||||
chirps_per_position = 32;
|
chirps_per_position = 32;
|
||||||
freq_min = 10.0e6; // 10 MHz
|
freq_min = 10.0e6; // 10 MHz
|
||||||
freq_max = 30.0e6; // 30 MHz
|
freq_max = 30.0e6; // 30 MHz
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ void USBHandler::processStartFlag(const uint8_t* data, uint32_t length) {
|
|||||||
// Start flag: bytes [23, 46, 158, 237]
|
// Start flag: bytes [23, 46, 158, 237]
|
||||||
const uint8_t START_FLAG[] = {23, 46, 158, 237};
|
const uint8_t START_FLAG[] = {23, 46, 158, 237};
|
||||||
|
|
||||||
// Guard: need at least 4 bytes to contain a start flag.
|
|
||||||
// Without this, length - 4 wraps to ~4 billion (uint32_t unsigned underflow)
|
|
||||||
// and the loop reads far past the buffer boundary.
|
|
||||||
if (length < 4) return;
|
|
||||||
|
|
||||||
// Check if start flag is in the received data
|
// Check if start flag is in the received data
|
||||||
for (uint32_t i = 0; i <= length - 4; i++) {
|
for (uint32_t i = 0; i <= length - 4; i++) {
|
||||||
if (memcmp(data + i, START_FLAG, 4) == 0) {
|
if (memcmp(data + i, START_FLAG, 4) == 0) {
|
||||||
|
|||||||
@@ -23,7 +23,6 @@
|
|||||||
#include "usbd_cdc_if.h"
|
#include "usbd_cdc_if.h"
|
||||||
#include "adar1000.h"
|
#include "adar1000.h"
|
||||||
#include "ADAR1000_Manager.h"
|
#include "ADAR1000_Manager.h"
|
||||||
#include "ADAR1000_AGC.h"
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include "ad9523.h"
|
#include "ad9523.h"
|
||||||
}
|
}
|
||||||
@@ -46,9 +45,7 @@ extern "C" {
|
|||||||
#include <vector>
|
#include <vector>
|
||||||
#include "stm32_spi.h"
|
#include "stm32_spi.h"
|
||||||
#include "stm32_delay.h"
|
#include "stm32_delay.h"
|
||||||
extern "C" {
|
#include "TinyGPSPlus.h"
|
||||||
#include "um982_gps.h"
|
|
||||||
}
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#include "GY_85_HAL.h"
|
#include "GY_85_HAL.h"
|
||||||
}
|
}
|
||||||
@@ -123,8 +120,8 @@ UART_HandleTypeDef huart5;
|
|||||||
UART_HandleTypeDef huart3;
|
UART_HandleTypeDef huart3;
|
||||||
|
|
||||||
/* USER CODE BEGIN PV */
|
/* USER CODE BEGIN PV */
|
||||||
// UM982 dual-antenna GPS receiver
|
// The TinyGPSPlus object
|
||||||
UM982_GPS_t um982;
|
TinyGPSPlus gps;
|
||||||
|
|
||||||
// Global data structures
|
// Global data structures
|
||||||
GPS_Data_t current_gps_data = {0};
|
GPS_Data_t current_gps_data = {0};
|
||||||
@@ -175,7 +172,7 @@ float RADAR_Altitude;
|
|||||||
double RADAR_Longitude = 0;
|
double RADAR_Longitude = 0;
|
||||||
double RADAR_Latitude = 0;
|
double RADAR_Latitude = 0;
|
||||||
|
|
||||||
extern uint8_t GUI_start_flag_received; // [STM32-006] Legacy, unused -- kept for linker compat
|
extern uint8_t GUI_start_flag_received;
|
||||||
|
|
||||||
|
|
||||||
//RADAR
|
//RADAR
|
||||||
@@ -227,7 +224,6 @@ extern SPI_HandleTypeDef hspi4;
|
|||||||
//ADAR1000
|
//ADAR1000
|
||||||
|
|
||||||
ADAR1000Manager adarManager;
|
ADAR1000Manager adarManager;
|
||||||
ADAR1000_AGC outerAgc;
|
|
||||||
static uint8_t matrix1[15][16];
|
static uint8_t matrix1[15][16];
|
||||||
static uint8_t matrix2[15][16];
|
static uint8_t matrix2[15][16];
|
||||||
static uint8_t vector_0[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
|
static uint8_t vector_0[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
|
||||||
@@ -622,8 +618,7 @@ 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;
|
||||||
@@ -634,27 +629,6 @@ 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) {
|
||||||
@@ -665,7 +639,6 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) {
|
if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) {
|
||||||
current_error = ERROR_AD9523_CLOCK;
|
current_error = ERROR_AD9523_CLOCK;
|
||||||
DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1);
|
DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1);
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
last_clock_check = HAL_GetTick();
|
last_clock_check = HAL_GetTick();
|
||||||
}
|
}
|
||||||
@@ -676,12 +649,10 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (!tx_locked) {
|
if (!tx_locked) {
|
||||||
current_error = ERROR_ADF4382_TX_UNLOCK;
|
current_error = ERROR_ADF4382_TX_UNLOCK;
|
||||||
DIAG_ERR("LO", "Health check: TX LO UNLOCKED");
|
DIAG_ERR("LO", "Health check: TX LO UNLOCKED");
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
if (!rx_locked) {
|
if (!rx_locked) {
|
||||||
current_error = ERROR_ADF4382_RX_UNLOCK;
|
current_error = ERROR_ADF4382_RX_UNLOCK;
|
||||||
DIAG_ERR("LO", "Health check: RX LO UNLOCKED");
|
DIAG_ERR("LO", "Health check: RX LO UNLOCKED");
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,14 +661,14 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (!adarManager.verifyDeviceCommunication(i)) {
|
if (!adarManager.verifyDeviceCommunication(i)) {
|
||||||
current_error = ERROR_ADAR1000_COMM;
|
current_error = ERROR_ADAR1000_COMM;
|
||||||
DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i);
|
DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i);
|
||||||
return current_error;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
float temp = adarManager.readTemperature(i);
|
float temp = adarManager.readTemperature(i);
|
||||||
if (temp > 85.0f) {
|
if (temp > 85.0f) {
|
||||||
current_error = ERROR_ADAR1000_TEMP;
|
current_error = ERROR_ADAR1000_TEMP;
|
||||||
DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp);
|
DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp);
|
||||||
return current_error;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +678,6 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (!GY85_Update(&imu)) {
|
if (!GY85_Update(&imu)) {
|
||||||
current_error = ERROR_IMU_COMM;
|
current_error = ERROR_IMU_COMM;
|
||||||
DIAG_ERR("IMU", "Health check: GY85_Update() FAILED");
|
DIAG_ERR("IMU", "Health check: GY85_Update() FAILED");
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
last_imu_check = HAL_GetTick();
|
last_imu_check = HAL_GetTick();
|
||||||
}
|
}
|
||||||
@@ -719,17 +689,18 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) {
|
if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) {
|
||||||
current_error = ERROR_BMP180_COMM;
|
current_error = ERROR_BMP180_COMM;
|
||||||
DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure);
|
DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure);
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
last_bmp_check = HAL_GetTick();
|
last_bmp_check = HAL_GetTick();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Check GPS Communication (30s grace period from boot / last valid fix)
|
// 6. Check GPS Communication
|
||||||
uint32_t gps_fix_age = um982_position_age(&um982);
|
static uint32_t last_gps_fix = 0;
|
||||||
if (gps_fix_age > 30000) {
|
if (gps.location.isUpdated()) {
|
||||||
|
last_gps_fix = HAL_GetTick();
|
||||||
|
}
|
||||||
|
if (HAL_GetTick() - last_gps_fix > 30000) {
|
||||||
current_error = ERROR_GPS_COMM;
|
current_error = ERROR_GPS_COMM;
|
||||||
DIAG_WARN("SYS", "Health check: GPS no fix for >30s (age=%lu ms)", (unsigned long)gps_fix_age);
|
DIAG_WARN("SYS", "Health check: GPS no fix for >30s");
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Check RF Power Amplifier Current
|
// 7. Check RF Power Amplifier Current
|
||||||
@@ -738,12 +709,12 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (Idq_reading[i] > 2.5f) {
|
if (Idq_reading[i] > 2.5f) {
|
||||||
current_error = ERROR_RF_PA_OVERCURRENT;
|
current_error = ERROR_RF_PA_OVERCURRENT;
|
||||||
DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]);
|
DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]);
|
||||||
return current_error;
|
break;
|
||||||
}
|
}
|
||||||
if (Idq_reading[i] < 0.1f) {
|
if (Idq_reading[i] < 0.1f) {
|
||||||
current_error = ERROR_RF_PA_BIAS;
|
current_error = ERROR_RF_PA_BIAS;
|
||||||
DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]);
|
DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]);
|
||||||
return current_error;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -752,10 +723,15 @@ SystemError_t checkSystemHealth(void) {
|
|||||||
if (temperature > 75.0f) {
|
if (temperature > 75.0f) {
|
||||||
current_error = ERROR_TEMPERATURE_HIGH;
|
current_error = ERROR_TEMPERATURE_HIGH;
|
||||||
DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature);
|
DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature);
|
||||||
return current_error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Watchdog check is performed at function entry (see step 0).
|
// 9. Simple watchdog check
|
||||||
|
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)");
|
||||||
|
}
|
||||||
|
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);
|
||||||
@@ -867,7 +843,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];
|
||||||
static const char* const error_strings[] = {
|
const char* error_strings[] = {
|
||||||
"No error",
|
"No error",
|
||||||
"AD9523 Clock failure",
|
"AD9523 Clock failure",
|
||||||
"ADF4382 TX LO unlocked",
|
"ADF4382 TX LO unlocked",
|
||||||
@@ -887,16 +863,9 @@ 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, err_name, error_count);
|
error, error_strings[error], 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
|
||||||
@@ -906,23 +875,9 @@ 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) {
|
||||||
// Safety-critical range: any fault that can damage the PAs or leave the
|
DIAG_ERR("SYS", "CRITICAL ERROR (code %d: %s) -- initiating Emergency_Stop()", error, error_strings[error]);
|
||||||
// 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);
|
||||||
@@ -964,41 +919,38 @@ bool checkSystemHealthStatus(void) {
|
|||||||
// Get system status for GUI
|
// Get system status for GUI
|
||||||
// Get system status for GUI with 8 temperature variables
|
// Get system status for GUI with 8 temperature variables
|
||||||
void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
||||||
// Build status string directly in the output buffer using offset-tracked
|
char temp_buffer[200];
|
||||||
// snprintf. Each call returns the number of chars written (excluding NUL),
|
char final_status[500] = "System Status: ";
|
||||||
// so we advance 'off' and shrink 'rem' to guarantee we never overflow.
|
|
||||||
size_t off = 0;
|
|
||||||
size_t rem = buffer_size;
|
|
||||||
int w;
|
|
||||||
|
|
||||||
// Basic status
|
// Basic status
|
||||||
if (system_emergency_state) {
|
if (system_emergency_state) {
|
||||||
w = snprintf(status_buffer + off, rem, "System Status: EMERGENCY_STOP|");
|
strcat(final_status, "EMERGENCY_STOP|");
|
||||||
} else {
|
} else {
|
||||||
w = snprintf(status_buffer + off, rem, "System Status: NORMAL|");
|
strcat(final_status, "NORMAL|");
|
||||||
}
|
}
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
|
||||||
|
|
||||||
// Error information
|
// Error information
|
||||||
w = snprintf(status_buffer + off, rem, "LastError:%d|ErrorCount:%lu|",
|
snprintf(temp_buffer, sizeof(temp_buffer), "LastError:%d|ErrorCount:%lu|",
|
||||||
last_error, error_count);
|
last_error, error_count);
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
strcat(final_status, temp_buffer);
|
||||||
|
|
||||||
// Sensor status
|
// Sensor status
|
||||||
w = snprintf(status_buffer + off, rem, "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|",
|
snprintf(temp_buffer, sizeof(temp_buffer), "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|",
|
||||||
Pitch_Sensor, Roll_Sensor, Yaw_Sensor,
|
Pitch_Sensor, Roll_Sensor, Yaw_Sensor,
|
||||||
RADAR_Latitude, RADAR_Longitude, RADAR_Altitude);
|
RADAR_Latitude, RADAR_Longitude, RADAR_Altitude);
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
strcat(final_status, temp_buffer);
|
||||||
|
|
||||||
// LO Status
|
// LO Status
|
||||||
bool tx_locked, rx_locked;
|
bool tx_locked, rx_locked;
|
||||||
ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked);
|
ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked);
|
||||||
w = snprintf(status_buffer + off, rem, "LO_TX:%s|LO_RX:%s|",
|
snprintf(temp_buffer, sizeof(temp_buffer), "LO_TX:%s|LO_RX:%s|",
|
||||||
tx_locked ? "LOCKED" : "UNLOCKED",
|
tx_locked ? "LOCKED" : "UNLOCKED",
|
||||||
rx_locked ? "LOCKED" : "UNLOCKED");
|
rx_locked ? "LOCKED" : "UNLOCKED");
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
strcat(final_status, temp_buffer);
|
||||||
|
|
||||||
// Temperature readings (8 variables)
|
// Temperature readings (8 variables)
|
||||||
|
// You'll need to populate these temperature values from your sensors
|
||||||
|
// For now, I'll show how to format them - replace with actual temperature readings
|
||||||
Temperature_1 = ADS7830_Measure_SingleEnded(&hadc3, 0);
|
Temperature_1 = ADS7830_Measure_SingleEnded(&hadc3, 0);
|
||||||
Temperature_2 = ADS7830_Measure_SingleEnded(&hadc3, 1);
|
Temperature_2 = ADS7830_Measure_SingleEnded(&hadc3, 1);
|
||||||
Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2);
|
Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2);
|
||||||
@@ -1009,11 +961,11 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
|||||||
Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7);
|
Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7);
|
||||||
|
|
||||||
// Format all 8 temperature variables
|
// Format all 8 temperature variables
|
||||||
w = snprintf(status_buffer + off, rem,
|
snprintf(temp_buffer, sizeof(temp_buffer),
|
||||||
"T1:%.1f|T2:%.1f|T3:%.1f|T4:%.1f|T5:%.1f|T6:%.1f|T7:%.1f|T8:%.1f|",
|
"T1:%.1f|T2:%.1f|T3:%.1f|T4:%.1f|T5:%.1f|T6:%.1f|T7:%.1f|T8:%.1f|",
|
||||||
Temperature_1, Temperature_2, Temperature_3, Temperature_4,
|
Temperature_1, Temperature_2, Temperature_3, Temperature_4,
|
||||||
Temperature_5, Temperature_6, Temperature_7, Temperature_8);
|
Temperature_5, Temperature_6, Temperature_7, Temperature_8);
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
strcat(final_status, temp_buffer);
|
||||||
|
|
||||||
// RF Power Amplifier status (if enabled)
|
// RF Power Amplifier status (if enabled)
|
||||||
if (PowerAmplifier) {
|
if (PowerAmplifier) {
|
||||||
@@ -1023,17 +975,18 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
|||||||
}
|
}
|
||||||
avg_current /= 16.0f;
|
avg_current /= 16.0f;
|
||||||
|
|
||||||
w = snprintf(status_buffer + off, rem, "PA_AvgCurrent:%.2f|PA_Enabled:%d|",
|
snprintf(temp_buffer, sizeof(temp_buffer), "PA_AvgCurrent:%.2f|PA_Enabled:%d|",
|
||||||
avg_current, PowerAmplifier);
|
avg_current, PowerAmplifier);
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
strcat(final_status, temp_buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Radar operation status
|
// Radar operation status
|
||||||
w = snprintf(status_buffer + off, rem, "BeamPos:%d|Azimuth:%d|ChirpCount:%d|",
|
snprintf(temp_buffer, sizeof(temp_buffer), "BeamPos:%d|Azimuth:%d|ChirpCount:%d|",
|
||||||
n, y, m);
|
n, y, m);
|
||||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
strcat(final_status, temp_buffer);
|
||||||
|
|
||||||
// NUL termination guaranteed by snprintf, but be safe
|
// Copy to output buffer
|
||||||
|
strncpy(status_buffer, final_status, buffer_size - 1);
|
||||||
status_buffer[buffer_size - 1] = '\0';
|
status_buffer[buffer_size - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,7 +1008,20 @@ static inline void delay_ms(uint32_t ms) { HAL_Delay(ms); }
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// smartDelay removed -- replaced by non-blocking um982_process() in main loop
|
// This custom version of delay() ensures that the gps object
|
||||||
|
// is being "fed".
|
||||||
|
static void smartDelay(unsigned long ms)
|
||||||
|
{
|
||||||
|
uint32_t start = HAL_GetTick();
|
||||||
|
uint8_t ch;
|
||||||
|
|
||||||
|
do {
|
||||||
|
// While there is new data available in UART (non-blocking)
|
||||||
|
if (HAL_UART_Receive(&huart5, &ch, 1, 0) == HAL_OK) {
|
||||||
|
gps.encode(ch); // Pass received byte to TinyGPS++ equivalent parser
|
||||||
|
}
|
||||||
|
} while (HAL_GetTick() - start < ms);
|
||||||
|
}
|
||||||
|
|
||||||
// Small helper to enable DWT cycle counter for microdelay
|
// Small helper to enable DWT cycle counter for microdelay
|
||||||
static void DWT_Init(void)
|
static void DWT_Init(void)
|
||||||
@@ -1199,14 +1165,7 @@ static int configure_ad9523(void)
|
|||||||
|
|
||||||
// init ad9523 defaults (fills any missing pdata defaults)
|
// init ad9523 defaults (fills any missing pdata defaults)
|
||||||
DIAG("CLK", "Calling ad9523_init() -- fills pdata defaults");
|
DIAG("CLK", "Calling ad9523_init() -- fills pdata defaults");
|
||||||
{
|
ad9523_init(&init_param);
|
||||||
int32_t init_ret = ad9523_init(&init_param);
|
|
||||||
DIAG("CLK", "ad9523_init() returned %ld", (long)init_ret);
|
|
||||||
if (init_ret != 0) {
|
|
||||||
DIAG_ERR("CLK", "ad9523_init() FAILED (ret=%ld)", (long)init_ret);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* [Bug #2 FIXED] Removed first ad9523_setup() call that was here.
|
/* [Bug #2 FIXED] Removed first ad9523_setup() call that was here.
|
||||||
* It wrote to the chip while still in reset — writes were lost.
|
* It wrote to the chip while still in reset — writes were lost.
|
||||||
@@ -1595,12 +1554,6 @@ int main(void)
|
|||||||
Yaw_Sensor = (180*atan2(magRawY,magRawX)/PI) - Mag_Declination;
|
Yaw_Sensor = (180*atan2(magRawY,magRawX)/PI) - Mag_Declination;
|
||||||
|
|
||||||
if(Yaw_Sensor<0)Yaw_Sensor+=360;
|
if(Yaw_Sensor<0)Yaw_Sensor+=360;
|
||||||
|
|
||||||
// Override magnetometer heading with UM982 dual-antenna heading when available
|
|
||||||
if (um982_is_heading_valid(&um982)) {
|
|
||||||
Yaw_Sensor = um982_get_heading(&um982);
|
|
||||||
}
|
|
||||||
|
|
||||||
RxEst_0 = RxEst_1;
|
RxEst_0 = RxEst_1;
|
||||||
RyEst_0 = RyEst_1;
|
RyEst_0 = RyEst_1;
|
||||||
RzEst_0 = RzEst_1;
|
RzEst_0 = RzEst_1;
|
||||||
@@ -1776,34 +1729,10 @@ int main(void)
|
|||||||
//////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////
|
||||||
//////////////////////////////////////////GPS/////////////////////////////////////////
|
//////////////////////////////////////////GPS/////////////////////////////////////////
|
||||||
//////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////
|
||||||
DIAG_SECTION("GPS INIT (UM982)");
|
for(int i=0; i<10;i++){
|
||||||
DIAG("GPS", "Initializing UM982 on UART5 @ 115200 (baseline=50cm, tol=3cm)");
|
smartDelay(1000);
|
||||||
if (!um982_init(&um982, &huart5, 50.0f, 3.0f)) {
|
RADAR_Longitude = gps.location.lng();
|
||||||
DIAG_WARN("GPS", "UM982 init: no VERSIONA response -- module may need more time");
|
RADAR_Latitude = gps.location.lat();
|
||||||
// Not fatal: module may still start sending NMEA data after boot
|
|
||||||
} else {
|
|
||||||
DIAG("GPS", "UM982 init OK -- VERSIONA received");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect GPS data for a few seconds (non-blocking pump)
|
|
||||||
DIAG("GPS", "Pumping GPS for 5 seconds to acquire initial fix...");
|
|
||||||
{
|
|
||||||
uint32_t gps_start = HAL_GetTick();
|
|
||||||
while (HAL_GetTick() - gps_start < 5000) {
|
|
||||||
um982_process(&um982);
|
|
||||||
HAL_Delay(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
RADAR_Longitude = um982_get_longitude(&um982);
|
|
||||||
RADAR_Latitude = um982_get_latitude(&um982);
|
|
||||||
DIAG("GPS", "Initial position: lat=%.6f lon=%.6f fix=%d sats=%d",
|
|
||||||
RADAR_Latitude, RADAR_Longitude,
|
|
||||||
um982_get_fix_quality(&um982), um982_get_num_sats(&um982));
|
|
||||||
|
|
||||||
// Re-apply heading after GPS init so the north-alignment stepper move uses
|
|
||||||
// UM982 dual-antenna heading when available.
|
|
||||||
if (um982_is_heading_valid(&um982)) {
|
|
||||||
Yaw_Sensor = um982_get_heading(&um982);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//move Stepper to position 1 = 0°
|
//move Stepper to position 1 = 0°
|
||||||
@@ -1829,11 +1758,29 @@ int main(void)
|
|||||||
HAL_UART_Transmit(&huart3, (uint8_t*)gps_send_error, sizeof(gps_send_error) - 1, 1000);
|
HAL_UART_Transmit(&huart3, (uint8_t*)gps_send_error, sizeof(gps_send_error) - 1, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* [STM32-006 FIXED] Removed blocking do-while loop that waited for
|
// Check if start flag was received and settings are ready
|
||||||
* usbHandler.isStartFlagReceived(). The production V7 PyQt GUI does not
|
do{
|
||||||
* send the legacy 4-byte start flag [23,46,158,237], so this loop hung
|
if (usbHandler.isStartFlagReceived() &&
|
||||||
* the MCU at boot indefinitely. The USB settings handshake (if ever
|
usbHandler.getState() == USBHandler::USBState::READY_FOR_DATA) {
|
||||||
* re-enabled) should be handled non-blocking in the main loop. */
|
|
||||||
|
const RadarSettings& settings = usbHandler.getSettings();
|
||||||
|
|
||||||
|
// Use the settings to configure your radar system
|
||||||
|
/*
|
||||||
|
settings.getSystemFrequency();
|
||||||
|
settings.getChirpDuration1();
|
||||||
|
settings.getChirpDuration2();
|
||||||
|
settings.getChirpsPerPosition();
|
||||||
|
settings.getFreqMin();
|
||||||
|
settings.getFreqMax();
|
||||||
|
settings.getPRF1();
|
||||||
|
settings.getPRF2();
|
||||||
|
settings.getMaxDistance();
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}while(!usbHandler.isStartFlagReceived());
|
||||||
|
|
||||||
/***************************************************************/
|
/***************************************************************/
|
||||||
/************RF Power Amplifier Powering up sequence************/
|
/************RF Power Amplifier Powering up sequence************/
|
||||||
@@ -2048,28 +1995,15 @@ int main(void)
|
|||||||
HAL_UART_Transmit(&huart3, (uint8_t*)emergency_msg, strlen(emergency_msg), 1000);
|
HAL_UART_Transmit(&huart3, (uint8_t*)emergency_msg, strlen(emergency_msg), 1000);
|
||||||
DIAG_ERR("SYS", "SAFE MODE ACTIVE -- blinking all LEDs, waiting for system_emergency_state clear");
|
DIAG_ERR("SYS", "SAFE MODE ACTIVE -- blinking all LEDs, waiting for system_emergency_state clear");
|
||||||
|
|
||||||
// Blink all LEDs to indicate safe mode (500ms period, visible to operator)
|
// Blink all LEDs to indicate safe mode
|
||||||
while (system_emergency_state) {
|
while (system_emergency_state) {
|
||||||
HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin);
|
HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin);
|
||||||
HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin);
|
HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin);
|
||||||
HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
|
HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
|
||||||
HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin);
|
HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin);
|
||||||
HAL_Delay(250);
|
|
||||||
}
|
}
|
||||||
DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared");
|
DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared");
|
||||||
}
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////// GPS: Non-blocking NMEA processing ////////////////////////
|
|
||||||
//////////////////////////////////////////////////////////////////////////////////////
|
|
||||||
um982_process(&um982);
|
|
||||||
|
|
||||||
// Update position globals continuously
|
|
||||||
if (um982_is_position_valid(&um982)) {
|
|
||||||
RADAR_Latitude = um982_get_latitude(&um982);
|
|
||||||
RADAR_Longitude = um982_get_longitude(&um982);
|
|
||||||
}
|
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////// Monitor ADF4382A lock status periodically//////////////////
|
////////////////////////// Monitor ADF4382A lock status periodically//////////////////
|
||||||
//////////////////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -2180,16 +2114,6 @@ int main(void)
|
|||||||
|
|
||||||
runRadarPulseSequence();
|
runRadarPulseSequence();
|
||||||
|
|
||||||
/* [AGC] Outer-loop AGC: read FPGA saturation flag (DIG_5 / PD13),
|
|
||||||
* adjust ADAR1000 VGA common gain once per radar frame (~258 ms).
|
|
||||||
* Only run when AGC is enabled — otherwise leave VGA gains untouched. */
|
|
||||||
if (outerAgc.enabled) {
|
|
||||||
bool sat = HAL_GPIO_ReadPin(FPGA_DIG5_SAT_GPIO_Port,
|
|
||||||
FPGA_DIG5_SAT_Pin) == GPIO_PIN_SET;
|
|
||||||
outerAgc.update(sat);
|
|
||||||
outerAgc.applyGain(adarManager);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* [GAP-3 FIX 2] Kick hardware watchdog — if we don't reach here within
|
/* [GAP-3 FIX 2] Kick hardware watchdog — if we don't reach here within
|
||||||
* ~4 s, the IWDG resets the MCU automatically. */
|
* ~4 s, the IWDG resets the MCU automatically. */
|
||||||
HAL_IWDG_Refresh(&hiwdg);
|
HAL_IWDG_Refresh(&hiwdg);
|
||||||
@@ -2620,7 +2544,7 @@ static void MX_UART5_Init(void)
|
|||||||
|
|
||||||
/* USER CODE END UART5_Init 1 */
|
/* USER CODE END UART5_Init 1 */
|
||||||
huart5.Instance = UART5;
|
huart5.Instance = UART5;
|
||||||
huart5.Init.BaudRate = 115200;
|
huart5.Init.BaudRate = 9600;
|
||||||
huart5.Init.WordLength = UART_WORDLENGTH_8B;
|
huart5.Init.WordLength = UART_WORDLENGTH_8B;
|
||||||
huart5.Init.StopBits = UART_STOPBITS_1;
|
huart5.Init.StopBits = UART_STOPBITS_1;
|
||||||
huart5.Init.Parity = UART_PARITY_NONE;
|
huart5.Init.Parity = UART_PARITY_NONE;
|
||||||
|
|||||||
@@ -141,15 +141,6 @@ void Error_Handler(void);
|
|||||||
#define EN_DIS_RFPA_VDD_GPIO_Port GPIOD
|
#define EN_DIS_RFPA_VDD_GPIO_Port GPIOD
|
||||||
#define EN_DIS_COOLING_Pin GPIO_PIN_7
|
#define EN_DIS_COOLING_Pin GPIO_PIN_7
|
||||||
#define EN_DIS_COOLING_GPIO_Port GPIOD
|
#define EN_DIS_COOLING_GPIO_Port GPIOD
|
||||||
|
|
||||||
/* FPGA digital I/O (directly connected GPIOs) */
|
|
||||||
#define FPGA_DIG5_SAT_Pin GPIO_PIN_13
|
|
||||||
#define FPGA_DIG5_SAT_GPIO_Port GPIOD
|
|
||||||
#define FPGA_DIG6_Pin GPIO_PIN_14
|
|
||||||
#define FPGA_DIG6_GPIO_Port GPIOD
|
|
||||||
#define FPGA_DIG7_Pin GPIO_PIN_15
|
|
||||||
#define FPGA_DIG7_GPIO_Port GPIOD
|
|
||||||
|
|
||||||
#define ADF4382_RX_CE_Pin GPIO_PIN_9
|
#define ADF4382_RX_CE_Pin GPIO_PIN_9
|
||||||
#define ADF4382_RX_CE_GPIO_Port GPIOG
|
#define ADF4382_RX_CE_GPIO_Port GPIOG
|
||||||
#define ADF4382_RX_CS_Pin GPIO_PIN_10
|
#define ADF4382_RX_CS_Pin GPIO_PIN_10
|
||||||
|
|||||||
@@ -1,586 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* um982_gps.c -- UM982 dual-antenna GNSS receiver driver implementation
|
|
||||||
*
|
|
||||||
* See um982_gps.h for API documentation.
|
|
||||||
* Command syntax per Unicore N4 Command Reference EN R1.14.
|
|
||||||
******************************************************************************/
|
|
||||||
#include "um982_gps.h"
|
|
||||||
#include <string.h>
|
|
||||||
#include <stdlib.h>
|
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
/* ========================= Internal helpers ========================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Advance to the next comma-delimited field in an NMEA sentence.
|
|
||||||
* Returns pointer to the start of the next field (after the comma),
|
|
||||||
* or NULL if no more commas found before end-of-string or '*'.
|
|
||||||
*
|
|
||||||
* Handles empty fields (consecutive commas) correctly by returning
|
|
||||||
* a pointer to the character after the comma (which may be another comma).
|
|
||||||
*/
|
|
||||||
static const char *next_field(const char *p)
|
|
||||||
{
|
|
||||||
if (p == NULL) return NULL;
|
|
||||||
while (*p != '\0' && *p != ',' && *p != '*') {
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
if (*p == ',') return p + 1;
|
|
||||||
return NULL; /* End of sentence or checksum marker */
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the length of the current field (up to next comma, '*', or '\0').
|
|
||||||
*/
|
|
||||||
static int field_len(const char *p)
|
|
||||||
{
|
|
||||||
int len = 0;
|
|
||||||
if (p == NULL) return 0;
|
|
||||||
while (p[len] != '\0' && p[len] != ',' && p[len] != '*') {
|
|
||||||
len++;
|
|
||||||
}
|
|
||||||
return len;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a field is non-empty (has at least one character before delimiter).
|
|
||||||
*/
|
|
||||||
static bool field_valid(const char *p)
|
|
||||||
{
|
|
||||||
return p != NULL && field_len(p) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a floating-point value from a field, returning 0.0 if empty.
|
|
||||||
*/
|
|
||||||
static double field_to_double(const char *p)
|
|
||||||
{
|
|
||||||
if (!field_valid(p)) return 0.0;
|
|
||||||
return strtod(p, NULL);
|
|
||||||
}
|
|
||||||
|
|
||||||
static float field_to_float(const char *p)
|
|
||||||
{
|
|
||||||
return (float)field_to_double(p);
|
|
||||||
}
|
|
||||||
|
|
||||||
static int field_to_int(const char *p)
|
|
||||||
{
|
|
||||||
if (!field_valid(p)) return 0;
|
|
||||||
return (int)strtol(p, NULL, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Checksum ================================== */
|
|
||||||
|
|
||||||
bool um982_verify_checksum(const char *sentence)
|
|
||||||
{
|
|
||||||
if (sentence == NULL || sentence[0] != '$') return false;
|
|
||||||
|
|
||||||
const char *p = sentence + 1; /* Skip '$' */
|
|
||||||
uint8_t computed = 0;
|
|
||||||
|
|
||||||
while (*p != '\0' && *p != '*') {
|
|
||||||
computed ^= (uint8_t)*p;
|
|
||||||
p++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (*p != '*') return false; /* No checksum marker found */
|
|
||||||
p++; /* Skip '*' */
|
|
||||||
|
|
||||||
/* Parse 2-char hex checksum */
|
|
||||||
if (p[0] == '\0' || p[1] == '\0') return false;
|
|
||||||
|
|
||||||
char hex_str[3] = { p[0], p[1], '\0' };
|
|
||||||
unsigned long expected = strtoul(hex_str, NULL, 16);
|
|
||||||
|
|
||||||
return computed == (uint8_t)expected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Coordinate parsing ======================== */
|
|
||||||
|
|
||||||
double um982_parse_coord(const char *field, char hemisphere)
|
|
||||||
{
|
|
||||||
if (field == NULL || field[0] == '\0') return NAN;
|
|
||||||
|
|
||||||
/* Find the decimal point to determine degree digit count.
|
|
||||||
* Latitude: ddmm.mmmm (dot at index 4, degrees = 2)
|
|
||||||
* Longitude: dddmm.mmmm (dot at index 5, degrees = 3)
|
|
||||||
* General: degree_digits = dot_position - 2
|
|
||||||
*/
|
|
||||||
const char *dot = strchr(field, '.');
|
|
||||||
if (dot == NULL) return NAN;
|
|
||||||
|
|
||||||
int dot_pos = (int)(dot - field);
|
|
||||||
int deg_digits = dot_pos - 2;
|
|
||||||
|
|
||||||
if (deg_digits < 1 || deg_digits > 3) return NAN;
|
|
||||||
|
|
||||||
/* Extract degree portion */
|
|
||||||
double degrees = 0.0;
|
|
||||||
for (int i = 0; i < deg_digits; i++) {
|
|
||||||
if (field[i] < '0' || field[i] > '9') return NAN;
|
|
||||||
degrees = degrees * 10.0 + (field[i] - '0');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Extract minutes portion (everything from deg_digits onward) */
|
|
||||||
double minutes = strtod(field + deg_digits, NULL);
|
|
||||||
if (minutes < 0.0 || minutes >= 60.0) return NAN;
|
|
||||||
|
|
||||||
double result = degrees + minutes / 60.0;
|
|
||||||
|
|
||||||
/* Apply hemisphere sign */
|
|
||||||
if (hemisphere == 'S' || hemisphere == 'W') {
|
|
||||||
result = -result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Sentence parsers ========================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identify the NMEA sentence type by skipping the 2-char talker ID
|
|
||||||
* and comparing the 3-letter formatter.
|
|
||||||
*
|
|
||||||
* "$GNGGA,..." -> talker="GN", formatter="GGA"
|
|
||||||
* "$GPTHS,..." -> talker="GP", formatter="THS"
|
|
||||||
*
|
|
||||||
* Returns pointer to the formatter (3 chars at sentence+3), or NULL
|
|
||||||
* if sentence is too short.
|
|
||||||
*/
|
|
||||||
static const char *get_formatter(const char *sentence)
|
|
||||||
{
|
|
||||||
/* sentence starts with '$', followed by 2-char talker + 3-char formatter */
|
|
||||||
if (sentence == NULL || strlen(sentence) < 6) return NULL;
|
|
||||||
return sentence + 3; /* Skip "$XX" -> points to formatter */
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse GGA sentence — position and fix quality.
|
|
||||||
*
|
|
||||||
* Format: $--GGA,time,lat,N/S,lon,E/W,quality,numSat,hdop,alt,M,geoidSep,M,dgpsAge,refID*XX
|
|
||||||
* field: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
|
||||||
*/
|
|
||||||
static void parse_gga(UM982_GPS_t *gps, const char *sentence)
|
|
||||||
{
|
|
||||||
/* Skip to first field (after "$XXGGA,") */
|
|
||||||
const char *f = strchr(sentence, ',');
|
|
||||||
if (f == NULL) return;
|
|
||||||
f++; /* f -> field 1 (time) */
|
|
||||||
|
|
||||||
/* Field 1: UTC time — skip for now */
|
|
||||||
const char *f2 = next_field(f); /* lat */
|
|
||||||
const char *f3 = next_field(f2); /* N/S */
|
|
||||||
const char *f4 = next_field(f3); /* lon */
|
|
||||||
const char *f5 = next_field(f4); /* E/W */
|
|
||||||
const char *f6 = next_field(f5); /* quality */
|
|
||||||
const char *f7 = next_field(f6); /* numSat */
|
|
||||||
const char *f8 = next_field(f7); /* hdop */
|
|
||||||
const char *f9 = next_field(f8); /* altitude */
|
|
||||||
const char *f10 = next_field(f9); /* M */
|
|
||||||
const char *f11 = next_field(f10); /* geoid sep */
|
|
||||||
|
|
||||||
uint32_t now = HAL_GetTick();
|
|
||||||
|
|
||||||
/* Parse fix quality first — if 0, position is meaningless */
|
|
||||||
gps->fix_quality = (uint8_t)field_to_int(f6);
|
|
||||||
|
|
||||||
/* Parse coordinates */
|
|
||||||
if (field_valid(f2) && field_valid(f3)) {
|
|
||||||
char hem = field_valid(f3) ? *f3 : 'N';
|
|
||||||
double lat = um982_parse_coord(f2, hem);
|
|
||||||
if (!isnan(lat)) gps->latitude = lat;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (field_valid(f4) && field_valid(f5)) {
|
|
||||||
char hem = field_valid(f5) ? *f5 : 'E';
|
|
||||||
double lon = um982_parse_coord(f4, hem);
|
|
||||||
if (!isnan(lon)) gps->longitude = lon;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Number of satellites */
|
|
||||||
gps->num_satellites = (uint8_t)field_to_int(f7);
|
|
||||||
|
|
||||||
/* HDOP */
|
|
||||||
if (field_valid(f8)) {
|
|
||||||
gps->hdop = field_to_float(f8);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Altitude */
|
|
||||||
if (field_valid(f9)) {
|
|
||||||
gps->altitude = field_to_float(f9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Geoid separation */
|
|
||||||
if (field_valid(f11)) {
|
|
||||||
gps->geoid_sep = field_to_float(f11);
|
|
||||||
}
|
|
||||||
|
|
||||||
gps->last_gga_tick = now;
|
|
||||||
if (gps->fix_quality != UM982_FIX_NONE) {
|
|
||||||
gps->last_fix_tick = now;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse RMC sentence — recommended minimum (position, speed, date).
|
|
||||||
*
|
|
||||||
* Format: $--RMC,time,status,lat,N/S,lon,E/W,speed,course,date,magVar,E/W,mode*XX
|
|
||||||
* field: 1 2 3 4 5 6 7 8 9 10 11 12
|
|
||||||
*/
|
|
||||||
static void parse_rmc(UM982_GPS_t *gps, const char *sentence)
|
|
||||||
{
|
|
||||||
const char *f = strchr(sentence, ',');
|
|
||||||
if (f == NULL) return;
|
|
||||||
f++; /* f -> field 1 (time) */
|
|
||||||
|
|
||||||
const char *f2 = next_field(f); /* status */
|
|
||||||
const char *f3 = next_field(f2); /* lat */
|
|
||||||
const char *f4 = next_field(f3); /* N/S */
|
|
||||||
const char *f5 = next_field(f4); /* lon */
|
|
||||||
const char *f6 = next_field(f5); /* E/W */
|
|
||||||
const char *f7 = next_field(f6); /* speed knots */
|
|
||||||
const char *f8 = next_field(f7); /* course true */
|
|
||||||
|
|
||||||
/* Status */
|
|
||||||
if (field_valid(f2)) {
|
|
||||||
gps->rmc_status = *f2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Position (only if status = A for valid) */
|
|
||||||
if (field_valid(f2) && *f2 == 'A') {
|
|
||||||
if (field_valid(f3) && field_valid(f4)) {
|
|
||||||
double lat = um982_parse_coord(f3, *f4);
|
|
||||||
if (!isnan(lat)) gps->latitude = lat;
|
|
||||||
}
|
|
||||||
if (field_valid(f5) && field_valid(f6)) {
|
|
||||||
double lon = um982_parse_coord(f5, *f6);
|
|
||||||
if (!isnan(lon)) gps->longitude = lon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Speed (knots) */
|
|
||||||
if (field_valid(f7)) {
|
|
||||||
gps->speed_knots = field_to_float(f7);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Course */
|
|
||||||
if (field_valid(f8)) {
|
|
||||||
gps->course_true = field_to_float(f8);
|
|
||||||
}
|
|
||||||
|
|
||||||
gps->last_rmc_tick = HAL_GetTick();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse THS sentence — true heading and status (UM982-specific).
|
|
||||||
*
|
|
||||||
* Format: $--THS,heading,mode*XX
|
|
||||||
* field: 1 2
|
|
||||||
*/
|
|
||||||
static void parse_ths(UM982_GPS_t *gps, const char *sentence)
|
|
||||||
{
|
|
||||||
const char *f = strchr(sentence, ',');
|
|
||||||
if (f == NULL) return;
|
|
||||||
f++; /* f -> field 1 (heading) */
|
|
||||||
|
|
||||||
const char *f2 = next_field(f); /* mode */
|
|
||||||
|
|
||||||
/* Heading */
|
|
||||||
if (field_valid(f)) {
|
|
||||||
gps->heading = field_to_float(f);
|
|
||||||
} else {
|
|
||||||
gps->heading = NAN;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mode */
|
|
||||||
if (field_valid(f2)) {
|
|
||||||
gps->heading_mode = *f2;
|
|
||||||
} else {
|
|
||||||
gps->heading_mode = 'V'; /* Not valid if missing */
|
|
||||||
}
|
|
||||||
|
|
||||||
gps->last_ths_tick = HAL_GetTick();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse VTG sentence — course and speed over ground.
|
|
||||||
*
|
|
||||||
* Format: $--VTG,courseTrue,T,courseMag,M,speedKnots,N,speedKmh,K,mode*XX
|
|
||||||
* field: 1 2 3 4 5 6 7 8 9
|
|
||||||
*/
|
|
||||||
static void parse_vtg(UM982_GPS_t *gps, const char *sentence)
|
|
||||||
{
|
|
||||||
const char *f = strchr(sentence, ',');
|
|
||||||
if (f == NULL) return;
|
|
||||||
f++; /* f -> field 1 (course true) */
|
|
||||||
|
|
||||||
const char *f2 = next_field(f); /* T */
|
|
||||||
const char *f3 = next_field(f2); /* course mag */
|
|
||||||
const char *f4 = next_field(f3); /* M */
|
|
||||||
const char *f5 = next_field(f4); /* speed knots */
|
|
||||||
const char *f6 = next_field(f5); /* N */
|
|
||||||
const char *f7 = next_field(f6); /* speed km/h */
|
|
||||||
|
|
||||||
/* Course true */
|
|
||||||
if (field_valid(f)) {
|
|
||||||
gps->course_true = field_to_float(f);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Speed knots */
|
|
||||||
if (field_valid(f5)) {
|
|
||||||
gps->speed_knots = field_to_float(f5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Speed km/h */
|
|
||||||
if (field_valid(f7)) {
|
|
||||||
gps->speed_kmh = field_to_float(f7);
|
|
||||||
}
|
|
||||||
|
|
||||||
gps->last_vtg_tick = HAL_GetTick();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Sentence dispatch ========================= */
|
|
||||||
|
|
||||||
void um982_parse_sentence(UM982_GPS_t *gps, const char *sentence)
|
|
||||||
{
|
|
||||||
if (sentence == NULL || sentence[0] != '$') return;
|
|
||||||
|
|
||||||
/* Verify checksum before parsing */
|
|
||||||
if (!um982_verify_checksum(sentence)) return;
|
|
||||||
|
|
||||||
/* Check for VERSIONA response (starts with '#', not '$') -- handled separately */
|
|
||||||
/* Actually VERSIONA starts with '#', so it won't enter here. We check in feed(). */
|
|
||||||
|
|
||||||
/* Identify sentence type */
|
|
||||||
const char *fmt = get_formatter(sentence);
|
|
||||||
if (fmt == NULL) return;
|
|
||||||
|
|
||||||
if (strncmp(fmt, "GGA", 3) == 0) {
|
|
||||||
gps->initialized = true;
|
|
||||||
parse_gga(gps, sentence);
|
|
||||||
} else if (strncmp(fmt, "RMC", 3) == 0) {
|
|
||||||
gps->initialized = true;
|
|
||||||
parse_rmc(gps, sentence);
|
|
||||||
} else if (strncmp(fmt, "THS", 3) == 0) {
|
|
||||||
gps->initialized = true;
|
|
||||||
parse_ths(gps, sentence);
|
|
||||||
} else if (strncmp(fmt, "VTG", 3) == 0) {
|
|
||||||
gps->initialized = true;
|
|
||||||
parse_vtg(gps, sentence);
|
|
||||||
}
|
|
||||||
/* Other sentences silently ignored */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Command interface ========================= */
|
|
||||||
|
|
||||||
bool um982_send_command(UM982_GPS_t *gps, const char *cmd)
|
|
||||||
{
|
|
||||||
if (gps == NULL || gps->huart == NULL || cmd == NULL) return false;
|
|
||||||
|
|
||||||
/* Build command with \r\n termination */
|
|
||||||
char buf[UM982_CMD_BUF_SIZE];
|
|
||||||
int len = snprintf(buf, sizeof(buf), "%s\r\n", cmd);
|
|
||||||
if (len <= 0 || (size_t)len >= sizeof(buf)) return false;
|
|
||||||
|
|
||||||
HAL_StatusTypeDef status = HAL_UART_Transmit(
|
|
||||||
gps->huart, (const uint8_t *)buf, (uint16_t)len, 100);
|
|
||||||
|
|
||||||
return status == HAL_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Line assembly + feed ====================== */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a completed line from the line buffer.
|
|
||||||
*/
|
|
||||||
static void process_line(UM982_GPS_t *gps, const char *line)
|
|
||||||
{
|
|
||||||
if (line == NULL || line[0] == '\0') return;
|
|
||||||
|
|
||||||
/* NMEA sentence starts with '$' */
|
|
||||||
if (line[0] == '$') {
|
|
||||||
um982_parse_sentence(gps, line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Unicore proprietary response starts with '#' (e.g. #VERSIONA) */
|
|
||||||
if (line[0] == '#') {
|
|
||||||
if (strncmp(line + 1, "VERSIONA", 8) == 0) {
|
|
||||||
gps->version_received = true;
|
|
||||||
gps->initialized = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void um982_feed(UM982_GPS_t *gps, const uint8_t *data, uint16_t len)
|
|
||||||
{
|
|
||||||
if (gps == NULL || data == NULL || len == 0) return;
|
|
||||||
|
|
||||||
for (uint16_t i = 0; i < len; i++) {
|
|
||||||
uint8_t ch = data[i];
|
|
||||||
|
|
||||||
/* End of line: process if we have content */
|
|
||||||
if (ch == '\n' || ch == '\r') {
|
|
||||||
if (gps->line_len > 0 && !gps->line_overflow) {
|
|
||||||
gps->line_buf[gps->line_len] = '\0';
|
|
||||||
process_line(gps, gps->line_buf);
|
|
||||||
}
|
|
||||||
gps->line_len = 0;
|
|
||||||
gps->line_overflow = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Accumulate into line buffer */
|
|
||||||
if (gps->line_len < UM982_LINE_BUF_SIZE - 1) {
|
|
||||||
gps->line_buf[gps->line_len++] = (char)ch;
|
|
||||||
} else {
|
|
||||||
gps->line_overflow = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= UART process (production) ================= */
|
|
||||||
|
|
||||||
void um982_process(UM982_GPS_t *gps)
|
|
||||||
{
|
|
||||||
if (gps == NULL || gps->huart == NULL) return;
|
|
||||||
|
|
||||||
/* Read all available bytes from the UART one at a time.
|
|
||||||
* At 115200 baud (~11.5 KB/s) and a typical main-loop period of ~10 ms,
|
|
||||||
* we expect ~115 bytes per call — negligible overhead on a 168 MHz STM32.
|
|
||||||
*
|
|
||||||
* Note: batch reads (HAL_UART_Receive with Size > 1 and Timeout = 0) are
|
|
||||||
* NOT safe here because the HAL consumes bytes from the data register as
|
|
||||||
* it reads them. If fewer than Size bytes are available, the consumed
|
|
||||||
* bytes are lost (HAL_TIMEOUT is returned and the caller has no way to
|
|
||||||
* know how many bytes were actually placed into the buffer). */
|
|
||||||
uint8_t ch;
|
|
||||||
while (HAL_UART_Receive(gps->huart, &ch, 1, 0) == HAL_OK) {
|
|
||||||
um982_feed(gps, &ch, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Validity checks =========================== */
|
|
||||||
|
|
||||||
bool um982_is_heading_valid(const UM982_GPS_t *gps)
|
|
||||||
{
|
|
||||||
if (gps == NULL) return false;
|
|
||||||
if (isnan(gps->heading)) return false;
|
|
||||||
|
|
||||||
/* Mode must be Autonomous or Differential */
|
|
||||||
if (gps->heading_mode != 'A' && gps->heading_mode != 'D') return false;
|
|
||||||
|
|
||||||
/* Check age */
|
|
||||||
uint32_t age = HAL_GetTick() - gps->last_ths_tick;
|
|
||||||
return age < UM982_HEADING_TIMEOUT_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool um982_is_position_valid(const UM982_GPS_t *gps)
|
|
||||||
{
|
|
||||||
if (gps == NULL) return false;
|
|
||||||
if (gps->fix_quality == UM982_FIX_NONE) return false;
|
|
||||||
|
|
||||||
/* Check age of the last valid fix */
|
|
||||||
uint32_t age = HAL_GetTick() - gps->last_fix_tick;
|
|
||||||
return age < UM982_POSITION_TIMEOUT_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t um982_heading_age(const UM982_GPS_t *gps)
|
|
||||||
{
|
|
||||||
if (gps == NULL) return UINT32_MAX;
|
|
||||||
return HAL_GetTick() - gps->last_ths_tick;
|
|
||||||
}
|
|
||||||
|
|
||||||
uint32_t um982_position_age(const UM982_GPS_t *gps)
|
|
||||||
{
|
|
||||||
if (gps == NULL) return UINT32_MAX;
|
|
||||||
return HAL_GetTick() - gps->last_fix_tick;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Initialization ============================ */
|
|
||||||
|
|
||||||
bool um982_init(UM982_GPS_t *gps, UART_HandleTypeDef *huart,
|
|
||||||
float baseline_cm, float tolerance_cm)
|
|
||||||
{
|
|
||||||
if (gps == NULL || huart == NULL) return false;
|
|
||||||
|
|
||||||
/* Zero-init entire structure */
|
|
||||||
memset(gps, 0, sizeof(UM982_GPS_t));
|
|
||||||
|
|
||||||
gps->huart = huart;
|
|
||||||
gps->heading = NAN;
|
|
||||||
gps->heading_mode = 'V';
|
|
||||||
gps->rmc_status = 'V';
|
|
||||||
gps->speed_knots = 0.0f;
|
|
||||||
|
|
||||||
/* Seed fix timestamp so position_age() returns ~0 instead of uptime.
|
|
||||||
* Gives the module a full 30s grace window from init to acquire a fix
|
|
||||||
* before the health check fires ERROR_GPS_COMM. */
|
|
||||||
gps->last_fix_tick = HAL_GetTick();
|
|
||||||
gps->speed_kmh = 0.0f;
|
|
||||||
gps->course_true = 0.0f;
|
|
||||||
|
|
||||||
/* Step 1: Stop all current output to get a clean slate */
|
|
||||||
um982_send_command(gps, "UNLOG");
|
|
||||||
HAL_Delay(100);
|
|
||||||
|
|
||||||
/* Step 2: Configure heading mode
|
|
||||||
* Per N4 Reference 4.18: CONFIG HEADING FIXLENGTH (default mode)
|
|
||||||
* "The distance between ANT1 and ANT2 is fixed. They move synchronously." */
|
|
||||||
um982_send_command(gps, "CONFIG HEADING FIXLENGTH");
|
|
||||||
HAL_Delay(50);
|
|
||||||
|
|
||||||
/* Step 3: Set baseline length if specified
|
|
||||||
* Per N4 Reference: CONFIG HEADING LENGTH <cm> <tolerance_cm>
|
|
||||||
* "parameter1: Fixed baseline length (cm), valid range >= 0"
|
|
||||||
* "parameter2: Tolerable error margin (cm), valid range > 0" */
|
|
||||||
if (baseline_cm > 0.0f) {
|
|
||||||
char cmd[64];
|
|
||||||
if (tolerance_cm > 0.0f) {
|
|
||||||
snprintf(cmd, sizeof(cmd), "CONFIG HEADING LENGTH %.0f %.0f",
|
|
||||||
baseline_cm, tolerance_cm);
|
|
||||||
} else {
|
|
||||||
snprintf(cmd, sizeof(cmd), "CONFIG HEADING LENGTH %.0f",
|
|
||||||
baseline_cm);
|
|
||||||
}
|
|
||||||
um982_send_command(gps, cmd);
|
|
||||||
HAL_Delay(50);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Step 4: Enable NMEA output sentences on COM2.
|
|
||||||
* Per N4 Reference: "When requesting NMEA messages, users should add GP
|
|
||||||
* before each command name"
|
|
||||||
*
|
|
||||||
* We target COM2 because the ELT0213 board (GNSS.STORE) exposes COM2
|
|
||||||
* (RXD2/TXD2) on its 12-pin JST connector (pins 5 & 6). The STM32
|
|
||||||
* UART5 (PC12-TX, PD2-RX) connects to these pins via JP8.
|
|
||||||
* COM2 defaults to 115200 baud — matching our UART5 config. */
|
|
||||||
um982_send_command(gps, "GPGGA COM2 1"); /* GGA at 1 Hz */
|
|
||||||
HAL_Delay(50);
|
|
||||||
um982_send_command(gps, "GPRMC COM2 1"); /* RMC at 1 Hz */
|
|
||||||
HAL_Delay(50);
|
|
||||||
um982_send_command(gps, "GPTHS COM2 0.2"); /* THS at 5 Hz (heading primary) */
|
|
||||||
HAL_Delay(50);
|
|
||||||
|
|
||||||
/* Step 5: Skip SAVECONFIG -- NMEA config is re-sent every boot anyway.
|
|
||||||
* Saving to NVM on every power cycle would wear flash. If persistent
|
|
||||||
* config is needed, call um982_send_command(gps, "SAVECONFIG") once
|
|
||||||
* during commissioning. */
|
|
||||||
|
|
||||||
/* Step 6: Query version to verify communication */
|
|
||||||
gps->version_received = false;
|
|
||||||
um982_send_command(gps, "VERSIONA");
|
|
||||||
|
|
||||||
/* Wait for VERSIONA response (non-blocking poll) */
|
|
||||||
uint32_t start = HAL_GetTick();
|
|
||||||
while (!gps->version_received &&
|
|
||||||
(HAL_GetTick() - start) < UM982_INIT_TIMEOUT_MS) {
|
|
||||||
um982_process(gps);
|
|
||||||
HAL_Delay(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
gps->initialized = gps->version_received;
|
|
||||||
return gps->initialized;
|
|
||||||
}
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* um982_gps.h -- UM982 dual-antenna GNSS receiver driver
|
|
||||||
*
|
|
||||||
* Parses NMEA sentences (GGA, RMC, THS, VTG) from the Unicore UM982 module
|
|
||||||
* and provides position, heading, and velocity data.
|
|
||||||
*
|
|
||||||
* Design principles:
|
|
||||||
* - Non-blocking: process() reads available UART bytes without waiting
|
|
||||||
* - Correct NMEA parsing: proper tokenizer handles empty fields
|
|
||||||
* - Longitude handles 3-digit degrees (dddmm.mmmm) via decimal-point detection
|
|
||||||
* - Checksum verified on every sentence
|
|
||||||
* - Command syntax verified against Unicore N4 Command Reference EN R1.14
|
|
||||||
*
|
|
||||||
* Hardware: UM982 on UART5 @ 115200 baud, dual-antenna heading mode
|
|
||||||
******************************************************************************/
|
|
||||||
#ifndef UM982_GPS_H
|
|
||||||
#define UM982_GPS_H
|
|
||||||
|
|
||||||
#include <stdint.h>
|
|
||||||
#include <stdbool.h>
|
|
||||||
#include <math.h>
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* Forward-declare the HAL UART handle type. The real definition comes from
|
|
||||||
* stm32f7xx_hal.h (production) or stm32_hal_mock.h (tests). */
|
|
||||||
#ifndef STM32_HAL_MOCK_H
|
|
||||||
#include "stm32f7xx_hal.h"
|
|
||||||
#else
|
|
||||||
/* Already included via mock -- nothing to do */
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ========================= Constants ================================= */
|
|
||||||
|
|
||||||
#define UM982_RX_BUF_SIZE 512 /* Ring buffer for incoming UART bytes */
|
|
||||||
#define UM982_LINE_BUF_SIZE 96 /* Max NMEA sentence (82 chars + margin) */
|
|
||||||
#define UM982_CMD_BUF_SIZE 128 /* Outgoing command buffer */
|
|
||||||
#define UM982_INIT_TIMEOUT_MS 3000 /* Timeout waiting for VERSIONA response */
|
|
||||||
|
|
||||||
/* Fix quality values (from GGA field 6) */
|
|
||||||
#define UM982_FIX_NONE 0
|
|
||||||
#define UM982_FIX_GPS 1
|
|
||||||
#define UM982_FIX_DGPS 2
|
|
||||||
#define UM982_FIX_RTK_FIXED 4
|
|
||||||
#define UM982_FIX_RTK_FLOAT 5
|
|
||||||
|
|
||||||
/* Validity timeout defaults (ms) */
|
|
||||||
#define UM982_HEADING_TIMEOUT_MS 2000
|
|
||||||
#define UM982_POSITION_TIMEOUT_MS 5000
|
|
||||||
|
|
||||||
/* ========================= Data Types ================================ */
|
|
||||||
|
|
||||||
typedef struct {
|
|
||||||
/* Position */
|
|
||||||
double latitude; /* Decimal degrees, positive = North */
|
|
||||||
double longitude; /* Decimal degrees, positive = East */
|
|
||||||
float altitude; /* Meters above MSL */
|
|
||||||
float geoid_sep; /* Geoid separation (meters) */
|
|
||||||
|
|
||||||
/* Heading (from dual-antenna THS) */
|
|
||||||
float heading; /* True heading 0-360 degrees, NAN if invalid */
|
|
||||||
char heading_mode; /* A=autonomous, D=diff, E=est, M=manual, S=sim, V=invalid */
|
|
||||||
|
|
||||||
/* Velocity */
|
|
||||||
float speed_knots; /* Speed over ground (knots) */
|
|
||||||
float speed_kmh; /* Speed over ground (km/h) */
|
|
||||||
float course_true; /* Course over ground (degrees true) */
|
|
||||||
|
|
||||||
/* Quality */
|
|
||||||
uint8_t fix_quality; /* 0=none, 1=GPS, 2=DGPS, 4=RTK fixed, 5=RTK float */
|
|
||||||
uint8_t num_satellites; /* Satellites used in fix */
|
|
||||||
float hdop; /* Horizontal dilution of precision */
|
|
||||||
|
|
||||||
/* RMC status */
|
|
||||||
char rmc_status; /* A=valid, V=warning */
|
|
||||||
|
|
||||||
/* Timestamps (HAL_GetTick() at last update) */
|
|
||||||
uint32_t last_fix_tick; /* Last valid GGA fix (fix_quality > 0) */
|
|
||||||
uint32_t last_gga_tick;
|
|
||||||
uint32_t last_rmc_tick;
|
|
||||||
uint32_t last_ths_tick;
|
|
||||||
uint32_t last_vtg_tick;
|
|
||||||
|
|
||||||
/* Communication state */
|
|
||||||
bool initialized; /* VERSIONA or supported NMEA traffic seen */
|
|
||||||
bool version_received; /* VERSIONA response seen */
|
|
||||||
|
|
||||||
/* ---- Internal parser state (not for external use) ---- */
|
|
||||||
|
|
||||||
/* Ring buffer */
|
|
||||||
uint8_t rx_buf[UM982_RX_BUF_SIZE];
|
|
||||||
uint16_t rx_head; /* Write index */
|
|
||||||
uint16_t rx_tail; /* Read index */
|
|
||||||
|
|
||||||
/* Line assembler */
|
|
||||||
char line_buf[UM982_LINE_BUF_SIZE];
|
|
||||||
uint8_t line_len;
|
|
||||||
bool line_overflow; /* Current line exceeded buffer */
|
|
||||||
|
|
||||||
/* UART handle */
|
|
||||||
UART_HandleTypeDef *huart;
|
|
||||||
|
|
||||||
} UM982_GPS_t;
|
|
||||||
|
|
||||||
/* ========================= Public API ================================ */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the UM982_GPS_t structure and configure the module.
|
|
||||||
*
|
|
||||||
* Sends: UNLOG, CONFIG HEADING, optional CONFIG HEADING LENGTH,
|
|
||||||
* GPGGA, GPRMC, GPTHS
|
|
||||||
* Queries VERSIONA to verify communication.
|
|
||||||
*
|
|
||||||
* @param gps Pointer to UM982_GPS_t instance
|
|
||||||
* @param huart UART handle (e.g. &huart5)
|
|
||||||
* @param baseline_cm Distance between antennas in cm (0 = use module default)
|
|
||||||
* @param tolerance_cm Baseline tolerance in cm (0 = use module default)
|
|
||||||
* @return true if VERSIONA response received within timeout
|
|
||||||
*/
|
|
||||||
bool um982_init(UM982_GPS_t *gps, UART_HandleTypeDef *huart,
|
|
||||||
float baseline_cm, float tolerance_cm);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process available UART data. Call from main loop — non-blocking.
|
|
||||||
*
|
|
||||||
* Reads all available bytes from UART, assembles lines, and dispatches
|
|
||||||
* complete NMEA sentences to the appropriate parser.
|
|
||||||
*
|
|
||||||
* @param gps Pointer to UM982_GPS_t instance
|
|
||||||
*/
|
|
||||||
void um982_process(UM982_GPS_t *gps);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feed raw bytes directly into the parser (useful for testing).
|
|
||||||
* In production, um982_process() calls this internally after UART read.
|
|
||||||
*
|
|
||||||
* @param gps Pointer to UM982_GPS_t instance
|
|
||||||
* @param data Pointer to byte array
|
|
||||||
* @param len Number of bytes
|
|
||||||
*/
|
|
||||||
void um982_feed(UM982_GPS_t *gps, const uint8_t *data, uint16_t len);
|
|
||||||
|
|
||||||
/* ---- Getters ---- */
|
|
||||||
|
|
||||||
static inline float um982_get_heading(const UM982_GPS_t *gps) { return gps->heading; }
|
|
||||||
static inline double um982_get_latitude(const UM982_GPS_t *gps) { return gps->latitude; }
|
|
||||||
static inline double um982_get_longitude(const UM982_GPS_t *gps) { return gps->longitude; }
|
|
||||||
static inline float um982_get_altitude(const UM982_GPS_t *gps) { return gps->altitude; }
|
|
||||||
static inline uint8_t um982_get_fix_quality(const UM982_GPS_t *gps) { return gps->fix_quality; }
|
|
||||||
static inline uint8_t um982_get_num_sats(const UM982_GPS_t *gps) { return gps->num_satellites; }
|
|
||||||
static inline float um982_get_hdop(const UM982_GPS_t *gps) { return gps->hdop; }
|
|
||||||
static inline float um982_get_speed_knots(const UM982_GPS_t *gps) { return gps->speed_knots; }
|
|
||||||
static inline float um982_get_speed_kmh(const UM982_GPS_t *gps) { return gps->speed_kmh; }
|
|
||||||
static inline float um982_get_course(const UM982_GPS_t *gps) { return gps->course_true; }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if heading is valid (mode A or D, and within timeout).
|
|
||||||
*/
|
|
||||||
bool um982_is_heading_valid(const UM982_GPS_t *gps);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if position is valid (fix_quality > 0, and within timeout).
|
|
||||||
*/
|
|
||||||
bool um982_is_position_valid(const UM982_GPS_t *gps);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get age of last heading update in milliseconds.
|
|
||||||
*/
|
|
||||||
uint32_t um982_heading_age(const UM982_GPS_t *gps);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get age of the last valid position fix in milliseconds.
|
|
||||||
*/
|
|
||||||
uint32_t um982_position_age(const UM982_GPS_t *gps);
|
|
||||||
|
|
||||||
/* ========================= Internal (exposed for testing) ============ */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify NMEA checksum. Returns true if valid.
|
|
||||||
* Sentence must start with '$' and contain '*XX' before termination.
|
|
||||||
*/
|
|
||||||
bool um982_verify_checksum(const char *sentence);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a complete NMEA line (with $ prefix and *XX checksum).
|
|
||||||
* Dispatches to GGA/RMC/THS/VTG parsers as appropriate.
|
|
||||||
*/
|
|
||||||
void um982_parse_sentence(UM982_GPS_t *gps, const char *sentence);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse NMEA coordinate string to decimal degrees.
|
|
||||||
* Works for both latitude (ddmm.mmmm) and longitude (dddmm.mmmm)
|
|
||||||
* by detecting the decimal point position.
|
|
||||||
*
|
|
||||||
* @param field NMEA coordinate field (e.g. "4404.14036" or "12118.85961")
|
|
||||||
* @param hemisphere 'N', 'S', 'E', or 'W'
|
|
||||||
* @return Decimal degrees (negative for S/W), or NAN on parse error
|
|
||||||
*/
|
|
||||||
double um982_parse_coord(const char *field, char hemisphere);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a command to the UM982. Appends \r\n automatically.
|
|
||||||
* @return true if UART transmit succeeded
|
|
||||||
*/
|
|
||||||
bool um982_send_command(UM982_GPS_t *gps, const char *cmd);
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* UM982_GPS_H */
|
|
||||||
@@ -3,38 +3,18 @@
|
|||||||
*.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_bug14_diag_section_args
|
test_bug9_platform_ops_null
|
||||||
test_gap3_emergency_stop_rails
|
test_bug10_spi_cs_not_toggled
|
||||||
|
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_gap3_iwdg_config
|
test_bug14_diag_section_args
|
||||||
test_gap3_temperature_max
|
test_bug15_htim3_dangling_extern
|
||||||
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
|
|
||||||
|
|||||||
@@ -16,21 +16,10 @@
|
|||||||
################################################################################
|
################################################################################
|
||||||
|
|
||||||
CC := cc
|
CC := cc
|
||||||
CXX := c++
|
|
||||||
CFLAGS := -std=c11 -Wall -Wextra -Wno-unused-parameter -g -O0
|
CFLAGS := -std=c11 -Wall -Wextra -Wno-unused-parameter -g -O0
|
||||||
CXXFLAGS := -std=c++17 -Wall -Wextra -Wno-unused-parameter -g -O0
|
|
||||||
# Shim headers come FIRST so they override real headers
|
# Shim headers come FIRST so they override real headers
|
||||||
INCLUDES := -Ishims -I. -I../9_1_1_C_Cpp_Libraries
|
INCLUDES := -Ishims -I. -I../9_1_1_C_Cpp_Libraries
|
||||||
|
|
||||||
# C++ library directory (AGC, ADAR1000 Manager)
|
|
||||||
CXX_LIB_DIR := ../9_1_1_C_Cpp_Libraries
|
|
||||||
CXX_SRCS := $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.cpp
|
|
||||||
CXX_OBJS := ADAR1000_AGC.o ADAR1000_Manager.o
|
|
||||||
|
|
||||||
# GPS driver source
|
|
||||||
GPS_SRC := ../9_1_3_C_Cpp_Code/um982_gps.c
|
|
||||||
GPS_OBJ := um982_gps.o
|
|
||||||
|
|
||||||
# Real source files compiled against mock headers
|
# Real source files compiled against mock headers
|
||||||
REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c
|
REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c
|
||||||
|
|
||||||
@@ -68,25 +57,16 @@ 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
|
||||||
|
|
||||||
# C++ tests (AGC outer loop)
|
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM)
|
||||||
TESTS_WITH_CXX := test_agc_outer_loop
|
|
||||||
|
|
||||||
# GPS driver tests (need mocks + GPS source + -lm)
|
|
||||||
TESTS_GPS := test_um982_gps
|
|
||||||
|
|
||||||
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) $(TESTS_WITH_CXX) $(TESTS_GPS)
|
|
||||||
|
|
||||||
.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
|
||||||
|
|
||||||
@@ -172,48 +152,10 @@ 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 $@
|
||||||
|
|
||||||
# --- C++ object rules ---
|
|
||||||
|
|
||||||
ADAR1000_AGC.o: $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_AGC.h
|
|
||||||
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
|
|
||||||
|
|
||||||
ADAR1000_Manager.o: $(CXX_LIB_DIR)/ADAR1000_Manager.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.h
|
|
||||||
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
|
|
||||||
|
|
||||||
# --- C++ test binary rules ---
|
|
||||||
|
|
||||||
test_agc_outer_loop: test_agc_outer_loop.cpp $(CXX_OBJS) $(MOCK_OBJS)
|
|
||||||
$(CXX) $(CXXFLAGS) $(INCLUDES) $< $(CXX_OBJS) $(MOCK_OBJS) -o $@
|
|
||||||
|
|
||||||
# Convenience target
|
|
||||||
.PHONY: test_agc
|
|
||||||
test_agc: test_agc_outer_loop
|
|
||||||
./test_agc_outer_loop
|
|
||||||
|
|
||||||
# --- GPS driver rules ---
|
|
||||||
|
|
||||||
$(GPS_OBJ): $(GPS_SRC)
|
|
||||||
$(CC) $(CFLAGS) $(INCLUDES) -I../9_1_3_C_Cpp_Code -c $< -o $@
|
|
||||||
|
|
||||||
# Note: test includes um982_gps.c directly for white-box testing (static fn access)
|
|
||||||
test_um982_gps: test_um982_gps.c $(MOCK_OBJS)
|
|
||||||
$(CC) $(CFLAGS) $(INCLUDES) -I../9_1_3_C_Cpp_Code $< $(MOCK_OBJS) -lm -o $@
|
|
||||||
|
|
||||||
# Convenience target
|
|
||||||
.PHONY: test_gps
|
|
||||||
test_gps: test_um982_gps
|
|
||||||
./test_um982_gps
|
|
||||||
|
|
||||||
# --- Individual test targets ---
|
# --- Individual test targets ---
|
||||||
|
|
||||||
test_bug1: test_bug1_timed_sync_init_ordering
|
test_bug1: test_bug1_timed_sync_init_ordering
|
||||||
@@ -276,12 +218,6 @@ 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:
|
||||||
|
|||||||
@@ -129,14 +129,6 @@ void Error_Handler(void);
|
|||||||
#define GYR_INT_Pin GPIO_PIN_8
|
#define GYR_INT_Pin GPIO_PIN_8
|
||||||
#define GYR_INT_GPIO_Port GPIOC
|
#define GYR_INT_GPIO_Port GPIOC
|
||||||
|
|
||||||
/* FPGA digital I/O (directly connected GPIOs) */
|
|
||||||
#define FPGA_DIG5_SAT_Pin GPIO_PIN_13
|
|
||||||
#define FPGA_DIG5_SAT_GPIO_Port GPIOD
|
|
||||||
#define FPGA_DIG6_Pin GPIO_PIN_14
|
|
||||||
#define FPGA_DIG6_GPIO_Port GPIOD
|
|
||||||
#define FPGA_DIG7_Pin GPIO_PIN_15
|
|
||||||
#define FPGA_DIG7_GPIO_Port GPIOD
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ SPI_HandleTypeDef hspi4 = { .id = 4 };
|
|||||||
I2C_HandleTypeDef hi2c1 = { .id = 1 };
|
I2C_HandleTypeDef hi2c1 = { .id = 1 };
|
||||||
I2C_HandleTypeDef hi2c2 = { .id = 2 };
|
I2C_HandleTypeDef hi2c2 = { .id = 2 };
|
||||||
UART_HandleTypeDef huart3 = { .id = 3 };
|
UART_HandleTypeDef huart3 = { .id = 3 };
|
||||||
UART_HandleTypeDef huart5 = { .id = 5 }; /* GPS UART */
|
|
||||||
ADC_HandleTypeDef hadc3 = { .id = 3 };
|
ADC_HandleTypeDef hadc3 = { .id = 3 };
|
||||||
TIM_HandleTypeDef htim3 = { .id = 3 };
|
TIM_HandleTypeDef htim3 = { .id = 3 };
|
||||||
|
|
||||||
@@ -35,26 +34,6 @@ uint32_t mock_tick = 0;
|
|||||||
/* ========================= Printf control ========================= */
|
/* ========================= Printf control ========================= */
|
||||||
int mock_printf_enabled = 0;
|
int mock_printf_enabled = 0;
|
||||||
|
|
||||||
/* ========================= Mock UART TX capture =================== */
|
|
||||||
uint8_t mock_uart_tx_buf[MOCK_UART_TX_BUF_SIZE];
|
|
||||||
uint16_t mock_uart_tx_len = 0;
|
|
||||||
|
|
||||||
/* ========================= Mock UART RX buffer ==================== */
|
|
||||||
#define MOCK_UART_RX_SLOTS 8
|
|
||||||
|
|
||||||
static struct {
|
|
||||||
uint32_t uart_id;
|
|
||||||
uint8_t buf[MOCK_UART_RX_BUF_SIZE];
|
|
||||||
uint16_t head;
|
|
||||||
uint16_t tail;
|
|
||||||
} mock_uart_rx[MOCK_UART_RX_SLOTS];
|
|
||||||
|
|
||||||
void mock_uart_tx_clear(void)
|
|
||||||
{
|
|
||||||
mock_uart_tx_len = 0;
|
|
||||||
memset(mock_uart_tx_buf, 0, sizeof(mock_uart_tx_buf));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Mock GPIO read ========================= */
|
/* ========================= Mock GPIO read ========================= */
|
||||||
#define GPIO_READ_TABLE_SIZE 32
|
#define GPIO_READ_TABLE_SIZE 32
|
||||||
static struct {
|
static struct {
|
||||||
@@ -70,9 +49,6 @@ void spy_reset(void)
|
|||||||
mock_tick = 0;
|
mock_tick = 0;
|
||||||
mock_printf_enabled = 0;
|
mock_printf_enabled = 0;
|
||||||
memset(gpio_read_table, 0, sizeof(gpio_read_table));
|
memset(gpio_read_table, 0, sizeof(gpio_read_table));
|
||||||
memset(mock_uart_rx, 0, sizeof(mock_uart_rx));
|
|
||||||
mock_uart_tx_len = 0;
|
|
||||||
memset(mock_uart_tx_buf, 0, sizeof(mock_uart_tx_buf));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SpyRecord *spy_get(int index)
|
const SpyRecord *spy_get(int index)
|
||||||
@@ -199,7 +175,7 @@ void HAL_Delay(uint32_t Delay)
|
|||||||
mock_tick += Delay;
|
mock_tick += Delay;
|
||||||
}
|
}
|
||||||
|
|
||||||
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData,
|
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData,
|
||||||
uint16_t Size, uint32_t Timeout)
|
uint16_t Size, uint32_t Timeout)
|
||||||
{
|
{
|
||||||
spy_push((SpyRecord){
|
spy_push((SpyRecord){
|
||||||
@@ -209,83 +185,6 @@ HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pD
|
|||||||
.value = Timeout,
|
.value = Timeout,
|
||||||
.extra = huart
|
.extra = huart
|
||||||
});
|
});
|
||||||
/* Capture TX data for test inspection */
|
|
||||||
for (uint16_t i = 0; i < Size && mock_uart_tx_len < MOCK_UART_TX_BUF_SIZE; i++) {
|
|
||||||
mock_uart_tx_buf[mock_uart_tx_len++] = pData[i];
|
|
||||||
}
|
|
||||||
return HAL_OK;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Mock UART RX helpers ====================== */
|
|
||||||
|
|
||||||
/* find_rx_slot, mock_uart_rx_load, etc. use the mock_uart_rx declared above */
|
|
||||||
|
|
||||||
static int find_rx_slot(UART_HandleTypeDef *huart)
|
|
||||||
{
|
|
||||||
if (huart == NULL) return -1;
|
|
||||||
/* Find existing slot */
|
|
||||||
for (int i = 0; i < MOCK_UART_RX_SLOTS; i++) {
|
|
||||||
if (mock_uart_rx[i].uart_id == huart->id && mock_uart_rx[i].head != mock_uart_rx[i].tail) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
if (mock_uart_rx[i].uart_id == huart->id) {
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* Find empty slot */
|
|
||||||
for (int i = 0; i < MOCK_UART_RX_SLOTS; i++) {
|
|
||||||
if (mock_uart_rx[i].uart_id == 0) {
|
|
||||||
mock_uart_rx[i].uart_id = huart->id;
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
void mock_uart_rx_load(UART_HandleTypeDef *huart, const uint8_t *data, uint16_t len)
|
|
||||||
{
|
|
||||||
int slot = find_rx_slot(huart);
|
|
||||||
if (slot < 0) return;
|
|
||||||
mock_uart_rx[slot].uart_id = huart->id;
|
|
||||||
for (uint16_t i = 0; i < len; i++) {
|
|
||||||
uint16_t next = (mock_uart_rx[slot].head + 1) % MOCK_UART_RX_BUF_SIZE;
|
|
||||||
if (next == mock_uart_rx[slot].tail) break; /* Buffer full */
|
|
||||||
mock_uart_rx[slot].buf[mock_uart_rx[slot].head] = data[i];
|
|
||||||
mock_uart_rx[slot].head = next;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void mock_uart_rx_clear(UART_HandleTypeDef *huart)
|
|
||||||
{
|
|
||||||
int slot = find_rx_slot(huart);
|
|
||||||
if (slot < 0) return;
|
|
||||||
mock_uart_rx[slot].head = 0;
|
|
||||||
mock_uart_rx[slot].tail = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData,
|
|
||||||
uint16_t Size, uint32_t Timeout)
|
|
||||||
{
|
|
||||||
(void)Timeout;
|
|
||||||
int slot = find_rx_slot(huart);
|
|
||||||
if (slot < 0) return HAL_TIMEOUT;
|
|
||||||
|
|
||||||
for (uint16_t i = 0; i < Size; i++) {
|
|
||||||
if (mock_uart_rx[slot].head == mock_uart_rx[slot].tail) {
|
|
||||||
return HAL_TIMEOUT; /* No more data */
|
|
||||||
}
|
|
||||||
pData[i] = mock_uart_rx[slot].buf[mock_uart_rx[slot].tail];
|
|
||||||
mock_uart_rx[slot].tail = (mock_uart_rx[slot].tail + 1) % MOCK_UART_RX_BUF_SIZE;
|
|
||||||
}
|
|
||||||
|
|
||||||
spy_push((SpyRecord){
|
|
||||||
.type = SPY_UART_RX,
|
|
||||||
.port = NULL,
|
|
||||||
.pin = Size,
|
|
||||||
.value = Timeout,
|
|
||||||
.extra = huart
|
|
||||||
});
|
|
||||||
|
|
||||||
return HAL_OK;
|
return HAL_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ typedef uint32_t HAL_StatusTypeDef;
|
|||||||
|
|
||||||
#define HAL_MAX_DELAY 0xFFFFFFFFU
|
#define HAL_MAX_DELAY 0xFFFFFFFFU
|
||||||
|
|
||||||
#ifndef __NOP
|
|
||||||
#define __NOP() ((void)0)
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/* ========================= GPIO Types ============================ */
|
/* ========================= GPIO Types ============================ */
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -105,7 +101,6 @@ typedef struct {
|
|||||||
extern SPI_HandleTypeDef hspi1, hspi4;
|
extern SPI_HandleTypeDef hspi1, hspi4;
|
||||||
extern I2C_HandleTypeDef hi2c1, hi2c2;
|
extern I2C_HandleTypeDef hi2c1, hi2c2;
|
||||||
extern UART_HandleTypeDef huart3;
|
extern UART_HandleTypeDef huart3;
|
||||||
extern UART_HandleTypeDef huart5; /* GPS UART */
|
|
||||||
extern ADC_HandleTypeDef hadc3;
|
extern ADC_HandleTypeDef hadc3;
|
||||||
extern TIM_HandleTypeDef htim3; /* Timer for DELADJ PWM */
|
extern TIM_HandleTypeDef htim3; /* Timer for DELADJ PWM */
|
||||||
|
|
||||||
@@ -140,7 +135,6 @@ typedef enum {
|
|||||||
SPY_TIM_SET_COMPARE,
|
SPY_TIM_SET_COMPARE,
|
||||||
SPY_SPI_TRANSMIT_RECEIVE,
|
SPY_SPI_TRANSMIT_RECEIVE,
|
||||||
SPY_SPI_TRANSMIT,
|
SPY_SPI_TRANSMIT,
|
||||||
SPY_UART_RX,
|
|
||||||
} SpyCallType;
|
} SpyCallType;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@@ -188,24 +182,7 @@ GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
|
|||||||
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
|
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
|
||||||
uint32_t HAL_GetTick(void);
|
uint32_t HAL_GetTick(void);
|
||||||
void HAL_Delay(uint32_t Delay);
|
void HAL_Delay(uint32_t Delay);
|
||||||
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout);
|
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
|
||||||
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
|
|
||||||
|
|
||||||
/* ========================= Mock UART RX buffer ======================= */
|
|
||||||
|
|
||||||
/* Inject bytes into the mock UART RX buffer for a specific UART handle.
|
|
||||||
* HAL_UART_Receive will return these bytes one at a time. */
|
|
||||||
#define MOCK_UART_RX_BUF_SIZE 2048
|
|
||||||
|
|
||||||
void mock_uart_rx_load(UART_HandleTypeDef *huart, const uint8_t *data, uint16_t len);
|
|
||||||
void mock_uart_rx_clear(UART_HandleTypeDef *huart);
|
|
||||||
|
|
||||||
/* Capture buffer for UART TX data (to verify commands sent to GPS module) */
|
|
||||||
#define MOCK_UART_TX_BUF_SIZE 2048
|
|
||||||
|
|
||||||
extern uint8_t mock_uart_tx_buf[MOCK_UART_TX_BUF_SIZE];
|
|
||||||
extern uint16_t mock_uart_tx_len;
|
|
||||||
void mock_uart_tx_clear(void);
|
|
||||||
|
|
||||||
/* ========================= SPI stubs ============================== */
|
/* ========================= SPI stubs ============================== */
|
||||||
|
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
// test_agc_outer_loop.cpp -- C++ unit tests for ADAR1000_AGC outer-loop AGC
|
|
||||||
//
|
|
||||||
// Tests the STM32 outer-loop AGC class that adjusts ADAR1000 VGA gain based
|
|
||||||
// on the FPGA's saturation flag. Uses the existing HAL mock/spy framework.
|
|
||||||
//
|
|
||||||
// Build: c++ -std=c++17 ... (see Makefile TESTS_WITH_CXX rule)
|
|
||||||
|
|
||||||
#include <cassert>
|
|
||||||
#include <cstdio>
|
|
||||||
#include <cstring>
|
|
||||||
|
|
||||||
// Shim headers override real STM32/diag headers
|
|
||||||
#include "stm32_hal_mock.h"
|
|
||||||
#include "ADAR1000_AGC.h"
|
|
||||||
#include "ADAR1000_Manager.h"
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Linker symbols required by ADAR1000_Manager.cpp (pulled in via main.h shim)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
uint8_t GUI_start_flag_received = 0;
|
|
||||||
uint8_t USB_Buffer[64] = {0};
|
|
||||||
extern "C" void Error_Handler(void) {}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
static int tests_passed = 0;
|
|
||||||
static int tests_total = 0;
|
|
||||||
|
|
||||||
#define RUN_TEST(fn) \
|
|
||||||
do { \
|
|
||||||
tests_total++; \
|
|
||||||
printf(" [%2d] %-55s ", tests_total, #fn); \
|
|
||||||
fn(); \
|
|
||||||
tests_passed++; \
|
|
||||||
printf("PASS\n"); \
|
|
||||||
} while (0)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 1: Default construction matches design spec
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_defaults()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
|
|
||||||
assert(agc.agc_base_gain == 30); // kDefaultRxVgaGain
|
|
||||||
assert(agc.gain_step_down == 4);
|
|
||||||
assert(agc.gain_step_up == 1);
|
|
||||||
assert(agc.min_gain == 0);
|
|
||||||
assert(agc.max_gain == 127);
|
|
||||||
assert(agc.holdoff_frames == 4);
|
|
||||||
assert(agc.enabled == true);
|
|
||||||
assert(agc.holdoff_counter == 0);
|
|
||||||
assert(agc.last_saturated == false);
|
|
||||||
assert(agc.saturation_event_count == 0);
|
|
||||||
|
|
||||||
// All cal offsets zero
|
|
||||||
for (int i = 0; i < AGC_TOTAL_CHANNELS; ++i) {
|
|
||||||
assert(agc.cal_offset[i] == 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 2: Saturation reduces gain by step_down
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_saturation_reduces_gain()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
uint8_t initial = agc.agc_base_gain; // 30
|
|
||||||
|
|
||||||
agc.update(true); // saturation
|
|
||||||
|
|
||||||
assert(agc.agc_base_gain == initial - agc.gain_step_down); // 26
|
|
||||||
assert(agc.last_saturated == true);
|
|
||||||
assert(agc.holdoff_counter == 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 3: Holdoff prevents premature gain-up
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_holdoff_prevents_early_gain_up()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.update(true); // saturate once -> gain = 26
|
|
||||||
uint8_t after_sat = agc.agc_base_gain;
|
|
||||||
|
|
||||||
// Feed (holdoff_frames - 1) clear frames — should NOT increase gain
|
|
||||||
for (uint8_t i = 0; i < agc.holdoff_frames - 1; ++i) {
|
|
||||||
agc.update(false);
|
|
||||||
assert(agc.agc_base_gain == after_sat);
|
|
||||||
}
|
|
||||||
|
|
||||||
// holdoff_counter should be holdoff_frames - 1
|
|
||||||
assert(agc.holdoff_counter == agc.holdoff_frames - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 4: Recovery after holdoff period
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_recovery_after_holdoff()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.update(true); // saturate -> gain = 26
|
|
||||||
uint8_t after_sat = agc.agc_base_gain;
|
|
||||||
|
|
||||||
// Feed exactly holdoff_frames clear frames
|
|
||||||
for (uint8_t i = 0; i < agc.holdoff_frames; ++i) {
|
|
||||||
agc.update(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert(agc.agc_base_gain == after_sat + agc.gain_step_up); // 27
|
|
||||||
assert(agc.holdoff_counter == 0); // reset after recovery
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 5: Min gain clamping
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_min_gain_clamp()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.min_gain = 10;
|
|
||||||
agc.agc_base_gain = 12;
|
|
||||||
agc.gain_step_down = 4;
|
|
||||||
|
|
||||||
agc.update(true); // 12 - 4 = 8, but min = 10
|
|
||||||
assert(agc.agc_base_gain == 10);
|
|
||||||
|
|
||||||
agc.update(true); // already at min
|
|
||||||
assert(agc.agc_base_gain == 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 6: Max gain clamping
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_max_gain_clamp()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.max_gain = 32;
|
|
||||||
agc.agc_base_gain = 31;
|
|
||||||
agc.gain_step_up = 2;
|
|
||||||
agc.holdoff_frames = 1; // immediate recovery
|
|
||||||
|
|
||||||
agc.update(false); // 31 + 2 = 33, but max = 32
|
|
||||||
assert(agc.agc_base_gain == 32);
|
|
||||||
|
|
||||||
agc.update(false); // already at max
|
|
||||||
assert(agc.agc_base_gain == 32);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 7: Per-channel calibration offsets
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_calibration_offsets()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.agc_base_gain = 30;
|
|
||||||
agc.min_gain = 0;
|
|
||||||
agc.max_gain = 60;
|
|
||||||
|
|
||||||
agc.cal_offset[0] = 5; // 30 + 5 = 35
|
|
||||||
agc.cal_offset[1] = -10; // 30 - 10 = 20
|
|
||||||
agc.cal_offset[15] = 40; // 30 + 40 = 60 (clamped to max)
|
|
||||||
|
|
||||||
assert(agc.effectiveGain(0) == 35);
|
|
||||||
assert(agc.effectiveGain(1) == 20);
|
|
||||||
assert(agc.effectiveGain(15) == 60); // clamped to max_gain
|
|
||||||
|
|
||||||
// Negative clamp
|
|
||||||
agc.cal_offset[2] = -50; // 30 - 50 = -20, clamped to min_gain = 0
|
|
||||||
assert(agc.effectiveGain(2) == 0);
|
|
||||||
|
|
||||||
// Out-of-range index returns min_gain
|
|
||||||
assert(agc.effectiveGain(16) == agc.min_gain);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 8: Disabled AGC is a no-op
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_disabled_noop()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.enabled = false;
|
|
||||||
uint8_t original = agc.agc_base_gain;
|
|
||||||
|
|
||||||
agc.update(true); // should be ignored
|
|
||||||
assert(agc.agc_base_gain == original);
|
|
||||||
assert(agc.last_saturated == false); // not updated when disabled
|
|
||||||
assert(agc.saturation_event_count == 0);
|
|
||||||
|
|
||||||
agc.update(false); // also ignored
|
|
||||||
assert(agc.agc_base_gain == original);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 9: applyGain() produces correct SPI writes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_apply_gain_spi()
|
|
||||||
{
|
|
||||||
spy_reset();
|
|
||||||
|
|
||||||
ADAR1000Manager mgr; // creates 4 devices
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.agc_base_gain = 42;
|
|
||||||
|
|
||||||
agc.applyGain(mgr);
|
|
||||||
|
|
||||||
// Each channel: adarSetRxVgaGain -> adarWrite(gain) + adarWrite(LOAD_WORKING)
|
|
||||||
// Each adarWrite: CS_low (GPIO_WRITE) + SPI_TRANSMIT + CS_high (GPIO_WRITE)
|
|
||||||
// = 3 spy records per adarWrite
|
|
||||||
// = 6 spy records per channel
|
|
||||||
// = 16 channels * 6 = 96 total spy records
|
|
||||||
|
|
||||||
// Verify SPI transmit count: 2 SPI calls per channel * 16 channels = 32
|
|
||||||
int spi_count = spy_count_type(SPY_SPI_TRANSMIT);
|
|
||||||
assert(spi_count == 32);
|
|
||||||
|
|
||||||
// Verify GPIO write count: 4 GPIO writes per channel (CS low + CS high for each of 2 adarWrite calls)
|
|
||||||
int gpio_writes = spy_count_type(SPY_GPIO_WRITE);
|
|
||||||
assert(gpio_writes == 64); // 16 ch * 2 adarWrite * 2 GPIO each
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 10: resetState() clears counters but preserves config
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_reset_preserves_config()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.agc_base_gain = 42;
|
|
||||||
agc.gain_step_down = 8;
|
|
||||||
agc.cal_offset[3] = -5;
|
|
||||||
|
|
||||||
// Generate some state
|
|
||||||
agc.update(true);
|
|
||||||
agc.update(true);
|
|
||||||
assert(agc.saturation_event_count == 2);
|
|
||||||
assert(agc.last_saturated == true);
|
|
||||||
|
|
||||||
agc.resetState();
|
|
||||||
|
|
||||||
// State cleared
|
|
||||||
assert(agc.holdoff_counter == 0);
|
|
||||||
assert(agc.last_saturated == false);
|
|
||||||
assert(agc.saturation_event_count == 0);
|
|
||||||
|
|
||||||
// Config preserved
|
|
||||||
assert(agc.agc_base_gain == 42 - 8 - 8); // two saturations applied before reset
|
|
||||||
assert(agc.gain_step_down == 8);
|
|
||||||
assert(agc.cal_offset[3] == -5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 11: Saturation counter increments correctly
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_saturation_counter()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
|
|
||||||
for (int i = 0; i < 10; ++i) {
|
|
||||||
agc.update(true);
|
|
||||||
}
|
|
||||||
assert(agc.saturation_event_count == 10);
|
|
||||||
|
|
||||||
// Clear frames don't increment saturation count
|
|
||||||
for (int i = 0; i < 5; ++i) {
|
|
||||||
agc.update(false);
|
|
||||||
}
|
|
||||||
assert(agc.saturation_event_count == 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 12: Mixed saturation/clear sequence
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_mixed_sequence()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.agc_base_gain = 30;
|
|
||||||
agc.gain_step_down = 4;
|
|
||||||
agc.gain_step_up = 1;
|
|
||||||
agc.holdoff_frames = 3;
|
|
||||||
|
|
||||||
// Saturate: 30 -> 26
|
|
||||||
agc.update(true);
|
|
||||||
assert(agc.agc_base_gain == 26);
|
|
||||||
assert(agc.holdoff_counter == 0);
|
|
||||||
|
|
||||||
// 2 clear frames (not enough for recovery)
|
|
||||||
agc.update(false);
|
|
||||||
agc.update(false);
|
|
||||||
assert(agc.agc_base_gain == 26);
|
|
||||||
assert(agc.holdoff_counter == 2);
|
|
||||||
|
|
||||||
// Saturate again: 26 -> 22, counter resets
|
|
||||||
agc.update(true);
|
|
||||||
assert(agc.agc_base_gain == 22);
|
|
||||||
assert(agc.holdoff_counter == 0);
|
|
||||||
assert(agc.saturation_event_count == 2);
|
|
||||||
|
|
||||||
// 3 clear frames -> recovery: 22 -> 23
|
|
||||||
agc.update(false);
|
|
||||||
agc.update(false);
|
|
||||||
agc.update(false);
|
|
||||||
assert(agc.agc_base_gain == 23);
|
|
||||||
assert(agc.holdoff_counter == 0);
|
|
||||||
|
|
||||||
// 3 more clear -> 23 -> 24
|
|
||||||
agc.update(false);
|
|
||||||
agc.update(false);
|
|
||||||
agc.update(false);
|
|
||||||
assert(agc.agc_base_gain == 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Test 13: Effective gain with edge-case base_gain values
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
static void test_effective_gain_edge_cases()
|
|
||||||
{
|
|
||||||
ADAR1000_AGC agc;
|
|
||||||
agc.min_gain = 5;
|
|
||||||
agc.max_gain = 250;
|
|
||||||
|
|
||||||
// Base gain at zero with positive offset
|
|
||||||
agc.agc_base_gain = 0;
|
|
||||||
agc.cal_offset[0] = 3;
|
|
||||||
assert(agc.effectiveGain(0) == 5); // 0 + 3 = 3, clamped to min_gain=5
|
|
||||||
|
|
||||||
// Base gain at max with zero offset
|
|
||||||
agc.agc_base_gain = 250;
|
|
||||||
agc.cal_offset[0] = 0;
|
|
||||||
assert(agc.effectiveGain(0) == 250);
|
|
||||||
|
|
||||||
// Base gain at max with positive offset -> clamped
|
|
||||||
agc.agc_base_gain = 250;
|
|
||||||
agc.cal_offset[0] = 10;
|
|
||||||
assert(agc.effectiveGain(0) == 250); // clamped to max_gain
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// main
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
int main()
|
|
||||||
{
|
|
||||||
printf("=== ADAR1000_AGC Outer-Loop Unit Tests ===\n");
|
|
||||||
|
|
||||||
RUN_TEST(test_defaults);
|
|
||||||
RUN_TEST(test_saturation_reduces_gain);
|
|
||||||
RUN_TEST(test_holdoff_prevents_early_gain_up);
|
|
||||||
RUN_TEST(test_recovery_after_holdoff);
|
|
||||||
RUN_TEST(test_min_gain_clamp);
|
|
||||||
RUN_TEST(test_max_gain_clamp);
|
|
||||||
RUN_TEST(test_calibration_offsets);
|
|
||||||
RUN_TEST(test_disabled_noop);
|
|
||||||
RUN_TEST(test_apply_gain_spi);
|
|
||||||
RUN_TEST(test_reset_preserves_config);
|
|
||||||
RUN_TEST(test_saturation_counter);
|
|
||||||
RUN_TEST(test_mixed_sequence);
|
|
||||||
RUN_TEST(test_effective_gain_edge_cases);
|
|
||||||
|
|
||||||
printf("=== Results: %d/%d passed ===\n", tests_passed, tests_total);
|
|
||||||
return (tests_passed == tests_total) ? 0 : 1;
|
|
||||||
}
|
|
||||||
@@ -34,25 +34,22 @@ 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 SystemError_t) */
|
/* Error codes (subset matching main.cpp) */
|
||||||
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_MOTOR = 11,
|
ERROR_STEPPER_FAULT = 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 (matches post-fix main.cpp predicate) */
|
/* Extracted critical-error handling logic (post-fix ordering) */
|
||||||
static void simulate_handleSystemError_critical(SystemError_t error)
|
static void simulate_handleSystemError_critical(SystemError_t error)
|
||||||
{
|
{
|
||||||
if ((error >= ERROR_RF_PA_OVERCURRENT && error <= ERROR_POWER_SUPPLY) ||
|
/* Only critical errors (PA overcurrent through power supply) trigger e-stop */
|
||||||
error == ERROR_TEMPERATURE_HIGH ||
|
if (error >= ERROR_RF_PA_OVERCURRENT && error <= ERROR_POWER_SUPPLY) {
|
||||||
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();
|
||||||
@@ -96,39 +93,17 @@ 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: Overtemp → MUST trigger e-stop (was incorrectly non-critical before fix) */
|
/* Test 4: Non-critical error → no e-stop, flag stays false */
|
||||||
printf(" Test 4: Overtemp triggers e-stop... ");
|
printf(" Test 4: Non-critical error (no 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 7: ERROR_NONE → no e-stop */
|
/* Test 5: ERROR_NONE → no e-stop */
|
||||||
printf(" Test 7: ERROR_NONE (no action)... ");
|
printf(" Test 5: 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);
|
||||||
@@ -136,6 +111,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 7 TESTS PASSED ===\n\n");
|
printf("\n=== Gap-3 Fix 5: ALL TESTS PASSED ===\n\n");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
@@ -1,853 +0,0 @@
|
|||||||
/*******************************************************************************
|
|
||||||
* test_um982_gps.c -- Unit tests for UM982 GPS driver
|
|
||||||
*
|
|
||||||
* Tests NMEA parsing, checksum validation, coordinate parsing, init sequence,
|
|
||||||
* and validity tracking. Uses the mock HAL infrastructure for UART.
|
|
||||||
*
|
|
||||||
* Build: see Makefile target test_um982_gps
|
|
||||||
* Run: ./test_um982_gps
|
|
||||||
******************************************************************************/
|
|
||||||
#include "stm32_hal_mock.h"
|
|
||||||
#include "../9_1_3_C_Cpp_Code/um982_gps.h"
|
|
||||||
#include "../9_1_3_C_Cpp_Code/um982_gps.c" /* Include .c directly for white-box testing */
|
|
||||||
|
|
||||||
#include <stdio.h>
|
|
||||||
#include <string.h>
|
|
||||||
#include <assert.h>
|
|
||||||
#include <math.h>
|
|
||||||
|
|
||||||
/* ========================= Test helpers ============================== */
|
|
||||||
|
|
||||||
static int tests_passed = 0;
|
|
||||||
static int tests_failed = 0;
|
|
||||||
|
|
||||||
#define TEST(name) \
|
|
||||||
do { printf(" [TEST] %-55s ", name); } while(0)
|
|
||||||
|
|
||||||
#define PASS() \
|
|
||||||
do { printf("PASS\n"); tests_passed++; } while(0)
|
|
||||||
|
|
||||||
#define FAIL(msg) \
|
|
||||||
do { printf("FAIL: %s\n", msg); tests_failed++; } while(0)
|
|
||||||
|
|
||||||
#define ASSERT_TRUE(expr, msg) \
|
|
||||||
do { if (!(expr)) { FAIL(msg); return; } } while(0)
|
|
||||||
|
|
||||||
#define ASSERT_FALSE(expr, msg) \
|
|
||||||
do { if (expr) { FAIL(msg); return; } } while(0)
|
|
||||||
|
|
||||||
#define ASSERT_EQ_INT(a, b, msg) \
|
|
||||||
do { if ((a) != (b)) { \
|
|
||||||
char _buf[256]; \
|
|
||||||
snprintf(_buf, sizeof(_buf), "%s (got %d, expected %d)", msg, (int)(a), (int)(b)); \
|
|
||||||
FAIL(_buf); return; \
|
|
||||||
} } while(0)
|
|
||||||
|
|
||||||
#define ASSERT_NEAR(a, b, tol, msg) \
|
|
||||||
do { if (fabs((double)(a) - (double)(b)) > (tol)) { \
|
|
||||||
char _buf[256]; \
|
|
||||||
snprintf(_buf, sizeof(_buf), "%s (got %.8f, expected %.8f)", msg, (double)(a), (double)(b)); \
|
|
||||||
FAIL(_buf); return; \
|
|
||||||
} } while(0)
|
|
||||||
|
|
||||||
#define ASSERT_NAN(val, msg) \
|
|
||||||
do { if (!isnan(val)) { FAIL(msg); return; } } while(0)
|
|
||||||
|
|
||||||
static UM982_GPS_t gps;
|
|
||||||
|
|
||||||
static void reset_gps(void)
|
|
||||||
{
|
|
||||||
spy_reset();
|
|
||||||
memset(&gps, 0, sizeof(gps));
|
|
||||||
gps.huart = &huart5;
|
|
||||||
gps.heading = NAN;
|
|
||||||
gps.heading_mode = 'V';
|
|
||||||
gps.rmc_status = 'V';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Checksum tests ============================ */
|
|
||||||
|
|
||||||
static void test_checksum_valid(void)
|
|
||||||
{
|
|
||||||
TEST("checksum: valid GGA");
|
|
||||||
ASSERT_TRUE(um982_verify_checksum(
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47"),
|
|
||||||
"should be valid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_checksum_valid_ths(void)
|
|
||||||
{
|
|
||||||
TEST("checksum: valid THS");
|
|
||||||
ASSERT_TRUE(um982_verify_checksum("$GNTHS,341.3344,A*1F"),
|
|
||||||
"should be valid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_checksum_invalid(void)
|
|
||||||
{
|
|
||||||
TEST("checksum: invalid (wrong value)");
|
|
||||||
ASSERT_FALSE(um982_verify_checksum("$GNTHS,341.3344,A*FF"),
|
|
||||||
"should be invalid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_checksum_missing_star(void)
|
|
||||||
{
|
|
||||||
TEST("checksum: missing * marker");
|
|
||||||
ASSERT_FALSE(um982_verify_checksum("$GNTHS,341.3344,A"),
|
|
||||||
"should be invalid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_checksum_null(void)
|
|
||||||
{
|
|
||||||
TEST("checksum: NULL input");
|
|
||||||
ASSERT_FALSE(um982_verify_checksum(NULL), "should be false");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_checksum_no_dollar(void)
|
|
||||||
{
|
|
||||||
TEST("checksum: missing $ prefix");
|
|
||||||
ASSERT_FALSE(um982_verify_checksum("GNTHS,341.3344,A*1F"),
|
|
||||||
"should be invalid without $");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Coordinate parsing tests ================== */
|
|
||||||
|
|
||||||
static void test_coord_latitude_north(void)
|
|
||||||
{
|
|
||||||
TEST("coord: latitude 4404.14036 N");
|
|
||||||
double lat = um982_parse_coord("4404.14036", 'N');
|
|
||||||
/* 44 + 04.14036/60 = 44.069006 */
|
|
||||||
ASSERT_NEAR(lat, 44.069006, 0.000001, "latitude");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_coord_latitude_south(void)
|
|
||||||
{
|
|
||||||
TEST("coord: latitude 3358.92500 S (negative)");
|
|
||||||
double lat = um982_parse_coord("3358.92500", 'S');
|
|
||||||
ASSERT_TRUE(lat < 0.0, "should be negative for S");
|
|
||||||
ASSERT_NEAR(lat, -(33.0 + 58.925/60.0), 0.000001, "latitude");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_coord_longitude_3digit(void)
|
|
||||||
{
|
|
||||||
TEST("coord: longitude 12118.85961 W (3-digit degrees)");
|
|
||||||
double lon = um982_parse_coord("12118.85961", 'W');
|
|
||||||
/* 121 + 18.85961/60 = 121.314327 */
|
|
||||||
ASSERT_TRUE(lon < 0.0, "should be negative for W");
|
|
||||||
ASSERT_NEAR(lon, -(121.0 + 18.85961/60.0), 0.000001, "longitude");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_coord_longitude_east(void)
|
|
||||||
{
|
|
||||||
TEST("coord: longitude 11614.19729 E");
|
|
||||||
double lon = um982_parse_coord("11614.19729", 'E');
|
|
||||||
ASSERT_TRUE(lon > 0.0, "should be positive for E");
|
|
||||||
ASSERT_NEAR(lon, 116.0 + 14.19729/60.0, 0.000001, "longitude");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_coord_empty(void)
|
|
||||||
{
|
|
||||||
TEST("coord: empty string returns NAN");
|
|
||||||
ASSERT_NAN(um982_parse_coord("", 'N'), "should be NAN");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_coord_null(void)
|
|
||||||
{
|
|
||||||
TEST("coord: NULL returns NAN");
|
|
||||||
ASSERT_NAN(um982_parse_coord(NULL, 'N'), "should be NAN");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_coord_no_dot(void)
|
|
||||||
{
|
|
||||||
TEST("coord: no decimal point returns NAN");
|
|
||||||
ASSERT_NAN(um982_parse_coord("440414036", 'N'), "should be NAN");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= GGA parsing tests ========================= */
|
|
||||||
|
|
||||||
static void test_parse_gga_full(void)
|
|
||||||
{
|
|
||||||
TEST("GGA: full sentence with all fields");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(1000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.latitude, 44.069006, 0.0001, "latitude");
|
|
||||||
ASSERT_NEAR(gps.longitude, -(121.0 + 18.85961/60.0), 0.0001, "longitude");
|
|
||||||
ASSERT_EQ_INT(gps.fix_quality, 1, "fix quality");
|
|
||||||
ASSERT_EQ_INT(gps.num_satellites, 12, "num sats");
|
|
||||||
ASSERT_NEAR(gps.hdop, 0.98, 0.01, "hdop");
|
|
||||||
ASSERT_NEAR(gps.altitude, 1113.0, 0.1, "altitude");
|
|
||||||
ASSERT_NEAR(gps.geoid_sep, -21.3, 0.1, "geoid sep");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_parse_gga_rtk_fixed(void)
|
|
||||||
{
|
|
||||||
TEST("GGA: RTK fixed (quality=4)");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,023634.00,4004.73871635,N,11614.19729418,E,4,28,0.7,61.0988,M,-8.4923,M,,*5D");
|
|
||||||
|
|
||||||
ASSERT_EQ_INT(gps.fix_quality, 4, "RTK fixed");
|
|
||||||
ASSERT_EQ_INT(gps.num_satellites, 28, "num sats");
|
|
||||||
ASSERT_NEAR(gps.latitude, 40.0 + 4.73871635/60.0, 0.0000001, "latitude");
|
|
||||||
ASSERT_NEAR(gps.longitude, 116.0 + 14.19729418/60.0, 0.0000001, "longitude");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_parse_gga_no_fix(void)
|
|
||||||
{
|
|
||||||
TEST("GGA: no fix (quality=0)");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
/* Compute checksum for this sentence */
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79");
|
|
||||||
|
|
||||||
ASSERT_EQ_INT(gps.fix_quality, 0, "no fix");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= RMC parsing tests ========================= */
|
|
||||||
|
|
||||||
static void test_parse_rmc_valid(void)
|
|
||||||
{
|
|
||||||
TEST("RMC: valid position and speed");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(2000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNRMC,001031.00,A,4404.13993,N,12118.86023,W,0.146,,100117,,,A*7B");
|
|
||||||
|
|
||||||
ASSERT_EQ_INT(gps.rmc_status, 'A', "status");
|
|
||||||
ASSERT_NEAR(gps.latitude, 44.0 + 4.13993/60.0, 0.0001, "latitude");
|
|
||||||
ASSERT_NEAR(gps.longitude, -(121.0 + 18.86023/60.0), 0.0001, "longitude");
|
|
||||||
ASSERT_NEAR(gps.speed_knots, 0.146, 0.001, "speed");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_parse_rmc_void(void)
|
|
||||||
{
|
|
||||||
TEST("RMC: void status (no valid fix)");
|
|
||||||
reset_gps();
|
|
||||||
gps.latitude = 12.34; /* Pre-set to check it doesn't get overwritten */
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNRMC,235959.00,V,,,,,,,100117,,,N*64");
|
|
||||||
|
|
||||||
ASSERT_EQ_INT(gps.rmc_status, 'V', "void status");
|
|
||||||
ASSERT_NEAR(gps.latitude, 12.34, 0.001, "lat should not change on void");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= THS parsing tests ========================= */
|
|
||||||
|
|
||||||
static void test_parse_ths_autonomous(void)
|
|
||||||
{
|
|
||||||
TEST("THS: autonomous heading 341.3344");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(3000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 341.3344, 0.001, "heading");
|
|
||||||
ASSERT_EQ_INT(gps.heading_mode, 'A', "mode");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_parse_ths_not_valid(void)
|
|
||||||
{
|
|
||||||
TEST("THS: not valid mode");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,,V*10");
|
|
||||||
|
|
||||||
ASSERT_NAN(gps.heading, "heading should be NAN when empty");
|
|
||||||
ASSERT_EQ_INT(gps.heading_mode, 'V', "mode V");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_parse_ths_zero(void)
|
|
||||||
{
|
|
||||||
TEST("THS: heading exactly 0.0000");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,0.0000,A*19");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 0.0, 0.001, "heading zero");
|
|
||||||
ASSERT_EQ_INT(gps.heading_mode, 'A', "mode A");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_parse_ths_360_boundary(void)
|
|
||||||
{
|
|
||||||
TEST("THS: heading near 360");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,359.9999,D*13");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 359.9999, 0.001, "heading near 360");
|
|
||||||
ASSERT_EQ_INT(gps.heading_mode, 'D', "mode D");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= VTG parsing tests ========================= */
|
|
||||||
|
|
||||||
static void test_parse_vtg(void)
|
|
||||||
{
|
|
||||||
TEST("VTG: course and speed");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GPVTG,220.86,T,,M,2.550,N,4.724,K,A*34");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.course_true, 220.86, 0.01, "course");
|
|
||||||
ASSERT_NEAR(gps.speed_knots, 2.550, 0.001, "speed knots");
|
|
||||||
ASSERT_NEAR(gps.speed_kmh, 4.724, 0.001, "speed kmh");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Talker ID tests =========================== */
|
|
||||||
|
|
||||||
static void test_talker_gp(void)
|
|
||||||
{
|
|
||||||
TEST("talker: GP prefix parses correctly");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GPTHS,123.4567,A*07");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 123.4567, 0.001, "heading with GP");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_talker_gl(void)
|
|
||||||
{
|
|
||||||
TEST("talker: GL prefix parses correctly");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GLTHS,123.4567,A*1B");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 123.4567, 0.001, "heading with GL");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Feed / line assembly tests ================ */
|
|
||||||
|
|
||||||
static void test_feed_single_sentence(void)
|
|
||||||
{
|
|
||||||
TEST("feed: single complete sentence with CRLF");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(5000);
|
|
||||||
|
|
||||||
const char *data = "$GNTHS,341.3344,A*1F\r\n";
|
|
||||||
um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data));
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 341.3344, 0.001, "heading");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_feed_multiple_sentences(void)
|
|
||||||
{
|
|
||||||
TEST("feed: multiple sentences in one chunk");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(5000);
|
|
||||||
|
|
||||||
const char *data =
|
|
||||||
"$GNTHS,100.0000,A*18\r\n"
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47\r\n";
|
|
||||||
um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data));
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 100.0, 0.01, "heading from THS");
|
|
||||||
ASSERT_EQ_INT(gps.fix_quality, 1, "fix from GGA");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_feed_partial_then_complete(void)
|
|
||||||
{
|
|
||||||
TEST("feed: partial bytes then complete");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(5000);
|
|
||||||
|
|
||||||
const char *part1 = "$GNTHS,200.";
|
|
||||||
const char *part2 = "5000,A*1E\r\n";
|
|
||||||
um982_feed(&gps, (const uint8_t *)part1, (uint16_t)strlen(part1));
|
|
||||||
/* Heading should not be set yet */
|
|
||||||
ASSERT_NAN(gps.heading, "should be NAN before complete");
|
|
||||||
|
|
||||||
um982_feed(&gps, (const uint8_t *)part2, (uint16_t)strlen(part2));
|
|
||||||
ASSERT_NEAR(gps.heading, 200.5, 0.01, "heading after complete");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_feed_bad_checksum_rejected(void)
|
|
||||||
{
|
|
||||||
TEST("feed: bad checksum sentence is rejected");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(5000);
|
|
||||||
|
|
||||||
const char *data = "$GNTHS,999.0000,A*FF\r\n";
|
|
||||||
um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data));
|
|
||||||
|
|
||||||
ASSERT_NAN(gps.heading, "heading should remain NAN");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_feed_versiona_response(void)
|
|
||||||
{
|
|
||||||
TEST("feed: VERSIONA response sets flag");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
const char *data = "#VERSIONA,79,GPS,FINE,2326,378237000,15434,0,18,889;\"UM982\"\r\n";
|
|
||||||
um982_feed(&gps, (const uint8_t *)data, (uint16_t)strlen(data));
|
|
||||||
|
|
||||||
ASSERT_TRUE(gps.version_received, "version_received should be true");
|
|
||||||
ASSERT_TRUE(gps.initialized, "VERSIONA should mark communication alive");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Validity / age tests ====================== */
|
|
||||||
|
|
||||||
static void test_heading_valid_within_timeout(void)
|
|
||||||
{
|
|
||||||
TEST("validity: heading valid within timeout");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(10000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F");
|
|
||||||
|
|
||||||
/* Still at tick 10000 */
|
|
||||||
ASSERT_TRUE(um982_is_heading_valid(&gps), "should be valid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_heading_invalid_after_timeout(void)
|
|
||||||
{
|
|
||||||
TEST("validity: heading invalid after 2s timeout");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(10000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F");
|
|
||||||
|
|
||||||
/* Advance past timeout */
|
|
||||||
mock_set_tick(12500);
|
|
||||||
ASSERT_FALSE(um982_is_heading_valid(&gps), "should be invalid after 2.5s");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_heading_invalid_mode_v(void)
|
|
||||||
{
|
|
||||||
TEST("validity: heading invalid with mode V");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(10000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,,V*10");
|
|
||||||
|
|
||||||
ASSERT_FALSE(um982_is_heading_valid(&gps), "mode V is invalid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_position_valid(void)
|
|
||||||
{
|
|
||||||
TEST("validity: position valid with fix quality 1");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(10000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47");
|
|
||||||
|
|
||||||
ASSERT_TRUE(um982_is_position_valid(&gps), "should be valid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_position_invalid_no_fix(void)
|
|
||||||
{
|
|
||||||
TEST("validity: position invalid with no fix");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(10000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79");
|
|
||||||
|
|
||||||
ASSERT_FALSE(um982_is_position_valid(&gps), "no fix = invalid");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_position_age_uses_last_valid_fix(void)
|
|
||||||
{
|
|
||||||
TEST("age: position age uses last valid fix, not no-fix GGA");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
mock_set_tick(10000);
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47");
|
|
||||||
|
|
||||||
mock_set_tick(12000);
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79");
|
|
||||||
|
|
||||||
mock_set_tick(12500);
|
|
||||||
ASSERT_EQ_INT(um982_position_age(&gps), 2500, "age should still be from last valid fix");
|
|
||||||
ASSERT_FALSE(um982_is_position_valid(&gps), "latest no-fix GGA should invalidate position");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_heading_age(void)
|
|
||||||
{
|
|
||||||
TEST("age: heading age computed correctly");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(10000);
|
|
||||||
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F");
|
|
||||||
|
|
||||||
mock_set_tick(10500);
|
|
||||||
uint32_t age = um982_heading_age(&gps);
|
|
||||||
ASSERT_EQ_INT(age, 500, "age should be 500ms");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Send command tests ======================== */
|
|
||||||
|
|
||||||
static void test_send_command_appends_crlf(void)
|
|
||||||
{
|
|
||||||
TEST("send_command: appends \\r\\n");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
um982_send_command(&gps, "GPGGA COM2 1");
|
|
||||||
|
|
||||||
/* Check that TX buffer contains "GPGGA COM2 1\r\n" */
|
|
||||||
const char *expected = "GPGGA COM2 1\r\n";
|
|
||||||
ASSERT_TRUE(mock_uart_tx_len == strlen(expected), "TX length");
|
|
||||||
ASSERT_TRUE(memcmp(mock_uart_tx_buf, expected, strlen(expected)) == 0,
|
|
||||||
"TX content should be 'GPGGA COM2 1\\r\\n'");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_send_command_null_safety(void)
|
|
||||||
{
|
|
||||||
TEST("send_command: NULL gps returns false");
|
|
||||||
ASSERT_FALSE(um982_send_command(NULL, "RESET"), "should return false");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Init sequence tests ======================= */
|
|
||||||
|
|
||||||
static void test_init_sends_correct_commands(void)
|
|
||||||
{
|
|
||||||
TEST("init: sends correct command sequence");
|
|
||||||
spy_reset();
|
|
||||||
mock_uart_tx_clear();
|
|
||||||
|
|
||||||
/* Pre-load VERSIONA response so init succeeds */
|
|
||||||
const char *ver_resp = "#VERSIONA,79,GPS,FINE,2326,378237000,15434,0,18,889;\"UM982\"\r\n";
|
|
||||||
mock_uart_rx_load(&huart5, (const uint8_t *)ver_resp, (uint16_t)strlen(ver_resp));
|
|
||||||
|
|
||||||
UM982_GPS_t init_gps;
|
|
||||||
bool ok = um982_init(&init_gps, &huart5, 50.0f, 3.0f);
|
|
||||||
|
|
||||||
ASSERT_TRUE(ok, "init should succeed");
|
|
||||||
ASSERT_TRUE(init_gps.initialized, "should be initialized");
|
|
||||||
|
|
||||||
/* Verify TX buffer contains expected commands */
|
|
||||||
const char *tx = (const char *)mock_uart_tx_buf;
|
|
||||||
ASSERT_TRUE(strstr(tx, "UNLOG\r\n") != NULL, "should send UNLOG");
|
|
||||||
ASSERT_TRUE(strstr(tx, "CONFIG HEADING FIXLENGTH\r\n") != NULL, "should send CONFIG HEADING");
|
|
||||||
ASSERT_TRUE(strstr(tx, "CONFIG HEADING LENGTH 50 3\r\n") != NULL, "should send LENGTH");
|
|
||||||
ASSERT_TRUE(strstr(tx, "GPGGA COM2 1\r\n") != NULL, "should enable GGA");
|
|
||||||
ASSERT_TRUE(strstr(tx, "GPRMC COM2 1\r\n") != NULL, "should enable RMC");
|
|
||||||
ASSERT_TRUE(strstr(tx, "GPTHS COM2 0.2\r\n") != NULL, "should enable THS at 5Hz");
|
|
||||||
ASSERT_TRUE(strstr(tx, "SAVECONFIG\r\n") == NULL, "should NOT save config (NVM wear)");
|
|
||||||
ASSERT_TRUE(strstr(tx, "VERSIONA\r\n") != NULL, "should query version");
|
|
||||||
|
|
||||||
/* Verify command order: UNLOG should come before GPGGA */
|
|
||||||
const char *unlog_pos = strstr(tx, "UNLOG\r\n");
|
|
||||||
const char *gpgga_pos = strstr(tx, "GPGGA COM2 1\r\n");
|
|
||||||
ASSERT_TRUE(unlog_pos < gpgga_pos, "UNLOG should precede GPGGA");
|
|
||||||
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_init_no_baseline(void)
|
|
||||||
{
|
|
||||||
TEST("init: baseline=0 skips LENGTH command");
|
|
||||||
spy_reset();
|
|
||||||
mock_uart_tx_clear();
|
|
||||||
|
|
||||||
const char *ver_resp = "#VERSIONA,79,GPS,FINE,2326,378237000,15434,0,18,889;\"UM982\"\r\n";
|
|
||||||
mock_uart_rx_load(&huart5, (const uint8_t *)ver_resp, (uint16_t)strlen(ver_resp));
|
|
||||||
|
|
||||||
UM982_GPS_t init_gps;
|
|
||||||
um982_init(&init_gps, &huart5, 0.0f, 0.0f);
|
|
||||||
|
|
||||||
const char *tx = (const char *)mock_uart_tx_buf;
|
|
||||||
ASSERT_TRUE(strstr(tx, "CONFIG HEADING LENGTH") == NULL, "should NOT send LENGTH");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_init_fails_no_version(void)
|
|
||||||
{
|
|
||||||
TEST("init: fails if no VERSIONA response");
|
|
||||||
spy_reset();
|
|
||||||
mock_uart_tx_clear();
|
|
||||||
|
|
||||||
/* Don't load any RX data — init should timeout */
|
|
||||||
UM982_GPS_t init_gps;
|
|
||||||
bool ok = um982_init(&init_gps, &huart5, 50.0f, 3.0f);
|
|
||||||
|
|
||||||
ASSERT_FALSE(ok, "init should fail without version response");
|
|
||||||
ASSERT_FALSE(init_gps.initialized, "should not be initialized");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_nmea_traffic_sets_initialized_without_versiona(void)
|
|
||||||
{
|
|
||||||
TEST("init state: supported NMEA traffic sets initialized");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
ASSERT_FALSE(gps.initialized, "should start uninitialized");
|
|
||||||
um982_parse_sentence(&gps, "$GNTHS,341.3344,A*1F");
|
|
||||||
ASSERT_TRUE(gps.initialized, "supported NMEA should mark communication alive");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Edge case tests =========================== */
|
|
||||||
|
|
||||||
static void test_empty_fields_handled(void)
|
|
||||||
{
|
|
||||||
TEST("edge: GGA with empty lat/lon fields");
|
|
||||||
reset_gps();
|
|
||||||
gps.latitude = 99.99;
|
|
||||||
gps.longitude = 99.99;
|
|
||||||
|
|
||||||
/* GGA with empty position fields (no fix) */
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,235959.00,,,,,0,00,99.99,,,,,,*79");
|
|
||||||
|
|
||||||
ASSERT_EQ_INT(gps.fix_quality, 0, "no fix");
|
|
||||||
/* Latitude/longitude should not be updated (fields are empty) */
|
|
||||||
ASSERT_NEAR(gps.latitude, 99.99, 0.01, "lat unchanged");
|
|
||||||
ASSERT_NEAR(gps.longitude, 99.99, 0.01, "lon unchanged");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_sentence_too_short(void)
|
|
||||||
{
|
|
||||||
TEST("edge: sentence too short to have formatter");
|
|
||||||
reset_gps();
|
|
||||||
/* Should not crash */
|
|
||||||
um982_parse_sentence(&gps, "$GN");
|
|
||||||
um982_parse_sentence(&gps, "$");
|
|
||||||
um982_parse_sentence(&gps, "");
|
|
||||||
um982_parse_sentence(&gps, NULL);
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_line_overflow(void)
|
|
||||||
{
|
|
||||||
TEST("edge: oversized line is dropped");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
/* Create a line longer than UM982_LINE_BUF_SIZE */
|
|
||||||
char big[200];
|
|
||||||
memset(big, 'X', sizeof(big));
|
|
||||||
big[0] = '$';
|
|
||||||
big[198] = '\n';
|
|
||||||
big[199] = '\0';
|
|
||||||
|
|
||||||
um982_feed(&gps, (const uint8_t *)big, 199);
|
|
||||||
/* Should not crash, heading should still be NAN */
|
|
||||||
ASSERT_NAN(gps.heading, "no valid data from overflow");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_process_via_mock_uart(void)
|
|
||||||
{
|
|
||||||
TEST("process: reads from mock UART RX buffer");
|
|
||||||
reset_gps();
|
|
||||||
mock_set_tick(5000);
|
|
||||||
|
|
||||||
/* Load data into mock UART RX */
|
|
||||||
const char *data = "$GNTHS,275.1234,D*18\r\n";
|
|
||||||
mock_uart_rx_load(&huart5, (const uint8_t *)data, (uint16_t)strlen(data));
|
|
||||||
|
|
||||||
/* Call process() which reads from UART */
|
|
||||||
um982_process(&gps);
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.heading, 275.1234, 0.001, "heading via process()");
|
|
||||||
ASSERT_EQ_INT(gps.heading_mode, 'D', "mode D");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= PR #68 bug regression tests =============== */
|
|
||||||
|
|
||||||
/* These tests specifically verify the bugs found in the reverted PR #68 */
|
|
||||||
|
|
||||||
static void test_regression_sentence_id_with_gn_prefix(void)
|
|
||||||
{
|
|
||||||
TEST("regression: GN-prefixed GGA is correctly identified");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
/* PR #68 bug: strncmp(sentence, "GGA", 3) compared "GNG" vs "GGA" — never matched.
|
|
||||||
* Our fix: skip 2-char talker ID, compare at sentence+3. */
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47");
|
|
||||||
|
|
||||||
ASSERT_EQ_INT(gps.fix_quality, 1, "GGA should parse with GN prefix");
|
|
||||||
ASSERT_NEAR(gps.latitude, 44.069006, 0.001, "latitude should be parsed");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_regression_longitude_3digit_degrees(void)
|
|
||||||
{
|
|
||||||
TEST("regression: 3-digit longitude degrees parsed correctly");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
/* PR #68 bug: hardcoded 2-digit degrees for longitude.
|
|
||||||
* 12118.85961 should be 121° 18.85961' = 121.314327° */
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47");
|
|
||||||
|
|
||||||
ASSERT_NEAR(gps.longitude, -(121.0 + 18.85961/60.0), 0.0001,
|
|
||||||
"longitude 121° should not be parsed as 12°");
|
|
||||||
ASSERT_TRUE(gps.longitude < -100.0, "longitude should be > 100 degrees");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_regression_hemisphere_no_ptr_corrupt(void)
|
|
||||||
{
|
|
||||||
TEST("regression: hemisphere parsing doesn't corrupt field pointer");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
/* PR #68 bug: GGA/RMC hemisphere cases manually advanced ptr,
|
|
||||||
* desynchronizing from field counter. Our parser uses proper tokenizer. */
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNGGA,001043.00,4404.14036,N,12118.85961,W,1,12,0.98,1113.0,M,-21.3,M*47");
|
|
||||||
|
|
||||||
/* After lat/lon, remaining fields should be correct */
|
|
||||||
ASSERT_EQ_INT(gps.num_satellites, 12, "sats after hemisphere");
|
|
||||||
ASSERT_NEAR(gps.hdop, 0.98, 0.01, "hdop after hemisphere");
|
|
||||||
ASSERT_NEAR(gps.altitude, 1113.0, 0.1, "altitude after hemisphere");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
static void test_regression_rmc_also_parsed(void)
|
|
||||||
{
|
|
||||||
TEST("regression: RMC sentence is actually parsed (not dead code)");
|
|
||||||
reset_gps();
|
|
||||||
|
|
||||||
/* PR #68 bug: identifySentence never matched GGA/RMC, so position
|
|
||||||
* parsing was dead code. */
|
|
||||||
um982_parse_sentence(&gps,
|
|
||||||
"$GNRMC,001031.00,A,4404.13993,N,12118.86023,W,0.146,,100117,,,A*7B");
|
|
||||||
|
|
||||||
ASSERT_TRUE(gps.latitude > 44.0, "RMC lat should be parsed");
|
|
||||||
ASSERT_TRUE(gps.longitude < -121.0, "RMC lon should be parsed");
|
|
||||||
ASSERT_NEAR(gps.speed_knots, 0.146, 0.001, "RMC speed");
|
|
||||||
PASS();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ========================= Main ====================================== */
|
|
||||||
|
|
||||||
int main(void)
|
|
||||||
{
|
|
||||||
printf("=== UM982 GPS Driver Tests ===\n\n");
|
|
||||||
|
|
||||||
printf("--- Checksum ---\n");
|
|
||||||
test_checksum_valid();
|
|
||||||
test_checksum_valid_ths();
|
|
||||||
test_checksum_invalid();
|
|
||||||
test_checksum_missing_star();
|
|
||||||
test_checksum_null();
|
|
||||||
test_checksum_no_dollar();
|
|
||||||
|
|
||||||
printf("\n--- Coordinate Parsing ---\n");
|
|
||||||
test_coord_latitude_north();
|
|
||||||
test_coord_latitude_south();
|
|
||||||
test_coord_longitude_3digit();
|
|
||||||
test_coord_longitude_east();
|
|
||||||
test_coord_empty();
|
|
||||||
test_coord_null();
|
|
||||||
test_coord_no_dot();
|
|
||||||
|
|
||||||
printf("\n--- GGA Parsing ---\n");
|
|
||||||
test_parse_gga_full();
|
|
||||||
test_parse_gga_rtk_fixed();
|
|
||||||
test_parse_gga_no_fix();
|
|
||||||
|
|
||||||
printf("\n--- RMC Parsing ---\n");
|
|
||||||
test_parse_rmc_valid();
|
|
||||||
test_parse_rmc_void();
|
|
||||||
|
|
||||||
printf("\n--- THS Parsing ---\n");
|
|
||||||
test_parse_ths_autonomous();
|
|
||||||
test_parse_ths_not_valid();
|
|
||||||
test_parse_ths_zero();
|
|
||||||
test_parse_ths_360_boundary();
|
|
||||||
|
|
||||||
printf("\n--- VTG Parsing ---\n");
|
|
||||||
test_parse_vtg();
|
|
||||||
|
|
||||||
printf("\n--- Talker IDs ---\n");
|
|
||||||
test_talker_gp();
|
|
||||||
test_talker_gl();
|
|
||||||
|
|
||||||
printf("\n--- Feed / Line Assembly ---\n");
|
|
||||||
test_feed_single_sentence();
|
|
||||||
test_feed_multiple_sentences();
|
|
||||||
test_feed_partial_then_complete();
|
|
||||||
test_feed_bad_checksum_rejected();
|
|
||||||
test_feed_versiona_response();
|
|
||||||
|
|
||||||
printf("\n--- Validity / Age ---\n");
|
|
||||||
test_heading_valid_within_timeout();
|
|
||||||
test_heading_invalid_after_timeout();
|
|
||||||
test_heading_invalid_mode_v();
|
|
||||||
test_position_valid();
|
|
||||||
test_position_invalid_no_fix();
|
|
||||||
test_position_age_uses_last_valid_fix();
|
|
||||||
test_heading_age();
|
|
||||||
|
|
||||||
printf("\n--- Send Command ---\n");
|
|
||||||
test_send_command_appends_crlf();
|
|
||||||
test_send_command_null_safety();
|
|
||||||
|
|
||||||
printf("\n--- Init Sequence ---\n");
|
|
||||||
test_init_sends_correct_commands();
|
|
||||||
test_init_no_baseline();
|
|
||||||
test_init_fails_no_version();
|
|
||||||
test_nmea_traffic_sets_initialized_without_versiona();
|
|
||||||
|
|
||||||
printf("\n--- Edge Cases ---\n");
|
|
||||||
test_empty_fields_handled();
|
|
||||||
test_sentence_too_short();
|
|
||||||
test_line_overflow();
|
|
||||||
test_process_via_mock_uart();
|
|
||||||
|
|
||||||
printf("\n--- PR #68 Regression ---\n");
|
|
||||||
test_regression_sentence_id_with_gn_prefix();
|
|
||||||
test_regression_longitude_3digit_degrees();
|
|
||||||
test_regression_hemisphere_no_ptr_corrupt();
|
|
||||||
test_regression_rmc_also_parsed();
|
|
||||||
|
|
||||||
printf("\n===============================================\n");
|
|
||||||
printf(" Results: %d passed, %d failed (of %d total)\n",
|
|
||||||
tests_passed, tests_failed, tests_passed + tests_failed);
|
|
||||||
printf("===============================================\n");
|
|
||||||
|
|
||||||
return tests_failed > 0 ? 1 : 0;
|
|
||||||
}
|
|
||||||
@@ -212,11 +212,6 @@ BUFG bufg_feedback (
|
|||||||
|
|
||||||
// ---- Output BUFG ----
|
// ---- Output BUFG ----
|
||||||
// Routes the jitter-cleaned 400 MHz CLKOUT0 onto a global clock network.
|
// Routes the jitter-cleaned 400 MHz CLKOUT0 onto a global clock network.
|
||||||
// DONT_TOUCH prevents phys_opt_design AggressiveExplore from replicating this
|
|
||||||
// BUFG into a cascaded chain (4 BUFGs in series observed in Build 26), which
|
|
||||||
// added ~243ps of clock insertion delay and caused -187ps clock skew on the
|
|
||||||
// NCO→DSP mixer critical path.
|
|
||||||
(* DONT_TOUCH = "TRUE" *)
|
|
||||||
BUFG bufg_clk400m (
|
BUFG bufg_clk400m (
|
||||||
.I(clk_mmcm_out0),
|
.I(clk_mmcm_out0),
|
||||||
.O(clk_400m_out)
|
.O(clk_400m_out)
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ reg signed [COMB_WIDTH-1:0] comb_delay [0:STAGES-1][0:COMB_DELAY-1];
|
|||||||
// Pipeline valid for comb stages 1-4: delayed by 1 cycle vs comb_pipe to
|
// Pipeline valid for comb stages 1-4: delayed by 1 cycle vs comb_pipe to
|
||||||
// account for CREG+AREG+BREG pipeline inside comb_0_dsp (explicit DSP48E1).
|
// account for CREG+AREG+BREG pipeline inside comb_0_dsp (explicit DSP48E1).
|
||||||
// Comb[0] result appears 1 cycle after data_valid_comb_pipe.
|
// Comb[0] result appears 1 cycle after data_valid_comb_pipe.
|
||||||
(* keep = "true", max_fanout = 16 *) reg data_valid_comb_0_out;
|
(* keep = "true", max_fanout = 4 *) reg data_valid_comb_0_out;
|
||||||
|
|
||||||
// Enhanced control and monitoring
|
// Enhanced control and monitoring
|
||||||
reg [1:0] decimation_counter;
|
reg [1:0] decimation_counter;
|
||||||
(* keep = "true", max_fanout = 16 *) reg data_valid_delayed;
|
(* keep = "true", max_fanout = 4 *) reg data_valid_delayed;
|
||||||
(* keep = "true", max_fanout = 16 *) reg data_valid_comb;
|
(* keep = "true", max_fanout = 4 *) reg data_valid_comb;
|
||||||
(* keep = "true", max_fanout = 16 *) reg data_valid_comb_pipe;
|
(* keep = "true", max_fanout = 4 *) reg data_valid_comb_pipe;
|
||||||
reg [7:0] output_counter;
|
reg [7:0] output_counter;
|
||||||
reg [ACC_WIDTH-1:0] max_integrator_value;
|
reg [ACC_WIDTH-1:0] max_integrator_value;
|
||||||
reg overflow_detected;
|
reg overflow_detected;
|
||||||
|
|||||||
@@ -83,13 +83,3 @@ set_false_path -through [get_pins rx_inst/adc/mmcm_inst/mmcm_adc_400m/LOCKED]
|
|||||||
# Waiving hold on these 8 paths (adc_d_p[0..7] → IDDR) is standard practice
|
# Waiving hold on these 8 paths (adc_d_p[0..7] → IDDR) is standard practice
|
||||||
# for source-synchronous LVDS ADC interfaces using BUFIO capture.
|
# for source-synchronous LVDS ADC interfaces using BUFIO capture.
|
||||||
set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p]
|
set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p]
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Timing margin for 400 MHz critical paths
|
|
||||||
# --------------------------------------------------------------------------
|
|
||||||
# Extra setup uncertainty forces Vivado to leave margin for temperature/voltage/
|
|
||||||
# aging variation. Reduced from 200 ps to 100 ps after NCO→mixer pipeline
|
|
||||||
# register fix eliminated the dominant timing bottleneck (WNS went from +0.002ns
|
|
||||||
# to comfortable margin). 100 ps still provides ~4% guardband on the 2.5ns period.
|
|
||||||
# This is additive to the existing jitter-based uncertainty (~53 ps).
|
|
||||||
set_clock_uncertainty -setup -add 0.100 [get_clocks clk_mmcm_out0]
|
|
||||||
|
|||||||
@@ -222,16 +222,8 @@ set_property IOSTANDARD LVCMOS33 [get_ports {stm32_new_*}]
|
|||||||
set_property IOSTANDARD LVCMOS33 [get_ports {stm32_mixers_enable}]
|
set_property IOSTANDARD LVCMOS33 [get_ports {stm32_mixers_enable}]
|
||||||
# reset_n is DIG_4 (PD12) — constrained above in the RESET section
|
# reset_n is DIG_4 (PD12) — constrained above in the RESET section
|
||||||
|
|
||||||
# DIG_5 = H11, DIG_6 = G12, DIG_7 = H12 — FPGA→STM32 status outputs
|
# DIG_5 = H11, DIG_6 = G12, DIG_7 = H12 — available for FPGA→STM32 status
|
||||||
# DIG_5: AGC saturation flag (PD13 on STM32)
|
# Currently unused in RTL. Could be connected to status outputs if needed.
|
||||||
# DIG_6: reserved (PD14)
|
|
||||||
# DIG_7: reserved (PD15)
|
|
||||||
set_property PACKAGE_PIN H11 [get_ports {gpio_dig5}]
|
|
||||||
set_property PACKAGE_PIN G12 [get_ports {gpio_dig6}]
|
|
||||||
set_property PACKAGE_PIN H12 [get_ports {gpio_dig7}]
|
|
||||||
set_property IOSTANDARD LVCMOS33 [get_ports {gpio_dig*}]
|
|
||||||
set_property DRIVE 8 [get_ports {gpio_dig*}]
|
|
||||||
set_property SLEW SLOW [get_ports {gpio_dig*}]
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# ADC INTERFACE (LVDS — Bank 14, VCCO=3.3V)
|
# ADC INTERFACE (LVDS — Bank 14, VCCO=3.3V)
|
||||||
|
|||||||
@@ -102,19 +102,14 @@ wire signed [17:0] debug_mixed_q_trunc;
|
|||||||
reg [7:0] signal_power_i, signal_power_q;
|
reg [7:0] signal_power_i, signal_power_q;
|
||||||
|
|
||||||
// Internal mixing signals
|
// Internal mixing signals
|
||||||
// Pipeline: NCO fabric reg (1) + DSP48E1 AREG/BREG (1) + MREG (1) + PREG (1) + retiming (1) = 5 cycles
|
// DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 handles all internal pipelining
|
||||||
// The NCO fabric pipeline register was added to break the long NCO→DSP B-port route
|
// Latency: 4 cycles (1 for AREG/BREG, 1 for MREG, 1 for PREG, 1 for post-DSP retiming)
|
||||||
// (1.505ns routing in Build 26, WNS=+0.002ns). With BREG=1 still active inside the DSP,
|
|
||||||
// total latency increases by 1 cycle (2.5ns at 400MHz — negligible for radar).
|
|
||||||
wire signed [MIXER_WIDTH-1:0] adc_signed_w;
|
wire signed [MIXER_WIDTH-1:0] adc_signed_w;
|
||||||
reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q;
|
reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q;
|
||||||
reg mixed_valid;
|
reg mixed_valid;
|
||||||
reg mixer_overflow_i, mixer_overflow_q;
|
reg mixer_overflow_i, mixer_overflow_q;
|
||||||
// Pipeline valid tracking: 5-stage shift register (1 NCO pipe + 3 DSP48E1 + 1 retiming)
|
// Pipeline valid tracking: 4-stage shift register (3 for DSP48E1 + 1 for post-DSP retiming)
|
||||||
reg [4:0] dsp_valid_pipe;
|
reg [3:0] dsp_valid_pipe;
|
||||||
// NCO→DSP pipeline registers — breaks the long NCO sin/cos → DSP48E1 B-port route
|
|
||||||
// DONT_TOUCH prevents Vivado from absorbing these into the DSP or optimizing away
|
|
||||||
(* DONT_TOUCH = "TRUE" *) reg signed [15:0] cos_nco_pipe, sin_nco_pipe;
|
|
||||||
// Post-DSP retiming registers — breaks DSP48E1 CLK→P to fabric timing path
|
// Post-DSP retiming registers — breaks DSP48E1 CLK→P to fabric timing path
|
||||||
// This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing,
|
// This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing,
|
||||||
// ensuring WNS > 0 at 400 MHz regardless of placement seed
|
// ensuring WNS > 0 at 400 MHz regardless of placement seed
|
||||||
@@ -215,11 +210,11 @@ nco_400m_enhanced nco_core (
|
|||||||
//
|
//
|
||||||
// Architecture:
|
// Architecture:
|
||||||
// ADC data → sign-extend to 18b → DSP48E1 A-port (AREG=1 pipelines it)
|
// ADC data → sign-extend to 18b → DSP48E1 A-port (AREG=1 pipelines it)
|
||||||
// NCO cos/sin → fabric pipeline reg → DSP48E1 B-port (BREG=1 pipelines it)
|
// NCO cos/sin → sign-extend to 18b → DSP48E1 B-port (BREG=1 pipelines it)
|
||||||
// Multiply result captured by MREG=1, then output registered by PREG=1
|
// Multiply result captured by MREG=1, then output registered by PREG=1
|
||||||
// force_saturation override applied AFTER DSP48E1 output (not on input path)
|
// force_saturation override applied AFTER DSP48E1 output (not on input path)
|
||||||
//
|
//
|
||||||
// Latency: 4 clock cycles (1 NCO pipe + 1 AREG/BREG + 1 MREG + 1 PREG) + 1 retiming = 5 total
|
// Latency: 3 clock cycles (AREG/BREG + MREG + PREG)
|
||||||
// PREG=1 absorbs DSP48E1 CLK→P delay internally, preventing fabric timing violations
|
// PREG=1 absorbs DSP48E1 CLK→P delay internally, preventing fabric timing violations
|
||||||
// In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only
|
// In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -228,35 +223,24 @@ nco_400m_enhanced nco_core (
|
|||||||
assign adc_signed_w = {1'b0, adc_data, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} -
|
assign adc_signed_w = {1'b0, adc_data, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} -
|
||||||
{1'b0, {ADC_WIDTH{1'b1}}, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} / 2;
|
{1'b0, {ADC_WIDTH{1'b1}}, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} / 2;
|
||||||
|
|
||||||
// Valid pipeline: 5-stage shift register (1 NCO pipe + 3 DSP48E1 AREG+MREG+PREG + 1 retiming)
|
// Valid pipeline: 4-stage shift register (3 for DSP48E1 AREG+MREG+PREG + 1 for retiming)
|
||||||
always @(posedge clk_400m or negedge reset_n_400m) begin
|
always @(posedge clk_400m or negedge reset_n_400m) begin
|
||||||
if (!reset_n_400m) begin
|
if (!reset_n_400m) begin
|
||||||
dsp_valid_pipe <= 5'b00000;
|
dsp_valid_pipe <= 4'b0000;
|
||||||
end else begin
|
end else begin
|
||||||
dsp_valid_pipe <= {dsp_valid_pipe[3:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)};
|
dsp_valid_pipe <= {dsp_valid_pipe[2:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)};
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
`ifdef SIMULATION
|
`ifdef SIMULATION
|
||||||
// ---- Behavioral model for Icarus Verilog simulation ----
|
// ---- Behavioral model for Icarus Verilog simulation ----
|
||||||
// Mimics NCO pipeline + DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (4-cycle DSP + 1 NCO pipe)
|
// Mimics DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (3-cycle latency)
|
||||||
reg signed [MIXER_WIDTH-1:0] adc_signed_reg; // Models AREG
|
reg signed [MIXER_WIDTH-1:0] adc_signed_reg; // Models AREG
|
||||||
reg signed [15:0] cos_pipe_reg, sin_pipe_reg; // Models BREG
|
reg signed [15:0] cos_pipe_reg, sin_pipe_reg; // Models BREG
|
||||||
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_internal, mult_q_internal; // Models MREG
|
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_internal, mult_q_internal; // Models MREG
|
||||||
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_reg, mult_q_reg; // Models PREG
|
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_reg, mult_q_reg; // Models PREG
|
||||||
|
|
||||||
// Stage 0: NCO pipeline — breaks long NCO→DSP route (matches synthesis fabric registers)
|
// Stage 1: AREG/BREG equivalent
|
||||||
always @(posedge clk_400m or negedge reset_n_400m) begin
|
|
||||||
if (!reset_n_400m) begin
|
|
||||||
cos_nco_pipe <= 0;
|
|
||||||
sin_nco_pipe <= 0;
|
|
||||||
end else begin
|
|
||||||
cos_nco_pipe <= cos_out;
|
|
||||||
sin_nco_pipe <= sin_out;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
// Stage 1: AREG/BREG equivalent (uses pipelined NCO outputs)
|
|
||||||
always @(posedge clk_400m or negedge reset_n_400m) begin
|
always @(posedge clk_400m or negedge reset_n_400m) begin
|
||||||
if (!reset_n_400m) begin
|
if (!reset_n_400m) begin
|
||||||
adc_signed_reg <= 0;
|
adc_signed_reg <= 0;
|
||||||
@@ -264,8 +248,8 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
|
|||||||
sin_pipe_reg <= 0;
|
sin_pipe_reg <= 0;
|
||||||
end else begin
|
end else begin
|
||||||
adc_signed_reg <= adc_signed_w;
|
adc_signed_reg <= adc_signed_w;
|
||||||
cos_pipe_reg <= cos_nco_pipe;
|
cos_pipe_reg <= cos_out;
|
||||||
sin_pipe_reg <= sin_nco_pipe;
|
sin_pipe_reg <= sin_out;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -307,20 +291,6 @@ end
|
|||||||
// This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz
|
// This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz
|
||||||
wire [47:0] dsp_p_i, dsp_p_q;
|
wire [47:0] dsp_p_i, dsp_p_q;
|
||||||
|
|
||||||
// NCO pipeline stage — breaks the long NCO sin/cos → DSP48E1 B-port route
|
|
||||||
// (1.505ns routing observed in Build 26). These fabric registers are placed
|
|
||||||
// near the DSP by the placer, splitting the route into two shorter segments.
|
|
||||||
// DONT_TOUCH on the reg declaration (above) prevents absorption/retiming.
|
|
||||||
always @(posedge clk_400m or negedge reset_n_400m) begin
|
|
||||||
if (!reset_n_400m) begin
|
|
||||||
cos_nco_pipe <= 0;
|
|
||||||
sin_nco_pipe <= 0;
|
|
||||||
end else begin
|
|
||||||
cos_nco_pipe <= cos_out;
|
|
||||||
sin_nco_pipe <= sin_out;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
// DSP48E1 for I-channel mixer (adc_signed * cos_out)
|
// DSP48E1 for I-channel mixer (adc_signed * cos_out)
|
||||||
DSP48E1 #(
|
DSP48E1 #(
|
||||||
// Feature control attributes
|
// Feature control attributes
|
||||||
@@ -380,7 +350,7 @@ DSP48E1 #(
|
|||||||
.CEINMODE(1'b0),
|
.CEINMODE(1'b0),
|
||||||
// Data ports
|
// Data ports
|
||||||
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b
|
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b
|
||||||
.B({{2{cos_nco_pipe[15]}}, cos_nco_pipe}), // Sign-extend 16b to 18b (pipelined)
|
.B({{2{cos_out[15]}}, cos_out}), // Sign-extend 16b to 18b
|
||||||
.C(48'b0),
|
.C(48'b0),
|
||||||
.D(25'b0),
|
.D(25'b0),
|
||||||
.CARRYIN(1'b0),
|
.CARRYIN(1'b0),
|
||||||
@@ -462,7 +432,7 @@ DSP48E1 #(
|
|||||||
.CED(1'b0),
|
.CED(1'b0),
|
||||||
.CEINMODE(1'b0),
|
.CEINMODE(1'b0),
|
||||||
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}),
|
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}),
|
||||||
.B({{2{sin_nco_pipe[15]}}, sin_nco_pipe}),
|
.B({{2{sin_out[15]}}, sin_out}),
|
||||||
.C(48'b0),
|
.C(48'b0),
|
||||||
.D(25'b0),
|
.D(25'b0),
|
||||||
.CARRYIN(1'b0),
|
.CARRYIN(1'b0),
|
||||||
@@ -522,7 +492,7 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
|
|||||||
mixer_overflow_q <= 0;
|
mixer_overflow_q <= 0;
|
||||||
saturation_count <= 0;
|
saturation_count <= 0;
|
||||||
overflow_detected <= 0;
|
overflow_detected <= 0;
|
||||||
end else if (dsp_valid_pipe[4]) begin
|
end else if (dsp_valid_pipe[3]) begin
|
||||||
// Force saturation for testing (applied after DSP output, not on input path)
|
// Force saturation for testing (applied after DSP output, not on input path)
|
||||||
if (force_saturation_sync) begin
|
if (force_saturation_sync) begin
|
||||||
mixed_i <= 34'h1FFFFFFFF;
|
mixed_i <= 34'h1FFFFFFFF;
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ always @(posedge clk or negedge reset_n) begin
|
|||||||
state <= ST_DONE;
|
state <= ST_DONE;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
// Timeout: if no ADC data after 1000 cycles (10 us @ 100 MHz), FAIL
|
// Timeout: if no ADC data after 10000 cycles, FAIL
|
||||||
step_cnt <= step_cnt + 1;
|
step_cnt <= step_cnt + 1;
|
||||||
if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin
|
if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin
|
||||||
result_flags[4] <= 1'b0;
|
result_flags[4] <= 1'b0;
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ module radar_receiver_final (
|
|||||||
input wire adc_dco_n, // Data Clock Output N (400MHz LVDS)
|
input wire adc_dco_n, // Data Clock Output N (400MHz LVDS)
|
||||||
output wire adc_pwdn,
|
output wire adc_pwdn,
|
||||||
|
|
||||||
// Chirp counter from transmitter (for matched filter indexing)
|
// Chirp counter from transmitter (for frame sync and matched filter)
|
||||||
input wire [5:0] chirp_counter,
|
input wire [5:0] chirp_counter,
|
||||||
// Frame-start pulse from transmitter (CDC-synchronized, 1 clk_100m cycle)
|
|
||||||
input wire tx_frame_start,
|
|
||||||
|
|
||||||
output wire [31:0] doppler_output,
|
output wire [31:0] doppler_output,
|
||||||
output wire doppler_valid,
|
output wire doppler_valid,
|
||||||
@@ -44,13 +42,6 @@ module radar_receiver_final (
|
|||||||
// [2:0]=shift amount: 0..7 bits. Default 0 = pass-through.
|
// [2:0]=shift amount: 0..7 bits. Default 0 = pass-through.
|
||||||
input wire [3:0] host_gain_shift,
|
input wire [3:0] host_gain_shift,
|
||||||
|
|
||||||
// AGC configuration (opcodes 0x28-0x2C, active only when agc_enable=1)
|
|
||||||
input wire host_agc_enable, // 0x28: 0=manual, 1=auto AGC
|
|
||||||
input wire [7:0] host_agc_target, // 0x29: target peak magnitude
|
|
||||||
input wire [3:0] host_agc_attack, // 0x2A: gain-down step on clipping
|
|
||||||
input wire [3:0] host_agc_decay, // 0x2B: gain-up step when weak
|
|
||||||
input wire [3:0] host_agc_holdoff, // 0x2C: frames before gain-up
|
|
||||||
|
|
||||||
// STM32 toggle signals for mode 00 (STM32-driven) pass-through.
|
// STM32 toggle signals for mode 00 (STM32-driven) pass-through.
|
||||||
// These are CDC-synchronized in radar_system_top.v / radar_transmitter.v
|
// These are CDC-synchronized in radar_system_top.v / radar_transmitter.v
|
||||||
// before reaching this module. In mode 00, the RX mode controller uses
|
// before reaching this module. In mode 00, the RX mode controller uses
|
||||||
@@ -69,12 +60,7 @@ module radar_receiver_final (
|
|||||||
// ADC raw data tap (clk_100m domain, post-DDC, for self-test / debug)
|
// ADC raw data tap (clk_100m domain, post-DDC, for self-test / debug)
|
||||||
output wire [15:0] dbg_adc_i, // DDC output I (16-bit signed, 100 MHz)
|
output wire [15:0] dbg_adc_i, // DDC output I (16-bit signed, 100 MHz)
|
||||||
output wire [15:0] dbg_adc_q, // DDC output Q (16-bit signed, 100 MHz)
|
output wire [15:0] dbg_adc_q, // DDC output Q (16-bit signed, 100 MHz)
|
||||||
output wire dbg_adc_valid, // DDC output valid (100 MHz)
|
output wire dbg_adc_valid // DDC output valid (100 MHz)
|
||||||
|
|
||||||
// AGC status outputs (for status readback / STM32 outer loop)
|
|
||||||
output wire [7:0] agc_saturation_count, // Per-frame clipped sample count
|
|
||||||
output wire [7:0] agc_peak_magnitude, // Per-frame peak (upper 8 bits)
|
|
||||||
output wire [3:0] agc_current_gain // Effective gain_shift encoding
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========== INTERNAL SIGNALS ==========
|
// ========== INTERNAL SIGNALS ==========
|
||||||
@@ -100,9 +86,7 @@ wire adc_valid_sync;
|
|||||||
// Gain-controlled signals (between DDC output and matched filter)
|
// Gain-controlled signals (between DDC output and matched filter)
|
||||||
wire signed [15:0] gc_i, gc_q;
|
wire signed [15:0] gc_i, gc_q;
|
||||||
wire gc_valid;
|
wire gc_valid;
|
||||||
wire [7:0] gc_saturation_count; // Diagnostic: per-frame clipped sample counter
|
wire [7:0] gc_saturation_count; // Diagnostic: clipped sample counter
|
||||||
wire [7:0] gc_peak_magnitude; // Diagnostic: per-frame peak magnitude
|
|
||||||
wire [3:0] gc_current_gain; // Diagnostic: effective gain_shift
|
|
||||||
|
|
||||||
// Reference signals for the processing chain
|
// Reference signals for the processing chain
|
||||||
wire [15:0] long_chirp_real, long_chirp_imag;
|
wire [15:0] long_chirp_real, long_chirp_imag;
|
||||||
@@ -176,7 +160,7 @@ wire clk_400m;
|
|||||||
// the buffered 400MHz DCO clock via adc_dco_bufg, avoiding duplicate
|
// the buffered 400MHz DCO clock via adc_dco_bufg, avoiding duplicate
|
||||||
// IBUFDS instantiations on the same LVDS clock pair.
|
// IBUFDS instantiations on the same LVDS clock pair.
|
||||||
|
|
||||||
// 1. ADC + CDC + Digital Gain
|
// 1. ADC + CDC + AGC
|
||||||
|
|
||||||
// CMOS Output Interface (400MHz Domain)
|
// CMOS Output Interface (400MHz Domain)
|
||||||
wire [7:0] adc_data_cmos; // 8-bit ADC data (CMOS, from ad9484_interface_400m)
|
wire [7:0] adc_data_cmos; // 8-bit ADC data (CMOS, from ad9484_interface_400m)
|
||||||
@@ -238,10 +222,9 @@ ddc_input_interface ddc_if (
|
|||||||
.data_sync_error()
|
.data_sync_error()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2b. Digital Gain Control with AGC
|
// 2b. Digital Gain Control (Fix 3)
|
||||||
// Host-configurable power-of-2 shift between DDC output and matched filter.
|
// Host-configurable power-of-2 shift between DDC output and matched filter.
|
||||||
// Default gain_shift=0, agc_enable=0 → pass-through (no behavioral change).
|
// Default gain_shift=0 → pass-through (no behavioral change from baseline).
|
||||||
// When agc_enable=1: auto-adjusts gain per frame based on peak/saturation.
|
|
||||||
rx_gain_control gain_ctrl (
|
rx_gain_control gain_ctrl (
|
||||||
.clk(clk),
|
.clk(clk),
|
||||||
.reset_n(reset_n),
|
.reset_n(reset_n),
|
||||||
@@ -249,21 +232,10 @@ rx_gain_control gain_ctrl (
|
|||||||
.data_q_in(adc_q_scaled),
|
.data_q_in(adc_q_scaled),
|
||||||
.valid_in(adc_valid_sync),
|
.valid_in(adc_valid_sync),
|
||||||
.gain_shift(host_gain_shift),
|
.gain_shift(host_gain_shift),
|
||||||
// AGC configuration
|
|
||||||
.agc_enable(host_agc_enable),
|
|
||||||
.agc_target(host_agc_target),
|
|
||||||
.agc_attack(host_agc_attack),
|
|
||||||
.agc_decay(host_agc_decay),
|
|
||||||
.agc_holdoff(host_agc_holdoff),
|
|
||||||
// Frame boundary from Doppler processor
|
|
||||||
.frame_boundary(doppler_frame_done),
|
|
||||||
// Outputs
|
|
||||||
.data_i_out(gc_i),
|
.data_i_out(gc_i),
|
||||||
.data_q_out(gc_q),
|
.data_q_out(gc_q),
|
||||||
.valid_out(gc_valid),
|
.valid_out(gc_valid),
|
||||||
.saturation_count(gc_saturation_count),
|
.saturation_count(gc_saturation_count)
|
||||||
.peak_magnitude(gc_peak_magnitude),
|
|
||||||
.current_gain(gc_current_gain)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Dual Chirp Memory Loader
|
// 3. Dual Chirp Memory Loader
|
||||||
@@ -394,31 +366,32 @@ mti_canceller #(
|
|||||||
.mti_first_chirp(mti_first_chirp)
|
.mti_first_chirp(mti_first_chirp)
|
||||||
);
|
);
|
||||||
|
|
||||||
// ========== FRAME SYNC FROM TRANSMITTER ==========
|
// ========== FRAME SYNC USING chirp_counter ==========
|
||||||
// [FPGA-001 FIXED] Use the authoritative new_chirp_frame signal from the
|
reg [5:0] chirp_counter_prev;
|
||||||
// transmitter (via plfm_chirp_controller_enhanced), CDC-synchronized to
|
|
||||||
// clk_100m in radar_system_top. Previous code tried to derive frame
|
|
||||||
// boundaries from chirp_counter == 0, but that counter comes from the
|
|
||||||
// transmitter path (plfm_chirp_controller_enhanced) which does NOT wrap
|
|
||||||
// at chirps_per_elev — it overflows to N and only wraps at 6-bit rollover
|
|
||||||
// (64). This caused frame pulses at half the expected rate for N=32.
|
|
||||||
reg tx_frame_start_prev;
|
|
||||||
reg new_frame_pulse;
|
reg new_frame_pulse;
|
||||||
|
|
||||||
always @(posedge clk or negedge reset_n) begin
|
always @(posedge clk or negedge reset_n) begin
|
||||||
if (!reset_n) begin
|
if (!reset_n) begin
|
||||||
tx_frame_start_prev <= 1'b0;
|
chirp_counter_prev <= 6'd0;
|
||||||
new_frame_pulse <= 1'b0;
|
new_frame_pulse <= 1'b0;
|
||||||
end else begin
|
end else begin
|
||||||
|
// Default: no pulse
|
||||||
new_frame_pulse <= 1'b0;
|
new_frame_pulse <= 1'b0;
|
||||||
|
|
||||||
// Edge detect: tx_frame_start is a toggle-CDC derived pulse that
|
// Dynamic frame detection using host_chirps_per_elev.
|
||||||
// may be 1 clock wide. Capture rising edge for clean 1-cycle pulse.
|
// Detect frame boundary when chirp_counter changes AND is a
|
||||||
if (tx_frame_start && !tx_frame_start_prev) begin
|
// multiple of host_chirps_per_elev (0, N, 2N, 3N, ...).
|
||||||
|
// Uses a modulo counter that resets at host_chirps_per_elev.
|
||||||
|
if (chirp_counter != chirp_counter_prev) begin
|
||||||
|
if (chirp_counter == 6'd0 ||
|
||||||
|
chirp_counter == host_chirps_per_elev ||
|
||||||
|
chirp_counter == {host_chirps_per_elev, 1'b0}) begin
|
||||||
new_frame_pulse <= 1'b1;
|
new_frame_pulse <= 1'b1;
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
tx_frame_start_prev <= tx_frame_start;
|
// Store previous value
|
||||||
|
chirp_counter_prev <= chirp_counter;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -484,6 +457,14 @@ always @(posedge clk or negedge reset_n) begin
|
|||||||
`endif
|
`endif
|
||||||
chirps_in_current_frame <= 0;
|
chirps_in_current_frame <= 0;
|
||||||
end
|
end
|
||||||
|
|
||||||
|
// Monitor chirp counter pattern
|
||||||
|
if (chirp_counter != chirp_counter_prev) begin
|
||||||
|
`ifdef SIMULATION
|
||||||
|
$display("[TOP] chirp_counter: %0d ? %0d",
|
||||||
|
chirp_counter_prev, chirp_counter);
|
||||||
|
`endif
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -493,9 +474,4 @@ assign dbg_adc_i = adc_i_scaled;
|
|||||||
assign dbg_adc_q = adc_q_scaled;
|
assign dbg_adc_q = adc_q_scaled;
|
||||||
assign dbg_adc_valid = adc_valid_sync;
|
assign dbg_adc_valid = adc_valid_sync;
|
||||||
|
|
||||||
// ========== AGC STATUS OUTPUTS ==========
|
|
||||||
assign agc_saturation_count = gc_saturation_count;
|
|
||||||
assign agc_peak_magnitude = gc_peak_magnitude;
|
|
||||||
assign agc_current_gain = gc_current_gain;
|
|
||||||
|
|
||||||
endmodule
|
endmodule
|
||||||
|
|||||||
@@ -125,13 +125,7 @@ module radar_system_top (
|
|||||||
output wire [5:0] dbg_range_bin,
|
output wire [5:0] dbg_range_bin,
|
||||||
|
|
||||||
// System status
|
// System status
|
||||||
output wire [3:0] system_status,
|
output wire [3:0] system_status
|
||||||
|
|
||||||
// FPGA→STM32 GPIO outputs (DIG_5..DIG_7 on 50T board)
|
|
||||||
// Used by STM32 outer AGC loop to read saturation state without USB polling.
|
|
||||||
output wire gpio_dig5, // DIG_5 (H11→PD13): AGC saturation flag (1=clipping detected)
|
|
||||||
output wire gpio_dig6, // DIG_6 (G12→PD14): reserved (tied low)
|
|
||||||
output wire gpio_dig7 // DIG_7 (H12→PD15): reserved (tied low)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -193,11 +187,6 @@ wire [15:0] rx_dbg_adc_i;
|
|||||||
wire [15:0] rx_dbg_adc_q;
|
wire [15:0] rx_dbg_adc_q;
|
||||||
wire rx_dbg_adc_valid;
|
wire rx_dbg_adc_valid;
|
||||||
|
|
||||||
// AGC status from receiver (for status readback and GPIO)
|
|
||||||
wire [7:0] rx_agc_saturation_count;
|
|
||||||
wire [7:0] rx_agc_peak_magnitude;
|
|
||||||
wire [3:0] rx_agc_current_gain;
|
|
||||||
|
|
||||||
// Data packing for USB
|
// Data packing for USB
|
||||||
wire [31:0] usb_range_profile;
|
wire [31:0] usb_range_profile;
|
||||||
wire usb_range_valid;
|
wire usb_range_valid;
|
||||||
@@ -270,13 +259,6 @@ reg host_cfar_enable; // Opcode 0x25: 1=CFAR, 0=simple threshold
|
|||||||
reg host_mti_enable; // Opcode 0x26: 1=MTI active, 0=pass-through
|
reg host_mti_enable; // Opcode 0x26: 1=MTI active, 0=pass-through
|
||||||
reg [2:0] host_dc_notch_width; // Opcode 0x27: DC notch ±width bins (0=off, 1..7)
|
reg [2:0] host_dc_notch_width; // Opcode 0x27: DC notch ±width bins (0=off, 1..7)
|
||||||
|
|
||||||
// AGC configuration registers (host-configurable via USB, opcodes 0x28-0x2C)
|
|
||||||
reg host_agc_enable; // Opcode 0x28: 0=manual gain, 1=auto AGC
|
|
||||||
reg [7:0] host_agc_target; // Opcode 0x29: target peak magnitude (default 200)
|
|
||||||
reg [3:0] host_agc_attack; // Opcode 0x2A: gain-down step on clipping (default 1)
|
|
||||||
reg [3:0] host_agc_decay; // Opcode 0x2B: gain-up step when weak (default 1)
|
|
||||||
reg [3:0] host_agc_holdoff; // Opcode 0x2C: frames to wait before gain-up (default 4)
|
|
||||||
|
|
||||||
// Board bring-up self-test registers (opcode 0x30 trigger, 0x31 readback)
|
// Board bring-up self-test registers (opcode 0x30 trigger, 0x31 readback)
|
||||||
reg host_self_test_trigger; // Opcode 0x30: self-clearing pulse
|
reg host_self_test_trigger; // Opcode 0x30: self-clearing pulse
|
||||||
wire self_test_busy;
|
wire self_test_busy;
|
||||||
@@ -505,8 +487,6 @@ radar_receiver_final rx_inst (
|
|||||||
|
|
||||||
// Chirp counter from transmitter (CDC-synchronized from 120 MHz domain)
|
// Chirp counter from transmitter (CDC-synchronized from 120 MHz domain)
|
||||||
.chirp_counter(tx_current_chirp_sync),
|
.chirp_counter(tx_current_chirp_sync),
|
||||||
// Frame-start pulse from transmitter (CDC-synchronized toggle→pulse)
|
|
||||||
.tx_frame_start(tx_new_chirp_frame_sync),
|
|
||||||
|
|
||||||
// ADC Physical Interface
|
// ADC Physical Interface
|
||||||
.adc_d_p(adc_d_p),
|
.adc_d_p(adc_d_p),
|
||||||
@@ -538,12 +518,6 @@ radar_receiver_final rx_inst (
|
|||||||
.host_chirps_per_elev(host_chirps_per_elev),
|
.host_chirps_per_elev(host_chirps_per_elev),
|
||||||
// Fix 3: digital gain control
|
// Fix 3: digital gain control
|
||||||
.host_gain_shift(host_gain_shift),
|
.host_gain_shift(host_gain_shift),
|
||||||
// AGC configuration (opcodes 0x28-0x2C)
|
|
||||||
.host_agc_enable(host_agc_enable),
|
|
||||||
.host_agc_target(host_agc_target),
|
|
||||||
.host_agc_attack(host_agc_attack),
|
|
||||||
.host_agc_decay(host_agc_decay),
|
|
||||||
.host_agc_holdoff(host_agc_holdoff),
|
|
||||||
// STM32 toggle signals for RX mode controller (mode 00 pass-through).
|
// STM32 toggle signals for RX mode controller (mode 00 pass-through).
|
||||||
// These are the raw GPIO inputs — the RX mode controller's edge detectors
|
// These are the raw GPIO inputs — the RX mode controller's edge detectors
|
||||||
// (inside radar_mode_controller) handle debouncing/edge detection.
|
// (inside radar_mode_controller) handle debouncing/edge detection.
|
||||||
@@ -558,11 +532,7 @@ radar_receiver_final rx_inst (
|
|||||||
// ADC debug tap (for self-test / bring-up)
|
// ADC debug tap (for self-test / bring-up)
|
||||||
.dbg_adc_i(rx_dbg_adc_i),
|
.dbg_adc_i(rx_dbg_adc_i),
|
||||||
.dbg_adc_q(rx_dbg_adc_q),
|
.dbg_adc_q(rx_dbg_adc_q),
|
||||||
.dbg_adc_valid(rx_dbg_adc_valid),
|
.dbg_adc_valid(rx_dbg_adc_valid)
|
||||||
// AGC status outputs
|
|
||||||
.agc_saturation_count(rx_agc_saturation_count),
|
|
||||||
.agc_peak_magnitude(rx_agc_peak_magnitude),
|
|
||||||
.agc_current_gain(rx_agc_current_gain)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -774,13 +744,7 @@ if (USB_MODE == 0) begin : gen_ft601
|
|||||||
// Self-test status readback
|
// Self-test status readback
|
||||||
.status_self_test_flags(self_test_flags_latched),
|
.status_self_test_flags(self_test_flags_latched),
|
||||||
.status_self_test_detail(self_test_detail_latched),
|
.status_self_test_detail(self_test_detail_latched),
|
||||||
.status_self_test_busy(self_test_busy),
|
.status_self_test_busy(self_test_busy)
|
||||||
|
|
||||||
// AGC status readback
|
|
||||||
.status_agc_current_gain(rx_agc_current_gain),
|
|
||||||
.status_agc_peak_magnitude(rx_agc_peak_magnitude),
|
|
||||||
.status_agc_saturation_count(rx_agc_saturation_count),
|
|
||||||
.status_agc_enable(host_agc_enable)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// FT2232H ports unused in FT601 mode — tie off
|
// FT2232H ports unused in FT601 mode — tie off
|
||||||
@@ -841,13 +805,7 @@ end else begin : gen_ft2232h
|
|||||||
// Self-test status readback
|
// Self-test status readback
|
||||||
.status_self_test_flags(self_test_flags_latched),
|
.status_self_test_flags(self_test_flags_latched),
|
||||||
.status_self_test_detail(self_test_detail_latched),
|
.status_self_test_detail(self_test_detail_latched),
|
||||||
.status_self_test_busy(self_test_busy),
|
.status_self_test_busy(self_test_busy)
|
||||||
|
|
||||||
// AGC status readback
|
|
||||||
.status_agc_current_gain(rx_agc_current_gain),
|
|
||||||
.status_agc_peak_magnitude(rx_agc_peak_magnitude),
|
|
||||||
.status_agc_saturation_count(rx_agc_saturation_count),
|
|
||||||
.status_agc_enable(host_agc_enable)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// FT601 ports unused in FT2232H mode — tie off
|
// FT601 ports unused in FT2232H mode — tie off
|
||||||
@@ -934,12 +892,6 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
|
|||||||
// Ground clutter removal defaults (disabled — backward-compatible)
|
// Ground clutter removal defaults (disabled — backward-compatible)
|
||||||
host_mti_enable <= 1'b0; // MTI off
|
host_mti_enable <= 1'b0; // MTI off
|
||||||
host_dc_notch_width <= 3'd0; // DC notch off
|
host_dc_notch_width <= 3'd0; // DC notch off
|
||||||
// AGC defaults (disabled — backward-compatible with manual gain)
|
|
||||||
host_agc_enable <= 1'b0; // AGC off (manual gain)
|
|
||||||
host_agc_target <= 8'd200; // Target peak magnitude
|
|
||||||
host_agc_attack <= 4'd1; // 1-step gain-down on clipping
|
|
||||||
host_agc_decay <= 4'd1; // 1-step gain-up when weak
|
|
||||||
host_agc_holdoff <= 4'd4; // 4 frames before gain-up
|
|
||||||
// Self-test defaults
|
// Self-test defaults
|
||||||
host_self_test_trigger <= 1'b0; // Self-test idle
|
host_self_test_trigger <= 1'b0; // Self-test idle
|
||||||
end else begin
|
end else begin
|
||||||
@@ -984,12 +936,6 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
|
|||||||
// Ground clutter removal opcodes
|
// Ground clutter removal opcodes
|
||||||
8'h26: host_mti_enable <= usb_cmd_value[0];
|
8'h26: host_mti_enable <= usb_cmd_value[0];
|
||||||
8'h27: host_dc_notch_width <= usb_cmd_value[2:0];
|
8'h27: host_dc_notch_width <= usb_cmd_value[2:0];
|
||||||
// AGC configuration opcodes
|
|
||||||
8'h28: host_agc_enable <= usb_cmd_value[0];
|
|
||||||
8'h29: host_agc_target <= usb_cmd_value[7:0];
|
|
||||||
8'h2A: host_agc_attack <= usb_cmd_value[3:0];
|
|
||||||
8'h2B: host_agc_decay <= usb_cmd_value[3:0];
|
|
||||||
8'h2C: host_agc_holdoff <= usb_cmd_value[3:0];
|
|
||||||
// Board bring-up self-test opcodes
|
// Board bring-up self-test opcodes
|
||||||
8'h30: host_self_test_trigger <= 1'b1; // Trigger self-test
|
8'h30: host_self_test_trigger <= 1'b1; // Trigger self-test
|
||||||
8'h31: host_status_request <= 1'b1; // Self-test readback (status alias)
|
8'h31: host_status_request <= 1'b1; // Self-test readback (status alias)
|
||||||
@@ -1032,16 +978,6 @@ end
|
|||||||
|
|
||||||
assign system_status = status_reg;
|
assign system_status = status_reg;
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// FPGA→STM32 GPIO OUTPUTS (DIG_5, DIG_6, DIG_7)
|
|
||||||
// ============================================================================
|
|
||||||
// DIG_5: AGC saturation flag — high when per-frame saturation_count > 0.
|
|
||||||
// STM32 reads PD13 to detect clipping and adjust ADAR1000 VGA gain.
|
|
||||||
// DIG_6, DIG_7: Reserved (tied low for future use).
|
|
||||||
assign gpio_dig5 = (rx_agc_saturation_count != 8'd0);
|
|
||||||
assign gpio_dig6 = 1'b0;
|
|
||||||
assign gpio_dig7 = 1'b0;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DEBUG AND VERIFICATION
|
// DEBUG AND VERIFICATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -76,12 +76,7 @@ module radar_system_top_50t (
|
|||||||
output wire ft_rd_n, // Read strobe (active low)
|
output wire ft_rd_n, // Read strobe (active low)
|
||||||
output wire ft_wr_n, // Write strobe (active low)
|
output wire ft_wr_n, // Write strobe (active low)
|
||||||
output wire ft_oe_n, // Output enable / bus direction
|
output wire ft_oe_n, // Output enable / bus direction
|
||||||
output wire ft_siwu, // Send Immediate / WakeUp
|
output wire ft_siwu // Send Immediate / WakeUp
|
||||||
|
|
||||||
// ===== FPGA→STM32 GPIO (Bank 15: 3.3V) =====
|
|
||||||
output wire gpio_dig5, // DIG_5 (H11→PD13): AGC saturation flag
|
|
||||||
output wire gpio_dig6, // DIG_6 (G12→PD14): reserved
|
|
||||||
output wire gpio_dig7 // DIG_7 (H12→PD15): reserved
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) =====
|
// ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) =====
|
||||||
@@ -212,12 +207,7 @@ module radar_system_top_50t (
|
|||||||
.dbg_doppler_valid (dbg_doppler_valid_nc),
|
.dbg_doppler_valid (dbg_doppler_valid_nc),
|
||||||
.dbg_doppler_bin (dbg_doppler_bin_nc),
|
.dbg_doppler_bin (dbg_doppler_bin_nc),
|
||||||
.dbg_range_bin (dbg_range_bin_nc),
|
.dbg_range_bin (dbg_range_bin_nc),
|
||||||
.system_status (system_status_nc),
|
.system_status (system_status_nc)
|
||||||
|
|
||||||
// ----- FPGA→STM32 GPIO (DIG_5..DIG_7) -----
|
|
||||||
.gpio_dig5 (gpio_dig5),
|
|
||||||
.gpio_dig6 (gpio_dig6),
|
|
||||||
.gpio_dig7 (gpio_dig7)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
endmodule
|
endmodule
|
||||||
|
|||||||
@@ -3,32 +3,19 @@
|
|||||||
/**
|
/**
|
||||||
* rx_gain_control.v
|
* rx_gain_control.v
|
||||||
*
|
*
|
||||||
* Digital gain control with optional per-frame automatic gain control (AGC)
|
* Host-configurable digital gain control for the receive path.
|
||||||
* for the receive path. Placed between DDC output and matched filter input.
|
* Placed between DDC output (ddc_input_interface) and matched filter input.
|
||||||
*
|
*
|
||||||
* Manual mode (agc_enable=0):
|
* Features:
|
||||||
* - Uses host_gain_shift directly (backward-compatible, no behavioral change)
|
* - Bidirectional power-of-2 gain shift (arithmetic shift)
|
||||||
* - gain_shift[3] = direction: 0 = left shift (amplify), 1 = right shift (attenuate)
|
* - gain_shift[3] = direction: 0 = left shift (amplify), 1 = right shift (attenuate)
|
||||||
* - gain_shift[2:0] = amount: 0..7 bits
|
* - gain_shift[2:0] = amount: 0..7 bits
|
||||||
* - Symmetric saturation to ±32767 on overflow
|
* - Symmetric saturation to ±32767 on overflow (left shift only)
|
||||||
|
* - Saturation counter: 8-bit, counts samples that clipped (wraps at 255)
|
||||||
|
* - 1-cycle latency, valid-in/valid-out pipeline
|
||||||
|
* - Zero-overhead pass-through when gain_shift == 0
|
||||||
*
|
*
|
||||||
* AGC mode (agc_enable=1):
|
* Intended insertion point in radar_receiver_final.v:
|
||||||
* - Per-frame automatic gain adjustment based on peak/saturation metrics
|
|
||||||
* - Internal signed gain: -7 (max attenuation) to +7 (max amplification)
|
|
||||||
* - On frame_boundary:
|
|
||||||
* * If saturation detected: gain -= agc_attack (fast, immediate)
|
|
||||||
* * Else if peak < target after holdoff frames: gain += agc_decay (slow)
|
|
||||||
* * Else: hold current gain
|
|
||||||
* - host_gain_shift serves as initial gain when AGC first enabled
|
|
||||||
*
|
|
||||||
* Status outputs (for readback via status_words):
|
|
||||||
* - current_gain[3:0]: effective gain_shift encoding (manual or AGC)
|
|
||||||
* - peak_magnitude[7:0]: per-frame peak |sample| (upper 8 bits of 15-bit value)
|
|
||||||
* - saturation_count[7:0]: per-frame clipped sample count (capped at 255)
|
|
||||||
*
|
|
||||||
* Timing: 1-cycle data latency, valid-in/valid-out pipeline.
|
|
||||||
*
|
|
||||||
* Insertion point in radar_receiver_final.v:
|
|
||||||
* ddc_input_interface → rx_gain_control → matched_filter_multi_segment
|
* ddc_input_interface → rx_gain_control → matched_filter_multi_segment
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -41,75 +28,27 @@ module rx_gain_control (
|
|||||||
input wire signed [15:0] data_q_in,
|
input wire signed [15:0] data_q_in,
|
||||||
input wire valid_in,
|
input wire valid_in,
|
||||||
|
|
||||||
// Host gain configuration (from USB command opcode 0x16)
|
// Gain configuration (from host via USB command)
|
||||||
// [3] = direction: 0=amplify (left shift), 1=attenuate (right shift)
|
// [3] = direction: 0=amplify (left shift), 1=attenuate (right shift)
|
||||||
// [2:0]=shift amount: 0..7 bits. Default 0x00 = pass-through.
|
// [2:0] = shift amount: 0..7 bits
|
||||||
// In AGC mode: serves as initial gain on AGC enable transition.
|
|
||||||
input wire [3:0] gain_shift,
|
input wire [3:0] gain_shift,
|
||||||
|
|
||||||
// AGC configuration inputs (from host via USB, opcodes 0x28-0x2C)
|
|
||||||
input wire agc_enable, // 0x28: 0=manual gain, 1=auto AGC
|
|
||||||
input wire [7:0] agc_target, // 0x29: target peak magnitude (unsigned, default 200)
|
|
||||||
input wire [3:0] agc_attack, // 0x2A: attenuation step on clipping (default 1)
|
|
||||||
input wire [3:0] agc_decay, // 0x2B: amplification step when weak (default 1)
|
|
||||||
input wire [3:0] agc_holdoff, // 0x2C: frames to wait before gain-up (default 4)
|
|
||||||
|
|
||||||
// Frame boundary pulse (1 clk cycle, from Doppler frame_complete)
|
|
||||||
input wire frame_boundary,
|
|
||||||
|
|
||||||
// Data output (to matched filter)
|
// Data output (to matched filter)
|
||||||
output reg signed [15:0] data_i_out,
|
output reg signed [15:0] data_i_out,
|
||||||
output reg signed [15:0] data_q_out,
|
output reg signed [15:0] data_q_out,
|
||||||
output reg valid_out,
|
output reg valid_out,
|
||||||
|
|
||||||
// Diagnostics / status readback
|
// Diagnostics
|
||||||
output reg [7:0] saturation_count, // Per-frame clipped sample count (capped at 255)
|
output reg [7:0] saturation_count // Number of clipped samples (wraps at 255)
|
||||||
output reg [7:0] peak_magnitude, // Per-frame peak |sample| (upper 8 bits of 15-bit)
|
|
||||||
output reg [3:0] current_gain // Current effective gain_shift (for status readback)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// =========================================================================
|
// Decompose gain_shift
|
||||||
// INTERNAL AGC STATE
|
wire shift_right = gain_shift[3];
|
||||||
// =========================================================================
|
wire [2:0] shift_amt = gain_shift[2:0];
|
||||||
|
|
||||||
// Signed internal gain: -7 (max attenuation) to +7 (max amplification)
|
// -------------------------------------------------------------------------
|
||||||
// Stored as 4-bit signed (range -8..+7, clamped to -7..+7)
|
// Combinational shift + saturation
|
||||||
reg signed [3:0] agc_gain;
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
// Holdoff counter: counts frames without saturation before allowing gain-up
|
|
||||||
reg [3:0] holdoff_counter;
|
|
||||||
|
|
||||||
// Per-frame accumulators (running, reset on frame_boundary)
|
|
||||||
reg [7:0] frame_sat_count; // Clipped samples this frame
|
|
||||||
reg [14:0] frame_peak; // Peak |sample| this frame (15-bit unsigned)
|
|
||||||
|
|
||||||
// Previous AGC enable state (for detecting 0→1 transition)
|
|
||||||
reg agc_enable_prev;
|
|
||||||
|
|
||||||
// Combinational helpers for inclusive frame-boundary snapshot
|
|
||||||
// (used when valid_in and frame_boundary coincide)
|
|
||||||
reg wire_frame_sat_incr;
|
|
||||||
reg wire_frame_peak_update;
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// EFFECTIVE GAIN SELECTION
|
|
||||||
// =========================================================================
|
|
||||||
|
|
||||||
// Convert between signed internal gain and the gain_shift[3:0] encoding.
|
|
||||||
// gain_shift[3]=0, [2:0]=N → amplify by N bits (internal gain = +N)
|
|
||||||
// gain_shift[3]=1, [2:0]=N → attenuate by N bits (internal gain = -N)
|
|
||||||
|
|
||||||
// Effective gain_shift used for the actual shift operation
|
|
||||||
wire [3:0] effective_gain;
|
|
||||||
assign effective_gain = agc_enable ? current_gain : gain_shift;
|
|
||||||
|
|
||||||
// Decompose effective gain for shift logic
|
|
||||||
wire shift_right = effective_gain[3];
|
|
||||||
wire [2:0] shift_amt = effective_gain[2:0];
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// COMBINATIONAL SHIFT + SATURATION
|
|
||||||
// =========================================================================
|
|
||||||
// Use wider intermediates to detect overflow on left shift.
|
// Use wider intermediates to detect overflow on left shift.
|
||||||
// 24 bits is enough: 16 + 7 shift = 23 significant bits max.
|
// 24 bits is enough: 16 + 7 shift = 23 significant bits max.
|
||||||
|
|
||||||
@@ -130,153 +69,26 @@ wire signed [15:0] sat_i = overflow_i ? (shifted_i[23] ? -16'sd32768 : 16'sd3276
|
|||||||
wire signed [15:0] sat_q = overflow_q ? (shifted_q[23] ? -16'sd32768 : 16'sd32767)
|
wire signed [15:0] sat_q = overflow_q ? (shifted_q[23] ? -16'sd32768 : 16'sd32767)
|
||||||
: shifted_q[15:0];
|
: shifted_q[15:0];
|
||||||
|
|
||||||
// =========================================================================
|
// -------------------------------------------------------------------------
|
||||||
// PEAK MAGNITUDE TRACKING (combinational)
|
// Registered output stage (1-cycle latency)
|
||||||
// =========================================================================
|
// -------------------------------------------------------------------------
|
||||||
// Absolute value of signed 16-bit: flip sign bit if negative.
|
|
||||||
// Result is 15-bit unsigned [0, 32767]. (We ignore -32768 → 32767 edge case.)
|
|
||||||
wire [14:0] abs_i = data_i_in[15] ? (~data_i_in[14:0] + 15'd1) : data_i_in[14:0];
|
|
||||||
wire [14:0] abs_q = data_q_in[15] ? (~data_q_in[14:0] + 15'd1) : data_q_in[14:0];
|
|
||||||
wire [14:0] max_iq = (abs_i > abs_q) ? abs_i : abs_q;
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// SIGNED GAIN ↔ GAIN_SHIFT ENCODING CONVERSION
|
|
||||||
// =========================================================================
|
|
||||||
// Convert signed agc_gain to gain_shift[3:0] encoding
|
|
||||||
function [3:0] signed_to_encoding;
|
|
||||||
input signed [3:0] g;
|
|
||||||
begin
|
|
||||||
if (g >= 0)
|
|
||||||
signed_to_encoding = {1'b0, g[2:0]}; // amplify
|
|
||||||
else
|
|
||||||
signed_to_encoding = {1'b1, (~g[2:0]) + 3'd1}; // attenuate: -g
|
|
||||||
end
|
|
||||||
endfunction
|
|
||||||
|
|
||||||
// Convert gain_shift[3:0] encoding to signed gain
|
|
||||||
function signed [3:0] encoding_to_signed;
|
|
||||||
input [3:0] enc;
|
|
||||||
begin
|
|
||||||
if (enc[3] == 1'b0)
|
|
||||||
encoding_to_signed = {1'b0, enc[2:0]}; // +0..+7
|
|
||||||
else
|
|
||||||
encoding_to_signed = -$signed({1'b0, enc[2:0]}); // -1..-7
|
|
||||||
end
|
|
||||||
endfunction
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// CLAMPING HELPER
|
|
||||||
// =========================================================================
|
|
||||||
// Clamp a wider signed value to [-7, +7]
|
|
||||||
function signed [3:0] clamp_gain;
|
|
||||||
input signed [4:0] val; // 5-bit to handle overflow from add
|
|
||||||
begin
|
|
||||||
if (val > 5'sd7)
|
|
||||||
clamp_gain = 4'sd7;
|
|
||||||
else if (val < -5'sd7)
|
|
||||||
clamp_gain = -4'sd7;
|
|
||||||
else
|
|
||||||
clamp_gain = val[3:0];
|
|
||||||
end
|
|
||||||
endfunction
|
|
||||||
|
|
||||||
// =========================================================================
|
|
||||||
// REGISTERED OUTPUT + AGC STATE MACHINE
|
|
||||||
// =========================================================================
|
|
||||||
always @(posedge clk or negedge reset_n) begin
|
always @(posedge clk or negedge reset_n) begin
|
||||||
if (!reset_n) begin
|
if (!reset_n) begin
|
||||||
// Data path
|
|
||||||
data_i_out <= 16'sd0;
|
data_i_out <= 16'sd0;
|
||||||
data_q_out <= 16'sd0;
|
data_q_out <= 16'sd0;
|
||||||
valid_out <= 1'b0;
|
valid_out <= 1'b0;
|
||||||
// Status outputs
|
|
||||||
saturation_count <= 8'd0;
|
saturation_count <= 8'd0;
|
||||||
peak_magnitude <= 8'd0;
|
|
||||||
current_gain <= 4'd0;
|
|
||||||
// AGC internal state
|
|
||||||
agc_gain <= 4'sd0;
|
|
||||||
holdoff_counter <= 4'd0;
|
|
||||||
frame_sat_count <= 8'd0;
|
|
||||||
frame_peak <= 15'd0;
|
|
||||||
agc_enable_prev <= 1'b0;
|
|
||||||
end else begin
|
end else begin
|
||||||
// Track AGC enable transitions
|
|
||||||
agc_enable_prev <= agc_enable;
|
|
||||||
|
|
||||||
// Compute inclusive metrics: if valid_in fires this cycle,
|
|
||||||
// include current sample in the snapshot taken at frame_boundary.
|
|
||||||
// This avoids losing the last sample when valid_in and
|
|
||||||
// frame_boundary coincide (NBA last-write-wins would otherwise
|
|
||||||
// snapshot stale values then reset, dropping the sample entirely).
|
|
||||||
wire_frame_sat_incr = (valid_in && (overflow_i || overflow_q)
|
|
||||||
&& (frame_sat_count != 8'hFF));
|
|
||||||
wire_frame_peak_update = (valid_in && (max_iq > frame_peak));
|
|
||||||
|
|
||||||
// ---- Data pipeline (1-cycle latency) ----
|
|
||||||
valid_out <= valid_in;
|
valid_out <= valid_in;
|
||||||
|
|
||||||
if (valid_in) begin
|
if (valid_in) begin
|
||||||
data_i_out <= sat_i;
|
data_i_out <= sat_i;
|
||||||
data_q_out <= sat_q;
|
data_q_out <= sat_q;
|
||||||
|
|
||||||
// Per-frame saturation counting
|
// Count clipped samples (either channel clipping counts as 1)
|
||||||
if ((overflow_i || overflow_q) && (frame_sat_count != 8'hFF))
|
if ((overflow_i || overflow_q) && (saturation_count != 8'hFF))
|
||||||
frame_sat_count <= frame_sat_count + 8'd1;
|
saturation_count <= saturation_count + 8'd1;
|
||||||
|
|
||||||
// Per-frame peak tracking (pre-gain, measures input signal level)
|
|
||||||
if (max_iq > frame_peak)
|
|
||||||
frame_peak <= max_iq;
|
|
||||||
end
|
end
|
||||||
|
|
||||||
// ---- Frame boundary: AGC update + metric snapshot ----
|
|
||||||
if (frame_boundary) begin
|
|
||||||
// Snapshot per-frame metrics INCLUDING current sample if valid_in
|
|
||||||
saturation_count <= wire_frame_sat_incr
|
|
||||||
? (frame_sat_count + 8'd1)
|
|
||||||
: frame_sat_count;
|
|
||||||
peak_magnitude <= wire_frame_peak_update
|
|
||||||
? max_iq[14:7]
|
|
||||||
: frame_peak[14:7];
|
|
||||||
|
|
||||||
// Reset per-frame accumulators for next frame
|
|
||||||
frame_sat_count <= 8'd0;
|
|
||||||
frame_peak <= 15'd0;
|
|
||||||
|
|
||||||
if (agc_enable) begin
|
|
||||||
// AGC auto-adjustment at frame boundary
|
|
||||||
// Use inclusive counts/peaks (accounting for simultaneous valid_in)
|
|
||||||
if (wire_frame_sat_incr || frame_sat_count > 8'd0) begin
|
|
||||||
// Clipping detected: reduce gain immediately (attack)
|
|
||||||
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) -
|
|
||||||
$signed({1'b0, agc_attack}));
|
|
||||||
holdoff_counter <= agc_holdoff; // Reset holdoff
|
|
||||||
end else if ((wire_frame_peak_update ? max_iq[14:7] : frame_peak[14:7])
|
|
||||||
< agc_target) begin
|
|
||||||
// Signal too weak: increase gain after holdoff expires
|
|
||||||
if (holdoff_counter == 4'd0) begin
|
|
||||||
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) +
|
|
||||||
$signed({1'b0, agc_decay}));
|
|
||||||
end else begin
|
|
||||||
holdoff_counter <= holdoff_counter - 4'd1;
|
|
||||||
end
|
|
||||||
end else begin
|
|
||||||
// Signal in good range, no saturation: hold gain
|
|
||||||
// Reset holdoff so next weak frame has to wait again
|
|
||||||
holdoff_counter <= agc_holdoff;
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
// ---- AGC enable transition: initialize from host gain ----
|
|
||||||
if (agc_enable && !agc_enable_prev) begin
|
|
||||||
agc_gain <= encoding_to_signed(gain_shift);
|
|
||||||
holdoff_counter <= agc_holdoff;
|
|
||||||
end
|
|
||||||
|
|
||||||
// ---- Update current_gain output ----
|
|
||||||
if (agc_enable)
|
|
||||||
current_gain <= signed_to_encoding(agc_gain);
|
|
||||||
else
|
|
||||||
current_gain <= gain_shift;
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -120,10 +120,9 @@ set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets {ft_clkout_IBUF}]
|
|||||||
|
|
||||||
# ---- Run implementation steps ----
|
# ---- Run implementation steps ----
|
||||||
opt_design -directive Explore
|
opt_design -directive Explore
|
||||||
place_design -directive ExtraNetDelay_high
|
place_design -directive Explore
|
||||||
phys_opt_design -directive AggressiveExplore
|
|
||||||
route_design -directive AggressiveExplore
|
|
||||||
phys_opt_design -directive AggressiveExplore
|
phys_opt_design -directive AggressiveExplore
|
||||||
|
route_design -directive Explore
|
||||||
phys_opt_design -directive AggressiveExplore
|
phys_opt_design -directive AggressiveExplore
|
||||||
|
|
||||||
set impl_elapsed [expr {[clock seconds] - $impl_start}]
|
set impl_elapsed [expr {[clock seconds] - $impl_start}]
|
||||||
|
|||||||
@@ -1,449 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Co-simulation Comparison: RTL vs Python Model for AERIS-10 DDC Chain.
|
|
||||||
|
|
||||||
Reads the ADC hex test vectors, runs them through the bit-accurate Python
|
|
||||||
model (fpga_model.py), then compares the output against the RTL simulation
|
|
||||||
CSV (from tb_ddc_cosim.v).
|
|
||||||
|
|
||||||
Key considerations:
|
|
||||||
- The RTL DDC has LFSR phase dithering on the NCO FTW, so exact bit-match
|
|
||||||
is not expected. We use statistical metrics (correlation, RMS error).
|
|
||||||
- The CDC (gray-coded 400→100 MHz crossing) may introduce non-deterministic
|
|
||||||
latency offsets. We auto-align using cross-correlation.
|
|
||||||
- The comparison reports pass/fail based on configurable thresholds.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 compare.py [scenario]
|
|
||||||
|
|
||||||
scenario: dc, single_target, multi_target, noise_only, sine_1mhz
|
|
||||||
(default: dc)
|
|
||||||
|
|
||||||
Author: Phase 0.5 co-simulation suite for PLFM_RADAR
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Add this directory to path for imports
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
from fpga_model import SignalChain
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Configuration
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
# Thresholds for pass/fail
|
|
||||||
# These are generous because of LFSR dithering and CDC latency jitter
|
|
||||||
MAX_RMS_ERROR_LSB = 50.0 # Max RMS error in 18-bit LSBs
|
|
||||||
MIN_CORRELATION = 0.90 # Min Pearson correlation coefficient
|
|
||||||
MAX_LATENCY_DRIFT = 15 # Max latency offset between RTL and model (samples)
|
|
||||||
MAX_COUNT_DIFF = 20 # Max output count difference (LFSR dithering affects CIC timing)
|
|
||||||
|
|
||||||
# Scenarios
|
|
||||||
SCENARIOS = {
|
|
||||||
'dc': {
|
|
||||||
'adc_hex': 'adc_dc.hex',
|
|
||||||
'rtl_csv': 'rtl_bb_dc.csv',
|
|
||||||
'description': 'DC input (ADC=128)',
|
|
||||||
# DC input: expect small outputs, but LFSR dithering adds ~+128 LSB
|
|
||||||
# average bias to NCO FTW which accumulates through CIC integrators
|
|
||||||
# as a small DC offset (~15-20 LSB in baseband). This is expected.
|
|
||||||
'max_rms': 25.0, # Relaxed to account for LFSR dithering bias
|
|
||||||
'min_corr': -1.0, # Correlation not meaningful for near-zero
|
|
||||||
},
|
|
||||||
'single_target': {
|
|
||||||
'adc_hex': 'adc_single_target.hex',
|
|
||||||
'rtl_csv': 'rtl_bb_single_target.csv',
|
|
||||||
'description': 'Single target at 500m',
|
|
||||||
'max_rms': MAX_RMS_ERROR_LSB,
|
|
||||||
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
|
|
||||||
},
|
|
||||||
'multi_target': {
|
|
||||||
'adc_hex': 'adc_multi_target.hex',
|
|
||||||
'rtl_csv': 'rtl_bb_multi_target.csv',
|
|
||||||
'description': 'Multi-target (5 targets)',
|
|
||||||
'max_rms': MAX_RMS_ERROR_LSB,
|
|
||||||
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
|
|
||||||
},
|
|
||||||
'noise_only': {
|
|
||||||
'adc_hex': 'adc_noise_only.hex',
|
|
||||||
'rtl_csv': 'rtl_bb_noise_only.csv',
|
|
||||||
'description': 'Noise only',
|
|
||||||
'max_rms': MAX_RMS_ERROR_LSB,
|
|
||||||
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
|
|
||||||
},
|
|
||||||
'sine_1mhz': {
|
|
||||||
'adc_hex': 'adc_sine_1mhz.hex',
|
|
||||||
'rtl_csv': 'rtl_bb_sine_1mhz.csv',
|
|
||||||
'description': '1 MHz sine wave',
|
|
||||||
'max_rms': MAX_RMS_ERROR_LSB,
|
|
||||||
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Helper functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def load_adc_hex(filepath):
|
|
||||||
"""Load 8-bit unsigned ADC samples from hex file."""
|
|
||||||
samples = []
|
|
||||||
with open(filepath) as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith('//'):
|
|
||||||
continue
|
|
||||||
samples.append(int(line, 16))
|
|
||||||
return samples
|
|
||||||
|
|
||||||
|
|
||||||
def load_rtl_csv(filepath):
|
|
||||||
"""Load RTL baseband output CSV (sample_idx, baseband_i, baseband_q)."""
|
|
||||||
bb_i = []
|
|
||||||
bb_q = []
|
|
||||||
with open(filepath) as f:
|
|
||||||
f.readline() # Skip header
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = line.split(',')
|
|
||||||
bb_i.append(int(parts[1]))
|
|
||||||
bb_q.append(int(parts[2]))
|
|
||||||
return bb_i, bb_q
|
|
||||||
|
|
||||||
|
|
||||||
def run_python_model(adc_samples):
|
|
||||||
"""Run ADC samples through the Python DDC model.
|
|
||||||
|
|
||||||
Returns the 18-bit FIR outputs (not the 16-bit DDC interface outputs),
|
|
||||||
because the RTL testbench captures the FIR output directly
|
|
||||||
(baseband_i_reg <= fir_i_out in ddc_400m.v).
|
|
||||||
"""
|
|
||||||
|
|
||||||
chain = SignalChain()
|
|
||||||
result = chain.process_adc_block(adc_samples)
|
|
||||||
|
|
||||||
# Use fir_i_raw / fir_q_raw (18-bit) to match RTL's baseband output
|
|
||||||
# which is the FIR output before DDC interface 18->16 rounding
|
|
||||||
bb_i = result['fir_i_raw']
|
|
||||||
bb_q = result['fir_q_raw']
|
|
||||||
|
|
||||||
return bb_i, bb_q
|
|
||||||
|
|
||||||
|
|
||||||
def compute_rms_error(a, b):
|
|
||||||
"""Compute RMS error between two equal-length lists."""
|
|
||||||
if len(a) != len(b):
|
|
||||||
raise ValueError(f"Length mismatch: {len(a)} vs {len(b)}")
|
|
||||||
if len(a) == 0:
|
|
||||||
return 0.0
|
|
||||||
sum_sq = sum((x - y) ** 2 for x, y in zip(a, b, strict=False))
|
|
||||||
return math.sqrt(sum_sq / len(a))
|
|
||||||
|
|
||||||
|
|
||||||
def compute_max_abs_error(a, b):
|
|
||||||
"""Compute maximum absolute error between two equal-length lists."""
|
|
||||||
if len(a) != len(b) or len(a) == 0:
|
|
||||||
return 0
|
|
||||||
return max(abs(x - y) for x, y in zip(a, b, strict=False))
|
|
||||||
|
|
||||||
|
|
||||||
def compute_correlation(a, b):
|
|
||||||
"""Compute Pearson correlation coefficient."""
|
|
||||||
n = len(a)
|
|
||||||
if n < 2:
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
mean_a = sum(a) / n
|
|
||||||
mean_b = sum(b) / n
|
|
||||||
|
|
||||||
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
|
|
||||||
std_a_sq = sum((x - mean_a) ** 2 for x in a)
|
|
||||||
std_b_sq = sum((x - mean_b) ** 2 for x in b)
|
|
||||||
|
|
||||||
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
|
|
||||||
# Near-zero variance (e.g., DC input)
|
|
||||||
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
|
|
||||||
|
|
||||||
return cov / math.sqrt(std_a_sq * std_b_sq)
|
|
||||||
|
|
||||||
|
|
||||||
def cross_correlate_lag(a, b, max_lag=20):
|
|
||||||
"""
|
|
||||||
Find the lag that maximizes cross-correlation between a and b.
|
|
||||||
Returns (best_lag, best_correlation) where positive lag means b is delayed.
|
|
||||||
"""
|
|
||||||
n = min(len(a), len(b))
|
|
||||||
if n < 10:
|
|
||||||
return 0, 0.0
|
|
||||||
|
|
||||||
best_lag = 0
|
|
||||||
best_corr = -2.0
|
|
||||||
|
|
||||||
for lag in range(-max_lag, max_lag + 1):
|
|
||||||
# Align: a[start_a:end_a] vs b[start_b:end_b]
|
|
||||||
if lag >= 0:
|
|
||||||
start_a = lag
|
|
||||||
start_b = 0
|
|
||||||
else:
|
|
||||||
start_a = 0
|
|
||||||
start_b = -lag
|
|
||||||
|
|
||||||
end = min(len(a) - start_a, len(b) - start_b)
|
|
||||||
if end < 10:
|
|
||||||
continue
|
|
||||||
|
|
||||||
seg_a = a[start_a:start_a + end]
|
|
||||||
seg_b = b[start_b:start_b + end]
|
|
||||||
|
|
||||||
corr = compute_correlation(seg_a, seg_b)
|
|
||||||
if corr > best_corr:
|
|
||||||
best_corr = corr
|
|
||||||
best_lag = lag
|
|
||||||
|
|
||||||
return best_lag, best_corr
|
|
||||||
|
|
||||||
|
|
||||||
def compute_signal_stats(samples):
|
|
||||||
"""Compute basic statistics of a signal."""
|
|
||||||
if not samples:
|
|
||||||
return {'mean': 0, 'rms': 0, 'min': 0, 'max': 0, 'count': 0}
|
|
||||||
n = len(samples)
|
|
||||||
mean = sum(samples) / n
|
|
||||||
rms = math.sqrt(sum(x * x for x in samples) / n)
|
|
||||||
return {
|
|
||||||
'mean': mean,
|
|
||||||
'rms': rms,
|
|
||||||
'min': min(samples),
|
|
||||||
'max': max(samples),
|
|
||||||
'count': n,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main comparison
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def compare_scenario(scenario_name):
|
|
||||||
"""Run comparison for one scenario. Returns True if passed."""
|
|
||||||
if scenario_name not in SCENARIOS:
|
|
||||||
return False
|
|
||||||
|
|
||||||
cfg = SCENARIOS[scenario_name]
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Load ADC data ----
|
|
||||||
adc_path = os.path.join(base_dir, cfg['adc_hex'])
|
|
||||||
if not os.path.exists(adc_path):
|
|
||||||
return False
|
|
||||||
adc_samples = load_adc_hex(adc_path)
|
|
||||||
|
|
||||||
# ---- Load RTL output ----
|
|
||||||
rtl_path = os.path.join(base_dir, cfg['rtl_csv'])
|
|
||||||
if not os.path.exists(rtl_path):
|
|
||||||
return False
|
|
||||||
rtl_i, rtl_q = load_rtl_csv(rtl_path)
|
|
||||||
|
|
||||||
# ---- Run Python model ----
|
|
||||||
py_i, py_q = run_python_model(adc_samples)
|
|
||||||
|
|
||||||
# ---- Length comparison ----
|
|
||||||
len_diff = abs(len(rtl_i) - len(py_i))
|
|
||||||
|
|
||||||
# ---- Signal statistics ----
|
|
||||||
rtl_i_stats = compute_signal_stats(rtl_i)
|
|
||||||
rtl_q_stats = compute_signal_stats(rtl_q)
|
|
||||||
py_i_stats = compute_signal_stats(py_i)
|
|
||||||
py_q_stats = compute_signal_stats(py_q)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Trim to common length ----
|
|
||||||
common_len = min(len(rtl_i), len(py_i))
|
|
||||||
if common_len < 10:
|
|
||||||
return False
|
|
||||||
|
|
||||||
rtl_i_trim = rtl_i[:common_len]
|
|
||||||
rtl_q_trim = rtl_q[:common_len]
|
|
||||||
py_i_trim = py_i[:common_len]
|
|
||||||
py_q_trim = py_q[:common_len]
|
|
||||||
|
|
||||||
# ---- Cross-correlation to find latency offset ----
|
|
||||||
lag_i, _corr_i = cross_correlate_lag(rtl_i_trim, py_i_trim,
|
|
||||||
max_lag=MAX_LATENCY_DRIFT)
|
|
||||||
lag_q, _corr_q = cross_correlate_lag(rtl_q_trim, py_q_trim,
|
|
||||||
max_lag=MAX_LATENCY_DRIFT)
|
|
||||||
|
|
||||||
# ---- Apply latency correction ----
|
|
||||||
best_lag = lag_i # Use I-channel lag (should be same as Q)
|
|
||||||
if abs(lag_i - lag_q) > 1:
|
|
||||||
# Use the average
|
|
||||||
best_lag = (lag_i + lag_q) // 2
|
|
||||||
|
|
||||||
if best_lag > 0:
|
|
||||||
# RTL is delayed relative to Python
|
|
||||||
aligned_rtl_i = rtl_i_trim[best_lag:]
|
|
||||||
aligned_rtl_q = rtl_q_trim[best_lag:]
|
|
||||||
aligned_py_i = py_i_trim[:len(aligned_rtl_i)]
|
|
||||||
aligned_py_q = py_q_trim[:len(aligned_rtl_q)]
|
|
||||||
elif best_lag < 0:
|
|
||||||
# Python is delayed relative to RTL
|
|
||||||
aligned_py_i = py_i_trim[-best_lag:]
|
|
||||||
aligned_py_q = py_q_trim[-best_lag:]
|
|
||||||
aligned_rtl_i = rtl_i_trim[:len(aligned_py_i)]
|
|
||||||
aligned_rtl_q = rtl_q_trim[:len(aligned_py_q)]
|
|
||||||
else:
|
|
||||||
aligned_rtl_i = rtl_i_trim
|
|
||||||
aligned_rtl_q = rtl_q_trim
|
|
||||||
aligned_py_i = py_i_trim
|
|
||||||
aligned_py_q = py_q_trim
|
|
||||||
|
|
||||||
aligned_len = min(len(aligned_rtl_i), len(aligned_py_i))
|
|
||||||
aligned_rtl_i = aligned_rtl_i[:aligned_len]
|
|
||||||
aligned_rtl_q = aligned_rtl_q[:aligned_len]
|
|
||||||
aligned_py_i = aligned_py_i[:aligned_len]
|
|
||||||
aligned_py_q = aligned_py_q[:aligned_len]
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Error metrics (after alignment) ----
|
|
||||||
rms_i = compute_rms_error(aligned_rtl_i, aligned_py_i)
|
|
||||||
rms_q = compute_rms_error(aligned_rtl_q, aligned_py_q)
|
|
||||||
compute_max_abs_error(aligned_rtl_i, aligned_py_i)
|
|
||||||
compute_max_abs_error(aligned_rtl_q, aligned_py_q)
|
|
||||||
corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i)
|
|
||||||
corr_q_aligned = compute_correlation(aligned_rtl_q, aligned_py_q)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- First/last sample comparison ----
|
|
||||||
for k in range(min(10, aligned_len)):
|
|
||||||
ei = aligned_rtl_i[k] - aligned_py_i[k]
|
|
||||||
eq = aligned_rtl_q[k] - aligned_py_q[k]
|
|
||||||
|
|
||||||
# ---- Write detailed comparison CSV ----
|
|
||||||
compare_csv_path = os.path.join(base_dir, f"compare_{scenario_name}.csv")
|
|
||||||
with open(compare_csv_path, 'w') as f:
|
|
||||||
f.write("idx,rtl_i,py_i,err_i,rtl_q,py_q,err_q\n")
|
|
||||||
for k in range(aligned_len):
|
|
||||||
ei = aligned_rtl_i[k] - aligned_py_i[k]
|
|
||||||
eq = aligned_rtl_q[k] - aligned_py_q[k]
|
|
||||||
f.write(f"{k},{aligned_rtl_i[k]},{aligned_py_i[k]},{ei},"
|
|
||||||
f"{aligned_rtl_q[k]},{aligned_py_q[k]},{eq}\n")
|
|
||||||
|
|
||||||
# ---- Pass/Fail ----
|
|
||||||
max_rms = cfg.get('max_rms', MAX_RMS_ERROR_LSB)
|
|
||||||
min_corr = cfg.get('min_corr', MIN_CORRELATION)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
# Check 1: Output count sanity
|
|
||||||
count_ok = len_diff <= MAX_COUNT_DIFF
|
|
||||||
results.append(('Output count match', count_ok,
|
|
||||||
f"diff={len_diff} <= {MAX_COUNT_DIFF}"))
|
|
||||||
|
|
||||||
# Check 2: RMS amplitude ratio (RTL vs Python should have same power)
|
|
||||||
# The LFSR dithering randomizes sample phases but preserves overall
|
|
||||||
# signal power, so RMS amplitudes should match within ~10%.
|
|
||||||
rtl_rms = max(rtl_i_stats['rms'], rtl_q_stats['rms'])
|
|
||||||
py_rms = max(py_i_stats['rms'], py_q_stats['rms'])
|
|
||||||
if py_rms > 1.0 and rtl_rms > 1.0:
|
|
||||||
rms_ratio = max(rtl_rms, py_rms) / min(rtl_rms, py_rms)
|
|
||||||
rms_ratio_ok = rms_ratio <= 1.20 # Within 20%
|
|
||||||
results.append(('RMS amplitude ratio', rms_ratio_ok,
|
|
||||||
f"ratio={rms_ratio:.3f} <= 1.20"))
|
|
||||||
else:
|
|
||||||
# Near-zero signals (DC input): check absolute RMS error
|
|
||||||
rms_ok = max(rms_i, rms_q) <= max_rms
|
|
||||||
results.append(('RMS error (low signal)', rms_ok,
|
|
||||||
f"max(I={rms_i:.2f}, Q={rms_q:.2f}) <= {max_rms:.1f}"))
|
|
||||||
|
|
||||||
# Check 3: Mean DC offset match
|
|
||||||
# Both should have similar DC bias. For large signals (where LFSR dithering
|
|
||||||
# causes the NCO to walk in phase), allow the mean to differ proportionally
|
|
||||||
# to the signal RMS. Use max(30 LSB, 3% of signal RMS).
|
|
||||||
mean_err_i = abs(rtl_i_stats['mean'] - py_i_stats['mean'])
|
|
||||||
mean_err_q = abs(rtl_q_stats['mean'] - py_q_stats['mean'])
|
|
||||||
max_mean_err = max(mean_err_i, mean_err_q)
|
|
||||||
signal_rms = max(rtl_rms, py_rms)
|
|
||||||
mean_threshold = max(30.0, signal_rms * 0.03) # 3% of signal RMS or 30 LSB
|
|
||||||
mean_ok = max_mean_err <= mean_threshold
|
|
||||||
results.append(('Mean DC offset match', mean_ok,
|
|
||||||
f"max_diff={max_mean_err:.1f} <= {mean_threshold:.1f}"))
|
|
||||||
|
|
||||||
# Check 4: Correlation (skip for near-zero signals or dithered scenarios)
|
|
||||||
if min_corr > -0.5:
|
|
||||||
corr_ok = min(corr_i_aligned, corr_q_aligned) >= min_corr
|
|
||||||
results.append(('Correlation', corr_ok,
|
|
||||||
f"min(I={corr_i_aligned:.4f}, Q={corr_q_aligned:.4f}) >= {min_corr:.2f}"))
|
|
||||||
|
|
||||||
# Check 5: Dynamic range match
|
|
||||||
# Peak amplitudes should be in the same ballpark
|
|
||||||
rtl_peak = max(abs(rtl_i_stats['min']), abs(rtl_i_stats['max']),
|
|
||||||
abs(rtl_q_stats['min']), abs(rtl_q_stats['max']))
|
|
||||||
py_peak = max(abs(py_i_stats['min']), abs(py_i_stats['max']),
|
|
||||||
abs(py_q_stats['min']), abs(py_q_stats['max']))
|
|
||||||
if py_peak > 10 and rtl_peak > 10:
|
|
||||||
peak_ratio = max(rtl_peak, py_peak) / min(rtl_peak, py_peak)
|
|
||||||
peak_ok = peak_ratio <= 1.50 # Within 50%
|
|
||||||
results.append(('Peak amplitude ratio', peak_ok,
|
|
||||||
f"ratio={peak_ratio:.3f} <= 1.50"))
|
|
||||||
|
|
||||||
# Check 6: Latency offset
|
|
||||||
lag_ok = abs(best_lag) <= MAX_LATENCY_DRIFT
|
|
||||||
results.append(('Latency offset', lag_ok,
|
|
||||||
f"|{best_lag}| <= {MAX_LATENCY_DRIFT}"))
|
|
||||||
|
|
||||||
# ---- Report ----
|
|
||||||
all_pass = True
|
|
||||||
for _name, ok, _detail in results:
|
|
||||||
if not ok:
|
|
||||||
all_pass = False
|
|
||||||
|
|
||||||
if all_pass:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return all_pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run comparison for specified scenario(s)."""
|
|
||||||
if len(sys.argv) > 1:
|
|
||||||
scenario = sys.argv[1]
|
|
||||||
if scenario == 'all':
|
|
||||||
# Run all scenarios that have RTL CSV files
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
overall_pass = True
|
|
||||||
run_count = 0
|
|
||||||
pass_count = 0
|
|
||||||
for name, cfg in SCENARIOS.items():
|
|
||||||
rtl_path = os.path.join(base_dir, cfg['rtl_csv'])
|
|
||||||
if os.path.exists(rtl_path):
|
|
||||||
ok = compare_scenario(name)
|
|
||||||
run_count += 1
|
|
||||||
if ok:
|
|
||||||
pass_count += 1
|
|
||||||
else:
|
|
||||||
overall_pass = False
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if overall_pass:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
return 0 if overall_pass else 1
|
|
||||||
ok = compare_scenario(scenario)
|
|
||||||
return 0 if ok else 1
|
|
||||||
ok = compare_scenario('dc')
|
|
||||||
return 0 if ok else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Co-simulation Comparison: RTL vs Python Model for AERIS-10 Doppler Processor.
|
|
||||||
|
|
||||||
Compares the RTL Doppler output (from tb_doppler_cosim.v) against the Python
|
|
||||||
model golden reference (from gen_doppler_golden.py).
|
|
||||||
|
|
||||||
After fixing the windowing pipeline bugs in doppler_processor.v (BRAM address
|
|
||||||
alignment and pipeline staging), the RTL achieves BIT-PERFECT match with the
|
|
||||||
Python model. The comparison checks:
|
|
||||||
1. Per-range-bin peak Doppler bin agreement (100% required)
|
|
||||||
2. Per-range-bin I/Q correlation (1.0 expected)
|
|
||||||
3. Per-range-bin magnitude spectrum correlation (1.0 expected)
|
|
||||||
4. Global output energy (exact match expected)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 compare_doppler.py [scenario|all]
|
|
||||||
|
|
||||||
scenario: stationary, moving, two_targets (default: stationary)
|
|
||||||
all: run all scenarios
|
|
||||||
|
|
||||||
Author: Phase 0.5 Doppler co-simulation suite for PLFM_RADAR
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Configuration
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
DOPPLER_FFT = 32
|
|
||||||
RANGE_BINS = 64
|
|
||||||
TOTAL_OUTPUTS = RANGE_BINS * DOPPLER_FFT # 2048
|
|
||||||
SUBFRAME_SIZE = 16
|
|
||||||
|
|
||||||
SCENARIOS = {
|
|
||||||
'stationary': {
|
|
||||||
'golden_csv': 'doppler_golden_py_stationary.csv',
|
|
||||||
'rtl_csv': 'rtl_doppler_stationary.csv',
|
|
||||||
'description': 'Single stationary target at ~500m',
|
|
||||||
},
|
|
||||||
'moving': {
|
|
||||||
'golden_csv': 'doppler_golden_py_moving.csv',
|
|
||||||
'rtl_csv': 'rtl_doppler_moving.csv',
|
|
||||||
'description': 'Single moving target v=15m/s',
|
|
||||||
},
|
|
||||||
'two_targets': {
|
|
||||||
'golden_csv': 'doppler_golden_py_two_targets.csv',
|
|
||||||
'rtl_csv': 'rtl_doppler_two_targets.csv',
|
|
||||||
'description': 'Two targets at different ranges/velocities',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Pass/fail thresholds — BIT-PERFECT match expected after pipeline fix
|
|
||||||
PEAK_AGREEMENT_MIN = 1.00 # 100% peak Doppler bin agreement required
|
|
||||||
MAG_CORR_MIN = 0.99 # Near-perfect magnitude correlation required
|
|
||||||
ENERGY_RATIO_MIN = 0.999 # Energy ratio must be ~1.0 (bit-perfect)
|
|
||||||
ENERGY_RATIO_MAX = 1.001 # Energy ratio must be ~1.0 (bit-perfect)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Helper functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def load_doppler_csv(filepath):
|
|
||||||
"""
|
|
||||||
Load Doppler output CSV with columns (range_bin, doppler_bin, out_i, out_q).
|
|
||||||
Returns dict: {rbin: [(dbin, i, q), ...]}
|
|
||||||
"""
|
|
||||||
data = {}
|
|
||||||
with open(filepath) as f:
|
|
||||||
f.readline() # Skip header
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = line.split(',')
|
|
||||||
rbin = int(parts[0])
|
|
||||||
dbin = int(parts[1])
|
|
||||||
i_val = int(parts[2])
|
|
||||||
q_val = int(parts[3])
|
|
||||||
if rbin not in data:
|
|
||||||
data[rbin] = []
|
|
||||||
data[rbin].append((dbin, i_val, q_val))
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def extract_iq_arrays(data_dict, rbin):
|
|
||||||
"""Extract I and Q arrays for a given range bin, ordered by doppler bin."""
|
|
||||||
if rbin not in data_dict:
|
|
||||||
return [0] * DOPPLER_FFT, [0] * DOPPLER_FFT
|
|
||||||
entries = sorted(data_dict[rbin], key=lambda x: x[0])
|
|
||||||
i_arr = [e[1] for e in entries]
|
|
||||||
q_arr = [e[2] for e in entries]
|
|
||||||
return i_arr, q_arr
|
|
||||||
|
|
||||||
|
|
||||||
def pearson_correlation(a, b):
|
|
||||||
"""Compute Pearson correlation coefficient."""
|
|
||||||
n = len(a)
|
|
||||||
if n < 2:
|
|
||||||
return 0.0
|
|
||||||
mean_a = sum(a) / n
|
|
||||||
mean_b = sum(b) / n
|
|
||||||
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
|
|
||||||
std_a_sq = sum((x - mean_a) ** 2 for x in a)
|
|
||||||
std_b_sq = sum((x - mean_b) ** 2 for x in b)
|
|
||||||
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
|
|
||||||
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
|
|
||||||
return cov / math.sqrt(std_a_sq * std_b_sq)
|
|
||||||
|
|
||||||
|
|
||||||
def magnitude_l1(i_arr, q_arr):
|
|
||||||
"""L1 magnitude: |I| + |Q|."""
|
|
||||||
return [abs(i) + abs(q) for i, q in zip(i_arr, q_arr, strict=False)]
|
|
||||||
|
|
||||||
|
|
||||||
def find_peak_bin(i_arr, q_arr):
|
|
||||||
"""Find bin with max L1 magnitude."""
|
|
||||||
mags = magnitude_l1(i_arr, q_arr)
|
|
||||||
return max(range(len(mags)), key=lambda k: mags[k])
|
|
||||||
|
|
||||||
|
|
||||||
def peak_bins_match(py_peak, rtl_peak):
|
|
||||||
"""Return True if peaks match within +/-1 bin inside the same sub-frame."""
|
|
||||||
py_sf = py_peak // SUBFRAME_SIZE
|
|
||||||
rtl_sf = rtl_peak // SUBFRAME_SIZE
|
|
||||||
if py_sf != rtl_sf:
|
|
||||||
return False
|
|
||||||
|
|
||||||
py_bin = py_peak % SUBFRAME_SIZE
|
|
||||||
rtl_bin = rtl_peak % SUBFRAME_SIZE
|
|
||||||
diff = abs(py_bin - rtl_bin)
|
|
||||||
return diff <= 1 or diff >= SUBFRAME_SIZE - 1
|
|
||||||
|
|
||||||
|
|
||||||
def total_energy(data_dict):
|
|
||||||
"""Sum of I^2 + Q^2 across all range bins and Doppler bins."""
|
|
||||||
total = 0
|
|
||||||
for rbin in data_dict:
|
|
||||||
for (_dbin, i_val, q_val) in data_dict[rbin]:
|
|
||||||
total += i_val * i_val + q_val * q_val
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Scenario comparison
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def compare_scenario(name, config, base_dir):
|
|
||||||
"""Compare one Doppler scenario. Returns (passed, result_dict)."""
|
|
||||||
|
|
||||||
golden_path = os.path.join(base_dir, config['golden_csv'])
|
|
||||||
rtl_path = os.path.join(base_dir, config['rtl_csv'])
|
|
||||||
|
|
||||||
if not os.path.exists(golden_path):
|
|
||||||
return False, {}
|
|
||||||
if not os.path.exists(rtl_path):
|
|
||||||
return False, {}
|
|
||||||
|
|
||||||
py_data = load_doppler_csv(golden_path)
|
|
||||||
rtl_data = load_doppler_csv(rtl_path)
|
|
||||||
|
|
||||||
sorted(py_data.keys())
|
|
||||||
sorted(rtl_data.keys())
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Check 1: Both have data ----
|
|
||||||
py_total = sum(len(v) for v in py_data.values())
|
|
||||||
rtl_total = sum(len(v) for v in rtl_data.values())
|
|
||||||
if py_total == 0 or rtl_total == 0:
|
|
||||||
return False, {}
|
|
||||||
|
|
||||||
# ---- Check 2: Output count ----
|
|
||||||
count_ok = (rtl_total == TOTAL_OUTPUTS)
|
|
||||||
|
|
||||||
# ---- Check 3: Global energy ----
|
|
||||||
py_energy = total_energy(py_data)
|
|
||||||
rtl_energy = total_energy(rtl_data)
|
|
||||||
if py_energy > 0:
|
|
||||||
energy_ratio = rtl_energy / py_energy
|
|
||||||
else:
|
|
||||||
energy_ratio = 1.0 if rtl_energy == 0 else float('inf')
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Check 4: Per-range-bin analysis ----
|
|
||||||
peak_agreements = 0
|
|
||||||
mag_correlations = []
|
|
||||||
i_correlations = []
|
|
||||||
q_correlations = []
|
|
||||||
|
|
||||||
peak_details = []
|
|
||||||
|
|
||||||
for rbin in range(RANGE_BINS):
|
|
||||||
py_i, py_q = extract_iq_arrays(py_data, rbin)
|
|
||||||
rtl_i, rtl_q = extract_iq_arrays(rtl_data, rbin)
|
|
||||||
|
|
||||||
py_peak = find_peak_bin(py_i, py_q)
|
|
||||||
rtl_peak = find_peak_bin(rtl_i, rtl_q)
|
|
||||||
|
|
||||||
# Peak agreement (allow +/-1 bin tolerance, but only within a sub-frame)
|
|
||||||
if peak_bins_match(py_peak, rtl_peak):
|
|
||||||
peak_agreements += 1
|
|
||||||
|
|
||||||
py_mag = magnitude_l1(py_i, py_q)
|
|
||||||
rtl_mag = magnitude_l1(rtl_i, rtl_q)
|
|
||||||
|
|
||||||
mag_corr = pearson_correlation(py_mag, rtl_mag)
|
|
||||||
corr_i = pearson_correlation(py_i, rtl_i)
|
|
||||||
corr_q = pearson_correlation(py_q, rtl_q)
|
|
||||||
|
|
||||||
mag_correlations.append(mag_corr)
|
|
||||||
i_correlations.append(corr_i)
|
|
||||||
q_correlations.append(corr_q)
|
|
||||||
|
|
||||||
py_rbin_energy = sum(i*i + q*q for i, q in zip(py_i, py_q, strict=False))
|
|
||||||
rtl_rbin_energy = sum(i*i + q*q for i, q in zip(rtl_i, rtl_q, strict=False))
|
|
||||||
|
|
||||||
peak_details.append({
|
|
||||||
'rbin': rbin,
|
|
||||||
'py_peak': py_peak,
|
|
||||||
'rtl_peak': rtl_peak,
|
|
||||||
'mag_corr': mag_corr,
|
|
||||||
'corr_i': corr_i,
|
|
||||||
'corr_q': corr_q,
|
|
||||||
'py_energy': py_rbin_energy,
|
|
||||||
'rtl_energy': rtl_rbin_energy,
|
|
||||||
})
|
|
||||||
|
|
||||||
peak_agreement_frac = peak_agreements / RANGE_BINS
|
|
||||||
avg_mag_corr = sum(mag_correlations) / len(mag_correlations)
|
|
||||||
avg_corr_i = sum(i_correlations) / len(i_correlations)
|
|
||||||
avg_corr_q = sum(q_correlations) / len(q_correlations)
|
|
||||||
|
|
||||||
|
|
||||||
# Show top 5 range bins by Python energy
|
|
||||||
top_rbins = sorted(peak_details, key=lambda x: -x['py_energy'])[:5]
|
|
||||||
for _d in top_rbins:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# ---- Pass/Fail ----
|
|
||||||
checks = []
|
|
||||||
|
|
||||||
checks.append(('RTL output count == 2048', count_ok))
|
|
||||||
|
|
||||||
energy_ok = (ENERGY_RATIO_MIN < energy_ratio < ENERGY_RATIO_MAX)
|
|
||||||
checks.append((f'Energy ratio in bounds '
|
|
||||||
f'({ENERGY_RATIO_MIN}-{ENERGY_RATIO_MAX})', energy_ok))
|
|
||||||
|
|
||||||
peak_ok = (peak_agreement_frac >= PEAK_AGREEMENT_MIN)
|
|
||||||
checks.append((f'Peak agreement >= {PEAK_AGREEMENT_MIN:.0%}', peak_ok))
|
|
||||||
|
|
||||||
# For range bins with significant energy, check magnitude correlation
|
|
||||||
high_energy_rbins = [d for d in peak_details
|
|
||||||
if d['py_energy'] > py_energy / (RANGE_BINS * 10)]
|
|
||||||
if high_energy_rbins:
|
|
||||||
he_mag_corr = sum(d['mag_corr'] for d in high_energy_rbins) / len(high_energy_rbins)
|
|
||||||
he_ok = (he_mag_corr >= MAG_CORR_MIN)
|
|
||||||
checks.append((f'High-energy rbin avg mag_corr >= {MAG_CORR_MIN:.2f} '
|
|
||||||
f'(actual={he_mag_corr:.3f})', he_ok))
|
|
||||||
|
|
||||||
all_pass = True
|
|
||||||
for _check_name, passed in checks:
|
|
||||||
if not passed:
|
|
||||||
all_pass = False
|
|
||||||
|
|
||||||
# ---- Write detailed comparison CSV ----
|
|
||||||
compare_csv = os.path.join(base_dir, f'compare_doppler_{name}.csv')
|
|
||||||
with open(compare_csv, 'w') as f:
|
|
||||||
f.write('range_bin,doppler_bin,py_i,py_q,rtl_i,rtl_q,diff_i,diff_q\n')
|
|
||||||
for rbin in range(RANGE_BINS):
|
|
||||||
py_i, py_q = extract_iq_arrays(py_data, rbin)
|
|
||||||
rtl_i, rtl_q = extract_iq_arrays(rtl_data, rbin)
|
|
||||||
for dbin in range(DOPPLER_FFT):
|
|
||||||
f.write(f'{rbin},{dbin},{py_i[dbin]},{py_q[dbin]},'
|
|
||||||
f'{rtl_i[dbin]},{rtl_q[dbin]},'
|
|
||||||
f'{rtl_i[dbin]-py_i[dbin]},{rtl_q[dbin]-py_q[dbin]}\n')
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'scenario': name,
|
|
||||||
'rtl_count': rtl_total,
|
|
||||||
'energy_ratio': energy_ratio,
|
|
||||||
'peak_agreement': peak_agreement_frac,
|
|
||||||
'avg_mag_corr': avg_mag_corr,
|
|
||||||
'avg_corr_i': avg_corr_i,
|
|
||||||
'avg_corr_q': avg_corr_q,
|
|
||||||
'passed': all_pass,
|
|
||||||
}
|
|
||||||
|
|
||||||
return all_pass, result
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def main():
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'stationary'
|
|
||||||
|
|
||||||
if arg == 'all':
|
|
||||||
run_scenarios = list(SCENARIOS.keys())
|
|
||||||
elif arg in SCENARIOS:
|
|
||||||
run_scenarios = [arg]
|
|
||||||
else:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for name in run_scenarios:
|
|
||||||
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
|
|
||||||
results.append((name, passed, result))
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
|
|
||||||
all_pass = True
|
|
||||||
for _name, passed, result in results:
|
|
||||||
if not result:
|
|
||||||
all_pass = False
|
|
||||||
else:
|
|
||||||
if not passed:
|
|
||||||
all_pass = False
|
|
||||||
|
|
||||||
if all_pass:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(0 if all_pass else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Co-simulation Comparison: RTL vs Python Model for AERIS-10 Matched Filter.
|
|
||||||
|
|
||||||
Compares the RTL matched filter output (from tb_mf_cosim.v) against the
|
|
||||||
Python model golden reference (from gen_mf_cosim_golden.py).
|
|
||||||
|
|
||||||
Two modes of operation:
|
|
||||||
1. Synthesis branch (no -DSIMULATION): RTL uses fft_engine.v with fixed-point
|
|
||||||
twiddle ROM (fft_twiddle_1024.mem) and frequency_matched_filter.v. The
|
|
||||||
Python model was built to match this exactly. Expect BIT-PERFECT results
|
|
||||||
(correlation = 1.0, energy ratio = 1.0).
|
|
||||||
|
|
||||||
2. SIMULATION branch (-DSIMULATION): RTL uses behavioral FFT with floating-
|
|
||||||
point twiddles ($rtoi($cos*32767)) and shift-then-add conjugate multiply.
|
|
||||||
Python model uses fixed-point twiddles and add-then-round. Expect large
|
|
||||||
numerical differences; only state-machine mechanics are validated.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 compare_mf.py [scenario|all]
|
|
||||||
|
|
||||||
scenario: chirp, dc, impulse, tone5 (default: chirp)
|
|
||||||
all: run all scenarios
|
|
||||||
|
|
||||||
Author: Phase 0.5 matched-filter co-simulation suite for PLFM_RADAR
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Configuration
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
FFT_SIZE = 1024
|
|
||||||
|
|
||||||
SCENARIOS = {
|
|
||||||
'chirp': {
|
|
||||||
'golden_csv': 'mf_golden_py_chirp.csv',
|
|
||||||
'rtl_csv': 'rtl_mf_chirp.csv',
|
|
||||||
'description': 'Radar chirp: 2 targets vs ref chirp',
|
|
||||||
},
|
|
||||||
'dc': {
|
|
||||||
'golden_csv': 'mf_golden_py_dc.csv',
|
|
||||||
'rtl_csv': 'rtl_mf_dc.csv',
|
|
||||||
'description': 'DC autocorrelation (I=0x1000)',
|
|
||||||
},
|
|
||||||
'impulse': {
|
|
||||||
'golden_csv': 'mf_golden_py_impulse.csv',
|
|
||||||
'rtl_csv': 'rtl_mf_impulse.csv',
|
|
||||||
'description': 'Impulse autocorrelation (delta at n=0)',
|
|
||||||
},
|
|
||||||
'tone5': {
|
|
||||||
'golden_csv': 'mf_golden_py_tone5.csv',
|
|
||||||
'rtl_csv': 'rtl_mf_tone5.csv',
|
|
||||||
'description': 'Tone autocorrelation (bin 5, amp=8000)',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Thresholds for pass/fail
|
|
||||||
# These are generous because of the fundamental twiddle arithmetic differences
|
|
||||||
# between the SIMULATION branch (float twiddles) and Python model (fixed twiddles)
|
|
||||||
ENERGY_CORR_MIN = 0.80 # Min correlation of magnitude spectra
|
|
||||||
TOP_PEAK_OVERLAP_MIN = 0.50 # At least 50% of top-N peaks must overlap
|
|
||||||
RMS_RATIO_MAX = 50.0 # Max ratio of RMS energies (generous, since gain differs)
|
|
||||||
ENERGY_RATIO_MIN = 0.001 # Min ratio (total energy RTL / total energy Python)
|
|
||||||
ENERGY_RATIO_MAX = 1000.0 # Max ratio
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Helper functions
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def load_csv(filepath):
|
|
||||||
"""Load CSV with columns (bin, out_i/range_profile_i, out_q/range_profile_q)."""
|
|
||||||
vals_i = []
|
|
||||||
vals_q = []
|
|
||||||
with open(filepath) as f:
|
|
||||||
f.readline() # Skip header
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
parts = line.split(',')
|
|
||||||
vals_i.append(int(parts[1]))
|
|
||||||
vals_q.append(int(parts[2]))
|
|
||||||
return vals_i, vals_q
|
|
||||||
|
|
||||||
|
|
||||||
def magnitude_spectrum(vals_i, vals_q):
|
|
||||||
"""Compute magnitude = |I| + |Q| for each bin (L1 norm, matches RTL)."""
|
|
||||||
return [abs(i) + abs(q) for i, q in zip(vals_i, vals_q, strict=False)]
|
|
||||||
|
|
||||||
|
|
||||||
def magnitude_l2(vals_i, vals_q):
|
|
||||||
"""Compute magnitude = sqrt(I^2 + Q^2) for each bin."""
|
|
||||||
return [math.sqrt(i*i + q*q) for i, q in zip(vals_i, vals_q, strict=False)]
|
|
||||||
|
|
||||||
|
|
||||||
def total_energy(vals_i, vals_q):
|
|
||||||
"""Compute total energy (sum of I^2 + Q^2)."""
|
|
||||||
return sum(i*i + q*q for i, q in zip(vals_i, vals_q, strict=False))
|
|
||||||
|
|
||||||
|
|
||||||
def rms_magnitude(vals_i, vals_q):
|
|
||||||
"""Compute RMS of complex magnitude."""
|
|
||||||
n = len(vals_i)
|
|
||||||
if n == 0:
|
|
||||||
return 0.0
|
|
||||||
return math.sqrt(sum(i*i + q*q for i, q in zip(vals_i, vals_q, strict=False)) / n)
|
|
||||||
|
|
||||||
|
|
||||||
def pearson_correlation(a, b):
|
|
||||||
"""Compute Pearson correlation coefficient between two lists."""
|
|
||||||
n = len(a)
|
|
||||||
if n < 2:
|
|
||||||
return 0.0
|
|
||||||
mean_a = sum(a) / n
|
|
||||||
mean_b = sum(b) / n
|
|
||||||
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
|
|
||||||
std_a_sq = sum((x - mean_a) ** 2 for x in a)
|
|
||||||
std_b_sq = sum((x - mean_b) ** 2 for x in b)
|
|
||||||
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
|
|
||||||
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
|
|
||||||
return cov / math.sqrt(std_a_sq * std_b_sq)
|
|
||||||
|
|
||||||
|
|
||||||
def find_peak(vals_i, vals_q):
|
|
||||||
"""Find the bin with the maximum L1 magnitude."""
|
|
||||||
mags = magnitude_spectrum(vals_i, vals_q)
|
|
||||||
peak_bin = 0
|
|
||||||
peak_mag = mags[0]
|
|
||||||
for i in range(1, len(mags)):
|
|
||||||
if mags[i] > peak_mag:
|
|
||||||
peak_mag = mags[i]
|
|
||||||
peak_bin = i
|
|
||||||
return peak_bin, peak_mag
|
|
||||||
|
|
||||||
|
|
||||||
def top_n_peaks(mags, n=10):
|
|
||||||
"""Find the top-N peak bins by magnitude. Returns set of bin indices."""
|
|
||||||
indexed = sorted(enumerate(mags), key=lambda x: -x[1])
|
|
||||||
return {idx for idx, _ in indexed[:n]}
|
|
||||||
|
|
||||||
|
|
||||||
def spectral_peak_overlap(mags_a, mags_b, n=10):
|
|
||||||
"""Fraction of top-N peaks from A that also appear in top-N of B."""
|
|
||||||
peaks_a = top_n_peaks(mags_a, n)
|
|
||||||
peaks_b = top_n_peaks(mags_b, n)
|
|
||||||
if len(peaks_a) == 0:
|
|
||||||
return 1.0
|
|
||||||
overlap = peaks_a & peaks_b
|
|
||||||
return len(overlap) / len(peaks_a)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Comparison for one scenario
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def compare_scenario(scenario_name, config, base_dir):
|
|
||||||
"""Compare one scenario. Returns (pass/fail, result_dict)."""
|
|
||||||
|
|
||||||
golden_path = os.path.join(base_dir, config['golden_csv'])
|
|
||||||
rtl_path = os.path.join(base_dir, config['rtl_csv'])
|
|
||||||
|
|
||||||
if not os.path.exists(golden_path):
|
|
||||||
return False, {}
|
|
||||||
if not os.path.exists(rtl_path):
|
|
||||||
return False, {}
|
|
||||||
|
|
||||||
py_i, py_q = load_csv(golden_path)
|
|
||||||
rtl_i, rtl_q = load_csv(rtl_path)
|
|
||||||
|
|
||||||
|
|
||||||
if len(py_i) != FFT_SIZE or len(rtl_i) != FFT_SIZE:
|
|
||||||
return False, {}
|
|
||||||
|
|
||||||
# ---- Metric 1: Energy ----
|
|
||||||
py_energy = total_energy(py_i, py_q)
|
|
||||||
rtl_energy = total_energy(rtl_i, rtl_q)
|
|
||||||
py_rms = rms_magnitude(py_i, py_q)
|
|
||||||
rtl_rms = rms_magnitude(rtl_i, rtl_q)
|
|
||||||
|
|
||||||
if py_energy > 0 and rtl_energy > 0:
|
|
||||||
energy_ratio = rtl_energy / py_energy
|
|
||||||
rms_ratio = rtl_rms / py_rms
|
|
||||||
elif py_energy == 0 and rtl_energy == 0:
|
|
||||||
energy_ratio = 1.0
|
|
||||||
rms_ratio = 1.0
|
|
||||||
else:
|
|
||||||
energy_ratio = float('inf') if py_energy == 0 else 0.0
|
|
||||||
rms_ratio = float('inf') if py_rms == 0 else 0.0
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Metric 2: Peak location ----
|
|
||||||
py_peak_bin, _py_peak_mag = find_peak(py_i, py_q)
|
|
||||||
rtl_peak_bin, _rtl_peak_mag = find_peak(rtl_i, rtl_q)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Metric 3: Magnitude spectrum correlation ----
|
|
||||||
py_mag = magnitude_l2(py_i, py_q)
|
|
||||||
rtl_mag = magnitude_l2(rtl_i, rtl_q)
|
|
||||||
mag_corr = pearson_correlation(py_mag, rtl_mag)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Metric 4: Top-N peak overlap ----
|
|
||||||
# Use L1 magnitudes for peak finding (matches RTL)
|
|
||||||
py_mag_l1 = magnitude_spectrum(py_i, py_q)
|
|
||||||
rtl_mag_l1 = magnitude_spectrum(rtl_i, rtl_q)
|
|
||||||
peak_overlap_10 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=10)
|
|
||||||
peak_overlap_20 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=20)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Metric 5: I and Q channel correlation ----
|
|
||||||
corr_i = pearson_correlation(py_i, rtl_i)
|
|
||||||
corr_q = pearson_correlation(py_q, rtl_q)
|
|
||||||
|
|
||||||
|
|
||||||
# ---- Pass/Fail Decision ----
|
|
||||||
# The SIMULATION branch uses floating-point twiddles ($cos/$sin) while
|
|
||||||
# the Python model uses the fixed-point twiddle ROM (matching synthesis).
|
|
||||||
# These are fundamentally different FFT implementations. We do NOT expect
|
|
||||||
# structural similarity (correlation, peak overlap) between them.
|
|
||||||
#
|
|
||||||
# What we CAN verify:
|
|
||||||
# 1. Both produce non-trivial output (state machine completes)
|
|
||||||
# 2. Output count is correct (1024 samples)
|
|
||||||
# 3. Energy is in a reasonable range (not wildly wrong)
|
|
||||||
#
|
|
||||||
# The true bit-accuracy comparison will happen when the synthesis branch
|
|
||||||
# is simulated (xsim on remote server) using the same fft_engine.v that
|
|
||||||
# the Python model was built to match.
|
|
||||||
|
|
||||||
checks = []
|
|
||||||
|
|
||||||
# Check 1: Both produce output
|
|
||||||
both_have_output = py_energy > 0 and rtl_energy > 0
|
|
||||||
checks.append(('Both produce output', both_have_output))
|
|
||||||
|
|
||||||
# Check 2: RTL produced expected sample count
|
|
||||||
correct_count = len(rtl_i) == FFT_SIZE
|
|
||||||
checks.append(('Correct output count (1024)', correct_count))
|
|
||||||
|
|
||||||
# Check 3: Energy ratio within generous bounds
|
|
||||||
# Allow very wide range since twiddle differences cause large gain variation
|
|
||||||
energy_ok = ENERGY_RATIO_MIN < energy_ratio < ENERGY_RATIO_MAX
|
|
||||||
checks.append((f'Energy ratio in bounds ({ENERGY_RATIO_MIN}-{ENERGY_RATIO_MAX})',
|
|
||||||
energy_ok))
|
|
||||||
|
|
||||||
# Print checks
|
|
||||||
all_pass = True
|
|
||||||
for _name, passed in checks:
|
|
||||||
if not passed:
|
|
||||||
all_pass = False
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'scenario': scenario_name,
|
|
||||||
'py_energy': py_energy,
|
|
||||||
'rtl_energy': rtl_energy,
|
|
||||||
'energy_ratio': energy_ratio,
|
|
||||||
'rms_ratio': rms_ratio,
|
|
||||||
'py_peak_bin': py_peak_bin,
|
|
||||||
'rtl_peak_bin': rtl_peak_bin,
|
|
||||||
'mag_corr': mag_corr,
|
|
||||||
'peak_overlap_10': peak_overlap_10,
|
|
||||||
'peak_overlap_20': peak_overlap_20,
|
|
||||||
'corr_i': corr_i,
|
|
||||||
'corr_q': corr_q,
|
|
||||||
'passed': all_pass,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Write detailed comparison CSV
|
|
||||||
compare_csv = os.path.join(base_dir, f'compare_mf_{scenario_name}.csv')
|
|
||||||
with open(compare_csv, 'w') as f:
|
|
||||||
f.write('bin,py_i,py_q,rtl_i,rtl_q,py_mag,rtl_mag,diff_i,diff_q\n')
|
|
||||||
for k in range(FFT_SIZE):
|
|
||||||
f.write(f'{k},{py_i[k]},{py_q[k]},{rtl_i[k]},{rtl_q[k]},'
|
|
||||||
f'{py_mag_l1[k]},{rtl_mag_l1[k]},'
|
|
||||||
f'{rtl_i[k]-py_i[k]},{rtl_q[k]-py_q[k]}\n')
|
|
||||||
|
|
||||||
return all_pass, result
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Main
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
def main():
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
|
|
||||||
arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'chirp'
|
|
||||||
|
|
||||||
if arg == 'all':
|
|
||||||
run_scenarios = list(SCENARIOS.keys())
|
|
||||||
elif arg in SCENARIOS:
|
|
||||||
run_scenarios = [arg]
|
|
||||||
else:
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for name in run_scenarios:
|
|
||||||
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
|
|
||||||
results.append((name, passed, result))
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
|
|
||||||
|
|
||||||
all_pass = True
|
|
||||||
for _name, passed, result in results:
|
|
||||||
if not result:
|
|
||||||
all_pass = False
|
|
||||||
else:
|
|
||||||
if not passed:
|
|
||||||
all_pass = False
|
|
||||||
|
|
||||||
if all_pass:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
sys.exit(0 if all_pass else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -126,17 +126,40 @@ def write_mem_file(filename, values):
|
|||||||
with open(path, 'w') as f:
|
with open(path, 'w') as f:
|
||||||
for v in values:
|
for v in values:
|
||||||
f.write(to_hex16(v) + '\n')
|
f.write(to_hex16(v) + '\n')
|
||||||
|
print(f" Wrote {filename}: {len(values)} entries")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
print("=" * 60)
|
||||||
|
print("AERIS-10 Chirp .mem File Generator")
|
||||||
|
print("=" * 60)
|
||||||
|
print()
|
||||||
|
print("Parameters:")
|
||||||
|
print(f" CHIRP_BW = {CHIRP_BW/1e6:.1f} MHz")
|
||||||
|
print(f" FS_SYS = {FS_SYS/1e6:.1f} MHz")
|
||||||
|
print(f" T_LONG_CHIRP = {T_LONG_CHIRP*1e6:.1f} us")
|
||||||
|
print(f" T_SHORT_CHIRP = {T_SHORT_CHIRP*1e6:.1f} us")
|
||||||
|
print(f" LONG_CHIRP_SAMPLES = {LONG_CHIRP_SAMPLES}")
|
||||||
|
print(f" SHORT_CHIRP_SAMPLES = {SHORT_CHIRP_SAMPLES}")
|
||||||
|
print(f" FFT_SIZE = {FFT_SIZE}")
|
||||||
|
print(f" Chirp rate (long) = {CHIRP_BW/T_LONG_CHIRP:.3e} Hz/s")
|
||||||
|
print(f" Chirp rate (short) = {CHIRP_BW/T_SHORT_CHIRP:.3e} Hz/s")
|
||||||
|
print(f" Q15 scale = {SCALE}")
|
||||||
|
print()
|
||||||
|
|
||||||
# ---- Long chirp ----
|
# ---- Long chirp ----
|
||||||
|
print("Generating full long chirp (3000 samples)...")
|
||||||
long_i, long_q = generate_full_long_chirp()
|
long_i, long_q = generate_full_long_chirp()
|
||||||
|
|
||||||
# Verify first sample matches generate_reference_chirp_q15() from radar_scene.py
|
# Verify first sample matches generate_reference_chirp_q15() from radar_scene.py
|
||||||
# (which only generates the first 1024 samples)
|
# (which only generates the first 1024 samples)
|
||||||
|
print(f" Sample[0]: I={long_i[0]:6d} Q={long_q[0]:6d}")
|
||||||
|
print(f" Sample[1023]: I={long_i[1023]:6d} Q={long_q[1023]:6d}")
|
||||||
|
print(f" Sample[2999]: I={long_i[2999]:6d} Q={long_q[2999]:6d}")
|
||||||
|
|
||||||
# Segment into 4 x 1024 blocks
|
# Segment into 4 x 1024 blocks
|
||||||
|
print()
|
||||||
|
print("Segmenting into 4 x 1024 blocks...")
|
||||||
for seg in range(LONG_SEGMENTS):
|
for seg in range(LONG_SEGMENTS):
|
||||||
start = seg * FFT_SIZE
|
start = seg * FFT_SIZE
|
||||||
end = start + FFT_SIZE
|
end = start + FFT_SIZE
|
||||||
@@ -154,18 +177,27 @@ def main():
|
|||||||
seg_i.append(0)
|
seg_i.append(0)
|
||||||
seg_q.append(0)
|
seg_q.append(0)
|
||||||
|
|
||||||
FFT_SIZE - valid_count
|
zero_count = FFT_SIZE - valid_count
|
||||||
|
print(f" Seg {seg}: indices [{start}:{end-1}], "
|
||||||
|
f"valid={valid_count}, zeros={zero_count}")
|
||||||
|
|
||||||
write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i)
|
write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i)
|
||||||
write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q)
|
write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q)
|
||||||
|
|
||||||
# ---- Short chirp ----
|
# ---- Short chirp ----
|
||||||
|
print()
|
||||||
|
print("Generating short chirp (50 samples)...")
|
||||||
short_i, short_q = generate_short_chirp()
|
short_i, short_q = generate_short_chirp()
|
||||||
|
print(f" Sample[0]: I={short_i[0]:6d} Q={short_q[0]:6d}")
|
||||||
|
print(f" Sample[49]: I={short_i[49]:6d} Q={short_q[49]:6d}")
|
||||||
|
|
||||||
write_mem_file("short_chirp_i.mem", short_i)
|
write_mem_file("short_chirp_i.mem", short_i)
|
||||||
write_mem_file("short_chirp_q.mem", short_q)
|
write_mem_file("short_chirp_q.mem", short_q)
|
||||||
|
|
||||||
# ---- Verification summary ----
|
# ---- Verification summary ----
|
||||||
|
print()
|
||||||
|
print("=" * 60)
|
||||||
|
print("Verification:")
|
||||||
|
|
||||||
# Cross-check seg0 against radar_scene.py generate_reference_chirp_q15()
|
# Cross-check seg0 against radar_scene.py generate_reference_chirp_q15()
|
||||||
# That function generates exactly the first 1024 samples of the chirp
|
# That function generates exactly the first 1024 samples of the chirp
|
||||||
@@ -180,24 +212,33 @@ def main():
|
|||||||
mismatches += 1
|
mismatches += 1
|
||||||
|
|
||||||
if mismatches == 0:
|
if mismatches == 0:
|
||||||
pass
|
print(" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()")
|
||||||
else:
|
else:
|
||||||
|
print(f" [FAIL] Seg0 has {mismatches} mismatches vs generate_reference_chirp_q15()")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Check magnitude envelope
|
# Check magnitude envelope
|
||||||
max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q, strict=False))
|
max_mag = max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q, strict=False))
|
||||||
|
print(f" Max magnitude: {max_mag:.1f} (expected ~{Q15_MAX * SCALE:.1f})")
|
||||||
|
print(f" Magnitude ratio: {max_mag / (Q15_MAX * SCALE):.6f}")
|
||||||
|
|
||||||
# Check seg3 zero padding
|
# Check seg3 zero padding
|
||||||
seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem')
|
seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem')
|
||||||
with open(seg3_i_path) as f:
|
with open(seg3_i_path) as f:
|
||||||
seg3_lines = [line.strip() for line in f if line.strip()]
|
seg3_lines = [line.strip() for line in f if line.strip()]
|
||||||
nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000')
|
nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000')
|
||||||
|
print(f" Seg3 non-zero entries: {nonzero_seg3}/{len(seg3_lines)} "
|
||||||
|
f"(expected 0 since chirp ends at sample 2999)")
|
||||||
|
|
||||||
if nonzero_seg3 == 0:
|
if nonzero_seg3 == 0:
|
||||||
pass
|
print(" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)")
|
||||||
else:
|
else:
|
||||||
pass
|
print(f" [WARN] Seg3 has {nonzero_seg3} non-zero entries")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(f"Generated 10 .mem files in {os.path.abspath(MEM_DIR)}")
|
||||||
|
print("Run validate_mem_files.py to do full validation.")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ def write_hex_32bit(filepath, samples):
|
|||||||
for (i_val, q_val) in samples:
|
for (i_val, q_val) in samples:
|
||||||
packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF)
|
packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF)
|
||||||
f.write(f"{packed:08X}\n")
|
f.write(f"{packed:08X}\n")
|
||||||
|
print(f" Wrote {len(samples)} packed samples to {filepath}")
|
||||||
|
|
||||||
|
|
||||||
def write_csv(filepath, headers, *columns):
|
def write_csv(filepath, headers, *columns):
|
||||||
@@ -60,6 +61,7 @@ def write_csv(filepath, headers, *columns):
|
|||||||
for i in range(len(columns[0])):
|
for i in range(len(columns[0])):
|
||||||
row = ','.join(str(col[i]) for col in columns)
|
row = ','.join(str(col[i]) for col in columns)
|
||||||
f.write(row + '\n')
|
f.write(row + '\n')
|
||||||
|
print(f" Wrote {len(columns[0])} rows to {filepath}")
|
||||||
|
|
||||||
|
|
||||||
def write_hex_16bit(filepath, data):
|
def write_hex_16bit(filepath, data):
|
||||||
@@ -116,10 +118,15 @@ SCENARIOS = {
|
|||||||
|
|
||||||
def generate_scenario(name, targets, description, base_dir):
|
def generate_scenario(name, targets, description, base_dir):
|
||||||
"""Generate input hex + golden output for one scenario."""
|
"""Generate input hex + golden output for one scenario."""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Scenario: {name} — {description}")
|
||||||
|
print("Model: CLEAN (dual 16-pt FFT)")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
# Generate Doppler frame (32 chirps x 64 range bins)
|
# Generate Doppler frame (32 chirps x 64 range bins)
|
||||||
frame_i, frame_q = generate_doppler_frame(targets, seed=42)
|
frame_i, frame_q = generate_doppler_frame(targets, seed=42)
|
||||||
|
|
||||||
|
print(f" Generated frame: {len(frame_i)} chirps x {len(frame_i[0])} range bins")
|
||||||
|
|
||||||
# ---- Write input hex file (packed 32-bit: {Q, I}) ----
|
# ---- Write input hex file (packed 32-bit: {Q, I}) ----
|
||||||
# RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ...
|
# RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ...
|
||||||
@@ -137,6 +144,8 @@ def generate_scenario(name, targets, description, base_dir):
|
|||||||
dp = DopplerProcessor()
|
dp = DopplerProcessor()
|
||||||
doppler_i, doppler_q = dp.process_frame(frame_i, frame_q)
|
doppler_i, doppler_q = dp.process_frame(frame_i, frame_q)
|
||||||
|
|
||||||
|
print(f" Doppler output: {len(doppler_i)} range bins x "
|
||||||
|
f"{len(doppler_i[0])} doppler bins (2 sub-frames x {DOPPLER_FFT_SIZE})")
|
||||||
|
|
||||||
# ---- Write golden output CSV ----
|
# ---- Write golden output CSV ----
|
||||||
# Format: range_bin, doppler_bin, out_i, out_q
|
# Format: range_bin, doppler_bin, out_i, out_q
|
||||||
@@ -164,6 +173,7 @@ def generate_scenario(name, targets, description, base_dir):
|
|||||||
write_hex_32bit(golden_hex, list(zip(flat_i, flat_q, strict=False)))
|
write_hex_32bit(golden_hex, list(zip(flat_i, flat_q, strict=False)))
|
||||||
|
|
||||||
# ---- Find peak per range bin ----
|
# ---- Find peak per range bin ----
|
||||||
|
print("\n Peak Doppler bins per range bin (top 5 by magnitude):")
|
||||||
peak_info = []
|
peak_info = []
|
||||||
for rbin in range(RANGE_BINS):
|
for rbin in range(RANGE_BINS):
|
||||||
mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d])
|
mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d])
|
||||||
@@ -174,11 +184,13 @@ def generate_scenario(name, targets, description, base_dir):
|
|||||||
|
|
||||||
# Sort by magnitude descending, show top 5
|
# Sort by magnitude descending, show top 5
|
||||||
peak_info.sort(key=lambda x: -x[2])
|
peak_info.sort(key=lambda x: -x[2])
|
||||||
for rbin, dbin, _mag in peak_info[:5]:
|
for rbin, dbin, mag in peak_info[:5]:
|
||||||
doppler_i[rbin][dbin]
|
i_val = doppler_i[rbin][dbin]
|
||||||
doppler_q[rbin][dbin]
|
q_val = doppler_q[rbin][dbin]
|
||||||
dbin // DOPPLER_FFT_SIZE
|
sf = dbin // DOPPLER_FFT_SIZE
|
||||||
dbin % DOPPLER_FFT_SIZE
|
bin_in_sf = dbin % DOPPLER_FFT_SIZE
|
||||||
|
print(f" rbin={rbin:2d}, dbin={dbin:2d} (sf{sf}:{bin_in_sf:2d}), mag={mag:6d}, "
|
||||||
|
f"I={i_val:6d}, Q={q_val:6d}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'name': name,
|
'name': name,
|
||||||
@@ -190,6 +202,10 @@ def generate_scenario(name, targets, description, base_dir):
|
|||||||
def main():
|
def main():
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Doppler Processor Co-Sim Golden Reference Generator")
|
||||||
|
print(f"Architecture: dual {DOPPLER_FFT_SIZE}-pt FFT ({DOPPLER_TOTAL_BINS} total bins)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
scenarios_to_run = list(SCENARIOS.keys())
|
scenarios_to_run = list(SCENARIOS.keys())
|
||||||
|
|
||||||
@@ -207,9 +223,17 @@ def main():
|
|||||||
r = generate_scenario(name, targets, description, base_dir)
|
r = generate_scenario(name, targets, description, base_dir)
|
||||||
results.append(r)
|
results.append(r)
|
||||||
|
|
||||||
for _ in results:
|
print(f"\n{'='*60}")
|
||||||
pass
|
print("Summary:")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
for r in results:
|
||||||
|
print(f" {r['name']:<15s} top peak: "
|
||||||
|
f"rbin={r['peak_info'][0][0]}, dbin={r['peak_info'][0][1]}, "
|
||||||
|
f"mag={r['peak_info'][0][2]}")
|
||||||
|
|
||||||
|
print(f"\nGenerated {len(results)} scenarios.")
|
||||||
|
print(f"Files written to: {base_dir}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
|
|||||||
|
|
||||||
Returns dict with case info and results.
|
Returns dict with case info and results.
|
||||||
"""
|
"""
|
||||||
|
print(f"\n--- {case_name}: {description} ---")
|
||||||
|
|
||||||
assert len(sig_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}"
|
assert len(sig_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}"
|
||||||
assert len(sig_q) == FFT_SIZE
|
assert len(sig_q) == FFT_SIZE
|
||||||
@@ -87,6 +88,8 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
|
|||||||
write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_q.hex"), sig_q)
|
write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_q.hex"), sig_q)
|
||||||
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_i.hex"), ref_i)
|
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_i.hex"), ref_i)
|
||||||
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q)
|
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q)
|
||||||
|
print(f" Wrote input hex: mf_sig_{case_name}_{{i,q}}.hex, "
|
||||||
|
f"mf_ref_{case_name}_{{i,q}}.hex")
|
||||||
|
|
||||||
# Run through bit-accurate Python model
|
# Run through bit-accurate Python model
|
||||||
mf = MatchedFilterChain(fft_size=FFT_SIZE)
|
mf = MatchedFilterChain(fft_size=FFT_SIZE)
|
||||||
@@ -101,6 +104,9 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
|
|||||||
peak_mag = mag
|
peak_mag = mag
|
||||||
peak_bin = k
|
peak_bin = k
|
||||||
|
|
||||||
|
print(f" Output: {len(out_i)} samples")
|
||||||
|
print(f" Peak bin: {peak_bin}, magnitude: {peak_mag}")
|
||||||
|
print(f" Peak I={out_i[peak_bin]}, Q={out_q[peak_bin]}")
|
||||||
|
|
||||||
# Save golden output hex
|
# Save golden output hex
|
||||||
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i)
|
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i)
|
||||||
@@ -129,6 +135,10 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
|
|||||||
def main():
|
def main():
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Matched Filter Co-Sim Golden Reference Generator")
|
||||||
|
print("Using bit-accurate Python model (fpga_model.py)")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
@@ -148,7 +158,8 @@ def main():
|
|||||||
base_dir)
|
base_dir)
|
||||||
results.append(r)
|
results.append(r)
|
||||||
else:
|
else:
|
||||||
pass
|
print("\nWARNING: bb_mf_test / ref_chirp hex files not found.")
|
||||||
|
print("Run radar_scene.py first.")
|
||||||
|
|
||||||
# ---- Case 2: DC autocorrelation ----
|
# ---- Case 2: DC autocorrelation ----
|
||||||
dc_val = 0x1000 # 4096
|
dc_val = 0x1000 # 4096
|
||||||
@@ -190,9 +201,16 @@ def main():
|
|||||||
results.append(r)
|
results.append(r)
|
||||||
|
|
||||||
# ---- Summary ----
|
# ---- Summary ----
|
||||||
for _ in results:
|
print("\n" + "=" * 60)
|
||||||
pass
|
print("Summary:")
|
||||||
|
print("=" * 60)
|
||||||
|
for r in results:
|
||||||
|
print(f" {r['case_name']:10s}: peak at bin {r['peak_bin']}, "
|
||||||
|
f"mag={r['peak_mag']}, I={r['peak_i']}, Q={r['peak_q']}")
|
||||||
|
|
||||||
|
print(f"\nGenerated {len(results)} golden reference cases.")
|
||||||
|
print("Files written to:", base_dir)
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
|
|||||||
return chirp_i, chirp_q
|
return chirp_i, chirp_q
|
||||||
|
|
||||||
|
|
||||||
def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, _f_if=F_IF, _fs=FS_ADC):
|
def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
|
||||||
"""
|
"""
|
||||||
Generate a reference chirp in Q15 format for the matched filter.
|
Generate a reference chirp in Q15 format for the matched filter.
|
||||||
|
|
||||||
@@ -398,6 +398,7 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
|
|||||||
for target in targets:
|
for target in targets:
|
||||||
# Which range bin does this target fall in?
|
# Which range bin does this target fall in?
|
||||||
# After matched filter + range decimation:
|
# After matched filter + range decimation:
|
||||||
|
# range_bin = target_delay_in_baseband_samples / decimation_factor
|
||||||
delay_baseband_samples = target.delay_s * FS_SYS
|
delay_baseband_samples = target.delay_s * FS_SYS
|
||||||
range_bin_float = delay_baseband_samples * n_range_bins / FFT_SIZE
|
range_bin_float = delay_baseband_samples * n_range_bins / FFT_SIZE
|
||||||
range_bin = round(range_bin_float)
|
range_bin = round(range_bin_float)
|
||||||
@@ -405,6 +406,7 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
|
|||||||
if range_bin < 0 or range_bin >= n_range_bins:
|
if range_bin < 0 or range_bin >= n_range_bins:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Amplitude (simplified)
|
||||||
amp = target.amplitude / 4.0
|
amp = target.amplitude / 4.0
|
||||||
|
|
||||||
# Doppler phase for this chirp.
|
# Doppler phase for this chirp.
|
||||||
@@ -472,6 +474,7 @@ def write_hex_file(filepath, samples, bits=8):
|
|||||||
val = s & ((1 << bits) - 1)
|
val = s & ((1 << bits) - 1)
|
||||||
f.write(fmt.format(val) + "\n")
|
f.write(fmt.format(val) + "\n")
|
||||||
|
|
||||||
|
print(f" Wrote {len(samples)} samples to {filepath}")
|
||||||
|
|
||||||
|
|
||||||
def write_csv_file(filepath, columns, headers=None):
|
def write_csv_file(filepath, columns, headers=None):
|
||||||
@@ -491,6 +494,7 @@ def write_csv_file(filepath, columns, headers=None):
|
|||||||
row = [str(col[i]) for col in columns]
|
row = [str(col[i]) for col in columns]
|
||||||
f.write(",".join(row) + "\n")
|
f.write(",".join(row) + "\n")
|
||||||
|
|
||||||
|
print(f" Wrote {n_rows} rows to {filepath}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -503,6 +507,10 @@ def scenario_single_target(range_m=500, velocity=0, rcs=0, n_adc_samples=16384):
|
|||||||
Good for validating matched filter range response.
|
Good for validating matched filter range response.
|
||||||
"""
|
"""
|
||||||
target = Target(range_m=range_m, velocity_mps=velocity, rcs_dbsm=rcs)
|
target = Target(range_m=range_m, velocity_mps=velocity, rcs_dbsm=rcs)
|
||||||
|
print(f"Scenario: Single target at {range_m}m")
|
||||||
|
print(f" {target}")
|
||||||
|
print(f" Beat freq: {CHIRP_BW / T_LONG_CHIRP * target.delay_s:.0f} Hz")
|
||||||
|
print(f" Delay: {target.delay_samples:.1f} ADC samples")
|
||||||
|
|
||||||
adc = generate_adc_samples([target], n_adc_samples, noise_stddev=2.0)
|
adc = generate_adc_samples([target], n_adc_samples, noise_stddev=2.0)
|
||||||
return adc, [target]
|
return adc, [target]
|
||||||
@@ -517,8 +525,9 @@ def scenario_two_targets(n_adc_samples=16384):
|
|||||||
Target(range_m=300, velocity_mps=0, rcs_dbsm=10, phase_deg=0),
|
Target(range_m=300, velocity_mps=0, rcs_dbsm=10, phase_deg=0),
|
||||||
Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45),
|
Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45),
|
||||||
]
|
]
|
||||||
for _t in targets:
|
print("Scenario: Two targets (range resolution test)")
|
||||||
pass
|
for t in targets:
|
||||||
|
print(f" {t}")
|
||||||
|
|
||||||
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0)
|
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0)
|
||||||
return adc, targets
|
return adc, targets
|
||||||
@@ -535,8 +544,9 @@ def scenario_multi_target(n_adc_samples=16384):
|
|||||||
Target(range_m=2000, velocity_mps=50, rcs_dbsm=0, phase_deg=45),
|
Target(range_m=2000, velocity_mps=50, rcs_dbsm=0, phase_deg=45),
|
||||||
Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270),
|
Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270),
|
||||||
]
|
]
|
||||||
for _t in targets:
|
print("Scenario: Multi-target (5 targets)")
|
||||||
pass
|
for t in targets:
|
||||||
|
print(f" {t}")
|
||||||
|
|
||||||
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0)
|
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0)
|
||||||
return adc, targets
|
return adc, targets
|
||||||
@@ -546,6 +556,7 @@ def scenario_noise_only(n_adc_samples=16384, noise_stddev=5.0):
|
|||||||
"""
|
"""
|
||||||
Noise-only scene — baseline for false alarm characterization.
|
Noise-only scene — baseline for false alarm characterization.
|
||||||
"""
|
"""
|
||||||
|
print(f"Scenario: Noise only (stddev={noise_stddev})")
|
||||||
adc = generate_adc_samples([], n_adc_samples, noise_stddev=noise_stddev)
|
adc = generate_adc_samples([], n_adc_samples, noise_stddev=noise_stddev)
|
||||||
return adc, []
|
return adc, []
|
||||||
|
|
||||||
@@ -554,6 +565,7 @@ def scenario_dc_tone(n_adc_samples=16384, adc_value=128):
|
|||||||
"""
|
"""
|
||||||
DC input — validates CIC decimation and DC response.
|
DC input — validates CIC decimation and DC response.
|
||||||
"""
|
"""
|
||||||
|
print(f"Scenario: DC tone (ADC value={adc_value})")
|
||||||
return [adc_value] * n_adc_samples, []
|
return [adc_value] * n_adc_samples, []
|
||||||
|
|
||||||
|
|
||||||
@@ -561,6 +573,7 @@ def scenario_sine_wave(n_adc_samples=16384, freq_hz=1e6, amplitude=50):
|
|||||||
"""
|
"""
|
||||||
Pure sine wave at ADC input — validates NCO/mixer frequency response.
|
Pure sine wave at ADC input — validates NCO/mixer frequency response.
|
||||||
"""
|
"""
|
||||||
|
print(f"Scenario: Sine wave at {freq_hz/1e6:.1f} MHz, amplitude={amplitude}")
|
||||||
adc = []
|
adc = []
|
||||||
for n in range(n_adc_samples):
|
for n in range(n_adc_samples):
|
||||||
t = n / FS_ADC
|
t = n / FS_ADC
|
||||||
@@ -590,35 +603,46 @@ def generate_all_test_vectors(output_dir=None):
|
|||||||
if output_dir is None:
|
if output_dir is None:
|
||||||
output_dir = os.path.dirname(os.path.abspath(__file__))
|
output_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
print("=" * 60)
|
||||||
|
print("Generating AERIS-10 Test Vectors")
|
||||||
|
print(f"Output directory: {output_dir}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
n_adc = 16384 # ~41 us of ADC data
|
n_adc = 16384 # ~41 us of ADC data
|
||||||
|
|
||||||
# --- Scenario 1: Single target ---
|
# --- Scenario 1: Single target ---
|
||||||
|
print("\n--- Scenario 1: Single Target ---")
|
||||||
adc1, targets1 = scenario_single_target(range_m=500, n_adc_samples=n_adc)
|
adc1, targets1 = scenario_single_target(range_m=500, n_adc_samples=n_adc)
|
||||||
write_hex_file(os.path.join(output_dir, "adc_single_target.hex"), adc1, bits=8)
|
write_hex_file(os.path.join(output_dir, "adc_single_target.hex"), adc1, bits=8)
|
||||||
|
|
||||||
# --- Scenario 2: Multi-target ---
|
# --- Scenario 2: Multi-target ---
|
||||||
|
print("\n--- Scenario 2: Multi-Target ---")
|
||||||
adc2, targets2 = scenario_multi_target(n_adc_samples=n_adc)
|
adc2, targets2 = scenario_multi_target(n_adc_samples=n_adc)
|
||||||
write_hex_file(os.path.join(output_dir, "adc_multi_target.hex"), adc2, bits=8)
|
write_hex_file(os.path.join(output_dir, "adc_multi_target.hex"), adc2, bits=8)
|
||||||
|
|
||||||
# --- Scenario 3: Noise only ---
|
# --- Scenario 3: Noise only ---
|
||||||
|
print("\n--- Scenario 3: Noise Only ---")
|
||||||
adc3, _ = scenario_noise_only(n_adc_samples=n_adc)
|
adc3, _ = scenario_noise_only(n_adc_samples=n_adc)
|
||||||
write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8)
|
write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8)
|
||||||
|
|
||||||
# --- Scenario 4: DC ---
|
# --- Scenario 4: DC ---
|
||||||
|
print("\n--- Scenario 4: DC Input ---")
|
||||||
adc4, _ = scenario_dc_tone(n_adc_samples=n_adc)
|
adc4, _ = scenario_dc_tone(n_adc_samples=n_adc)
|
||||||
write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8)
|
write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8)
|
||||||
|
|
||||||
# --- Scenario 5: Sine wave ---
|
# --- Scenario 5: Sine wave ---
|
||||||
|
print("\n--- Scenario 5: 1 MHz Sine ---")
|
||||||
adc5, _ = scenario_sine_wave(n_adc_samples=n_adc, freq_hz=1e6, amplitude=50)
|
adc5, _ = scenario_sine_wave(n_adc_samples=n_adc, freq_hz=1e6, amplitude=50)
|
||||||
write_hex_file(os.path.join(output_dir, "adc_sine_1mhz.hex"), adc5, bits=8)
|
write_hex_file(os.path.join(output_dir, "adc_sine_1mhz.hex"), adc5, bits=8)
|
||||||
|
|
||||||
# --- Reference chirp for matched filter ---
|
# --- Reference chirp for matched filter ---
|
||||||
|
print("\n--- Reference Chirp ---")
|
||||||
ref_re, ref_im = generate_reference_chirp_q15()
|
ref_re, ref_im = generate_reference_chirp_q15()
|
||||||
write_hex_file(os.path.join(output_dir, "ref_chirp_i.hex"), ref_re, bits=16)
|
write_hex_file(os.path.join(output_dir, "ref_chirp_i.hex"), ref_re, bits=16)
|
||||||
write_hex_file(os.path.join(output_dir, "ref_chirp_q.hex"), ref_im, bits=16)
|
write_hex_file(os.path.join(output_dir, "ref_chirp_q.hex"), ref_im, bits=16)
|
||||||
|
|
||||||
# --- Baseband samples for matched filter test (bypass DDC) ---
|
# --- Baseband samples for matched filter test (bypass DDC) ---
|
||||||
|
print("\n--- Baseband Samples (bypass DDC) ---")
|
||||||
bb_targets = [
|
bb_targets = [
|
||||||
Target(range_m=500, velocity_mps=0, rcs_dbsm=10),
|
Target(range_m=500, velocity_mps=0, rcs_dbsm=10),
|
||||||
Target(range_m=1500, velocity_mps=20, rcs_dbsm=5),
|
Target(range_m=1500, velocity_mps=20, rcs_dbsm=5),
|
||||||
@@ -628,6 +652,7 @@ def generate_all_test_vectors(output_dir=None):
|
|||||||
write_hex_file(os.path.join(output_dir, "bb_mf_test_q.hex"), bb_q, bits=16)
|
write_hex_file(os.path.join(output_dir, "bb_mf_test_q.hex"), bb_q, bits=16)
|
||||||
|
|
||||||
# --- Scenario info CSV ---
|
# --- Scenario info CSV ---
|
||||||
|
print("\n--- Scenario Info ---")
|
||||||
with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f:
|
with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f:
|
||||||
f.write("AERIS-10 Test Vector Scenarios\n")
|
f.write("AERIS-10 Test Vector Scenarios\n")
|
||||||
f.write("=" * 60 + "\n\n")
|
f.write("=" * 60 + "\n\n")
|
||||||
@@ -657,7 +682,11 @@ def generate_all_test_vectors(output_dir=None):
|
|||||||
for t in bb_targets:
|
for t in bb_targets:
|
||||||
f.write(f" {t}\n")
|
f.write(f" {t}\n")
|
||||||
|
|
||||||
|
print(f"\n Wrote scenario info to {os.path.join(output_dir, 'scenario_info.txt')}")
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ALL TEST VECTORS GENERATED")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'adc_single': adc1,
|
'adc_single': adc1,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ FIR_COEFFS_HEX = [
|
|||||||
# DDC output interface
|
# DDC output interface
|
||||||
DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation
|
DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation
|
||||||
|
|
||||||
|
# FFT (Range)
|
||||||
FFT_SIZE = 1024
|
FFT_SIZE = 1024
|
||||||
FFT_DATA_W = 16
|
FFT_DATA_W = 16
|
||||||
FFT_INTERNAL_W = 32
|
FFT_INTERNAL_W = 32
|
||||||
@@ -147,15 +148,21 @@ def load_and_quantize_adi_data(data_path, config_path, frame_idx=0):
|
|||||||
4. Upconvert to 120 MHz IF (add I*cos - Q*sin) to create real signal
|
4. Upconvert to 120 MHz IF (add I*cos - Q*sin) to create real signal
|
||||||
5. Quantize to 8-bit unsigned (matching AD9484)
|
5. Quantize to 8-bit unsigned (matching AD9484)
|
||||||
"""
|
"""
|
||||||
|
print(f"[LOAD] Loading ADI dataset from {data_path}")
|
||||||
data = np.load(data_path, allow_pickle=True)
|
data = np.load(data_path, allow_pickle=True)
|
||||||
config = np.load(config_path, allow_pickle=True)
|
config = np.load(config_path, allow_pickle=True)
|
||||||
|
|
||||||
|
print(f" Shape: {data.shape}, dtype: {data.dtype}")
|
||||||
|
print(f" Config: sample_rate={config[0]:.0f}, IF={config[1]:.0f}, "
|
||||||
|
f"RF={config[2]:.0f}, chirps={config[3]:.0f}, BW={config[4]:.0f}, "
|
||||||
|
f"ramp={config[5]:.6f}s")
|
||||||
|
|
||||||
# Extract one frame
|
# Extract one frame
|
||||||
frame = data[frame_idx] # (256, 1079) complex
|
frame = data[frame_idx] # (256, 1079) complex
|
||||||
|
|
||||||
# Use first 32 chirps, first 1024 samples
|
# Use first 32 chirps, first 1024 samples
|
||||||
iq_block = frame[:DOPPLER_CHIRPS, :FFT_SIZE] # (32, 1024) complex
|
iq_block = frame[:DOPPLER_CHIRPS, :FFT_SIZE] # (32, 1024) complex
|
||||||
|
print(f" Using frame {frame_idx}: {DOPPLER_CHIRPS} chirps x {FFT_SIZE} samples")
|
||||||
|
|
||||||
# The ADI data is baseband complex IQ at 4 MSPS.
|
# The ADI data is baseband complex IQ at 4 MSPS.
|
||||||
# AERIS-10 sees a real signal at 400 MSPS with 120 MHz IF.
|
# AERIS-10 sees a real signal at 400 MSPS with 120 MHz IF.
|
||||||
@@ -190,6 +197,9 @@ def load_and_quantize_adi_data(data_path, config_path, frame_idx=0):
|
|||||||
iq_i = np.clip(iq_i, -32768, 32767)
|
iq_i = np.clip(iq_i, -32768, 32767)
|
||||||
iq_q = np.clip(iq_q, -32768, 32767)
|
iq_q = np.clip(iq_q, -32768, 32767)
|
||||||
|
|
||||||
|
print(f" Scaled to 16-bit (peak target {INPUT_PEAK_TARGET}): "
|
||||||
|
f"I range [{iq_i.min()}, {iq_i.max()}], "
|
||||||
|
f"Q range [{iq_q.min()}, {iq_q.max()}]")
|
||||||
|
|
||||||
# Also create 8-bit ADC stimulus for DDC validation
|
# Also create 8-bit ADC stimulus for DDC validation
|
||||||
# Use just one chirp of real-valued data (I channel only, shifted to unsigned)
|
# Use just one chirp of real-valued data (I channel only, shifted to unsigned)
|
||||||
@@ -281,6 +291,7 @@ def run_ddc(adc_samples):
|
|||||||
# Build FIR coefficients as signed integers
|
# Build FIR coefficients as signed integers
|
||||||
fir_coeffs = np.array([hex_to_signed(c, 18) for c in FIR_COEFFS_HEX], dtype=np.int64)
|
fir_coeffs = np.array([hex_to_signed(c, 18) for c in FIR_COEFFS_HEX], dtype=np.int64)
|
||||||
|
|
||||||
|
print(f"[DDC] Processing {n_samples} ADC samples at 400 MHz")
|
||||||
|
|
||||||
# --- NCO + Mixer ---
|
# --- NCO + Mixer ---
|
||||||
phase_accum = np.int64(0)
|
phase_accum = np.int64(0)
|
||||||
@@ -313,6 +324,7 @@ def run_ddc(adc_samples):
|
|||||||
# Phase accumulator update (ignore dithering for bit-accuracy)
|
# Phase accumulator update (ignore dithering for bit-accuracy)
|
||||||
phase_accum = (phase_accum + NCO_PHASE_INC) & 0xFFFFFFFF
|
phase_accum = (phase_accum + NCO_PHASE_INC) & 0xFFFFFFFF
|
||||||
|
|
||||||
|
print(f" Mixer output: I range [{mixed_i.min()}, {mixed_i.max()}]")
|
||||||
|
|
||||||
# --- CIC Decimator (5-stage, decimate-by-4) ---
|
# --- CIC Decimator (5-stage, decimate-by-4) ---
|
||||||
# Integrator section (at 400 MHz rate)
|
# Integrator section (at 400 MHz rate)
|
||||||
@@ -320,9 +332,7 @@ def run_ddc(adc_samples):
|
|||||||
for n in range(n_samples):
|
for n in range(n_samples):
|
||||||
integrators[0][n + 1] = (integrators[0][n] + mixed_i[n]) & ((1 << CIC_ACC_WIDTH) - 1)
|
integrators[0][n + 1] = (integrators[0][n] + mixed_i[n]) & ((1 << CIC_ACC_WIDTH) - 1)
|
||||||
for s in range(1, CIC_STAGES):
|
for s in range(1, CIC_STAGES):
|
||||||
integrators[s][n + 1] = (
|
integrators[s][n + 1] = (integrators[s][n] + integrators[s - 1][n + 1]) & ((1 << CIC_ACC_WIDTH) - 1)
|
||||||
integrators[s][n] + integrators[s - 1][n + 1]
|
|
||||||
) & ((1 << CIC_ACC_WIDTH) - 1)
|
|
||||||
|
|
||||||
# Downsample by 4
|
# Downsample by 4
|
||||||
n_decimated = n_samples // CIC_DECIMATION
|
n_decimated = n_samples // CIC_DECIMATION
|
||||||
@@ -356,6 +366,7 @@ def run_ddc(adc_samples):
|
|||||||
scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT
|
scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT
|
||||||
cic_output[k] = saturate(scaled, CIC_OUT_BITS)
|
cic_output[k] = saturate(scaled, CIC_OUT_BITS)
|
||||||
|
|
||||||
|
print(f" CIC output: {n_decimated} samples, range [{cic_output.min()}, {cic_output.max()}]")
|
||||||
|
|
||||||
# --- FIR Filter (32-tap) ---
|
# --- FIR Filter (32-tap) ---
|
||||||
delay_line = np.zeros(FIR_TAPS, dtype=np.int64)
|
delay_line = np.zeros(FIR_TAPS, dtype=np.int64)
|
||||||
@@ -377,6 +388,7 @@ def run_ddc(adc_samples):
|
|||||||
if fir_output[k] >= (1 << 17):
|
if fir_output[k] >= (1 << 17):
|
||||||
fir_output[k] -= (1 << 18)
|
fir_output[k] -= (1 << 18)
|
||||||
|
|
||||||
|
print(f" FIR output: range [{fir_output.min()}, {fir_output.max()}]")
|
||||||
|
|
||||||
# --- DDC Interface (18 → 16 bit) ---
|
# --- DDC Interface (18 → 16 bit) ---
|
||||||
ddc_output = np.zeros(n_decimated, dtype=np.int64)
|
ddc_output = np.zeros(n_decimated, dtype=np.int64)
|
||||||
@@ -393,6 +405,7 @@ def run_ddc(adc_samples):
|
|||||||
else:
|
else:
|
||||||
ddc_output[k] = saturate(trunc + round_bit, 16)
|
ddc_output[k] = saturate(trunc + round_bit, 16)
|
||||||
|
|
||||||
|
print(f" DDC output (16-bit): range [{ddc_output.min()}, {ddc_output.max()}]")
|
||||||
|
|
||||||
return ddc_output
|
return ddc_output
|
||||||
|
|
||||||
@@ -465,6 +478,7 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
|
|||||||
# Generate twiddle factors if file not available
|
# Generate twiddle factors if file not available
|
||||||
cos_rom = np.round(32767 * np.cos(2 * np.pi * np.arange(N // 4) / N)).astype(np.int64)
|
cos_rom = np.round(32767 * np.cos(2 * np.pi * np.arange(N // 4) / N)).astype(np.int64)
|
||||||
|
|
||||||
|
print(f"[FFT] Running {N}-point range FFT (bit-accurate)")
|
||||||
|
|
||||||
# Bit-reverse and sign-extend to 32-bit internal width
|
# Bit-reverse and sign-extend to 32-bit internal width
|
||||||
def bit_reverse(val, bits):
|
def bit_reverse(val, bits):
|
||||||
@@ -502,6 +516,9 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
|
|||||||
b_re = mem_re[addr_odd]
|
b_re = mem_re[addr_odd]
|
||||||
b_im = mem_im[addr_odd]
|
b_im = mem_im[addr_odd]
|
||||||
|
|
||||||
|
# Twiddle multiply: forward FFT
|
||||||
|
# prod_re = b_re * tw_cos + b_im * tw_sin
|
||||||
|
# prod_im = b_im * tw_cos - b_re * tw_sin
|
||||||
prod_re = b_re * tw_cos + b_im * tw_sin
|
prod_re = b_re * tw_cos + b_im * tw_sin
|
||||||
prod_im = b_im * tw_cos - b_re * tw_sin
|
prod_im = b_im * tw_cos - b_re * tw_sin
|
||||||
|
|
||||||
@@ -524,6 +541,8 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
|
|||||||
out_re[n] = saturate(mem_re[n], FFT_DATA_W)
|
out_re[n] = saturate(mem_re[n], FFT_DATA_W)
|
||||||
out_im[n] = saturate(mem_im[n], FFT_DATA_W)
|
out_im[n] = saturate(mem_im[n], FFT_DATA_W)
|
||||||
|
|
||||||
|
print(f" FFT output: re range [{out_re.min()}, {out_re.max()}], "
|
||||||
|
f"im range [{out_im.min()}, {out_im.max()}]")
|
||||||
|
|
||||||
return out_re, out_im
|
return out_re, out_im
|
||||||
|
|
||||||
@@ -558,6 +577,8 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
|
|||||||
decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64)
|
decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64)
|
||||||
decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64)
|
decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64)
|
||||||
|
|
||||||
|
print(f"[DECIM] Decimating {n_in}→{output_bins} bins, mode={'peak' if mode==1 else 'avg' if mode==2 else 'simple'}, "
|
||||||
|
f"start_bin={start_bin}, {n_chirps} chirps")
|
||||||
|
|
||||||
for c in range(n_chirps):
|
for c in range(n_chirps):
|
||||||
# Index into input, skip start_bin
|
# Index into input, skip start_bin
|
||||||
@@ -606,7 +627,7 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
|
|||||||
# Averaging: sum group, then >> 4 (divide by 16)
|
# Averaging: sum group, then >> 4 (divide by 16)
|
||||||
sum_i = np.int64(0)
|
sum_i = np.int64(0)
|
||||||
sum_q = np.int64(0)
|
sum_q = np.int64(0)
|
||||||
for _ in range(decimation_factor):
|
for _s in range(decimation_factor):
|
||||||
if in_idx >= input_bins:
|
if in_idx >= input_bins:
|
||||||
break
|
break
|
||||||
sum_i += int(range_fft_i[c, in_idx])
|
sum_i += int(range_fft_i[c, in_idx])
|
||||||
@@ -616,6 +637,9 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
|
|||||||
decimated_i[c, obin] = int(sum_i) >> 4
|
decimated_i[c, obin] = int(sum_i) >> 4
|
||||||
decimated_q[c, obin] = int(sum_q) >> 4
|
decimated_q[c, obin] = int(sum_q) >> 4
|
||||||
|
|
||||||
|
print(f" Decimated output: shape ({n_chirps}, {output_bins}), "
|
||||||
|
f"I range [{decimated_i.min()}, {decimated_i.max()}], "
|
||||||
|
f"Q range [{decimated_q.min()}, {decimated_q.max()}]")
|
||||||
|
|
||||||
return decimated_i, decimated_q
|
return decimated_i, decimated_q
|
||||||
|
|
||||||
@@ -641,6 +665,7 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
|
|||||||
n_total = DOPPLER_TOTAL_BINS
|
n_total = DOPPLER_TOTAL_BINS
|
||||||
n_sf = CHIRPS_PER_SUBFRAME
|
n_sf = CHIRPS_PER_SUBFRAME
|
||||||
|
|
||||||
|
print(f"[DOPPLER] Processing {n_range} range bins x {n_chirps} chirps → dual {n_fft}-point FFT")
|
||||||
|
|
||||||
# Build 16-point Hamming window as signed 16-bit
|
# Build 16-point Hamming window as signed 16-bit
|
||||||
hamming = np.array([int(v) for v in HAMMING_Q15], dtype=np.int64)
|
hamming = np.array([int(v) for v in HAMMING_Q15], dtype=np.int64)
|
||||||
@@ -650,9 +675,7 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
|
|||||||
if twiddle_file_16 and os.path.exists(twiddle_file_16):
|
if twiddle_file_16 and os.path.exists(twiddle_file_16):
|
||||||
cos_rom_16 = load_twiddle_rom(twiddle_file_16)
|
cos_rom_16 = load_twiddle_rom(twiddle_file_16)
|
||||||
else:
|
else:
|
||||||
cos_rom_16 = np.round(
|
cos_rom_16 = np.round(32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)).astype(np.int64)
|
||||||
32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)
|
|
||||||
).astype(np.int64)
|
|
||||||
|
|
||||||
LOG2N_16 = 4
|
LOG2N_16 = 4
|
||||||
doppler_map_i = np.zeros((n_range, n_total), dtype=np.int64)
|
doppler_map_i = np.zeros((n_range, n_total), dtype=np.int64)
|
||||||
@@ -724,6 +747,8 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
|
|||||||
doppler_map_i[rbin, bin_offset + n] = saturate(mem_re[n], 16)
|
doppler_map_i[rbin, bin_offset + n] = saturate(mem_re[n], 16)
|
||||||
doppler_map_q[rbin, bin_offset + n] = saturate(mem_im[n], 16)
|
doppler_map_q[rbin, bin_offset + n] = saturate(mem_im[n], 16)
|
||||||
|
|
||||||
|
print(f" Doppler map: shape ({n_range}, {n_total}), "
|
||||||
|
f"I range [{doppler_map_i.min()}, {doppler_map_i.max()}]")
|
||||||
|
|
||||||
return doppler_map_i, doppler_map_q
|
return doppler_map_i, doppler_map_q
|
||||||
|
|
||||||
@@ -753,10 +778,12 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
|
|||||||
mti_i = np.zeros_like(decim_i)
|
mti_i = np.zeros_like(decim_i)
|
||||||
mti_q = np.zeros_like(decim_q)
|
mti_q = np.zeros_like(decim_q)
|
||||||
|
|
||||||
|
print(f"[MTI] 2-pulse canceller, enable={enable}, {n_chirps} chirps x {n_bins} bins")
|
||||||
|
|
||||||
if not enable:
|
if not enable:
|
||||||
mti_i[:] = decim_i
|
mti_i[:] = decim_i
|
||||||
mti_q[:] = decim_q
|
mti_q[:] = decim_q
|
||||||
|
print(" Pass-through mode (MTI disabled)")
|
||||||
return mti_i, mti_q
|
return mti_i, mti_q
|
||||||
|
|
||||||
for c in range(n_chirps):
|
for c in range(n_chirps):
|
||||||
@@ -772,6 +799,9 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
|
|||||||
mti_i[c, r] = saturate(diff_i, 16)
|
mti_i[c, r] = saturate(diff_i, 16)
|
||||||
mti_q[c, r] = saturate(diff_q, 16)
|
mti_q[c, r] = saturate(diff_q, 16)
|
||||||
|
|
||||||
|
print(" Chirp 0: muted (zeros)")
|
||||||
|
print(f" Chirps 1-{n_chirps-1}: I range [{mti_i[1:].min()}, {mti_i[1:].max()}], "
|
||||||
|
f"Q range [{mti_q[1:].min()}, {mti_q[1:].max()}]")
|
||||||
return mti_i, mti_q
|
return mti_i, mti_q
|
||||||
|
|
||||||
|
|
||||||
@@ -798,12 +828,14 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
|
|||||||
dc_notch_active = (width != 0) &&
|
dc_notch_active = (width != 0) &&
|
||||||
(bin_within_sf < width || bin_within_sf > (15 - width + 1))
|
(bin_within_sf < width || bin_within_sf > (15 - width + 1))
|
||||||
"""
|
"""
|
||||||
_n_range, n_doppler = doppler_i.shape
|
n_range, n_doppler = doppler_i.shape
|
||||||
notched_i = doppler_i.copy()
|
notched_i = doppler_i.copy()
|
||||||
notched_q = doppler_q.copy()
|
notched_q = doppler_q.copy()
|
||||||
|
|
||||||
|
print(f"[DC NOTCH] width={width}, {n_range} range bins x {n_doppler} Doppler bins (dual sub-frame)")
|
||||||
|
|
||||||
if width == 0:
|
if width == 0:
|
||||||
|
print(" Pass-through (width=0)")
|
||||||
return notched_i, notched_q
|
return notched_i, notched_q
|
||||||
|
|
||||||
zeroed_count = 0
|
zeroed_count = 0
|
||||||
@@ -815,6 +847,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
|
|||||||
notched_q[:, dbin] = 0
|
notched_q[:, dbin] = 0
|
||||||
zeroed_count += 1
|
zeroed_count += 1
|
||||||
|
|
||||||
|
print(f" Zeroed {zeroed_count} Doppler bin columns")
|
||||||
return notched_i, notched_q
|
return notched_i, notched_q
|
||||||
|
|
||||||
|
|
||||||
@@ -822,7 +855,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
|
|||||||
# Stage 3e: CA-CFAR Detector (bit-accurate)
|
# Stage 3e: CA-CFAR Detector (bit-accurate)
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
|
def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
|
||||||
alpha_q44=0x30, mode='CA', _simple_threshold=500):
|
alpha_q44=0x30, mode='CA', simple_threshold=500):
|
||||||
"""
|
"""
|
||||||
Bit-accurate model of cfar_ca.v — Cell-Averaging CFAR detector.
|
Bit-accurate model of cfar_ca.v — Cell-Averaging CFAR detector.
|
||||||
|
|
||||||
@@ -860,6 +893,9 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
|
|||||||
if train == 0:
|
if train == 0:
|
||||||
train = 1
|
train = 1
|
||||||
|
|
||||||
|
print(f"[CFAR] mode={mode}, guard={guard}, train={train}, "
|
||||||
|
f"alpha=0x{alpha_q44:02X} (Q4.4={alpha_q44/16:.2f}), "
|
||||||
|
f"{n_range} range x {n_doppler} Doppler")
|
||||||
|
|
||||||
# Compute magnitudes: |I| + |Q| (17-bit unsigned, matching RTL L1 norm)
|
# Compute magnitudes: |I| + |Q| (17-bit unsigned, matching RTL L1 norm)
|
||||||
# RTL: abs_i = I[15] ? (~I + 1) : I; abs_q = Q[15] ? (~Q + 1) : Q
|
# RTL: abs_i = I[15] ? (~I + 1) : I; abs_q = Q[15] ? (~Q + 1) : Q
|
||||||
@@ -927,6 +963,10 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
|
|||||||
else:
|
else:
|
||||||
noise_sum = leading_sum + lagging_sum # Default to CA
|
noise_sum = leading_sum + lagging_sum # Default to CA
|
||||||
|
|
||||||
|
# Threshold = (alpha * noise_sum) >> ALPHA_FRAC_BITS
|
||||||
|
# RTL: noise_product = r_alpha * noise_sum_reg (31-bit)
|
||||||
|
# threshold = noise_product[ALPHA_FRAC_BITS +: MAG_WIDTH]
|
||||||
|
# saturate if overflow
|
||||||
noise_product = alpha_q44 * noise_sum
|
noise_product = alpha_q44 * noise_sum
|
||||||
threshold_raw = noise_product >> ALPHA_FRAC_BITS
|
threshold_raw = noise_product >> ALPHA_FRAC_BITS
|
||||||
|
|
||||||
@@ -934,12 +974,15 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
|
|||||||
MAX_MAG = (1 << 17) - 1 # 131071
|
MAX_MAG = (1 << 17) - 1 # 131071
|
||||||
threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw)
|
threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw)
|
||||||
|
|
||||||
|
# Detection: magnitude > threshold
|
||||||
if int(col[cut_idx]) > threshold_val:
|
if int(col[cut_idx]) > threshold_val:
|
||||||
detect_flags[cut_idx, dbin] = True
|
detect_flags[cut_idx, dbin] = True
|
||||||
total_detections += 1
|
total_detections += 1
|
||||||
|
|
||||||
thresholds[cut_idx, dbin] = threshold_val
|
thresholds[cut_idx, dbin] = threshold_val
|
||||||
|
|
||||||
|
print(f" Total detections: {total_detections}")
|
||||||
|
print(f" Magnitude range: [{magnitudes.min()}, {magnitudes.max()}]")
|
||||||
|
|
||||||
return detect_flags, magnitudes, thresholds
|
return detect_flags, magnitudes, thresholds
|
||||||
|
|
||||||
@@ -953,16 +996,19 @@ def run_detection(doppler_i, doppler_q, threshold=10000):
|
|||||||
cfar_mag = |I| + |Q| (17-bit)
|
cfar_mag = |I| + |Q| (17-bit)
|
||||||
detection if cfar_mag > threshold
|
detection if cfar_mag > threshold
|
||||||
"""
|
"""
|
||||||
|
print(f"[DETECT] Running magnitude threshold detection (threshold={threshold})")
|
||||||
|
|
||||||
mag = np.abs(doppler_i) + np.abs(doppler_q) # L1 norm (|I| + |Q|)
|
mag = np.abs(doppler_i) + np.abs(doppler_q) # L1 norm (|I| + |Q|)
|
||||||
detections = np.argwhere(mag > threshold)
|
detections = np.argwhere(mag > threshold)
|
||||||
|
|
||||||
|
print(f" {len(detections)} detections found")
|
||||||
for d in detections[:20]: # Print first 20
|
for d in detections[:20]: # Print first 20
|
||||||
rbin, dbin = d
|
rbin, dbin = d
|
||||||
mag[rbin, dbin]
|
m = mag[rbin, dbin]
|
||||||
|
print(f" Range bin {rbin}, Doppler bin {dbin}: magnitude {m}")
|
||||||
|
|
||||||
if len(detections) > 20:
|
if len(detections) > 20:
|
||||||
pass
|
print(f" ... and {len(detections) - 20} more")
|
||||||
|
|
||||||
return mag, detections
|
return mag, detections
|
||||||
|
|
||||||
@@ -976,6 +1022,7 @@ def run_float_reference(iq_i, iq_q):
|
|||||||
Uses the exact same RTL Hamming window coefficients (Q15) to isolate
|
Uses the exact same RTL Hamming window coefficients (Q15) to isolate
|
||||||
only the FFT fixed-point quantization error.
|
only the FFT fixed-point quantization error.
|
||||||
"""
|
"""
|
||||||
|
print("\n[FLOAT REF] Running floating-point reference pipeline")
|
||||||
|
|
||||||
n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i)
|
n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i)
|
||||||
|
|
||||||
@@ -1023,6 +1070,8 @@ def write_hex_files(output_dir, iq_i, iq_q, prefix="stim"):
|
|||||||
fi.write(signed_to_hex(int(iq_i[n]), 16) + '\n')
|
fi.write(signed_to_hex(int(iq_i[n]), 16) + '\n')
|
||||||
fq.write(signed_to_hex(int(iq_q[n]), 16) + '\n')
|
fq.write(signed_to_hex(int(iq_q[n]), 16) + '\n')
|
||||||
|
|
||||||
|
print(f" Wrote {fn_i} ({n_samples} samples)")
|
||||||
|
print(f" Wrote {fn_q} ({n_samples} samples)")
|
||||||
|
|
||||||
elif iq_i.ndim == 2:
|
elif iq_i.ndim == 2:
|
||||||
n_rows, n_cols = iq_i.shape
|
n_rows, n_cols = iq_i.shape
|
||||||
@@ -1036,6 +1085,8 @@ def write_hex_files(output_dir, iq_i, iq_q, prefix="stim"):
|
|||||||
fi.write(signed_to_hex(int(iq_i[r, c]), 16) + '\n')
|
fi.write(signed_to_hex(int(iq_i[r, c]), 16) + '\n')
|
||||||
fq.write(signed_to_hex(int(iq_q[r, c]), 16) + '\n')
|
fq.write(signed_to_hex(int(iq_q[r, c]), 16) + '\n')
|
||||||
|
|
||||||
|
print(f" Wrote {fn_i} ({n_rows}x{n_cols} = {n_rows * n_cols} samples)")
|
||||||
|
print(f" Wrote {fn_q} ({n_rows}x{n_cols} = {n_rows * n_cols} samples)")
|
||||||
|
|
||||||
|
|
||||||
def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
|
def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
|
||||||
@@ -1047,12 +1098,13 @@ def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
|
|||||||
for n in range(len(adc_data)):
|
for n in range(len(adc_data)):
|
||||||
f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n')
|
f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n')
|
||||||
|
|
||||||
|
print(f" Wrote {fn} ({len(adc_data)} samples)")
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Comparison metrics
|
# Comparison metrics
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
|
def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
|
||||||
"""Compare fixed-point outputs against floating-point reference.
|
"""Compare fixed-point outputs against floating-point reference.
|
||||||
|
|
||||||
Reports two metrics:
|
Reports two metrics:
|
||||||
@@ -1068,7 +1120,7 @@ def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
|
|||||||
|
|
||||||
# Count saturated bins
|
# Count saturated bins
|
||||||
sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767)
|
sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767)
|
||||||
np.sum(sat_mask)
|
n_saturated = np.sum(sat_mask)
|
||||||
|
|
||||||
# Complex error — overall
|
# Complex error — overall
|
||||||
fixed_complex = fi + 1j * fq
|
fixed_complex = fi + 1j * fq
|
||||||
@@ -1077,8 +1129,8 @@ def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
|
|||||||
|
|
||||||
signal_power = np.mean(np.abs(ref_complex) ** 2) + 1e-30
|
signal_power = np.mean(np.abs(ref_complex) ** 2) + 1e-30
|
||||||
noise_power = np.mean(np.abs(error) ** 2) + 1e-30
|
noise_power = np.mean(np.abs(error) ** 2) + 1e-30
|
||||||
10 * np.log10(signal_power / noise_power)
|
snr_db = 10 * np.log10(signal_power / noise_power)
|
||||||
np.max(np.abs(error))
|
max_error = np.max(np.abs(error))
|
||||||
|
|
||||||
# Non-saturated comparison
|
# Non-saturated comparison
|
||||||
non_sat = ~sat_mask
|
non_sat = ~sat_mask
|
||||||
@@ -1087,10 +1139,17 @@ def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
|
|||||||
sig_ns = np.mean(np.abs(ref_complex[non_sat]) ** 2) + 1e-30
|
sig_ns = np.mean(np.abs(ref_complex[non_sat]) ** 2) + 1e-30
|
||||||
noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30
|
noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30
|
||||||
snr_ns = 10 * np.log10(sig_ns / noise_ns)
|
snr_ns = 10 * np.log10(sig_ns / noise_ns)
|
||||||
np.max(np.abs(error_ns))
|
max_err_ns = np.max(np.abs(error_ns))
|
||||||
else:
|
else:
|
||||||
snr_ns = 0.0
|
snr_ns = 0.0
|
||||||
|
max_err_ns = 0.0
|
||||||
|
|
||||||
|
print(f"\n [{name}] Comparison ({n} points):")
|
||||||
|
print(f" Saturated: {n_saturated}/{n} ({100.0*n_saturated/n:.2f}%)")
|
||||||
|
print(f" Overall SNR: {snr_db:.1f} dB")
|
||||||
|
print(f" Overall max error: {max_error:.1f}")
|
||||||
|
print(f" Non-sat SNR: {snr_ns:.1f} dB")
|
||||||
|
print(f" Non-sat max error: {max_err_ns:.1f}")
|
||||||
|
|
||||||
return snr_ns # Return the meaningful metric
|
return snr_ns # Return the meaningful metric
|
||||||
|
|
||||||
@@ -1102,12 +1161,7 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description="AERIS-10 FPGA golden reference model")
|
parser = argparse.ArgumentParser(description="AERIS-10 FPGA golden reference model")
|
||||||
parser.add_argument('--frame', type=int, default=0, help='Frame index to process')
|
parser.add_argument('--frame', type=int, default=0, help='Frame index to process')
|
||||||
parser.add_argument('--plot', action='store_true', help='Show plots')
|
parser.add_argument('--plot', action='store_true', help='Show plots')
|
||||||
parser.add_argument(
|
parser.add_argument('--threshold', type=int, default=10000, help='Detection threshold (L1 magnitude)')
|
||||||
'--threshold',
|
|
||||||
type=int,
|
|
||||||
default=10000,
|
|
||||||
help='Detection threshold (L1 magnitude)'
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
@@ -1115,14 +1169,14 @@ def main():
|
|||||||
fpga_dir = os.path.abspath(os.path.join(script_dir, '..', '..', '..'))
|
fpga_dir = os.path.abspath(os.path.join(script_dir, '..', '..', '..'))
|
||||||
data_base = os.path.expanduser("~/Downloads/adi_radar_data")
|
data_base = os.path.expanduser("~/Downloads/adi_radar_data")
|
||||||
amp_data = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB.npy")
|
amp_data = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB.npy")
|
||||||
amp_config = os.path.join(
|
amp_config = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy")
|
||||||
data_base,
|
|
||||||
"amp_radar",
|
|
||||||
"phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy"
|
|
||||||
)
|
|
||||||
twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem")
|
twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem")
|
||||||
output_dir = os.path.join(script_dir, "hex")
|
output_dir = os.path.join(script_dir, "hex")
|
||||||
|
|
||||||
|
print("=" * 72)
|
||||||
|
print("AERIS-10 FPGA Golden Reference Model")
|
||||||
|
print("Using ADI CN0566 Phaser Radar Data (10.525 GHz X-band FMCW)")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Load and quantize ADI data
|
# Load and quantize ADI data
|
||||||
@@ -1132,10 +1186,16 @@ def main():
|
|||||||
)
|
)
|
||||||
|
|
||||||
# iq_i, iq_q: (32, 1024) int64, 16-bit range — post-DDC equivalent
|
# iq_i, iq_q: (32, 1024) int64, 16-bit range — post-DDC equivalent
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 0: Data loaded and quantized to 16-bit signed")
|
||||||
|
print(f" IQ block shape: ({iq_i.shape[0]}, {iq_i.shape[1]})")
|
||||||
|
print(f" ADC stimulus: {len(adc_8bit)} samples (8-bit unsigned)")
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Write stimulus files
|
# Write stimulus files
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Writing hex stimulus files for RTL testbenches")
|
||||||
|
|
||||||
# Post-DDC IQ for each chirp (for FFT + Doppler validation)
|
# Post-DDC IQ for each chirp (for FFT + Doppler validation)
|
||||||
write_hex_files(output_dir, iq_i, iq_q, "post_ddc")
|
write_hex_files(output_dir, iq_i, iq_q, "post_ddc")
|
||||||
@@ -1149,6 +1209,8 @@ def main():
|
|||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Run range FFT on first chirp (bit-accurate)
|
# Run range FFT on first chirp (bit-accurate)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 2: Range FFT (1024-point, bit-accurate)")
|
||||||
range_fft_i, range_fft_q = run_range_fft(iq_i[0], iq_q[0], twiddle_1024)
|
range_fft_i, range_fft_q = run_range_fft(iq_i[0], iq_q[0], twiddle_1024)
|
||||||
write_hex_files(output_dir, range_fft_i, range_fft_q, "range_fft_chirp0")
|
write_hex_files(output_dir, range_fft_i, range_fft_q, "range_fft_chirp0")
|
||||||
|
|
||||||
@@ -1156,16 +1218,20 @@ def main():
|
|||||||
all_range_i = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
|
all_range_i = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
|
||||||
all_range_q = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
|
all_range_q = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
|
||||||
|
|
||||||
|
print(f"\n Running range FFT for all {DOPPLER_CHIRPS} chirps...")
|
||||||
for c in range(DOPPLER_CHIRPS):
|
for c in range(DOPPLER_CHIRPS):
|
||||||
ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024)
|
ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024)
|
||||||
all_range_i[c] = ri
|
all_range_i[c] = ri
|
||||||
all_range_q[c] = rq
|
all_range_q[c] = rq
|
||||||
if (c + 1) % 8 == 0:
|
if (c + 1) % 8 == 0:
|
||||||
pass
|
print(f" Chirp {c + 1}/{DOPPLER_CHIRPS} done")
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Run Doppler FFT (bit-accurate) — "direct" path (first 64 bins)
|
# Run Doppler FFT (bit-accurate) — "direct" path (first 64 bins)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 3: Doppler FFT (dual 16-point with Hamming window)")
|
||||||
|
print(" [direct path: first 64 range bins, no decimation]")
|
||||||
twiddle_16 = os.path.join(fpga_dir, "fft_twiddle_16.mem")
|
twiddle_16 = os.path.join(fpga_dir, "fft_twiddle_16.mem")
|
||||||
doppler_i, doppler_q = run_doppler_fft(all_range_i, all_range_q, twiddle_file_16=twiddle_16)
|
doppler_i, doppler_q = run_doppler_fft(all_range_i, all_range_q, twiddle_file_16=twiddle_16)
|
||||||
write_hex_files(output_dir, doppler_i, doppler_q, "doppler_map")
|
write_hex_files(output_dir, doppler_i, doppler_q, "doppler_map")
|
||||||
@@ -1175,6 +1241,8 @@ def main():
|
|||||||
# This models the actual RTL data flow:
|
# This models the actual RTL data flow:
|
||||||
# range FFT → range_bin_decimator (peak detection) → Doppler
|
# range FFT → range_bin_decimator (peak detection) → Doppler
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 2b: Range Bin Decimator (1024 → 64, peak detection)")
|
||||||
|
|
||||||
decim_i, decim_q = run_range_bin_decimator(
|
decim_i, decim_q = run_range_bin_decimator(
|
||||||
all_range_i, all_range_q,
|
all_range_i, all_range_q,
|
||||||
@@ -1194,11 +1262,14 @@ def main():
|
|||||||
q_val = int(all_range_q[c, b]) & 0xFFFF
|
q_val = int(all_range_q[c, b]) & 0xFFFF
|
||||||
packed = (q_val << 16) | i_val
|
packed = (q_val << 16) | i_val
|
||||||
f.write(f"{packed:08X}\n")
|
f.write(f"{packed:08X}\n")
|
||||||
|
print(f" Wrote {fc_input_file} ({DOPPLER_CHIRPS * FFT_SIZE} packed IQ words)")
|
||||||
|
|
||||||
# Write decimated output reference for standalone decimator test
|
# Write decimated output reference for standalone decimator test
|
||||||
write_hex_files(output_dir, decim_i, decim_q, "decimated_range")
|
write_hex_files(output_dir, decim_i, decim_q, "decimated_range")
|
||||||
|
|
||||||
# Now run Doppler on the decimated data — this is the full-chain reference
|
# Now run Doppler on the decimated data — this is the full-chain reference
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 3b: Doppler FFT on decimated data (full-chain path)")
|
||||||
fc_doppler_i, fc_doppler_q = run_doppler_fft(
|
fc_doppler_i, fc_doppler_q = run_doppler_fft(
|
||||||
decim_i, decim_q, twiddle_file_16=twiddle_16
|
decim_i, decim_q, twiddle_file_16=twiddle_16
|
||||||
)
|
)
|
||||||
@@ -1213,6 +1284,7 @@ def main():
|
|||||||
q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF
|
q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF
|
||||||
packed = (q_val << 16) | i_val
|
packed = (q_val << 16) | i_val
|
||||||
f.write(f"{packed:08X}\n")
|
f.write(f"{packed:08X}\n")
|
||||||
|
print(f" Wrote {fc_doppler_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)")
|
||||||
|
|
||||||
# Save numpy arrays for the full-chain path
|
# Save numpy arrays for the full-chain path
|
||||||
np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i)
|
np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i)
|
||||||
@@ -1225,12 +1297,16 @@ def main():
|
|||||||
# This models the complete RTL data flow:
|
# This models the complete RTL data flow:
|
||||||
# range FFT → decimator → MTI canceller → Doppler → DC notch → CFAR
|
# range FFT → decimator → MTI canceller → Doppler → DC notch → CFAR
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 3c: MTI Canceller (2-pulse, on decimated data)")
|
||||||
mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=True)
|
mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=True)
|
||||||
write_hex_files(output_dir, mti_i, mti_q, "fullchain_mti_ref")
|
write_hex_files(output_dir, mti_i, mti_q, "fullchain_mti_ref")
|
||||||
np.save(os.path.join(output_dir, "fullchain_mti_i.npy"), mti_i)
|
np.save(os.path.join(output_dir, "fullchain_mti_i.npy"), mti_i)
|
||||||
np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q)
|
np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q)
|
||||||
|
|
||||||
# Doppler on MTI-filtered data
|
# Doppler on MTI-filtered data
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 3b+c: Doppler FFT on MTI-filtered decimated data")
|
||||||
mti_doppler_i, mti_doppler_q = run_doppler_fft(
|
mti_doppler_i, mti_doppler_q = run_doppler_fft(
|
||||||
mti_i, mti_q, twiddle_file_16=twiddle_16
|
mti_i, mti_q, twiddle_file_16=twiddle_16
|
||||||
)
|
)
|
||||||
@@ -1240,6 +1316,8 @@ def main():
|
|||||||
|
|
||||||
# DC notch on MTI-Doppler data
|
# DC notch on MTI-Doppler data
|
||||||
DC_NOTCH_WIDTH = 2 # Default test value: zero bins {0, 1, 31}
|
DC_NOTCH_WIDTH = 2 # Default test value: zero bins {0, 1, 31}
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print(f"Stage 3d: DC Notch Filter (width={DC_NOTCH_WIDTH})")
|
||||||
notched_i, notched_q = run_dc_notch(mti_doppler_i, mti_doppler_q, width=DC_NOTCH_WIDTH)
|
notched_i, notched_q = run_dc_notch(mti_doppler_i, mti_doppler_q, width=DC_NOTCH_WIDTH)
|
||||||
write_hex_files(output_dir, notched_i, notched_q, "fullchain_notched_ref")
|
write_hex_files(output_dir, notched_i, notched_q, "fullchain_notched_ref")
|
||||||
|
|
||||||
@@ -1252,12 +1330,15 @@ def main():
|
|||||||
q_val = int(notched_q[rbin, dbin]) & 0xFFFF
|
q_val = int(notched_q[rbin, dbin]) & 0xFFFF
|
||||||
packed = (q_val << 16) | i_val
|
packed = (q_val << 16) | i_val
|
||||||
f.write(f"{packed:08X}\n")
|
f.write(f"{packed:08X}\n")
|
||||||
|
print(f" Wrote {fc_notched_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)")
|
||||||
|
|
||||||
# CFAR on DC-notched data
|
# CFAR on DC-notched data
|
||||||
CFAR_GUARD = 2
|
CFAR_GUARD = 2
|
||||||
CFAR_TRAIN = 8
|
CFAR_TRAIN = 8
|
||||||
CFAR_ALPHA = 0x30 # Q4.4 = 3.0
|
CFAR_ALPHA = 0x30 # Q4.4 = 3.0
|
||||||
CFAR_MODE = 'CA'
|
CFAR_MODE = 'CA'
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print(f"Stage 3e: CA-CFAR (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})")
|
||||||
cfar_flags, cfar_mag, cfar_thr = run_cfar_ca(
|
cfar_flags, cfar_mag, cfar_thr = run_cfar_ca(
|
||||||
notched_i, notched_q,
|
notched_i, notched_q,
|
||||||
guard=CFAR_GUARD, train=CFAR_TRAIN,
|
guard=CFAR_GUARD, train=CFAR_TRAIN,
|
||||||
@@ -1272,6 +1353,7 @@ def main():
|
|||||||
for dbin in range(DOPPLER_TOTAL_BINS):
|
for dbin in range(DOPPLER_TOTAL_BINS):
|
||||||
m = int(cfar_mag[rbin, dbin]) & 0x1FFFF
|
m = int(cfar_mag[rbin, dbin]) & 0x1FFFF
|
||||||
f.write(f"{m:05X}\n")
|
f.write(f"{m:05X}\n")
|
||||||
|
print(f" Wrote {cfar_mag_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} mag values)")
|
||||||
|
|
||||||
# 2. Threshold map (17-bit unsigned)
|
# 2. Threshold map (17-bit unsigned)
|
||||||
cfar_thr_file = os.path.join(output_dir, "fullchain_cfar_thr.hex")
|
cfar_thr_file = os.path.join(output_dir, "fullchain_cfar_thr.hex")
|
||||||
@@ -1280,6 +1362,7 @@ def main():
|
|||||||
for dbin in range(DOPPLER_TOTAL_BINS):
|
for dbin in range(DOPPLER_TOTAL_BINS):
|
||||||
t = int(cfar_thr[rbin, dbin]) & 0x1FFFF
|
t = int(cfar_thr[rbin, dbin]) & 0x1FFFF
|
||||||
f.write(f"{t:05X}\n")
|
f.write(f"{t:05X}\n")
|
||||||
|
print(f" Wrote {cfar_thr_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} threshold values)")
|
||||||
|
|
||||||
# 3. Detection flags (1-bit per cell)
|
# 3. Detection flags (1-bit per cell)
|
||||||
cfar_det_file = os.path.join(output_dir, "fullchain_cfar_det.hex")
|
cfar_det_file = os.path.join(output_dir, "fullchain_cfar_det.hex")
|
||||||
@@ -1288,6 +1371,7 @@ def main():
|
|||||||
for dbin in range(DOPPLER_TOTAL_BINS):
|
for dbin in range(DOPPLER_TOTAL_BINS):
|
||||||
d = 1 if cfar_flags[rbin, dbin] else 0
|
d = 1 if cfar_flags[rbin, dbin] else 0
|
||||||
f.write(f"{d:01X}\n")
|
f.write(f"{d:01X}\n")
|
||||||
|
print(f" Wrote {cfar_det_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} detection flags)")
|
||||||
|
|
||||||
# 4. Detection list (text)
|
# 4. Detection list (text)
|
||||||
cfar_detections = np.argwhere(cfar_flags)
|
cfar_detections = np.argwhere(cfar_flags)
|
||||||
@@ -1295,14 +1379,12 @@ def main():
|
|||||||
with open(cfar_det_list_file, 'w') as f:
|
with open(cfar_det_list_file, 'w') as f:
|
||||||
f.write("# AERIS-10 Full-Chain CFAR Detection List\n")
|
f.write("# AERIS-10 Full-Chain CFAR Detection List\n")
|
||||||
f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n")
|
f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n")
|
||||||
f.write(
|
f.write(f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n")
|
||||||
f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, "
|
|
||||||
f"alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n"
|
|
||||||
)
|
|
||||||
f.write("# Format: range_bin doppler_bin magnitude threshold\n")
|
f.write("# Format: range_bin doppler_bin magnitude threshold\n")
|
||||||
for det in cfar_detections:
|
for det in cfar_detections:
|
||||||
r, d = det
|
r, d = det
|
||||||
f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n")
|
f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n")
|
||||||
|
print(f" Wrote {cfar_det_list_file} ({len(cfar_detections)} detections)")
|
||||||
|
|
||||||
# Save numpy arrays
|
# Save numpy arrays
|
||||||
np.save(os.path.join(output_dir, "fullchain_cfar_mag.npy"), cfar_mag)
|
np.save(os.path.join(output_dir, "fullchain_cfar_mag.npy"), cfar_mag)
|
||||||
@@ -1310,6 +1392,8 @@ def main():
|
|||||||
np.save(os.path.join(output_dir, "fullchain_cfar_flags.npy"), cfar_flags)
|
np.save(os.path.join(output_dir, "fullchain_cfar_flags.npy"), cfar_flags)
|
||||||
|
|
||||||
# Run detection on full-chain Doppler map
|
# Run detection on full-chain Doppler map
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 4: Detection on full-chain Doppler map")
|
||||||
fc_mag, fc_detections = run_detection(fc_doppler_i, fc_doppler_q, threshold=args.threshold)
|
fc_mag, fc_detections = run_detection(fc_doppler_i, fc_doppler_q, threshold=args.threshold)
|
||||||
|
|
||||||
# Save full-chain detection reference
|
# Save full-chain detection reference
|
||||||
@@ -1321,6 +1405,7 @@ def main():
|
|||||||
for d in fc_detections:
|
for d in fc_detections:
|
||||||
rbin, dbin = d
|
rbin, dbin = d
|
||||||
f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n")
|
f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n")
|
||||||
|
print(f" Wrote {fc_det_file} ({len(fc_detections)} detections)")
|
||||||
|
|
||||||
# Also write detection reference as hex for RTL comparison
|
# Also write detection reference as hex for RTL comparison
|
||||||
fc_det_mag_file = os.path.join(output_dir, "fullchain_detection_mag.hex")
|
fc_det_mag_file = os.path.join(output_dir, "fullchain_detection_mag.hex")
|
||||||
@@ -1329,10 +1414,13 @@ def main():
|
|||||||
for dbin in range(DOPPLER_TOTAL_BINS):
|
for dbin in range(DOPPLER_TOTAL_BINS):
|
||||||
m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned
|
m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned
|
||||||
f.write(f"{m:05X}\n")
|
f.write(f"{m:05X}\n")
|
||||||
|
print(f" Wrote {fc_det_mag_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} magnitude values)")
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Run detection on direct-path Doppler map (for backward compatibility)
|
# Run detection on direct-path Doppler map (for backward compatibility)
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Stage 4b: Detection on direct-path Doppler map")
|
||||||
mag, detections = run_detection(doppler_i, doppler_q, threshold=args.threshold)
|
mag, detections = run_detection(doppler_i, doppler_q, threshold=args.threshold)
|
||||||
|
|
||||||
# Save detection list
|
# Save detection list
|
||||||
@@ -1344,23 +1432,26 @@ def main():
|
|||||||
for d in detections:
|
for d in detections:
|
||||||
rbin, dbin = d
|
rbin, dbin = d
|
||||||
f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n")
|
f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n")
|
||||||
|
print(f" Wrote {det_file} ({len(detections)} detections)")
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Float reference and comparison
|
# Float reference and comparison
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("Comparison: Fixed-point vs Float reference")
|
||||||
|
|
||||||
range_fft_float, doppler_float = run_float_reference(iq_i, iq_q)
|
range_fft_float, doppler_float = run_float_reference(iq_i, iq_q)
|
||||||
|
|
||||||
# Compare range FFT (chirp 0)
|
# Compare range FFT (chirp 0)
|
||||||
float_range_i = np.real(range_fft_float[0, :]).astype(np.float64)
|
float_range_i = np.real(range_fft_float[0, :]).astype(np.float64)
|
||||||
float_range_q = np.imag(range_fft_float[0, :]).astype(np.float64)
|
float_range_q = np.imag(range_fft_float[0, :]).astype(np.float64)
|
||||||
compare_outputs("Range FFT", range_fft_i, range_fft_q,
|
snr_range = compare_outputs("Range FFT", range_fft_i, range_fft_q,
|
||||||
float_range_i, float_range_q)
|
float_range_i, float_range_q)
|
||||||
|
|
||||||
# Compare Doppler map
|
# Compare Doppler map
|
||||||
float_doppler_i = np.real(doppler_float).flatten().astype(np.float64)
|
float_doppler_i = np.real(doppler_float).flatten().astype(np.float64)
|
||||||
float_doppler_q = np.imag(doppler_float).flatten().astype(np.float64)
|
float_doppler_q = np.imag(doppler_float).flatten().astype(np.float64)
|
||||||
compare_outputs("Doppler FFT",
|
snr_doppler = compare_outputs("Doppler FFT",
|
||||||
doppler_i.flatten(), doppler_q.flatten(),
|
doppler_i.flatten(), doppler_q.flatten(),
|
||||||
float_doppler_i, float_doppler_q)
|
float_doppler_i, float_doppler_q)
|
||||||
|
|
||||||
@@ -1372,10 +1463,26 @@ def main():
|
|||||||
np.save(os.path.join(output_dir, "doppler_map_i.npy"), doppler_i)
|
np.save(os.path.join(output_dir, "doppler_map_i.npy"), doppler_i)
|
||||||
np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q)
|
np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q)
|
||||||
np.save(os.path.join(output_dir, "detection_mag.npy"), mag)
|
np.save(os.path.join(output_dir, "detection_mag.npy"), mag)
|
||||||
|
print(f"\n Saved numpy reference files to {output_dir}/")
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Summary
|
# Summary
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
|
print(f"\n{'=' * 72}")
|
||||||
|
print("SUMMARY")
|
||||||
|
print(f"{'=' * 72}")
|
||||||
|
print(f" ADI dataset: frame {args.frame} of amp_radar (CN0566, 10.525 GHz)")
|
||||||
|
print(f" Chirps processed: {DOPPLER_CHIRPS}")
|
||||||
|
print(f" Samples/chirp: {FFT_SIZE}")
|
||||||
|
print(f" Range FFT: {FFT_SIZE}-point → {snr_range:.1f} dB vs float")
|
||||||
|
print(f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming → {snr_doppler:.1f} dB vs float")
|
||||||
|
print(f" Detections (direct): {len(detections)} (threshold={args.threshold})")
|
||||||
|
print(" Full-chain decimator: 1024→64 peak detection")
|
||||||
|
print(f" Full-chain detections: {len(fc_detections)} (threshold={args.threshold})")
|
||||||
|
print(f" MTI+CFAR chain: decim → MTI → Doppler → DC notch(w={DC_NOTCH_WIDTH}) → CA-CFAR")
|
||||||
|
print(f" CFAR detections: {len(cfar_detections)} (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})")
|
||||||
|
print(f" Hex stimulus files: {output_dir}/")
|
||||||
|
print(" Ready for RTL co-simulation with Icarus Verilog")
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
# -----------------------------------------------------------------------
|
||||||
# Optional plots
|
# Optional plots
|
||||||
@@ -1426,10 +1533,11 @@ def main():
|
|||||||
plt.tight_layout()
|
plt.tight_layout()
|
||||||
plot_file = os.path.join(output_dir, "golden_reference_plots.png")
|
plot_file = os.path.join(output_dir, "golden_reference_plots.png")
|
||||||
plt.savefig(plot_file, dpi=150)
|
plt.savefig(plot_file, dpi=150)
|
||||||
|
print(f"\n Saved plots to {plot_file}")
|
||||||
plt.show()
|
plt.show()
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
print("\n [WARN] matplotlib not available, skipping plots")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,569 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
validate_mem_files.py — Validate all .mem files against AERIS-10 radar parameters.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. Structural: line counts, hex format, value ranges for all 12 .mem files
|
|
||||||
2. FFT twiddle files: bit-exact match against cos(2*pi*k/N) in Q15
|
|
||||||
3. Long chirp .mem files: reverse-engineer parameters, check for chirp structure
|
|
||||||
4. Short chirp .mem files: check length, value range, spectral content
|
|
||||||
5. latency_buffer LATENCY=3187 parameter validation
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 validate_mem_files.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# AERIS-10 System Parameters (from radar_scene.py)
|
|
||||||
# ============================================================================
|
|
||||||
F_CARRIER = 10.5e9 # 10.5 GHz carrier
|
|
||||||
C_LIGHT = 3.0e8
|
|
||||||
F_IF = 120e6 # IF frequency
|
|
||||||
CHIRP_BW = 20e6 # 20 MHz sweep
|
|
||||||
FS_ADC = 400e6 # ADC sample rate
|
|
||||||
FS_SYS = 100e6 # System clock (100 MHz, after CIC 4x)
|
|
||||||
T_LONG_CHIRP = 30e-6 # 30 us long chirp
|
|
||||||
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
|
|
||||||
CIC_DECIMATION = 4
|
|
||||||
FFT_SIZE = 1024
|
|
||||||
DOPPLER_FFT_SIZE = 16
|
|
||||||
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000 at 100 MHz
|
|
||||||
|
|
||||||
# Overlap-save parameters
|
|
||||||
OVERLAP_SAMPLES = 128
|
|
||||||
SEGMENT_ADVANCE = FFT_SIZE - OVERLAP_SAMPLES # 896
|
|
||||||
LONG_SEGMENTS = 4
|
|
||||||
|
|
||||||
MEM_DIR = os.path.join(os.path.dirname(__file__), '..', '..')
|
|
||||||
|
|
||||||
pass_count = 0
|
|
||||||
fail_count = 0
|
|
||||||
warn_count = 0
|
|
||||||
|
|
||||||
def check(condition, _label):
|
|
||||||
global pass_count, fail_count
|
|
||||||
if condition:
|
|
||||||
pass_count += 1
|
|
||||||
else:
|
|
||||||
fail_count += 1
|
|
||||||
|
|
||||||
def warn(_label):
|
|
||||||
global warn_count
|
|
||||||
warn_count += 1
|
|
||||||
|
|
||||||
def read_mem_hex(filename):
|
|
||||||
"""Read a .mem file, return list of integer values (16-bit signed)."""
|
|
||||||
path = os.path.join(MEM_DIR, filename)
|
|
||||||
values = []
|
|
||||||
with open(path) as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith('//'):
|
|
||||||
continue
|
|
||||||
val = int(line, 16)
|
|
||||||
# Interpret as 16-bit signed
|
|
||||||
if val >= 0x8000:
|
|
||||||
val -= 0x10000
|
|
||||||
values.append(val)
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 1: Structural validation of all .mem files
|
|
||||||
# ============================================================================
|
|
||||||
def test_structural():
|
|
||||||
|
|
||||||
expected = {
|
|
||||||
# FFT twiddle files (quarter-wave cosine ROMs)
|
|
||||||
'fft_twiddle_1024.mem': {'lines': 256, 'desc': '1024-pt FFT quarter-wave cos ROM'},
|
|
||||||
'fft_twiddle_16.mem': {'lines': 4, 'desc': '16-pt FFT quarter-wave cos ROM'},
|
|
||||||
# Long chirp segments (4 segments x 1024 samples each)
|
|
||||||
'long_chirp_seg0_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 I'},
|
|
||||||
'long_chirp_seg0_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 Q'},
|
|
||||||
'long_chirp_seg1_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 I'},
|
|
||||||
'long_chirp_seg1_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 Q'},
|
|
||||||
'long_chirp_seg2_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 I'},
|
|
||||||
'long_chirp_seg2_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 Q'},
|
|
||||||
'long_chirp_seg3_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 I'},
|
|
||||||
'long_chirp_seg3_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 Q'},
|
|
||||||
# Short chirp (50 samples)
|
|
||||||
'short_chirp_i.mem': {'lines': 50, 'desc': 'Short chirp I'},
|
|
||||||
'short_chirp_q.mem': {'lines': 50, 'desc': 'Short chirp Q'},
|
|
||||||
}
|
|
||||||
|
|
||||||
for fname, info in expected.items():
|
|
||||||
path = os.path.join(MEM_DIR, fname)
|
|
||||||
exists = os.path.isfile(path)
|
|
||||||
check(exists, f"{fname} exists")
|
|
||||||
if not exists:
|
|
||||||
continue
|
|
||||||
|
|
||||||
vals = read_mem_hex(fname)
|
|
||||||
check(len(vals) == info['lines'],
|
|
||||||
f"{fname}: {len(vals)} data lines (expected {info['lines']})")
|
|
||||||
|
|
||||||
# Check all values are in 16-bit signed range
|
|
||||||
in_range = all(-32768 <= v <= 32767 for v in vals)
|
|
||||||
check(in_range, f"{fname}: all values in [-32768, 32767]")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 2: FFT Twiddle Factor Validation
|
|
||||||
# ============================================================================
|
|
||||||
def test_twiddle_1024():
|
|
||||||
vals = read_mem_hex('fft_twiddle_1024.mem')
|
|
||||||
|
|
||||||
max_err = 0
|
|
||||||
err_details = []
|
|
||||||
for k in range(min(256, len(vals))):
|
|
||||||
angle = 2.0 * math.pi * k / 1024.0
|
|
||||||
expected = round(math.cos(angle) * 32767.0)
|
|
||||||
expected = max(-32768, min(32767, expected))
|
|
||||||
actual = vals[k]
|
|
||||||
err = abs(actual - expected)
|
|
||||||
if err > max_err:
|
|
||||||
max_err = err
|
|
||||||
if err > 1:
|
|
||||||
err_details.append((k, actual, expected, err))
|
|
||||||
|
|
||||||
check(max_err <= 1,
|
|
||||||
f"fft_twiddle_1024.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
|
|
||||||
if err_details:
|
|
||||||
for _, _act, _exp, _e in err_details[:5]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_twiddle_16():
|
|
||||||
vals = read_mem_hex('fft_twiddle_16.mem')
|
|
||||||
|
|
||||||
max_err = 0
|
|
||||||
for k in range(min(4, len(vals))):
|
|
||||||
angle = 2.0 * math.pi * k / 16.0
|
|
||||||
expected = round(math.cos(angle) * 32767.0)
|
|
||||||
expected = max(-32768, min(32767, expected))
|
|
||||||
actual = vals[k]
|
|
||||||
err = abs(actual - expected)
|
|
||||||
if err > max_err:
|
|
||||||
max_err = err
|
|
||||||
|
|
||||||
check(max_err <= 1,
|
|
||||||
f"fft_twiddle_16.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
|
|
||||||
|
|
||||||
# Print all 4 entries for reference
|
|
||||||
for k in range(min(4, len(vals))):
|
|
||||||
angle = 2.0 * math.pi * k / 16.0
|
|
||||||
expected = round(math.cos(angle) * 32767.0)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 3: Long Chirp .mem File Analysis
|
|
||||||
# ============================================================================
|
|
||||||
def test_long_chirp():
|
|
||||||
|
|
||||||
# Load all 4 segments
|
|
||||||
all_i = []
|
|
||||||
all_q = []
|
|
||||||
for seg in range(4):
|
|
||||||
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
|
|
||||||
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
|
|
||||||
all_i.extend(seg_i)
|
|
||||||
all_q.extend(seg_q)
|
|
||||||
|
|
||||||
total_samples = len(all_i)
|
|
||||||
check(total_samples == 4096,
|
|
||||||
f"Total long chirp samples: {total_samples} (expected 4096 = 4 segs x 1024)")
|
|
||||||
|
|
||||||
# Compute magnitude envelope
|
|
||||||
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(all_i, all_q, strict=False)]
|
|
||||||
max_mag = max(magnitudes)
|
|
||||||
min(magnitudes)
|
|
||||||
sum(magnitudes) / len(magnitudes)
|
|
||||||
|
|
||||||
|
|
||||||
# Check if this looks like it came from generate_reference_chirp_q15
|
|
||||||
# That function uses 32767 * 0.9 scaling => max magnitude ~29490
|
|
||||||
expected_max_from_model = 32767 * 0.9
|
|
||||||
uses_model_scaling = max_mag > expected_max_from_model * 0.8
|
|
||||||
if uses_model_scaling:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model "
|
|
||||||
f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.")
|
|
||||||
|
|
||||||
# Check non-zero content: how many samples are non-zero?
|
|
||||||
sum(1 for v in all_i if v != 0)
|
|
||||||
sum(1 for v in all_q if v != 0)
|
|
||||||
|
|
||||||
# Analyze instantaneous frequency via phase differences
|
|
||||||
phases = []
|
|
||||||
for i_val, q_val in zip(all_i, all_q, strict=False):
|
|
||||||
if abs(i_val) > 5 or abs(q_val) > 5: # Skip near-zero samples
|
|
||||||
phases.append(math.atan2(q_val, i_val))
|
|
||||||
else:
|
|
||||||
phases.append(None)
|
|
||||||
|
|
||||||
# Compute phase differences (instantaneous frequency)
|
|
||||||
freq_estimates = []
|
|
||||||
for n in range(1, len(phases)):
|
|
||||||
if phases[n] is not None and phases[n-1] is not None:
|
|
||||||
dp = phases[n] - phases[n-1]
|
|
||||||
# Unwrap
|
|
||||||
while dp > math.pi:
|
|
||||||
dp -= 2 * math.pi
|
|
||||||
while dp < -math.pi:
|
|
||||||
dp += 2 * math.pi
|
|
||||||
# Frequency in Hz (at 100 MHz sample rate, since these are post-DDC)
|
|
||||||
f_inst = dp * FS_SYS / (2 * math.pi)
|
|
||||||
freq_estimates.append(f_inst)
|
|
||||||
|
|
||||||
if freq_estimates:
|
|
||||||
sum(freq_estimates[:50]) / 50 if len(freq_estimates) > 50 else freq_estimates[0]
|
|
||||||
sum(freq_estimates[-50:]) / 50 if len(freq_estimates) > 50 else freq_estimates[-1]
|
|
||||||
f_min = min(freq_estimates)
|
|
||||||
f_max = max(freq_estimates)
|
|
||||||
f_range = f_max - f_min
|
|
||||||
|
|
||||||
|
|
||||||
# A chirp should show frequency sweep
|
|
||||||
is_chirp = f_range > 0.5e6 # At least 0.5 MHz sweep
|
|
||||||
check(is_chirp,
|
|
||||||
f"Long chirp shows frequency sweep ({f_range/1e6:.2f} MHz > 0.5 MHz)")
|
|
||||||
|
|
||||||
# Check if bandwidth roughly matches expected
|
|
||||||
bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50%
|
|
||||||
if bw_match:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz")
|
|
||||||
|
|
||||||
# Compare segment boundaries for overlap-save consistency
|
|
||||||
# In proper overlap-save, the chirp data should be segmented at 896-sample boundaries
|
|
||||||
# with segments being 1024-sample FFT blocks
|
|
||||||
for seg in range(4):
|
|
||||||
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
|
|
||||||
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
|
|
||||||
seg_mags = [math.sqrt(i*i + q*q) for i, q in zip(seg_i, seg_q, strict=False)]
|
|
||||||
sum(seg_mags) / len(seg_mags)
|
|
||||||
max(seg_mags)
|
|
||||||
|
|
||||||
# Check segment 3 zero-padding (chirp is 3000 samples, seg3 starts at 3072)
|
|
||||||
# Samples 3000-4095 should be zero (or near-zero) if chirp is exactly 3000 samples
|
|
||||||
if seg == 3:
|
|
||||||
# Seg3 covers chirp samples 3072..4095
|
|
||||||
# If chirp is only 3000 samples, then only samples 0..(3000-3072) = NONE are valid
|
|
||||||
# Actually chirp has 3000 samples total. Seg3 starts at index 3*1024=3072.
|
|
||||||
# So seg3 should only have 3000-3072 = -72 -> no valid chirp data!
|
|
||||||
# Wait, but the .mem files have 1024 lines with non-trivial data...
|
|
||||||
# Let's check if seg3 has significant data
|
|
||||||
zero_count = sum(1 for m in seg_mags if m < 2)
|
|
||||||
if zero_count > 500:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 4: Short Chirp .mem File Analysis
|
|
||||||
# ============================================================================
|
|
||||||
def test_short_chirp():
|
|
||||||
|
|
||||||
short_i = read_mem_hex('short_chirp_i.mem')
|
|
||||||
short_q = read_mem_hex('short_chirp_q.mem')
|
|
||||||
|
|
||||||
check(len(short_i) == 50, f"Short chirp I: {len(short_i)} samples (expected 50)")
|
|
||||||
check(len(short_q) == 50, f"Short chirp Q: {len(short_q)} samples (expected 50)")
|
|
||||||
|
|
||||||
# Expected: 0.5 us chirp at 100 MHz = 50 samples
|
|
||||||
expected_samples = int(T_SHORT_CHIRP * FS_SYS)
|
|
||||||
check(len(short_i) == expected_samples,
|
|
||||||
f"Short chirp length matches T_SHORT_CHIRP * FS_SYS = {expected_samples}")
|
|
||||||
|
|
||||||
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(short_i, short_q, strict=False)]
|
|
||||||
max(magnitudes)
|
|
||||||
sum(magnitudes) / len(magnitudes)
|
|
||||||
|
|
||||||
|
|
||||||
# Check non-zero
|
|
||||||
nonzero = sum(1 for m in magnitudes if m > 1)
|
|
||||||
check(nonzero == len(short_i), f"All {nonzero}/{len(short_i)} samples non-zero")
|
|
||||||
|
|
||||||
# Check it looks like a chirp (phase should be quadratic)
|
|
||||||
phases = [math.atan2(q, i) for i, q in zip(short_i, short_q, strict=False)]
|
|
||||||
freq_est = []
|
|
||||||
for n in range(1, len(phases)):
|
|
||||||
dp = phases[n] - phases[n-1]
|
|
||||||
while dp > math.pi:
|
|
||||||
dp -= 2 * math.pi
|
|
||||||
while dp < -math.pi:
|
|
||||||
dp += 2 * math.pi
|
|
||||||
freq_est.append(dp * FS_SYS / (2 * math.pi))
|
|
||||||
|
|
||||||
if freq_est:
|
|
||||||
freq_est[0]
|
|
||||||
freq_est[-1]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 5: Generate Expected Chirp .mem and Compare
|
|
||||||
# ============================================================================
|
|
||||||
def test_chirp_vs_model():
|
|
||||||
|
|
||||||
# Generate reference using the same method as radar_scene.py
|
|
||||||
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
|
|
||||||
|
|
||||||
model_i = []
|
|
||||||
model_q = []
|
|
||||||
n_chirp = min(FFT_SIZE, LONG_CHIRP_SAMPLES) # 1024
|
|
||||||
|
|
||||||
for n in range(n_chirp):
|
|
||||||
t = n / FS_SYS
|
|
||||||
phase = math.pi * chirp_rate * t * t
|
|
||||||
re_val = round(32767 * 0.9 * math.cos(phase))
|
|
||||||
im_val = round(32767 * 0.9 * math.sin(phase))
|
|
||||||
model_i.append(max(-32768, min(32767, re_val)))
|
|
||||||
model_q.append(max(-32768, min(32767, im_val)))
|
|
||||||
|
|
||||||
# Read seg0 from .mem
|
|
||||||
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
|
|
||||||
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
|
|
||||||
|
|
||||||
# Compare magnitudes
|
|
||||||
model_mags = [math.sqrt(i*i + q*q) for i, q in zip(model_i, model_q, strict=False)]
|
|
||||||
mem_mags = [math.sqrt(i*i + q*q) for i, q in zip(mem_i, mem_q, strict=False)]
|
|
||||||
|
|
||||||
model_max = max(model_mags)
|
|
||||||
mem_max = max(mem_mags)
|
|
||||||
|
|
||||||
|
|
||||||
# Check if they match (they almost certainly won't based on magnitude analysis)
|
|
||||||
matches = sum(1 for a, b in zip(model_i, mem_i, strict=False) if a == b)
|
|
||||||
|
|
||||||
if matches > len(model_i) * 0.9:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
warn(".mem files do NOT match Python model. They likely have different provenance.")
|
|
||||||
# Try to detect scaling
|
|
||||||
if mem_max > 0:
|
|
||||||
model_max / mem_max
|
|
||||||
|
|
||||||
# Check phase correlation (shape match regardless of scaling)
|
|
||||||
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
|
|
||||||
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
|
|
||||||
|
|
||||||
# Compute phase differences
|
|
||||||
phase_diffs = []
|
|
||||||
for mp, fp in zip(model_phases, mem_phases, strict=False):
|
|
||||||
d = mp - fp
|
|
||||||
while d > math.pi:
|
|
||||||
d -= 2 * math.pi
|
|
||||||
while d < -math.pi:
|
|
||||||
d += 2 * math.pi
|
|
||||||
phase_diffs.append(d)
|
|
||||||
|
|
||||||
sum(phase_diffs) / len(phase_diffs)
|
|
||||||
max_phase_diff = max(abs(d) for d in phase_diffs)
|
|
||||||
|
|
||||||
|
|
||||||
phase_match = max_phase_diff < 0.5 # within 0.5 rad
|
|
||||||
check(
|
|
||||||
phase_match,
|
|
||||||
f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg "
|
|
||||||
f"(tolerance: 28.6 deg)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 6: Latency Buffer LATENCY=3187 Validation
|
|
||||||
# ============================================================================
|
|
||||||
def test_latency_buffer():
|
|
||||||
|
|
||||||
# The latency buffer delays the reference chirp data to align with
|
|
||||||
# the matched filter processing chain output.
|
|
||||||
#
|
|
||||||
# The total latency through the processing chain depends on the branch:
|
|
||||||
#
|
|
||||||
# SYNTHESIS branch (fft_engine.v):
|
|
||||||
# - Load: 1024 cycles (input)
|
|
||||||
# - Forward FFT: LOG2N=10 stages x N/2=512 butterflies x 5-cycle pipeline = variable
|
|
||||||
# - Reference FFT: same
|
|
||||||
# - Conjugate multiply: 1024 cycles (4-stage pipeline in frequency_matched_filter)
|
|
||||||
# - Inverse FFT: same as forward
|
|
||||||
# - Output: 1024 cycles
|
|
||||||
# Total: roughly 3000-4000 cycles depending on pipeline fill
|
|
||||||
#
|
|
||||||
# The LATENCY=3187 value was likely determined empirically to align
|
|
||||||
# the reference chirp arriving at the processing chain with the
|
|
||||||
# correct time-domain position.
|
|
||||||
#
|
|
||||||
# Key constraint: LATENCY must be < 4096 (BRAM buffer size)
|
|
||||||
LATENCY = 3187
|
|
||||||
BRAM_SIZE = 4096
|
|
||||||
|
|
||||||
check(LATENCY < BRAM_SIZE,
|
|
||||||
f"LATENCY ({LATENCY}) < BRAM size ({BRAM_SIZE})")
|
|
||||||
|
|
||||||
# The fft_engine processes in stages:
|
|
||||||
# - LOAD: 1024 clocks (accepts input)
|
|
||||||
# - Per butterfly stage: 512 butterflies x 5 pipeline stages = ~2560 clocks + overhead
|
|
||||||
# Actually: 512 butterflies, each takes 5 cycles = 2560 per stage, 10 stages
|
|
||||||
# Total compute: 10 * 2560 = 25600 clocks
|
|
||||||
# But this is just for ONE FFT. The chain does 3 FFTs + multiply.
|
|
||||||
#
|
|
||||||
# For the SIMULATION branch, it's 1 clock per operation (behavioral).
|
|
||||||
# LATENCY=3187 doesn't apply to simulation branch behavior —
|
|
||||||
# it's the physical hardware pipeline latency.
|
|
||||||
#
|
|
||||||
# For synthesis: the latency_buffer feeds ref data to the chain via
|
|
||||||
# chirp_memory_loader_param → latency_buffer → chain.
|
|
||||||
# But wait — looking at radar_receiver_final.v:
|
|
||||||
# - mem_request drives valid_in on the latency buffer
|
|
||||||
# - The buffer delays {ref_i, ref_q} by LATENCY valid_in cycles
|
|
||||||
# - The delayed output feeds long_chirp_real/imag → chain
|
|
||||||
#
|
|
||||||
# The purpose: the chain in the SYNTHESIS branch reads reference data
|
|
||||||
# via the long_chirp_real/imag ports DURING ST_FWD_FFT (while collecting
|
|
||||||
# input samples). The reference data needs to arrive LATENCY cycles
|
|
||||||
# after the first mem_request, where LATENCY accounts for:
|
|
||||||
# - The fft_engine pipeline latency from input to output
|
|
||||||
# - Specifically, the chain processes: load 1024 → FFT → FFT → multiply → IFFT → output
|
|
||||||
# The reference is consumed during the second FFT (ST_REF_BITREV/BUTTERFLY)
|
|
||||||
# which starts after the first FFT completes.
|
|
||||||
|
|
||||||
# For now, validate that LATENCY is reasonable (between 1000 and 4095)
|
|
||||||
check(1000 < LATENCY < 4095,
|
|
||||||
f"LATENCY={LATENCY} in reasonable range [1000, 4095]")
|
|
||||||
|
|
||||||
# Check that the module name vs parameter is consistent
|
|
||||||
# Module name was renamed from latency_buffer_2159 to latency_buffer
|
|
||||||
# to match the actual parameterized LATENCY value. No warning needed.
|
|
||||||
|
|
||||||
# Validate address arithmetic won't overflow
|
|
||||||
min_read_ptr = 4096 + 0 - LATENCY
|
|
||||||
check(min_read_ptr >= 0 and min_read_ptr < 4096,
|
|
||||||
f"Min read_ptr after wrap = {min_read_ptr} (valid: 0..4095)")
|
|
||||||
|
|
||||||
# The latency buffer uses valid_in gated reads, so it only counts
|
|
||||||
# valid samples. The number of valid_in pulses between first write
|
|
||||||
# and first read is LATENCY.
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 7: Cross-check chirp memory loader addressing
|
|
||||||
# ============================================================================
|
|
||||||
def test_memory_addressing():
|
|
||||||
|
|
||||||
# chirp_memory_loader_param uses: long_addr = {segment_select[1:0], sample_addr[9:0]}
|
|
||||||
# This creates a 12-bit address: seg[1:0] ++ addr[9:0]
|
|
||||||
# Segment 0: addresses 0x000..0x3FF (0..1023)
|
|
||||||
# Segment 1: addresses 0x400..0x7FF (1024..2047)
|
|
||||||
# Segment 2: addresses 0x800..0xBFF (2048..3071)
|
|
||||||
# Segment 3: addresses 0xC00..0xFFF (3072..4095)
|
|
||||||
|
|
||||||
for seg in range(4):
|
|
||||||
base = seg * 1024
|
|
||||||
end = base + 1023
|
|
||||||
addr_from_concat = (seg << 10) | 0 # {seg[1:0], 10'b0}
|
|
||||||
addr_end = (seg << 10) | 1023
|
|
||||||
|
|
||||||
check(
|
|
||||||
addr_from_concat == base,
|
|
||||||
f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} "
|
|
||||||
f"(expected {base})",
|
|
||||||
)
|
|
||||||
check(addr_end == end,
|
|
||||||
f"Seg {seg} end address: {{{seg}[1:0], 10'h3FF}} = {addr_end} (expected {end})")
|
|
||||||
|
|
||||||
# Memory is declared as: reg [15:0] long_chirp_i [0:4095]
|
|
||||||
# $readmemh loads seg0 to [0:1023], seg1 to [1024:2047], etc.
|
|
||||||
# Addressing via {segment_select, sample_addr} maps correctly.
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# TEST 8: Seg3 zero-padding analysis
|
|
||||||
# ============================================================================
|
|
||||||
def test_seg3_padding():
|
|
||||||
|
|
||||||
# The long chirp has 3000 samples (30 us at 100 MHz).
|
|
||||||
# With 4 segments of 1024 samples = 4096 total memory slots.
|
|
||||||
# Segments are loaded contiguously into memory:
|
|
||||||
# Seg0: chirp samples 0..1023
|
|
||||||
# Seg1: chirp samples 1024..2047
|
|
||||||
# Seg2: chirp samples 2048..3071
|
|
||||||
# Seg3: chirp samples 3072..4095
|
|
||||||
#
|
|
||||||
# But the chirp only has 3000 samples! So seg3 should have:
|
|
||||||
# Valid chirp data at indices 0..(3000-3072-1) = NEGATIVE
|
|
||||||
# Wait — 3072 > 3000, so seg3 has NO valid chirp samples if chirp is exactly 3000.
|
|
||||||
#
|
|
||||||
# However, the overlap-save algorithm in matched_filter_multi_segment.v
|
|
||||||
# collects data differently:
|
|
||||||
# Seg0: collect 896 DDC samples, buffer[0:895], zero-pad [896:1023]
|
|
||||||
# Seg1: overlap from seg0[768:895] → buffer[0:127], collect 896 → buffer[128:1023]
|
|
||||||
# ...
|
|
||||||
# The chirp reference is indexed by segment_select + sample_addr,
|
|
||||||
# so it reads ALL 1024 values for each segment regardless.
|
|
||||||
#
|
|
||||||
# If the chirp is 3000 samples but only 4*1024=4096 slots exist,
|
|
||||||
# the question is: do the .mem files contain 3000 samples of real chirp
|
|
||||||
# data spread across 4096 slots, or something else?
|
|
||||||
|
|
||||||
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
|
|
||||||
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
|
|
||||||
|
|
||||||
mags = [math.sqrt(i*i + q*q) for i, q in zip(seg3_i, seg3_q, strict=False)]
|
|
||||||
|
|
||||||
# Count trailing zeros (samples after chirp ends)
|
|
||||||
trailing_zeros = 0
|
|
||||||
for m in reversed(mags):
|
|
||||||
if m < 2:
|
|
||||||
trailing_zeros += 1
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
nonzero = sum(1 for m in mags if m > 2)
|
|
||||||
|
|
||||||
|
|
||||||
if nonzero == 1024:
|
|
||||||
# This means the .mem files encode 4096 chirp samples, not 3000
|
|
||||||
# The chirp duration used for .mem generation was different from T_LONG_CHIRP
|
|
||||||
actual_chirp_samples = 4 * 1024 # = 4096
|
|
||||||
actual_duration = actual_chirp_samples / FS_SYS
|
|
||||||
warn(f"Chirp in .mem files appears to be {actual_chirp_samples} samples "
|
|
||||||
f"({actual_duration*1e6:.1f} us), not {LONG_CHIRP_SAMPLES} samples "
|
|
||||||
f"({T_LONG_CHIRP*1e6:.1f} us)")
|
|
||||||
elif trailing_zeros > 100:
|
|
||||||
# Some padding at end
|
|
||||||
3072 + (1024 - trailing_zeros)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# MAIN
|
|
||||||
# ============================================================================
|
|
||||||
def main():
|
|
||||||
|
|
||||||
test_structural()
|
|
||||||
test_twiddle_1024()
|
|
||||||
test_twiddle_16()
|
|
||||||
test_long_chirp()
|
|
||||||
test_short_chirp()
|
|
||||||
test_chirp_vs_model()
|
|
||||||
test_latency_buffer()
|
|
||||||
test_memory_addressing()
|
|
||||||
test_seg3_padding()
|
|
||||||
|
|
||||||
if fail_count == 0:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return 0 if fail_count == 0 else 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -147,6 +147,7 @@ def main():
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Case 2: Tone autocorrelation at bin 5
|
# Case 2: Tone autocorrelation at bin 5
|
||||||
# Signal and reference: complex tone at bin 5, amplitude 8000 (Q15)
|
# Signal and reference: complex tone at bin 5, amplitude 8000 (Q15)
|
||||||
|
# sig[n] = 8000 * exp(j * 2*pi*5*n/N)
|
||||||
# Autocorrelation of a tone => peak at bin 0 (lag 0)
|
# Autocorrelation of a tone => peak at bin 0 (lag 0)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
amp = 8000.0
|
amp = 8000.0
|
||||||
@@ -240,12 +241,28 @@ def main():
|
|||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Print summary to stdout
|
# Print summary to stdout
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
print("=" * 72)
|
||||||
|
print("Matched Filter Golden Reference Generator")
|
||||||
|
print(f"Output directory: {outdir}")
|
||||||
|
print(f"FFT length: {N}")
|
||||||
|
print("=" * 72)
|
||||||
|
|
||||||
for _ in summaries:
|
for s in summaries:
|
||||||
pass
|
print()
|
||||||
|
print(f"Case {s['case']}: {s['description']}")
|
||||||
|
print(f" Peak bin: {s['peak_bin']}")
|
||||||
|
print(f" Peak magnitude (float):{s['peak_mag_float']:.6f}")
|
||||||
|
print(f" Peak I (float): {s['peak_i_float']:.6f}")
|
||||||
|
print(f" Peak Q (float): {s['peak_q_float']:.6f}")
|
||||||
|
print(f" Peak I (quantized): {s['peak_i_quant']}")
|
||||||
|
print(f" Peak Q (quantized): {s['peak_q_quant']}")
|
||||||
|
|
||||||
for _ in all_files:
|
print()
|
||||||
pass
|
print(f"Generated {len(all_files)} files:")
|
||||||
|
for fname in all_files:
|
||||||
|
print(f" {fname}")
|
||||||
|
print()
|
||||||
|
print("Done.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -96,31 +96,15 @@ end
|
|||||||
reg [5:0] chirp_counter;
|
reg [5:0] chirp_counter;
|
||||||
reg mc_new_chirp_prev;
|
reg mc_new_chirp_prev;
|
||||||
|
|
||||||
// Frame-start pulse: mirrors the real transmitter's new_chirp_frame signal.
|
|
||||||
// In the real system this fires on IDLE→LONG_CHIRP transitions in the chirp
|
|
||||||
// controller. Here we derive it from the mode controller's chirp_count
|
|
||||||
// wrapping back to 0 (which wraps correctly at cfg_chirps_per_elev).
|
|
||||||
reg tx_frame_start;
|
|
||||||
reg [5:0] rmc_chirp_prev;
|
|
||||||
|
|
||||||
always @(posedge clk_100m or negedge reset_n) begin
|
always @(posedge clk_100m or negedge reset_n) begin
|
||||||
if (!reset_n) begin
|
if (!reset_n) begin
|
||||||
chirp_counter <= 6'd0;
|
chirp_counter <= 6'd0;
|
||||||
mc_new_chirp_prev <= 1'b0;
|
mc_new_chirp_prev <= 1'b0;
|
||||||
tx_frame_start <= 1'b0;
|
|
||||||
rmc_chirp_prev <= 6'd0;
|
|
||||||
end else begin
|
end else begin
|
||||||
mc_new_chirp_prev <= dut.mc_new_chirp;
|
mc_new_chirp_prev <= dut.mc_new_chirp;
|
||||||
if (dut.mc_new_chirp != mc_new_chirp_prev) begin
|
if (dut.mc_new_chirp != mc_new_chirp_prev) begin
|
||||||
chirp_counter <= chirp_counter + 1;
|
chirp_counter <= chirp_counter + 1;
|
||||||
end
|
end
|
||||||
|
|
||||||
// Detect when the internal mode controller's chirp_count wraps to 0
|
|
||||||
tx_frame_start <= 1'b0;
|
|
||||||
if (dut.rmc_chirp_count == 6'd0 && rmc_chirp_prev != 6'd0) begin
|
|
||||||
tx_frame_start <= 1'b1;
|
|
||||||
end
|
|
||||||
rmc_chirp_prev <= dut.rmc_chirp_count;
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -144,7 +128,6 @@ radar_receiver_final dut (
|
|||||||
.adc_pwdn(),
|
.adc_pwdn(),
|
||||||
|
|
||||||
.chirp_counter(chirp_counter),
|
.chirp_counter(chirp_counter),
|
||||||
.tx_frame_start(tx_frame_start),
|
|
||||||
|
|
||||||
.doppler_output(doppler_output),
|
.doppler_output(doppler_output),
|
||||||
.doppler_valid(doppler_valid),
|
.doppler_valid(doppler_valid),
|
||||||
|
|||||||
@@ -38,20 +38,10 @@ reg signed [15:0] data_q_in;
|
|||||||
reg valid_in;
|
reg valid_in;
|
||||||
reg [3:0] gain_shift;
|
reg [3:0] gain_shift;
|
||||||
|
|
||||||
// AGC configuration (default: AGC disabled — manual mode)
|
|
||||||
reg agc_enable;
|
|
||||||
reg [7:0] agc_target;
|
|
||||||
reg [3:0] agc_attack;
|
|
||||||
reg [3:0] agc_decay;
|
|
||||||
reg [3:0] agc_holdoff;
|
|
||||||
reg frame_boundary;
|
|
||||||
|
|
||||||
wire signed [15:0] data_i_out;
|
wire signed [15:0] data_i_out;
|
||||||
wire signed [15:0] data_q_out;
|
wire signed [15:0] data_q_out;
|
||||||
wire valid_out;
|
wire valid_out;
|
||||||
wire [7:0] saturation_count;
|
wire [7:0] saturation_count;
|
||||||
wire [7:0] peak_magnitude;
|
|
||||||
wire [3:0] current_gain;
|
|
||||||
|
|
||||||
rx_gain_control dut (
|
rx_gain_control dut (
|
||||||
.clk(clk),
|
.clk(clk),
|
||||||
@@ -60,18 +50,10 @@ rx_gain_control dut (
|
|||||||
.data_q_in(data_q_in),
|
.data_q_in(data_q_in),
|
||||||
.valid_in(valid_in),
|
.valid_in(valid_in),
|
||||||
.gain_shift(gain_shift),
|
.gain_shift(gain_shift),
|
||||||
.agc_enable(agc_enable),
|
|
||||||
.agc_target(agc_target),
|
|
||||||
.agc_attack(agc_attack),
|
|
||||||
.agc_decay(agc_decay),
|
|
||||||
.agc_holdoff(agc_holdoff),
|
|
||||||
.frame_boundary(frame_boundary),
|
|
||||||
.data_i_out(data_i_out),
|
.data_i_out(data_i_out),
|
||||||
.data_q_out(data_q_out),
|
.data_q_out(data_q_out),
|
||||||
.valid_out(valid_out),
|
.valid_out(valid_out),
|
||||||
.saturation_count(saturation_count),
|
.saturation_count(saturation_count)
|
||||||
.peak_magnitude(peak_magnitude),
|
|
||||||
.current_gain(current_gain)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
@@ -123,13 +105,6 @@ initial begin
|
|||||||
data_q_in = 0;
|
data_q_in = 0;
|
||||||
valid_in = 0;
|
valid_in = 0;
|
||||||
gain_shift = 4'd0;
|
gain_shift = 4'd0;
|
||||||
// AGC disabled for backward-compatible tests (Tests 1-12)
|
|
||||||
agc_enable = 0;
|
|
||||||
agc_target = 8'd200;
|
|
||||||
agc_attack = 4'd1;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd4;
|
|
||||||
frame_boundary = 0;
|
|
||||||
|
|
||||||
repeat (4) @(posedge clk);
|
repeat (4) @(posedge clk);
|
||||||
reset_n = 1;
|
reset_n = 1;
|
||||||
@@ -177,9 +152,6 @@ initial begin
|
|||||||
"T3.1: I saturated to +32767");
|
"T3.1: I saturated to +32767");
|
||||||
check(data_q_out == -16'sd32768,
|
check(data_q_out == -16'sd32768,
|
||||||
"T3.2: Q saturated to -32768");
|
"T3.2: Q saturated to -32768");
|
||||||
// Pulse frame_boundary to snapshot the per-frame saturation count
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
check(saturation_count == 8'd1,
|
check(saturation_count == 8'd1,
|
||||||
"T3.3: Saturation counter = 1 (both channels clipped counts as 1)");
|
"T3.3: Saturation counter = 1 (both channels clipped counts as 1)");
|
||||||
|
|
||||||
@@ -201,9 +173,6 @@ initial begin
|
|||||||
"T4.1: I attenuated 4000>>2 = 1000");
|
"T4.1: I attenuated 4000>>2 = 1000");
|
||||||
check(data_q_out == -16'sd500,
|
check(data_q_out == -16'sd500,
|
||||||
"T4.2: Q attenuated -2000>>2 = -500");
|
"T4.2: Q attenuated -2000>>2 = -500");
|
||||||
// Pulse frame_boundary to snapshot (should be 0 — no clipping)
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
check(saturation_count == 8'd0,
|
check(saturation_count == 8'd0,
|
||||||
"T4.3: No saturation on right shift");
|
"T4.3: No saturation on right shift");
|
||||||
|
|
||||||
@@ -346,18 +315,13 @@ initial begin
|
|||||||
valid_in = 1'b0;
|
valid_in = 1'b0;
|
||||||
@(posedge clk); #1;
|
@(posedge clk); #1;
|
||||||
|
|
||||||
// Pulse frame_boundary to snapshot per-frame saturation count
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
check(saturation_count == 8'd255,
|
check(saturation_count == 8'd255,
|
||||||
"T11.1: Counter capped at 255 after 256 saturating samples");
|
"T11.1: Counter capped at 255 after 256 saturating samples");
|
||||||
|
|
||||||
// One more sample + frame boundary — should still be capped at 1 (new frame)
|
// One more sample — should stay at 255
|
||||||
send_sample(16'sd20000, 16'sd20000);
|
send_sample(16'sd20000, 16'sd20000);
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
check(saturation_count == 8'd255,
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
"T11.2: Counter stays at 255 (no wrap)");
|
||||||
check(saturation_count == 8'd1,
|
|
||||||
"T11.2: New frame counter = 1 (single sample)");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// TEST 12: Reset clears everything
|
// TEST 12: Reset clears everything
|
||||||
@@ -365,8 +329,6 @@ initial begin
|
|||||||
$display("");
|
$display("");
|
||||||
$display("--- Test 12: Reset clears all ---");
|
$display("--- Test 12: Reset clears all ---");
|
||||||
|
|
||||||
gain_shift = 4'd0; // Reset gain_shift to 0 so current_gain reads 0
|
|
||||||
agc_enable = 0;
|
|
||||||
reset_n = 0;
|
reset_n = 0;
|
||||||
repeat (2) @(posedge clk);
|
repeat (2) @(posedge clk);
|
||||||
reset_n = 1;
|
reset_n = 1;
|
||||||
@@ -380,479 +342,6 @@ initial begin
|
|||||||
"T12.3: valid_out cleared on reset");
|
"T12.3: valid_out cleared on reset");
|
||||||
check(saturation_count == 8'd0,
|
check(saturation_count == 8'd0,
|
||||||
"T12.4: Saturation counter cleared on reset");
|
"T12.4: Saturation counter cleared on reset");
|
||||||
check(current_gain == 4'd0,
|
|
||||||
"T12.5: current_gain cleared on reset");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// TEST 13: current_gain reflects gain_shift in manual mode
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 13: current_gain tracks gain_shift (manual) ---");
|
|
||||||
|
|
||||||
gain_shift = 4'b0_011; // amplify x8
|
|
||||||
@(posedge clk); @(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0011,
|
|
||||||
"T13.1: current_gain = 0x3 (amplify x8)");
|
|
||||||
|
|
||||||
gain_shift = 4'b1_010; // attenuate /4
|
|
||||||
@(posedge clk); @(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1010,
|
|
||||||
"T13.2: current_gain = 0xA (attenuate /4)");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// TEST 14: Peak magnitude tracking
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 14: Peak magnitude tracking ---");
|
|
||||||
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_000; // pass-through
|
|
||||||
// Send samples with increasing magnitude
|
|
||||||
send_sample(16'sd100, 16'sd50);
|
|
||||||
send_sample(16'sd1000, 16'sd500);
|
|
||||||
send_sample(16'sd8000, 16'sd2000); // peak = 8000
|
|
||||||
send_sample(16'sd200, 16'sd100);
|
|
||||||
// Pulse frame_boundary to snapshot
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
// peak_magnitude = upper 8 bits of 15-bit peak (8000)
|
|
||||||
// 8000 = 0x1F40, 15-bit = 0x1F40, [14:7] = 0x3E = 62
|
|
||||||
check(peak_magnitude == 8'd62,
|
|
||||||
"T14.1: Peak magnitude = 62 (8000 >> 7)");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// TEST 15: AGC auto gain-down on saturation
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 15: AGC gain-down on saturation ---");
|
|
||||||
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
// Start with amplify x4 (gain_shift = 0x02), then enable AGC
|
|
||||||
gain_shift = 4'b0_010; // amplify x4, internal gain = +2
|
|
||||||
agc_enable = 0;
|
|
||||||
agc_attack = 4'd1;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd2;
|
|
||||||
agc_target = 8'd100;
|
|
||||||
@(posedge clk); @(posedge clk);
|
|
||||||
|
|
||||||
// Enable AGC — should initialize from gain_shift
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); @(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0010,
|
|
||||||
"T15.1: AGC initialized from gain_shift (amplify x4)");
|
|
||||||
|
|
||||||
// Send saturating samples (will clip at x4 gain)
|
|
||||||
send_sample(16'sd20000, 16'sd20000);
|
|
||||||
send_sample(16'sd20000, 16'sd20000);
|
|
||||||
|
|
||||||
// Pulse frame_boundary — AGC should reduce gain by attack=1
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
// current_gain lags agc_gain by 1 cycle (NBA), wait one extra cycle
|
|
||||||
@(posedge clk); #1;
|
|
||||||
// Internal gain was +2, attack=1 → new gain = +1 (0x01)
|
|
||||||
check(current_gain == 4'b0001,
|
|
||||||
"T15.2: AGC reduced gain to x2 after saturation");
|
|
||||||
|
|
||||||
// Another frame with saturation (20000*2 = 40000 > 32767)
|
|
||||||
send_sample(16'sd20000, 16'sd20000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
// gain was +1, attack=1 → new gain = 0 (0x00)
|
|
||||||
check(current_gain == 4'b0000,
|
|
||||||
"T15.3: AGC reduced gain to x1 (pass-through)");
|
|
||||||
|
|
||||||
// At gain 0 (pass-through), 20000 does NOT overflow 16-bit range,
|
|
||||||
// so no saturation occurs. Signal peak = 20000 >> 7 = 156 > target(100),
|
|
||||||
// so AGC correctly holds gain at 0. This is expected behavior.
|
|
||||||
// To test crossing into attenuation: increase attack to 3.
|
|
||||||
agc_attack = 4'd3;
|
|
||||||
// Reset and start fresh with gain +2, attack=3
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_010; // amplify x4, internal gain = +2
|
|
||||||
agc_enable = 0;
|
|
||||||
@(posedge clk);
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); @(posedge clk); #1;
|
|
||||||
|
|
||||||
// Send saturating samples
|
|
||||||
send_sample(16'sd20000, 16'sd20000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
// gain was +2, attack=3 → new gain = -1 → encoding 0x09
|
|
||||||
check(current_gain == 4'b1001,
|
|
||||||
"T15.4: Large attack step crosses to attenuation (gain +2 - 3 = -1 → 0x9)");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// TEST 16: AGC auto gain-up after holdoff
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 16: AGC gain-up after holdoff ---");
|
|
||||||
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
// Start with low gain, weak signal, holdoff=2
|
|
||||||
gain_shift = 4'b0_000; // pass-through (internal gain = 0)
|
|
||||||
agc_enable = 0;
|
|
||||||
agc_attack = 4'd1;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd2;
|
|
||||||
agc_target = 8'd100; // target peak = 100 (in upper 8 bits = 12800 raw)
|
|
||||||
@(posedge clk); @(posedge clk);
|
|
||||||
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); #1;
|
|
||||||
|
|
||||||
// Frame 1: send weak signal (peak < target), holdoff counter = 2
|
|
||||||
send_sample(16'sd100, 16'sd50); // peak=100, [14:7]=0 (very weak)
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0000,
|
|
||||||
"T16.1: Gain held during holdoff (frame 1, holdoff=2)");
|
|
||||||
|
|
||||||
// Frame 2: still weak, holdoff counter decrements to 1
|
|
||||||
send_sample(16'sd100, 16'sd50);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0000,
|
|
||||||
"T16.2: Gain held during holdoff (frame 2, holdoff=1)");
|
|
||||||
|
|
||||||
// Frame 3: holdoff expired (was 0 at start of frame) → gain up
|
|
||||||
send_sample(16'sd100, 16'sd50);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0001,
|
|
||||||
"T16.3: Gain increased after holdoff expired (gain 0->1)");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// TEST 17: Repeated attacks drive gain negative, clamp at -7,
|
|
||||||
// then decay recovers
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 17: Repeated attack → negative clamp → decay recovery ---");
|
|
||||||
|
|
||||||
// ----- 17a: Walk gain from +7 down through zero via repeated attack -----
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_111; // amplify x128, internal gain = +7
|
|
||||||
agc_enable = 0;
|
|
||||||
agc_attack = 4'd2;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd2;
|
|
||||||
agc_target = 8'd100;
|
|
||||||
@(posedge clk);
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); @(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0_111,
|
|
||||||
"T17a.1: AGC initialized at gain +7 (0x7)");
|
|
||||||
|
|
||||||
// Frame 1: saturating at gain +7 → gain 7-2=5
|
|
||||||
send_sample(16'sd1000, 16'sd1000); // 1000<<7 = 128000 → overflow
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0_101,
|
|
||||||
"T17a.2: After attack: gain +5 (0x5)");
|
|
||||||
|
|
||||||
// Frame 2: still saturating at gain +5 → gain 5-2=3
|
|
||||||
send_sample(16'sd1000, 16'sd1000); // 1000<<5 = 32000 → no overflow
|
|
||||||
send_sample(16'sd2000, 16'sd2000); // 2000<<5 = 64000 → overflow
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0_011,
|
|
||||||
"T17a.3: After attack: gain +3 (0x3)");
|
|
||||||
|
|
||||||
// Frame 3: saturating at gain +3 → gain 3-2=1
|
|
||||||
send_sample(16'sd5000, 16'sd5000); // 5000<<3 = 40000 → overflow
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0_001,
|
|
||||||
"T17a.4: After attack: gain +1 (0x1)");
|
|
||||||
|
|
||||||
// Frame 4: saturating at gain +1 → gain 1-2=-1 → encoding 0x9
|
|
||||||
send_sample(16'sd20000, 16'sd20000); // 20000<<1 = 40000 → overflow
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_001,
|
|
||||||
"T17a.5: Attack crossed zero: gain -1 (0x9)");
|
|
||||||
|
|
||||||
// Frame 5: at gain -1 (right shift 1), 20000>>>1=10000, NO overflow.
|
|
||||||
// peak = 20000 → [14:7]=156 > target(100) → HOLD, gain stays -1
|
|
||||||
send_sample(16'sd20000, 16'sd20000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_001,
|
|
||||||
"T17a.6: No overflow at -1, peak>target → HOLD, gain stays -1");
|
|
||||||
|
|
||||||
// ----- 17b: Max attack step clamps at -7 -----
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 17b: Max attack clamps at -7 ---");
|
|
||||||
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_011; // amplify x8, internal gain = +3
|
|
||||||
agc_attack = 4'd15; // max attack step
|
|
||||||
agc_enable = 0;
|
|
||||||
@(posedge clk);
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); @(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b0_011,
|
|
||||||
"T17b.1: Initialized at gain +3");
|
|
||||||
|
|
||||||
// One saturating frame: gain = clamp(3 - 15) = clamp(-12) = -7 → 0xF
|
|
||||||
send_sample(16'sd5000, 16'sd5000); // 5000<<3 = 40000 → overflow
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_111,
|
|
||||||
"T17b.2: Gain clamped at -7 (0xF) after max attack");
|
|
||||||
|
|
||||||
// Another frame at gain -7: 5000>>>7 = 39, peak = 5000→[14:7]=39 < target(100)
|
|
||||||
// → decay path, but holdoff counter was reset to 2 by the attack above
|
|
||||||
send_sample(16'sd5000, 16'sd5000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_111,
|
|
||||||
"T17b.3: Gain still -7 (holdoff active, 2→1)");
|
|
||||||
|
|
||||||
// ----- 17c: Decay recovery from -7 after holdoff -----
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 17c: Decay recovery from deep negative ---");
|
|
||||||
|
|
||||||
// Holdoff was 2. After attack (frame above), holdoff=2.
|
|
||||||
// Frame after 17b.3: holdoff decrements to 0
|
|
||||||
send_sample(16'sd5000, 16'sd5000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_111,
|
|
||||||
"T17c.1: Gain still -7 (holdoff 1→0)");
|
|
||||||
|
|
||||||
// Now holdoff=0, next weak frame should trigger decay: -7 + 1 = -6 → 0xE
|
|
||||||
send_sample(16'sd5000, 16'sd5000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_110,
|
|
||||||
"T17c.2: Decay from -7 to -6 (0xE) after holdoff expired");
|
|
||||||
|
|
||||||
// One more decay: -6 + 1 = -5 → 0xD
|
|
||||||
send_sample(16'sd5000, 16'sd5000);
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
check(current_gain == 4'b1_101,
|
|
||||||
"T17c.3: Decay from -6 to -5 (0xD)");
|
|
||||||
|
|
||||||
// Verify output is actually attenuated: at gain -5 (right shift 5),
|
|
||||||
// 5000 >>> 5 = 156
|
|
||||||
send_sample(16'sd5000, 16'sd0);
|
|
||||||
check(data_i_out == 16'sd156,
|
|
||||||
"T17c.4: Output correctly attenuated: 5000>>>5 = 156");
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Test 18: valid_in + frame_boundary on the SAME cycle
|
|
||||||
// Verify the coincident sample is included in the frame snapshot
|
|
||||||
// (Bug #7 fix — previously lost due to NBA last-write-wins)
|
|
||||||
// =================================================================
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 18: valid_in + frame_boundary simultaneous ---");
|
|
||||||
|
|
||||||
// ----- 18a: Coincident saturating sample included in sat count -----
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_011; // amplify x8 (shift left 3)
|
|
||||||
agc_attack = 4'd1;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd2;
|
|
||||||
agc_target = 8'd100;
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); @(posedge clk); #1;
|
|
||||||
|
|
||||||
// Send one normal sample first (establishes a non-zero frame)
|
|
||||||
send_sample(16'sd100, 16'sd100); // small, no overflow at gain +3
|
|
||||||
|
|
||||||
// Now: assert valid_in AND frame_boundary on the SAME posedge.
|
|
||||||
// The sample is large enough to overflow at gain +3: 5000<<3 = 40000 > 32767
|
|
||||||
@(negedge clk);
|
|
||||||
data_i_in = 16'sd5000;
|
|
||||||
data_q_in = 16'sd5000;
|
|
||||||
valid_in = 1'b1;
|
|
||||||
frame_boundary = 1'b1;
|
|
||||||
@(posedge clk); #1; // DUT samples both signals
|
|
||||||
@(negedge clk);
|
|
||||||
valid_in = 1'b0;
|
|
||||||
frame_boundary = 1'b0;
|
|
||||||
@(posedge clk); #1; // let NBA settle
|
|
||||||
@(posedge clk); #1;
|
|
||||||
|
|
||||||
// Saturation count should be 1 (the coincident sample overflowed)
|
|
||||||
check(saturation_count == 8'd1,
|
|
||||||
"T18a.1: Coincident saturating sample counted in snapshot (sat_count=1)");
|
|
||||||
|
|
||||||
// Peak should reflect pre-gain max(|5000|,|5000|) = 5000 → [14:7] = 39
|
|
||||||
// (or at least >= the first sample's peak of 100→[14:7]=0)
|
|
||||||
check(peak_magnitude == 8'd39,
|
|
||||||
"T18a.2: Coincident sample peak included in snapshot (peak=39)");
|
|
||||||
|
|
||||||
// AGC should have attacked (sat > 0): gain +3 → +3-1 = +2
|
|
||||||
check(current_gain == 4'b0_010,
|
|
||||||
"T18a.3: AGC attacked on coincident saturation (gain +3 → +2)");
|
|
||||||
|
|
||||||
// ----- 18b: Coincident non-saturating peak updates snapshot -----
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 18b: Coincident peak-only sample ---");
|
|
||||||
|
|
||||||
reset_n = 0;
|
|
||||||
agc_enable = 0; // deassert so transition fires with NEW gain_shift
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_000; // no amplification (shift 0)
|
|
||||||
agc_attack = 4'd1;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd0;
|
|
||||||
agc_target = 8'd200; // high target so signal is "weak"
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); @(posedge clk); @(posedge clk); #1;
|
|
||||||
|
|
||||||
// Send a small sample
|
|
||||||
send_sample(16'sd50, 16'sd50);
|
|
||||||
|
|
||||||
// Coincident frame_boundary + valid_in with a LARGER sample (not saturating)
|
|
||||||
@(negedge clk);
|
|
||||||
data_i_in = 16'sd10000;
|
|
||||||
data_q_in = 16'sd10000;
|
|
||||||
valid_in = 1'b1;
|
|
||||||
frame_boundary = 1'b1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
@(negedge clk);
|
|
||||||
valid_in = 1'b0;
|
|
||||||
frame_boundary = 1'b0;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
|
|
||||||
// Peak should be max(|10000|,|10000|) = 10000 → [14:7] = 78
|
|
||||||
check(peak_magnitude == 8'd78,
|
|
||||||
"T18b.1: Coincident larger peak included (peak=78)");
|
|
||||||
// No saturation at gain 0
|
|
||||||
check(saturation_count == 8'd0,
|
|
||||||
"T18b.2: No saturation (gain=0, no overflow)");
|
|
||||||
|
|
||||||
// =================================================================
|
|
||||||
// Test 19: AGC enable toggle mid-frame
|
|
||||||
// Verify gain initializes from gain_shift and holdoff resets
|
|
||||||
// =================================================================
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 19: AGC enable toggle mid-frame ---");
|
|
||||||
|
|
||||||
// ----- 19a: Enable AGC mid-frame, verify gain init -----
|
|
||||||
reset_n = 0;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
reset_n = 1;
|
|
||||||
repeat (2) @(posedge clk);
|
|
||||||
|
|
||||||
gain_shift = 4'b0_101; // amplify x32 (shift left 5), internal = +5
|
|
||||||
agc_attack = 4'd2;
|
|
||||||
agc_decay = 4'd1;
|
|
||||||
agc_holdoff = 4'd3;
|
|
||||||
agc_target = 8'd100;
|
|
||||||
agc_enable = 0; // start disabled
|
|
||||||
@(posedge clk); #1;
|
|
||||||
|
|
||||||
// With AGC off, current_gain should follow gain_shift directly
|
|
||||||
check(current_gain == 4'b0_101,
|
|
||||||
"T19a.1: AGC disabled → current_gain = gain_shift (0x5)");
|
|
||||||
|
|
||||||
// Send a few samples (building up frame metrics)
|
|
||||||
send_sample(16'sd1000, 16'sd1000);
|
|
||||||
send_sample(16'sd2000, 16'sd2000);
|
|
||||||
|
|
||||||
// Toggle AGC enable ON mid-frame
|
|
||||||
@(negedge clk);
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
@(posedge clk); #1; // let enable transition register
|
|
||||||
|
|
||||||
// Gain should initialize from gain_shift encoding (0b0_101 → +5)
|
|
||||||
check(current_gain == 4'b0_101,
|
|
||||||
"T19a.2: AGC enabled mid-frame → gain initialized from gain_shift (+5)");
|
|
||||||
|
|
||||||
// Send a saturating sample, then boundary
|
|
||||||
send_sample(16'sd5000, 16'sd5000); // 5000<<5 overflows
|
|
||||||
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
|
|
||||||
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
|
|
||||||
// AGC should attack: gain +5 → +5-2 = +3
|
|
||||||
check(current_gain == 4'b0_011,
|
|
||||||
"T19a.3: After boundary, AGC attacked (gain +5 → +3)");
|
|
||||||
|
|
||||||
// ----- 19b: Disable AGC mid-frame, verify passthrough -----
|
|
||||||
$display("");
|
|
||||||
$display("--- Test 19b: Disable AGC mid-frame ---");
|
|
||||||
|
|
||||||
// Change gain_shift to a new value
|
|
||||||
@(negedge clk);
|
|
||||||
gain_shift = 4'b1_010; // attenuate by 2 (right shift 2)
|
|
||||||
agc_enable = 0;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
|
|
||||||
// With AGC off, current_gain should follow gain_shift
|
|
||||||
check(current_gain == 4'b1_010,
|
|
||||||
"T19b.1: AGC disabled → current_gain = gain_shift (0xA, atten 2)");
|
|
||||||
|
|
||||||
// Send sample: 1000 >> 2 = 250
|
|
||||||
send_sample(16'sd1000, 16'sd0);
|
|
||||||
check(data_i_out == 16'sd250,
|
|
||||||
"T19b.2: Output uses host gain_shift when AGC off: 1000>>2=250");
|
|
||||||
|
|
||||||
// ----- 19c: Re-enable, verify gain re-initializes -----
|
|
||||||
@(negedge clk);
|
|
||||||
gain_shift = 4'b0_010; // amplify by 4 (shift left 2), internal = +2
|
|
||||||
agc_enable = 1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
@(posedge clk); #1;
|
|
||||||
|
|
||||||
check(current_gain == 4'b0_010,
|
|
||||||
"T19c.1: AGC re-enabled → gain re-initialized from gain_shift (+2)");
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// SUMMARY
|
// SUMMARY
|
||||||
|
|||||||
@@ -79,12 +79,6 @@ module tb_usb_data_interface;
|
|||||||
reg [7:0] status_self_test_detail;
|
reg [7:0] status_self_test_detail;
|
||||||
reg status_self_test_busy;
|
reg status_self_test_busy;
|
||||||
|
|
||||||
// AGC status readback inputs
|
|
||||||
reg [3:0] status_agc_current_gain;
|
|
||||||
reg [7:0] status_agc_peak_magnitude;
|
|
||||||
reg [7:0] status_agc_saturation_count;
|
|
||||||
reg status_agc_enable;
|
|
||||||
|
|
||||||
// ── Clock generators (asynchronous) ────────────────────────
|
// ── Clock generators (asynchronous) ────────────────────────
|
||||||
always #(CLK_PERIOD / 2) clk = ~clk;
|
always #(CLK_PERIOD / 2) clk = ~clk;
|
||||||
always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in;
|
always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in;
|
||||||
@@ -140,13 +134,7 @@ module tb_usb_data_interface;
|
|||||||
// Self-test status readback
|
// Self-test status readback
|
||||||
.status_self_test_flags (status_self_test_flags),
|
.status_self_test_flags (status_self_test_flags),
|
||||||
.status_self_test_detail(status_self_test_detail),
|
.status_self_test_detail(status_self_test_detail),
|
||||||
.status_self_test_busy (status_self_test_busy),
|
.status_self_test_busy (status_self_test_busy)
|
||||||
|
|
||||||
// AGC status readback
|
|
||||||
.status_agc_current_gain (status_agc_current_gain),
|
|
||||||
.status_agc_peak_magnitude (status_agc_peak_magnitude),
|
|
||||||
.status_agc_saturation_count(status_agc_saturation_count),
|
|
||||||
.status_agc_enable (status_agc_enable)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Test bookkeeping ───────────────────────────────────────
|
// ── Test bookkeeping ───────────────────────────────────────
|
||||||
@@ -206,10 +194,6 @@ module tb_usb_data_interface;
|
|||||||
status_self_test_flags = 5'b00000;
|
status_self_test_flags = 5'b00000;
|
||||||
status_self_test_detail = 8'd0;
|
status_self_test_detail = 8'd0;
|
||||||
status_self_test_busy = 1'b0;
|
status_self_test_busy = 1'b0;
|
||||||
status_agc_current_gain = 4'd0;
|
|
||||||
status_agc_peak_magnitude = 8'd0;
|
|
||||||
status_agc_saturation_count = 8'd0;
|
|
||||||
status_agc_enable = 1'b0;
|
|
||||||
repeat (6) @(posedge ft601_clk_in);
|
repeat (6) @(posedge ft601_clk_in);
|
||||||
reset_n = 1;
|
reset_n = 1;
|
||||||
// Wait enough cycles for stream_control CDC to propagate
|
// Wait enough cycles for stream_control CDC to propagate
|
||||||
@@ -918,11 +902,6 @@ module tb_usb_data_interface;
|
|||||||
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: gain=5, peak=180, sat_count=12, enabled
|
|
||||||
status_agc_current_gain = 4'd5;
|
|
||||||
status_agc_peak_magnitude = 8'd180;
|
|
||||||
status_agc_saturation_count = 8'd12;
|
|
||||||
status_agc_enable = 1'b1;
|
|
||||||
|
|
||||||
// Pulse status_request (1 cycle in clk domain — toggles status_req_toggle_100m)
|
// Pulse status_request (1 cycle in clk domain — toggles status_req_toggle_100m)
|
||||||
@(posedge clk);
|
@(posedge clk);
|
||||||
@@ -979,8 +958,8 @@ module tb_usb_data_interface;
|
|||||||
"Status readback: word 2 = {guard, short_chirp}");
|
"Status readback: word 2 = {guard, short_chirp}");
|
||||||
check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32},
|
check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32},
|
||||||
"Status readback: word 3 = {short_listen, 0, chirps_per_elev}");
|
"Status readback: word 3 = {short_listen, 0, chirps_per_elev}");
|
||||||
check(uut.status_words[4] === {4'd5, 8'd180, 8'd12, 1'b1, 9'd0, 2'b10},
|
check(uut.status_words[4] === {30'd0, 2'b10},
|
||||||
"Status readback: word 4 = {agc_gain=5, peak=180, sat=12, en=1, range_mode=2}");
|
"Status readback: word 4 = range_mode=2'b10");
|
||||||
// status_words[5] = {7'd0, busy, 8'd0, detail[7:0], 3'd0, flags[4:0]}
|
// status_words[5] = {7'd0, busy, 8'd0, detail[7:0], 3'd0, flags[4:0]}
|
||||||
// = {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111}
|
// = {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111}
|
||||||
check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111},
|
check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111},
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ module usb_data_interface (
|
|||||||
// Control signals
|
// Control signals
|
||||||
output reg ft601_txe_n, // Transmit enable (active low)
|
output reg ft601_txe_n, // Transmit enable (active low)
|
||||||
output reg ft601_rxf_n, // Receive enable (active low)
|
output reg ft601_rxf_n, // Receive enable (active low)
|
||||||
input wire ft601_txe, // TXE: Transmit FIFO Not Full (high = space available to write)
|
input wire ft601_txe, // Transmit FIFO empty
|
||||||
input wire ft601_rxf, // RXF: Receive FIFO Not Empty (high = data available to read)
|
input wire ft601_rxf, // Receive FIFO full
|
||||||
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)
|
||||||
@@ -77,13 +77,7 @@ module usb_data_interface (
|
|||||||
// Self-test status readback (opcode 0x31 / included in 0xFF status packet)
|
// Self-test status readback (opcode 0x31 / included in 0xFF status packet)
|
||||||
input wire [4:0] status_self_test_flags, // Per-test PASS(1)/FAIL(0) latched
|
input wire [4:0] status_self_test_flags, // Per-test PASS(1)/FAIL(0) latched
|
||||||
input wire [7:0] status_self_test_detail, // Diagnostic detail byte latched
|
input wire [7:0] status_self_test_detail, // Diagnostic detail byte latched
|
||||||
input wire status_self_test_busy, // Self-test FSM still running
|
input wire status_self_test_busy // Self-test FSM still running
|
||||||
|
|
||||||
// AGC status readback
|
|
||||||
input wire [3:0] status_agc_current_gain,
|
|
||||||
input wire [7:0] status_agc_peak_magnitude,
|
|
||||||
input wire [7:0] status_agc_saturation_count,
|
|
||||||
input wire status_agc_enable
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// USB packet structure (same as before)
|
// USB packet structure (same as before)
|
||||||
@@ -273,13 +267,8 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
|
|||||||
status_words[2] <= {status_guard, status_short_chirp};
|
status_words[2] <= {status_guard, status_short_chirp};
|
||||||
// Word 3: {short_listen_cycles[15:0], chirps_per_elev[5:0], 10'b0}
|
// Word 3: {short_listen_cycles[15:0], chirps_per_elev[5:0], 10'b0}
|
||||||
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
|
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
|
||||||
// Word 4: AGC metrics + range_mode
|
// Word 4: Fix 7 — range_mode in bits [1:0], rest reserved
|
||||||
status_words[4] <= {status_agc_current_gain, // [31:28]
|
status_words[4] <= {30'd0, status_range_mode};
|
||||||
status_agc_peak_magnitude, // [27:20]
|
|
||||||
status_agc_saturation_count, // [19:12]
|
|
||||||
status_agc_enable, // [11]
|
|
||||||
9'd0, // [10:2] reserved
|
|
||||||
status_range_mode}; // [1:0]
|
|
||||||
// Word 5: Self-test results {reserved[6:0], busy, reserved[7:0], detail[7:0], reserved[2:0], flags[4:0]}
|
// Word 5: Self-test results {reserved[6:0], busy, reserved[7:0], detail[7:0], reserved[2:0], flags[4:0]}
|
||||||
status_words[5] <= {7'd0, status_self_test_busy,
|
status_words[5] <= {7'd0, status_self_test_busy,
|
||||||
8'd0, status_self_test_detail,
|
8'd0, status_self_test_detail,
|
||||||
|
|||||||
@@ -90,13 +90,7 @@ module usb_data_interface_ft2232h (
|
|||||||
// Self-test status readback
|
// Self-test status readback
|
||||||
input wire [4:0] status_self_test_flags,
|
input wire [4:0] status_self_test_flags,
|
||||||
input wire [7:0] status_self_test_detail,
|
input wire [7:0] status_self_test_detail,
|
||||||
input wire status_self_test_busy,
|
input wire status_self_test_busy
|
||||||
|
|
||||||
// AGC status readback
|
|
||||||
input wire [3:0] status_agc_current_gain,
|
|
||||||
input wire [7:0] status_agc_peak_magnitude,
|
|
||||||
input wire [7:0] status_agc_saturation_count,
|
|
||||||
input wire status_agc_enable
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -287,12 +281,7 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
status_words[1] <= {status_long_chirp, status_long_listen};
|
status_words[1] <= {status_long_chirp, status_long_listen};
|
||||||
status_words[2] <= {status_guard, status_short_chirp};
|
status_words[2] <= {status_guard, status_short_chirp};
|
||||||
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
|
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
|
||||||
status_words[4] <= {status_agc_current_gain, // [31:28]
|
status_words[4] <= {30'd0, status_range_mode};
|
||||||
status_agc_peak_magnitude, // [27:20]
|
|
||||||
status_agc_saturation_count, // [19:12]
|
|
||||||
status_agc_enable, // [11]
|
|
||||||
9'd0, // [10:2] reserved
|
|
||||||
status_range_mode}; // [1:0]
|
|
||||||
status_words[5] <= {7'd0, status_self_test_busy,
|
status_words[5] <= {7'd0, status_self_test_busy,
|
||||||
8'd0, status_self_test_detail,
|
8'd0, status_self_test_detail,
|
||||||
3'd0, status_self_test_flags};
|
3'd0, status_self_test_flags};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,6 @@ GUI_V5 ==> Added Mercury Color
|
|||||||
|
|
||||||
GUI_V6 ==> Added USB3 FT601 support
|
GUI_V6 ==> Added USB3 FT601 support
|
||||||
|
|
||||||
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_dashboard ==> Board bring-up dashboard (FT2232H reader, real-time R-D heatmap, CFAR overlay, waterfall, host commands, HDF5 recording)
|
||||||
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)
|
||||||
|
|||||||
@@ -1,338 +0,0 @@
|
|||||||
# ruff: noqa: T201
|
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
One-off AGC saturation analysis for ADI CN0566 raw IQ captures.
|
|
||||||
|
|
||||||
Bit-accurate simulation of rx_gain_control.v AGC inner loop applied
|
|
||||||
to real captured IQ data. Three scenarios per dataset:
|
|
||||||
|
|
||||||
Row 1 — AGC OFF: Fixed gain_shift=0 (pass-through). Shows raw clipping.
|
|
||||||
Row 2 — AGC ON: Auto-adjusts from gain_shift=0. Clipping clears.
|
|
||||||
Row 3 — AGC delayed: OFF for first half, ON at midpoint.
|
|
||||||
Shows the transition: clipping → AGC activates → clears.
|
|
||||||
|
|
||||||
Key RTL details modelled exactly:
|
|
||||||
- gain_shift[3]=direction (0=amplify/left, 1=attenuate/right), [2:0]=amount
|
|
||||||
- Internal agc_gain is signed -7..+7
|
|
||||||
- Peak is measured PRE-gain (raw input |sample|, upper 8 of 15 bits)
|
|
||||||
- Saturation is measured POST-gain (overflow from shift)
|
|
||||||
- Attack: gain -= agc_attack when any sample clips (immediate)
|
|
||||||
- Decay: gain += agc_decay when peak < target AND holdoff expired
|
|
||||||
- Hold: when peak >= target AND no saturation, hold gain, reset holdoff
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python adi_agc_analysis.py
|
|
||||||
python adi_agc_analysis.py --data /path/to/file.npy --label "my capture"
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
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)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
AGC_TARGET = 200 # host_agc_target (8-bit, default 200)
|
|
||||||
ADC_RAIL = 4095 # 12-bit ADC max absolute value
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Per-frame AGC simulation using v7.agc_sim (bit-accurate to RTL)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
|
|
||||||
enable_at_frame: int = 0,
|
|
||||||
initial_gain_enc: int = 0x00) -> dict:
|
|
||||||
"""Simulate FPGA inner-loop AGC across all frames.
|
|
||||||
|
|
||||||
Parameters
|
|
||||||
----------
|
|
||||||
frames : (N, chirps, samples) complex — raw ADC captures (12-bit range)
|
|
||||||
agc_enabled : if False, gain stays fixed
|
|
||||||
enable_at_frame : frame index where AGC activates
|
|
||||||
initial_gain_enc : gain_shift[3:0] encoding when AGC enables (default 0x00 = pass-through)
|
|
||||||
"""
|
|
||||||
n_frames = frames.shape[0]
|
|
||||||
|
|
||||||
# Output arrays
|
|
||||||
out_gain_enc = np.zeros(n_frames, dtype=int)
|
|
||||||
out_gain_signed = np.zeros(n_frames, dtype=int)
|
|
||||||
out_peak_mag = np.zeros(n_frames, dtype=int)
|
|
||||||
out_sat_count = np.zeros(n_frames, dtype=int)
|
|
||||||
out_sat_rate = np.zeros(n_frames, dtype=float)
|
|
||||||
out_rms_post = np.zeros(n_frames, dtype=float)
|
|
||||||
|
|
||||||
# AGC state — managed by process_agc_frame()
|
|
||||||
state = AGCState(
|
|
||||||
gain=encoding_to_signed(initial_gain_enc),
|
|
||||||
holdoff_counter=0,
|
|
||||||
was_enabled=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
for i in range(n_frames):
|
|
||||||
frame_i, frame_q = quantize_iq(frames[i])
|
|
||||||
|
|
||||||
agc_active = agc_enabled and (i >= enable_at_frame)
|
|
||||||
|
|
||||||
# Build per-frame config (enable toggles at enable_at_frame)
|
|
||||||
config = AGCConfig(enabled=agc_active)
|
|
||||||
|
|
||||||
result = process_agc_frame(frame_i, frame_q, config, state)
|
|
||||||
|
|
||||||
# RMS of shifted signal
|
|
||||||
rms = float(np.sqrt(np.mean(
|
|
||||||
result.shifted_i.astype(np.float64)**2
|
|
||||||
+ result.shifted_q.astype(np.float64)**2)))
|
|
||||||
|
|
||||||
total_samples = frame_i.size + frame_q.size
|
|
||||||
sat_rate = result.overflow_raw / total_samples if total_samples > 0 else 0.0
|
|
||||||
|
|
||||||
# Record outputs
|
|
||||||
out_gain_enc[i] = result.gain_enc
|
|
||||||
out_gain_signed[i] = result.gain_signed
|
|
||||||
out_peak_mag[i] = result.peak_mag_8bit
|
|
||||||
out_sat_count[i] = result.saturation_count
|
|
||||||
out_sat_rate[i] = sat_rate
|
|
||||||
out_rms_post[i] = rms
|
|
||||||
|
|
||||||
return {
|
|
||||||
"gain_enc": out_gain_enc,
|
|
||||||
"gain_signed": out_gain_signed,
|
|
||||||
"peak_mag": out_peak_mag,
|
|
||||||
"sat_count": out_sat_count,
|
|
||||||
"sat_rate": out_sat_rate,
|
|
||||||
"rms_post": out_rms_post,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Range-Doppler processing for heatmap display
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def process_frame_rd(frame: np.ndarray, gain_enc: int,
|
|
||||||
n_range: int = 64,
|
|
||||||
n_doppler: int = 32) -> np.ndarray:
|
|
||||||
"""Range-Doppler magnitude for one frame with gain applied."""
|
|
||||||
frame_i, frame_q = quantize_iq(frame)
|
|
||||||
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
|
|
||||||
|
|
||||||
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
|
|
||||||
n_chirps, _ = iq.shape
|
|
||||||
|
|
||||||
range_fft = np.fft.fft(iq, axis=1)[:, :n_range]
|
|
||||||
doppler_fft = np.fft.fftshift(np.fft.fft(range_fft, axis=0), axes=0)
|
|
||||||
center = n_chirps // 2
|
|
||||||
half_d = n_doppler // 2
|
|
||||||
doppler_fft = doppler_fft[center - half_d:center + half_d, :]
|
|
||||||
|
|
||||||
rd_mag = np.abs(doppler_fft.real) + np.abs(doppler_fft.imag)
|
|
||||||
return rd_mag.T # (n_range, n_doppler)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plotting
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def plot_scenario(axes, data: np.ndarray, agc: dict, title: str,
|
|
||||||
enable_frame: int = 0):
|
|
||||||
"""Plot one AGC scenario across 5 axes."""
|
|
||||||
n = data.shape[0]
|
|
||||||
xs = np.arange(n)
|
|
||||||
|
|
||||||
# Range-Doppler heatmap
|
|
||||||
if enable_frame > 0 and enable_frame < n:
|
|
||||||
f_before = max(0, enable_frame - 1)
|
|
||||||
f_after = min(n - 1, n - 2)
|
|
||||||
rd_before = process_frame_rd(data[f_before], int(agc["gain_enc"][f_before]))
|
|
||||||
rd_after = process_frame_rd(data[f_after], int(agc["gain_enc"][f_after]))
|
|
||||||
combined = np.hstack([rd_before, rd_after])
|
|
||||||
im = axes[0].imshow(
|
|
||||||
20 * np.log10(combined + 1), aspect="auto", origin="lower",
|
|
||||||
cmap="inferno", interpolation="nearest")
|
|
||||||
axes[0].axvline(x=rd_before.shape[1] - 0.5, color="cyan",
|
|
||||||
linewidth=2, linestyle="--")
|
|
||||||
axes[0].set_title(f"{title}\nL: f{f_before} (pre) | R: f{f_after} (post)")
|
|
||||||
else:
|
|
||||||
worst = int(np.argmax(agc["sat_count"]))
|
|
||||||
best = int(np.argmin(agc["sat_count"]))
|
|
||||||
f_show = worst if agc["sat_count"][worst] > 0 else best
|
|
||||||
rd = process_frame_rd(data[f_show], int(agc["gain_enc"][f_show]))
|
|
||||||
im = axes[0].imshow(
|
|
||||||
20 * np.log10(rd + 1), aspect="auto", origin="lower",
|
|
||||||
cmap="inferno", interpolation="nearest")
|
|
||||||
axes[0].set_title(f"{title}\nFrame {f_show}")
|
|
||||||
|
|
||||||
axes[0].set_xlabel("Doppler bin")
|
|
||||||
axes[0].set_ylabel("Range bin")
|
|
||||||
plt.colorbar(im, ax=axes[0], label="dB", shrink=0.8)
|
|
||||||
|
|
||||||
# Signed gain history (the real AGC state)
|
|
||||||
axes[1].plot(xs, agc["gain_signed"], color="#00ff88", linewidth=1.5)
|
|
||||||
axes[1].axhline(y=0, color="gray", linestyle=":", alpha=0.5,
|
|
||||||
label="Pass-through")
|
|
||||||
if enable_frame > 0:
|
|
||||||
axes[1].axvline(x=enable_frame, color="yellow", linewidth=2,
|
|
||||||
linestyle="--", label="AGC ON")
|
|
||||||
axes[1].set_ylim(-8, 8)
|
|
||||||
axes[1].set_ylabel("Gain (signed)")
|
|
||||||
axes[1].set_title("AGC Internal Gain (-7=max atten, +7=max amp)")
|
|
||||||
axes[1].legend(fontsize=7, loc="upper right")
|
|
||||||
axes[1].grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
# Peak magnitude (PRE-gain, 8-bit)
|
|
||||||
axes[2].plot(xs, agc["peak_mag"], color="#ffaa00", linewidth=1.0)
|
|
||||||
axes[2].axhline(y=AGC_TARGET, color="cyan", linestyle="--",
|
|
||||||
alpha=0.7, label=f"Target ({AGC_TARGET})")
|
|
||||||
axes[2].axhspan(240, 255, color="red", alpha=0.15, label="Clip zone")
|
|
||||||
if enable_frame > 0:
|
|
||||||
axes[2].axvline(x=enable_frame, color="yellow", linewidth=2,
|
|
||||||
linestyle="--", alpha=0.8)
|
|
||||||
axes[2].set_ylim(0, 260)
|
|
||||||
axes[2].set_ylabel("Peak (8-bit)")
|
|
||||||
axes[2].set_title("Peak Magnitude (pre-gain, raw input)")
|
|
||||||
axes[2].legend(fontsize=7, loc="upper right")
|
|
||||||
axes[2].grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
# Saturation count (POST-gain overflow)
|
|
||||||
axes[3].fill_between(xs, agc["sat_count"], color="red", alpha=0.4)
|
|
||||||
axes[3].plot(xs, agc["sat_count"], color="red", linewidth=0.8)
|
|
||||||
if enable_frame > 0:
|
|
||||||
axes[3].axvline(x=enable_frame, color="yellow", linewidth=2,
|
|
||||||
linestyle="--", alpha=0.8)
|
|
||||||
axes[3].set_ylabel("Overflow Count")
|
|
||||||
total = int(agc["sat_count"].sum())
|
|
||||||
axes[3].set_title(f"Post-Gain Overflow (total={total})")
|
|
||||||
axes[3].grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
# RMS signal level (post-gain)
|
|
||||||
axes[4].plot(xs, agc["rms_post"], color="#44aaff", linewidth=1.0)
|
|
||||||
if enable_frame > 0:
|
|
||||||
axes[4].axvline(x=enable_frame, color="yellow", linewidth=2,
|
|
||||||
linestyle="--", alpha=0.8)
|
|
||||||
axes[4].set_ylabel("RMS")
|
|
||||||
axes[4].set_xlabel("Frame")
|
|
||||||
axes[4].set_title("Post-Gain RMS Level")
|
|
||||||
axes[4].grid(True, alpha=0.3)
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_dataset(data: np.ndarray, label: str):
|
|
||||||
"""Run 3-scenario analysis for one dataset."""
|
|
||||||
n_frames = data.shape[0]
|
|
||||||
mid = n_frames // 2
|
|
||||||
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" {label} — shape {data.shape}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
# Raw ADC stats
|
|
||||||
raw_sat = np.sum((np.abs(data.real) >= ADC_RAIL) |
|
|
||||||
(np.abs(data.imag) >= ADC_RAIL))
|
|
||||||
print(f" Raw ADC saturation: {raw_sat} samples "
|
|
||||||
f"({100*raw_sat/(2*data.size):.2f}%)")
|
|
||||||
|
|
||||||
# Scenario 1: AGC OFF — pass-through (gain_shift=0x00)
|
|
||||||
print(" [1/3] AGC OFF (gain=0, pass-through) ...")
|
|
||||||
agc_off = simulate_agc(data, agc_enabled=False, initial_gain_enc=0x00)
|
|
||||||
print(f" Post-gain overflow: {agc_off['sat_count'].sum()} "
|
|
||||||
f"(should be 0 — no amplification)")
|
|
||||||
|
|
||||||
# Scenario 2: AGC ON from frame 0
|
|
||||||
print(" [2/3] AGC ON (from start) ...")
|
|
||||||
agc_on = simulate_agc(data, agc_enabled=True, enable_at_frame=0,
|
|
||||||
initial_gain_enc=0x00)
|
|
||||||
print(f" Final gain: {agc_on['gain_signed'][-1]} "
|
|
||||||
f"(enc=0x{agc_on['gain_enc'][-1]:X})")
|
|
||||||
print(f" Post-gain overflow: {agc_on['sat_count'].sum()}")
|
|
||||||
|
|
||||||
# Scenario 3: AGC delayed
|
|
||||||
print(f" [3/3] AGC delayed (ON at frame {mid}) ...")
|
|
||||||
agc_delayed = simulate_agc(data, agc_enabled=True,
|
|
||||||
enable_at_frame=mid,
|
|
||||||
initial_gain_enc=0x00)
|
|
||||||
pre_sat = int(agc_delayed["sat_count"][:mid].sum())
|
|
||||||
post_sat = int(agc_delayed["sat_count"][mid:].sum())
|
|
||||||
print(f" Pre-AGC overflow: {pre_sat} "
|
|
||||||
f"Post-AGC overflow: {post_sat}")
|
|
||||||
|
|
||||||
# Plot
|
|
||||||
fig, axes = plt.subplots(3, 5, figsize=(28, 14))
|
|
||||||
fig.suptitle(f"AERIS-10 AGC Analysis — {label}\n"
|
|
||||||
f"({n_frames} frames, {data.shape[1]} chirps, "
|
|
||||||
f"{data.shape[2]} samples/chirp, "
|
|
||||||
f"raw ADC sat={100*raw_sat/(2*data.size):.2f}%)",
|
|
||||||
fontsize=13, fontweight="bold", y=0.99)
|
|
||||||
|
|
||||||
plot_scenario(axes[0], data, agc_off, "AGC OFF (pass-through)")
|
|
||||||
plot_scenario(axes[1], data, agc_on, "AGC ON (from start)")
|
|
||||||
plot_scenario(axes[2], data, agc_delayed,
|
|
||||||
f"AGC delayed (ON at frame {mid})", enable_frame=mid)
|
|
||||||
|
|
||||||
for ax, lbl in zip(axes[:, 0],
|
|
||||||
["AGC OFF", "AGC ON", "AGC DELAYED"],
|
|
||||||
strict=True):
|
|
||||||
ax.annotate(lbl, xy=(-0.35, 0.5), xycoords="axes fraction",
|
|
||||||
fontsize=13, fontweight="bold", color="white",
|
|
||||||
ha="center", va="center", rotation=90)
|
|
||||||
|
|
||||||
plt.tight_layout(rect=[0.03, 0, 1, 0.95])
|
|
||||||
return fig
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="AGC analysis for ADI raw IQ captures "
|
|
||||||
"(bit-accurate rx_gain_control.v simulation)")
|
|
||||||
parser.add_argument("--amp", type=str,
|
|
||||||
default=str(Path.home() / "Downloads/adi_radar_data"
|
|
||||||
"/amp_radar"
|
|
||||||
"/phaser_amp_4MSPS_500M_300u_256_m3dB.npy"),
|
|
||||||
help="Path to amplified radar .npy")
|
|
||||||
parser.add_argument("--noamp", type=str,
|
|
||||||
default=str(Path.home() / "Downloads/adi_radar_data"
|
|
||||||
"/no_amp_radar"
|
|
||||||
"/phaser_NOamp_4MSPS_500M_300u_256.npy"),
|
|
||||||
help="Path to non-amplified radar .npy")
|
|
||||||
parser.add_argument("--data", type=str, default=None,
|
|
||||||
help="Single dataset mode")
|
|
||||||
parser.add_argument("--label", type=str, default="Custom Data")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
plt.style.use("dark_background")
|
|
||||||
|
|
||||||
if args.data:
|
|
||||||
data = np.load(args.data)
|
|
||||||
analyze_dataset(data, args.label)
|
|
||||||
plt.show()
|
|
||||||
return
|
|
||||||
|
|
||||||
figs = []
|
|
||||||
for path, label in [(args.amp, "With Amplifier (-3 dB)"),
|
|
||||||
(args.noamp, "No Amplifier")]:
|
|
||||||
if not Path(path).exists():
|
|
||||||
print(f"WARNING: {path} not found, skipping")
|
|
||||||
continue
|
|
||||||
data = np.load(path)
|
|
||||||
fig = analyze_dataset(data, label)
|
|
||||||
figs.append(fig)
|
|
||||||
|
|
||||||
if not figs:
|
|
||||||
print("No data found. Use --amp/--noamp or --data.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
AERIS-10 Radar Dashboard
|
||||||
|
===================================================
|
||||||
|
Real-time visualization and control for the AERIS-10 phased-array radar
|
||||||
|
via FT2232H USB 2.0 interface.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- FT2232H USB reader with packet parsing (matches usb_data_interface_ft2232h.v)
|
||||||
|
- Real-time range-Doppler magnitude heatmap (64x32)
|
||||||
|
- CFAR detection overlay (flagged cells highlighted)
|
||||||
|
- Range profile waterfall plot (range vs. time)
|
||||||
|
- Host command sender (opcodes per radar_system_top.v:
|
||||||
|
0x01-0x04, 0x10-0x16, 0x20-0x27, 0x30-0x31, 0xFF)
|
||||||
|
- Configuration panel for all radar parameters
|
||||||
|
- HDF5 data recording for offline analysis
|
||||||
|
- Mock mode for development/testing without hardware
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python radar_dashboard.py # Launch with mock data
|
||||||
|
python radar_dashboard.py --live # Launch with FT2232H hardware
|
||||||
|
python radar_dashboard.py --record # Launch with HDF5 recording
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import queue
|
||||||
|
import logging
|
||||||
|
import argparse
|
||||||
|
import threading
|
||||||
|
import contextlib
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk, filedialog
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
matplotlib.use("TkAgg")
|
||||||
|
from matplotlib.figure import Figure
|
||||||
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||||
|
|
||||||
|
# Import protocol layer (no GUI deps)
|
||||||
|
from radar_protocol import (
|
||||||
|
RadarProtocol, FT2232HConnection, ReplayConnection,
|
||||||
|
DataRecorder, RadarAcquisition,
|
||||||
|
RadarFrame, StatusResponse,
|
||||||
|
NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH,
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("radar_dashboard")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Dashboard GUI
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Dark theme colors
|
||||||
|
BG = "#1e1e2e"
|
||||||
|
BG2 = "#282840"
|
||||||
|
FG = "#cdd6f4"
|
||||||
|
ACCENT = "#89b4fa"
|
||||||
|
GREEN = "#a6e3a1"
|
||||||
|
RED = "#f38ba8"
|
||||||
|
YELLOW = "#f9e2af"
|
||||||
|
SURFACE = "#313244"
|
||||||
|
|
||||||
|
|
||||||
|
class RadarDashboard:
|
||||||
|
"""Main tkinter application: real-time radar visualization and control."""
|
||||||
|
|
||||||
|
UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh
|
||||||
|
|
||||||
|
# Radar parameters used for range-axis scaling.
|
||||||
|
BANDWIDTH = 500e6 # Hz — chirp bandwidth
|
||||||
|
C = 3e8 # m/s — speed of light
|
||||||
|
|
||||||
|
def __init__(self, root: tk.Tk, connection: FT2232HConnection,
|
||||||
|
recorder: DataRecorder, device_index: int = 0):
|
||||||
|
self.root = root
|
||||||
|
self.conn = connection
|
||||||
|
self.recorder = recorder
|
||||||
|
self.device_index = device_index
|
||||||
|
|
||||||
|
self.root.title("AERIS-10 Radar Dashboard")
|
||||||
|
self.root.geometry("1600x950")
|
||||||
|
self.root.configure(bg=BG)
|
||||||
|
|
||||||
|
# Frame queue (acquisition → display)
|
||||||
|
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
|
||||||
|
self._acq_thread: RadarAcquisition | None = None
|
||||||
|
|
||||||
|
# Display state
|
||||||
|
self._current_frame = RadarFrame()
|
||||||
|
self._waterfall = deque(maxlen=WATERFALL_DEPTH)
|
||||||
|
for _ in range(WATERFALL_DEPTH):
|
||||||
|
self._waterfall.append(np.zeros(NUM_RANGE_BINS))
|
||||||
|
|
||||||
|
self._frame_count = 0
|
||||||
|
self._fps_ts = time.time()
|
||||||
|
self._fps = 0.0
|
||||||
|
|
||||||
|
# Stable colorscale — exponential moving average of vmax
|
||||||
|
self._vmax_ema = 1000.0
|
||||||
|
self._vmax_alpha = 0.15 # smoothing factor (lower = more stable)
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._schedule_update()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ UI
|
||||||
|
def _build_ui(self):
|
||||||
|
style = ttk.Style()
|
||||||
|
style.theme_use("clam")
|
||||||
|
style.configure(".", background=BG, foreground=FG, fieldbackground=SURFACE)
|
||||||
|
style.configure("TFrame", background=BG)
|
||||||
|
style.configure("TLabel", background=BG, foreground=FG)
|
||||||
|
style.configure("TButton", background=SURFACE, foreground=FG)
|
||||||
|
style.configure("TLabelframe", background=BG, foreground=ACCENT)
|
||||||
|
style.configure("TLabelframe.Label", background=BG, foreground=ACCENT)
|
||||||
|
style.configure("Accent.TButton", background=ACCENT, foreground=BG)
|
||||||
|
style.configure("TNotebook", background=BG)
|
||||||
|
style.configure("TNotebook.Tab", background=SURFACE, foreground=FG,
|
||||||
|
padding=[12, 4])
|
||||||
|
style.map("TNotebook.Tab", background=[("selected", ACCENT)],
|
||||||
|
foreground=[("selected", BG)])
|
||||||
|
|
||||||
|
# Top bar
|
||||||
|
top = ttk.Frame(self.root)
|
||||||
|
top.pack(fill="x", padx=8, pady=(8, 0))
|
||||||
|
|
||||||
|
self.lbl_status = ttk.Label(top, text="DISCONNECTED", foreground=RED,
|
||||||
|
font=("Menlo", 11, "bold"))
|
||||||
|
self.lbl_status.pack(side="left", padx=8)
|
||||||
|
|
||||||
|
self.lbl_fps = ttk.Label(top, text="0.0 fps", font=("Menlo", 10))
|
||||||
|
self.lbl_fps.pack(side="left", padx=16)
|
||||||
|
|
||||||
|
self.lbl_detections = ttk.Label(top, text="Det: 0", font=("Menlo", 10))
|
||||||
|
self.lbl_detections.pack(side="left", padx=16)
|
||||||
|
|
||||||
|
self.lbl_frame = ttk.Label(top, text="Frame: 0", font=("Menlo", 10))
|
||||||
|
self.lbl_frame.pack(side="left", padx=16)
|
||||||
|
|
||||||
|
self.btn_connect = ttk.Button(top, text="Connect",
|
||||||
|
command=self._on_connect,
|
||||||
|
style="Accent.TButton")
|
||||||
|
self.btn_connect.pack(side="right", padx=4)
|
||||||
|
|
||||||
|
self.btn_record = ttk.Button(top, text="Record", command=self._on_record)
|
||||||
|
self.btn_record.pack(side="right", padx=4)
|
||||||
|
|
||||||
|
# -- Tabbed notebook layout --
|
||||||
|
nb = ttk.Notebook(self.root)
|
||||||
|
nb.pack(fill="both", expand=True, padx=8, pady=8)
|
||||||
|
|
||||||
|
tab_display = ttk.Frame(nb)
|
||||||
|
tab_control = ttk.Frame(nb)
|
||||||
|
tab_log = ttk.Frame(nb)
|
||||||
|
nb.add(tab_display, text=" Display ")
|
||||||
|
nb.add(tab_control, text=" Control ")
|
||||||
|
nb.add(tab_log, text=" Log ")
|
||||||
|
|
||||||
|
self._build_display_tab(tab_display)
|
||||||
|
self._build_control_tab(tab_control)
|
||||||
|
self._build_log_tab(tab_log)
|
||||||
|
|
||||||
|
def _build_display_tab(self, parent):
|
||||||
|
# Compute physical axis limits
|
||||||
|
# Range resolution: dR = c / (2 * BW) per range bin
|
||||||
|
# But we decimate 1024→64 bins, so each bin spans 16 FFT bins.
|
||||||
|
# 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
|
||||||
|
|
||||||
|
doppler_bin_lo = 0
|
||||||
|
doppler_bin_hi = NUM_DOPPLER_BINS
|
||||||
|
|
||||||
|
# Matplotlib figure with 3 subplots
|
||||||
|
self.fig = Figure(figsize=(14, 7), facecolor=BG)
|
||||||
|
self.fig.subplots_adjust(left=0.07, right=0.98, top=0.94, bottom=0.10,
|
||||||
|
wspace=0.30, hspace=0.35)
|
||||||
|
|
||||||
|
# Range-Doppler heatmap
|
||||||
|
self.ax_rd = self.fig.add_subplot(1, 3, (1, 2))
|
||||||
|
self.ax_rd.set_facecolor(BG2)
|
||||||
|
self._rd_img = self.ax_rd.imshow(
|
||||||
|
np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS)),
|
||||||
|
aspect="auto", cmap="inferno", origin="lower",
|
||||||
|
extent=[doppler_bin_lo, doppler_bin_hi, 0, max_range],
|
||||||
|
vmin=0, vmax=1000,
|
||||||
|
)
|
||||||
|
self.ax_rd.set_title("Range-Doppler Map", color=FG, fontsize=12)
|
||||||
|
self.ax_rd.set_xlabel("Doppler Bin (0-15: long PRI, 16-31: short PRI)", color=FG)
|
||||||
|
self.ax_rd.set_ylabel("Range (m)", color=FG)
|
||||||
|
self.ax_rd.tick_params(colors=FG)
|
||||||
|
|
||||||
|
# Save axis limits for coordinate conversions
|
||||||
|
self._max_range = max_range
|
||||||
|
self._range_per_bin = range_per_bin
|
||||||
|
|
||||||
|
# CFAR detection overlay (scatter)
|
||||||
|
self._det_scatter = self.ax_rd.scatter([], [], s=30, c=GREEN,
|
||||||
|
marker="x", linewidths=1.5,
|
||||||
|
zorder=5, label="CFAR Det")
|
||||||
|
|
||||||
|
# Waterfall plot (range profile vs time)
|
||||||
|
self.ax_wf = self.fig.add_subplot(1, 3, 3)
|
||||||
|
self.ax_wf.set_facecolor(BG2)
|
||||||
|
wf_init = np.zeros((WATERFALL_DEPTH, NUM_RANGE_BINS))
|
||||||
|
self._wf_img = self.ax_wf.imshow(
|
||||||
|
wf_init, aspect="auto", cmap="viridis", origin="lower",
|
||||||
|
extent=[0, max_range, 0, WATERFALL_DEPTH],
|
||||||
|
vmin=0, vmax=5000,
|
||||||
|
)
|
||||||
|
self.ax_wf.set_title("Range Waterfall", color=FG, fontsize=12)
|
||||||
|
self.ax_wf.set_xlabel("Range (m)", color=FG)
|
||||||
|
self.ax_wf.set_ylabel("Frame", color=FG)
|
||||||
|
self.ax_wf.tick_params(colors=FG)
|
||||||
|
|
||||||
|
canvas = FigureCanvasTkAgg(self.fig, master=parent)
|
||||||
|
canvas.draw()
|
||||||
|
canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||||
|
self._canvas = canvas
|
||||||
|
|
||||||
|
def _build_control_tab(self, parent):
|
||||||
|
"""Host command sender — organized by FPGA register groups.
|
||||||
|
|
||||||
|
Layout: scrollable canvas with three columns:
|
||||||
|
Left: Quick Actions + Diagnostics (self-test)
|
||||||
|
Center: Waveform Timing + Signal Processing
|
||||||
|
Right: Detection (CFAR) + Custom Command
|
||||||
|
"""
|
||||||
|
# Scrollable wrapper for small screens
|
||||||
|
canvas = tk.Canvas(parent, bg=BG, highlightthickness=0)
|
||||||
|
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
|
||||||
|
outer = ttk.Frame(canvas)
|
||||||
|
outer.bind("<Configure>",
|
||||||
|
lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||||
|
canvas.create_window((0, 0), window=outer, anchor="nw")
|
||||||
|
canvas.configure(yscrollcommand=scrollbar.set)
|
||||||
|
canvas.pack(side="left", fill="both", expand=True, padx=8, pady=8)
|
||||||
|
scrollbar.pack(side="right", fill="y")
|
||||||
|
|
||||||
|
self._param_vars: dict[str, tk.StringVar] = {}
|
||||||
|
|
||||||
|
# ── Left column: Quick Actions + Diagnostics ──────────────────
|
||||||
|
left = ttk.Frame(outer)
|
||||||
|
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||||||
|
|
||||||
|
# -- Radar Operation --
|
||||||
|
grp_op = ttk.LabelFrame(left, text="Radar Operation", padding=10)
|
||||||
|
grp_op.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
ttk.Button(grp_op, text="Radar Mode On",
|
||||||
|
command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=2)
|
||||||
|
ttk.Button(grp_op, text="Radar Mode Off",
|
||||||
|
command=lambda: self._send_cmd(0x01, 0)).pack(fill="x", pady=2)
|
||||||
|
ttk.Button(grp_op, text="Trigger Chirp",
|
||||||
|
command=lambda: self._send_cmd(0x02, 1)).pack(fill="x", pady=2)
|
||||||
|
|
||||||
|
# Stream Control (3-bit mask)
|
||||||
|
sc_row = ttk.Frame(grp_op)
|
||||||
|
sc_row.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(sc_row, text="Stream Control").pack(side="left")
|
||||||
|
var_sc = tk.StringVar(value="7")
|
||||||
|
self._param_vars["4"] = var_sc
|
||||||
|
ttk.Entry(sc_row, textvariable=var_sc, width=6).pack(side="left", padx=6)
|
||||||
|
ttk.Label(sc_row, text="0-7", foreground=ACCENT,
|
||||||
|
font=("Menlo", 9)).pack(side="left")
|
||||||
|
ttk.Button(sc_row, text="Set",
|
||||||
|
command=lambda: self._send_validated(
|
||||||
|
0x04, var_sc, bits=3)).pack(side="right")
|
||||||
|
|
||||||
|
ttk.Button(grp_op, text="Request Status",
|
||||||
|
command=lambda: self._send_cmd(0xFF, 0)).pack(fill="x", pady=2)
|
||||||
|
|
||||||
|
# -- Signal Processing --
|
||||||
|
grp_sp = ttk.LabelFrame(left, text="Signal Processing", padding=10)
|
||||||
|
grp_sp.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
sp_params = [
|
||||||
|
# Format: label, opcode, default, bits, hint
|
||||||
|
("Detect Threshold", 0x03, "10000", 16, "0-65535"),
|
||||||
|
("Gain Shift", 0x16, "0", 4, "0-15, dir+shift"),
|
||||||
|
("MTI Enable", 0x26, "0", 1, "0=off, 1=on"),
|
||||||
|
("DC Notch Width", 0x27, "0", 3, "0-7 bins"),
|
||||||
|
]
|
||||||
|
for label, opcode, default, bits, hint in sp_params:
|
||||||
|
self._add_param_row(grp_sp, label, opcode, default, bits, hint)
|
||||||
|
|
||||||
|
# MTI quick toggle
|
||||||
|
mti_row = ttk.Frame(grp_sp)
|
||||||
|
mti_row.pack(fill="x", pady=2)
|
||||||
|
ttk.Button(mti_row, text="Enable MTI",
|
||||||
|
command=lambda: self._send_cmd(0x26, 1)).pack(
|
||||||
|
side="left", expand=True, fill="x", padx=(0, 2))
|
||||||
|
ttk.Button(mti_row, text="Disable MTI",
|
||||||
|
command=lambda: self._send_cmd(0x26, 0)).pack(
|
||||||
|
side="left", expand=True, fill="x", padx=(2, 0))
|
||||||
|
|
||||||
|
# -- Diagnostics --
|
||||||
|
grp_diag = ttk.LabelFrame(left, text="Diagnostics", padding=10)
|
||||||
|
grp_diag.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
ttk.Button(grp_diag, text="Run Self-Test",
|
||||||
|
command=lambda: self._send_cmd(0x30, 1)).pack(fill="x", pady=2)
|
||||||
|
ttk.Button(grp_diag, text="Read Self-Test Result",
|
||||||
|
command=lambda: self._send_cmd(0x31, 0)).pack(fill="x", pady=2)
|
||||||
|
|
||||||
|
st_frame = ttk.LabelFrame(grp_diag, text="Self-Test Results", padding=6)
|
||||||
|
st_frame.pack(fill="x", pady=(4, 0))
|
||||||
|
self._st_labels = {}
|
||||||
|
for name, default_text in [
|
||||||
|
("busy", "Busy: --"),
|
||||||
|
("flags", "Flags: -----"),
|
||||||
|
("detail", "Detail: 0x--"),
|
||||||
|
("t0", "T0 BRAM: --"),
|
||||||
|
("t1", "T1 CIC: --"),
|
||||||
|
("t2", "T2 FFT: --"),
|
||||||
|
("t3", "T3 Arith: --"),
|
||||||
|
("t4", "T4 ADC: --"),
|
||||||
|
]:
|
||||||
|
lbl = ttk.Label(st_frame, text=default_text, font=("Menlo", 9))
|
||||||
|
lbl.pack(anchor="w")
|
||||||
|
self._st_labels[name] = lbl
|
||||||
|
|
||||||
|
# ── Center column: Waveform Timing ────────────────────────────
|
||||||
|
center = ttk.Frame(outer)
|
||||||
|
center.grid(row=0, column=1, sticky="nsew", padx=6)
|
||||||
|
|
||||||
|
grp_wf = ttk.LabelFrame(center, text="Waveform Timing", padding=10)
|
||||||
|
grp_wf.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
wf_params = [
|
||||||
|
# label opcode default bits hint min max
|
||||||
|
("Long Chirp Cycles", 0x10, "3000", 16, "0-65535, rst=3000", 0, None),
|
||||||
|
("Long Listen Cycles", 0x11, "13700", 16, "0-65535, rst=13700", 0, None),
|
||||||
|
("Guard Cycles", 0x12, "17540", 16, "0-65535, rst=17540", 0, None),
|
||||||
|
("Short Chirp Cycles", 0x13, "50", 16, "0-65535, rst=50", 0, None),
|
||||||
|
("Short Listen Cycles", 0x14, "17450", 16, "0-65535, rst=17450", 0, None),
|
||||||
|
("Chirps Per Elevation", 0x15, "32", 6, "1-32, clamped", 1, 32),
|
||||||
|
]
|
||||||
|
for label, opcode, default, bits, hint, min_v, max_v in wf_params:
|
||||||
|
self._add_param_row(grp_wf, label, opcode, default, bits, hint,
|
||||||
|
min_val=min_v, max_val=max_v)
|
||||||
|
|
||||||
|
# ── Right column: Detection (CFAR) + Custom ───────────────────
|
||||||
|
right = ttk.Frame(outer)
|
||||||
|
right.grid(row=0, column=2, sticky="nsew", padx=(6, 0))
|
||||||
|
|
||||||
|
grp_cfar = ttk.LabelFrame(right, text="Detection (CFAR)", padding=10)
|
||||||
|
grp_cfar.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
cfar_params = [
|
||||||
|
("CFAR Enable", 0x25, "0", 1, "0=off, 1=on"),
|
||||||
|
("CFAR Guard Cells", 0x21, "2", 4, "0-15, rst=2"),
|
||||||
|
("CFAR Train Cells", 0x22, "8", 5, "1-31, rst=8"),
|
||||||
|
("CFAR Alpha (Q4.4)", 0x23, "48", 8, "0-255, rst=0x30=3.0"),
|
||||||
|
("CFAR Mode", 0x24, "0", 2, "0=CA 1=GO 2=SO"),
|
||||||
|
]
|
||||||
|
for label, opcode, default, bits, hint in cfar_params:
|
||||||
|
self._add_param_row(grp_cfar, label, opcode, default, bits, hint)
|
||||||
|
|
||||||
|
# CFAR quick toggle
|
||||||
|
cfar_row = ttk.Frame(grp_cfar)
|
||||||
|
cfar_row.pack(fill="x", pady=2)
|
||||||
|
ttk.Button(cfar_row, text="Enable CFAR",
|
||||||
|
command=lambda: self._send_cmd(0x25, 1)).pack(
|
||||||
|
side="left", expand=True, fill="x", padx=(0, 2))
|
||||||
|
ttk.Button(cfar_row, text="Disable CFAR",
|
||||||
|
command=lambda: self._send_cmd(0x25, 0)).pack(
|
||||||
|
side="left", expand=True, fill="x", padx=(2, 0))
|
||||||
|
|
||||||
|
# ── Custom Command (advanced / debug) ─────────────────────────
|
||||||
|
grp_cust = ttk.LabelFrame(right, text="Custom Command", padding=10)
|
||||||
|
grp_cust.pack(fill="x", pady=(0, 8))
|
||||||
|
|
||||||
|
r0 = ttk.Frame(grp_cust)
|
||||||
|
r0.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(r0, text="Opcode (hex)").pack(side="left")
|
||||||
|
self._custom_op = tk.StringVar(value="01")
|
||||||
|
ttk.Entry(r0, textvariable=self._custom_op, width=8).pack(
|
||||||
|
side="left", padx=6)
|
||||||
|
|
||||||
|
r1 = ttk.Frame(grp_cust)
|
||||||
|
r1.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(r1, text="Value (dec)").pack(side="left")
|
||||||
|
self._custom_val = tk.StringVar(value="0")
|
||||||
|
ttk.Entry(r1, textvariable=self._custom_val, width=8).pack(
|
||||||
|
side="left", padx=6)
|
||||||
|
|
||||||
|
ttk.Button(grp_cust, text="Send",
|
||||||
|
command=self._send_custom).pack(fill="x", pady=2)
|
||||||
|
|
||||||
|
# Column weights
|
||||||
|
outer.columnconfigure(0, weight=1)
|
||||||
|
outer.columnconfigure(1, weight=1)
|
||||||
|
outer.columnconfigure(2, weight=1)
|
||||||
|
outer.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
|
def _add_param_row(self, parent, label: str, opcode: int,
|
||||||
|
default: str, bits: int, hint: str,
|
||||||
|
min_val: int = 0, max_val: int | None = None):
|
||||||
|
"""Add a single parameter row: label, entry, hint, Set button with validation."""
|
||||||
|
row = ttk.Frame(parent)
|
||||||
|
row.pack(fill="x", pady=2)
|
||||||
|
ttk.Label(row, text=label).pack(side="left")
|
||||||
|
var = tk.StringVar(value=default)
|
||||||
|
self._param_vars[str(opcode)] = var
|
||||||
|
ttk.Entry(row, textvariable=var, width=8).pack(side="left", padx=6)
|
||||||
|
ttk.Label(row, text=hint, foreground=ACCENT,
|
||||||
|
font=("Menlo", 9)).pack(side="left")
|
||||||
|
ttk.Button(row, text="Set",
|
||||||
|
command=lambda: self._send_validated(
|
||||||
|
opcode, var, bits=bits,
|
||||||
|
min_val=min_val, max_val=max_val)).pack(side="right")
|
||||||
|
|
||||||
|
def _send_validated(self, opcode: int, var: tk.StringVar, bits: int,
|
||||||
|
min_val: int = 0, max_val: int | None = None):
|
||||||
|
"""Parse, clamp to [min_val, max_val], send command, and update the entry."""
|
||||||
|
try:
|
||||||
|
raw = int(var.get())
|
||||||
|
except ValueError:
|
||||||
|
log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}")
|
||||||
|
return
|
||||||
|
ceiling = (1 << bits) - 1 if max_val is None else max_val
|
||||||
|
clamped = max(min_val, min(raw, ceiling))
|
||||||
|
if clamped != raw:
|
||||||
|
log.warning(f"Value {raw} clamped to {clamped} "
|
||||||
|
f"(range {min_val}-{ceiling}) for opcode 0x{opcode:02X}")
|
||||||
|
var.set(str(clamped))
|
||||||
|
self._send_cmd(opcode, clamped)
|
||||||
|
|
||||||
|
def _build_log_tab(self, parent):
|
||||||
|
self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10),
|
||||||
|
insertbackground=FG, wrap="word")
|
||||||
|
self.log_text.pack(fill="both", expand=True, padx=8, pady=8)
|
||||||
|
|
||||||
|
# Redirect log handler to text widget
|
||||||
|
handler = _TextHandler(self.log_text)
|
||||||
|
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S"))
|
||||||
|
logging.getLogger().addHandler(handler)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Actions
|
||||||
|
def _on_connect(self):
|
||||||
|
if self.conn.is_open:
|
||||||
|
# Disconnect
|
||||||
|
if self._acq_thread is not None:
|
||||||
|
self._acq_thread.stop()
|
||||||
|
self._acq_thread.join(timeout=2)
|
||||||
|
self._acq_thread = None
|
||||||
|
self.conn.close()
|
||||||
|
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
|
||||||
|
self.btn_connect.config(text="Connect")
|
||||||
|
log.info("Disconnected")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Open connection in a background thread to avoid blocking the GUI
|
||||||
|
self.lbl_status.config(text="CONNECTING...", foreground=YELLOW)
|
||||||
|
self.btn_connect.config(state="disabled")
|
||||||
|
self.root.update_idletasks()
|
||||||
|
|
||||||
|
def _do_connect():
|
||||||
|
ok = self.conn.open(self.device_index)
|
||||||
|
# Schedule UI update back on the main thread
|
||||||
|
self.root.after(0, lambda: self._on_connect_done(ok))
|
||||||
|
|
||||||
|
threading.Thread(target=_do_connect, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_connect_done(self, success: bool):
|
||||||
|
"""Called on main thread after connection attempt completes."""
|
||||||
|
self.btn_connect.config(state="normal")
|
||||||
|
if success:
|
||||||
|
self.lbl_status.config(text="CONNECTED", foreground=GREEN)
|
||||||
|
self.btn_connect.config(text="Disconnect")
|
||||||
|
self._acq_thread = RadarAcquisition(
|
||||||
|
self.conn, self.frame_queue, self.recorder,
|
||||||
|
status_callback=self._on_status_received)
|
||||||
|
self._acq_thread.start()
|
||||||
|
log.info("Connected and acquisition started")
|
||||||
|
else:
|
||||||
|
self.lbl_status.config(text="CONNECT FAILED", foreground=RED)
|
||||||
|
self.btn_connect.config(text="Connect")
|
||||||
|
|
||||||
|
def _on_record(self):
|
||||||
|
if self.recorder.recording:
|
||||||
|
self.recorder.stop()
|
||||||
|
self.btn_record.config(text="Record")
|
||||||
|
return
|
||||||
|
|
||||||
|
filepath = filedialog.asksaveasfilename(
|
||||||
|
defaultextension=".h5",
|
||||||
|
filetypes=[("HDF5", "*.h5"), ("All", "*.*")],
|
||||||
|
initialfile=f"radar_{time.strftime('%Y%m%d_%H%M%S')}.h5",
|
||||||
|
)
|
||||||
|
if filepath:
|
||||||
|
self.recorder.start(filepath)
|
||||||
|
self.btn_record.config(text="Stop Rec")
|
||||||
|
|
||||||
|
def _send_cmd(self, opcode: int, value: int):
|
||||||
|
cmd = RadarProtocol.build_command(opcode, value)
|
||||||
|
ok = self.conn.write(cmd)
|
||||||
|
log.info(f"CMD 0x{opcode:02X} val={value} ({'OK' if ok else 'FAIL'})")
|
||||||
|
|
||||||
|
def _send_custom(self):
|
||||||
|
try:
|
||||||
|
op = int(self._custom_op.get(), 16)
|
||||||
|
val = int(self._custom_val.get())
|
||||||
|
self._send_cmd(op, val)
|
||||||
|
except ValueError:
|
||||||
|
log.error("Invalid custom command values")
|
||||||
|
|
||||||
|
def _on_status_received(self, status: StatusResponse):
|
||||||
|
"""Called from acquisition thread — schedule UI update on main thread."""
|
||||||
|
self.root.after(0, self._update_self_test_labels, status)
|
||||||
|
|
||||||
|
def _update_self_test_labels(self, status: StatusResponse):
|
||||||
|
"""Update the self-test result labels from a StatusResponse."""
|
||||||
|
if not hasattr(self, '_st_labels'):
|
||||||
|
return
|
||||||
|
flags = status.self_test_flags
|
||||||
|
detail = status.self_test_detail
|
||||||
|
busy = status.self_test_busy
|
||||||
|
|
||||||
|
busy_str = "RUNNING" if busy else "IDLE"
|
||||||
|
busy_color = YELLOW if busy else FG
|
||||||
|
self._st_labels["busy"].config(text=f"Busy: {busy_str}",
|
||||||
|
foreground=busy_color)
|
||||||
|
self._st_labels["flags"].config(text=f"Flags: {flags:05b}")
|
||||||
|
self._st_labels["detail"].config(text=f"Detail: 0x{detail:02X}")
|
||||||
|
|
||||||
|
# Individual test results (bit = 1 means PASS)
|
||||||
|
test_names = [
|
||||||
|
("t0", "T0 BRAM"),
|
||||||
|
("t1", "T1 CIC"),
|
||||||
|
("t2", "T2 FFT"),
|
||||||
|
("t3", "T3 Arith"),
|
||||||
|
("t4", "T4 ADC"),
|
||||||
|
]
|
||||||
|
for i, (key, name) in enumerate(test_names):
|
||||||
|
if busy:
|
||||||
|
result_str = "..."
|
||||||
|
color = YELLOW
|
||||||
|
elif flags & (1 << i):
|
||||||
|
result_str = "PASS"
|
||||||
|
color = GREEN
|
||||||
|
else:
|
||||||
|
result_str = "FAIL"
|
||||||
|
color = RED
|
||||||
|
self._st_labels[key].config(
|
||||||
|
text=f"{name}: {result_str}", foreground=color)
|
||||||
|
|
||||||
|
# --------------------------------------------------------- Display loop
|
||||||
|
def _schedule_update(self):
|
||||||
|
self._update_display()
|
||||||
|
self.root.after(self.UPDATE_INTERVAL_MS, self._schedule_update)
|
||||||
|
|
||||||
|
def _update_display(self):
|
||||||
|
"""Pull latest frame from queue and update plots."""
|
||||||
|
frame = None
|
||||||
|
# Drain queue, keep latest
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frame = self.frame_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._current_frame = frame
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
# FPS calculation
|
||||||
|
now = time.time()
|
||||||
|
dt = now - self._fps_ts
|
||||||
|
if dt > 0.5:
|
||||||
|
self._fps = self._frame_count / dt
|
||||||
|
self._frame_count = 0
|
||||||
|
self._fps_ts = now
|
||||||
|
|
||||||
|
# Update labels
|
||||||
|
self.lbl_fps.config(text=f"{self._fps:.1f} fps")
|
||||||
|
self.lbl_detections.config(text=f"Det: {frame.detection_count}")
|
||||||
|
self.lbl_frame.config(text=f"Frame: {frame.frame_number}")
|
||||||
|
|
||||||
|
# Update range-Doppler heatmap in raw dual-subframe bin order
|
||||||
|
mag = frame.magnitude
|
||||||
|
det_shifted = frame.detections
|
||||||
|
|
||||||
|
# Stable colorscale via EMA smoothing of vmax
|
||||||
|
frame_vmax = float(np.max(mag)) if np.max(mag) > 0 else 1.0
|
||||||
|
self._vmax_ema = (self._vmax_alpha * frame_vmax +
|
||||||
|
(1.0 - self._vmax_alpha) * self._vmax_ema)
|
||||||
|
stable_vmax = max(self._vmax_ema, 1.0)
|
||||||
|
|
||||||
|
self._rd_img.set_data(mag)
|
||||||
|
self._rd_img.set_clim(vmin=0, vmax=stable_vmax)
|
||||||
|
|
||||||
|
# Update CFAR overlay in raw Doppler-bin coordinates
|
||||||
|
det_coords = np.argwhere(det_shifted > 0)
|
||||||
|
if len(det_coords) > 0:
|
||||||
|
# det_coords[:, 0] = range bin, det_coords[:, 1] = Doppler bin
|
||||||
|
range_m = (det_coords[:, 0] + 0.5) * self._range_per_bin
|
||||||
|
doppler_bins = det_coords[:, 1] + 0.5
|
||||||
|
offsets = np.column_stack([doppler_bins, range_m])
|
||||||
|
self._det_scatter.set_offsets(offsets)
|
||||||
|
else:
|
||||||
|
self._det_scatter.set_offsets(np.empty((0, 2)))
|
||||||
|
|
||||||
|
# Update waterfall
|
||||||
|
self._waterfall.append(frame.range_profile.copy())
|
||||||
|
wf_arr = np.array(list(self._waterfall))
|
||||||
|
wf_max = max(np.max(wf_arr), 1.0)
|
||||||
|
self._wf_img.set_data(wf_arr)
|
||||||
|
self._wf_img.set_clim(vmin=0, vmax=wf_max)
|
||||||
|
|
||||||
|
self._canvas.draw_idle()
|
||||||
|
|
||||||
|
|
||||||
|
class _TextHandler(logging.Handler):
|
||||||
|
"""Logging handler that writes to a tkinter Text widget."""
|
||||||
|
|
||||||
|
def __init__(self, text_widget: tk.Text):
|
||||||
|
super().__init__()
|
||||||
|
self._text = text_widget
|
||||||
|
|
||||||
|
def emit(self, record):
|
||||||
|
msg = self.format(record)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self._text.after(0, self._append, msg)
|
||||||
|
|
||||||
|
def _append(self, msg: str):
|
||||||
|
self._text.insert("end", msg + "\n")
|
||||||
|
self._text.see("end")
|
||||||
|
# Keep last 500 lines
|
||||||
|
lines = int(self._text.index("end-1c").split(".")[0])
|
||||||
|
if lines > 500:
|
||||||
|
self._text.delete("1.0", f"{lines - 500}.0")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Entry Point
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def main():
|
||||||
|
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",
|
||||||
|
help="Start HDF5 recording immediately")
|
||||||
|
parser.add_argument("--device", type=int, default=0,
|
||||||
|
help="FT2232H device index (default: 0)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.replay:
|
||||||
|
npy_dir = os.path.abspath(args.replay)
|
||||||
|
conn = ReplayConnection(npy_dir, use_mti=not args.no_mti)
|
||||||
|
mode_str = f"REPLAY ({npy_dir}, MTI={'OFF' if args.no_mti else 'ON'})"
|
||||||
|
elif args.live:
|
||||||
|
conn = FT2232HConnection(mock=False)
|
||||||
|
mode_str = "LIVE"
|
||||||
|
else:
|
||||||
|
conn = FT2232HConnection(mock=True)
|
||||||
|
mode_str = "MOCK"
|
||||||
|
|
||||||
|
recorder = DataRecorder()
|
||||||
|
|
||||||
|
root = tk.Tk()
|
||||||
|
|
||||||
|
dashboard = RadarDashboard(root, conn, recorder, device_index=args.device)
|
||||||
|
|
||||||
|
if args.record:
|
||||||
|
filepath = os.path.join(
|
||||||
|
os.getcwd(),
|
||||||
|
f"radar_{time.strftime('%Y%m%d_%H%M%S')}.h5"
|
||||||
|
)
|
||||||
|
recorder.start(filepath)
|
||||||
|
|
||||||
|
def on_closing():
|
||||||
|
if dashboard._acq_thread is not None:
|
||||||
|
dashboard._acq_thread.stop()
|
||||||
|
dashboard._acq_thread.join(timeout=2)
|
||||||
|
if conn.is_open:
|
||||||
|
conn.close()
|
||||||
|
if recorder.recording:
|
||||||
|
recorder.stop()
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
root.protocol("WM_DELETE_WINDOW", on_closing)
|
||||||
|
|
||||||
|
log.info(f"Dashboard started (mode={mode_str})")
|
||||||
|
root.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -15,6 +15,7 @@ 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
|
||||||
@@ -58,9 +59,9 @@ class Opcode(IntEnum):
|
|||||||
0x03 host_detect_threshold 0x16 host_gain_shift
|
0x03 host_detect_threshold 0x16 host_gain_shift
|
||||||
0x04 host_stream_control 0x20 host_range_mode
|
0x04 host_stream_control 0x20 host_range_mode
|
||||||
0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch
|
0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch
|
||||||
0x11 host_long_listen_cycles 0x28-0x2C AGC control
|
0x11 host_long_listen_cycles 0x30 host_self_test_trigger
|
||||||
0x12 host_guard_cycles 0x30 host_self_test_trigger
|
0x12 host_guard_cycles 0x31 host_status_request
|
||||||
0x13 host_short_chirp_cycles 0x31/0xFF host_status_request
|
0x13 host_short_chirp_cycles 0xFF host_status_request
|
||||||
"""
|
"""
|
||||||
# --- Basic control (0x01-0x04) ---
|
# --- Basic control (0x01-0x04) ---
|
||||||
RADAR_MODE = 0x01 # 2-bit mode select
|
RADAR_MODE = 0x01 # 2-bit mode select
|
||||||
@@ -89,13 +90,6 @@ class Opcode(IntEnum):
|
|||||||
MTI_ENABLE = 0x26
|
MTI_ENABLE = 0x26
|
||||||
DC_NOTCH_WIDTH = 0x27
|
DC_NOTCH_WIDTH = 0x27
|
||||||
|
|
||||||
# --- AGC (0x28-0x2C) ---
|
|
||||||
AGC_ENABLE = 0x28
|
|
||||||
AGC_TARGET = 0x29
|
|
||||||
AGC_ATTACK = 0x2A
|
|
||||||
AGC_DECAY = 0x2B
|
|
||||||
AGC_HOLDOFF = 0x2C
|
|
||||||
|
|
||||||
# --- Board self-test / status (0x30-0x31, 0xFF) ---
|
# --- Board self-test / status (0x30-0x31, 0xFF) ---
|
||||||
SELF_TEST_TRIGGER = 0x30
|
SELF_TEST_TRIGGER = 0x30
|
||||||
SELF_TEST_STATUS = 0x31
|
SELF_TEST_STATUS = 0x31
|
||||||
@@ -141,11 +135,6 @@ class StatusResponse:
|
|||||||
self_test_flags: int = 0 # 5-bit result flags [4:0]
|
self_test_flags: int = 0 # 5-bit result flags [4:0]
|
||||||
self_test_detail: int = 0 # 8-bit detail code [7:0]
|
self_test_detail: int = 0 # 8-bit detail code [7:0]
|
||||||
self_test_busy: int = 0 # 1-bit busy flag
|
self_test_busy: int = 0 # 1-bit busy flag
|
||||||
# AGC metrics (word 4, added for hybrid AGC)
|
|
||||||
agc_current_gain: int = 0 # 4-bit current gain encoding [3:0]
|
|
||||||
agc_peak_magnitude: int = 0 # 8-bit peak magnitude [7:0]
|
|
||||||
agc_saturation_count: int = 0 # 8-bit saturation count [7:0]
|
|
||||||
agc_enable: int = 0 # 1-bit AGC enable readback
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -243,13 +232,8 @@ class RadarProtocol:
|
|||||||
# Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]}
|
# Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]}
|
||||||
sr.chirps_per_elev = words[3] & 0x3F
|
sr.chirps_per_elev = words[3] & 0x3F
|
||||||
sr.short_listen = (words[3] >> 16) & 0xFFFF
|
sr.short_listen = (words[3] >> 16) & 0xFFFF
|
||||||
# Word 4: {agc_current_gain[31:28], agc_peak_magnitude[27:20],
|
# Word 4: {30'd0, range_mode[1:0]}
|
||||||
# agc_saturation_count[19:12], agc_enable[11], 9'd0, range_mode[1:0]}
|
|
||||||
sr.range_mode = words[4] & 0x03
|
sr.range_mode = words[4] & 0x03
|
||||||
sr.agc_enable = (words[4] >> 11) & 0x01
|
|
||||||
sr.agc_saturation_count = (words[4] >> 12) & 0xFF
|
|
||||||
sr.agc_peak_magnitude = (words[4] >> 20) & 0xFF
|
|
||||||
sr.agc_current_gain = (words[4] >> 28) & 0x0F
|
|
||||||
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
|
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
|
||||||
# 3'd0, self_test_flags[4:0]}
|
# 3'd0, self_test_flags[4:0]}
|
||||||
sr.self_test_flags = words[5] & 0x1F
|
sr.self_test_flags = words[5] & 0x1F
|
||||||
@@ -442,7 +426,377 @@ class FT2232HConnection:
|
|||||||
return bytes(buf)
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Replay Connection — feed real .npy data through the dashboard
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Hardware-only opcodes that cannot be adjusted in replay mode
|
||||||
|
# Values must match radar_system_top.v case(usb_cmd_opcode).
|
||||||
|
_HARDWARE_ONLY_OPCODES = {
|
||||||
|
0x01, # RADAR_MODE
|
||||||
|
0x02, # TRIGGER_PULSE
|
||||||
|
0x03, # DETECT_THRESHOLD
|
||||||
|
0x04, # STREAM_CONTROL
|
||||||
|
0x10, # LONG_CHIRP
|
||||||
|
0x11, # LONG_LISTEN
|
||||||
|
0x12, # GUARD
|
||||||
|
0x13, # SHORT_CHIRP
|
||||||
|
0x14, # SHORT_LISTEN
|
||||||
|
0x15, # CHIRPS_PER_ELEV
|
||||||
|
0x16, # GAIN_SHIFT
|
||||||
|
0x20, # RANGE_MODE
|
||||||
|
0x30, # SELF_TEST_TRIGGER
|
||||||
|
0x31, # SELF_TEST_STATUS
|
||||||
|
0xFF, # STATUS_REQUEST
|
||||||
|
}
|
||||||
|
|
||||||
|
# Replay-adjustable opcodes (re-run signal processing)
|
||||||
|
_REPLAY_ADJUSTABLE_OPCODES = {
|
||||||
|
0x21, # CFAR_GUARD
|
||||||
|
0x22, # CFAR_TRAIN
|
||||||
|
0x23, # CFAR_ALPHA
|
||||||
|
0x24, # CFAR_MODE
|
||||||
|
0x25, # CFAR_ENABLE
|
||||||
|
0x26, # MTI_ENABLE
|
||||||
|
0x27, # DC_NOTCH_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _saturate(val: int, bits: int) -> int:
|
||||||
|
"""Saturate signed value to fit in 'bits' width."""
|
||||||
|
max_pos = (1 << (bits - 1)) - 1
|
||||||
|
max_neg = -(1 << (bits - 1))
|
||||||
|
return max(max_neg, min(max_pos, int(val)))
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray,
|
||||||
|
width: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Bit-accurate DC notch filter (matches radar_system_top.v inline).
|
||||||
|
|
||||||
|
Dual sub-frame notch: doppler_bin[4:0] = {sub_frame, bin[3:0]}.
|
||||||
|
Each 16-bin sub-frame has its own DC at bin 0, so we zero bins
|
||||||
|
where ``bin_within_sf < width`` or ``bin_within_sf > (15 - width + 1)``.
|
||||||
|
"""
|
||||||
|
out_i = doppler_i.copy()
|
||||||
|
out_q = doppler_q.copy()
|
||||||
|
if width == 0:
|
||||||
|
return out_i, out_q
|
||||||
|
n_doppler = doppler_i.shape[1]
|
||||||
|
for dbin in range(n_doppler):
|
||||||
|
bin_within_sf = dbin & 0xF
|
||||||
|
if bin_within_sf < width or bin_within_sf > (15 - width + 1):
|
||||||
|
out_i[:, dbin] = 0
|
||||||
|
out_q[:, dbin] = 0
|
||||||
|
return out_i, out_q
|
||||||
|
|
||||||
|
|
||||||
|
def _replay_cfar(doppler_i: np.ndarray, doppler_q: np.ndarray,
|
||||||
|
guard: int, train: int, alpha_q44: int,
|
||||||
|
mode: int) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""
|
||||||
|
Bit-accurate CA-CFAR detector (matches cfar_ca.v).
|
||||||
|
Returns (detect_flags, magnitudes) both (64, 32).
|
||||||
|
"""
|
||||||
|
ALPHA_FRAC_BITS = 4
|
||||||
|
n_range, n_doppler = doppler_i.shape
|
||||||
|
if train == 0:
|
||||||
|
train = 1
|
||||||
|
|
||||||
|
# Compute magnitudes: |I| + |Q| (17-bit unsigned L1 norm)
|
||||||
|
magnitudes = np.zeros((n_range, n_doppler), dtype=np.int64)
|
||||||
|
for r in range(n_range):
|
||||||
|
for d in range(n_doppler):
|
||||||
|
i_val = int(doppler_i[r, d])
|
||||||
|
q_val = int(doppler_q[r, d])
|
||||||
|
abs_i = (-i_val) & 0xFFFF if i_val < 0 else i_val & 0xFFFF
|
||||||
|
abs_q = (-q_val) & 0xFFFF if q_val < 0 else q_val & 0xFFFF
|
||||||
|
magnitudes[r, d] = abs_i + abs_q
|
||||||
|
|
||||||
|
detect_flags = np.zeros((n_range, n_doppler), dtype=np.bool_)
|
||||||
|
MAX_MAG = (1 << 17) - 1
|
||||||
|
|
||||||
|
mode_names = {0: 'CA', 1: 'GO', 2: 'SO'}
|
||||||
|
mode_str = mode_names.get(mode, 'CA')
|
||||||
|
|
||||||
|
for dbin in range(n_doppler):
|
||||||
|
col = magnitudes[:, dbin]
|
||||||
|
for cut in range(n_range):
|
||||||
|
lead_sum, lead_cnt = 0, 0
|
||||||
|
for t in range(1, train + 1):
|
||||||
|
idx = cut - guard - t
|
||||||
|
if 0 <= idx < n_range:
|
||||||
|
lead_sum += int(col[idx])
|
||||||
|
lead_cnt += 1
|
||||||
|
lag_sum, lag_cnt = 0, 0
|
||||||
|
for t in range(1, train + 1):
|
||||||
|
idx = cut + guard + t
|
||||||
|
if 0 <= idx < n_range:
|
||||||
|
lag_sum += int(col[idx])
|
||||||
|
lag_cnt += 1
|
||||||
|
|
||||||
|
if mode_str == 'CA':
|
||||||
|
noise = lead_sum + lag_sum
|
||||||
|
elif mode_str == 'GO':
|
||||||
|
if lead_cnt > 0 and lag_cnt > 0:
|
||||||
|
noise = lead_sum if lead_sum * lag_cnt > lag_sum * lead_cnt else lag_sum
|
||||||
|
else:
|
||||||
|
noise = lead_sum if lead_cnt > 0 else lag_sum
|
||||||
|
elif mode_str == 'SO':
|
||||||
|
if lead_cnt > 0 and lag_cnt > 0:
|
||||||
|
noise = lead_sum if lead_sum * lag_cnt < lag_sum * lead_cnt else lag_sum
|
||||||
|
else:
|
||||||
|
noise = lead_sum if lead_cnt > 0 else lag_sum
|
||||||
|
else:
|
||||||
|
noise = lead_sum + lag_sum
|
||||||
|
|
||||||
|
thr = min((alpha_q44 * noise) >> ALPHA_FRAC_BITS, MAX_MAG)
|
||||||
|
if int(col[cut]) > thr:
|
||||||
|
detect_flags[cut, dbin] = True
|
||||||
|
|
||||||
|
return detect_flags, magnitudes
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayConnection:
|
||||||
|
"""
|
||||||
|
Loads pre-computed .npy arrays (from golden_reference.py co-sim output)
|
||||||
|
and serves them as USB data packets to the dashboard, exercising the full
|
||||||
|
parsing pipeline with real ADI CN0566 radar data.
|
||||||
|
|
||||||
|
Signal processing parameters (CFAR guard/train/alpha/mode, MTI enable,
|
||||||
|
DC notch width) can be adjusted at runtime via write() — the connection
|
||||||
|
re-runs the bit-accurate processing pipeline and rebuilds packets.
|
||||||
|
|
||||||
|
Required npy directory layout (e.g. tb/cosim/real_data/hex/):
|
||||||
|
decimated_range_i.npy (32, 64) int — pre-Doppler range I
|
||||||
|
decimated_range_q.npy (32, 64) int — pre-Doppler range Q
|
||||||
|
doppler_map_i.npy (64, 32) int — Doppler I (no MTI)
|
||||||
|
doppler_map_q.npy (64, 32) int — Doppler Q (no MTI)
|
||||||
|
fullchain_mti_doppler_i.npy (64, 32) int — Doppler I (with MTI)
|
||||||
|
fullchain_mti_doppler_q.npy (64, 32) int — Doppler Q (with MTI)
|
||||||
|
fullchain_cfar_flags.npy (64, 32) bool — CFAR detections
|
||||||
|
fullchain_cfar_mag.npy (64, 32) int — CFAR |I|+|Q| magnitude
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, npy_dir: str, use_mti: bool = True,
|
||||||
|
replay_fps: float = 5.0):
|
||||||
|
self._npy_dir = npy_dir
|
||||||
|
self._use_mti = use_mti
|
||||||
|
self._replay_fps = max(replay_fps, 0.1)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.is_open = False
|
||||||
|
self._packets: bytes = b""
|
||||||
|
self._read_offset = 0
|
||||||
|
self._frame_len = 0
|
||||||
|
# Current signal-processing parameters
|
||||||
|
self._mti_enable: bool = use_mti
|
||||||
|
self._dc_notch_width: int = 2
|
||||||
|
self._cfar_guard: int = 2
|
||||||
|
self._cfar_train: int = 8
|
||||||
|
self._cfar_alpha: int = 0x30
|
||||||
|
self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO
|
||||||
|
self._cfar_enable: bool = True
|
||||||
|
# Raw source arrays (loaded once, reprocessed on param change)
|
||||||
|
self._dop_mti_i: np.ndarray | None = None
|
||||||
|
self._dop_mti_q: np.ndarray | None = None
|
||||||
|
self._dop_nomti_i: np.ndarray | None = None
|
||||||
|
self._dop_nomti_q: np.ndarray | None = None
|
||||||
|
self._range_i_vec: np.ndarray | None = None
|
||||||
|
self._range_q_vec: np.ndarray | None = None
|
||||||
|
# Rebuild flag
|
||||||
|
self._needs_rebuild = False
|
||||||
|
|
||||||
|
def open(self, _device_index: int = 0) -> bool:
|
||||||
|
try:
|
||||||
|
self._load_arrays()
|
||||||
|
self._packets = self._build_packets()
|
||||||
|
self._frame_len = len(self._packets)
|
||||||
|
self._read_offset = 0
|
||||||
|
self.is_open = True
|
||||||
|
log.info(f"Replay connection opened: {self._npy_dir} "
|
||||||
|
f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
|
||||||
|
f"{self._frame_len} bytes/frame)")
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError, struct.error) as e:
|
||||||
|
log.error(f"Replay open failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.is_open = False
|
||||||
|
|
||||||
|
def read(self, size: int = 4096) -> bytes | None:
|
||||||
|
if not self.is_open:
|
||||||
|
return None
|
||||||
|
# Pace reads to target FPS (spread across ~64 reads per frame)
|
||||||
|
time.sleep((1.0 / self._replay_fps) / (NUM_CELLS / 32))
|
||||||
|
with self._lock:
|
||||||
|
# If params changed, rebuild packets
|
||||||
|
if self._needs_rebuild:
|
||||||
|
self._packets = self._build_packets()
|
||||||
|
self._frame_len = len(self._packets)
|
||||||
|
self._read_offset = 0
|
||||||
|
self._needs_rebuild = False
|
||||||
|
end = self._read_offset + size
|
||||||
|
if end <= self._frame_len:
|
||||||
|
chunk = self._packets[self._read_offset:end]
|
||||||
|
self._read_offset = end
|
||||||
|
else:
|
||||||
|
chunk = self._packets[self._read_offset:]
|
||||||
|
self._read_offset = 0
|
||||||
|
return chunk
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> bool:
|
||||||
|
"""
|
||||||
|
Handle host commands in replay mode.
|
||||||
|
Signal-processing params (CFAR, MTI, DC notch) trigger re-processing.
|
||||||
|
Hardware-only params are silently ignored.
|
||||||
|
"""
|
||||||
|
if len(data) < 4:
|
||||||
|
return True
|
||||||
|
word = struct.unpack(">I", data[:4])[0]
|
||||||
|
opcode = (word >> 24) & 0xFF
|
||||||
|
value = word & 0xFFFF
|
||||||
|
|
||||||
|
if opcode in _REPLAY_ADJUSTABLE_OPCODES:
|
||||||
|
changed = False
|
||||||
|
with self._lock:
|
||||||
|
if opcode == 0x21: # CFAR_GUARD
|
||||||
|
if self._cfar_guard != value:
|
||||||
|
self._cfar_guard = value
|
||||||
|
changed = True
|
||||||
|
elif opcode == 0x22: # CFAR_TRAIN
|
||||||
|
if self._cfar_train != value:
|
||||||
|
self._cfar_train = value
|
||||||
|
changed = True
|
||||||
|
elif opcode == 0x23: # CFAR_ALPHA
|
||||||
|
if self._cfar_alpha != value:
|
||||||
|
self._cfar_alpha = value
|
||||||
|
changed = True
|
||||||
|
elif opcode == 0x24: # CFAR_MODE
|
||||||
|
if self._cfar_mode != value:
|
||||||
|
self._cfar_mode = value
|
||||||
|
changed = True
|
||||||
|
elif opcode == 0x25: # CFAR_ENABLE
|
||||||
|
new_en = bool(value)
|
||||||
|
if self._cfar_enable != new_en:
|
||||||
|
self._cfar_enable = new_en
|
||||||
|
changed = True
|
||||||
|
elif opcode == 0x26: # MTI_ENABLE
|
||||||
|
new_en = bool(value)
|
||||||
|
if self._mti_enable != new_en:
|
||||||
|
self._mti_enable = new_en
|
||||||
|
changed = True
|
||||||
|
elif opcode == 0x27 and self._dc_notch_width != value: # DC_NOTCH_WIDTH
|
||||||
|
self._dc_notch_width = value
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
self._needs_rebuild = True
|
||||||
|
if changed:
|
||||||
|
log.info(f"Replay param updated: opcode=0x{opcode:02X} "
|
||||||
|
f"value={value} — will re-process")
|
||||||
|
else:
|
||||||
|
log.debug(f"Replay param unchanged: opcode=0x{opcode:02X} "
|
||||||
|
f"value={value}")
|
||||||
|
elif opcode in _HARDWARE_ONLY_OPCODES:
|
||||||
|
log.debug(f"Replay: hardware-only opcode 0x{opcode:02X} "
|
||||||
|
f"(ignored in replay mode)")
|
||||||
|
else:
|
||||||
|
log.debug(f"Replay: unknown opcode 0x{opcode:02X} (ignored)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _load_arrays(self):
|
||||||
|
"""Load source npy arrays once."""
|
||||||
|
npy = self._npy_dir
|
||||||
|
# MTI Doppler
|
||||||
|
self._dop_mti_i = np.load(
|
||||||
|
os.path.join(npy, "fullchain_mti_doppler_i.npy")).astype(np.int64)
|
||||||
|
self._dop_mti_q = np.load(
|
||||||
|
os.path.join(npy, "fullchain_mti_doppler_q.npy")).astype(np.int64)
|
||||||
|
# Non-MTI Doppler
|
||||||
|
self._dop_nomti_i = np.load(
|
||||||
|
os.path.join(npy, "doppler_map_i.npy")).astype(np.int64)
|
||||||
|
self._dop_nomti_q = np.load(
|
||||||
|
os.path.join(npy, "doppler_map_q.npy")).astype(np.int64)
|
||||||
|
# Range data
|
||||||
|
try:
|
||||||
|
range_i_all = np.load(
|
||||||
|
os.path.join(npy, "decimated_range_i.npy")).astype(np.int64)
|
||||||
|
range_q_all = np.load(
|
||||||
|
os.path.join(npy, "decimated_range_q.npy")).astype(np.int64)
|
||||||
|
self._range_i_vec = range_i_all[-1, :] # last chirp
|
||||||
|
self._range_q_vec = range_q_all[-1, :]
|
||||||
|
except FileNotFoundError:
|
||||||
|
self._range_i_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64)
|
||||||
|
self._range_q_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64)
|
||||||
|
|
||||||
|
def _build_packets(self) -> bytes:
|
||||||
|
"""Build a full frame of USB data packets from current params."""
|
||||||
|
# Select Doppler data based on MTI
|
||||||
|
if self._mti_enable:
|
||||||
|
dop_i = self._dop_mti_i
|
||||||
|
dop_q = self._dop_mti_q
|
||||||
|
else:
|
||||||
|
dop_i = self._dop_nomti_i
|
||||||
|
dop_q = self._dop_nomti_q
|
||||||
|
|
||||||
|
# Apply DC notch
|
||||||
|
dop_i, dop_q = _replay_dc_notch(dop_i, dop_q, self._dc_notch_width)
|
||||||
|
|
||||||
|
# Run CFAR
|
||||||
|
if self._cfar_enable:
|
||||||
|
det, _mag = _replay_cfar(
|
||||||
|
dop_i, dop_q,
|
||||||
|
guard=self._cfar_guard,
|
||||||
|
train=self._cfar_train,
|
||||||
|
alpha_q44=self._cfar_alpha,
|
||||||
|
mode=self._cfar_mode,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=bool)
|
||||||
|
|
||||||
|
det_count = int(det.sum())
|
||||||
|
log.info(f"Replay: rebuilt {NUM_CELLS} packets ("
|
||||||
|
f"MTI={'ON' if self._mti_enable else 'OFF'}, "
|
||||||
|
f"DC_notch={self._dc_notch_width}, "
|
||||||
|
f"CFAR={'ON' if self._cfar_enable else 'OFF'} "
|
||||||
|
f"G={self._cfar_guard} T={self._cfar_train} "
|
||||||
|
f"a=0x{self._cfar_alpha:02X} m={self._cfar_mode}, "
|
||||||
|
f"{det_count} detections)")
|
||||||
|
|
||||||
|
range_i = self._range_i_vec
|
||||||
|
range_q = self._range_q_vec
|
||||||
|
|
||||||
|
return self._build_packets_data(range_i, range_q, dop_i, dop_q, det)
|
||||||
|
|
||||||
|
def _build_packets_data(self, range_i, range_q, dop_i, dop_q, det) -> bytes:
|
||||||
|
"""Build 11-byte data packets for FT2232H interface."""
|
||||||
|
buf = bytearray(NUM_CELLS * DATA_PACKET_SIZE)
|
||||||
|
pos = 0
|
||||||
|
for rbin in range(NUM_RANGE_BINS):
|
||||||
|
ri = int(np.clip(range_i[rbin], -32768, 32767))
|
||||||
|
rq = int(np.clip(range_q[rbin], -32768, 32767))
|
||||||
|
rq_bytes = struct.pack(">h", rq)
|
||||||
|
ri_bytes = struct.pack(">h", ri)
|
||||||
|
for dbin in range(NUM_DOPPLER_BINS):
|
||||||
|
di = int(np.clip(dop_i[rbin, dbin], -32768, 32767))
|
||||||
|
dq = int(np.clip(dop_q[rbin, dbin], -32768, 32767))
|
||||||
|
d = 1 if det[rbin, dbin] else 0
|
||||||
|
|
||||||
|
buf[pos] = HEADER_BYTE
|
||||||
|
pos += 1
|
||||||
|
buf[pos:pos+2] = rq_bytes
|
||||||
|
pos += 2
|
||||||
|
buf[pos:pos+2] = ri_bytes
|
||||||
|
pos += 2
|
||||||
|
buf[pos:pos+2] = struct.pack(">h", di)
|
||||||
|
pos += 2
|
||||||
|
buf[pos:pos+2] = struct.pack(">h", dq)
|
||||||
|
pos += 2
|
||||||
|
buf[pos] = d
|
||||||
|
pos += 1
|
||||||
|
buf[pos] = FOOTER_BYTE
|
||||||
|
pos += 1
|
||||||
|
|
||||||
|
return bytes(buf)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -17,6 +17,3 @@ scipy>=1.10
|
|||||||
# Tracking / clustering (optional)
|
# Tracking / clustering (optional)
|
||||||
scikit-learn>=1.2
|
scikit-learn>=1.2
|
||||||
filterpy>=1.4
|
filterpy>=1.4
|
||||||
|
|
||||||
# CRC validation (optional)
|
|
||||||
crcmod>=1.7
|
|
||||||
|
|||||||
+233
-400
@@ -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_GUI_V65_Tk.py -v
|
Run: python -m pytest test_radar_dashboard.py -v
|
||||||
or: python test_GUI_V65_Tk.py
|
or: python test_radar_dashboard.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import struct
|
import struct
|
||||||
@@ -19,10 +19,10 @@ from radar_protocol import (
|
|||||||
RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition,
|
RadarProtocol, FT2232HConnection, DataRecorder, RadarAcquisition,
|
||||||
RadarFrame, StatusResponse, Opcode,
|
RadarFrame, StatusResponse, Opcode,
|
||||||
HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE,
|
HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE,
|
||||||
NUM_RANGE_BINS, NUM_DOPPLER_BINS,
|
NUM_RANGE_BINS, NUM_DOPPLER_BINS, NUM_CELLS,
|
||||||
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):
|
||||||
@@ -125,8 +125,7 @@ class TestRadarProtocol(unittest.TestCase):
|
|||||||
long_chirp=3000, long_listen=13700,
|
long_chirp=3000, long_listen=13700,
|
||||||
guard=17540, short_chirp=50,
|
guard=17540, short_chirp=50,
|
||||||
short_listen=17450, chirps=32, range_mode=0,
|
short_listen=17450, chirps=32, range_mode=0,
|
||||||
st_flags=0, st_detail=0, st_busy=0,
|
st_flags=0, st_detail=0, st_busy=0):
|
||||||
agc_gain=0, agc_peak=0, agc_sat=0, agc_enable=0):
|
|
||||||
"""Build a 26-byte status response matching FPGA format (Build 26)."""
|
"""Build a 26-byte status response matching FPGA format (Build 26)."""
|
||||||
pkt = bytearray()
|
pkt = bytearray()
|
||||||
pkt.append(STATUS_HEADER_BYTE)
|
pkt.append(STATUS_HEADER_BYTE)
|
||||||
@@ -147,11 +146,8 @@ class TestRadarProtocol(unittest.TestCase):
|
|||||||
w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F)
|
w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F)
|
||||||
pkt += struct.pack(">I", w3)
|
pkt += struct.pack(">I", w3)
|
||||||
|
|
||||||
# Word 4: {agc_current_gain[3:0], agc_peak_magnitude[7:0],
|
# Word 4: {30'd0, range_mode[1:0]}
|
||||||
# agc_saturation_count[7:0], agc_enable, 9'd0, range_mode[1:0]}
|
w4 = range_mode & 0x03
|
||||||
w4 = (((agc_gain & 0x0F) << 28) | ((agc_peak & 0xFF) << 20) |
|
|
||||||
((agc_sat & 0xFF) << 12) | ((agc_enable & 0x01) << 11) |
|
|
||||||
(range_mode & 0x03))
|
|
||||||
pkt += struct.pack(">I", w4)
|
pkt += struct.pack(">I", w4)
|
||||||
|
|
||||||
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
|
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
|
||||||
@@ -459,6 +455,218 @@ 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)."""
|
||||||
|
|
||||||
@@ -478,6 +686,15 @@ 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)
|
||||||
@@ -496,12 +713,16 @@ 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,
|
||||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
|
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
|
||||||
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
|
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
|
||||||
0x28, 0x29, 0x2A, 0x2B, 0x2C,
|
|
||||||
0x30, 0x31, 0xFF}
|
0x30, 0x31, 0xFF}
|
||||||
enum_values = {int(m) for m in Opcode}
|
enum_values = {int(m) for m in Opcode}
|
||||||
for op in expected:
|
for op in expected:
|
||||||
@@ -526,393 +747,5 @@ class TestStatusResponseDefaults(unittest.TestCase):
|
|||||||
self.assertEqual(sr.self_test_busy, 1)
|
self.assertEqual(sr.self_test_busy, 1)
|
||||||
|
|
||||||
|
|
||||||
class TestAGCOpcodes(unittest.TestCase):
|
|
||||||
"""Verify AGC opcode enum members match FPGA RTL (0x28-0x2C)."""
|
|
||||||
|
|
||||||
def test_agc_enable_opcode(self):
|
|
||||||
self.assertEqual(Opcode.AGC_ENABLE, 0x28)
|
|
||||||
|
|
||||||
def test_agc_target_opcode(self):
|
|
||||||
self.assertEqual(Opcode.AGC_TARGET, 0x29)
|
|
||||||
|
|
||||||
def test_agc_attack_opcode(self):
|
|
||||||
self.assertEqual(Opcode.AGC_ATTACK, 0x2A)
|
|
||||||
|
|
||||||
def test_agc_decay_opcode(self):
|
|
||||||
self.assertEqual(Opcode.AGC_DECAY, 0x2B)
|
|
||||||
|
|
||||||
def test_agc_holdoff_opcode(self):
|
|
||||||
self.assertEqual(Opcode.AGC_HOLDOFF, 0x2C)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAGCStatusParsing(unittest.TestCase):
|
|
||||||
"""Verify AGC fields in status_words[4] are parsed correctly."""
|
|
||||||
|
|
||||||
def _make_status_packet(self, **kwargs):
|
|
||||||
"""Delegate to TestRadarProtocol helper."""
|
|
||||||
helper = TestRadarProtocol()
|
|
||||||
return helper._make_status_packet(**kwargs)
|
|
||||||
|
|
||||||
def test_agc_fields_default_zero(self):
|
|
||||||
"""With no AGC fields set, all should be 0."""
|
|
||||||
raw = self._make_status_packet()
|
|
||||||
sr = RadarProtocol.parse_status_packet(raw)
|
|
||||||
self.assertEqual(sr.agc_current_gain, 0)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, 0)
|
|
||||||
self.assertEqual(sr.agc_saturation_count, 0)
|
|
||||||
self.assertEqual(sr.agc_enable, 0)
|
|
||||||
|
|
||||||
def test_agc_fields_nonzero(self):
|
|
||||||
"""AGC fields round-trip through status packet."""
|
|
||||||
raw = self._make_status_packet(agc_gain=7, agc_peak=200,
|
|
||||||
agc_sat=15, agc_enable=1)
|
|
||||||
sr = RadarProtocol.parse_status_packet(raw)
|
|
||||||
self.assertEqual(sr.agc_current_gain, 7)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, 200)
|
|
||||||
self.assertEqual(sr.agc_saturation_count, 15)
|
|
||||||
self.assertEqual(sr.agc_enable, 1)
|
|
||||||
|
|
||||||
def test_agc_max_values(self):
|
|
||||||
"""AGC fields at max values."""
|
|
||||||
raw = self._make_status_packet(agc_gain=15, agc_peak=255,
|
|
||||||
agc_sat=255, agc_enable=1)
|
|
||||||
sr = RadarProtocol.parse_status_packet(raw)
|
|
||||||
self.assertEqual(sr.agc_current_gain, 15)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, 255)
|
|
||||||
self.assertEqual(sr.agc_saturation_count, 255)
|
|
||||||
self.assertEqual(sr.agc_enable, 1)
|
|
||||||
|
|
||||||
def test_agc_and_range_mode_coexist(self):
|
|
||||||
"""AGC fields and range_mode occupy the same word without conflict."""
|
|
||||||
raw = self._make_status_packet(agc_gain=5, agc_peak=128,
|
|
||||||
agc_sat=42, agc_enable=1,
|
|
||||||
range_mode=2)
|
|
||||||
sr = RadarProtocol.parse_status_packet(raw)
|
|
||||||
self.assertEqual(sr.agc_current_gain, 5)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, 128)
|
|
||||||
self.assertEqual(sr.agc_saturation_count, 42)
|
|
||||||
self.assertEqual(sr.agc_enable, 1)
|
|
||||||
self.assertEqual(sr.range_mode, 2)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAGCStatusResponseDefaults(unittest.TestCase):
|
|
||||||
"""Verify StatusResponse AGC field defaults."""
|
|
||||||
|
|
||||||
def test_default_agc_fields(self):
|
|
||||||
sr = StatusResponse()
|
|
||||||
self.assertEqual(sr.agc_current_gain, 0)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, 0)
|
|
||||||
self.assertEqual(sr.agc_saturation_count, 0)
|
|
||||||
self.assertEqual(sr.agc_enable, 0)
|
|
||||||
|
|
||||||
def test_agc_fields_set(self):
|
|
||||||
sr = StatusResponse(agc_current_gain=7, agc_peak_magnitude=200,
|
|
||||||
agc_saturation_count=15, agc_enable=1)
|
|
||||||
self.assertEqual(sr.agc_current_gain, 7)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, 200)
|
|
||||||
self.assertEqual(sr.agc_saturation_count, 15)
|
|
||||||
self.assertEqual(sr.agc_enable, 1)
|
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# AGC Visualization — ring buffer / data model tests
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestAGCVisualizationHistory(unittest.TestCase):
|
|
||||||
"""Test the AGC visualization ring buffer logic (no GUI required)."""
|
|
||||||
|
|
||||||
def _make_deque(self, maxlen=256):
|
|
||||||
from collections import deque
|
|
||||||
return deque(maxlen=maxlen)
|
|
||||||
|
|
||||||
def test_ring_buffer_maxlen(self):
|
|
||||||
"""Ring buffer should evict oldest when full."""
|
|
||||||
d = self._make_deque(maxlen=4)
|
|
||||||
for i in range(6):
|
|
||||||
d.append(i)
|
|
||||||
self.assertEqual(list(d), [2, 3, 4, 5])
|
|
||||||
self.assertEqual(len(d), 4)
|
|
||||||
|
|
||||||
def test_gain_history_accumulation(self):
|
|
||||||
"""Gain values accumulate correctly in a deque."""
|
|
||||||
gain_hist = self._make_deque(maxlen=256)
|
|
||||||
statuses = [
|
|
||||||
StatusResponse(agc_current_gain=g)
|
|
||||||
for g in [0, 3, 7, 15, 8, 2]
|
|
||||||
]
|
|
||||||
for st in statuses:
|
|
||||||
gain_hist.append(st.agc_current_gain)
|
|
||||||
self.assertEqual(list(gain_hist), [0, 3, 7, 15, 8, 2])
|
|
||||||
|
|
||||||
def test_peak_history_accumulation(self):
|
|
||||||
"""Peak magnitude values accumulate correctly."""
|
|
||||||
peak_hist = self._make_deque(maxlen=256)
|
|
||||||
for p in [0, 50, 200, 255, 128]:
|
|
||||||
peak_hist.append(p)
|
|
||||||
self.assertEqual(list(peak_hist), [0, 50, 200, 255, 128])
|
|
||||||
|
|
||||||
def test_saturation_total_computation(self):
|
|
||||||
"""Sum of saturation ring buffer gives running total."""
|
|
||||||
sat_hist = self._make_deque(maxlen=256)
|
|
||||||
for s in [0, 0, 5, 0, 12, 3]:
|
|
||||||
sat_hist.append(s)
|
|
||||||
self.assertEqual(sum(sat_hist), 20)
|
|
||||||
|
|
||||||
def test_saturation_color_thresholds(self):
|
|
||||||
"""Color logic: green=0, yellow=1-10, red>10."""
|
|
||||||
def sat_color(total):
|
|
||||||
if total > 10:
|
|
||||||
return "red"
|
|
||||||
if total > 0:
|
|
||||||
return "yellow"
|
|
||||||
return "green"
|
|
||||||
self.assertEqual(sat_color(0), "green")
|
|
||||||
self.assertEqual(sat_color(1), "yellow")
|
|
||||||
self.assertEqual(sat_color(10), "yellow")
|
|
||||||
self.assertEqual(sat_color(11), "red")
|
|
||||||
self.assertEqual(sat_color(255), "red")
|
|
||||||
|
|
||||||
def test_ring_buffer_eviction_preserves_latest(self):
|
|
||||||
"""After overflow, only the most recent values remain."""
|
|
||||||
d = self._make_deque(maxlen=8)
|
|
||||||
for i in range(20):
|
|
||||||
d.append(i)
|
|
||||||
self.assertEqual(list(d), [12, 13, 14, 15, 16, 17, 18, 19])
|
|
||||||
|
|
||||||
def test_empty_history_safe(self):
|
|
||||||
"""Empty ring buffer should be safe for max/sum."""
|
|
||||||
d = self._make_deque(maxlen=256)
|
|
||||||
self.assertEqual(sum(d), 0)
|
|
||||||
self.assertEqual(len(d), 0)
|
|
||||||
# max() on empty would raise — test the guard pattern used in viz code
|
|
||||||
max_sat = max(d) if d else 0
|
|
||||||
self.assertEqual(max_sat, 0)
|
|
||||||
|
|
||||||
def test_agc_mode_string(self):
|
|
||||||
"""AGC mode display string from enable flag."""
|
|
||||||
self.assertEqual(
|
|
||||||
"AUTO" if StatusResponse(agc_enable=1).agc_enable else "MANUAL",
|
|
||||||
"AUTO")
|
|
||||||
self.assertEqual(
|
|
||||||
"AUTO" if StatusResponse(agc_enable=0).agc_enable else "MANUAL",
|
|
||||||
"MANUAL")
|
|
||||||
|
|
||||||
def test_xlim_scroll_logic(self):
|
|
||||||
"""X-axis scroll: when n >= history_len, xlim should expand."""
|
|
||||||
history_len = 8
|
|
||||||
d = self._make_deque(maxlen=history_len)
|
|
||||||
for i in range(10):
|
|
||||||
d.append(i)
|
|
||||||
n = len(d)
|
|
||||||
# After 10 pushes into maxlen=8, n=8
|
|
||||||
self.assertEqual(n, history_len)
|
|
||||||
# xlim should be (0, n) for static or (n-history_len, n) for scrolling
|
|
||||||
self.assertEqual(max(0, n - history_len), 0)
|
|
||||||
self.assertEqual(n, 8)
|
|
||||||
|
|
||||||
def test_sat_autoscale_ylim(self):
|
|
||||||
"""Saturation y-axis auto-scale: max(max_sat * 1.5, 5)."""
|
|
||||||
# No saturation
|
|
||||||
self.assertEqual(max(0 * 1.5, 5), 5)
|
|
||||||
# Some saturation
|
|
||||||
self.assertAlmostEqual(max(10 * 1.5, 5), 15.0)
|
|
||||||
# High saturation
|
|
||||||
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,7 +11,6 @@ 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
|
||||||
@@ -265,15 +264,6 @@ 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
|
||||||
@@ -336,638 +326,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",
|
||||||
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
|
"RadarDataWorker", "RadarMapWidget",
|
||||||
# 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}")
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Test: AGC Visualization data model
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
class TestAGCVisualizationV7(unittest.TestCase):
|
|
||||||
"""AGC visualization ring buffer and data model tests (no Qt required)."""
|
|
||||||
|
|
||||||
def _make_deque(self, maxlen=256):
|
|
||||||
from collections import deque
|
|
||||||
return deque(maxlen=maxlen)
|
|
||||||
|
|
||||||
def test_ring_buffer_basics(self):
|
|
||||||
d = self._make_deque(maxlen=4)
|
|
||||||
for i in range(6):
|
|
||||||
d.append(i)
|
|
||||||
self.assertEqual(list(d), [2, 3, 4, 5])
|
|
||||||
|
|
||||||
def test_gain_range_4bit(self):
|
|
||||||
"""AGC gain is 4-bit (0-15)."""
|
|
||||||
from radar_protocol import StatusResponse
|
|
||||||
for g in [0, 7, 15]:
|
|
||||||
sr = StatusResponse(agc_current_gain=g)
|
|
||||||
self.assertEqual(sr.agc_current_gain, g)
|
|
||||||
|
|
||||||
def test_peak_range_8bit(self):
|
|
||||||
"""Peak magnitude is 8-bit (0-255)."""
|
|
||||||
from radar_protocol import StatusResponse
|
|
||||||
for p in [0, 128, 255]:
|
|
||||||
sr = StatusResponse(agc_peak_magnitude=p)
|
|
||||||
self.assertEqual(sr.agc_peak_magnitude, p)
|
|
||||||
|
|
||||||
def test_saturation_accumulation(self):
|
|
||||||
"""Saturation ring buffer sum tracks total events."""
|
|
||||||
sat = self._make_deque(maxlen=256)
|
|
||||||
for s in [0, 5, 0, 10, 3]:
|
|
||||||
sat.append(s)
|
|
||||||
self.assertEqual(sum(sat), 18)
|
|
||||||
|
|
||||||
def test_mode_label_logic(self):
|
|
||||||
"""AGC mode string from enable field."""
|
|
||||||
from radar_protocol import StatusResponse
|
|
||||||
self.assertEqual(
|
|
||||||
"AUTO" if StatusResponse(agc_enable=1).agc_enable else "MANUAL",
|
|
||||||
"AUTO")
|
|
||||||
self.assertEqual(
|
|
||||||
"AUTO" if StatusResponse(agc_enable=0).agc_enable else "MANUAL",
|
|
||||||
"MANUAL")
|
|
||||||
|
|
||||||
def test_history_len_default(self):
|
|
||||||
"""Default history length should be 256."""
|
|
||||||
d = self._make_deque(maxlen=256)
|
|
||||||
self.assertEqual(d.maxlen, 256)
|
|
||||||
|
|
||||||
def test_color_thresholds(self):
|
|
||||||
"""Saturation color: green=0, warning=1-10, error>10."""
|
|
||||||
from v7.models import DARK_SUCCESS, DARK_WARNING, DARK_ERROR
|
|
||||||
def pick_color(total):
|
|
||||||
if total > 10:
|
|
||||||
return DARK_ERROR
|
|
||||||
if total > 0:
|
|
||||||
return DARK_WARNING
|
|
||||||
return DARK_SUCCESS
|
|
||||||
self.assertEqual(pick_color(0), DARK_SUCCESS)
|
|
||||||
self.assertEqual(pick_color(5), DARK_WARNING)
|
|
||||||
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, 4e6)
|
|
||||||
self.assertEqual(wc.bandwidth_hz, 500e6)
|
|
||||||
self.assertEqual(wc.chirp_duration_s, 300e-6)
|
|
||||||
self.assertEqual(wc.center_freq_hz, 10.525e9)
|
|
||||||
self.assertEqual(wc.n_range_bins, 64)
|
|
||||||
self.assertEqual(wc.n_doppler_bins, 32)
|
|
||||||
self.assertEqual(wc.fft_size, 1024)
|
|
||||||
self.assertEqual(wc.decimation_factor, 16)
|
|
||||||
|
|
||||||
def test_range_resolution(self):
|
|
||||||
"""range_resolution_m should be ~5.62 m/bin with ADI defaults."""
|
|
||||||
from v7.models import WaveformConfig
|
|
||||||
wc = WaveformConfig()
|
|
||||||
self.assertAlmostEqual(wc.range_resolution_m, 5.621, places=1)
|
|
||||||
|
|
||||||
def test_velocity_resolution(self):
|
|
||||||
"""velocity_resolution_mps should be ~1.484 m/s/bin."""
|
|
||||||
from v7.models import WaveformConfig
|
|
||||||
wc = WaveformConfig()
|
|
||||||
self.assertAlmostEqual(wc.velocity_resolution_mps, 1.484, places=2)
|
|
||||||
|
|
||||||
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(bandwidth_hz=1e9) # double BW → halve range res
|
|
||||||
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=5.621)
|
|
||||||
self.assertEqual(len(targets), 1)
|
|
||||||
self.assertAlmostEqual(targets[0].range, 10 * 5.621, places=2)
|
|
||||||
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,7 +14,6 @@ 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,
|
||||||
@@ -26,6 +25,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,
|
||||||
RadarProtocol,
|
RadarProtocol,
|
||||||
Opcode,
|
Opcode,
|
||||||
RadarAcquisition,
|
RadarAcquisition,
|
||||||
@@ -40,48 +40,31 @@ from .processing import (
|
|||||||
RadarProcessor,
|
RadarProcessor,
|
||||||
USBPacketParser,
|
USBPacketParser,
|
||||||
apply_pitch_correction,
|
apply_pitch_correction,
|
||||||
polar_to_geographic,
|
|
||||||
extract_targets_from_frame,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Software FPGA (depends on golden_reference.py in FPGA cosim tree)
|
# Workers and simulator
|
||||||
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 (
|
from .workers import (
|
||||||
RadarDataWorker,
|
RadarDataWorker,
|
||||||
GPSDataWorker,
|
GPSDataWorker,
|
||||||
TargetSimulator,
|
TargetSimulator,
|
||||||
ReplayWorker,
|
polar_to_geographic,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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",
|
||||||
@@ -89,18 +72,15 @@ __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", "RadarProtocol", "Opcode",
|
"FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode",
|
||||||
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
|
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
|
||||||
"STM32USBInterface",
|
"STM32USBInterface",
|
||||||
# processing
|
# processing
|
||||||
"RadarProcessor", "USBPacketParser",
|
"RadarProcessor", "USBPacketParser",
|
||||||
"apply_pitch_correction", "polar_to_geographic",
|
"apply_pitch_correction",
|
||||||
"extract_targets_from_frame",
|
|
||||||
# software FPGA + replay
|
|
||||||
"SoftwareFPGA", "quantize_raw_iq",
|
|
||||||
"ReplayEngine", "ReplayFormat",
|
|
||||||
# workers
|
# workers
|
||||||
"RadarDataWorker", "GPSDataWorker", "TargetSimulator", "ReplayWorker",
|
"RadarDataWorker", "GPSDataWorker", "TargetSimulator",
|
||||||
|
"polar_to_geographic",
|
||||||
# map
|
# map
|
||||||
"MapBridge", "RadarMapWidget",
|
"MapBridge", "RadarMapWidget",
|
||||||
# dashboard
|
# dashboard
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
"""
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
@@ -1,20 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
v7.dashboard — Main application window for the PLFM Radar GUI V7.
|
v7.dashboard — Main application window for the PLFM Radar GUI V7.
|
||||||
|
|
||||||
RadarDashboard is a QMainWindow with six tabs:
|
RadarDashboard is a QMainWindow with five tabs:
|
||||||
1. Main View — Range-Doppler matplotlib canvas (64x32), device combos,
|
1. Main View — Range-Doppler matplotlib canvas (64x32), device combos,
|
||||||
Start/Stop, targets table
|
Start/Stop, targets table
|
||||||
2. Map View — Embedded Leaflet map + sidebar
|
2. Map View — Embedded Leaflet map + sidebar
|
||||||
3. FPGA Control — Full FPGA register control panel (all 27 opcodes incl. AGC,
|
3. FPGA Control — Full FPGA register control panel (all 22 opcodes,
|
||||||
bit-width validation, grouped layout matching production)
|
bit-width validation, grouped layout matching production)
|
||||||
4. AGC Monitor — Real-time AGC strip charts (gain, peak magnitude, saturation)
|
4. Diagnostics — Connection indicators, packet stats, dependency status,
|
||||||
5. Diagnostics — Connection indicators, packet stats, dependency status,
|
|
||||||
self-test results, log viewer
|
self-test results, log viewer
|
||||||
6. Settings — Host-side DSP parameters + About section
|
5. 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 real hardware
|
||||||
- Unified replay via SoftwareFPGA + ReplayEngine + ReplayWorker
|
- ReplayConnection for offline .npy replay
|
||||||
- Mock mode (FT2232HConnection(mock=True)) for development
|
- Mock mode (FT2232HConnection(mock=True)) for development
|
||||||
|
|
||||||
The old STM32 magic-packet start flow has been removed. FPGA registers
|
The old STM32 magic-packet start flow has been removed. FPGA registers
|
||||||
@@ -22,12 +21,8 @@ are controlled directly via 4-byte {opcode, addr, value_hi, value_lo}
|
|||||||
commands sent over FT2232H.
|
commands sent over FT2232H.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from collections import deque
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -35,11 +30,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, QSlider, QFileDialog,
|
QDoubleSpinBox, QSpinBox, QLineEdit,
|
||||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||||
QPlainTextEdit, QStatusBar, QMessageBox,
|
QPlainTextEdit, QStatusBar, QMessageBox,
|
||||||
)
|
)
|
||||||
from PyQt6.QtCore import Qt, QLocale, QTimer, pyqtSignal, pyqtSlot, QObject
|
from PyQt6.QtCore import Qt, QTimer, pyqtSlot
|
||||||
|
|
||||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
||||||
from matplotlib.figure import Figure
|
from matplotlib.figure import Figure
|
||||||
@@ -55,6 +50,7 @@ from .models import (
|
|||||||
)
|
)
|
||||||
from .hardware import (
|
from .hardware import (
|
||||||
FT2232HConnection,
|
FT2232HConnection,
|
||||||
|
ReplayConnection,
|
||||||
RadarProtocol,
|
RadarProtocol,
|
||||||
RadarFrame,
|
RadarFrame,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
@@ -62,30 +58,15 @@ from .hardware import (
|
|||||||
STM32USBInterface,
|
STM32USBInterface,
|
||||||
)
|
)
|
||||||
from .processing import RadarProcessor, USBPacketParser
|
from .processing import RadarProcessor, USBPacketParser
|
||||||
from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator, ReplayWorker
|
from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator
|
||||||
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)
|
||||||
@@ -159,12 +140,6 @@ 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
|
||||||
@@ -173,20 +148,11 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._last_status: StatusResponse | None = None
|
self._last_status: StatusResponse | None = None
|
||||||
self._frame_count = 0
|
self._frame_count = 0
|
||||||
self._gps_packet_count = 0
|
self._gps_packet_count = 0
|
||||||
self._last_stats: dict = {}
|
|
||||||
self._current_targets: list[RadarTarget] = []
|
self._current_targets: list[RadarTarget] = []
|
||||||
|
|
||||||
# FPGA control parameter widgets
|
# FPGA control parameter widgets
|
||||||
self._param_spins: dict = {} # opcode_hex -> QSpinBox
|
self._param_spins: dict = {} # opcode_hex -> QSpinBox
|
||||||
|
|
||||||
# AGC visualization history (ring buffers)
|
|
||||||
self._agc_history_len = 256
|
|
||||||
self._agc_gain_history: deque[int] = deque(maxlen=self._agc_history_len)
|
|
||||||
self._agc_peak_history: deque[int] = deque(maxlen=self._agc_history_len)
|
|
||||||
self._agc_sat_history: deque[int] = deque(maxlen=self._agc_history_len)
|
|
||||||
self._agc_last_redraw: float = 0.0 # throttle chart redraws
|
|
||||||
self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws
|
|
||||||
|
|
||||||
# ---- Build UI ------------------------------------------------------
|
# ---- Build UI ------------------------------------------------------
|
||||||
self._apply_dark_theme()
|
self._apply_dark_theme()
|
||||||
self._setup_ui()
|
self._setup_ui()
|
||||||
@@ -197,10 +163,8 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._gui_timer.timeout.connect(self._refresh_gui)
|
self._gui_timer.timeout.connect(self._refresh_gui)
|
||||||
self._gui_timer.start(100)
|
self._gui_timer.start(100)
|
||||||
|
|
||||||
# Log handler for diagnostics (thread-safe via Qt signal)
|
# Log handler for diagnostics
|
||||||
self._log_bridge = _LogSignalBridge(self)
|
self._log_handler = _QtLogHandler(self._log_append)
|
||||||
self._log_bridge.log_message.connect(self._log_append)
|
|
||||||
self._log_handler = _QtLogHandler(self._log_bridge)
|
|
||||||
self._log_handler.setLevel(logging.INFO)
|
self._log_handler.setLevel(logging.INFO)
|
||||||
logging.getLogger().addHandler(self._log_handler)
|
logging.getLogger().addHandler(self._log_handler)
|
||||||
|
|
||||||
@@ -342,7 +306,6 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._create_main_tab()
|
self._create_main_tab()
|
||||||
self._create_map_tab()
|
self._create_map_tab()
|
||||||
self._create_fpga_control_tab()
|
self._create_fpga_control_tab()
|
||||||
self._create_agc_monitor_tab()
|
|
||||||
self._create_diagnostics_tab()
|
self._create_diagnostics_tab()
|
||||||
self._create_settings_tab()
|
self._create_settings_tab()
|
||||||
|
|
||||||
@@ -364,7 +327,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"])
|
self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay (.npy)"])
|
||||||
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)
|
||||||
|
|
||||||
@@ -413,55 +376,6 @@ 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) ------------------
|
||||||
@@ -478,7 +392,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._targets_table_main = QTableWidget()
|
self._targets_table_main = QTableWidget()
|
||||||
self._targets_table_main.setColumnCount(5)
|
self._targets_table_main.setColumnCount(5)
|
||||||
self._targets_table_main.setHorizontalHeaderLabels([
|
self._targets_table_main.setHorizontalHeaderLabels([
|
||||||
"Range (m)", "Velocity (m/s)", "Magnitude", "SNR (dB)", "Track ID",
|
"Range Bin", "Doppler Bin", "Magnitude", "SNR (dB)", "Track ID",
|
||||||
])
|
])
|
||||||
self._targets_table_main.setAlternatingRowColors(True)
|
self._targets_table_main.setAlternatingRowColors(True)
|
||||||
self._targets_table_main.setSelectionBehavior(
|
self._targets_table_main.setSelectionBehavior(
|
||||||
@@ -524,19 +438,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 = _make_dspin()
|
self._lat_spin = QDoubleSpinBox()
|
||||||
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 = _make_dspin()
|
self._lon_spin = QDoubleSpinBox()
|
||||||
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 = _make_dspin()
|
self._alt_spin = QDoubleSpinBox()
|
||||||
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)
|
||||||
@@ -555,7 +469,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 = _make_dspin()
|
self._coverage_spin = QDoubleSpinBox()
|
||||||
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)
|
||||||
@@ -767,48 +681,6 @@ class RadarDashboard(QMainWindow):
|
|||||||
|
|
||||||
right_layout.addWidget(grp_cfar)
|
right_layout.addWidget(grp_cfar)
|
||||||
|
|
||||||
# ── AGC (Automatic Gain Control) ──────────────────────────────
|
|
||||||
grp_agc = QGroupBox("AGC (Auto Gain)")
|
|
||||||
agc_layout = QVBoxLayout(grp_agc)
|
|
||||||
|
|
||||||
agc_params = [
|
|
||||||
("AGC Enable", 0x28, 0, 1, "0=manual, 1=auto"),
|
|
||||||
("AGC Target", 0x29, 200, 8, "0-255, peak target"),
|
|
||||||
("AGC Attack", 0x2A, 1, 4, "0-15, atten step"),
|
|
||||||
("AGC Decay", 0x2B, 1, 4, "0-15, gain-up step"),
|
|
||||||
("AGC Holdoff", 0x2C, 4, 4, "0-15, frames"),
|
|
||||||
]
|
|
||||||
for label, opcode, default, bits, hint in agc_params:
|
|
||||||
self._add_fpga_param_row(agc_layout, label, opcode, default, bits, hint)
|
|
||||||
|
|
||||||
# AGC quick toggles
|
|
||||||
agc_row = QHBoxLayout()
|
|
||||||
btn_agc_on = QPushButton("Enable AGC")
|
|
||||||
btn_agc_on.clicked.connect(lambda: self._send_fpga_cmd(0x28, 1))
|
|
||||||
agc_row.addWidget(btn_agc_on)
|
|
||||||
btn_agc_off = QPushButton("Disable AGC")
|
|
||||||
btn_agc_off.clicked.connect(lambda: self._send_fpga_cmd(0x28, 0))
|
|
||||||
agc_row.addWidget(btn_agc_off)
|
|
||||||
agc_layout.addLayout(agc_row)
|
|
||||||
|
|
||||||
# AGC status readback labels
|
|
||||||
agc_st_group = QGroupBox("AGC Status")
|
|
||||||
agc_st_layout = QVBoxLayout(agc_st_group)
|
|
||||||
self._agc_labels: dict[str, QLabel] = {}
|
|
||||||
for name, default_text in [
|
|
||||||
("enable", "AGC: --"),
|
|
||||||
("gain", "Gain: --"),
|
|
||||||
("peak", "Peak: --"),
|
|
||||||
("sat", "Sat Count: --"),
|
|
||||||
]:
|
|
||||||
lbl = QLabel(default_text)
|
|
||||||
lbl.setStyleSheet(f"color: {DARK_INFO}; font-size: 10px;")
|
|
||||||
agc_st_layout.addWidget(lbl)
|
|
||||||
self._agc_labels[name] = lbl
|
|
||||||
agc_layout.addWidget(agc_st_group)
|
|
||||||
|
|
||||||
right_layout.addWidget(grp_agc)
|
|
||||||
|
|
||||||
# Custom Command
|
# Custom Command
|
||||||
grp_custom = QGroupBox("Custom Command")
|
grp_custom = QGroupBox("Custom Command")
|
||||||
cust_layout = QGridLayout(grp_custom)
|
cust_layout = QGridLayout(grp_custom)
|
||||||
@@ -869,122 +741,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
parent_layout.addLayout(row)
|
parent_layout.addLayout(row)
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# TAB 4: AGC Monitor
|
# TAB 4: Diagnostics
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
def _create_agc_monitor_tab(self):
|
|
||||||
"""AGC Monitor — real-time strip charts for FPGA inner-loop AGC."""
|
|
||||||
tab = QWidget()
|
|
||||||
layout = QVBoxLayout(tab)
|
|
||||||
layout.setContentsMargins(8, 8, 8, 8)
|
|
||||||
|
|
||||||
# ---- Top indicator row ---------------------------------------------
|
|
||||||
indicator = QFrame()
|
|
||||||
indicator.setStyleSheet(
|
|
||||||
f"background-color: {DARK_ACCENT}; border-radius: 4px;")
|
|
||||||
ind_layout = QHBoxLayout(indicator)
|
|
||||||
ind_layout.setContentsMargins(12, 8, 12, 8)
|
|
||||||
|
|
||||||
self._agc_mode_lbl = QLabel("AGC: --")
|
|
||||||
self._agc_mode_lbl.setStyleSheet(
|
|
||||||
f"color: {DARK_FG}; font-size: 16px; font-weight: bold;")
|
|
||||||
ind_layout.addWidget(self._agc_mode_lbl)
|
|
||||||
|
|
||||||
self._agc_gain_lbl = QLabel("Gain: --")
|
|
||||||
self._agc_gain_lbl.setStyleSheet(
|
|
||||||
f"color: {DARK_INFO}; font-size: 14px;")
|
|
||||||
ind_layout.addWidget(self._agc_gain_lbl)
|
|
||||||
|
|
||||||
self._agc_peak_lbl = QLabel("Peak: --")
|
|
||||||
self._agc_peak_lbl.setStyleSheet(
|
|
||||||
f"color: {DARK_INFO}; font-size: 14px;")
|
|
||||||
ind_layout.addWidget(self._agc_peak_lbl)
|
|
||||||
|
|
||||||
self._agc_sat_total_lbl = QLabel("Total Saturations: 0")
|
|
||||||
self._agc_sat_total_lbl.setStyleSheet(
|
|
||||||
f"color: {DARK_SUCCESS}; font-size: 14px; font-weight: bold;")
|
|
||||||
ind_layout.addWidget(self._agc_sat_total_lbl)
|
|
||||||
|
|
||||||
ind_layout.addStretch()
|
|
||||||
layout.addWidget(indicator)
|
|
||||||
|
|
||||||
# ---- Matplotlib figure with 3 subplots -----------------------------
|
|
||||||
agc_fig = Figure(figsize=(12, 7), facecolor=DARK_BG)
|
|
||||||
agc_fig.subplots_adjust(
|
|
||||||
left=0.07, right=0.96, top=0.95, bottom=0.07,
|
|
||||||
hspace=0.32)
|
|
||||||
|
|
||||||
# Subplot 1: Gain history (4-bit, 0-15)
|
|
||||||
self._agc_ax_gain = agc_fig.add_subplot(3, 1, 1)
|
|
||||||
self._agc_ax_gain.set_facecolor(DARK_ACCENT)
|
|
||||||
self._agc_ax_gain.set_ylabel("Gain Code", color=DARK_FG, fontsize=10)
|
|
||||||
self._agc_ax_gain.set_title(
|
|
||||||
"FPGA Inner-Loop Gain (4-bit)", color=DARK_FG, fontsize=11)
|
|
||||||
self._agc_ax_gain.set_ylim(-0.5, 15.5)
|
|
||||||
self._agc_ax_gain.tick_params(colors=DARK_FG, labelsize=9)
|
|
||||||
self._agc_ax_gain.set_xlim(0, self._agc_history_len)
|
|
||||||
for spine in self._agc_ax_gain.spines.values():
|
|
||||||
spine.set_color(DARK_BORDER)
|
|
||||||
self._agc_gain_line, = self._agc_ax_gain.plot(
|
|
||||||
[], [], color="#89b4fa", linewidth=1.5, label="Gain")
|
|
||||||
self._agc_ax_gain.axhline(y=7.5, color=DARK_WARNING, linestyle="--",
|
|
||||||
linewidth=0.8, alpha=0.5, label="Midpoint")
|
|
||||||
self._agc_ax_gain.legend(
|
|
||||||
loc="upper right", fontsize=8,
|
|
||||||
facecolor=DARK_ACCENT, edgecolor=DARK_BORDER,
|
|
||||||
labelcolor=DARK_FG)
|
|
||||||
|
|
||||||
# Subplot 2: Peak magnitude (8-bit, 0-255)
|
|
||||||
self._agc_ax_peak = agc_fig.add_subplot(
|
|
||||||
3, 1, 2, sharex=self._agc_ax_gain)
|
|
||||||
self._agc_ax_peak.set_facecolor(DARK_ACCENT)
|
|
||||||
self._agc_ax_peak.set_ylabel("Peak Mag", color=DARK_FG, fontsize=10)
|
|
||||||
self._agc_ax_peak.set_title(
|
|
||||||
"ADC Peak Magnitude (8-bit)", color=DARK_FG, fontsize=11)
|
|
||||||
self._agc_ax_peak.set_ylim(-5, 260)
|
|
||||||
self._agc_ax_peak.tick_params(colors=DARK_FG, labelsize=9)
|
|
||||||
for spine in self._agc_ax_peak.spines.values():
|
|
||||||
spine.set_color(DARK_BORDER)
|
|
||||||
self._agc_peak_line, = self._agc_ax_peak.plot(
|
|
||||||
[], [], color=DARK_SUCCESS, linewidth=1.5, label="Peak")
|
|
||||||
self._agc_ax_peak.axhline(y=200, color=DARK_WARNING, linestyle="--",
|
|
||||||
linewidth=0.8, alpha=0.5,
|
|
||||||
label="Target (200)")
|
|
||||||
self._agc_ax_peak.axhspan(240, 255, alpha=0.15, color=DARK_ERROR,
|
|
||||||
label="Sat Zone")
|
|
||||||
self._agc_ax_peak.legend(
|
|
||||||
loc="upper right", fontsize=8,
|
|
||||||
facecolor=DARK_ACCENT, edgecolor=DARK_BORDER,
|
|
||||||
labelcolor=DARK_FG)
|
|
||||||
|
|
||||||
# Subplot 3: Saturation count per update (8-bit, 0-255)
|
|
||||||
self._agc_ax_sat = agc_fig.add_subplot(
|
|
||||||
3, 1, 3, sharex=self._agc_ax_gain)
|
|
||||||
self._agc_ax_sat.set_facecolor(DARK_ACCENT)
|
|
||||||
self._agc_ax_sat.set_ylabel("Sat Count", color=DARK_FG, fontsize=10)
|
|
||||||
self._agc_ax_sat.set_xlabel(
|
|
||||||
"Sample (newest right)", color=DARK_FG, fontsize=10)
|
|
||||||
self._agc_ax_sat.set_title(
|
|
||||||
"Saturation Events per Update", color=DARK_FG, fontsize=11)
|
|
||||||
self._agc_ax_sat.set_ylim(-1, 10)
|
|
||||||
self._agc_ax_sat.tick_params(colors=DARK_FG, labelsize=9)
|
|
||||||
for spine in self._agc_ax_sat.spines.values():
|
|
||||||
spine.set_color(DARK_BORDER)
|
|
||||||
self._agc_sat_line, = self._agc_ax_sat.plot(
|
|
||||||
[], [], color=DARK_ERROR, linewidth=1.0, label="Saturation")
|
|
||||||
self._agc_sat_fill_artist = None
|
|
||||||
self._agc_ax_sat.legend(
|
|
||||||
loc="upper right", fontsize=8,
|
|
||||||
facecolor=DARK_ACCENT, edgecolor=DARK_BORDER,
|
|
||||||
labelcolor=DARK_FG)
|
|
||||||
|
|
||||||
self._agc_canvas = FigureCanvasQTAgg(agc_fig)
|
|
||||||
layout.addWidget(self._agc_canvas, stretch=1)
|
|
||||||
|
|
||||||
self._tabs.addTab(tab, "AGC Monitor")
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
# TAB 5: Diagnostics
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
||||||
def _create_diagnostics_tab(self):
|
def _create_diagnostics_tab(self):
|
||||||
@@ -1119,7 +876,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 = _make_dspin()
|
self._cluster_eps_spin = QDoubleSpinBox()
|
||||||
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)
|
||||||
@@ -1236,11 +993,7 @@ 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:
|
||||||
@@ -1250,18 +1003,7 @@ 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."""
|
||||||
@@ -1278,112 +1020,36 @@ class RadarDashboard(QMainWindow):
|
|||||||
|
|
||||||
def _start_radar(self):
|
def _start_radar(self):
|
||||||
"""Start radar data acquisition using production protocol."""
|
"""Start radar data acquisition using production protocol."""
|
||||||
# Mutual exclusion: stop demo if running
|
|
||||||
if self._demo_mode:
|
|
||||||
self._stop_demo()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mode = self._mode_combo.currentText()
|
mode = self._mode_combo.currentText()
|
||||||
|
|
||||||
if "Mock" in mode:
|
if "Mock" in mode:
|
||||||
self._replay_mode = False
|
|
||||||
self._connection = FT2232HConnection(mock=True)
|
self._connection = FT2232HConnection(mock=True)
|
||||||
if not self._connection.open():
|
if not self._connection.open():
|
||||||
QMessageBox.critical(self, "Error", "Failed to open mock connection.")
|
QMessageBox.critical(self, "Error", "Failed to open mock connection.")
|
||||||
return
|
return
|
||||||
elif "Live" in mode:
|
elif "Live" in mode:
|
||||||
self._replay_mode = False
|
|
||||||
self._connection = FT2232HConnection(mock=False)
|
self._connection = FT2232HConnection(mock=False)
|
||||||
if not self._connection.open():
|
if not self._connection.open():
|
||||||
QMessageBox.critical(self, "Error",
|
QMessageBox.critical(self, "Error",
|
||||||
"Failed to open FT2232H. Check USB connection.")
|
"Failed to open FT2232H. Check USB connection.")
|
||||||
return
|
return
|
||||||
elif "Replay" in mode:
|
elif "Replay" in mode:
|
||||||
self._replay_mode = True
|
from PyQt6.QtWidgets import QFileDialog
|
||||||
replay_path = self._replay_file_label.text()
|
npy_dir = QFileDialog.getExistingDirectory(
|
||||||
if replay_path == "No file loaded" or not replay_path:
|
self, "Select .npy replay directory")
|
||||||
QMessageBox.warning(
|
if not npy_dir:
|
||||||
self, "Replay",
|
|
||||||
"Use 'Browse...' to select a replay"
|
|
||||||
" file or directory first.")
|
|
||||||
return
|
return
|
||||||
|
self._connection = ReplayConnection(npy_dir)
|
||||||
from .software_fpga import SoftwareFPGA
|
if not self._connection.open():
|
||||||
from .replay import ReplayEngine
|
QMessageBox.critical(self, "Error",
|
||||||
|
"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._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 (mock / live — NOT replay)
|
# Start radar worker
|
||||||
self._radar_worker = RadarDataWorker(
|
self._radar_worker = RadarDataWorker(
|
||||||
connection=self._connection,
|
connection=self._connection,
|
||||||
processor=self._processor,
|
processor=self._processor,
|
||||||
@@ -1417,8 +1083,6 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._start_btn.setEnabled(False)
|
self._start_btn.setEnabled(False)
|
||||||
self._stop_btn.setEnabled(True)
|
self._stop_btn.setEnabled(True)
|
||||||
self._mode_combo.setEnabled(False)
|
self._mode_combo.setEnabled(False)
|
||||||
self._demo_btn_main.setEnabled(False)
|
|
||||||
self._demo_btn_map.setEnabled(False)
|
|
||||||
self._status_label_main.setText(f"Status: Running ({mode})")
|
self._status_label_main.setText(f"Status: Running ({mode})")
|
||||||
self._sb_status.setText(f"Running ({mode})")
|
self._sb_status.setText(f"Running ({mode})")
|
||||||
self._sb_mode.setText(mode)
|
self._sb_mode.setText(mode)
|
||||||
@@ -1436,18 +1100,6 @@ 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)
|
||||||
@@ -1462,120 +1114,11 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._start_btn.setEnabled(True)
|
self._start_btn.setEnabled(True)
|
||||||
self._stop_btn.setEnabled(False)
|
self._stop_btn.setEnabled(False)
|
||||||
self._mode_combo.setEnabled(True)
|
self._mode_combo.setEnabled(True)
|
||||||
self._demo_btn_main.setEnabled(True)
|
|
||||||
self._demo_btn_map.setEnabled(True)
|
|
||||||
self._status_label_main.setText("Status: Radar stopped")
|
self._status_label_main.setText("Status: Radar stopped")
|
||||||
self._sb_status.setText("Radar stopped")
|
self._sb_status.setText("Radar stopped")
|
||||||
self._sb_mode.setText("Idle")
|
self._sb_mode.setText("Idle")
|
||||||
logger.info("Radar system stopped")
|
logger.info("Radar system stopped")
|
||||||
|
|
||||||
# =====================================================================
|
|
||||||
# Replay helpers
|
|
||||||
# =====================================================================
|
|
||||||
|
|
||||||
def _on_mode_changed(self, text: str):
|
|
||||||
"""Show/hide replay transport controls based on mode selection."""
|
|
||||||
is_replay = "Replay" in text
|
|
||||||
for w in self._replay_controls:
|
|
||||||
w.setVisible(is_replay)
|
|
||||||
|
|
||||||
def _browse_replay_file(self):
|
|
||||||
"""Open file/directory picker for replay source."""
|
|
||||||
path, _ = QFileDialog.getOpenFileName(
|
|
||||||
self, "Select replay file",
|
|
||||||
"",
|
|
||||||
"All supported (*.npy *.h5);;NumPy files (*.npy);;HDF5 files (*.h5);;All files (*)",
|
|
||||||
)
|
|
||||||
if path:
|
|
||||||
self._replay_file_label.setText(path)
|
|
||||||
return
|
|
||||||
# If no file selected, try directory (for co-sim)
|
|
||||||
dir_path = QFileDialog.getExistingDirectory(
|
|
||||||
self, "Select co-sim replay directory")
|
|
||||||
if dir_path:
|
|
||||||
self._replay_file_label.setText(dir_path)
|
|
||||||
|
|
||||||
def _replay_play_pause(self):
|
|
||||||
"""Toggle play/pause on the replay worker."""
|
|
||||||
if self._replay_worker is None:
|
|
||||||
return
|
|
||||||
if self._replay_worker.is_playing:
|
|
||||||
self._replay_worker.pause()
|
|
||||||
self._replay_play_btn.setText("Play")
|
|
||||||
else:
|
|
||||||
self._replay_worker.play()
|
|
||||||
self._replay_play_btn.setText("Pause")
|
|
||||||
|
|
||||||
def _replay_stop(self):
|
|
||||||
"""Stop replay playback (keeps data loaded)."""
|
|
||||||
if self._replay_worker is not None:
|
|
||||||
self._replay_worker.pause()
|
|
||||||
self._replay_worker.seek(0)
|
|
||||||
self._replay_play_btn.setText("Play")
|
|
||||||
|
|
||||||
def _replay_seek(self, value: int):
|
|
||||||
"""Seek to a specific frame from the slider."""
|
|
||||||
if self._replay_worker is not None and not self._replay_worker.is_playing:
|
|
||||||
self._replay_worker.seek(value)
|
|
||||||
|
|
||||||
def _replay_speed_changed(self, index: int):
|
|
||||||
"""Update replay frame interval from speed combo."""
|
|
||||||
speed_map = {0: 50, 1: 100, 2: 200, 3: 500}
|
|
||||||
ms = speed_map.get(index, 100)
|
|
||||||
if self._replay_worker is not None:
|
|
||||||
self._replay_worker.set_frame_interval(ms)
|
|
||||||
|
|
||||||
def _replay_loop_changed(self, state: int):
|
|
||||||
"""Update replay loop setting."""
|
|
||||||
if self._replay_worker is not None:
|
|
||||||
self._replay_worker.set_loop(state == Qt.CheckState.Checked.value)
|
|
||||||
|
|
||||||
@pyqtSlot(str)
|
|
||||||
def _on_playback_state_changed(self, state: str):
|
|
||||||
"""Update UI when replay playback state changes."""
|
|
||||||
if state == "playing":
|
|
||||||
self._replay_play_btn.setText("Pause")
|
|
||||||
elif state in ("paused", "stopped"):
|
|
||||||
self._replay_play_btn.setText("Play")
|
|
||||||
if state == "stopped" and self._replay_worker is not None:
|
|
||||||
self._status_label_main.setText("Status: Replay finished")
|
|
||||||
|
|
||||||
@pyqtSlot(int, int)
|
|
||||||
def _on_frame_index_changed(self, current: int, total: int):
|
|
||||||
"""Update slider and frame label from replay worker."""
|
|
||||||
self._replay_slider.blockSignals(True)
|
|
||||||
self._replay_slider.setValue(current)
|
|
||||||
self._replay_slider.blockSignals(False)
|
|
||||||
self._replay_frame_label.setText(f"{current} / {total}")
|
|
||||||
|
|
||||||
def _dispatch_to_software_fpga(self, opcode: int, value: int):
|
|
||||||
"""Route an FPGA opcode+value to the SoftwareFPGA setter."""
|
|
||||||
fpga = self._software_fpga
|
|
||||||
if fpga is None:
|
|
||||||
return
|
|
||||||
_opcode_dispatch = {
|
|
||||||
0x03: lambda v: fpga.set_detect_threshold(v),
|
|
||||||
0x16: lambda v: fpga.set_gain_shift(v),
|
|
||||||
0x21: lambda v: fpga.set_cfar_guard(v),
|
|
||||||
0x22: lambda v: fpga.set_cfar_train(v),
|
|
||||||
0x23: lambda v: fpga.set_cfar_alpha(v),
|
|
||||||
0x24: lambda v: fpga.set_cfar_mode(v),
|
|
||||||
0x25: lambda v: fpga.set_cfar_enable(bool(v)),
|
|
||||||
0x26: lambda v: fpga.set_mti_enable(bool(v)),
|
|
||||||
0x27: lambda v: fpga.set_dc_notch_width(v),
|
|
||||||
0x28: lambda v: fpga.set_agc_enable(bool(v)),
|
|
||||||
0x29: lambda v: fpga.set_agc_params(target=v),
|
|
||||||
0x2A: lambda v: fpga.set_agc_params(attack=v),
|
|
||||||
0x2B: lambda v: fpga.set_agc_params(decay=v),
|
|
||||||
0x2C: lambda v: fpga.set_agc_params(holdoff=v),
|
|
||||||
}
|
|
||||||
handler = _opcode_dispatch.get(opcode)
|
|
||||||
if handler is not None:
|
|
||||||
handler(value)
|
|
||||||
logger.info(f"SoftwareFPGA: 0x{opcode:02X} = {value}")
|
|
||||||
else:
|
|
||||||
logger.debug(f"SoftwareFPGA: opcode 0x{opcode:02X} not handled (no-op)")
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Demo mode
|
# Demo mode
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -1583,10 +1126,6 @@ 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)
|
||||||
@@ -1603,13 +1142,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._simulator.stop()
|
self._simulator.stop()
|
||||||
self._simulator = None
|
self._simulator = None
|
||||||
self._demo_mode = False
|
self._demo_mode = False
|
||||||
if not self._running:
|
self._sb_mode.setText("Idle" if not self._running else "Live")
|
||||||
mode = "Idle"
|
|
||||||
elif self._replay_mode:
|
|
||||||
mode = "Replay"
|
|
||||||
else:
|
|
||||||
mode = "Live"
|
|
||||||
self._sb_mode.setText(mode)
|
|
||||||
self._sb_status.setText("Demo stopped")
|
self._sb_status.setText("Demo stopped")
|
||||||
self._demo_btn_main.setText("Start Demo")
|
self._demo_btn_main.setText("Start Demo")
|
||||||
self._demo_btn_map.setText("Start Demo")
|
self._demo_btn_map.setText("Start Demo")
|
||||||
@@ -1656,7 +1189,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
|
|
||||||
@pyqtSlot(dict)
|
@pyqtSlot(dict)
|
||||||
def _on_radar_stats(self, stats: dict):
|
def _on_radar_stats(self, stats: dict):
|
||||||
self._last_stats = stats
|
pass # Stats are displayed in _refresh_gui
|
||||||
|
|
||||||
@pyqtSlot(str)
|
@pyqtSlot(str)
|
||||||
def _on_worker_error(self, msg: str):
|
def _on_worker_error(self, msg: str):
|
||||||
@@ -1743,97 +1276,6 @@ class RadarDashboard(QMainWindow):
|
|||||||
self._st_labels["t4"].setText(
|
self._st_labels["t4"].setText(
|
||||||
f"T4 ADC: {'PASS' if flags & 0x10 else 'FAIL'}")
|
f"T4 ADC: {'PASS' if flags & 0x10 else 'FAIL'}")
|
||||||
|
|
||||||
# AGC status readback
|
|
||||||
if hasattr(self, '_agc_labels'):
|
|
||||||
agc_str = "AUTO" if st.agc_enable else "MANUAL"
|
|
||||||
agc_color = DARK_SUCCESS if st.agc_enable else DARK_INFO
|
|
||||||
self._agc_labels["enable"].setStyleSheet(
|
|
||||||
f"color: {agc_color}; font-weight: bold;")
|
|
||||||
self._agc_labels["enable"].setText(f"AGC: {agc_str}")
|
|
||||||
self._agc_labels["gain"].setText(
|
|
||||||
f"Gain: {st.agc_current_gain}")
|
|
||||||
self._agc_labels["peak"].setText(
|
|
||||||
f"Peak: {st.agc_peak_magnitude}")
|
|
||||||
sat_color = DARK_ERROR if st.agc_saturation_count > 0 else DARK_INFO
|
|
||||||
self._agc_labels["sat"].setStyleSheet(
|
|
||||||
f"color: {sat_color}; font-weight: bold;")
|
|
||||||
self._agc_labels["sat"].setText(
|
|
||||||
f"Sat Count: {st.agc_saturation_count}")
|
|
||||||
|
|
||||||
# AGC Monitor tab visualization
|
|
||||||
self._update_agc_visualization(st)
|
|
||||||
|
|
||||||
def _update_agc_visualization(self, st: StatusResponse):
|
|
||||||
"""Push AGC metrics into ring buffers and redraw AGC Monitor charts.
|
|
||||||
|
|
||||||
Data is always accumulated (cheap), but matplotlib redraws are
|
|
||||||
throttled to ``_AGC_REDRAW_INTERVAL`` seconds to avoid saturating
|
|
||||||
the GUI event-loop when status packets arrive at 20 Hz.
|
|
||||||
"""
|
|
||||||
if not hasattr(self, '_agc_canvas'):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Push data into ring buffers (always — O(1))
|
|
||||||
self._agc_gain_history.append(st.agc_current_gain)
|
|
||||||
self._agc_peak_history.append(st.agc_peak_magnitude)
|
|
||||||
self._agc_sat_history.append(st.agc_saturation_count)
|
|
||||||
|
|
||||||
# Update indicator labels (cheap Qt calls)
|
|
||||||
agc_str = "AUTO" if st.agc_enable else "MANUAL"
|
|
||||||
agc_color = DARK_SUCCESS if st.agc_enable else DARK_INFO
|
|
||||||
self._agc_mode_lbl.setStyleSheet(
|
|
||||||
f"color: {agc_color}; font-size: 16px; font-weight: bold;")
|
|
||||||
self._agc_mode_lbl.setText(f"AGC: {agc_str}")
|
|
||||||
self._agc_gain_lbl.setText(f"Gain: {st.agc_current_gain}")
|
|
||||||
self._agc_peak_lbl.setText(f"Peak: {st.agc_peak_magnitude}")
|
|
||||||
|
|
||||||
total_sat = sum(self._agc_sat_history)
|
|
||||||
if total_sat > 10:
|
|
||||||
sat_color = DARK_ERROR
|
|
||||||
elif total_sat > 0:
|
|
||||||
sat_color = DARK_WARNING
|
|
||||||
else:
|
|
||||||
sat_color = DARK_SUCCESS
|
|
||||||
self._agc_sat_total_lbl.setStyleSheet(
|
|
||||||
f"color: {sat_color}; font-size: 14px; font-weight: bold;")
|
|
||||||
self._agc_sat_total_lbl.setText(f"Total Saturations: {total_sat}")
|
|
||||||
|
|
||||||
# ---- Throttle matplotlib redraws ---------------------------------
|
|
||||||
now = time.monotonic()
|
|
||||||
if now - self._agc_last_redraw < self._AGC_REDRAW_INTERVAL:
|
|
||||||
return
|
|
||||||
self._agc_last_redraw = now
|
|
||||||
|
|
||||||
n = len(self._agc_gain_history)
|
|
||||||
xs = list(range(n))
|
|
||||||
|
|
||||||
# Update line plots
|
|
||||||
gain_data = list(self._agc_gain_history)
|
|
||||||
peak_data = list(self._agc_peak_history)
|
|
||||||
sat_data = list(self._agc_sat_history)
|
|
||||||
|
|
||||||
self._agc_gain_line.set_data(xs, gain_data)
|
|
||||||
self._agc_peak_line.set_data(xs, peak_data)
|
|
||||||
self._agc_sat_line.set_data(xs, sat_data)
|
|
||||||
|
|
||||||
# Update saturation fill
|
|
||||||
if self._agc_sat_fill_artist is not None:
|
|
||||||
self._agc_sat_fill_artist.remove()
|
|
||||||
if n > 0:
|
|
||||||
self._agc_sat_fill_artist = self._agc_ax_sat.fill_between(
|
|
||||||
xs, sat_data, color=DARK_ERROR, alpha=0.4)
|
|
||||||
else:
|
|
||||||
self._agc_sat_fill_artist = None
|
|
||||||
|
|
||||||
# Auto-scale saturation y-axis
|
|
||||||
max_sat = max(sat_data) if sat_data else 1
|
|
||||||
self._agc_ax_sat.set_ylim(-1, max(max_sat * 1.3, 5))
|
|
||||||
|
|
||||||
# Scroll x-axis
|
|
||||||
self._agc_ax_gain.set_xlim(max(0, n - self._agc_history_len), n)
|
|
||||||
|
|
||||||
self._agc_canvas.draw_idle()
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Position / coverage callbacks (map sidebar)
|
# Position / coverage callbacks (map sidebar)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -1967,7 +1409,7 @@ class RadarDashboard(QMainWindow):
|
|||||||
str(self._frame_count),
|
str(self._frame_count),
|
||||||
str(det),
|
str(det),
|
||||||
str(gps_count),
|
str(gps_count),
|
||||||
str(self._last_stats.get("errors", 0)),
|
"0", # errors
|
||||||
f"{uptime:.0f}s",
|
f"{uptime:.0f}s",
|
||||||
f"{frame_rate:.1f}/s",
|
f"{frame_rate:.1f}/s",
|
||||||
]
|
]
|
||||||
@@ -2018,22 +1460,15 @@ class RadarDashboard(QMainWindow):
|
|||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Qt-compatible log handler (routes Python logging -> QTextEdit via signal)
|
# Qt-compatible log handler (routes Python logging -> QTextEdit)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
class _LogSignalBridge(QObject):
|
|
||||||
"""Thread-safe bridge: emits a Qt signal so the slot runs on the GUI thread."""
|
|
||||||
|
|
||||||
log_message = pyqtSignal(str)
|
|
||||||
|
|
||||||
|
|
||||||
class _QtLogHandler(logging.Handler):
|
class _QtLogHandler(logging.Handler):
|
||||||
"""Sends log records to a QObject signal (safe from any thread)."""
|
"""Sends log records to a callback (called on the thread that emitted)."""
|
||||||
|
|
||||||
def __init__(self, bridge: _LogSignalBridge):
|
def __init__(self, callback):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._bridge = bridge
|
self._callback = callback
|
||||||
self.setFormatter(logging.Formatter(
|
self.setFormatter(logging.Formatter(
|
||||||
"%(asctime)s %(levelname)-8s %(message)s",
|
"%(asctime)s %(levelname)-8s %(message)s",
|
||||||
datefmt="%H:%M:%S",
|
datefmt="%H:%M:%S",
|
||||||
@@ -2042,6 +1477,6 @@ class _QtLogHandler(logging.Handler):
|
|||||||
def emit(self, record):
|
def emit(self, record):
|
||||||
try:
|
try:
|
||||||
msg = self.format(record)
|
msg = self.format(record)
|
||||||
self._bridge.log_message.emit(msg)
|
self._callback(msg)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ 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.
|
parses 0xAA data / 0xBB status packets from the FPGA. The old magic-packet
|
||||||
|
and 'SET'...'END' binary settings protocol has been removed — it was
|
||||||
|
incompatible with the FPGA register interface.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import importlib.util
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from .models import USB_AVAILABLE
|
from .models import USB_AVAILABLE
|
||||||
@@ -21,17 +25,44 @@ if USB_AVAILABLE:
|
|||||||
import usb.core
|
import usb.core
|
||||||
import usb.util
|
import usb.util
|
||||||
|
|
||||||
# Import production protocol layer — single source of truth for FPGA comms
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
def _load_radar_protocol():
|
||||||
from radar_protocol import ( # noqa: F401 — re-exported for v7 package
|
"""Load radar_protocol.py by absolute path without mutating sys.path."""
|
||||||
FT2232HConnection,
|
mod_name = "radar_protocol"
|
||||||
RadarProtocol,
|
if mod_name in sys.modules:
|
||||||
Opcode,
|
return sys.modules[mod_name]
|
||||||
RadarAcquisition,
|
proto_path = pathlib.Path(__file__).resolve().parent.parent / "radar_protocol.py"
|
||||||
RadarFrame,
|
if not proto_path.is_file():
|
||||||
StatusResponse,
|
raise FileNotFoundError(
|
||||||
DataRecorder,
|
f"radar_protocol.py not found at expected location: {proto_path}"
|
||||||
)
|
)
|
||||||
|
spec = importlib.util.spec_from_file_location(mod_name, proto_path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError(
|
||||||
|
f"Cannot create module spec for radar_protocol.py at {proto_path}"
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
# Register before exec so cyclic imports resolve correctly, but remove on failure
|
||||||
|
sys.modules[mod_name] = mod
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
except Exception:
|
||||||
|
sys.modules.pop(mod_name, None)
|
||||||
|
raise
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
_rp = _load_radar_protocol()
|
||||||
|
|
||||||
|
# Re-exported for the v7 package — single source of truth for FPGA comms
|
||||||
|
FT2232HConnection = _rp.FT2232HConnection
|
||||||
|
ReplayConnection = _rp.ReplayConnection
|
||||||
|
RadarProtocol = _rp.RadarProtocol
|
||||||
|
Opcode = _rp.Opcode
|
||||||
|
RadarAcquisition = _rp.RadarAcquisition
|
||||||
|
RadarFrame = _rp.RadarFrame
|
||||||
|
StatusResponse = _rp.StatusResponse
|
||||||
|
DataRecorder = _rp.DataRecorder
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ 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, QUrl, pyqtSignal, pyqtSlot, QObject
|
from PyQt6.QtCore import Qt, 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
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ class MapBridge(QObject):
|
|||||||
|
|
||||||
@pyqtSlot(str)
|
@pyqtSlot(str)
|
||||||
def logFromJS(self, message: str):
|
def logFromJS(self, message: str):
|
||||||
logger.info(f"[JS] {message}")
|
logger.debug(f"[JS] {message}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_ready(self) -> bool:
|
def is_ready(self) -> bool:
|
||||||
@@ -518,20 +517,8 @@ document.addEventListener('DOMContentLoaded', function() {{
|
|||||||
# ---- load / helpers ----------------------------------------------------
|
# ---- load / helpers ----------------------------------------------------
|
||||||
|
|
||||||
def _load_map(self):
|
def _load_map(self):
|
||||||
# Enable remote resource access so Leaflet CDN scripts/tiles can load.
|
self._web_view.setHtml(self._get_map_html())
|
||||||
settings = self._web_view.page().settings()
|
logger.info("Leaflet map HTML loaded")
|
||||||
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")
|
||||||
@@ -591,10 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {{
|
|||||||
return
|
return
|
||||||
data = [t.to_dict() for t in targets]
|
data = [t.to_dict() for t in targets]
|
||||||
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
|
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
|
||||||
logger.info(
|
logger.debug("set_targets: %d targets", len(targets))
|
||||||
"set_targets: %d targets, JSON len=%d, first 200 chars: %s",
|
|
||||||
len(targets), len(js_payload), js_payload[:200],
|
|
||||||
)
|
|
||||||
self._status_label.setText(f"{len(targets)} targets tracked")
|
self._status_label.setText(f"{len(targets)} targets tracked")
|
||||||
self._run_js(f"updateTargets('{js_payload}')")
|
self._run_js(f"updateTargets('{js_payload}')")
|
||||||
|
|
||||||
|
|||||||
@@ -186,59 +186,3 @@ 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 ADI CN0566 Phaser capture parameters used in
|
|
||||||
the golden_reference cosim (4 MSPS, 500 MHz BW, 300 us chirp).
|
|
||||||
"""
|
|
||||||
|
|
||||||
sample_rate_hz: float = 4e6 # ADC sample rate
|
|
||||||
bandwidth_hz: float = 500e6 # Chirp bandwidth
|
|
||||||
chirp_duration_s: float = 300e-6 # Chirp ramp time
|
|
||||||
center_freq_hz: float = 10.525e9 # Carrier frequency
|
|
||||||
n_range_bins: int = 64 # After decimation
|
|
||||||
n_doppler_bins: int = 32 # After Doppler FFT
|
|
||||||
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 (FMCW deramped baseband).
|
|
||||||
|
|
||||||
For deramped FMCW: bin spacing = c * Fs * T / (2 * N_FFT * BW).
|
|
||||||
After decimation the bin spacing grows by *decimation_factor*.
|
|
||||||
"""
|
|
||||||
c = 299_792_458.0
|
|
||||||
raw_bin = (
|
|
||||||
c * self.sample_rate_hz * self.chirp_duration_s
|
|
||||||
/ (2.0 * self.fft_size * self.bandwidth_hz)
|
|
||||||
)
|
|
||||||
return raw_bin * self.decimation_factor
|
|
||||||
|
|
||||||
@property
|
|
||||||
def velocity_resolution_mps(self) -> float:
|
|
||||||
"""m/s per Doppler bin. lambda / (2 * n_doppler * chirp_duration)."""
|
|
||||||
c = 299_792_458.0
|
|
||||||
wavelength = c / self.center_freq_hz
|
|
||||||
return wavelength / (2.0 * self.n_doppler_bins * self.chirp_duration_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,103 +451,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,288 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"""
|
|
||||||
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,6 +13,7 @@ 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
|
||||||
@@ -35,25 +36,58 @@ 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, parses 0xAA/0xBB
|
Background worker that reads radar data from FT2232H (or ReplayConnection),
|
||||||
packets via production RadarAcquisition, runs optional host-side DSP,
|
parses 0xAA/0xBB packets via production RadarAcquisition, runs optional
|
||||||
and emits PyQt signals with results.
|
host-side DSP, and emits PyQt signals with results.
|
||||||
|
|
||||||
Uses production radar_protocol.py for all packet parsing and frame
|
This replaces the old V7 worker which used an incompatible packet format.
|
||||||
|
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
|
||||||
@@ -71,7 +105,7 @@ class RadarDataWorker(QThread):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
connection, # FT2232HConnection
|
connection, # FT2232HConnection or ReplayConnection
|
||||||
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,
|
||||||
@@ -97,6 +131,10 @@ class RadarDataWorker(QThread):
|
|||||||
self._byte_count = 0
|
self._byte_count = 0
|
||||||
self._error_count = 0
|
self._error_count = 0
|
||||||
|
|
||||||
|
# Monotonically increasing target ID — persisted across frames so map
|
||||||
|
# JS can key markers/trails by a stable ID.
|
||||||
|
self._next_target_id = 0
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._running = False
|
self._running = False
|
||||||
if self._acquisition:
|
if self._acquisition:
|
||||||
@@ -210,7 +248,7 @@ class RadarDataWorker(QThread):
|
|||||||
)
|
)
|
||||||
|
|
||||||
target = RadarTarget(
|
target = RadarTarget(
|
||||||
id=len(targets),
|
id=self._next_target_id,
|
||||||
range=range_m,
|
range=range_m,
|
||||||
velocity=velocity_ms,
|
velocity=velocity_ms,
|
||||||
azimuth=azimuth,
|
azimuth=azimuth,
|
||||||
@@ -220,6 +258,7 @@ class RadarDataWorker(QThread):
|
|||||||
snr=snr,
|
snr=snr,
|
||||||
timestamp=frame.timestamp,
|
timestamp=frame.timestamp,
|
||||||
)
|
)
|
||||||
|
self._next_target_id += 1
|
||||||
targets.append(target)
|
targets.append(target)
|
||||||
|
|
||||||
# DBSCAN clustering
|
# DBSCAN clustering
|
||||||
@@ -402,172 +441,3 @@ 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,
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ status_packet.txt
|
|||||||
*.vvp
|
*.vvp
|
||||||
|
|
||||||
# Compiled C stub
|
# Compiled C stub
|
||||||
stm32_stub
|
stm32_settings_stub
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
@@ -527,8 +527,6 @@ def parse_verilog_status_word_concats(
|
|||||||
):
|
):
|
||||||
idx = int(m.group(1))
|
idx = int(m.group(1))
|
||||||
expr = m.group(2)
|
expr = m.group(2)
|
||||||
# Strip single-line comments before normalizing whitespace
|
|
||||||
expr = re.sub(r'//[^\n]*', '', expr)
|
|
||||||
# Normalize whitespace
|
# Normalize whitespace
|
||||||
expr = re.sub(r'\s+', ' ', expr).strip()
|
expr = re.sub(r'\s+', ' ', expr).strip()
|
||||||
results[idx] = expr
|
results[idx] = expr
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ module tb_cross_layer_ft2232h;
|
|||||||
reg [4:0] status_self_test_flags;
|
reg [4:0] status_self_test_flags;
|
||||||
reg [7:0] status_self_test_detail;
|
reg [7:0] status_self_test_detail;
|
||||||
reg status_self_test_busy;
|
reg status_self_test_busy;
|
||||||
reg [3:0] status_agc_current_gain;
|
|
||||||
reg [7:0] status_agc_peak_magnitude;
|
|
||||||
reg [7:0] status_agc_saturation_count;
|
|
||||||
reg status_agc_enable;
|
|
||||||
|
|
||||||
// ---- Clock generators ----
|
// ---- Clock generators ----
|
||||||
always #(CLK_PERIOD / 2) clk = ~clk;
|
always #(CLK_PERIOD / 2) clk = ~clk;
|
||||||
@@ -134,11 +130,7 @@ module tb_cross_layer_ft2232h;
|
|||||||
.status_range_mode (status_range_mode),
|
.status_range_mode (status_range_mode),
|
||||||
.status_self_test_flags (status_self_test_flags),
|
.status_self_test_flags (status_self_test_flags),
|
||||||
.status_self_test_detail(status_self_test_detail),
|
.status_self_test_detail(status_self_test_detail),
|
||||||
.status_self_test_busy (status_self_test_busy),
|
.status_self_test_busy (status_self_test_busy)
|
||||||
.status_agc_current_gain (status_agc_current_gain),
|
|
||||||
.status_agc_peak_magnitude (status_agc_peak_magnitude),
|
|
||||||
.status_agc_saturation_count(status_agc_saturation_count),
|
|
||||||
.status_agc_enable (status_agc_enable)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Test bookkeeping ----
|
// ---- Test bookkeeping ----
|
||||||
@@ -196,10 +188,6 @@ module tb_cross_layer_ft2232h;
|
|||||||
status_self_test_flags = 5'b00000;
|
status_self_test_flags = 5'b00000;
|
||||||
status_self_test_detail = 8'd0;
|
status_self_test_detail = 8'd0;
|
||||||
status_self_test_busy = 1'b0;
|
status_self_test_busy = 1'b0;
|
||||||
status_agc_current_gain = 4'd0;
|
|
||||||
status_agc_peak_magnitude = 8'd0;
|
|
||||||
status_agc_saturation_count = 8'd0;
|
|
||||||
status_agc_enable = 1'b0;
|
|
||||||
repeat (6) @(posedge ft_clk);
|
repeat (6) @(posedge ft_clk);
|
||||||
reset_n = 1;
|
reset_n = 1;
|
||||||
ft_reset_n = 1;
|
ft_reset_n = 1;
|
||||||
@@ -504,37 +492,6 @@ module tb_cross_layer_ft2232h;
|
|||||||
check(cmd_opcode === 8'h27 && cmd_value === 16'h0003,
|
check(cmd_opcode === 8'h27 && cmd_value === 16'h0003,
|
||||||
"Cmd 0x27: DC_NOTCH_WIDTH=3");
|
"Cmd 0x27: DC_NOTCH_WIDTH=3");
|
||||||
|
|
||||||
// AGC registers (0x28-0x2C)
|
|
||||||
send_command_ft2232h(8'h28, 8'h00, 8'h00, 8'h01); // AGC_ENABLE=1
|
|
||||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
|
||||||
8'h28, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
|
|
||||||
check(cmd_opcode === 8'h28 && cmd_value === 16'h0001,
|
|
||||||
"Cmd 0x28: AGC_ENABLE=1");
|
|
||||||
|
|
||||||
send_command_ft2232h(8'h29, 8'h00, 8'h00, 8'hC8); // AGC_TARGET=200
|
|
||||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
|
||||||
8'h29, 8'h00, 16'h00C8, cmd_opcode, cmd_addr, cmd_value);
|
|
||||||
check(cmd_opcode === 8'h29 && cmd_value === 16'h00C8,
|
|
||||||
"Cmd 0x29: AGC_TARGET=200");
|
|
||||||
|
|
||||||
send_command_ft2232h(8'h2A, 8'h00, 8'h00, 8'h02); // AGC_ATTACK=2
|
|
||||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
|
||||||
8'h2A, 8'h00, 16'h0002, cmd_opcode, cmd_addr, cmd_value);
|
|
||||||
check(cmd_opcode === 8'h2A && cmd_value === 16'h0002,
|
|
||||||
"Cmd 0x2A: AGC_ATTACK=2");
|
|
||||||
|
|
||||||
send_command_ft2232h(8'h2B, 8'h00, 8'h00, 8'h03); // AGC_DECAY=3
|
|
||||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
|
||||||
8'h2B, 8'h00, 16'h0003, cmd_opcode, cmd_addr, cmd_value);
|
|
||||||
check(cmd_opcode === 8'h2B && cmd_value === 16'h0003,
|
|
||||||
"Cmd 0x2B: AGC_DECAY=3");
|
|
||||||
|
|
||||||
send_command_ft2232h(8'h2C, 8'h00, 8'h00, 8'h06); // AGC_HOLDOFF=6
|
|
||||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
|
||||||
8'h2C, 8'h00, 16'h0006, cmd_opcode, cmd_addr, cmd_value);
|
|
||||||
check(cmd_opcode === 8'h2C && cmd_value === 16'h0006,
|
|
||||||
"Cmd 0x2C: AGC_HOLDOFF=6");
|
|
||||||
|
|
||||||
// Self-test / status
|
// Self-test / status
|
||||||
send_command_ft2232h(8'h30, 8'h00, 8'h00, 8'h01); // SELF_TEST_TRIGGER
|
send_command_ft2232h(8'h30, 8'h00, 8'h00, 8'h01); // SELF_TEST_TRIGGER
|
||||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
||||||
@@ -648,10 +605,6 @@ module tb_cross_layer_ft2232h;
|
|||||||
status_self_test_flags = 5'b10101;
|
status_self_test_flags = 5'b10101;
|
||||||
status_self_test_detail = 8'hA5;
|
status_self_test_detail = 8'hA5;
|
||||||
status_self_test_busy = 1'b1;
|
status_self_test_busy = 1'b1;
|
||||||
status_agc_current_gain = 4'd7;
|
|
||||||
status_agc_peak_magnitude = 8'd200;
|
|
||||||
status_agc_saturation_count = 8'd15;
|
|
||||||
status_agc_enable = 1'b1;
|
|
||||||
|
|
||||||
// Pulse status_request and capture bytes IN PARALLEL
|
// Pulse status_request and capture bytes IN PARALLEL
|
||||||
// (same reason as Exercise B — write FSM starts before CDC wait ends)
|
// (same reason as Exercise B — write FSM starts before CDC wait ends)
|
||||||
|
|||||||
@@ -100,11 +100,6 @@ GROUND_TRUTH_OPCODES = {
|
|||||||
0x25: ("host_cfar_enable", 1),
|
0x25: ("host_cfar_enable", 1),
|
||||||
0x26: ("host_mti_enable", 1),
|
0x26: ("host_mti_enable", 1),
|
||||||
0x27: ("host_dc_notch_width", 3),
|
0x27: ("host_dc_notch_width", 3),
|
||||||
0x28: ("host_agc_enable", 1),
|
|
||||||
0x29: ("host_agc_target", 8),
|
|
||||||
0x2A: ("host_agc_attack", 4),
|
|
||||||
0x2B: ("host_agc_decay", 4),
|
|
||||||
0x2C: ("host_agc_holdoff", 4),
|
|
||||||
0x30: ("host_self_test_trigger", 1), # pulse
|
0x30: ("host_self_test_trigger", 1), # pulse
|
||||||
0x31: ("host_status_request", 1), # pulse
|
0x31: ("host_status_request", 1), # pulse
|
||||||
0xFF: ("host_status_request", 1), # alias, pulse
|
0xFF: ("host_status_request", 1), # alias, pulse
|
||||||
@@ -129,11 +124,6 @@ GROUND_TRUTH_RESET_DEFAULTS = {
|
|||||||
"host_cfar_enable": 0,
|
"host_cfar_enable": 0,
|
||||||
"host_mti_enable": 0,
|
"host_mti_enable": 0,
|
||||||
"host_dc_notch_width": 0,
|
"host_dc_notch_width": 0,
|
||||||
"host_agc_enable": 0,
|
|
||||||
"host_agc_target": 200,
|
|
||||||
"host_agc_attack": 1,
|
|
||||||
"host_agc_decay": 1,
|
|
||||||
"host_agc_holdoff": 4,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
GROUND_TRUTH_PACKET_CONSTANTS = {
|
GROUND_TRUTH_PACKET_CONSTANTS = {
|
||||||
@@ -614,10 +604,6 @@ class TestTier2VerilogCosim:
|
|||||||
# status_self_test_flags = 5'b10101 = 21
|
# status_self_test_flags = 5'b10101 = 21
|
||||||
# status_self_test_detail = 0xA5
|
# status_self_test_detail = 0xA5
|
||||||
# status_self_test_busy = 1
|
# status_self_test_busy = 1
|
||||||
# status_agc_current_gain = 7
|
|
||||||
# status_agc_peak_magnitude = 200
|
|
||||||
# status_agc_saturation_count = 15
|
|
||||||
# status_agc_enable = 1
|
|
||||||
|
|
||||||
# Words 1-5 should be correct (no truncation bug)
|
# Words 1-5 should be correct (no truncation bug)
|
||||||
assert sr.cfar_threshold == 0xABCD, f"cfar_threshold: 0x{sr.cfar_threshold:04X}"
|
assert sr.cfar_threshold == 0xABCD, f"cfar_threshold: 0x{sr.cfar_threshold:04X}"
|
||||||
@@ -632,12 +618,6 @@ class TestTier2VerilogCosim:
|
|||||||
assert sr.self_test_detail == 0xA5, f"self_test_detail: 0x{sr.self_test_detail:02X}"
|
assert sr.self_test_detail == 0xA5, f"self_test_detail: 0x{sr.self_test_detail:02X}"
|
||||||
assert sr.self_test_busy == 1, f"self_test_busy: {sr.self_test_busy}"
|
assert sr.self_test_busy == 1, f"self_test_busy: {sr.self_test_busy}"
|
||||||
|
|
||||||
# AGC fields (word 4)
|
|
||||||
assert sr.agc_current_gain == 7, f"agc_current_gain: {sr.agc_current_gain}"
|
|
||||||
assert sr.agc_peak_magnitude == 200, f"agc_peak_magnitude: {sr.agc_peak_magnitude}"
|
|
||||||
assert sr.agc_saturation_count == 15, f"agc_saturation_count: {sr.agc_saturation_count}"
|
|
||||||
assert sr.agc_enable == 1, f"agc_enable: {sr.agc_enable}"
|
|
||||||
|
|
||||||
# Word 0: stream_ctrl should be 5 (3'b101)
|
# Word 0: stream_ctrl should be 5 (3'b101)
|
||||||
assert sr.stream_ctrl == 5, (
|
assert sr.stream_ctrl == 5, (
|
||||||
f"stream_ctrl: {sr.stream_ctrl} != 5. "
|
f"stream_ctrl: {sr.stream_ctrl} != 5. "
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
"""
|
||||||
|
test_mem_validation.py — Validate FPGA .mem files against AERIS-10 radar parameters.
|
||||||
|
|
||||||
|
Migrated from tb/cosim/validate_mem_files.py into CI-friendly pytest tests.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. Structural: line counts, hex format, value ranges for all 12+ .mem files
|
||||||
|
2. FFT twiddle files: bit-exact match against cos(2*pi*k/N) in Q15
|
||||||
|
3. Long chirp .mem files: frequency sweep, magnitude envelope, segment count
|
||||||
|
4. Short chirp .mem files: length, value range, non-zero content
|
||||||
|
5. Chirp vs independent model: phase shape agreement
|
||||||
|
6. Latency buffer LATENCY=3187 parameter validation
|
||||||
|
7. Chirp memory loader addressing: {segment_select, sample_addr} arithmetic
|
||||||
|
8. Seg3 zero-padding analysis
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# AERIS-10 System Parameters (independently derived from hardware specs)
|
||||||
|
# ============================================================================
|
||||||
|
F_CARRIER = 10.5e9 # 10.5 GHz carrier
|
||||||
|
C_LIGHT = 3.0e8
|
||||||
|
F_IF = 120e6 # IF frequency
|
||||||
|
CHIRP_BW = 20e6 # 20 MHz sweep bandwidth
|
||||||
|
FS_ADC = 400e6 # ADC sample rate
|
||||||
|
FS_SYS = 100e6 # System clock (100 MHz, after CIC 4x decimation)
|
||||||
|
T_LONG_CHIRP = 30e-6 # 30 us long chirp
|
||||||
|
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
|
||||||
|
CIC_DECIMATION = 4
|
||||||
|
FFT_SIZE = 1024
|
||||||
|
DOPPLER_FFT_SIZE = 16
|
||||||
|
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000 at 100 MHz
|
||||||
|
|
||||||
|
# Overlap-save parameters
|
||||||
|
OVERLAP_SAMPLES = 128
|
||||||
|
SEGMENT_ADVANCE = FFT_SIZE - OVERLAP_SAMPLES # 896
|
||||||
|
LONG_SEGMENTS = 4
|
||||||
|
|
||||||
|
# Path to FPGA RTL directory containing .mem files
|
||||||
|
MEM_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..', '9_2_FPGA'))
|
||||||
|
|
||||||
|
# Expected .mem file inventory
|
||||||
|
EXPECTED_MEM_FILES = {
|
||||||
|
'fft_twiddle_1024.mem': {'lines': 256, 'desc': '1024-pt FFT quarter-wave cos ROM'},
|
||||||
|
'fft_twiddle_16.mem': {'lines': 4, 'desc': '16-pt FFT quarter-wave cos ROM'},
|
||||||
|
'long_chirp_seg0_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 I'},
|
||||||
|
'long_chirp_seg0_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 Q'},
|
||||||
|
'long_chirp_seg1_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 I'},
|
||||||
|
'long_chirp_seg1_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 Q'},
|
||||||
|
'long_chirp_seg2_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 I'},
|
||||||
|
'long_chirp_seg2_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 Q'},
|
||||||
|
'long_chirp_seg3_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 I'},
|
||||||
|
'long_chirp_seg3_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 Q'},
|
||||||
|
'short_chirp_i.mem': {'lines': 50, 'desc': 'Short chirp I'},
|
||||||
|
'short_chirp_q.mem': {'lines': 50, 'desc': 'Short chirp Q'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_mem_hex(filename: str) -> list[int]:
|
||||||
|
"""Read a .mem file, return list of integer values (16-bit signed)."""
|
||||||
|
path = os.path.join(MEM_DIR, filename)
|
||||||
|
values = []
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith('//'):
|
||||||
|
continue
|
||||||
|
val = int(line, 16)
|
||||||
|
if val >= 0x8000:
|
||||||
|
val -= 0x10000
|
||||||
|
values.append(val)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def compute_magnitudes(i_vals: list[int], q_vals: list[int]) -> list[float]:
|
||||||
|
"""Compute magnitude envelope from I/Q sample lists."""
|
||||||
|
return [math.sqrt(i * i + q * q) for i, q in zip(i_vals, q_vals, strict=False)]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_inst_freq(i_vals: list[int], q_vals: list[int],
|
||||||
|
fs: float, mag_thresh: float = 5.0) -> list[float]:
|
||||||
|
"""Compute instantaneous frequency from I/Q via phase differencing."""
|
||||||
|
phases = []
|
||||||
|
for i_val, q_val in zip(i_vals, q_vals, strict=False):
|
||||||
|
if abs(i_val) > mag_thresh or abs(q_val) > mag_thresh:
|
||||||
|
phases.append(math.atan2(q_val, i_val))
|
||||||
|
else:
|
||||||
|
phases.append(None)
|
||||||
|
|
||||||
|
freq_estimates = []
|
||||||
|
for n in range(1, len(phases)):
|
||||||
|
if phases[n] is not None and phases[n - 1] is not None:
|
||||||
|
dp = phases[n] - phases[n - 1]
|
||||||
|
while dp > math.pi:
|
||||||
|
dp -= 2 * math.pi
|
||||||
|
while dp < -math.pi:
|
||||||
|
dp += 2 * math.pi
|
||||||
|
freq_estimates.append(dp * fs / (2 * math.pi))
|
||||||
|
return freq_estimates
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 1: Structural validation — all .mem files exist with correct sizes
|
||||||
|
# ============================================================================
|
||||||
|
class TestStructural:
|
||||||
|
"""Verify every expected .mem file exists, has the right line count, and valid values."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
|
||||||
|
ids=EXPECTED_MEM_FILES.keys())
|
||||||
|
def test_file_exists(self, fname, info):
|
||||||
|
path = os.path.join(MEM_DIR, fname)
|
||||||
|
assert os.path.isfile(path), f"{fname} missing from {MEM_DIR}"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
|
||||||
|
ids=EXPECTED_MEM_FILES.keys())
|
||||||
|
def test_line_count(self, fname, info):
|
||||||
|
vals = read_mem_hex(fname)
|
||||||
|
assert len(vals) == info['lines'], (
|
||||||
|
f"{fname}: got {len(vals)} data lines, expected {info['lines']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
|
||||||
|
ids=EXPECTED_MEM_FILES.keys())
|
||||||
|
def test_value_range(self, fname, info):
|
||||||
|
vals = read_mem_hex(fname)
|
||||||
|
for i, v in enumerate(vals):
|
||||||
|
assert -32768 <= v <= 32767, (
|
||||||
|
f"{fname}[{i}]: value {v} out of 16-bit signed range"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 2: FFT Twiddle Factor Validation (bit-exact against cos formula)
|
||||||
|
# ============================================================================
|
||||||
|
class TestTwiddle:
|
||||||
|
"""Verify FFT twiddle .mem files match cos(2*pi*k/N) in Q15 to <=1 LSB."""
|
||||||
|
|
||||||
|
def test_twiddle_1024_bit_exact(self):
|
||||||
|
vals = read_mem_hex('fft_twiddle_1024.mem')
|
||||||
|
assert len(vals) == 256, f"Expected 256 quarter-wave entries, got {len(vals)}"
|
||||||
|
|
||||||
|
max_err = 0
|
||||||
|
worst_k = -1
|
||||||
|
for k in range(256):
|
||||||
|
angle = 2.0 * math.pi * k / 1024.0
|
||||||
|
expected = max(-32768, min(32767, round(math.cos(angle) * 32767.0)))
|
||||||
|
err = abs(vals[k] - expected)
|
||||||
|
if err > max_err:
|
||||||
|
max_err = err
|
||||||
|
worst_k = k
|
||||||
|
|
||||||
|
assert max_err <= 1, (
|
||||||
|
f"fft_twiddle_1024.mem: max error {max_err} LSB at k={worst_k} "
|
||||||
|
f"(got {vals[worst_k]}, expected "
|
||||||
|
f"{max(-32768, min(32767, round(math.cos(2*math.pi*worst_k/1024)*32767)))})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_twiddle_16_bit_exact(self):
|
||||||
|
vals = read_mem_hex('fft_twiddle_16.mem')
|
||||||
|
assert len(vals) == 4, f"Expected 4 quarter-wave entries, got {len(vals)}"
|
||||||
|
|
||||||
|
max_err = 0
|
||||||
|
for k in range(4):
|
||||||
|
angle = 2.0 * math.pi * k / 16.0
|
||||||
|
expected = max(-32768, min(32767, round(math.cos(angle) * 32767.0)))
|
||||||
|
err = abs(vals[k] - expected)
|
||||||
|
if err > max_err:
|
||||||
|
max_err = err
|
||||||
|
|
||||||
|
assert max_err <= 1, f"fft_twiddle_16.mem: max error {max_err} LSB (tolerance: 1)"
|
||||||
|
|
||||||
|
def test_twiddle_1024_known_values(self):
|
||||||
|
"""Spot-check specific twiddle values against hand-calculated results."""
|
||||||
|
vals = read_mem_hex('fft_twiddle_1024.mem')
|
||||||
|
# k=0: cos(0) = 1.0 -> 32767
|
||||||
|
assert vals[0] == 32767, f"k=0: expected 32767, got {vals[0]}"
|
||||||
|
# k=128: cos(pi/4) = sqrt(2)/2 -> round(32767 * 0.7071) = 23170
|
||||||
|
expected_128 = round(math.cos(2 * math.pi * 128 / 1024) * 32767)
|
||||||
|
assert abs(vals[128] - expected_128) <= 1, (
|
||||||
|
f"k=128: expected ~{expected_128}, got {vals[128]}"
|
||||||
|
)
|
||||||
|
# k=255: last entry in quarter-wave table
|
||||||
|
expected_255 = round(math.cos(2 * math.pi * 255 / 1024) * 32767)
|
||||||
|
assert abs(vals[255] - expected_255) <= 1, (
|
||||||
|
f"k=255: expected ~{expected_255}, got {vals[255]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 3: Long Chirp .mem File Analysis
|
||||||
|
# ============================================================================
|
||||||
|
class TestLongChirp:
|
||||||
|
"""Validate long chirp .mem files show correct chirp characteristics."""
|
||||||
|
|
||||||
|
def test_total_sample_count(self):
|
||||||
|
"""4 segments x 1024 samples = 4096 total."""
|
||||||
|
all_i, all_q = [], []
|
||||||
|
for seg in range(4):
|
||||||
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
||||||
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
||||||
|
assert len(all_i) == 4096, f"Total I samples: {len(all_i)}, expected 4096"
|
||||||
|
assert len(all_q) == 4096, f"Total Q samples: {len(all_q)}, expected 4096"
|
||||||
|
|
||||||
|
def test_nonzero_magnitude(self):
|
||||||
|
"""Chirp should have significant non-zero content."""
|
||||||
|
all_i, all_q = [], []
|
||||||
|
for seg in range(4):
|
||||||
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
||||||
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
||||||
|
mags = compute_magnitudes(all_i, all_q)
|
||||||
|
max_mag = max(mags)
|
||||||
|
# Should use substantial dynamic range (at least 1000 out of 32767)
|
||||||
|
assert max_mag > 1000, f"Max magnitude {max_mag:.0f} is suspiciously low"
|
||||||
|
|
||||||
|
def test_frequency_sweep(self):
|
||||||
|
"""Chirp should show at least 0.5 MHz frequency sweep."""
|
||||||
|
all_i, all_q = [], []
|
||||||
|
for seg in range(4):
|
||||||
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
||||||
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
||||||
|
|
||||||
|
freq_est = compute_inst_freq(all_i, all_q, FS_SYS)
|
||||||
|
assert len(freq_est) > 100, "Not enough valid phase samples for frequency analysis"
|
||||||
|
|
||||||
|
f_range = max(freq_est) - min(freq_est)
|
||||||
|
assert f_range > 0.5e6, (
|
||||||
|
f"Frequency sweep {f_range / 1e6:.2f} MHz is too narrow "
|
||||||
|
f"(expected > 0.5 MHz for a chirp)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bandwidth_reasonable(self):
|
||||||
|
"""Chirp bandwidth should be within 50% of expected 20 MHz."""
|
||||||
|
all_i, all_q = [], []
|
||||||
|
for seg in range(4):
|
||||||
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
||||||
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
||||||
|
|
||||||
|
freq_est = compute_inst_freq(all_i, all_q, FS_SYS)
|
||||||
|
if not freq_est:
|
||||||
|
pytest.skip("No valid frequency estimates")
|
||||||
|
|
||||||
|
f_range = max(freq_est) - min(freq_est)
|
||||||
|
bw_error = abs(f_range - CHIRP_BW) / CHIRP_BW
|
||||||
|
if bw_error >= 0.5:
|
||||||
|
warnings.warn(
|
||||||
|
f"Bandwidth {f_range / 1e6:.2f} MHz differs from expected "
|
||||||
|
f"{CHIRP_BW / 1e6:.2f} MHz by {bw_error:.0%}",
|
||||||
|
stacklevel=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 4: Short Chirp .mem File Analysis
|
||||||
|
# ============================================================================
|
||||||
|
class TestShortChirp:
|
||||||
|
"""Validate short chirp .mem files."""
|
||||||
|
|
||||||
|
def test_sample_count_matches_duration(self):
|
||||||
|
"""0.5 us at 100 MHz = 50 samples."""
|
||||||
|
short_i = read_mem_hex('short_chirp_i.mem')
|
||||||
|
short_q = read_mem_hex('short_chirp_q.mem')
|
||||||
|
expected = int(T_SHORT_CHIRP * FS_SYS)
|
||||||
|
assert len(short_i) == expected, f"Short chirp I: {len(short_i)} != {expected}"
|
||||||
|
assert len(short_q) == expected, f"Short chirp Q: {len(short_q)} != {expected}"
|
||||||
|
|
||||||
|
def test_all_samples_nonzero(self):
|
||||||
|
"""Every sample in the short chirp should have non-trivial magnitude."""
|
||||||
|
short_i = read_mem_hex('short_chirp_i.mem')
|
||||||
|
short_q = read_mem_hex('short_chirp_q.mem')
|
||||||
|
mags = compute_magnitudes(short_i, short_q)
|
||||||
|
nonzero = sum(1 for m in mags if m > 1)
|
||||||
|
assert nonzero == len(short_i), (
|
||||||
|
f"Only {nonzero}/{len(short_i)} samples are non-zero"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 5: Chirp vs Independent Model (phase shape agreement)
|
||||||
|
# ============================================================================
|
||||||
|
class TestChirpVsModel:
|
||||||
|
"""Compare seg0 against independently generated chirp reference."""
|
||||||
|
|
||||||
|
def test_phase_shape_match(self):
|
||||||
|
"""Phase trajectory of .mem seg0 should match model within 0.5 rad."""
|
||||||
|
# Generate reference chirp independently from first principles
|
||||||
|
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
|
||||||
|
n_samples = FFT_SIZE # 1024
|
||||||
|
|
||||||
|
model_i, model_q = [], []
|
||||||
|
for n in range(n_samples):
|
||||||
|
t = n / FS_SYS
|
||||||
|
phase = math.pi * chirp_rate * t * t
|
||||||
|
re_val = max(-32768, min(32767, round(32767 * 0.9 * math.cos(phase))))
|
||||||
|
im_val = max(-32768, min(32767, round(32767 * 0.9 * math.sin(phase))))
|
||||||
|
model_i.append(re_val)
|
||||||
|
model_q.append(im_val)
|
||||||
|
|
||||||
|
# Read seg0 from .mem
|
||||||
|
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
|
||||||
|
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
|
||||||
|
|
||||||
|
# Compare phase trajectories (shape match regardless of scaling)
|
||||||
|
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
|
||||||
|
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
|
||||||
|
|
||||||
|
phase_diffs = []
|
||||||
|
for mp, fp in zip(model_phases, mem_phases, strict=False):
|
||||||
|
d = mp - fp
|
||||||
|
while d > math.pi:
|
||||||
|
d -= 2 * math.pi
|
||||||
|
while d < -math.pi:
|
||||||
|
d += 2 * math.pi
|
||||||
|
phase_diffs.append(d)
|
||||||
|
|
||||||
|
max_phase_diff = max(abs(d) for d in phase_diffs)
|
||||||
|
assert max_phase_diff < 0.5, (
|
||||||
|
f"Max phase difference {math.degrees(max_phase_diff):.1f} deg "
|
||||||
|
f"exceeds 28.6 deg tolerance"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_magnitude_scaling(self):
|
||||||
|
"""Seg0 magnitude should be consistent with Q15 * 0.9 scaling."""
|
||||||
|
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
|
||||||
|
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
|
||||||
|
mags = compute_magnitudes(mem_i, mem_q)
|
||||||
|
max_mag = max(mags)
|
||||||
|
|
||||||
|
# Expected from 32767 * 0.9 scaling = ~29490
|
||||||
|
expected_max = 32767 * 0.9
|
||||||
|
# Should be at least 80% of expected (allows for different provenance)
|
||||||
|
if max_mag < expected_max * 0.8:
|
||||||
|
warnings.warn(
|
||||||
|
f"Seg0 max magnitude {max_mag:.0f} is below expected "
|
||||||
|
f"{expected_max:.0f} * 0.8 = {expected_max * 0.8:.0f}. "
|
||||||
|
f"The .mem files may have different provenance.",
|
||||||
|
stacklevel=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 6: Latency Buffer LATENCY=3187 Validation
|
||||||
|
# ============================================================================
|
||||||
|
class TestLatencyBuffer:
|
||||||
|
"""Validate latency buffer parameter constraints."""
|
||||||
|
|
||||||
|
LATENCY = 3187
|
||||||
|
BRAM_SIZE = 4096
|
||||||
|
|
||||||
|
def test_latency_within_bram(self):
|
||||||
|
assert self.LATENCY < self.BRAM_SIZE, (
|
||||||
|
f"LATENCY ({self.LATENCY}) must be < BRAM size ({self.BRAM_SIZE})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_latency_in_reasonable_range(self):
|
||||||
|
"""LATENCY should be between 1000 and 4095 (empirically determined)."""
|
||||||
|
assert 1000 < self.LATENCY < 4095, (
|
||||||
|
f"LATENCY={self.LATENCY} outside reasonable range [1000, 4095]"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_read_ptr_no_overflow(self):
|
||||||
|
"""Address arithmetic for read_ptr after initial wrap must stay valid."""
|
||||||
|
min_read_ptr = self.BRAM_SIZE + 0 - self.LATENCY
|
||||||
|
assert 0 <= min_read_ptr < self.BRAM_SIZE, (
|
||||||
|
f"min_read_ptr after wrap = {min_read_ptr}, must be in [0, {self.BRAM_SIZE})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 7: Chirp Memory Loader Addressing
|
||||||
|
# ============================================================================
|
||||||
|
class TestMemoryAddressing:
|
||||||
|
"""Validate {segment_select[1:0], sample_addr[9:0]} address mapping."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("seg", range(4), ids=[f"seg{s}" for s in range(4)])
|
||||||
|
def test_segment_base_address(self, seg):
|
||||||
|
"""Concatenated address {seg, 10'b0} should equal seg * 1024."""
|
||||||
|
addr = (seg << 10) | 0
|
||||||
|
expected = seg * 1024
|
||||||
|
assert addr == expected, (
|
||||||
|
f"Seg {seg}: {{seg[1:0], 10'b0}} = {addr}, expected {expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("seg", range(4), ids=[f"seg{s}" for s in range(4)])
|
||||||
|
def test_segment_end_address(self, seg):
|
||||||
|
"""Concatenated address {seg, 10'h3FF} should equal seg * 1024 + 1023."""
|
||||||
|
addr = (seg << 10) | 1023
|
||||||
|
expected = seg * 1024 + 1023
|
||||||
|
assert addr == expected, (
|
||||||
|
f"Seg {seg}: {{seg[1:0], 10'h3FF}} = {addr}, expected {expected}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_address_space(self):
|
||||||
|
"""4 segments x 1024 = 4096 addresses, covering full 12-bit range."""
|
||||||
|
all_addrs = set()
|
||||||
|
for seg in range(4):
|
||||||
|
for sample in range(1024):
|
||||||
|
all_addrs.add((seg << 10) | sample)
|
||||||
|
assert len(all_addrs) == 4096
|
||||||
|
assert min(all_addrs) == 0
|
||||||
|
assert max(all_addrs) == 4095
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# TEST 8: Seg3 Zero-Padding Analysis
|
||||||
|
# ============================================================================
|
||||||
|
class TestSeg3Padding:
|
||||||
|
"""Analyze seg3 content — chirp is 3000 samples but 4 segs x 1024 = 4096 slots."""
|
||||||
|
|
||||||
|
def test_seg3_content_analysis(self):
|
||||||
|
"""Seg3 should either be full (4096-sample chirp) or have trailing zeros."""
|
||||||
|
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
|
||||||
|
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
|
||||||
|
mags = compute_magnitudes(seg3_i, seg3_q)
|
||||||
|
|
||||||
|
# Count trailing zeros
|
||||||
|
trailing_zeros = 0
|
||||||
|
for m in reversed(mags):
|
||||||
|
if m < 2:
|
||||||
|
trailing_zeros += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
nonzero = sum(1 for m in mags if m > 2)
|
||||||
|
|
||||||
|
if nonzero == 1024:
|
||||||
|
# .mem files encode 4096 chirp samples, not 3000
|
||||||
|
# This means the chirp duration used for .mem generation differs
|
||||||
|
actual_samples = 4 * 1024
|
||||||
|
actual_us = actual_samples / FS_SYS * 1e6
|
||||||
|
warnings.warn(
|
||||||
|
f"Chirp in .mem files is {actual_samples} samples ({actual_us:.1f} us), "
|
||||||
|
f"not {LONG_CHIRP_SAMPLES} samples ({T_LONG_CHIRP * 1e6:.1f} us). "
|
||||||
|
f"The .mem files use a different chirp duration than the system parameter.",
|
||||||
|
stacklevel=1,
|
||||||
|
)
|
||||||
|
elif trailing_zeros > 100:
|
||||||
|
# Some zero-padding at end — chirp ends partway through seg3
|
||||||
|
effective_chirp_end = 3072 + (1024 - trailing_zeros)
|
||||||
|
assert effective_chirp_end <= 4096, "Chirp end calculation overflow"
|
||||||
@@ -39,6 +39,7 @@ try:
|
|||||||
import serial
|
import serial
|
||||||
import serial.tools.list_ports
|
import serial.tools.list_ports
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
print("ERROR: pyserial not installed. Run: pip install pyserial", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -94,9 +95,12 @@ def list_ports():
|
|||||||
"""Print available serial ports."""
|
"""Print available serial ports."""
|
||||||
ports = serial.tools.list_ports.comports()
|
ports = serial.tools.list_ports.comports()
|
||||||
if not ports:
|
if not ports:
|
||||||
|
print("No serial ports found.")
|
||||||
return
|
return
|
||||||
for _p in sorted(ports, key=lambda x: x.device):
|
print(f"{'Port':<30} {'Description':<40} {'HWID'}")
|
||||||
pass
|
print("-" * 100)
|
||||||
|
for p in sorted(ports, key=lambda x: x.device):
|
||||||
|
print(f"{p.device:<30} {p.description:<40} {p.hwid}")
|
||||||
|
|
||||||
|
|
||||||
def auto_detect_port():
|
def auto_detect_port():
|
||||||
@@ -224,7 +228,7 @@ class CaptureStats:
|
|||||||
# Main capture loop
|
# Main capture loop
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def capture(port, baud, log_file, filter_subsys, errors_only, _use_color):
|
def capture(port, baud, log_file, filter_subsys, errors_only, use_color):
|
||||||
"""Open serial port and capture DIAG output."""
|
"""Open serial port and capture DIAG output."""
|
||||||
stats = CaptureStats()
|
stats = CaptureStats()
|
||||||
running = True
|
running = True
|
||||||
@@ -245,15 +249,18 @@ def capture(port, baud, log_file, filter_subsys, errors_only, _use_color):
|
|||||||
stopbits=serial.STOPBITS_ONE,
|
stopbits=serial.STOPBITS_ONE,
|
||||||
timeout=0.1, # 100ms read timeout for responsive Ctrl-C
|
timeout=0.1, # 100ms read timeout for responsive Ctrl-C
|
||||||
)
|
)
|
||||||
except serial.SerialException:
|
except serial.SerialException as e:
|
||||||
|
print(f"ERROR: Could not open {port}: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f"Connected to {port} at {baud} baud")
|
||||||
if log_file:
|
if log_file:
|
||||||
pass
|
print(f"Logging to {log_file}")
|
||||||
if filter_subsys:
|
if filter_subsys:
|
||||||
pass
|
print(f"Filter: {', '.join(sorted(filter_subsys))}")
|
||||||
if errors_only:
|
if errors_only:
|
||||||
pass
|
print("Mode: errors/warnings only")
|
||||||
|
print("Press Ctrl-C to stop.\n")
|
||||||
|
|
||||||
if log_file:
|
if log_file:
|
||||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||||
@@ -300,13 +307,15 @@ def capture(port, baud, log_file, filter_subsys, errors_only, _use_color):
|
|||||||
|
|
||||||
# Terminal display respects filters
|
# Terminal display respects filters
|
||||||
if should_display(line, filter_subsys, errors_only):
|
if should_display(line, filter_subsys, errors_only):
|
||||||
pass
|
sys.stdout.write(colorize(line, use_color) + "\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
if flog:
|
if flog:
|
||||||
flog.write(f"\n{stats.summary()}\n")
|
flog.write(f"\n{stats.summary()}\n")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
ser.close()
|
ser.close()
|
||||||
|
print(stats.summary())
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -369,6 +378,10 @@ def main():
|
|||||||
if not port:
|
if not port:
|
||||||
port = auto_detect_port()
|
port = auto_detect_port()
|
||||||
if not port:
|
if not port:
|
||||||
|
print(
|
||||||
|
"ERROR: No serial port detected. Use -p to specify, or --list to see ports.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Resolve log file
|
# Resolve log file
|
||||||
|
|||||||
+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_GUI_V65_Tk.py -v
|
python3 -m pytest test_radar_dashboard.py -v
|
||||||
# or without pytest:
|
# or without pytest:
|
||||||
python3 -m unittest test_GUI_V65_Tk -v
|
python3 -m unittest test_radar_dashboard -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_GUI_V65_Tk -v` — all pass
|
3. `python3 -m unittest test_radar_dashboard -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
|
||||||
|
|||||||
+6
-2
@@ -46,6 +46,10 @@ select = [
|
|||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[tool.ruff.lint.per-file-ignores]
|
||||||
# Tests: allow unused args (fixtures), prints (debugging), commented code (examples)
|
# Tests: allow unused args (fixtures), prints (debugging), commented code (examples)
|
||||||
"test_*.py" = ["ARG", "T20", "ERA"]
|
"**/test_*.py" = ["ARG", "T20", "ERA"]
|
||||||
# Re-export modules: unused imports are intentional
|
# Re-export modules: unused imports are intentional
|
||||||
"v7/hardware.py" = ["F401"]
|
"**/v7/hardware.py" = ["F401"]
|
||||||
|
# CLI tools & cosim scripts: print() is the intentional output mechanism
|
||||||
|
"**/uart_capture.py" = ["T20"]
|
||||||
|
"**/tb/cosim/**" = ["T20", "ERA", "ARG", "E501"]
|
||||||
|
"**/tb/gen_mf_golden_ref.py" = ["T20", "ERA"]
|
||||||
|
|||||||
Reference in New Issue
Block a user