mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-04-19 11:36:01 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 063fa081fe | |||
| b4d1869582 | |||
| 88ce0819a8 | |||
| 3ef6416e3f | |||
| 666527fa7d |
@@ -46,7 +46,9 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: >
|
||||
uv run pytest
|
||||
9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short
|
||||
9_Firmware/9_3_GUI/test_radar_dashboard.py
|
||||
9_Firmware/9_3_GUI/test_v7.py
|
||||
-v --tb=short
|
||||
|
||||
# ===========================================================================
|
||||
# MCU Firmware Unit Tests (20 tests)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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
|
||||
@@ -43,6 +43,11 @@ void USBHandler::processStartFlag(const uint8_t* data, uint32_t length) {
|
||||
// Start flag: bytes [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
|
||||
for (uint32_t i = 0; i <= length - 4; i++) {
|
||||
if (memcmp(data + i, START_FLAG, 4) == 0) {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "usbd_cdc_if.h"
|
||||
#include "adar1000.h"
|
||||
#include "ADAR1000_Manager.h"
|
||||
#include "ADAR1000_AGC.h"
|
||||
extern "C" {
|
||||
#include "ad9523.h"
|
||||
}
|
||||
@@ -224,6 +225,7 @@ extern SPI_HandleTypeDef hspi4;
|
||||
//ADAR1000
|
||||
|
||||
ADAR1000Manager adarManager;
|
||||
ADAR1000_AGC outerAgc;
|
||||
static uint8_t matrix1[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};
|
||||
@@ -639,6 +641,7 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) {
|
||||
current_error = ERROR_AD9523_CLOCK;
|
||||
DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1);
|
||||
return current_error;
|
||||
}
|
||||
last_clock_check = HAL_GetTick();
|
||||
}
|
||||
@@ -649,10 +652,12 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (!tx_locked) {
|
||||
current_error = ERROR_ADF4382_TX_UNLOCK;
|
||||
DIAG_ERR("LO", "Health check: TX LO UNLOCKED");
|
||||
return current_error;
|
||||
}
|
||||
if (!rx_locked) {
|
||||
current_error = ERROR_ADF4382_RX_UNLOCK;
|
||||
DIAG_ERR("LO", "Health check: RX LO UNLOCKED");
|
||||
return current_error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,14 +666,14 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (!adarManager.verifyDeviceCommunication(i)) {
|
||||
current_error = ERROR_ADAR1000_COMM;
|
||||
DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i);
|
||||
break;
|
||||
return current_error;
|
||||
}
|
||||
|
||||
float temp = adarManager.readTemperature(i);
|
||||
if (temp > 85.0f) {
|
||||
current_error = ERROR_ADAR1000_TEMP;
|
||||
DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp);
|
||||
break;
|
||||
return current_error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,6 +683,7 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (!GY85_Update(&imu)) {
|
||||
current_error = ERROR_IMU_COMM;
|
||||
DIAG_ERR("IMU", "Health check: GY85_Update() FAILED");
|
||||
return current_error;
|
||||
}
|
||||
last_imu_check = HAL_GetTick();
|
||||
}
|
||||
@@ -689,6 +695,7 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) {
|
||||
current_error = ERROR_BMP180_COMM;
|
||||
DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure);
|
||||
return current_error;
|
||||
}
|
||||
last_bmp_check = HAL_GetTick();
|
||||
}
|
||||
@@ -701,6 +708,7 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (HAL_GetTick() - last_gps_fix > 30000) {
|
||||
current_error = ERROR_GPS_COMM;
|
||||
DIAG_WARN("SYS", "Health check: GPS no fix for >30s");
|
||||
return current_error;
|
||||
}
|
||||
|
||||
// 7. Check RF Power Amplifier Current
|
||||
@@ -709,12 +717,12 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (Idq_reading[i] > 2.5f) {
|
||||
current_error = ERROR_RF_PA_OVERCURRENT;
|
||||
DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]);
|
||||
break;
|
||||
return current_error;
|
||||
}
|
||||
if (Idq_reading[i] < 0.1f) {
|
||||
current_error = ERROR_RF_PA_BIAS;
|
||||
DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]);
|
||||
break;
|
||||
return current_error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -723,6 +731,7 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (temperature > 75.0f) {
|
||||
current_error = ERROR_TEMPERATURE_HIGH;
|
||||
DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature);
|
||||
return current_error;
|
||||
}
|
||||
|
||||
// 9. Simple watchdog check
|
||||
@@ -730,6 +739,7 @@ SystemError_t checkSystemHealth(void) {
|
||||
if (HAL_GetTick() - last_health_check > 60000) {
|
||||
current_error = ERROR_WATCHDOG_TIMEOUT;
|
||||
DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)");
|
||||
return current_error;
|
||||
}
|
||||
last_health_check = HAL_GetTick();
|
||||
|
||||
@@ -919,38 +929,41 @@ bool checkSystemHealthStatus(void) {
|
||||
// Get system status for GUI
|
||||
// Get system status for GUI with 8 temperature variables
|
||||
void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
||||
char temp_buffer[200];
|
||||
char final_status[500] = "System Status: ";
|
||||
// Build status string directly in the output buffer using offset-tracked
|
||||
// snprintf. Each call returns the number of chars written (excluding NUL),
|
||||
// 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
|
||||
if (system_emergency_state) {
|
||||
strcat(final_status, "EMERGENCY_STOP|");
|
||||
w = snprintf(status_buffer + off, rem, "System Status: EMERGENCY_STOP|");
|
||||
} else {
|
||||
strcat(final_status, "NORMAL|");
|
||||
w = snprintf(status_buffer + off, rem, "System Status: NORMAL|");
|
||||
}
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
|
||||
// Error information
|
||||
snprintf(temp_buffer, sizeof(temp_buffer), "LastError:%d|ErrorCount:%lu|",
|
||||
last_error, error_count);
|
||||
strcat(final_status, temp_buffer);
|
||||
w = snprintf(status_buffer + off, rem, "LastError:%d|ErrorCount:%lu|",
|
||||
last_error, error_count);
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
|
||||
// Sensor status
|
||||
snprintf(temp_buffer, sizeof(temp_buffer), "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|",
|
||||
Pitch_Sensor, Roll_Sensor, Yaw_Sensor,
|
||||
RADAR_Latitude, RADAR_Longitude, RADAR_Altitude);
|
||||
strcat(final_status, temp_buffer);
|
||||
w = snprintf(status_buffer + off, rem, "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|",
|
||||
Pitch_Sensor, Roll_Sensor, Yaw_Sensor,
|
||||
RADAR_Latitude, RADAR_Longitude, RADAR_Altitude);
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
|
||||
// LO Status
|
||||
bool tx_locked, rx_locked;
|
||||
ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked);
|
||||
snprintf(temp_buffer, sizeof(temp_buffer), "LO_TX:%s|LO_RX:%s|",
|
||||
tx_locked ? "LOCKED" : "UNLOCKED",
|
||||
rx_locked ? "LOCKED" : "UNLOCKED");
|
||||
strcat(final_status, temp_buffer);
|
||||
w = snprintf(status_buffer + off, rem, "LO_TX:%s|LO_RX:%s|",
|
||||
tx_locked ? "LOCKED" : "UNLOCKED",
|
||||
rx_locked ? "LOCKED" : "UNLOCKED");
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
|
||||
// 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_2 = ADS7830_Measure_SingleEnded(&hadc3, 1);
|
||||
Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2);
|
||||
@@ -961,11 +974,11 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
||||
Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7);
|
||||
|
||||
// Format all 8 temperature variables
|
||||
snprintf(temp_buffer, sizeof(temp_buffer),
|
||||
"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_5, Temperature_6, Temperature_7, Temperature_8);
|
||||
strcat(final_status, temp_buffer);
|
||||
w = snprintf(status_buffer + off, rem,
|
||||
"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_5, Temperature_6, Temperature_7, Temperature_8);
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
|
||||
// RF Power Amplifier status (if enabled)
|
||||
if (PowerAmplifier) {
|
||||
@@ -975,18 +988,17 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
|
||||
}
|
||||
avg_current /= 16.0f;
|
||||
|
||||
snprintf(temp_buffer, sizeof(temp_buffer), "PA_AvgCurrent:%.2f|PA_Enabled:%d|",
|
||||
avg_current, PowerAmplifier);
|
||||
strcat(final_status, temp_buffer);
|
||||
w = snprintf(status_buffer + off, rem, "PA_AvgCurrent:%.2f|PA_Enabled:%d|",
|
||||
avg_current, PowerAmplifier);
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
}
|
||||
|
||||
// Radar operation status
|
||||
snprintf(temp_buffer, sizeof(temp_buffer), "BeamPos:%d|Azimuth:%d|ChirpCount:%d|",
|
||||
n, y, m);
|
||||
strcat(final_status, temp_buffer);
|
||||
w = snprintf(status_buffer + off, rem, "BeamPos:%d|Azimuth:%d|ChirpCount:%d|",
|
||||
n, y, m);
|
||||
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
|
||||
|
||||
// Copy to output buffer
|
||||
strncpy(status_buffer, final_status, buffer_size - 1);
|
||||
// NUL termination guaranteed by snprintf, but be safe
|
||||
status_buffer[buffer_size - 1] = '\0';
|
||||
}
|
||||
|
||||
@@ -1995,12 +2007,13 @@ int main(void)
|
||||
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");
|
||||
|
||||
// Blink all LEDs to indicate safe mode
|
||||
// Blink all LEDs to indicate safe mode (500ms period, visible to operator)
|
||||
while (system_emergency_state) {
|
||||
HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_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_4_GPIO_Port, LED_4_Pin);
|
||||
HAL_Delay(250);
|
||||
}
|
||||
DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared");
|
||||
}
|
||||
@@ -2114,6 +2127,16 @@ int main(void)
|
||||
|
||||
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
|
||||
* ~4 s, the IWDG resets the MCU automatically. */
|
||||
HAL_IWDG_Refresh(&hiwdg);
|
||||
|
||||
@@ -141,6 +141,15 @@ void Error_Handler(void);
|
||||
#define EN_DIS_RFPA_VDD_GPIO_Port GPIOD
|
||||
#define EN_DIS_COOLING_Pin GPIO_PIN_7
|
||||
#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_GPIO_Port GPIOG
|
||||
#define ADF4382_RX_CS_Pin GPIO_PIN_10
|
||||
|
||||
@@ -16,10 +16,17 @@
|
||||
################################################################################
|
||||
|
||||
CC := cc
|
||||
CXX := c++
|
||||
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
|
||||
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
|
||||
|
||||
# Real source files compiled against mock headers
|
||||
REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c
|
||||
|
||||
@@ -62,7 +69,10 @@ TESTS_STANDALONE := test_bug12_pa_cal_loop_inverted \
|
||||
# Tests that need platform_noos_stm32.o + mocks
|
||||
TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only
|
||||
|
||||
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM)
|
||||
# C++ tests (AGC outer loop)
|
||||
TESTS_WITH_CXX := test_agc_outer_loop
|
||||
|
||||
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) $(TESTS_WITH_CXX)
|
||||
|
||||
.PHONY: all build test clean \
|
||||
$(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \
|
||||
@@ -156,6 +166,24 @@ test_gap3_emergency_state_ordering: test_gap3_emergency_state_ordering.c
|
||||
$(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ)
|
||||
$(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
|
||||
|
||||
# --- Individual test targets ---
|
||||
|
||||
test_bug1: test_bug1_timed_sync_init_ordering
|
||||
|
||||
@@ -129,6 +129,14 @@ void Error_Handler(void);
|
||||
#define GYR_INT_Pin GPIO_PIN_8
|
||||
#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
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -175,7 +175,7 @@ void HAL_Delay(uint32_t Delay)
|
||||
mock_tick += Delay;
|
||||
}
|
||||
|
||||
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData,
|
||||
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData,
|
||||
uint16_t Size, uint32_t Timeout)
|
||||
{
|
||||
spy_push((SpyRecord){
|
||||
|
||||
@@ -34,6 +34,10 @@ typedef uint32_t HAL_StatusTypeDef;
|
||||
|
||||
#define HAL_MAX_DELAY 0xFFFFFFFFU
|
||||
|
||||
#ifndef __NOP
|
||||
#define __NOP() ((void)0)
|
||||
#endif
|
||||
|
||||
/* ========================= GPIO Types ============================ */
|
||||
|
||||
typedef struct {
|
||||
@@ -182,7 +186,7 @@ GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
|
||||
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
|
||||
uint32_t HAL_GetTick(void);
|
||||
void HAL_Delay(uint32_t Delay);
|
||||
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
|
||||
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout);
|
||||
|
||||
/* ========================= SPI stubs ============================== */
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -212,6 +212,11 @@ BUFG bufg_feedback (
|
||||
|
||||
// ---- Output BUFG ----
|
||||
// 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 (
|
||||
.I(clk_mmcm_out0),
|
||||
.O(clk_400m_out)
|
||||
|
||||
@@ -85,10 +85,11 @@ set_false_path -through [get_pins rx_inst/adc/mmcm_inst/mmcm_adc_400m/LOCKED]
|
||||
set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Timing margin for 400 MHz CIC critical path
|
||||
# Timing margin for 400 MHz critical paths
|
||||
# --------------------------------------------------------------------------
|
||||
# The CIC decimator at 400 MHz has near-zero margin (WNS = +0.001 ns in
|
||||
# Build 26). Adding 200 ps of extra setup uncertainty forces Vivado to
|
||||
# leave comfortable margin for temperature/voltage/aging variation.
|
||||
# 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.200 [get_clocks clk_mmcm_out0]
|
||||
set_clock_uncertainty -setup -add 0.100 [get_clocks clk_mmcm_out0]
|
||||
|
||||
@@ -102,14 +102,19 @@ wire signed [17:0] debug_mixed_q_trunc;
|
||||
reg [7:0] signal_power_i, signal_power_q;
|
||||
|
||||
// Internal mixing signals
|
||||
// DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 handles all internal pipelining
|
||||
// Latency: 4 cycles (1 for AREG/BREG, 1 for MREG, 1 for PREG, 1 for post-DSP retiming)
|
||||
// Pipeline: NCO fabric reg (1) + DSP48E1 AREG/BREG (1) + MREG (1) + PREG (1) + retiming (1) = 5 cycles
|
||||
// The NCO fabric pipeline register was added to break the long NCO→DSP B-port route
|
||||
// (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;
|
||||
reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q;
|
||||
reg mixed_valid;
|
||||
reg mixer_overflow_i, mixer_overflow_q;
|
||||
// Pipeline valid tracking: 4-stage shift register (3 for DSP48E1 + 1 for post-DSP retiming)
|
||||
reg [3:0] dsp_valid_pipe;
|
||||
// Pipeline valid tracking: 5-stage shift register (1 NCO pipe + 3 DSP48E1 + 1 retiming)
|
||||
reg [4: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
|
||||
// This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing,
|
||||
// ensuring WNS > 0 at 400 MHz regardless of placement seed
|
||||
@@ -210,11 +215,11 @@ nco_400m_enhanced nco_core (
|
||||
//
|
||||
// Architecture:
|
||||
// ADC data → sign-extend to 18b → DSP48E1 A-port (AREG=1 pipelines it)
|
||||
// NCO cos/sin → sign-extend to 18b → DSP48E1 B-port (BREG=1 pipelines it)
|
||||
// NCO cos/sin → fabric pipeline reg → DSP48E1 B-port (BREG=1 pipelines it)
|
||||
// Multiply result captured by MREG=1, then output registered by PREG=1
|
||||
// force_saturation override applied AFTER DSP48E1 output (not on input path)
|
||||
//
|
||||
// Latency: 3 clock cycles (AREG/BREG + MREG + PREG)
|
||||
// Latency: 4 clock cycles (1 NCO pipe + 1 AREG/BREG + 1 MREG + 1 PREG) + 1 retiming = 5 total
|
||||
// PREG=1 absorbs DSP48E1 CLK→P delay internally, preventing fabric timing violations
|
||||
// In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only
|
||||
// ============================================================================
|
||||
@@ -223,24 +228,35 @@ nco_400m_enhanced nco_core (
|
||||
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;
|
||||
|
||||
// Valid pipeline: 4-stage shift register (3 for DSP48E1 AREG+MREG+PREG + 1 for retiming)
|
||||
// Valid pipeline: 5-stage shift register (1 NCO pipe + 3 DSP48E1 AREG+MREG+PREG + 1 retiming)
|
||||
always @(posedge clk_400m or negedge reset_n_400m) begin
|
||||
if (!reset_n_400m) begin
|
||||
dsp_valid_pipe <= 4'b0000;
|
||||
dsp_valid_pipe <= 5'b00000;
|
||||
end else begin
|
||||
dsp_valid_pipe <= {dsp_valid_pipe[2:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)};
|
||||
dsp_valid_pipe <= {dsp_valid_pipe[3:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)};
|
||||
end
|
||||
end
|
||||
|
||||
`ifdef SIMULATION
|
||||
// ---- Behavioral model for Icarus Verilog simulation ----
|
||||
// Mimics DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (3-cycle latency)
|
||||
// Mimics NCO pipeline + DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (4-cycle DSP + 1 NCO pipe)
|
||||
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 [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
|
||||
|
||||
// Stage 1: AREG/BREG equivalent
|
||||
// Stage 0: NCO pipeline — breaks long NCO→DSP route (matches synthesis fabric registers)
|
||||
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
|
||||
if (!reset_n_400m) begin
|
||||
adc_signed_reg <= 0;
|
||||
@@ -248,8 +264,8 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
|
||||
sin_pipe_reg <= 0;
|
||||
end else begin
|
||||
adc_signed_reg <= adc_signed_w;
|
||||
cos_pipe_reg <= cos_out;
|
||||
sin_pipe_reg <= sin_out;
|
||||
cos_pipe_reg <= cos_nco_pipe;
|
||||
sin_pipe_reg <= sin_nco_pipe;
|
||||
end
|
||||
end
|
||||
|
||||
@@ -291,6 +307,20 @@ end
|
||||
// This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz
|
||||
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 #(
|
||||
// Feature control attributes
|
||||
@@ -350,7 +380,7 @@ DSP48E1 #(
|
||||
.CEINMODE(1'b0),
|
||||
// Data ports
|
||||
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b
|
||||
.B({{2{cos_out[15]}}, cos_out}), // Sign-extend 16b to 18b
|
||||
.B({{2{cos_nco_pipe[15]}}, cos_nco_pipe}), // Sign-extend 16b to 18b (pipelined)
|
||||
.C(48'b0),
|
||||
.D(25'b0),
|
||||
.CARRYIN(1'b0),
|
||||
@@ -432,7 +462,7 @@ DSP48E1 #(
|
||||
.CED(1'b0),
|
||||
.CEINMODE(1'b0),
|
||||
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}),
|
||||
.B({{2{sin_out[15]}}, sin_out}),
|
||||
.B({{2{sin_nco_pipe[15]}}, sin_nco_pipe}),
|
||||
.C(48'b0),
|
||||
.D(25'b0),
|
||||
.CARRYIN(1'b0),
|
||||
@@ -492,7 +522,7 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
|
||||
mixer_overflow_q <= 0;
|
||||
saturation_count <= 0;
|
||||
overflow_detected <= 0;
|
||||
end else if (dsp_valid_pipe[3]) begin
|
||||
end else if (dsp_valid_pipe[4]) begin
|
||||
// Force saturation for testing (applied after DSP output, not on input path)
|
||||
if (force_saturation_sync) begin
|
||||
mixed_i <= 34'h1FFFFFFFF;
|
||||
|
||||
@@ -296,7 +296,7 @@ always @(posedge clk or negedge reset_n) begin
|
||||
state <= ST_DONE;
|
||||
end
|
||||
end
|
||||
// Timeout: if no ADC data after 10000 cycles, FAIL
|
||||
// Timeout: if no ADC data after 1000 cycles (10 us @ 100 MHz), FAIL
|
||||
step_cnt <= step_cnt + 1;
|
||||
if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin
|
||||
result_flags[4] <= 1'b0;
|
||||
|
||||
@@ -86,6 +86,11 @@ 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
|
||||
// =========================================================================
|
||||
@@ -198,6 +203,15 @@ always @(posedge clk or negedge reset_n) 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;
|
||||
if (valid_in) begin
|
||||
@@ -215,9 +229,13 @@ always @(posedge clk or negedge reset_n) begin
|
||||
|
||||
// ---- Frame boundary: AGC update + metric snapshot ----
|
||||
if (frame_boundary) begin
|
||||
// Snapshot per-frame metrics to output registers
|
||||
saturation_count <= frame_sat_count;
|
||||
peak_magnitude <= frame_peak[14:7]; // Upper 8 bits of 15-bit peak
|
||||
// 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;
|
||||
@@ -225,15 +243,17 @@ always @(posedge clk or negedge reset_n) begin
|
||||
|
||||
if (agc_enable) begin
|
||||
// AGC auto-adjustment at frame boundary
|
||||
if (frame_sat_count > 8'd0) begin
|
||||
// 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({1'b0, agc_gain}) -
|
||||
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) -
|
||||
$signed({1'b0, agc_attack}));
|
||||
holdoff_counter <= agc_holdoff; // Reset holdoff
|
||||
end else if (frame_peak[14:7] < agc_target) begin
|
||||
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({1'b0, agc_gain}) +
|
||||
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) +
|
||||
$signed({1'b0, agc_decay}));
|
||||
end else begin
|
||||
holdoff_counter <= holdoff_counter - 4'd1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -545,6 +545,315 @@ initial begin
|
||||
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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
@@ -20,8 +20,8 @@ module usb_data_interface (
|
||||
// Control signals
|
||||
output reg ft601_txe_n, // Transmit enable (active low)
|
||||
output reg ft601_rxf_n, // Receive enable (active low)
|
||||
input wire ft601_txe, // Transmit FIFO empty
|
||||
input wire ft601_rxf, // Receive FIFO full
|
||||
input wire ft601_txe, // TXE: Transmit FIFO Not Full (high = space available to write)
|
||||
input wire ft601_rxf, // RXF: Receive FIFO Not Empty (high = data available to read)
|
||||
output reg ft601_wr_n, // Write strobe (active low)
|
||||
output reg ft601_rd_n, // Read strobe (active low)
|
||||
output reg ft601_oe_n, // Output enable (active low)
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
# ruff: noqa: T201
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-off AGC saturation analysis for ADI CN0566 raw IQ captures.
|
||||
|
||||
Bit-accurate simulation of rx_gain_control.v AGC inner loop applied
|
||||
to real captured IQ data. Three scenarios per dataset:
|
||||
|
||||
Row 1 — AGC OFF: Fixed gain_shift=0 (pass-through). Shows raw clipping.
|
||||
Row 2 — AGC ON: Auto-adjusts from gain_shift=0. Clipping clears.
|
||||
Row 3 — AGC delayed: OFF for first half, ON at midpoint.
|
||||
Shows the transition: clipping → AGC activates → clears.
|
||||
|
||||
Key RTL details modelled exactly:
|
||||
- gain_shift[3]=direction (0=amplify/left, 1=attenuate/right), [2:0]=amount
|
||||
- Internal agc_gain is signed -7..+7
|
||||
- Peak is measured PRE-gain (raw input |sample|, upper 8 of 15 bits)
|
||||
- Saturation is measured POST-gain (overflow from shift)
|
||||
- Attack: gain -= agc_attack when any sample clips (immediate)
|
||||
- Decay: gain += agc_decay when peak < target AND holdoff expired
|
||||
- Hold: when peak >= target AND no saturation, hold gain, reset holdoff
|
||||
|
||||
Usage:
|
||||
python adi_agc_analysis.py
|
||||
python adi_agc_analysis.py --data /path/to/file.npy --label "my capture"
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FPGA AGC parameters (rx_gain_control.v reset defaults)
|
||||
# ---------------------------------------------------------------------------
|
||||
AGC_TARGET = 200 # host_agc_target (8-bit, default 200)
|
||||
AGC_ATTACK = 1 # host_agc_attack (4-bit, default 1)
|
||||
AGC_DECAY = 1 # host_agc_decay (4-bit, default 1)
|
||||
AGC_HOLDOFF = 4 # host_agc_holdoff (4-bit, default 4)
|
||||
ADC_RAIL = 4095 # 12-bit ADC max absolute value
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def signed_to_encoding(g: int) -> int:
|
||||
"""Convert signed gain (-7..+7) to gain_shift[3:0] encoding.
|
||||
[3]=0, [2:0]=N → amplify (left shift) by N
|
||||
[3]=1, [2:0]=N → attenuate (right shift) by N
|
||||
"""
|
||||
if g >= 0:
|
||||
return g & 0x07
|
||||
return 0x08 | ((-g) & 0x07)
|
||||
|
||||
|
||||
def encoding_to_signed(enc: int) -> int:
|
||||
"""Convert gain_shift[3:0] encoding to signed gain."""
|
||||
if (enc & 0x08) == 0:
|
||||
return enc & 0x07
|
||||
return -(enc & 0x07)
|
||||
|
||||
|
||||
def clamp_gain(val: int) -> int:
|
||||
"""Clamp to [-7, +7] (matches RTL clamp_gain function)."""
|
||||
return max(-7, min(7, val))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apply gain shift to IQ data (matches RTL combinational logic)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def apply_gain_shift(frame_i: np.ndarray, frame_q: np.ndarray,
|
||||
gain_enc: int) -> tuple[np.ndarray, np.ndarray, int]:
|
||||
"""Apply gain_shift encoding to 16-bit signed IQ arrays.
|
||||
|
||||
Returns (shifted_i, shifted_q, overflow_count).
|
||||
Matches the RTL: left shift = amplify, right shift = attenuate,
|
||||
saturate to ±32767 on overflow.
|
||||
"""
|
||||
direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate
|
||||
amount = gain_enc & 0x07
|
||||
|
||||
if amount == 0:
|
||||
return frame_i.copy(), frame_q.copy(), 0
|
||||
|
||||
if direction == 0:
|
||||
# Left shift (amplify)
|
||||
si = frame_i.astype(np.int64) * (1 << amount)
|
||||
sq = frame_q.astype(np.int64) * (1 << amount)
|
||||
else:
|
||||
# Arithmetic right shift (attenuate)
|
||||
si = frame_i.astype(np.int64) >> amount
|
||||
sq = frame_q.astype(np.int64) >> amount
|
||||
|
||||
# Count overflows (post-shift values outside 16-bit signed range)
|
||||
overflow_i = (si > 32767) | (si < -32768)
|
||||
overflow_q = (sq > 32767) | (sq < -32768)
|
||||
overflow_count = int((overflow_i | overflow_q).sum())
|
||||
|
||||
# Saturate to ±32767
|
||||
si = np.clip(si, -32768, 32767).astype(np.int16)
|
||||
sq = np.clip(sq, -32768, 32767).astype(np.int16)
|
||||
|
||||
return si, sq, overflow_count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-frame AGC simulation (bit-accurate to rx_gain_control.v)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
|
||||
enable_at_frame: int = 0,
|
||||
initial_gain_enc: int = 0x00) -> dict:
|
||||
"""Simulate FPGA inner-loop AGC across all frames.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frames : (N, chirps, samples) complex — raw ADC captures (12-bit range)
|
||||
agc_enabled : if False, gain stays fixed
|
||||
enable_at_frame : frame index where AGC activates
|
||||
initial_gain_enc : gain_shift[3:0] encoding when AGC enables (default 0x00 = pass-through)
|
||||
"""
|
||||
n_frames = frames.shape[0]
|
||||
|
||||
# Output arrays
|
||||
out_gain_enc = np.zeros(n_frames, dtype=int) # gain_shift encoding [3:0]
|
||||
out_gain_signed = np.zeros(n_frames, dtype=int) # signed gain for plotting
|
||||
out_peak_mag = np.zeros(n_frames, dtype=int) # peak_magnitude[7:0]
|
||||
out_sat_count = np.zeros(n_frames, dtype=int) # saturation_count[7:0]
|
||||
out_sat_rate = np.zeros(n_frames, dtype=float)
|
||||
out_rms_post = np.zeros(n_frames, dtype=float) # RMS after gain shift
|
||||
|
||||
# AGC internal state
|
||||
agc_gain = 0 # signed, -7..+7
|
||||
holdoff_counter = 0
|
||||
agc_was_enabled = False
|
||||
|
||||
for i in range(n_frames):
|
||||
frame = frames[i]
|
||||
# Quantize to 16-bit signed (ADC is 12-bit, sign-extended to 16)
|
||||
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
|
||||
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
|
||||
|
||||
# --- PRE-gain peak measurement (RTL lines 133-135, 211-213) ---
|
||||
abs_i = np.abs(frame_i.astype(np.int32))
|
||||
abs_q = np.abs(frame_q.astype(np.int32))
|
||||
max_iq = np.maximum(abs_i, abs_q)
|
||||
frame_peak_15bit = int(max_iq.max()) # 15-bit unsigned
|
||||
peak_8bit = (frame_peak_15bit >> 7) & 0xFF # Upper 8 bits
|
||||
|
||||
# --- Determine effective gain ---
|
||||
agc_active = agc_enabled and (i >= enable_at_frame)
|
||||
|
||||
# AGC enable transition (RTL lines 250-253)
|
||||
if agc_active and not agc_was_enabled:
|
||||
agc_gain = encoding_to_signed(initial_gain_enc)
|
||||
holdoff_counter = AGC_HOLDOFF
|
||||
|
||||
effective_enc = signed_to_encoding(agc_gain) if agc_active else initial_gain_enc
|
||||
|
||||
agc_was_enabled = agc_active
|
||||
|
||||
# --- Apply gain shift + count POST-gain overflow (RTL lines 114-126, 207-209) ---
|
||||
shifted_i, shifted_q, frame_overflow = apply_gain_shift(
|
||||
frame_i, frame_q, effective_enc)
|
||||
frame_sat = min(255, frame_overflow)
|
||||
|
||||
# RMS of shifted signal
|
||||
rms = float(np.sqrt(np.mean(
|
||||
shifted_i.astype(np.float64)**2 + shifted_q.astype(np.float64)**2)))
|
||||
|
||||
total_samples = frame_i.size + frame_q.size
|
||||
sat_rate = frame_overflow / total_samples if total_samples > 0 else 0.0
|
||||
|
||||
# --- Record outputs ---
|
||||
out_gain_enc[i] = effective_enc
|
||||
out_gain_signed[i] = agc_gain if agc_active else encoding_to_signed(initial_gain_enc)
|
||||
out_peak_mag[i] = peak_8bit
|
||||
out_sat_count[i] = frame_sat
|
||||
out_sat_rate[i] = sat_rate
|
||||
out_rms_post[i] = rms
|
||||
|
||||
# --- AGC update at frame boundary (RTL lines 226-246) ---
|
||||
if agc_active:
|
||||
if frame_sat > 0:
|
||||
# Clipping: reduce gain immediately (attack)
|
||||
agc_gain = clamp_gain(agc_gain - AGC_ATTACK)
|
||||
holdoff_counter = AGC_HOLDOFF
|
||||
elif peak_8bit < AGC_TARGET:
|
||||
# Signal too weak: increase gain after holdoff
|
||||
if holdoff_counter == 0:
|
||||
agc_gain = clamp_gain(agc_gain + AGC_DECAY)
|
||||
else:
|
||||
holdoff_counter -= 1
|
||||
else:
|
||||
# Good range (peak >= target, no sat): hold, reset holdoff
|
||||
holdoff_counter = AGC_HOLDOFF
|
||||
|
||||
return {
|
||||
"gain_enc": out_gain_enc,
|
||||
"gain_signed": out_gain_signed,
|
||||
"peak_mag": out_peak_mag,
|
||||
"sat_count": out_sat_count,
|
||||
"sat_rate": out_sat_rate,
|
||||
"rms_post": out_rms_post,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Range-Doppler processing for heatmap display
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_frame_rd(frame: np.ndarray, gain_enc: int,
|
||||
n_range: int = 64,
|
||||
n_doppler: int = 32) -> np.ndarray:
|
||||
"""Range-Doppler magnitude for one frame with gain applied."""
|
||||
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
|
||||
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
|
||||
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
|
||||
|
||||
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
|
||||
n_chirps, _ = iq.shape
|
||||
|
||||
range_fft = np.fft.fft(iq, axis=1)[:, :n_range]
|
||||
doppler_fft = np.fft.fftshift(np.fft.fft(range_fft, axis=0), axes=0)
|
||||
center = n_chirps // 2
|
||||
half_d = n_doppler // 2
|
||||
doppler_fft = doppler_fft[center - half_d:center + half_d, :]
|
||||
|
||||
rd_mag = np.abs(doppler_fft.real) + np.abs(doppler_fft.imag)
|
||||
return rd_mag.T # (n_range, n_doppler)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plotting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def plot_scenario(axes, data: np.ndarray, agc: dict, title: str,
|
||||
enable_frame: int = 0):
|
||||
"""Plot one AGC scenario across 5 axes."""
|
||||
n = data.shape[0]
|
||||
xs = np.arange(n)
|
||||
|
||||
# Range-Doppler heatmap
|
||||
if enable_frame > 0 and enable_frame < n:
|
||||
f_before = max(0, enable_frame - 1)
|
||||
f_after = min(n - 1, n - 2)
|
||||
rd_before = process_frame_rd(data[f_before], int(agc["gain_enc"][f_before]))
|
||||
rd_after = process_frame_rd(data[f_after], int(agc["gain_enc"][f_after]))
|
||||
combined = np.hstack([rd_before, rd_after])
|
||||
im = axes[0].imshow(
|
||||
20 * np.log10(combined + 1), aspect="auto", origin="lower",
|
||||
cmap="inferno", interpolation="nearest")
|
||||
axes[0].axvline(x=rd_before.shape[1] - 0.5, color="cyan",
|
||||
linewidth=2, linestyle="--")
|
||||
axes[0].set_title(f"{title}\nL: f{f_before} (pre) | R: f{f_after} (post)")
|
||||
else:
|
||||
worst = int(np.argmax(agc["sat_count"]))
|
||||
best = int(np.argmin(agc["sat_count"]))
|
||||
f_show = worst if agc["sat_count"][worst] > 0 else best
|
||||
rd = process_frame_rd(data[f_show], int(agc["gain_enc"][f_show]))
|
||||
im = axes[0].imshow(
|
||||
20 * np.log10(rd + 1), aspect="auto", origin="lower",
|
||||
cmap="inferno", interpolation="nearest")
|
||||
axes[0].set_title(f"{title}\nFrame {f_show}")
|
||||
|
||||
axes[0].set_xlabel("Doppler bin")
|
||||
axes[0].set_ylabel("Range bin")
|
||||
plt.colorbar(im, ax=axes[0], label="dB", shrink=0.8)
|
||||
|
||||
# Signed gain history (the real AGC state)
|
||||
axes[1].plot(xs, agc["gain_signed"], color="#00ff88", linewidth=1.5)
|
||||
axes[1].axhline(y=0, color="gray", linestyle=":", alpha=0.5,
|
||||
label="Pass-through")
|
||||
if enable_frame > 0:
|
||||
axes[1].axvline(x=enable_frame, color="yellow", linewidth=2,
|
||||
linestyle="--", label="AGC ON")
|
||||
axes[1].set_ylim(-8, 8)
|
||||
axes[1].set_ylabel("Gain (signed)")
|
||||
axes[1].set_title("AGC Internal Gain (-7=max atten, +7=max amp)")
|
||||
axes[1].legend(fontsize=7, loc="upper right")
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
# Peak magnitude (PRE-gain, 8-bit)
|
||||
axes[2].plot(xs, agc["peak_mag"], color="#ffaa00", linewidth=1.0)
|
||||
axes[2].axhline(y=AGC_TARGET, color="cyan", linestyle="--",
|
||||
alpha=0.7, label=f"Target ({AGC_TARGET})")
|
||||
axes[2].axhspan(240, 255, color="red", alpha=0.15, label="Clip zone")
|
||||
if enable_frame > 0:
|
||||
axes[2].axvline(x=enable_frame, color="yellow", linewidth=2,
|
||||
linestyle="--", alpha=0.8)
|
||||
axes[2].set_ylim(0, 260)
|
||||
axes[2].set_ylabel("Peak (8-bit)")
|
||||
axes[2].set_title("Peak Magnitude (pre-gain, raw input)")
|
||||
axes[2].legend(fontsize=7, loc="upper right")
|
||||
axes[2].grid(True, alpha=0.3)
|
||||
|
||||
# Saturation count (POST-gain overflow)
|
||||
axes[3].fill_between(xs, agc["sat_count"], color="red", alpha=0.4)
|
||||
axes[3].plot(xs, agc["sat_count"], color="red", linewidth=0.8)
|
||||
if enable_frame > 0:
|
||||
axes[3].axvline(x=enable_frame, color="yellow", linewidth=2,
|
||||
linestyle="--", alpha=0.8)
|
||||
axes[3].set_ylabel("Overflow Count")
|
||||
total = int(agc["sat_count"].sum())
|
||||
axes[3].set_title(f"Post-Gain Overflow (total={total})")
|
||||
axes[3].grid(True, alpha=0.3)
|
||||
|
||||
# RMS signal level (post-gain)
|
||||
axes[4].plot(xs, agc["rms_post"], color="#44aaff", linewidth=1.0)
|
||||
if enable_frame > 0:
|
||||
axes[4].axvline(x=enable_frame, color="yellow", linewidth=2,
|
||||
linestyle="--", alpha=0.8)
|
||||
axes[4].set_ylabel("RMS")
|
||||
axes[4].set_xlabel("Frame")
|
||||
axes[4].set_title("Post-Gain RMS Level")
|
||||
axes[4].grid(True, alpha=0.3)
|
||||
|
||||
|
||||
def analyze_dataset(data: np.ndarray, label: str):
|
||||
"""Run 3-scenario analysis for one dataset."""
|
||||
n_frames = data.shape[0]
|
||||
mid = n_frames // 2
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" {label} — shape {data.shape}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
# Raw ADC stats
|
||||
raw_sat = np.sum((np.abs(data.real) >= ADC_RAIL) |
|
||||
(np.abs(data.imag) >= ADC_RAIL))
|
||||
print(f" Raw ADC saturation: {raw_sat} samples "
|
||||
f"({100*raw_sat/(2*data.size):.2f}%)")
|
||||
|
||||
# Scenario 1: AGC OFF — pass-through (gain_shift=0x00)
|
||||
print(" [1/3] AGC OFF (gain=0, pass-through) ...")
|
||||
agc_off = simulate_agc(data, agc_enabled=False, initial_gain_enc=0x00)
|
||||
print(f" Post-gain overflow: {agc_off['sat_count'].sum()} "
|
||||
f"(should be 0 — no amplification)")
|
||||
|
||||
# Scenario 2: AGC ON from frame 0
|
||||
print(" [2/3] AGC ON (from start) ...")
|
||||
agc_on = simulate_agc(data, agc_enabled=True, enable_at_frame=0,
|
||||
initial_gain_enc=0x00)
|
||||
print(f" Final gain: {agc_on['gain_signed'][-1]} "
|
||||
f"(enc=0x{agc_on['gain_enc'][-1]:X})")
|
||||
print(f" Post-gain overflow: {agc_on['sat_count'].sum()}")
|
||||
|
||||
# Scenario 3: AGC delayed
|
||||
print(f" [3/3] AGC delayed (ON at frame {mid}) ...")
|
||||
agc_delayed = simulate_agc(data, agc_enabled=True,
|
||||
enable_at_frame=mid,
|
||||
initial_gain_enc=0x00)
|
||||
pre_sat = int(agc_delayed["sat_count"][:mid].sum())
|
||||
post_sat = int(agc_delayed["sat_count"][mid:].sum())
|
||||
print(f" Pre-AGC overflow: {pre_sat} "
|
||||
f"Post-AGC overflow: {post_sat}")
|
||||
|
||||
# Plot
|
||||
fig, axes = plt.subplots(3, 5, figsize=(28, 14))
|
||||
fig.suptitle(f"AERIS-10 AGC Analysis — {label}\n"
|
||||
f"({n_frames} frames, {data.shape[1]} chirps, "
|
||||
f"{data.shape[2]} samples/chirp, "
|
||||
f"raw ADC sat={100*raw_sat/(2*data.size):.2f}%)",
|
||||
fontsize=13, fontweight="bold", y=0.99)
|
||||
|
||||
plot_scenario(axes[0], data, agc_off, "AGC OFF (pass-through)")
|
||||
plot_scenario(axes[1], data, agc_on, "AGC ON (from start)")
|
||||
plot_scenario(axes[2], data, agc_delayed,
|
||||
f"AGC delayed (ON at frame {mid})", enable_frame=mid)
|
||||
|
||||
for ax, lbl in zip(axes[:, 0],
|
||||
["AGC OFF", "AGC ON", "AGC DELAYED"],
|
||||
strict=True):
|
||||
ax.annotate(lbl, xy=(-0.35, 0.5), xycoords="axes fraction",
|
||||
fontsize=13, fontweight="bold", color="white",
|
||||
ha="center", va="center", rotation=90)
|
||||
|
||||
plt.tight_layout(rect=[0.03, 0, 1, 0.95])
|
||||
return fig
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AGC analysis for ADI raw IQ captures "
|
||||
"(bit-accurate rx_gain_control.v simulation)")
|
||||
parser.add_argument("--amp", type=str,
|
||||
default=str(Path.home() / "Downloads/adi_radar_data"
|
||||
"/amp_radar"
|
||||
"/phaser_amp_4MSPS_500M_300u_256_m3dB.npy"),
|
||||
help="Path to amplified radar .npy")
|
||||
parser.add_argument("--noamp", type=str,
|
||||
default=str(Path.home() / "Downloads/adi_radar_data"
|
||||
"/no_amp_radar"
|
||||
"/phaser_NOamp_4MSPS_500M_300u_256.npy"),
|
||||
help="Path to non-amplified radar .npy")
|
||||
parser.add_argument("--data", type=str, default=None,
|
||||
help="Single dataset mode")
|
||||
parser.add_argument("--label", type=str, default="Custom Data")
|
||||
args = parser.parse_args()
|
||||
|
||||
plt.style.use("dark_background")
|
||||
|
||||
if args.data:
|
||||
data = np.load(args.data)
|
||||
analyze_dataset(data, args.label)
|
||||
plt.show()
|
||||
return
|
||||
|
||||
figs = []
|
||||
for path, label in [(args.amp, "With Amplifier (-3 dB)"),
|
||||
(args.noamp, "No Amplifier")]:
|
||||
if not Path(path).exists():
|
||||
print(f"WARNING: {path} not found, skipping")
|
||||
continue
|
||||
data = np.load(path)
|
||||
fig = analyze_dataset(data, label)
|
||||
figs.append(fig)
|
||||
|
||||
if not figs:
|
||||
print("No data found. Use --amp/--noamp or --data.")
|
||||
sys.exit(1)
|
||||
|
||||
plt.show()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -97,6 +97,11 @@ class RadarDashboard:
|
||||
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
|
||||
self._acq_thread: RadarAcquisition | None = None
|
||||
|
||||
# Thread-safe UI message queue — avoids calling root.after() from
|
||||
# background threads which crashes Python 3.12 (GIL state corruption).
|
||||
# Entries are (tag, payload) tuples drained by _schedule_update().
|
||||
self._ui_queue: queue.Queue[tuple[str, object]] = queue.Queue()
|
||||
|
||||
# Display state
|
||||
self._current_frame = RadarFrame()
|
||||
self._waterfall = deque(maxlen=WATERFALL_DEPTH)
|
||||
@@ -111,6 +116,16 @@ class RadarDashboard:
|
||||
self._vmax_ema = 1000.0
|
||||
self._vmax_alpha = 0.15 # smoothing factor (lower = more stable)
|
||||
|
||||
# AGC visualization history (ring buffers, ~60s at 10 Hz)
|
||||
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_time_history: deque[float] = deque(maxlen=self._agc_history_len)
|
||||
self._agc_t0: float = time.time()
|
||||
self._agc_last_redraw: float = 0.0 # throttle chart redraws
|
||||
self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws
|
||||
|
||||
self._build_ui()
|
||||
self._schedule_update()
|
||||
|
||||
@@ -162,13 +177,16 @@ class RadarDashboard:
|
||||
|
||||
tab_display = ttk.Frame(nb)
|
||||
tab_control = ttk.Frame(nb)
|
||||
tab_agc = ttk.Frame(nb)
|
||||
tab_log = ttk.Frame(nb)
|
||||
nb.add(tab_display, text=" Display ")
|
||||
nb.add(tab_control, text=" Control ")
|
||||
nb.add(tab_agc, text=" AGC Monitor ")
|
||||
nb.add(tab_log, text=" Log ")
|
||||
|
||||
self._build_display_tab(tab_display)
|
||||
self._build_control_tab(tab_control)
|
||||
self._build_agc_tab(tab_agc)
|
||||
self._build_log_tab(tab_log)
|
||||
|
||||
def _build_display_tab(self, parent):
|
||||
@@ -474,13 +492,98 @@ class RadarDashboard:
|
||||
var.set(str(clamped))
|
||||
self._send_cmd(opcode, clamped)
|
||||
|
||||
def _build_agc_tab(self, parent):
|
||||
"""AGC Monitor tab — real-time strip charts for gain, peak, and saturation."""
|
||||
# Top row: AGC status badge + saturation indicator
|
||||
top = ttk.Frame(parent)
|
||||
top.pack(fill="x", padx=8, pady=(8, 0))
|
||||
|
||||
self._agc_badge = ttk.Label(
|
||||
top, text="AGC: --", font=("Menlo", 14, "bold"), foreground=FG)
|
||||
self._agc_badge.pack(side="left", padx=(0, 24))
|
||||
|
||||
self._agc_sat_badge = ttk.Label(
|
||||
top, text="Saturation: 0", font=("Menlo", 12), foreground=GREEN)
|
||||
self._agc_sat_badge.pack(side="left", padx=(0, 24))
|
||||
|
||||
self._agc_gain_value = ttk.Label(
|
||||
top, text="Gain: --", font=("Menlo", 12), foreground=ACCENT)
|
||||
self._agc_gain_value.pack(side="left", padx=(0, 24))
|
||||
|
||||
self._agc_peak_value = ttk.Label(
|
||||
top, text="Peak: --", font=("Menlo", 12), foreground=ACCENT)
|
||||
self._agc_peak_value.pack(side="left")
|
||||
|
||||
# Matplotlib figure with 3 stacked subplots sharing x-axis (time)
|
||||
self._agc_fig = Figure(figsize=(14, 7), facecolor=BG)
|
||||
self._agc_fig.subplots_adjust(
|
||||
left=0.07, right=0.98, top=0.95, bottom=0.08,
|
||||
hspace=0.30)
|
||||
|
||||
# Subplot 1: FPGA inner-loop gain (4-bit, 0-15)
|
||||
self._ax_gain = self._agc_fig.add_subplot(3, 1, 1)
|
||||
self._ax_gain.set_facecolor(BG2)
|
||||
self._ax_gain.set_title("FPGA AGC Gain (inner loop)", color=FG, fontsize=10)
|
||||
self._ax_gain.set_ylabel("Gain Level", color=FG)
|
||||
self._ax_gain.set_ylim(-0.5, 15.5)
|
||||
self._ax_gain.tick_params(colors=FG)
|
||||
self._ax_gain.set_xlim(0, self._agc_history_len)
|
||||
self._gain_line, = self._ax_gain.plot(
|
||||
[], [], color=ACCENT, linewidth=1.5, label="Gain")
|
||||
self._ax_gain.axhline(y=0, color=RED, linewidth=0.5, alpha=0.5, linestyle="--")
|
||||
self._ax_gain.axhline(y=15, color=RED, linewidth=0.5, alpha=0.5, linestyle="--")
|
||||
for spine in self._ax_gain.spines.values():
|
||||
spine.set_color(SURFACE)
|
||||
|
||||
# Subplot 2: Peak magnitude (8-bit, 0-255)
|
||||
self._ax_peak = self._agc_fig.add_subplot(3, 1, 2)
|
||||
self._ax_peak.set_facecolor(BG2)
|
||||
self._ax_peak.set_title("Peak Magnitude", color=FG, fontsize=10)
|
||||
self._ax_peak.set_ylabel("Peak (8-bit)", color=FG)
|
||||
self._ax_peak.set_ylim(-5, 260)
|
||||
self._ax_peak.tick_params(colors=FG)
|
||||
self._ax_peak.set_xlim(0, self._agc_history_len)
|
||||
self._peak_line, = self._ax_peak.plot(
|
||||
[], [], color=YELLOW, linewidth=1.5, label="Peak")
|
||||
# AGC target reference line (default 200)
|
||||
self._agc_target_line = self._ax_peak.axhline(
|
||||
y=200, color=GREEN, linewidth=1.0, alpha=0.7, linestyle="--",
|
||||
label="Target (200)")
|
||||
self._ax_peak.legend(loc="upper right", fontsize=8,
|
||||
facecolor=BG2, edgecolor=SURFACE,
|
||||
labelcolor=FG)
|
||||
for spine in self._ax_peak.spines.values():
|
||||
spine.set_color(SURFACE)
|
||||
|
||||
# Subplot 3: Saturation count (8-bit, 0-255) as bar-style fill
|
||||
self._ax_sat = self._agc_fig.add_subplot(3, 1, 3)
|
||||
self._ax_sat.set_facecolor(BG2)
|
||||
self._ax_sat.set_title("Saturation Count", color=FG, fontsize=10)
|
||||
self._ax_sat.set_ylabel("Sat Count", color=FG)
|
||||
self._ax_sat.set_xlabel("Sample Index", color=FG)
|
||||
self._ax_sat.set_ylim(-1, 40)
|
||||
self._ax_sat.tick_params(colors=FG)
|
||||
self._ax_sat.set_xlim(0, self._agc_history_len)
|
||||
self._sat_fill = self._ax_sat.fill_between(
|
||||
[], [], color=RED, alpha=0.6, label="Saturation")
|
||||
self._sat_line, = self._ax_sat.plot(
|
||||
[], [], color=RED, linewidth=1.0)
|
||||
self._ax_sat.axhline(y=0, color=GREEN, linewidth=0.5, alpha=0.5, linestyle="--")
|
||||
for spine in self._ax_sat.spines.values():
|
||||
spine.set_color(SURFACE)
|
||||
|
||||
agc_canvas = FigureCanvasTkAgg(self._agc_fig, master=parent)
|
||||
agc_canvas.draw()
|
||||
agc_canvas.get_tk_widget().pack(fill="both", expand=True)
|
||||
self._agc_canvas = agc_canvas
|
||||
|
||||
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)
|
||||
# Redirect log handler to text widget (via UI queue for thread safety)
|
||||
handler = _TextHandler(self._ui_queue)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S"))
|
||||
logging.getLogger().addHandler(handler)
|
||||
@@ -506,8 +609,8 @@ class RadarDashboard:
|
||||
|
||||
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))
|
||||
# Post result to UI queue (drained by _schedule_update)
|
||||
self._ui_queue.put(("connect", ok))
|
||||
|
||||
threading.Thread(target=_do_connect, daemon=True).start()
|
||||
|
||||
@@ -555,8 +658,8 @@ class RadarDashboard:
|
||||
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)
|
||||
"""Called from acquisition thread — post to UI queue for main thread."""
|
||||
self._ui_queue.put(("status", status))
|
||||
|
||||
def _update_self_test_labels(self, status: StatusResponse):
|
||||
"""Update the self-test result labels and AGC status from a StatusResponse."""
|
||||
@@ -609,11 +712,109 @@ class RadarDashboard:
|
||||
text=f"Sat Count: {status.agc_saturation_count}",
|
||||
foreground=sat_color)
|
||||
|
||||
# AGC visualization update
|
||||
self._update_agc_visualization(status)
|
||||
|
||||
def _update_agc_visualization(self, status: StatusResponse):
|
||||
"""Push AGC metrics into ring buffers and redraw strip 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
|
||||
|
||||
# Append to ring buffers (always — this is O(1))
|
||||
self._agc_gain_history.append(status.agc_current_gain)
|
||||
self._agc_peak_history.append(status.agc_peak_magnitude)
|
||||
self._agc_sat_history.append(status.agc_saturation_count)
|
||||
|
||||
# Update indicator labels (cheap Tk config calls)
|
||||
mode_str = "AUTO" if status.agc_enable else "MANUAL"
|
||||
mode_color = GREEN if status.agc_enable else FG
|
||||
self._agc_badge.config(text=f"AGC: {mode_str}", foreground=mode_color)
|
||||
self._agc_gain_value.config(
|
||||
text=f"Gain: {status.agc_current_gain}")
|
||||
self._agc_peak_value.config(
|
||||
text=f"Peak: {status.agc_peak_magnitude}")
|
||||
|
||||
total_sat = sum(self._agc_sat_history)
|
||||
if total_sat > 10:
|
||||
sat_color = RED
|
||||
elif total_sat > 0:
|
||||
sat_color = YELLOW
|
||||
else:
|
||||
sat_color = GREEN
|
||||
self._agc_sat_badge.config(
|
||||
text=f"Saturation: {total_sat}", foreground=sat_color)
|
||||
|
||||
# ---- 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._gain_line.set_data(xs, gain_data)
|
||||
self._peak_line.set_data(xs, peak_data)
|
||||
|
||||
# Saturation: redraw as filled area
|
||||
self._sat_line.set_data(xs, sat_data)
|
||||
if self._sat_fill is not None:
|
||||
self._sat_fill.remove()
|
||||
self._sat_fill = self._ax_sat.fill_between(
|
||||
xs, sat_data, color=RED, alpha=0.4)
|
||||
|
||||
# Auto-scale saturation Y axis to data
|
||||
max_sat = max(sat_data) if sat_data else 0
|
||||
self._ax_sat.set_ylim(-1, max(max_sat * 1.5, 5))
|
||||
|
||||
# Scroll X axis to keep latest data visible
|
||||
if n >= self._agc_history_len:
|
||||
self._ax_gain.set_xlim(0, n)
|
||||
self._ax_peak.set_xlim(0, n)
|
||||
self._ax_sat.set_xlim(0, n)
|
||||
|
||||
self._agc_canvas.draw_idle()
|
||||
|
||||
# --------------------------------------------------------- Display loop
|
||||
def _schedule_update(self):
|
||||
self._drain_ui_queue()
|
||||
self._update_display()
|
||||
self.root.after(self.UPDATE_INTERVAL_MS, self._schedule_update)
|
||||
|
||||
def _drain_ui_queue(self):
|
||||
"""Process all pending cross-thread messages on the main thread."""
|
||||
while True:
|
||||
try:
|
||||
tag, payload = self._ui_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if tag == "connect":
|
||||
self._on_connect_done(payload)
|
||||
elif tag == "status":
|
||||
self._update_self_test_labels(payload)
|
||||
elif tag == "log":
|
||||
self._log_handler_append(payload)
|
||||
|
||||
def _log_handler_append(self, msg: str):
|
||||
"""Append a log message to the log Text widget (main thread only)."""
|
||||
with contextlib.suppress(Exception):
|
||||
self.log_text.insert("end", msg + "\n")
|
||||
self.log_text.see("end")
|
||||
# Keep last 500 lines
|
||||
lines = int(self.log_text.index("end-1c").split(".")[0])
|
||||
if lines > 500:
|
||||
self.log_text.delete("1.0", f"{lines - 500}.0")
|
||||
|
||||
def _update_display(self):
|
||||
"""Pull latest frame from queue and update plots."""
|
||||
frame = None
|
||||
@@ -678,24 +879,21 @@ class RadarDashboard:
|
||||
|
||||
|
||||
class _TextHandler(logging.Handler):
|
||||
"""Logging handler that writes to a tkinter Text widget."""
|
||||
"""Logging handler that posts messages to a queue for main-thread append.
|
||||
|
||||
def __init__(self, text_widget: tk.Text):
|
||||
Using widget.after() from background threads crashes Python 3.12 due to
|
||||
GIL state corruption. Instead we post to the dashboard's _ui_queue and
|
||||
let _drain_ui_queue() append on the main thread.
|
||||
"""
|
||||
|
||||
def __init__(self, ui_queue: queue.Queue[tuple[str, object]]):
|
||||
super().__init__()
|
||||
self._text = text_widget
|
||||
self._ui_queue = ui_queue
|
||||
|
||||
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")
|
||||
self._ui_queue.put(("log", msg))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -452,7 +452,7 @@ class FT2232HConnection:
|
||||
_HARDWARE_ONLY_OPCODES = {
|
||||
0x01, # RADAR_MODE
|
||||
0x02, # TRIGGER_PULSE
|
||||
0x03, # DETECT_THRESHOLD
|
||||
# 0x03 (DETECT_THRESHOLD) is NOT hardware-only — it's in _REPLAY_ADJUSTABLE_OPCODES
|
||||
0x04, # STREAM_CONTROL
|
||||
0x10, # LONG_CHIRP
|
||||
0x11, # LONG_LISTEN
|
||||
@@ -462,6 +462,11 @@ _HARDWARE_ONLY_OPCODES = {
|
||||
0x15, # CHIRPS_PER_ELEV
|
||||
0x16, # GAIN_SHIFT
|
||||
0x20, # RANGE_MODE
|
||||
0x28, # AGC_ENABLE
|
||||
0x29, # AGC_TARGET
|
||||
0x2A, # AGC_ATTACK
|
||||
0x2B, # AGC_DECAY
|
||||
0x2C, # AGC_HOLDOFF
|
||||
0x30, # SELF_TEST_TRIGGER
|
||||
0x31, # SELF_TEST_STATUS
|
||||
0xFF, # STATUS_REQUEST
|
||||
@@ -469,6 +474,7 @@ _HARDWARE_ONLY_OPCODES = {
|
||||
|
||||
# Replay-adjustable opcodes (re-run signal processing)
|
||||
_REPLAY_ADJUSTABLE_OPCODES = {
|
||||
0x03, # DETECT_THRESHOLD
|
||||
0x21, # CFAR_GUARD
|
||||
0x22, # CFAR_TRAIN
|
||||
0x23, # CFAR_ALPHA
|
||||
@@ -612,6 +618,7 @@ class ReplayConnection:
|
||||
self._cfar_alpha: int = 0x30
|
||||
self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO
|
||||
self._cfar_enable: bool = True
|
||||
self._detect_threshold: int = 10000 # RTL default (host_detect_threshold)
|
||||
# Raw source arrays (loaded once, reprocessed on param change)
|
||||
self._dop_mti_i: np.ndarray | None = None
|
||||
self._dop_mti_q: np.ndarray | None = None
|
||||
@@ -633,7 +640,7 @@ class ReplayConnection:
|
||||
f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
|
||||
f"{self._frame_len} bytes/frame)")
|
||||
return True
|
||||
except (OSError, ValueError, struct.error) as e:
|
||||
except (OSError, ValueError, IndexError, struct.error) as e:
|
||||
log.error(f"Replay open failed: {e}")
|
||||
return False
|
||||
|
||||
@@ -676,7 +683,11 @@ class ReplayConnection:
|
||||
if opcode in _REPLAY_ADJUSTABLE_OPCODES:
|
||||
changed = False
|
||||
with self._lock:
|
||||
if opcode == 0x21: # CFAR_GUARD
|
||||
if opcode == 0x03: # DETECT_THRESHOLD
|
||||
if self._detect_threshold != value:
|
||||
self._detect_threshold = value
|
||||
changed = True
|
||||
elif opcode == 0x21: # CFAR_GUARD
|
||||
if self._cfar_guard != value:
|
||||
self._cfar_guard = value
|
||||
changed = True
|
||||
@@ -768,7 +779,10 @@ class ReplayConnection:
|
||||
mode=self._cfar_mode,
|
||||
)
|
||||
else:
|
||||
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=bool)
|
||||
# Simple threshold fallback matching RTL cfar_ca.v:
|
||||
# detect = (|I| + |Q|) > detect_threshold (L1 norm)
|
||||
mag = np.abs(dop_i) + np.abs(dop_q)
|
||||
det = mag > self._detect_threshold
|
||||
|
||||
det_count = int(det.sum())
|
||||
log.info(f"Replay: rebuilt {NUM_CELLS} packets ("
|
||||
|
||||
@@ -840,5 +840,111 @@ class TestAGCStatusResponseDefaults(unittest.TestCase):
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
@@ -334,6 +334,73 @@ class TestV7Init(unittest.TestCase):
|
||||
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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Helper: lazy import of v7.models
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""
|
||||
v7.dashboard — Main application window for the PLFM Radar GUI V7.
|
||||
|
||||
RadarDashboard is a QMainWindow with five tabs:
|
||||
RadarDashboard is a QMainWindow with six tabs:
|
||||
1. Main View — Range-Doppler matplotlib canvas (64x32), device combos,
|
||||
Start/Stop, targets table
|
||||
2. Map View — Embedded Leaflet map + sidebar
|
||||
3. FPGA Control — Full FPGA register control panel (all 27 opcodes incl. AGC,
|
||||
bit-width validation, grouped layout matching production)
|
||||
4. Diagnostics — Connection indicators, packet stats, dependency status,
|
||||
4. AGC Monitor — Real-time AGC strip charts (gain, peak magnitude, saturation)
|
||||
5. Diagnostics — Connection indicators, packet stats, dependency status,
|
||||
self-test results, log viewer
|
||||
5. Settings — Host-side DSP parameters + About section
|
||||
6. Settings — Host-side DSP parameters + About section
|
||||
|
||||
Uses production radar_protocol.py for all FPGA communication:
|
||||
- FT2232HConnection for real hardware
|
||||
@@ -23,6 +24,7 @@ commands sent over FT2232H.
|
||||
|
||||
import time
|
||||
import logging
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -34,7 +36,7 @@ from PyQt6.QtWidgets import (
|
||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QPlainTextEdit, QStatusBar, QMessageBox,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSlot
|
||||
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QObject
|
||||
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
|
||||
from matplotlib.figure import Figure
|
||||
@@ -148,11 +150,20 @@ class RadarDashboard(QMainWindow):
|
||||
self._last_status: StatusResponse | None = None
|
||||
self._frame_count = 0
|
||||
self._gps_packet_count = 0
|
||||
self._last_stats: dict = {}
|
||||
self._current_targets: list[RadarTarget] = []
|
||||
|
||||
# FPGA control parameter widgets
|
||||
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 ------------------------------------------------------
|
||||
self._apply_dark_theme()
|
||||
self._setup_ui()
|
||||
@@ -163,8 +174,10 @@ class RadarDashboard(QMainWindow):
|
||||
self._gui_timer.timeout.connect(self._refresh_gui)
|
||||
self._gui_timer.start(100)
|
||||
|
||||
# Log handler for diagnostics
|
||||
self._log_handler = _QtLogHandler(self._log_append)
|
||||
# Log handler for diagnostics (thread-safe via Qt signal)
|
||||
self._log_bridge = _LogSignalBridge(self)
|
||||
self._log_bridge.log_message.connect(self._log_append)
|
||||
self._log_handler = _QtLogHandler(self._log_bridge)
|
||||
self._log_handler.setLevel(logging.INFO)
|
||||
logging.getLogger().addHandler(self._log_handler)
|
||||
|
||||
@@ -306,6 +319,7 @@ class RadarDashboard(QMainWindow):
|
||||
self._create_main_tab()
|
||||
self._create_map_tab()
|
||||
self._create_fpga_control_tab()
|
||||
self._create_agc_monitor_tab()
|
||||
self._create_diagnostics_tab()
|
||||
self._create_settings_tab()
|
||||
|
||||
@@ -392,7 +406,7 @@ class RadarDashboard(QMainWindow):
|
||||
self._targets_table_main = QTableWidget()
|
||||
self._targets_table_main.setColumnCount(5)
|
||||
self._targets_table_main.setHorizontalHeaderLabels([
|
||||
"Range Bin", "Doppler Bin", "Magnitude", "SNR (dB)", "Track ID",
|
||||
"Range (m)", "Velocity (m/s)", "Magnitude", "SNR (dB)", "Track ID",
|
||||
])
|
||||
self._targets_table_main.setAlternatingRowColors(True)
|
||||
self._targets_table_main.setSelectionBehavior(
|
||||
@@ -783,7 +797,122 @@ class RadarDashboard(QMainWindow):
|
||||
parent_layout.addLayout(row)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# TAB 4: Diagnostics
|
||||
# TAB 4: AGC Monitor
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
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)
|
||||
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):
|
||||
@@ -1184,7 +1313,13 @@ class RadarDashboard(QMainWindow):
|
||||
self._simulator.stop()
|
||||
self._simulator = None
|
||||
self._demo_mode = False
|
||||
self._sb_mode.setText("Idle" if not self._running else "Live")
|
||||
if not self._running:
|
||||
mode = "Idle"
|
||||
elif isinstance(self._connection, ReplayConnection):
|
||||
mode = "Replay"
|
||||
else:
|
||||
mode = "Live"
|
||||
self._sb_mode.setText(mode)
|
||||
self._sb_status.setText("Demo stopped")
|
||||
self._demo_btn_main.setText("Start Demo")
|
||||
self._demo_btn_map.setText("Start Demo")
|
||||
@@ -1231,7 +1366,7 @@ class RadarDashboard(QMainWindow):
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def _on_radar_stats(self, stats: dict):
|
||||
pass # Stats are displayed in _refresh_gui
|
||||
self._last_stats = stats
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_worker_error(self, msg: str):
|
||||
@@ -1335,6 +1470,80 @@ class RadarDashboard(QMainWindow):
|
||||
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)
|
||||
# =====================================================================
|
||||
@@ -1468,7 +1677,7 @@ class RadarDashboard(QMainWindow):
|
||||
str(self._frame_count),
|
||||
str(det),
|
||||
str(gps_count),
|
||||
"0", # errors
|
||||
str(self._last_stats.get("errors", 0)),
|
||||
f"{uptime:.0f}s",
|
||||
f"{frame_rate:.1f}/s",
|
||||
]
|
||||
@@ -1519,15 +1728,22 @@ class RadarDashboard(QMainWindow):
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Qt-compatible log handler (routes Python logging -> QTextEdit)
|
||||
# Qt-compatible log handler (routes Python logging -> QTextEdit via signal)
|
||||
# =============================================================================
|
||||
|
||||
class _QtLogHandler(logging.Handler):
|
||||
"""Sends log records to a callback (called on the thread that emitted)."""
|
||||
|
||||
def __init__(self, callback):
|
||||
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):
|
||||
"""Sends log records to a QObject signal (safe from any thread)."""
|
||||
|
||||
def __init__(self, bridge: _LogSignalBridge):
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
self._bridge = bridge
|
||||
self.setFormatter(logging.Formatter(
|
||||
"%(asctime)s %(levelname)-8s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
@@ -1536,6 +1752,6 @@ class _QtLogHandler(logging.Handler):
|
||||
def emit(self, record):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
self._callback(msg)
|
||||
self._bridge.log_message.emit(msg)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
@@ -504,6 +504,37 @@ module tb_cross_layer_ft2232h;
|
||||
check(cmd_opcode === 8'h27 && cmd_value === 16'h0003,
|
||||
"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
|
||||
send_command_ft2232h(8'h30, 8'h00, 8'h00, 8'h01); // SELF_TEST_TRIGGER
|
||||
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
|
||||
|
||||
Reference in New Issue
Block a user