Compare commits

..

9 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] b54c04272f fix: correct .gitignore stub name stm32_stub → stm32_settings_stub
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/15d36e68-be17-4ee3-b6e0-1da7de544671

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-13 15:21:06 +00:00
copilot-swe-agent[bot] ce61b71cf4 fix: stable target IDs, hardware.py null checks, remove unused crcmod
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/39ac635f-c79b-438f-8764-8db7361e4d50

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-13 15:13:15 +00:00
copilot-swe-agent[bot] bbaf1e3436 fix: restore actionable error messages to stderr in uart_capture.py
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/3a9a3676-8353-4df6-96b3-0163bd25923f

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-13 15:08:30 +00:00
Jason 4578621c75 fix: restore T20-stripped print() calls in cosim scripts; add 60 mem validation tests
- Restored print() output in 6 generator/cosim scripts that ruff T20
  had silently stripped, leaving dead 'for _var: pass' stubs and
  orphaned expressions. Files restored from pre-ruff commit and
  re-linted with T20/ERA/ARG/E501 per-file-ignores.
- Removed 5 dead/self-blessing scripts (compare.py, compare_doppler.py,
  compare_mf.py, validate_mem_files.py, LUT.py).
- Added test_mem_validation.py: 60 pytest tests validating .mem files
  against independently-derived ground truth (twiddle factors, chirp
  waveforms, memory addressing, segment padding).
- Updated CI cross-layer-tests job to include test_mem_validation.py.
- All 150 tests pass (61 GUI + 29 cross-layer + 60 mem validation).
2026-04-13 20:36:28 +05:45
copilot-swe-agent[bot] 8901894b6c fix: restore uart_capture.py terminal output; add T20 per-file ignore for CLI tool
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/671cf948-60b5-47c3-af69-7e1d26366728

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-13 14:11:23 +00:00
copilot-swe-agent[bot] e6e2217b76 fix: enforce 1-32 range for Chirps Per Elevation (opcode 0x15); mojibake already fixed
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/9509b8cb-c385-479a-a7a6-a4a9307f2615

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-12 19:15:58 +00:00
Jason cc9ab27d44 Update 9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/RadarSettings.cpp
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-12 22:10:27 +03:00
copilot-swe-agent[bot] 56d0ea2883 fix: use importlib for radar_protocol import; downgrade noisy log levels to DEBUG
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/8acb5f68-51fa-4632-a73b-0188b876bed1

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-12 19:09:23 +00:00
copilot-swe-agent[bot] b394f6bc49 fix: widen per-file-ignores globs in pyproject.toml to use ** patterns
Agent-Logs-Url: https://github.com/NawfalMotii79/PLFM_RADAR/sessions/1aaab9fe-f41c-4e43-9391-99ce5a500686

Co-authored-by: JJassonn69 <83615043+JJassonn69@users.noreply.github.com>
2026-04-12 19:06:10 +00:00
57 changed files with 5182 additions and 9039 deletions
+2 -3
View File
@@ -46,9 +46,7 @@ jobs:
- name: Unit tests - name: Unit tests
run: > run: >
uv run pytest uv run pytest
9_Firmware/9_3_GUI/test_radar_dashboard.py 9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short
9_Firmware/9_3_GUI/test_v7.py
-v --tb=short
# =========================================================================== # ===========================================================================
# MCU Firmware Unit Tests (20 tests) # MCU Firmware Unit Tests (20 tests)
@@ -113,4 +111,5 @@ jobs:
run: > run: >
uv run pytest uv run pytest
9_Firmware/tests/cross_layer/test_cross_layer_contract.py 9_Firmware/tests/cross_layer/test_cross_layer_contract.py
9_Firmware/tests/cross_layer/test_mem_validation.py
-v --tb=short -v --tb=short
-24
View File
@@ -1,24 +0,0 @@
import numpy as np
# Define parameters
fs = 120e6 # Sampling frequency
Ts = 1 / fs # Sampling time
Tb = 1e-6 # Burst time
Tau = 30e-6 # Pulse repetition time
fmax = 15e6 # Maximum frequency on ramp
fmin = 1e6 # Minimum frequency on ramp
# Compute number of samples per ramp
n = int(Tb / Ts)
N = np.arange(0, n, 1)
# Compute instantaneous phase
theta_n = 2 * np.pi * ((N**2 * Ts**2 * (fmax - fmin) / (2 * Tb)) + fmin * N * Ts)
# Generate waveform and scale it to 8-bit unsigned values (0 to 255)
y = 1 + np.sin(theta_n) # Normalize from 0 to 2
y_scaled = np.round(y * 127.5).astype(int) # Scale to 8-bit range (0-255)
# Print values in Verilog-friendly format
for _i in range(n):
pass
@@ -1,116 +0,0 @@
// ADAR1000_AGC.cpp -- STM32 outer-loop AGC implementation
//
// See ADAR1000_AGC.h for architecture overview.
#include "ADAR1000_AGC.h"
#include "ADAR1000_Manager.h"
#include "diag_log.h"
#include <cstring>
// ---------------------------------------------------------------------------
// Constructor -- set all config fields to safe defaults
// ---------------------------------------------------------------------------
ADAR1000_AGC::ADAR1000_AGC()
: agc_base_gain(ADAR1000Manager::kDefaultRxVgaGain) // 30
, gain_step_down(4)
, gain_step_up(1)
, min_gain(0)
, max_gain(127)
, holdoff_frames(4)
, enabled(true)
, holdoff_counter(0)
, last_saturated(false)
, saturation_event_count(0)
{
memset(cal_offset, 0, sizeof(cal_offset));
}
// ---------------------------------------------------------------------------
// update -- called once per frame with the FPGA DIG_5 saturation flag
//
// Returns true if agc_base_gain changed (caller should then applyGain).
// ---------------------------------------------------------------------------
void ADAR1000_AGC::update(bool fpga_saturation)
{
if (!enabled)
return;
last_saturated = fpga_saturation;
if (fpga_saturation) {
// Attack: reduce gain immediately
saturation_event_count++;
holdoff_counter = 0;
if (agc_base_gain >= gain_step_down + min_gain) {
agc_base_gain -= gain_step_down;
} else {
agc_base_gain = min_gain;
}
DIAG("AGC", "SAT detected -- gain_base -> %u (events=%lu)",
(unsigned)agc_base_gain, (unsigned long)saturation_event_count);
} else {
// Recovery: wait for holdoff, then increase gain
holdoff_counter++;
if (holdoff_counter >= holdoff_frames) {
holdoff_counter = 0;
if (agc_base_gain + gain_step_up <= max_gain) {
agc_base_gain += gain_step_up;
} else {
agc_base_gain = max_gain;
}
DIAG("AGC", "Recovery step -- gain_base -> %u", (unsigned)agc_base_gain);
}
}
}
// ---------------------------------------------------------------------------
// applyGain -- write effective gain to all 16 RX VGA channels
//
// Uses the Manager's adarSetRxVgaGain which takes 1-based channel indices
// (matching the convention in setBeamAngle).
// ---------------------------------------------------------------------------
void ADAR1000_AGC::applyGain(ADAR1000Manager &mgr)
{
for (uint8_t dev = 0; dev < AGC_NUM_DEVICES; ++dev) {
for (uint8_t ch = 0; ch < AGC_NUM_CHANNELS; ++ch) {
uint8_t gain = effectiveGain(dev * AGC_NUM_CHANNELS + ch);
// Channel parameter is 1-based per Manager convention
mgr.adarSetRxVgaGain(dev, ch + 1, gain, BROADCAST_OFF);
}
}
}
// ---------------------------------------------------------------------------
// resetState -- clear runtime counters, preserve configuration
// ---------------------------------------------------------------------------
void ADAR1000_AGC::resetState()
{
holdoff_counter = 0;
last_saturated = false;
saturation_event_count = 0;
}
// ---------------------------------------------------------------------------
// effectiveGain -- compute clamped per-channel gain
// ---------------------------------------------------------------------------
uint8_t ADAR1000_AGC::effectiveGain(uint8_t channel_index) const
{
if (channel_index >= AGC_TOTAL_CHANNELS)
return min_gain; // safety fallback — OOB channels get minimum gain
int16_t raw = static_cast<int16_t>(agc_base_gain) + cal_offset[channel_index];
if (raw < static_cast<int16_t>(min_gain))
return min_gain;
if (raw > static_cast<int16_t>(max_gain))
return max_gain;
return static_cast<uint8_t>(raw);
}
@@ -1,97 +0,0 @@
// ADAR1000_AGC.h -- STM32 outer-loop AGC for ADAR1000 RX VGA gain
//
// Adjusts the analog VGA common-mode gain on each ADAR1000 RX channel based on
// the FPGA's saturation flag (DIG_5 / PD13). Runs once per radar frame
// (~258 ms) in the main loop, after runRadarPulseSequence().
//
// Architecture:
// - Inner loop (FPGA, per-sample): rx_gain_control auto-adjusts digital
// gain_shift based on peak magnitude / saturation. Range ±42 dB.
// - Outer loop (THIS MODULE, per-frame): reads FPGA DIG_5 GPIO. If
// saturation detected, reduces agc_base_gain immediately (attack). If no
// saturation for holdoff_frames, increases agc_base_gain (decay/recovery).
//
// Per-channel gain formula:
// VGA[dev][ch] = clamp(agc_base_gain + cal_offset[dev*4+ch], min_gain, max_gain)
//
// The cal_offset array allows per-element calibration to correct inter-channel
// gain imbalance. Default is all zeros (uniform gain).
#ifndef ADAR1000_AGC_H
#define ADAR1000_AGC_H
#include <stdint.h>
// Forward-declare to avoid pulling in the full ADAR1000_Manager header here.
// The .cpp includes the real header.
class ADAR1000Manager;
// Number of ADAR1000 devices
#define AGC_NUM_DEVICES 4
// Number of channels per ADAR1000
#define AGC_NUM_CHANNELS 4
// Total RX channels
#define AGC_TOTAL_CHANNELS (AGC_NUM_DEVICES * AGC_NUM_CHANNELS)
class ADAR1000_AGC {
public:
// --- Configuration (public for easy field-testing / GUI override) ---
// Common-mode base gain (raw ADAR1000 register value, 0-255).
// Default matches ADAR1000Manager::kDefaultRxVgaGain = 30.
uint8_t agc_base_gain;
// Per-channel calibration offset (signed, added to agc_base_gain).
// Index = device*4 + channel. Default: all 0.
int8_t cal_offset[AGC_TOTAL_CHANNELS];
// How much to decrease agc_base_gain per frame when saturated (attack).
uint8_t gain_step_down;
// How much to increase agc_base_gain per frame when recovering (decay).
uint8_t gain_step_up;
// Minimum allowed agc_base_gain (floor).
uint8_t min_gain;
// Maximum allowed agc_base_gain (ceiling).
uint8_t max_gain;
// Number of consecutive non-saturated frames required before gain-up.
uint8_t holdoff_frames;
// Master enable. When false, update() is a no-op.
bool enabled;
// --- Runtime state (read-only for diagnostics) ---
// Consecutive non-saturated frame counter (resets on saturation).
uint8_t holdoff_counter;
// True if the last update() saw saturation.
bool last_saturated;
// Total saturation events since reset/construction.
uint32_t saturation_event_count;
// --- Methods ---
ADAR1000_AGC();
// Call once per frame after runRadarPulseSequence().
// fpga_saturation: result of HAL_GPIO_ReadPin(GPIOD, GPIO_PIN_13) == GPIO_PIN_SET
void update(bool fpga_saturation);
// Apply the current gain to all 16 RX VGA channels via the Manager.
void applyGain(ADAR1000Manager &mgr);
// Reset runtime state (holdoff counter, saturation count) without
// changing configuration.
void resetState();
// Compute the effective gain for a specific channel index (0-15),
// clamped to [min_gain, max_gain]. Useful for diagnostics.
uint8_t effectiveGain(uint8_t channel_index) const;
};
#endif // ADAR1000_AGC_H
@@ -7,8 +7,8 @@ RadarSettings::RadarSettings() {
void RadarSettings::resetToDefaults() { void RadarSettings::resetToDefaults() {
system_frequency = 10.0e9; // 10 GHz system_frequency = 10.0e9; // 10 GHz
chirp_duration_1 = 30.0e-6; // 30 s chirp_duration_1 = 30.0e-6; // 30 us
chirp_duration_2 = 0.5e-6; // 0.5 s chirp_duration_2 = 0.5e-6; // 0.5 us
chirps_per_position = 32; chirps_per_position = 32;
freq_min = 10.0e6; // 10 MHz freq_min = 10.0e6; // 10 MHz
freq_max = 30.0e6; // 30 MHz freq_max = 30.0e6; // 30 MHz
@@ -43,11 +43,6 @@ void USBHandler::processStartFlag(const uint8_t* data, uint32_t length) {
// Start flag: bytes [23, 46, 158, 237] // Start flag: bytes [23, 46, 158, 237]
const uint8_t START_FLAG[] = {23, 46, 158, 237}; const uint8_t START_FLAG[] = {23, 46, 158, 237};
// Guard: need at least 4 bytes to contain a start flag.
// Without this, length - 4 wraps to ~4 billion (uint32_t unsigned underflow)
// and the loop reads far past the buffer boundary.
if (length < 4) return;
// Check if start flag is in the received data // Check if start flag is in the received data
for (uint32_t i = 0; i <= length - 4; i++) { for (uint32_t i = 0; i <= length - 4; i++) {
if (memcmp(data + i, START_FLAG, 4) == 0) { if (memcmp(data + i, START_FLAG, 4) == 0) {
@@ -23,7 +23,6 @@
#include "usbd_cdc_if.h" #include "usbd_cdc_if.h"
#include "adar1000.h" #include "adar1000.h"
#include "ADAR1000_Manager.h" #include "ADAR1000_Manager.h"
#include "ADAR1000_AGC.h"
extern "C" { extern "C" {
#include "ad9523.h" #include "ad9523.h"
} }
@@ -225,7 +224,6 @@ extern SPI_HandleTypeDef hspi4;
//ADAR1000 //ADAR1000
ADAR1000Manager adarManager; ADAR1000Manager adarManager;
ADAR1000_AGC outerAgc;
static uint8_t matrix1[15][16]; static uint8_t matrix1[15][16];
static uint8_t matrix2[15][16]; static uint8_t matrix2[15][16];
static uint8_t vector_0[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; static uint8_t vector_0[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
@@ -641,7 +639,6 @@ SystemError_t checkSystemHealth(void) {
if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) { if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) {
current_error = ERROR_AD9523_CLOCK; current_error = ERROR_AD9523_CLOCK;
DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1); DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1);
return current_error;
} }
last_clock_check = HAL_GetTick(); last_clock_check = HAL_GetTick();
} }
@@ -652,12 +649,10 @@ SystemError_t checkSystemHealth(void) {
if (!tx_locked) { if (!tx_locked) {
current_error = ERROR_ADF4382_TX_UNLOCK; current_error = ERROR_ADF4382_TX_UNLOCK;
DIAG_ERR("LO", "Health check: TX LO UNLOCKED"); DIAG_ERR("LO", "Health check: TX LO UNLOCKED");
return current_error;
} }
if (!rx_locked) { if (!rx_locked) {
current_error = ERROR_ADF4382_RX_UNLOCK; current_error = ERROR_ADF4382_RX_UNLOCK;
DIAG_ERR("LO", "Health check: RX LO UNLOCKED"); DIAG_ERR("LO", "Health check: RX LO UNLOCKED");
return current_error;
} }
} }
@@ -666,14 +661,14 @@ SystemError_t checkSystemHealth(void) {
if (!adarManager.verifyDeviceCommunication(i)) { if (!adarManager.verifyDeviceCommunication(i)) {
current_error = ERROR_ADAR1000_COMM; current_error = ERROR_ADAR1000_COMM;
DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i); DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i);
return current_error; break;
} }
float temp = adarManager.readTemperature(i); float temp = adarManager.readTemperature(i);
if (temp > 85.0f) { if (temp > 85.0f) {
current_error = ERROR_ADAR1000_TEMP; current_error = ERROR_ADAR1000_TEMP;
DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp); DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp);
return current_error; break;
} }
} }
@@ -683,7 +678,6 @@ SystemError_t checkSystemHealth(void) {
if (!GY85_Update(&imu)) { if (!GY85_Update(&imu)) {
current_error = ERROR_IMU_COMM; current_error = ERROR_IMU_COMM;
DIAG_ERR("IMU", "Health check: GY85_Update() FAILED"); DIAG_ERR("IMU", "Health check: GY85_Update() FAILED");
return current_error;
} }
last_imu_check = HAL_GetTick(); last_imu_check = HAL_GetTick();
} }
@@ -695,7 +689,6 @@ SystemError_t checkSystemHealth(void) {
if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) { if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) {
current_error = ERROR_BMP180_COMM; current_error = ERROR_BMP180_COMM;
DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure); DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure);
return current_error;
} }
last_bmp_check = HAL_GetTick(); last_bmp_check = HAL_GetTick();
} }
@@ -708,7 +701,6 @@ SystemError_t checkSystemHealth(void) {
if (HAL_GetTick() - last_gps_fix > 30000) { if (HAL_GetTick() - last_gps_fix > 30000) {
current_error = ERROR_GPS_COMM; current_error = ERROR_GPS_COMM;
DIAG_WARN("SYS", "Health check: GPS no fix for >30s"); DIAG_WARN("SYS", "Health check: GPS no fix for >30s");
return current_error;
} }
// 7. Check RF Power Amplifier Current // 7. Check RF Power Amplifier Current
@@ -717,12 +709,12 @@ SystemError_t checkSystemHealth(void) {
if (Idq_reading[i] > 2.5f) { if (Idq_reading[i] > 2.5f) {
current_error = ERROR_RF_PA_OVERCURRENT; current_error = ERROR_RF_PA_OVERCURRENT;
DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]); DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]);
return current_error; break;
} }
if (Idq_reading[i] < 0.1f) { if (Idq_reading[i] < 0.1f) {
current_error = ERROR_RF_PA_BIAS; current_error = ERROR_RF_PA_BIAS;
DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]); DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]);
return current_error; break;
} }
} }
} }
@@ -731,7 +723,6 @@ SystemError_t checkSystemHealth(void) {
if (temperature > 75.0f) { if (temperature > 75.0f) {
current_error = ERROR_TEMPERATURE_HIGH; current_error = ERROR_TEMPERATURE_HIGH;
DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature); DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature);
return current_error;
} }
// 9. Simple watchdog check // 9. Simple watchdog check
@@ -739,7 +730,6 @@ SystemError_t checkSystemHealth(void) {
if (HAL_GetTick() - last_health_check > 60000) { if (HAL_GetTick() - last_health_check > 60000) {
current_error = ERROR_WATCHDOG_TIMEOUT; current_error = ERROR_WATCHDOG_TIMEOUT;
DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)"); DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)");
return current_error;
} }
last_health_check = HAL_GetTick(); last_health_check = HAL_GetTick();
@@ -929,41 +919,38 @@ bool checkSystemHealthStatus(void) {
// Get system status for GUI // Get system status for GUI
// Get system status for GUI with 8 temperature variables // Get system status for GUI with 8 temperature variables
void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) { void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
// Build status string directly in the output buffer using offset-tracked char temp_buffer[200];
// snprintf. Each call returns the number of chars written (excluding NUL), char final_status[500] = "System Status: ";
// so we advance 'off' and shrink 'rem' to guarantee we never overflow.
size_t off = 0;
size_t rem = buffer_size;
int w;
// Basic status // Basic status
if (system_emergency_state) { if (system_emergency_state) {
w = snprintf(status_buffer + off, rem, "System Status: EMERGENCY_STOP|"); strcat(final_status, "EMERGENCY_STOP|");
} else { } else {
w = snprintf(status_buffer + off, rem, "System Status: NORMAL|"); strcat(final_status, "NORMAL|");
} }
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// Error information // Error information
w = snprintf(status_buffer + off, rem, "LastError:%d|ErrorCount:%lu|", snprintf(temp_buffer, sizeof(temp_buffer), "LastError:%d|ErrorCount:%lu|",
last_error, error_count); last_error, error_count);
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; } strcat(final_status, temp_buffer);
// Sensor status // Sensor status
w = snprintf(status_buffer + off, rem, "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|", snprintf(temp_buffer, sizeof(temp_buffer), "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|",
Pitch_Sensor, Roll_Sensor, Yaw_Sensor, Pitch_Sensor, Roll_Sensor, Yaw_Sensor,
RADAR_Latitude, RADAR_Longitude, RADAR_Altitude); RADAR_Latitude, RADAR_Longitude, RADAR_Altitude);
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; } strcat(final_status, temp_buffer);
// LO Status // LO Status
bool tx_locked, rx_locked; bool tx_locked, rx_locked;
ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked); ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked);
w = snprintf(status_buffer + off, rem, "LO_TX:%s|LO_RX:%s|", snprintf(temp_buffer, sizeof(temp_buffer), "LO_TX:%s|LO_RX:%s|",
tx_locked ? "LOCKED" : "UNLOCKED", tx_locked ? "LOCKED" : "UNLOCKED",
rx_locked ? "LOCKED" : "UNLOCKED"); rx_locked ? "LOCKED" : "UNLOCKED");
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; } strcat(final_status, temp_buffer);
// Temperature readings (8 variables) // Temperature readings (8 variables)
// You'll need to populate these temperature values from your sensors
// For now, I'll show how to format them - replace with actual temperature readings
Temperature_1 = ADS7830_Measure_SingleEnded(&hadc3, 0); Temperature_1 = ADS7830_Measure_SingleEnded(&hadc3, 0);
Temperature_2 = ADS7830_Measure_SingleEnded(&hadc3, 1); Temperature_2 = ADS7830_Measure_SingleEnded(&hadc3, 1);
Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2); Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2);
@@ -974,11 +961,11 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7); Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7);
// Format all 8 temperature variables // Format all 8 temperature variables
w = snprintf(status_buffer + off, rem, snprintf(temp_buffer, sizeof(temp_buffer),
"T1:%.1f|T2:%.1f|T3:%.1f|T4:%.1f|T5:%.1f|T6:%.1f|T7:%.1f|T8:%.1f|", "T1:%.1f|T2:%.1f|T3:%.1f|T4:%.1f|T5:%.1f|T6:%.1f|T7:%.1f|T8:%.1f|",
Temperature_1, Temperature_2, Temperature_3, Temperature_4, Temperature_1, Temperature_2, Temperature_3, Temperature_4,
Temperature_5, Temperature_6, Temperature_7, Temperature_8); Temperature_5, Temperature_6, Temperature_7, Temperature_8);
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; } strcat(final_status, temp_buffer);
// RF Power Amplifier status (if enabled) // RF Power Amplifier status (if enabled)
if (PowerAmplifier) { if (PowerAmplifier) {
@@ -988,17 +975,18 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
} }
avg_current /= 16.0f; avg_current /= 16.0f;
w = snprintf(status_buffer + off, rem, "PA_AvgCurrent:%.2f|PA_Enabled:%d|", snprintf(temp_buffer, sizeof(temp_buffer), "PA_AvgCurrent:%.2f|PA_Enabled:%d|",
avg_current, PowerAmplifier); avg_current, PowerAmplifier);
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; } strcat(final_status, temp_buffer);
} }
// Radar operation status // Radar operation status
w = snprintf(status_buffer + off, rem, "BeamPos:%d|Azimuth:%d|ChirpCount:%d|", snprintf(temp_buffer, sizeof(temp_buffer), "BeamPos:%d|Azimuth:%d|ChirpCount:%d|",
n, y, m); n, y, m);
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; } strcat(final_status, temp_buffer);
// NUL termination guaranteed by snprintf, but be safe // Copy to output buffer
strncpy(status_buffer, final_status, buffer_size - 1);
status_buffer[buffer_size - 1] = '\0'; status_buffer[buffer_size - 1] = '\0';
} }
@@ -2007,13 +1995,12 @@ int main(void)
HAL_UART_Transmit(&huart3, (uint8_t*)emergency_msg, strlen(emergency_msg), 1000); HAL_UART_Transmit(&huart3, (uint8_t*)emergency_msg, strlen(emergency_msg), 1000);
DIAG_ERR("SYS", "SAFE MODE ACTIVE -- blinking all LEDs, waiting for system_emergency_state clear"); DIAG_ERR("SYS", "SAFE MODE ACTIVE -- blinking all LEDs, waiting for system_emergency_state clear");
// Blink all LEDs to indicate safe mode (500ms period, visible to operator) // Blink all LEDs to indicate safe mode
while (system_emergency_state) { while (system_emergency_state) {
HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin); HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin);
HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin); HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin);
HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin); HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin); HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin);
HAL_Delay(250);
} }
DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared"); DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared");
} }
@@ -2127,16 +2114,6 @@ int main(void)
runRadarPulseSequence(); runRadarPulseSequence();
/* [AGC] Outer-loop AGC: read FPGA saturation flag (DIG_5 / PD13),
* adjust ADAR1000 VGA common gain once per radar frame (~258 ms).
* Only run when AGC is enabled — otherwise leave VGA gains untouched. */
if (outerAgc.enabled) {
bool sat = HAL_GPIO_ReadPin(FPGA_DIG5_SAT_GPIO_Port,
FPGA_DIG5_SAT_Pin) == GPIO_PIN_SET;
outerAgc.update(sat);
outerAgc.applyGain(adarManager);
}
/* [GAP-3 FIX 2] Kick hardware watchdog — if we don't reach here within /* [GAP-3 FIX 2] Kick hardware watchdog — if we don't reach here within
* ~4 s, the IWDG resets the MCU automatically. */ * ~4 s, the IWDG resets the MCU automatically. */
HAL_IWDG_Refresh(&hiwdg); HAL_IWDG_Refresh(&hiwdg);
@@ -141,15 +141,6 @@ void Error_Handler(void);
#define EN_DIS_RFPA_VDD_GPIO_Port GPIOD #define EN_DIS_RFPA_VDD_GPIO_Port GPIOD
#define EN_DIS_COOLING_Pin GPIO_PIN_7 #define EN_DIS_COOLING_Pin GPIO_PIN_7
#define EN_DIS_COOLING_GPIO_Port GPIOD #define EN_DIS_COOLING_GPIO_Port GPIOD
/* FPGA digital I/O (directly connected GPIOs) */
#define FPGA_DIG5_SAT_Pin GPIO_PIN_13
#define FPGA_DIG5_SAT_GPIO_Port GPIOD
#define FPGA_DIG6_Pin GPIO_PIN_14
#define FPGA_DIG6_GPIO_Port GPIOD
#define FPGA_DIG7_Pin GPIO_PIN_15
#define FPGA_DIG7_GPIO_Port GPIOD
#define ADF4382_RX_CE_Pin GPIO_PIN_9 #define ADF4382_RX_CE_Pin GPIO_PIN_9
#define ADF4382_RX_CE_GPIO_Port GPIOG #define ADF4382_RX_CE_GPIO_Port GPIOG
#define ADF4382_RX_CS_Pin GPIO_PIN_10 #define ADF4382_RX_CS_Pin GPIO_PIN_10
+1 -29
View File
@@ -16,17 +16,10 @@
################################################################################ ################################################################################
CC := cc CC := cc
CXX := c++
CFLAGS := -std=c11 -Wall -Wextra -Wno-unused-parameter -g -O0 CFLAGS := -std=c11 -Wall -Wextra -Wno-unused-parameter -g -O0
CXXFLAGS := -std=c++17 -Wall -Wextra -Wno-unused-parameter -g -O0
# Shim headers come FIRST so they override real headers # Shim headers come FIRST so they override real headers
INCLUDES := -Ishims -I. -I../9_1_1_C_Cpp_Libraries INCLUDES := -Ishims -I. -I../9_1_1_C_Cpp_Libraries
# C++ library directory (AGC, ADAR1000 Manager)
CXX_LIB_DIR := ../9_1_1_C_Cpp_Libraries
CXX_SRCS := $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.cpp
CXX_OBJS := ADAR1000_AGC.o ADAR1000_Manager.o
# Real source files compiled against mock headers # Real source files compiled against mock headers
REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c
@@ -69,10 +62,7 @@ TESTS_STANDALONE := test_bug12_pa_cal_loop_inverted \
# Tests that need platform_noos_stm32.o + mocks # Tests that need platform_noos_stm32.o + mocks
TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only
# C++ tests (AGC outer loop) ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM)
TESTS_WITH_CXX := test_agc_outer_loop
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) $(TESTS_WITH_CXX)
.PHONY: all build test clean \ .PHONY: all build test clean \
$(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \ $(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \
@@ -166,24 +156,6 @@ test_gap3_emergency_state_ordering: test_gap3_emergency_state_ordering.c
$(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ) $(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ)
$(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@ $(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@
# --- C++ object rules ---
ADAR1000_AGC.o: $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_AGC.h
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
ADAR1000_Manager.o: $(CXX_LIB_DIR)/ADAR1000_Manager.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.h
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
# --- C++ test binary rules ---
test_agc_outer_loop: test_agc_outer_loop.cpp $(CXX_OBJS) $(MOCK_OBJS)
$(CXX) $(CXXFLAGS) $(INCLUDES) $< $(CXX_OBJS) $(MOCK_OBJS) -o $@
# Convenience target
.PHONY: test_agc
test_agc: test_agc_outer_loop
./test_agc_outer_loop
# --- Individual test targets --- # --- Individual test targets ---
test_bug1: test_bug1_timed_sync_init_ordering test_bug1: test_bug1_timed_sync_init_ordering
@@ -129,14 +129,6 @@ void Error_Handler(void);
#define GYR_INT_Pin GPIO_PIN_8 #define GYR_INT_Pin GPIO_PIN_8
#define GYR_INT_GPIO_Port GPIOC #define GYR_INT_GPIO_Port GPIOC
/* FPGA digital I/O (directly connected GPIOs) */
#define FPGA_DIG5_SAT_Pin GPIO_PIN_13
#define FPGA_DIG5_SAT_GPIO_Port GPIOD
#define FPGA_DIG6_Pin GPIO_PIN_14
#define FPGA_DIG6_GPIO_Port GPIOD
#define FPGA_DIG7_Pin GPIO_PIN_15
#define FPGA_DIG7_GPIO_Port GPIOD
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
@@ -175,7 +175,7 @@ void HAL_Delay(uint32_t Delay)
mock_tick += Delay; mock_tick += Delay;
} }
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData,
uint16_t Size, uint32_t Timeout) uint16_t Size, uint32_t Timeout)
{ {
spy_push((SpyRecord){ spy_push((SpyRecord){
@@ -34,10 +34,6 @@ typedef uint32_t HAL_StatusTypeDef;
#define HAL_MAX_DELAY 0xFFFFFFFFU #define HAL_MAX_DELAY 0xFFFFFFFFU
#ifndef __NOP
#define __NOP() ((void)0)
#endif
/* ========================= GPIO Types ============================ */ /* ========================= GPIO Types ============================ */
typedef struct { typedef struct {
@@ -186,7 +182,7 @@ GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
uint32_t HAL_GetTick(void); uint32_t HAL_GetTick(void);
void HAL_Delay(uint32_t Delay); void HAL_Delay(uint32_t Delay);
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* ========================= SPI stubs ============================== */ /* ========================= SPI stubs ============================== */
@@ -1,361 +0,0 @@
// test_agc_outer_loop.cpp -- C++ unit tests for ADAR1000_AGC outer-loop AGC
//
// Tests the STM32 outer-loop AGC class that adjusts ADAR1000 VGA gain based
// on the FPGA's saturation flag. Uses the existing HAL mock/spy framework.
//
// Build: c++ -std=c++17 ... (see Makefile TESTS_WITH_CXX rule)
#include <cassert>
#include <cstdio>
#include <cstring>
// Shim headers override real STM32/diag headers
#include "stm32_hal_mock.h"
#include "ADAR1000_AGC.h"
#include "ADAR1000_Manager.h"
// ---------------------------------------------------------------------------
// Linker symbols required by ADAR1000_Manager.cpp (pulled in via main.h shim)
// ---------------------------------------------------------------------------
uint8_t GUI_start_flag_received = 0;
uint8_t USB_Buffer[64] = {0};
extern "C" void Error_Handler(void) {}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static int tests_passed = 0;
static int tests_total = 0;
#define RUN_TEST(fn) \
do { \
tests_total++; \
printf(" [%2d] %-55s ", tests_total, #fn); \
fn(); \
tests_passed++; \
printf("PASS\n"); \
} while (0)
// ---------------------------------------------------------------------------
// Test 1: Default construction matches design spec
// ---------------------------------------------------------------------------
static void test_defaults()
{
ADAR1000_AGC agc;
assert(agc.agc_base_gain == 30); // kDefaultRxVgaGain
assert(agc.gain_step_down == 4);
assert(agc.gain_step_up == 1);
assert(agc.min_gain == 0);
assert(agc.max_gain == 127);
assert(agc.holdoff_frames == 4);
assert(agc.enabled == true);
assert(agc.holdoff_counter == 0);
assert(agc.last_saturated == false);
assert(agc.saturation_event_count == 0);
// All cal offsets zero
for (int i = 0; i < AGC_TOTAL_CHANNELS; ++i) {
assert(agc.cal_offset[i] == 0);
}
}
// ---------------------------------------------------------------------------
// Test 2: Saturation reduces gain by step_down
// ---------------------------------------------------------------------------
static void test_saturation_reduces_gain()
{
ADAR1000_AGC agc;
uint8_t initial = agc.agc_base_gain; // 30
agc.update(true); // saturation
assert(agc.agc_base_gain == initial - agc.gain_step_down); // 26
assert(agc.last_saturated == true);
assert(agc.holdoff_counter == 0);
}
// ---------------------------------------------------------------------------
// Test 3: Holdoff prevents premature gain-up
// ---------------------------------------------------------------------------
static void test_holdoff_prevents_early_gain_up()
{
ADAR1000_AGC agc;
agc.update(true); // saturate once -> gain = 26
uint8_t after_sat = agc.agc_base_gain;
// Feed (holdoff_frames - 1) clear frames — should NOT increase gain
for (uint8_t i = 0; i < agc.holdoff_frames - 1; ++i) {
agc.update(false);
assert(agc.agc_base_gain == after_sat);
}
// holdoff_counter should be holdoff_frames - 1
assert(agc.holdoff_counter == agc.holdoff_frames - 1);
}
// ---------------------------------------------------------------------------
// Test 4: Recovery after holdoff period
// ---------------------------------------------------------------------------
static void test_recovery_after_holdoff()
{
ADAR1000_AGC agc;
agc.update(true); // saturate -> gain = 26
uint8_t after_sat = agc.agc_base_gain;
// Feed exactly holdoff_frames clear frames
for (uint8_t i = 0; i < agc.holdoff_frames; ++i) {
agc.update(false);
}
assert(agc.agc_base_gain == after_sat + agc.gain_step_up); // 27
assert(agc.holdoff_counter == 0); // reset after recovery
}
// ---------------------------------------------------------------------------
// Test 5: Min gain clamping
// ---------------------------------------------------------------------------
static void test_min_gain_clamp()
{
ADAR1000_AGC agc;
agc.min_gain = 10;
agc.agc_base_gain = 12;
agc.gain_step_down = 4;
agc.update(true); // 12 - 4 = 8, but min = 10
assert(agc.agc_base_gain == 10);
agc.update(true); // already at min
assert(agc.agc_base_gain == 10);
}
// ---------------------------------------------------------------------------
// Test 6: Max gain clamping
// ---------------------------------------------------------------------------
static void test_max_gain_clamp()
{
ADAR1000_AGC agc;
agc.max_gain = 32;
agc.agc_base_gain = 31;
agc.gain_step_up = 2;
agc.holdoff_frames = 1; // immediate recovery
agc.update(false); // 31 + 2 = 33, but max = 32
assert(agc.agc_base_gain == 32);
agc.update(false); // already at max
assert(agc.agc_base_gain == 32);
}
// ---------------------------------------------------------------------------
// Test 7: Per-channel calibration offsets
// ---------------------------------------------------------------------------
static void test_calibration_offsets()
{
ADAR1000_AGC agc;
agc.agc_base_gain = 30;
agc.min_gain = 0;
agc.max_gain = 60;
agc.cal_offset[0] = 5; // 30 + 5 = 35
agc.cal_offset[1] = -10; // 30 - 10 = 20
agc.cal_offset[15] = 40; // 30 + 40 = 60 (clamped to max)
assert(agc.effectiveGain(0) == 35);
assert(agc.effectiveGain(1) == 20);
assert(agc.effectiveGain(15) == 60); // clamped to max_gain
// Negative clamp
agc.cal_offset[2] = -50; // 30 - 50 = -20, clamped to min_gain = 0
assert(agc.effectiveGain(2) == 0);
// Out-of-range index returns min_gain
assert(agc.effectiveGain(16) == agc.min_gain);
}
// ---------------------------------------------------------------------------
// Test 8: Disabled AGC is a no-op
// ---------------------------------------------------------------------------
static void test_disabled_noop()
{
ADAR1000_AGC agc;
agc.enabled = false;
uint8_t original = agc.agc_base_gain;
agc.update(true); // should be ignored
assert(agc.agc_base_gain == original);
assert(agc.last_saturated == false); // not updated when disabled
assert(agc.saturation_event_count == 0);
agc.update(false); // also ignored
assert(agc.agc_base_gain == original);
}
// ---------------------------------------------------------------------------
// Test 9: applyGain() produces correct SPI writes
// ---------------------------------------------------------------------------
static void test_apply_gain_spi()
{
spy_reset();
ADAR1000Manager mgr; // creates 4 devices
ADAR1000_AGC agc;
agc.agc_base_gain = 42;
agc.applyGain(mgr);
// Each channel: adarSetRxVgaGain -> adarWrite(gain) + adarWrite(LOAD_WORKING)
// Each adarWrite: CS_low (GPIO_WRITE) + SPI_TRANSMIT + CS_high (GPIO_WRITE)
// = 3 spy records per adarWrite
// = 6 spy records per channel
// = 16 channels * 6 = 96 total spy records
// Verify SPI transmit count: 2 SPI calls per channel * 16 channels = 32
int spi_count = spy_count_type(SPY_SPI_TRANSMIT);
assert(spi_count == 32);
// Verify GPIO write count: 4 GPIO writes per channel (CS low + CS high for each of 2 adarWrite calls)
int gpio_writes = spy_count_type(SPY_GPIO_WRITE);
assert(gpio_writes == 64); // 16 ch * 2 adarWrite * 2 GPIO each
}
// ---------------------------------------------------------------------------
// Test 10: resetState() clears counters but preserves config
// ---------------------------------------------------------------------------
static void test_reset_preserves_config()
{
ADAR1000_AGC agc;
agc.agc_base_gain = 42;
agc.gain_step_down = 8;
agc.cal_offset[3] = -5;
// Generate some state
agc.update(true);
agc.update(true);
assert(agc.saturation_event_count == 2);
assert(agc.last_saturated == true);
agc.resetState();
// State cleared
assert(agc.holdoff_counter == 0);
assert(agc.last_saturated == false);
assert(agc.saturation_event_count == 0);
// Config preserved
assert(agc.agc_base_gain == 42 - 8 - 8); // two saturations applied before reset
assert(agc.gain_step_down == 8);
assert(agc.cal_offset[3] == -5);
}
// ---------------------------------------------------------------------------
// Test 11: Saturation counter increments correctly
// ---------------------------------------------------------------------------
static void test_saturation_counter()
{
ADAR1000_AGC agc;
for (int i = 0; i < 10; ++i) {
agc.update(true);
}
assert(agc.saturation_event_count == 10);
// Clear frames don't increment saturation count
for (int i = 0; i < 5; ++i) {
agc.update(false);
}
assert(agc.saturation_event_count == 10);
}
// ---------------------------------------------------------------------------
// Test 12: Mixed saturation/clear sequence
// ---------------------------------------------------------------------------
static void test_mixed_sequence()
{
ADAR1000_AGC agc;
agc.agc_base_gain = 30;
agc.gain_step_down = 4;
agc.gain_step_up = 1;
agc.holdoff_frames = 3;
// Saturate: 30 -> 26
agc.update(true);
assert(agc.agc_base_gain == 26);
assert(agc.holdoff_counter == 0);
// 2 clear frames (not enough for recovery)
agc.update(false);
agc.update(false);
assert(agc.agc_base_gain == 26);
assert(agc.holdoff_counter == 2);
// Saturate again: 26 -> 22, counter resets
agc.update(true);
assert(agc.agc_base_gain == 22);
assert(agc.holdoff_counter == 0);
assert(agc.saturation_event_count == 2);
// 3 clear frames -> recovery: 22 -> 23
agc.update(false);
agc.update(false);
agc.update(false);
assert(agc.agc_base_gain == 23);
assert(agc.holdoff_counter == 0);
// 3 more clear -> 23 -> 24
agc.update(false);
agc.update(false);
agc.update(false);
assert(agc.agc_base_gain == 24);
}
// ---------------------------------------------------------------------------
// Test 13: Effective gain with edge-case base_gain values
// ---------------------------------------------------------------------------
static void test_effective_gain_edge_cases()
{
ADAR1000_AGC agc;
agc.min_gain = 5;
agc.max_gain = 250;
// Base gain at zero with positive offset
agc.agc_base_gain = 0;
agc.cal_offset[0] = 3;
assert(agc.effectiveGain(0) == 5); // 0 + 3 = 3, clamped to min_gain=5
// Base gain at max with zero offset
agc.agc_base_gain = 250;
agc.cal_offset[0] = 0;
assert(agc.effectiveGain(0) == 250);
// Base gain at max with positive offset -> clamped
agc.agc_base_gain = 250;
agc.cal_offset[0] = 10;
assert(agc.effectiveGain(0) == 250); // clamped to max_gain
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
int main()
{
printf("=== ADAR1000_AGC Outer-Loop Unit Tests ===\n");
RUN_TEST(test_defaults);
RUN_TEST(test_saturation_reduces_gain);
RUN_TEST(test_holdoff_prevents_early_gain_up);
RUN_TEST(test_recovery_after_holdoff);
RUN_TEST(test_min_gain_clamp);
RUN_TEST(test_max_gain_clamp);
RUN_TEST(test_calibration_offsets);
RUN_TEST(test_disabled_noop);
RUN_TEST(test_apply_gain_spi);
RUN_TEST(test_reset_preserves_config);
RUN_TEST(test_saturation_counter);
RUN_TEST(test_mixed_sequence);
RUN_TEST(test_effective_gain_edge_cases);
printf("=== Results: %d/%d passed ===\n", tests_passed, tests_total);
return (tests_passed == tests_total) ? 0 : 1;
}
-5
View File
@@ -212,11 +212,6 @@ BUFG bufg_feedback (
// ---- Output BUFG ---- // ---- Output BUFG ----
// Routes the jitter-cleaned 400 MHz CLKOUT0 onto a global clock network. // Routes the jitter-cleaned 400 MHz CLKOUT0 onto a global clock network.
// DONT_TOUCH prevents phys_opt_design AggressiveExplore from replicating this
// BUFG into a cascaded chain (4 BUFGs in series observed in Build 26), which
// added ~243ps of clock insertion delay and caused -187ps clock skew on the
// NCODSP mixer critical path.
(* DONT_TOUCH = "TRUE" *)
BUFG bufg_clk400m ( BUFG bufg_clk400m (
.I(clk_mmcm_out0), .I(clk_mmcm_out0),
.O(clk_400m_out) .O(clk_400m_out)
@@ -66,13 +66,13 @@ reg signed [COMB_WIDTH-1:0] comb_delay [0:STAGES-1][0:COMB_DELAY-1];
// Pipeline valid for comb stages 1-4: delayed by 1 cycle vs comb_pipe to // Pipeline valid for comb stages 1-4: delayed by 1 cycle vs comb_pipe to
// account for CREG+AREG+BREG pipeline inside comb_0_dsp (explicit DSP48E1). // account for CREG+AREG+BREG pipeline inside comb_0_dsp (explicit DSP48E1).
// Comb[0] result appears 1 cycle after data_valid_comb_pipe. // Comb[0] result appears 1 cycle after data_valid_comb_pipe.
(* keep = "true", max_fanout = 16 *) reg data_valid_comb_0_out; (* keep = "true", max_fanout = 4 *) reg data_valid_comb_0_out;
// Enhanced control and monitoring // Enhanced control and monitoring
reg [1:0] decimation_counter; reg [1:0] decimation_counter;
(* keep = "true", max_fanout = 16 *) reg data_valid_delayed; (* keep = "true", max_fanout = 4 *) reg data_valid_delayed;
(* keep = "true", max_fanout = 16 *) reg data_valid_comb; (* keep = "true", max_fanout = 4 *) reg data_valid_comb;
(* keep = "true", max_fanout = 16 *) reg data_valid_comb_pipe; (* keep = "true", max_fanout = 4 *) reg data_valid_comb_pipe;
reg [7:0] output_counter; reg [7:0] output_counter;
reg [ACC_WIDTH-1:0] max_integrator_value; reg [ACC_WIDTH-1:0] max_integrator_value;
reg overflow_detected; reg overflow_detected;
@@ -83,13 +83,3 @@ set_false_path -through [get_pins rx_inst/adc/mmcm_inst/mmcm_adc_400m/LOCKED]
# Waiving hold on these 8 paths (adc_d_p[0..7] → IDDR) is standard practice # Waiving hold on these 8 paths (adc_d_p[0..7] → IDDR) is standard practice
# for source-synchronous LVDS ADC interfaces using BUFIO capture. # for source-synchronous LVDS ADC interfaces using BUFIO capture.
set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p] set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p]
# --------------------------------------------------------------------------
# Timing margin for 400 MHz critical paths
# --------------------------------------------------------------------------
# Extra setup uncertainty forces Vivado to leave margin for temperature/voltage/
# aging variation. Reduced from 200 ps to 100 ps after NCO→mixer pipeline
# register fix eliminated the dominant timing bottleneck (WNS went from +0.002ns
# to comfortable margin). 100 ps still provides ~4% guardband on the 2.5ns period.
# This is additive to the existing jitter-based uncertainty (~53 ps).
set_clock_uncertainty -setup -add 0.100 [get_clocks clk_mmcm_out0]
@@ -222,16 +222,8 @@ set_property IOSTANDARD LVCMOS33 [get_ports {stm32_new_*}]
set_property IOSTANDARD LVCMOS33 [get_ports {stm32_mixers_enable}] set_property IOSTANDARD LVCMOS33 [get_ports {stm32_mixers_enable}]
# reset_n is DIG_4 (PD12) — constrained above in the RESET section # reset_n is DIG_4 (PD12) — constrained above in the RESET section
# DIG_5 = H11, DIG_6 = G12, DIG_7 = H12 — FPGA→STM32 status outputs # DIG_5 = H11, DIG_6 = G12, DIG_7 = H12 — available for FPGA→STM32 status
# DIG_5: AGC saturation flag (PD13 on STM32) # Currently unused in RTL. Could be connected to status outputs if needed.
# DIG_6: reserved (PD14)
# DIG_7: reserved (PD15)
set_property PACKAGE_PIN H11 [get_ports {gpio_dig5}]
set_property PACKAGE_PIN G12 [get_ports {gpio_dig6}]
set_property PACKAGE_PIN H12 [get_ports {gpio_dig7}]
set_property IOSTANDARD LVCMOS33 [get_ports {gpio_dig*}]
set_property DRIVE 8 [get_ports {gpio_dig*}]
set_property SLEW SLOW [get_ports {gpio_dig*}]
# ============================================================================ # ============================================================================
# ADC INTERFACE (LVDS — Bank 14, VCCO=3.3V) # ADC INTERFACE (LVDS — Bank 14, VCCO=3.3V)
+16 -46
View File
@@ -102,19 +102,14 @@ wire signed [17:0] debug_mixed_q_trunc;
reg [7:0] signal_power_i, signal_power_q; reg [7:0] signal_power_i, signal_power_q;
// Internal mixing signals // Internal mixing signals
// Pipeline: NCO fabric reg (1) + DSP48E1 AREG/BREG (1) + MREG (1) + PREG (1) + retiming (1) = 5 cycles // DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 handles all internal pipelining
// The NCO fabric pipeline register was added to break the long NCODSP B-port route // Latency: 4 cycles (1 for AREG/BREG, 1 for MREG, 1 for PREG, 1 for post-DSP retiming)
// (1.505ns routing in Build 26, WNS=+0.002ns). With BREG=1 still active inside the DSP,
// total latency increases by 1 cycle (2.5ns at 400MHz negligible for radar).
wire signed [MIXER_WIDTH-1:0] adc_signed_w; wire signed [MIXER_WIDTH-1:0] adc_signed_w;
reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q; reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q;
reg mixed_valid; reg mixed_valid;
reg mixer_overflow_i, mixer_overflow_q; reg mixer_overflow_i, mixer_overflow_q;
// Pipeline valid tracking: 5-stage shift register (1 NCO pipe + 3 DSP48E1 + 1 retiming) // Pipeline valid tracking: 4-stage shift register (3 for DSP48E1 + 1 for post-DSP retiming)
reg [4:0] dsp_valid_pipe; reg [3:0] dsp_valid_pipe;
// NCODSP 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 CLKP to fabric timing path // Post-DSP retiming registers breaks DSP48E1 CLKP to fabric timing path
// This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing, // This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing,
// ensuring WNS > 0 at 400 MHz regardless of placement seed // ensuring WNS > 0 at 400 MHz regardless of placement seed
@@ -215,11 +210,11 @@ nco_400m_enhanced nco_core (
// //
// Architecture: // Architecture:
// ADC data sign-extend to 18b DSP48E1 A-port (AREG=1 pipelines it) // ADC data sign-extend to 18b DSP48E1 A-port (AREG=1 pipelines it)
// NCO cos/sin fabric pipeline reg DSP48E1 B-port (BREG=1 pipelines it) // NCO cos/sin sign-extend to 18b DSP48E1 B-port (BREG=1 pipelines it)
// Multiply result captured by MREG=1, then output registered by PREG=1 // Multiply result captured by MREG=1, then output registered by PREG=1
// force_saturation override applied AFTER DSP48E1 output (not on input path) // force_saturation override applied AFTER DSP48E1 output (not on input path)
// //
// Latency: 4 clock cycles (1 NCO pipe + 1 AREG/BREG + 1 MREG + 1 PREG) + 1 retiming = 5 total // Latency: 3 clock cycles (AREG/BREG + MREG + PREG)
// PREG=1 absorbs DSP48E1 CLKP delay internally, preventing fabric timing violations // PREG=1 absorbs DSP48E1 CLKP delay internally, preventing fabric timing violations
// In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only // In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only
// ============================================================================ // ============================================================================
@@ -228,35 +223,24 @@ nco_400m_enhanced nco_core (
assign adc_signed_w = {1'b0, adc_data, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} - assign adc_signed_w = {1'b0, adc_data, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} -
{1'b0, {ADC_WIDTH{1'b1}}, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} / 2; {1'b0, {ADC_WIDTH{1'b1}}, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} / 2;
// Valid pipeline: 5-stage shift register (1 NCO pipe + 3 DSP48E1 AREG+MREG+PREG + 1 retiming) // Valid pipeline: 4-stage shift register (3 for DSP48E1 AREG+MREG+PREG + 1 for retiming)
always @(posedge clk_400m or negedge reset_n_400m) begin always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin if (!reset_n_400m) begin
dsp_valid_pipe <= 5'b00000; dsp_valid_pipe <= 4'b0000;
end else begin end else begin
dsp_valid_pipe <= {dsp_valid_pipe[3:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)}; dsp_valid_pipe <= {dsp_valid_pipe[2:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)};
end end
end end
`ifdef SIMULATION `ifdef SIMULATION
// ---- Behavioral model for Icarus Verilog simulation ---- // ---- Behavioral model for Icarus Verilog simulation ----
// Mimics NCO pipeline + DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (4-cycle DSP + 1 NCO pipe) // Mimics DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (3-cycle latency)
reg signed [MIXER_WIDTH-1:0] adc_signed_reg; // Models AREG reg signed [MIXER_WIDTH-1:0] adc_signed_reg; // Models AREG
reg signed [15:0] cos_pipe_reg, sin_pipe_reg; // Models BREG reg signed [15:0] cos_pipe_reg, sin_pipe_reg; // Models BREG
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_internal, mult_q_internal; // Models MREG reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_internal, mult_q_internal; // Models MREG
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_reg, mult_q_reg; // Models PREG reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_reg, mult_q_reg; // Models PREG
// Stage 0: NCO pipeline — breaks long NCO→DSP route (matches synthesis fabric registers) // Stage 1: AREG/BREG equivalent
always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin
cos_nco_pipe <= 0;
sin_nco_pipe <= 0;
end else begin
cos_nco_pipe <= cos_out;
sin_nco_pipe <= sin_out;
end
end
// Stage 1: AREG/BREG equivalent (uses pipelined NCO outputs)
always @(posedge clk_400m or negedge reset_n_400m) begin always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin if (!reset_n_400m) begin
adc_signed_reg <= 0; adc_signed_reg <= 0;
@@ -264,8 +248,8 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
sin_pipe_reg <= 0; sin_pipe_reg <= 0;
end else begin end else begin
adc_signed_reg <= adc_signed_w; adc_signed_reg <= adc_signed_w;
cos_pipe_reg <= cos_nco_pipe; cos_pipe_reg <= cos_out;
sin_pipe_reg <= sin_nco_pipe; sin_pipe_reg <= sin_out;
end end
end end
@@ -307,20 +291,6 @@ end
// This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz // This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz
wire [47:0] dsp_p_i, dsp_p_q; wire [47:0] dsp_p_i, dsp_p_q;
// NCO pipeline stage breaks the long NCO sin/cos DSP48E1 B-port route
// (1.505ns routing observed in Build 26). These fabric registers are placed
// near the DSP by the placer, splitting the route into two shorter segments.
// DONT_TOUCH on the reg declaration (above) prevents absorption/retiming.
always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin
cos_nco_pipe <= 0;
sin_nco_pipe <= 0;
end else begin
cos_nco_pipe <= cos_out;
sin_nco_pipe <= sin_out;
end
end
// DSP48E1 for I-channel mixer (adc_signed * cos_out) // DSP48E1 for I-channel mixer (adc_signed * cos_out)
DSP48E1 #( DSP48E1 #(
// Feature control attributes // Feature control attributes
@@ -380,7 +350,7 @@ DSP48E1 #(
.CEINMODE(1'b0), .CEINMODE(1'b0),
// Data ports // Data ports
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b .A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b
.B({{2{cos_nco_pipe[15]}}, cos_nco_pipe}), // Sign-extend 16b to 18b (pipelined) .B({{2{cos_out[15]}}, cos_out}), // Sign-extend 16b to 18b
.C(48'b0), .C(48'b0),
.D(25'b0), .D(25'b0),
.CARRYIN(1'b0), .CARRYIN(1'b0),
@@ -462,7 +432,7 @@ DSP48E1 #(
.CED(1'b0), .CED(1'b0),
.CEINMODE(1'b0), .CEINMODE(1'b0),
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), .A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}),
.B({{2{sin_nco_pipe[15]}}, sin_nco_pipe}), .B({{2{sin_out[15]}}, sin_out}),
.C(48'b0), .C(48'b0),
.D(25'b0), .D(25'b0),
.CARRYIN(1'b0), .CARRYIN(1'b0),
@@ -522,7 +492,7 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
mixer_overflow_q <= 0; mixer_overflow_q <= 0;
saturation_count <= 0; saturation_count <= 0;
overflow_detected <= 0; overflow_detected <= 0;
end else if (dsp_valid_pipe[4]) begin end else if (dsp_valid_pipe[3]) begin
// Force saturation for testing (applied after DSP output, not on input path) // Force saturation for testing (applied after DSP output, not on input path)
if (force_saturation_sync) begin if (force_saturation_sync) begin
mixed_i <= 34'h1FFFFFFFF; mixed_i <= 34'h1FFFFFFFF;
+1 -1
View File
@@ -296,7 +296,7 @@ always @(posedge clk or negedge reset_n) begin
state <= ST_DONE; state <= ST_DONE;
end end
end end
// Timeout: if no ADC data after 1000 cycles (10 us @ 100 MHz), FAIL // Timeout: if no ADC data after 10000 cycles, FAIL
step_cnt <= step_cnt + 1; step_cnt <= step_cnt + 1;
if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin
result_flags[4] <= 1'b0; result_flags[4] <= 1'b0;
+6 -37
View File
@@ -42,13 +42,6 @@ module radar_receiver_final (
// [2:0]=shift amount: 0..7 bits. Default 0 = pass-through. // [2:0]=shift amount: 0..7 bits. Default 0 = pass-through.
input wire [3:0] host_gain_shift, input wire [3:0] host_gain_shift,
// AGC configuration (opcodes 0x28-0x2C, active only when agc_enable=1)
input wire host_agc_enable, // 0x28: 0=manual, 1=auto AGC
input wire [7:0] host_agc_target, // 0x29: target peak magnitude
input wire [3:0] host_agc_attack, // 0x2A: gain-down step on clipping
input wire [3:0] host_agc_decay, // 0x2B: gain-up step when weak
input wire [3:0] host_agc_holdoff, // 0x2C: frames before gain-up
// STM32 toggle signals for mode 00 (STM32-driven) pass-through. // STM32 toggle signals for mode 00 (STM32-driven) pass-through.
// These are CDC-synchronized in radar_system_top.v / radar_transmitter.v // These are CDC-synchronized in radar_system_top.v / radar_transmitter.v
// before reaching this module. In mode 00, the RX mode controller uses // before reaching this module. In mode 00, the RX mode controller uses
@@ -67,12 +60,7 @@ module radar_receiver_final (
// ADC raw data tap (clk_100m domain, post-DDC, for self-test / debug) // ADC raw data tap (clk_100m domain, post-DDC, for self-test / debug)
output wire [15:0] dbg_adc_i, // DDC output I (16-bit signed, 100 MHz) output wire [15:0] dbg_adc_i, // DDC output I (16-bit signed, 100 MHz)
output wire [15:0] dbg_adc_q, // DDC output Q (16-bit signed, 100 MHz) output wire [15:0] dbg_adc_q, // DDC output Q (16-bit signed, 100 MHz)
output wire dbg_adc_valid, // DDC output valid (100 MHz) output wire dbg_adc_valid // DDC output valid (100 MHz)
// AGC status outputs (for status readback / STM32 outer loop)
output wire [7:0] agc_saturation_count, // Per-frame clipped sample count
output wire [7:0] agc_peak_magnitude, // Per-frame peak (upper 8 bits)
output wire [3:0] agc_current_gain // Effective gain_shift encoding
); );
// ========== INTERNAL SIGNALS ========== // ========== INTERNAL SIGNALS ==========
@@ -98,9 +86,7 @@ wire adc_valid_sync;
// Gain-controlled signals (between DDC output and matched filter) // Gain-controlled signals (between DDC output and matched filter)
wire signed [15:0] gc_i, gc_q; wire signed [15:0] gc_i, gc_q;
wire gc_valid; wire gc_valid;
wire [7:0] gc_saturation_count; // Diagnostic: per-frame clipped sample counter wire [7:0] gc_saturation_count; // Diagnostic: clipped sample counter
wire [7:0] gc_peak_magnitude; // Diagnostic: per-frame peak magnitude
wire [3:0] gc_current_gain; // Diagnostic: effective gain_shift
// Reference signals for the processing chain // Reference signals for the processing chain
wire [15:0] long_chirp_real, long_chirp_imag; wire [15:0] long_chirp_real, long_chirp_imag;
@@ -174,7 +160,7 @@ wire clk_400m;
// the buffered 400MHz DCO clock via adc_dco_bufg, avoiding duplicate // the buffered 400MHz DCO clock via adc_dco_bufg, avoiding duplicate
// IBUFDS instantiations on the same LVDS clock pair. // IBUFDS instantiations on the same LVDS clock pair.
// 1. ADC + CDC + Digital Gain // 1. ADC + CDC + AGC
// CMOS Output Interface (400MHz Domain) // CMOS Output Interface (400MHz Domain)
wire [7:0] adc_data_cmos; // 8-bit ADC data (CMOS, from ad9484_interface_400m) wire [7:0] adc_data_cmos; // 8-bit ADC data (CMOS, from ad9484_interface_400m)
@@ -236,10 +222,9 @@ ddc_input_interface ddc_if (
.data_sync_error() .data_sync_error()
); );
// 2b. Digital Gain Control with AGC // 2b. Digital Gain Control (Fix 3)
// Host-configurable power-of-2 shift between DDC output and matched filter. // Host-configurable power-of-2 shift between DDC output and matched filter.
// Default gain_shift=0, agc_enable=0 pass-through (no behavioral change). // Default gain_shift=0 pass-through (no behavioral change from baseline).
// When agc_enable=1: auto-adjusts gain per frame based on peak/saturation.
rx_gain_control gain_ctrl ( rx_gain_control gain_ctrl (
.clk(clk), .clk(clk),
.reset_n(reset_n), .reset_n(reset_n),
@@ -247,21 +232,10 @@ rx_gain_control gain_ctrl (
.data_q_in(adc_q_scaled), .data_q_in(adc_q_scaled),
.valid_in(adc_valid_sync), .valid_in(adc_valid_sync),
.gain_shift(host_gain_shift), .gain_shift(host_gain_shift),
// AGC configuration
.agc_enable(host_agc_enable),
.agc_target(host_agc_target),
.agc_attack(host_agc_attack),
.agc_decay(host_agc_decay),
.agc_holdoff(host_agc_holdoff),
// Frame boundary from Doppler processor
.frame_boundary(doppler_frame_done),
// Outputs
.data_i_out(gc_i), .data_i_out(gc_i),
.data_q_out(gc_q), .data_q_out(gc_q),
.valid_out(gc_valid), .valid_out(gc_valid),
.saturation_count(gc_saturation_count), .saturation_count(gc_saturation_count)
.peak_magnitude(gc_peak_magnitude),
.current_gain(gc_current_gain)
); );
// 3. Dual Chirp Memory Loader // 3. Dual Chirp Memory Loader
@@ -500,9 +474,4 @@ assign dbg_adc_i = adc_i_scaled;
assign dbg_adc_q = adc_q_scaled; assign dbg_adc_q = adc_q_scaled;
assign dbg_adc_valid = adc_valid_sync; assign dbg_adc_valid = adc_valid_sync;
// ========== AGC STATUS OUTPUTS ==========
assign agc_saturation_count = gc_saturation_count;
assign agc_peak_magnitude = gc_peak_magnitude;
assign agc_current_gain = gc_current_gain;
endmodule endmodule
+4 -66
View File
@@ -125,13 +125,7 @@ module radar_system_top (
output wire [5:0] dbg_range_bin, output wire [5:0] dbg_range_bin,
// System status // System status
output wire [3:0] system_status, output wire [3:0] system_status
// FPGASTM32 GPIO outputs (DIG_5..DIG_7 on 50T board)
// Used by STM32 outer AGC loop to read saturation state without USB polling.
output wire gpio_dig5, // DIG_5 (H11PD13): AGC saturation flag (1=clipping detected)
output wire gpio_dig6, // DIG_6 (G12PD14): reserved (tied low)
output wire gpio_dig7 // DIG_7 (H12PD15): reserved (tied low)
); );
// ============================================================================ // ============================================================================
@@ -193,11 +187,6 @@ wire [15:0] rx_dbg_adc_i;
wire [15:0] rx_dbg_adc_q; wire [15:0] rx_dbg_adc_q;
wire rx_dbg_adc_valid; wire rx_dbg_adc_valid;
// AGC status from receiver (for status readback and GPIO)
wire [7:0] rx_agc_saturation_count;
wire [7:0] rx_agc_peak_magnitude;
wire [3:0] rx_agc_current_gain;
// Data packing for USB // Data packing for USB
wire [31:0] usb_range_profile; wire [31:0] usb_range_profile;
wire usb_range_valid; wire usb_range_valid;
@@ -270,13 +259,6 @@ reg host_cfar_enable; // Opcode 0x25: 1=CFAR, 0=simple threshold
reg host_mti_enable; // Opcode 0x26: 1=MTI active, 0=pass-through reg host_mti_enable; // Opcode 0x26: 1=MTI active, 0=pass-through
reg [2:0] host_dc_notch_width; // Opcode 0x27: DC notch ±width bins (0=off, 1..7) reg [2:0] host_dc_notch_width; // Opcode 0x27: DC notch ±width bins (0=off, 1..7)
// AGC configuration registers (host-configurable via USB, opcodes 0x28-0x2C)
reg host_agc_enable; // Opcode 0x28: 0=manual gain, 1=auto AGC
reg [7:0] host_agc_target; // Opcode 0x29: target peak magnitude (default 200)
reg [3:0] host_agc_attack; // Opcode 0x2A: gain-down step on clipping (default 1)
reg [3:0] host_agc_decay; // Opcode 0x2B: gain-up step when weak (default 1)
reg [3:0] host_agc_holdoff; // Opcode 0x2C: frames to wait before gain-up (default 4)
// Board bring-up self-test registers (opcode 0x30 trigger, 0x31 readback) // Board bring-up self-test registers (opcode 0x30 trigger, 0x31 readback)
reg host_self_test_trigger; // Opcode 0x30: self-clearing pulse reg host_self_test_trigger; // Opcode 0x30: self-clearing pulse
wire self_test_busy; wire self_test_busy;
@@ -536,12 +518,6 @@ radar_receiver_final rx_inst (
.host_chirps_per_elev(host_chirps_per_elev), .host_chirps_per_elev(host_chirps_per_elev),
// Fix 3: digital gain control // Fix 3: digital gain control
.host_gain_shift(host_gain_shift), .host_gain_shift(host_gain_shift),
// AGC configuration (opcodes 0x28-0x2C)
.host_agc_enable(host_agc_enable),
.host_agc_target(host_agc_target),
.host_agc_attack(host_agc_attack),
.host_agc_decay(host_agc_decay),
.host_agc_holdoff(host_agc_holdoff),
// STM32 toggle signals for RX mode controller (mode 00 pass-through). // STM32 toggle signals for RX mode controller (mode 00 pass-through).
// These are the raw GPIO inputs the RX mode controller's edge detectors // These are the raw GPIO inputs the RX mode controller's edge detectors
// (inside radar_mode_controller) handle debouncing/edge detection. // (inside radar_mode_controller) handle debouncing/edge detection.
@@ -556,11 +532,7 @@ radar_receiver_final rx_inst (
// ADC debug tap (for self-test / bring-up) // ADC debug tap (for self-test / bring-up)
.dbg_adc_i(rx_dbg_adc_i), .dbg_adc_i(rx_dbg_adc_i),
.dbg_adc_q(rx_dbg_adc_q), .dbg_adc_q(rx_dbg_adc_q),
.dbg_adc_valid(rx_dbg_adc_valid), .dbg_adc_valid(rx_dbg_adc_valid)
// AGC status outputs
.agc_saturation_count(rx_agc_saturation_count),
.agc_peak_magnitude(rx_agc_peak_magnitude),
.agc_current_gain(rx_agc_current_gain)
); );
// ============================================================================ // ============================================================================
@@ -772,13 +744,7 @@ if (USB_MODE == 0) begin : gen_ft601
// Self-test status readback // Self-test status readback
.status_self_test_flags(self_test_flags_latched), .status_self_test_flags(self_test_flags_latched),
.status_self_test_detail(self_test_detail_latched), .status_self_test_detail(self_test_detail_latched),
.status_self_test_busy(self_test_busy), .status_self_test_busy(self_test_busy)
// AGC status readback
.status_agc_current_gain(rx_agc_current_gain),
.status_agc_peak_magnitude(rx_agc_peak_magnitude),
.status_agc_saturation_count(rx_agc_saturation_count),
.status_agc_enable(host_agc_enable)
); );
// FT2232H ports unused in FT601 mode — tie off // FT2232H ports unused in FT601 mode — tie off
@@ -839,13 +805,7 @@ end else begin : gen_ft2232h
// Self-test status readback // Self-test status readback
.status_self_test_flags(self_test_flags_latched), .status_self_test_flags(self_test_flags_latched),
.status_self_test_detail(self_test_detail_latched), .status_self_test_detail(self_test_detail_latched),
.status_self_test_busy(self_test_busy), .status_self_test_busy(self_test_busy)
// AGC status readback
.status_agc_current_gain(rx_agc_current_gain),
.status_agc_peak_magnitude(rx_agc_peak_magnitude),
.status_agc_saturation_count(rx_agc_saturation_count),
.status_agc_enable(host_agc_enable)
); );
// FT601 ports unused in FT2232H mode — tie off // FT601 ports unused in FT2232H mode — tie off
@@ -932,12 +892,6 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
// Ground clutter removal defaults (disabled backward-compatible) // Ground clutter removal defaults (disabled backward-compatible)
host_mti_enable <= 1'b0; // MTI off host_mti_enable <= 1'b0; // MTI off
host_dc_notch_width <= 3'd0; // DC notch off host_dc_notch_width <= 3'd0; // DC notch off
// AGC defaults (disabled backward-compatible with manual gain)
host_agc_enable <= 1'b0; // AGC off (manual gain)
host_agc_target <= 8'd200; // Target peak magnitude
host_agc_attack <= 4'd1; // 1-step gain-down on clipping
host_agc_decay <= 4'd1; // 1-step gain-up when weak
host_agc_holdoff <= 4'd4; // 4 frames before gain-up
// Self-test defaults // Self-test defaults
host_self_test_trigger <= 1'b0; // Self-test idle host_self_test_trigger <= 1'b0; // Self-test idle
end else begin end else begin
@@ -982,12 +936,6 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
// Ground clutter removal opcodes // Ground clutter removal opcodes
8'h26: host_mti_enable <= usb_cmd_value[0]; 8'h26: host_mti_enable <= usb_cmd_value[0];
8'h27: host_dc_notch_width <= usb_cmd_value[2:0]; 8'h27: host_dc_notch_width <= usb_cmd_value[2:0];
// AGC configuration opcodes
8'h28: host_agc_enable <= usb_cmd_value[0];
8'h29: host_agc_target <= usb_cmd_value[7:0];
8'h2A: host_agc_attack <= usb_cmd_value[3:0];
8'h2B: host_agc_decay <= usb_cmd_value[3:0];
8'h2C: host_agc_holdoff <= usb_cmd_value[3:0];
// Board bring-up self-test opcodes // Board bring-up self-test opcodes
8'h30: host_self_test_trigger <= 1'b1; // Trigger self-test 8'h30: host_self_test_trigger <= 1'b1; // Trigger self-test
8'h31: host_status_request <= 1'b1; // Self-test readback (status alias) 8'h31: host_status_request <= 1'b1; // Self-test readback (status alias)
@@ -1030,16 +978,6 @@ end
assign system_status = status_reg; assign system_status = status_reg;
// ============================================================================
// FPGA→STM32 GPIO OUTPUTS (DIG_5, DIG_6, DIG_7)
// ============================================================================
// DIG_5: AGC saturation flag — high when per-frame saturation_count > 0.
// STM32 reads PD13 to detect clipping and adjust ADAR1000 VGA gain.
// DIG_6, DIG_7: Reserved (tied low for future use).
assign gpio_dig5 = (rx_agc_saturation_count != 8'd0);
assign gpio_dig6 = 1'b0;
assign gpio_dig7 = 1'b0;
// ============================================================================ // ============================================================================
// DEBUG AND VERIFICATION // DEBUG AND VERIFICATION
// ============================================================================ // ============================================================================
+2 -12
View File
@@ -76,12 +76,7 @@ module radar_system_top_50t (
output wire ft_rd_n, // Read strobe (active low) output wire ft_rd_n, // Read strobe (active low)
output wire ft_wr_n, // Write strobe (active low) output wire ft_wr_n, // Write strobe (active low)
output wire ft_oe_n, // Output enable / bus direction output wire ft_oe_n, // Output enable / bus direction
output wire ft_siwu, // Send Immediate / WakeUp output wire ft_siwu // Send Immediate / WakeUp
// ===== FPGASTM32 GPIO (Bank 15: 3.3V) =====
output wire gpio_dig5, // DIG_5 (H11PD13): AGC saturation flag
output wire gpio_dig6, // DIG_6 (G12PD14): reserved
output wire gpio_dig7 // DIG_7 (H12PD15): reserved
); );
// ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) ===== // ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) =====
@@ -212,12 +207,7 @@ module radar_system_top_50t (
.dbg_doppler_valid (dbg_doppler_valid_nc), .dbg_doppler_valid (dbg_doppler_valid_nc),
.dbg_doppler_bin (dbg_doppler_bin_nc), .dbg_doppler_bin (dbg_doppler_bin_nc),
.dbg_range_bin (dbg_range_bin_nc), .dbg_range_bin (dbg_range_bin_nc),
.system_status (system_status_nc), .system_status (system_status_nc)
// ----- FPGASTM32 GPIO (DIG_5..DIG_7) -----
.gpio_dig5 (gpio_dig5),
.gpio_dig6 (gpio_dig6),
.gpio_dig7 (gpio_dig7)
); );
endmodule endmodule
+26 -214
View File
@@ -3,32 +3,19 @@
/** /**
* rx_gain_control.v * rx_gain_control.v
* *
* Digital gain control with optional per-frame automatic gain control (AGC) * Host-configurable digital gain control for the receive path.
* for the receive path. Placed between DDC output and matched filter input. * Placed between DDC output (ddc_input_interface) and matched filter input.
* *
* Manual mode (agc_enable=0): * Features:
* - Uses host_gain_shift directly (backward-compatible, no behavioral change) * - Bidirectional power-of-2 gain shift (arithmetic shift)
* - gain_shift[3] = direction: 0 = left shift (amplify), 1 = right shift (attenuate) * - gain_shift[3] = direction: 0 = left shift (amplify), 1 = right shift (attenuate)
* - gain_shift[2:0] = amount: 0..7 bits * - gain_shift[2:0] = amount: 0..7 bits
* - Symmetric saturation to ±32767 on overflow * - Symmetric saturation to ±32767 on overflow (left shift only)
* - Saturation counter: 8-bit, counts samples that clipped (wraps at 255)
* - 1-cycle latency, valid-in/valid-out pipeline
* - Zero-overhead pass-through when gain_shift == 0
* *
* AGC mode (agc_enable=1): * Intended insertion point in radar_receiver_final.v:
* - Per-frame automatic gain adjustment based on peak/saturation metrics
* - Internal signed gain: -7 (max attenuation) to +7 (max amplification)
* - On frame_boundary:
* * If saturation detected: gain -= agc_attack (fast, immediate)
* * Else if peak < target after holdoff frames: gain += agc_decay (slow)
* * Else: hold current gain
* - host_gain_shift serves as initial gain when AGC first enabled
*
* Status outputs (for readback via status_words):
* - current_gain[3:0]: effective gain_shift encoding (manual or AGC)
* - peak_magnitude[7:0]: per-frame peak |sample| (upper 8 bits of 15-bit value)
* - saturation_count[7:0]: per-frame clipped sample count (capped at 255)
*
* Timing: 1-cycle data latency, valid-in/valid-out pipeline.
*
* Insertion point in radar_receiver_final.v:
* ddc_input_interface rx_gain_control matched_filter_multi_segment * ddc_input_interface rx_gain_control matched_filter_multi_segment
*/ */
@@ -41,75 +28,27 @@ module rx_gain_control (
input wire signed [15:0] data_q_in, input wire signed [15:0] data_q_in,
input wire valid_in, input wire valid_in,
// Host gain configuration (from USB command opcode 0x16) // Gain configuration (from host via USB command)
// [3] = direction: 0=amplify (left shift), 1=attenuate (right shift) // [3] = direction: 0=amplify (left shift), 1=attenuate (right shift)
// [2:0]=shift amount: 0..7 bits. Default 0x00 = pass-through. // [2:0] = shift amount: 0..7 bits
// In AGC mode: serves as initial gain on AGC enable transition.
input wire [3:0] gain_shift, input wire [3:0] gain_shift,
// AGC configuration inputs (from host via USB, opcodes 0x28-0x2C)
input wire agc_enable, // 0x28: 0=manual gain, 1=auto AGC
input wire [7:0] agc_target, // 0x29: target peak magnitude (unsigned, default 200)
input wire [3:0] agc_attack, // 0x2A: attenuation step on clipping (default 1)
input wire [3:0] agc_decay, // 0x2B: amplification step when weak (default 1)
input wire [3:0] agc_holdoff, // 0x2C: frames to wait before gain-up (default 4)
// Frame boundary pulse (1 clk cycle, from Doppler frame_complete)
input wire frame_boundary,
// Data output (to matched filter) // Data output (to matched filter)
output reg signed [15:0] data_i_out, output reg signed [15:0] data_i_out,
output reg signed [15:0] data_q_out, output reg signed [15:0] data_q_out,
output reg valid_out, output reg valid_out,
// Diagnostics / status readback // Diagnostics
output reg [7:0] saturation_count, // Per-frame clipped sample count (capped at 255) output reg [7:0] saturation_count // Number of clipped samples (wraps at 255)
output reg [7:0] peak_magnitude, // Per-frame peak |sample| (upper 8 bits of 15-bit)
output reg [3:0] current_gain // Current effective gain_shift (for status readback)
); );
// ========================================================================= // Decompose gain_shift
// INTERNAL AGC STATE wire shift_right = gain_shift[3];
// ========================================================================= wire [2:0] shift_amt = gain_shift[2:0];
// Signed internal gain: -7 (max attenuation) to +7 (max amplification) // -------------------------------------------------------------------------
// Stored as 4-bit signed (range -8..+7, clamped to -7..+7) // Combinational shift + saturation
reg signed [3:0] agc_gain; // -------------------------------------------------------------------------
// Holdoff counter: counts frames without saturation before allowing gain-up
reg [3:0] holdoff_counter;
// Per-frame accumulators (running, reset on frame_boundary)
reg [7:0] frame_sat_count; // Clipped samples this frame
reg [14:0] frame_peak; // Peak |sample| this frame (15-bit unsigned)
// Previous AGC enable state (for detecting 01 transition)
reg agc_enable_prev;
// Combinational helpers for inclusive frame-boundary snapshot
// (used when valid_in and frame_boundary coincide)
reg wire_frame_sat_incr;
reg wire_frame_peak_update;
// =========================================================================
// EFFECTIVE GAIN SELECTION
// =========================================================================
// Convert between signed internal gain and the gain_shift[3:0] encoding.
// gain_shift[3]=0, [2:0]=N amplify by N bits (internal gain = +N)
// gain_shift[3]=1, [2:0]=N attenuate by N bits (internal gain = -N)
// Effective gain_shift used for the actual shift operation
wire [3:0] effective_gain;
assign effective_gain = agc_enable ? current_gain : gain_shift;
// Decompose effective gain for shift logic
wire shift_right = effective_gain[3];
wire [2:0] shift_amt = effective_gain[2:0];
// =========================================================================
// COMBINATIONAL SHIFT + SATURATION
// =========================================================================
// Use wider intermediates to detect overflow on left shift. // Use wider intermediates to detect overflow on left shift.
// 24 bits is enough: 16 + 7 shift = 23 significant bits max. // 24 bits is enough: 16 + 7 shift = 23 significant bits max.
@@ -130,153 +69,26 @@ wire signed [15:0] sat_i = overflow_i ? (shifted_i[23] ? -16'sd32768 : 16'sd3276
wire signed [15:0] sat_q = overflow_q ? (shifted_q[23] ? -16'sd32768 : 16'sd32767) wire signed [15:0] sat_q = overflow_q ? (shifted_q[23] ? -16'sd32768 : 16'sd32767)
: shifted_q[15:0]; : shifted_q[15:0];
// ========================================================================= // -------------------------------------------------------------------------
// PEAK MAGNITUDE TRACKING (combinational) // Registered output stage (1-cycle latency)
// ========================================================================= // -------------------------------------------------------------------------
// Absolute value of signed 16-bit: flip sign bit if negative.
// Result is 15-bit unsigned [0, 32767]. (We ignore -32768 32767 edge case.)
wire [14:0] abs_i = data_i_in[15] ? (~data_i_in[14:0] + 15'd1) : data_i_in[14:0];
wire [14:0] abs_q = data_q_in[15] ? (~data_q_in[14:0] + 15'd1) : data_q_in[14:0];
wire [14:0] max_iq = (abs_i > abs_q) ? abs_i : abs_q;
// =========================================================================
// SIGNED GAIN GAIN_SHIFT ENCODING CONVERSION
// =========================================================================
// Convert signed agc_gain to gain_shift[3:0] encoding
function [3:0] signed_to_encoding;
input signed [3:0] g;
begin
if (g >= 0)
signed_to_encoding = {1'b0, g[2:0]}; // amplify
else
signed_to_encoding = {1'b1, (~g[2:0]) + 3'd1}; // attenuate: -g
end
endfunction
// Convert gain_shift[3:0] encoding to signed gain
function signed [3:0] encoding_to_signed;
input [3:0] enc;
begin
if (enc[3] == 1'b0)
encoding_to_signed = {1'b0, enc[2:0]}; // +0..+7
else
encoding_to_signed = -$signed({1'b0, enc[2:0]}); // -1..-7
end
endfunction
// =========================================================================
// CLAMPING HELPER
// =========================================================================
// Clamp a wider signed value to [-7, +7]
function signed [3:0] clamp_gain;
input signed [4:0] val; // 5-bit to handle overflow from add
begin
if (val > 5'sd7)
clamp_gain = 4'sd7;
else if (val < -5'sd7)
clamp_gain = -4'sd7;
else
clamp_gain = val[3:0];
end
endfunction
// =========================================================================
// REGISTERED OUTPUT + AGC STATE MACHINE
// =========================================================================
always @(posedge clk or negedge reset_n) begin always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin if (!reset_n) begin
// Data path
data_i_out <= 16'sd0; data_i_out <= 16'sd0;
data_q_out <= 16'sd0; data_q_out <= 16'sd0;
valid_out <= 1'b0; valid_out <= 1'b0;
// Status outputs
saturation_count <= 8'd0; saturation_count <= 8'd0;
peak_magnitude <= 8'd0;
current_gain <= 4'd0;
// AGC internal state
agc_gain <= 4'sd0;
holdoff_counter <= 4'd0;
frame_sat_count <= 8'd0;
frame_peak <= 15'd0;
agc_enable_prev <= 1'b0;
end else begin end else begin
// Track AGC enable transitions
agc_enable_prev <= agc_enable;
// Compute inclusive metrics: if valid_in fires this cycle,
// include current sample in the snapshot taken at frame_boundary.
// This avoids losing the last sample when valid_in and
// frame_boundary coincide (NBA last-write-wins would otherwise
// snapshot stale values then reset, dropping the sample entirely).
wire_frame_sat_incr = (valid_in && (overflow_i || overflow_q)
&& (frame_sat_count != 8'hFF));
wire_frame_peak_update = (valid_in && (max_iq > frame_peak));
// ---- Data pipeline (1-cycle latency) ----
valid_out <= valid_in; valid_out <= valid_in;
if (valid_in) begin if (valid_in) begin
data_i_out <= sat_i; data_i_out <= sat_i;
data_q_out <= sat_q; data_q_out <= sat_q;
// Per-frame saturation counting // Count clipped samples (either channel clipping counts as 1)
if ((overflow_i || overflow_q) && (frame_sat_count != 8'hFF)) if ((overflow_i || overflow_q) && (saturation_count != 8'hFF))
frame_sat_count <= frame_sat_count + 8'd1; saturation_count <= saturation_count + 8'd1;
// Per-frame peak tracking (pre-gain, measures input signal level)
if (max_iq > frame_peak)
frame_peak <= max_iq;
end end
// ---- Frame boundary: AGC update + metric snapshot ----
if (frame_boundary) begin
// Snapshot per-frame metrics INCLUDING current sample if valid_in
saturation_count <= wire_frame_sat_incr
? (frame_sat_count + 8'd1)
: frame_sat_count;
peak_magnitude <= wire_frame_peak_update
? max_iq[14:7]
: frame_peak[14:7];
// Reset per-frame accumulators for next frame
frame_sat_count <= 8'd0;
frame_peak <= 15'd0;
if (agc_enable) begin
// AGC auto-adjustment at frame boundary
// Use inclusive counts/peaks (accounting for simultaneous valid_in)
if (wire_frame_sat_incr || frame_sat_count > 8'd0) begin
// Clipping detected: reduce gain immediately (attack)
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) -
$signed({1'b0, agc_attack}));
holdoff_counter <= agc_holdoff; // Reset holdoff
end else if ((wire_frame_peak_update ? max_iq[14:7] : frame_peak[14:7])
< agc_target) begin
// Signal too weak: increase gain after holdoff expires
if (holdoff_counter == 4'd0) begin
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) +
$signed({1'b0, agc_decay}));
end else begin
holdoff_counter <= holdoff_counter - 4'd1;
end
end else begin
// Signal in good range, no saturation: hold gain
// Reset holdoff so next weak frame has to wait again
holdoff_counter <= agc_holdoff;
end
end
end
// ---- AGC enable transition: initialize from host gain ----
if (agc_enable && !agc_enable_prev) begin
agc_gain <= encoding_to_signed(gain_shift);
holdoff_counter <= agc_holdoff;
end
// ---- Update current_gain output ----
if (agc_enable)
current_gain <= signed_to_encoding(agc_gain);
else
current_gain <= gain_shift;
end end
end end
@@ -120,10 +120,9 @@ set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets {ft_clkout_IBUF}]
# ---- Run implementation steps ---- # ---- Run implementation steps ----
opt_design -directive Explore opt_design -directive Explore
place_design -directive ExtraNetDelay_high place_design -directive Explore
phys_opt_design -directive AggressiveExplore
route_design -directive AggressiveExplore
phys_opt_design -directive AggressiveExplore phys_opt_design -directive AggressiveExplore
route_design -directive Explore
phys_opt_design -directive AggressiveExplore phys_opt_design -directive AggressiveExplore
set impl_elapsed [expr {[clock seconds] - $impl_start}] set impl_elapsed [expr {[clock seconds] - $impl_start}]
-449
View File
@@ -1,449 +0,0 @@
#!/usr/bin/env python3
"""
Co-simulation Comparison: RTL vs Python Model for AERIS-10 DDC Chain.
Reads the ADC hex test vectors, runs them through the bit-accurate Python
model (fpga_model.py), then compares the output against the RTL simulation
CSV (from tb_ddc_cosim.v).
Key considerations:
- The RTL DDC has LFSR phase dithering on the NCO FTW, so exact bit-match
is not expected. We use statistical metrics (correlation, RMS error).
- The CDC (gray-coded 400→100 MHz crossing) may introduce non-deterministic
latency offsets. We auto-align using cross-correlation.
- The comparison reports pass/fail based on configurable thresholds.
Usage:
python3 compare.py [scenario]
scenario: dc, single_target, multi_target, noise_only, sine_1mhz
(default: dc)
Author: Phase 0.5 co-simulation suite for PLFM_RADAR
"""
import math
import os
import sys
# Add this directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fpga_model import SignalChain
# =============================================================================
# Configuration
# =============================================================================
# Thresholds for pass/fail
# These are generous because of LFSR dithering and CDC latency jitter
MAX_RMS_ERROR_LSB = 50.0 # Max RMS error in 18-bit LSBs
MIN_CORRELATION = 0.90 # Min Pearson correlation coefficient
MAX_LATENCY_DRIFT = 15 # Max latency offset between RTL and model (samples)
MAX_COUNT_DIFF = 20 # Max output count difference (LFSR dithering affects CIC timing)
# Scenarios
SCENARIOS = {
'dc': {
'adc_hex': 'adc_dc.hex',
'rtl_csv': 'rtl_bb_dc.csv',
'description': 'DC input (ADC=128)',
# DC input: expect small outputs, but LFSR dithering adds ~+128 LSB
# average bias to NCO FTW which accumulates through CIC integrators
# as a small DC offset (~15-20 LSB in baseband). This is expected.
'max_rms': 25.0, # Relaxed to account for LFSR dithering bias
'min_corr': -1.0, # Correlation not meaningful for near-zero
},
'single_target': {
'adc_hex': 'adc_single_target.hex',
'rtl_csv': 'rtl_bb_single_target.csv',
'description': 'Single target at 500m',
'max_rms': MAX_RMS_ERROR_LSB,
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
},
'multi_target': {
'adc_hex': 'adc_multi_target.hex',
'rtl_csv': 'rtl_bb_multi_target.csv',
'description': 'Multi-target (5 targets)',
'max_rms': MAX_RMS_ERROR_LSB,
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
},
'noise_only': {
'adc_hex': 'adc_noise_only.hex',
'rtl_csv': 'rtl_bb_noise_only.csv',
'description': 'Noise only',
'max_rms': MAX_RMS_ERROR_LSB,
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
},
'sine_1mhz': {
'adc_hex': 'adc_sine_1mhz.hex',
'rtl_csv': 'rtl_bb_sine_1mhz.csv',
'description': '1 MHz sine wave',
'max_rms': MAX_RMS_ERROR_LSB,
'min_corr': -1.0, # Correlation not meaningful with LFSR dithering
},
}
# =============================================================================
# Helper functions
# =============================================================================
def load_adc_hex(filepath):
"""Load 8-bit unsigned ADC samples from hex file."""
samples = []
with open(filepath) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
continue
samples.append(int(line, 16))
return samples
def load_rtl_csv(filepath):
"""Load RTL baseband output CSV (sample_idx, baseband_i, baseband_q)."""
bb_i = []
bb_q = []
with open(filepath) as f:
f.readline() # Skip header
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(',')
bb_i.append(int(parts[1]))
bb_q.append(int(parts[2]))
return bb_i, bb_q
def run_python_model(adc_samples):
"""Run ADC samples through the Python DDC model.
Returns the 18-bit FIR outputs (not the 16-bit DDC interface outputs),
because the RTL testbench captures the FIR output directly
(baseband_i_reg <= fir_i_out in ddc_400m.v).
"""
chain = SignalChain()
result = chain.process_adc_block(adc_samples)
# Use fir_i_raw / fir_q_raw (18-bit) to match RTL's baseband output
# which is the FIR output before DDC interface 18->16 rounding
bb_i = result['fir_i_raw']
bb_q = result['fir_q_raw']
return bb_i, bb_q
def compute_rms_error(a, b):
"""Compute RMS error between two equal-length lists."""
if len(a) != len(b):
raise ValueError(f"Length mismatch: {len(a)} vs {len(b)}")
if len(a) == 0:
return 0.0
sum_sq = sum((x - y) ** 2 for x, y in zip(a, b, strict=False))
return math.sqrt(sum_sq / len(a))
def compute_max_abs_error(a, b):
"""Compute maximum absolute error between two equal-length lists."""
if len(a) != len(b) or len(a) == 0:
return 0
return max(abs(x - y) for x, y in zip(a, b, strict=False))
def compute_correlation(a, b):
"""Compute Pearson correlation coefficient."""
n = len(a)
if n < 2:
return 0.0
mean_a = sum(a) / n
mean_b = sum(b) / n
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
std_a_sq = sum((x - mean_a) ** 2 for x in a)
std_b_sq = sum((x - mean_b) ** 2 for x in b)
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
# Near-zero variance (e.g., DC input)
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
return cov / math.sqrt(std_a_sq * std_b_sq)
def cross_correlate_lag(a, b, max_lag=20):
"""
Find the lag that maximizes cross-correlation between a and b.
Returns (best_lag, best_correlation) where positive lag means b is delayed.
"""
n = min(len(a), len(b))
if n < 10:
return 0, 0.0
best_lag = 0
best_corr = -2.0
for lag in range(-max_lag, max_lag + 1):
# Align: a[start_a:end_a] vs b[start_b:end_b]
if lag >= 0:
start_a = lag
start_b = 0
else:
start_a = 0
start_b = -lag
end = min(len(a) - start_a, len(b) - start_b)
if end < 10:
continue
seg_a = a[start_a:start_a + end]
seg_b = b[start_b:start_b + end]
corr = compute_correlation(seg_a, seg_b)
if corr > best_corr:
best_corr = corr
best_lag = lag
return best_lag, best_corr
def compute_signal_stats(samples):
"""Compute basic statistics of a signal."""
if not samples:
return {'mean': 0, 'rms': 0, 'min': 0, 'max': 0, 'count': 0}
n = len(samples)
mean = sum(samples) / n
rms = math.sqrt(sum(x * x for x in samples) / n)
return {
'mean': mean,
'rms': rms,
'min': min(samples),
'max': max(samples),
'count': n,
}
# =============================================================================
# Main comparison
# =============================================================================
def compare_scenario(scenario_name):
"""Run comparison for one scenario. Returns True if passed."""
if scenario_name not in SCENARIOS:
return False
cfg = SCENARIOS[scenario_name]
base_dir = os.path.dirname(os.path.abspath(__file__))
# ---- Load ADC data ----
adc_path = os.path.join(base_dir, cfg['adc_hex'])
if not os.path.exists(adc_path):
return False
adc_samples = load_adc_hex(adc_path)
# ---- Load RTL output ----
rtl_path = os.path.join(base_dir, cfg['rtl_csv'])
if not os.path.exists(rtl_path):
return False
rtl_i, rtl_q = load_rtl_csv(rtl_path)
# ---- Run Python model ----
py_i, py_q = run_python_model(adc_samples)
# ---- Length comparison ----
len_diff = abs(len(rtl_i) - len(py_i))
# ---- Signal statistics ----
rtl_i_stats = compute_signal_stats(rtl_i)
rtl_q_stats = compute_signal_stats(rtl_q)
py_i_stats = compute_signal_stats(py_i)
py_q_stats = compute_signal_stats(py_q)
# ---- Trim to common length ----
common_len = min(len(rtl_i), len(py_i))
if common_len < 10:
return False
rtl_i_trim = rtl_i[:common_len]
rtl_q_trim = rtl_q[:common_len]
py_i_trim = py_i[:common_len]
py_q_trim = py_q[:common_len]
# ---- Cross-correlation to find latency offset ----
lag_i, _corr_i = cross_correlate_lag(rtl_i_trim, py_i_trim,
max_lag=MAX_LATENCY_DRIFT)
lag_q, _corr_q = cross_correlate_lag(rtl_q_trim, py_q_trim,
max_lag=MAX_LATENCY_DRIFT)
# ---- Apply latency correction ----
best_lag = lag_i # Use I-channel lag (should be same as Q)
if abs(lag_i - lag_q) > 1:
# Use the average
best_lag = (lag_i + lag_q) // 2
if best_lag > 0:
# RTL is delayed relative to Python
aligned_rtl_i = rtl_i_trim[best_lag:]
aligned_rtl_q = rtl_q_trim[best_lag:]
aligned_py_i = py_i_trim[:len(aligned_rtl_i)]
aligned_py_q = py_q_trim[:len(aligned_rtl_q)]
elif best_lag < 0:
# Python is delayed relative to RTL
aligned_py_i = py_i_trim[-best_lag:]
aligned_py_q = py_q_trim[-best_lag:]
aligned_rtl_i = rtl_i_trim[:len(aligned_py_i)]
aligned_rtl_q = rtl_q_trim[:len(aligned_py_q)]
else:
aligned_rtl_i = rtl_i_trim
aligned_rtl_q = rtl_q_trim
aligned_py_i = py_i_trim
aligned_py_q = py_q_trim
aligned_len = min(len(aligned_rtl_i), len(aligned_py_i))
aligned_rtl_i = aligned_rtl_i[:aligned_len]
aligned_rtl_q = aligned_rtl_q[:aligned_len]
aligned_py_i = aligned_py_i[:aligned_len]
aligned_py_q = aligned_py_q[:aligned_len]
# ---- Error metrics (after alignment) ----
rms_i = compute_rms_error(aligned_rtl_i, aligned_py_i)
rms_q = compute_rms_error(aligned_rtl_q, aligned_py_q)
compute_max_abs_error(aligned_rtl_i, aligned_py_i)
compute_max_abs_error(aligned_rtl_q, aligned_py_q)
corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i)
corr_q_aligned = compute_correlation(aligned_rtl_q, aligned_py_q)
# ---- First/last sample comparison ----
for k in range(min(10, aligned_len)):
ei = aligned_rtl_i[k] - aligned_py_i[k]
eq = aligned_rtl_q[k] - aligned_py_q[k]
# ---- Write detailed comparison CSV ----
compare_csv_path = os.path.join(base_dir, f"compare_{scenario_name}.csv")
with open(compare_csv_path, 'w') as f:
f.write("idx,rtl_i,py_i,err_i,rtl_q,py_q,err_q\n")
for k in range(aligned_len):
ei = aligned_rtl_i[k] - aligned_py_i[k]
eq = aligned_rtl_q[k] - aligned_py_q[k]
f.write(f"{k},{aligned_rtl_i[k]},{aligned_py_i[k]},{ei},"
f"{aligned_rtl_q[k]},{aligned_py_q[k]},{eq}\n")
# ---- Pass/Fail ----
max_rms = cfg.get('max_rms', MAX_RMS_ERROR_LSB)
min_corr = cfg.get('min_corr', MIN_CORRELATION)
results = []
# Check 1: Output count sanity
count_ok = len_diff <= MAX_COUNT_DIFF
results.append(('Output count match', count_ok,
f"diff={len_diff} <= {MAX_COUNT_DIFF}"))
# Check 2: RMS amplitude ratio (RTL vs Python should have same power)
# The LFSR dithering randomizes sample phases but preserves overall
# signal power, so RMS amplitudes should match within ~10%.
rtl_rms = max(rtl_i_stats['rms'], rtl_q_stats['rms'])
py_rms = max(py_i_stats['rms'], py_q_stats['rms'])
if py_rms > 1.0 and rtl_rms > 1.0:
rms_ratio = max(rtl_rms, py_rms) / min(rtl_rms, py_rms)
rms_ratio_ok = rms_ratio <= 1.20 # Within 20%
results.append(('RMS amplitude ratio', rms_ratio_ok,
f"ratio={rms_ratio:.3f} <= 1.20"))
else:
# Near-zero signals (DC input): check absolute RMS error
rms_ok = max(rms_i, rms_q) <= max_rms
results.append(('RMS error (low signal)', rms_ok,
f"max(I={rms_i:.2f}, Q={rms_q:.2f}) <= {max_rms:.1f}"))
# Check 3: Mean DC offset match
# Both should have similar DC bias. For large signals (where LFSR dithering
# causes the NCO to walk in phase), allow the mean to differ proportionally
# to the signal RMS. Use max(30 LSB, 3% of signal RMS).
mean_err_i = abs(rtl_i_stats['mean'] - py_i_stats['mean'])
mean_err_q = abs(rtl_q_stats['mean'] - py_q_stats['mean'])
max_mean_err = max(mean_err_i, mean_err_q)
signal_rms = max(rtl_rms, py_rms)
mean_threshold = max(30.0, signal_rms * 0.03) # 3% of signal RMS or 30 LSB
mean_ok = max_mean_err <= mean_threshold
results.append(('Mean DC offset match', mean_ok,
f"max_diff={max_mean_err:.1f} <= {mean_threshold:.1f}"))
# Check 4: Correlation (skip for near-zero signals or dithered scenarios)
if min_corr > -0.5:
corr_ok = min(corr_i_aligned, corr_q_aligned) >= min_corr
results.append(('Correlation', corr_ok,
f"min(I={corr_i_aligned:.4f}, Q={corr_q_aligned:.4f}) >= {min_corr:.2f}"))
# Check 5: Dynamic range match
# Peak amplitudes should be in the same ballpark
rtl_peak = max(abs(rtl_i_stats['min']), abs(rtl_i_stats['max']),
abs(rtl_q_stats['min']), abs(rtl_q_stats['max']))
py_peak = max(abs(py_i_stats['min']), abs(py_i_stats['max']),
abs(py_q_stats['min']), abs(py_q_stats['max']))
if py_peak > 10 and rtl_peak > 10:
peak_ratio = max(rtl_peak, py_peak) / min(rtl_peak, py_peak)
peak_ok = peak_ratio <= 1.50 # Within 50%
results.append(('Peak amplitude ratio', peak_ok,
f"ratio={peak_ratio:.3f} <= 1.50"))
# Check 6: Latency offset
lag_ok = abs(best_lag) <= MAX_LATENCY_DRIFT
results.append(('Latency offset', lag_ok,
f"|{best_lag}| <= {MAX_LATENCY_DRIFT}"))
# ---- Report ----
all_pass = True
for _name, ok, _detail in results:
if not ok:
all_pass = False
if all_pass:
pass
else:
pass
return all_pass
def main():
"""Run comparison for specified scenario(s)."""
if len(sys.argv) > 1:
scenario = sys.argv[1]
if scenario == 'all':
# Run all scenarios that have RTL CSV files
base_dir = os.path.dirname(os.path.abspath(__file__))
overall_pass = True
run_count = 0
pass_count = 0
for name, cfg in SCENARIOS.items():
rtl_path = os.path.join(base_dir, cfg['rtl_csv'])
if os.path.exists(rtl_path):
ok = compare_scenario(name)
run_count += 1
if ok:
pass_count += 1
else:
overall_pass = False
else:
pass
if overall_pass:
pass
else:
pass
return 0 if overall_pass else 1
ok = compare_scenario(scenario)
return 0 if ok else 1
ok = compare_scenario('dc')
return 0 if ok else 1
if __name__ == '__main__':
sys.exit(main())
@@ -1,340 +0,0 @@
#!/usr/bin/env python3
"""
Co-simulation Comparison: RTL vs Python Model for AERIS-10 Doppler Processor.
Compares the RTL Doppler output (from tb_doppler_cosim.v) against the Python
model golden reference (from gen_doppler_golden.py).
After fixing the windowing pipeline bugs in doppler_processor.v (BRAM address
alignment and pipeline staging), the RTL achieves BIT-PERFECT match with the
Python model. The comparison checks:
1. Per-range-bin peak Doppler bin agreement (100% required)
2. Per-range-bin I/Q correlation (1.0 expected)
3. Per-range-bin magnitude spectrum correlation (1.0 expected)
4. Global output energy (exact match expected)
Usage:
python3 compare_doppler.py [scenario|all]
scenario: stationary, moving, two_targets (default: stationary)
all: run all scenarios
Author: Phase 0.5 Doppler co-simulation suite for PLFM_RADAR
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# =============================================================================
# Configuration
# =============================================================================
DOPPLER_FFT = 32
RANGE_BINS = 64
TOTAL_OUTPUTS = RANGE_BINS * DOPPLER_FFT # 2048
SUBFRAME_SIZE = 16
SCENARIOS = {
'stationary': {
'golden_csv': 'doppler_golden_py_stationary.csv',
'rtl_csv': 'rtl_doppler_stationary.csv',
'description': 'Single stationary target at ~500m',
},
'moving': {
'golden_csv': 'doppler_golden_py_moving.csv',
'rtl_csv': 'rtl_doppler_moving.csv',
'description': 'Single moving target v=15m/s',
},
'two_targets': {
'golden_csv': 'doppler_golden_py_two_targets.csv',
'rtl_csv': 'rtl_doppler_two_targets.csv',
'description': 'Two targets at different ranges/velocities',
},
}
# Pass/fail thresholds — BIT-PERFECT match expected after pipeline fix
PEAK_AGREEMENT_MIN = 1.00 # 100% peak Doppler bin agreement required
MAG_CORR_MIN = 0.99 # Near-perfect magnitude correlation required
ENERGY_RATIO_MIN = 0.999 # Energy ratio must be ~1.0 (bit-perfect)
ENERGY_RATIO_MAX = 1.001 # Energy ratio must be ~1.0 (bit-perfect)
# =============================================================================
# Helper functions
# =============================================================================
def load_doppler_csv(filepath):
"""
Load Doppler output CSV with columns (range_bin, doppler_bin, out_i, out_q).
Returns dict: {rbin: [(dbin, i, q), ...]}
"""
data = {}
with open(filepath) as f:
f.readline() # Skip header
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(',')
rbin = int(parts[0])
dbin = int(parts[1])
i_val = int(parts[2])
q_val = int(parts[3])
if rbin not in data:
data[rbin] = []
data[rbin].append((dbin, i_val, q_val))
return data
def extract_iq_arrays(data_dict, rbin):
"""Extract I and Q arrays for a given range bin, ordered by doppler bin."""
if rbin not in data_dict:
return [0] * DOPPLER_FFT, [0] * DOPPLER_FFT
entries = sorted(data_dict[rbin], key=lambda x: x[0])
i_arr = [e[1] for e in entries]
q_arr = [e[2] for e in entries]
return i_arr, q_arr
def pearson_correlation(a, b):
"""Compute Pearson correlation coefficient."""
n = len(a)
if n < 2:
return 0.0
mean_a = sum(a) / n
mean_b = sum(b) / n
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
std_a_sq = sum((x - mean_a) ** 2 for x in a)
std_b_sq = sum((x - mean_b) ** 2 for x in b)
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
return cov / math.sqrt(std_a_sq * std_b_sq)
def magnitude_l1(i_arr, q_arr):
"""L1 magnitude: |I| + |Q|."""
return [abs(i) + abs(q) for i, q in zip(i_arr, q_arr, strict=False)]
def find_peak_bin(i_arr, q_arr):
"""Find bin with max L1 magnitude."""
mags = magnitude_l1(i_arr, q_arr)
return max(range(len(mags)), key=lambda k: mags[k])
def peak_bins_match(py_peak, rtl_peak):
"""Return True if peaks match within +/-1 bin inside the same sub-frame."""
py_sf = py_peak // SUBFRAME_SIZE
rtl_sf = rtl_peak // SUBFRAME_SIZE
if py_sf != rtl_sf:
return False
py_bin = py_peak % SUBFRAME_SIZE
rtl_bin = rtl_peak % SUBFRAME_SIZE
diff = abs(py_bin - rtl_bin)
return diff <= 1 or diff >= SUBFRAME_SIZE - 1
def total_energy(data_dict):
"""Sum of I^2 + Q^2 across all range bins and Doppler bins."""
total = 0
for rbin in data_dict:
for (_dbin, i_val, q_val) in data_dict[rbin]:
total += i_val * i_val + q_val * q_val
return total
# =============================================================================
# Scenario comparison
# =============================================================================
def compare_scenario(name, config, base_dir):
"""Compare one Doppler scenario. Returns (passed, result_dict)."""
golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path):
return False, {}
if not os.path.exists(rtl_path):
return False, {}
py_data = load_doppler_csv(golden_path)
rtl_data = load_doppler_csv(rtl_path)
sorted(py_data.keys())
sorted(rtl_data.keys())
# ---- Check 1: Both have data ----
py_total = sum(len(v) for v in py_data.values())
rtl_total = sum(len(v) for v in rtl_data.values())
if py_total == 0 or rtl_total == 0:
return False, {}
# ---- Check 2: Output count ----
count_ok = (rtl_total == TOTAL_OUTPUTS)
# ---- Check 3: Global energy ----
py_energy = total_energy(py_data)
rtl_energy = total_energy(rtl_data)
if py_energy > 0:
energy_ratio = rtl_energy / py_energy
else:
energy_ratio = 1.0 if rtl_energy == 0 else float('inf')
# ---- Check 4: Per-range-bin analysis ----
peak_agreements = 0
mag_correlations = []
i_correlations = []
q_correlations = []
peak_details = []
for rbin in range(RANGE_BINS):
py_i, py_q = extract_iq_arrays(py_data, rbin)
rtl_i, rtl_q = extract_iq_arrays(rtl_data, rbin)
py_peak = find_peak_bin(py_i, py_q)
rtl_peak = find_peak_bin(rtl_i, rtl_q)
# Peak agreement (allow +/-1 bin tolerance, but only within a sub-frame)
if peak_bins_match(py_peak, rtl_peak):
peak_agreements += 1
py_mag = magnitude_l1(py_i, py_q)
rtl_mag = magnitude_l1(rtl_i, rtl_q)
mag_corr = pearson_correlation(py_mag, rtl_mag)
corr_i = pearson_correlation(py_i, rtl_i)
corr_q = pearson_correlation(py_q, rtl_q)
mag_correlations.append(mag_corr)
i_correlations.append(corr_i)
q_correlations.append(corr_q)
py_rbin_energy = sum(i*i + q*q for i, q in zip(py_i, py_q, strict=False))
rtl_rbin_energy = sum(i*i + q*q for i, q in zip(rtl_i, rtl_q, strict=False))
peak_details.append({
'rbin': rbin,
'py_peak': py_peak,
'rtl_peak': rtl_peak,
'mag_corr': mag_corr,
'corr_i': corr_i,
'corr_q': corr_q,
'py_energy': py_rbin_energy,
'rtl_energy': rtl_rbin_energy,
})
peak_agreement_frac = peak_agreements / RANGE_BINS
avg_mag_corr = sum(mag_correlations) / len(mag_correlations)
avg_corr_i = sum(i_correlations) / len(i_correlations)
avg_corr_q = sum(q_correlations) / len(q_correlations)
# Show top 5 range bins by Python energy
top_rbins = sorted(peak_details, key=lambda x: -x['py_energy'])[:5]
for _d in top_rbins:
pass
# ---- Pass/Fail ----
checks = []
checks.append(('RTL output count == 2048', count_ok))
energy_ok = (ENERGY_RATIO_MIN < energy_ratio < ENERGY_RATIO_MAX)
checks.append((f'Energy ratio in bounds '
f'({ENERGY_RATIO_MIN}-{ENERGY_RATIO_MAX})', energy_ok))
peak_ok = (peak_agreement_frac >= PEAK_AGREEMENT_MIN)
checks.append((f'Peak agreement >= {PEAK_AGREEMENT_MIN:.0%}', peak_ok))
# For range bins with significant energy, check magnitude correlation
high_energy_rbins = [d for d in peak_details
if d['py_energy'] > py_energy / (RANGE_BINS * 10)]
if high_energy_rbins:
he_mag_corr = sum(d['mag_corr'] for d in high_energy_rbins) / len(high_energy_rbins)
he_ok = (he_mag_corr >= MAG_CORR_MIN)
checks.append((f'High-energy rbin avg mag_corr >= {MAG_CORR_MIN:.2f} '
f'(actual={he_mag_corr:.3f})', he_ok))
all_pass = True
for _check_name, passed in checks:
if not passed:
all_pass = False
# ---- Write detailed comparison CSV ----
compare_csv = os.path.join(base_dir, f'compare_doppler_{name}.csv')
with open(compare_csv, 'w') as f:
f.write('range_bin,doppler_bin,py_i,py_q,rtl_i,rtl_q,diff_i,diff_q\n')
for rbin in range(RANGE_BINS):
py_i, py_q = extract_iq_arrays(py_data, rbin)
rtl_i, rtl_q = extract_iq_arrays(rtl_data, rbin)
for dbin in range(DOPPLER_FFT):
f.write(f'{rbin},{dbin},{py_i[dbin]},{py_q[dbin]},'
f'{rtl_i[dbin]},{rtl_q[dbin]},'
f'{rtl_i[dbin]-py_i[dbin]},{rtl_q[dbin]-py_q[dbin]}\n')
result = {
'scenario': name,
'rtl_count': rtl_total,
'energy_ratio': energy_ratio,
'peak_agreement': peak_agreement_frac,
'avg_mag_corr': avg_mag_corr,
'avg_corr_i': avg_corr_i,
'avg_corr_q': avg_corr_q,
'passed': all_pass,
}
return all_pass, result
# =============================================================================
# Main
# =============================================================================
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'stationary'
if arg == 'all':
run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS:
run_scenarios = [arg]
else:
sys.exit(1)
results = []
for name in run_scenarios:
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
results.append((name, passed, result))
# Summary
all_pass = True
for _name, passed, result in results:
if not result:
all_pass = False
else:
if not passed:
all_pass = False
if all_pass:
pass
else:
pass
sys.exit(0 if all_pass else 1)
if __name__ == '__main__':
main()
-330
View File
@@ -1,330 +0,0 @@
#!/usr/bin/env python3
"""
Co-simulation Comparison: RTL vs Python Model for AERIS-10 Matched Filter.
Compares the RTL matched filter output (from tb_mf_cosim.v) against the
Python model golden reference (from gen_mf_cosim_golden.py).
Two modes of operation:
1. Synthesis branch (no -DSIMULATION): RTL uses fft_engine.v with fixed-point
twiddle ROM (fft_twiddle_1024.mem) and frequency_matched_filter.v. The
Python model was built to match this exactly. Expect BIT-PERFECT results
(correlation = 1.0, energy ratio = 1.0).
2. SIMULATION branch (-DSIMULATION): RTL uses behavioral FFT with floating-
point twiddles ($rtoi($cos*32767)) and shift-then-add conjugate multiply.
Python model uses fixed-point twiddles and add-then-round. Expect large
numerical differences; only state-machine mechanics are validated.
Usage:
python3 compare_mf.py [scenario|all]
scenario: chirp, dc, impulse, tone5 (default: chirp)
all: run all scenarios
Author: Phase 0.5 matched-filter co-simulation suite for PLFM_RADAR
"""
import math
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# =============================================================================
# Configuration
# =============================================================================
FFT_SIZE = 1024
SCENARIOS = {
'chirp': {
'golden_csv': 'mf_golden_py_chirp.csv',
'rtl_csv': 'rtl_mf_chirp.csv',
'description': 'Radar chirp: 2 targets vs ref chirp',
},
'dc': {
'golden_csv': 'mf_golden_py_dc.csv',
'rtl_csv': 'rtl_mf_dc.csv',
'description': 'DC autocorrelation (I=0x1000)',
},
'impulse': {
'golden_csv': 'mf_golden_py_impulse.csv',
'rtl_csv': 'rtl_mf_impulse.csv',
'description': 'Impulse autocorrelation (delta at n=0)',
},
'tone5': {
'golden_csv': 'mf_golden_py_tone5.csv',
'rtl_csv': 'rtl_mf_tone5.csv',
'description': 'Tone autocorrelation (bin 5, amp=8000)',
},
}
# Thresholds for pass/fail
# These are generous because of the fundamental twiddle arithmetic differences
# between the SIMULATION branch (float twiddles) and Python model (fixed twiddles)
ENERGY_CORR_MIN = 0.80 # Min correlation of magnitude spectra
TOP_PEAK_OVERLAP_MIN = 0.50 # At least 50% of top-N peaks must overlap
RMS_RATIO_MAX = 50.0 # Max ratio of RMS energies (generous, since gain differs)
ENERGY_RATIO_MIN = 0.001 # Min ratio (total energy RTL / total energy Python)
ENERGY_RATIO_MAX = 1000.0 # Max ratio
# =============================================================================
# Helper functions
# =============================================================================
def load_csv(filepath):
"""Load CSV with columns (bin, out_i/range_profile_i, out_q/range_profile_q)."""
vals_i = []
vals_q = []
with open(filepath) as f:
f.readline() # Skip header
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(',')
vals_i.append(int(parts[1]))
vals_q.append(int(parts[2]))
return vals_i, vals_q
def magnitude_spectrum(vals_i, vals_q):
"""Compute magnitude = |I| + |Q| for each bin (L1 norm, matches RTL)."""
return [abs(i) + abs(q) for i, q in zip(vals_i, vals_q, strict=False)]
def magnitude_l2(vals_i, vals_q):
"""Compute magnitude = sqrt(I^2 + Q^2) for each bin."""
return [math.sqrt(i*i + q*q) for i, q in zip(vals_i, vals_q, strict=False)]
def total_energy(vals_i, vals_q):
"""Compute total energy (sum of I^2 + Q^2)."""
return sum(i*i + q*q for i, q in zip(vals_i, vals_q, strict=False))
def rms_magnitude(vals_i, vals_q):
"""Compute RMS of complex magnitude."""
n = len(vals_i)
if n == 0:
return 0.0
return math.sqrt(sum(i*i + q*q for i, q in zip(vals_i, vals_q, strict=False)) / n)
def pearson_correlation(a, b):
"""Compute Pearson correlation coefficient between two lists."""
n = len(a)
if n < 2:
return 0.0
mean_a = sum(a) / n
mean_b = sum(b) / n
cov = sum((a[i] - mean_a) * (b[i] - mean_b) for i in range(n))
std_a_sq = sum((x - mean_a) ** 2 for x in a)
std_b_sq = sum((x - mean_b) ** 2 for x in b)
if std_a_sq < 1e-10 or std_b_sq < 1e-10:
return 1.0 if abs(mean_a - mean_b) < 1.0 else 0.0
return cov / math.sqrt(std_a_sq * std_b_sq)
def find_peak(vals_i, vals_q):
"""Find the bin with the maximum L1 magnitude."""
mags = magnitude_spectrum(vals_i, vals_q)
peak_bin = 0
peak_mag = mags[0]
for i in range(1, len(mags)):
if mags[i] > peak_mag:
peak_mag = mags[i]
peak_bin = i
return peak_bin, peak_mag
def top_n_peaks(mags, n=10):
"""Find the top-N peak bins by magnitude. Returns set of bin indices."""
indexed = sorted(enumerate(mags), key=lambda x: -x[1])
return {idx for idx, _ in indexed[:n]}
def spectral_peak_overlap(mags_a, mags_b, n=10):
"""Fraction of top-N peaks from A that also appear in top-N of B."""
peaks_a = top_n_peaks(mags_a, n)
peaks_b = top_n_peaks(mags_b, n)
if len(peaks_a) == 0:
return 1.0
overlap = peaks_a & peaks_b
return len(overlap) / len(peaks_a)
# =============================================================================
# Comparison for one scenario
# =============================================================================
def compare_scenario(scenario_name, config, base_dir):
"""Compare one scenario. Returns (pass/fail, result_dict)."""
golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path):
return False, {}
if not os.path.exists(rtl_path):
return False, {}
py_i, py_q = load_csv(golden_path)
rtl_i, rtl_q = load_csv(rtl_path)
if len(py_i) != FFT_SIZE or len(rtl_i) != FFT_SIZE:
return False, {}
# ---- Metric 1: Energy ----
py_energy = total_energy(py_i, py_q)
rtl_energy = total_energy(rtl_i, rtl_q)
py_rms = rms_magnitude(py_i, py_q)
rtl_rms = rms_magnitude(rtl_i, rtl_q)
if py_energy > 0 and rtl_energy > 0:
energy_ratio = rtl_energy / py_energy
rms_ratio = rtl_rms / py_rms
elif py_energy == 0 and rtl_energy == 0:
energy_ratio = 1.0
rms_ratio = 1.0
else:
energy_ratio = float('inf') if py_energy == 0 else 0.0
rms_ratio = float('inf') if py_rms == 0 else 0.0
# ---- Metric 2: Peak location ----
py_peak_bin, _py_peak_mag = find_peak(py_i, py_q)
rtl_peak_bin, _rtl_peak_mag = find_peak(rtl_i, rtl_q)
# ---- Metric 3: Magnitude spectrum correlation ----
py_mag = magnitude_l2(py_i, py_q)
rtl_mag = magnitude_l2(rtl_i, rtl_q)
mag_corr = pearson_correlation(py_mag, rtl_mag)
# ---- Metric 4: Top-N peak overlap ----
# Use L1 magnitudes for peak finding (matches RTL)
py_mag_l1 = magnitude_spectrum(py_i, py_q)
rtl_mag_l1 = magnitude_spectrum(rtl_i, rtl_q)
peak_overlap_10 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=10)
peak_overlap_20 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=20)
# ---- Metric 5: I and Q channel correlation ----
corr_i = pearson_correlation(py_i, rtl_i)
corr_q = pearson_correlation(py_q, rtl_q)
# ---- Pass/Fail Decision ----
# The SIMULATION branch uses floating-point twiddles ($cos/$sin) while
# the Python model uses the fixed-point twiddle ROM (matching synthesis).
# These are fundamentally different FFT implementations. We do NOT expect
# structural similarity (correlation, peak overlap) between them.
#
# What we CAN verify:
# 1. Both produce non-trivial output (state machine completes)
# 2. Output count is correct (1024 samples)
# 3. Energy is in a reasonable range (not wildly wrong)
#
# The true bit-accuracy comparison will happen when the synthesis branch
# is simulated (xsim on remote server) using the same fft_engine.v that
# the Python model was built to match.
checks = []
# Check 1: Both produce output
both_have_output = py_energy > 0 and rtl_energy > 0
checks.append(('Both produce output', both_have_output))
# Check 2: RTL produced expected sample count
correct_count = len(rtl_i) == FFT_SIZE
checks.append(('Correct output count (1024)', correct_count))
# Check 3: Energy ratio within generous bounds
# Allow very wide range since twiddle differences cause large gain variation
energy_ok = ENERGY_RATIO_MIN < energy_ratio < ENERGY_RATIO_MAX
checks.append((f'Energy ratio in bounds ({ENERGY_RATIO_MIN}-{ENERGY_RATIO_MAX})',
energy_ok))
# Print checks
all_pass = True
for _name, passed in checks:
if not passed:
all_pass = False
result = {
'scenario': scenario_name,
'py_energy': py_energy,
'rtl_energy': rtl_energy,
'energy_ratio': energy_ratio,
'rms_ratio': rms_ratio,
'py_peak_bin': py_peak_bin,
'rtl_peak_bin': rtl_peak_bin,
'mag_corr': mag_corr,
'peak_overlap_10': peak_overlap_10,
'peak_overlap_20': peak_overlap_20,
'corr_i': corr_i,
'corr_q': corr_q,
'passed': all_pass,
}
# Write detailed comparison CSV
compare_csv = os.path.join(base_dir, f'compare_mf_{scenario_name}.csv')
with open(compare_csv, 'w') as f:
f.write('bin,py_i,py_q,rtl_i,rtl_q,py_mag,rtl_mag,diff_i,diff_q\n')
for k in range(FFT_SIZE):
f.write(f'{k},{py_i[k]},{py_q[k]},{rtl_i[k]},{rtl_q[k]},'
f'{py_mag_l1[k]},{rtl_mag_l1[k]},'
f'{rtl_i[k]-py_i[k]},{rtl_q[k]-py_q[k]}\n')
return all_pass, result
# =============================================================================
# Main
# =============================================================================
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'chirp'
if arg == 'all':
run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS:
run_scenarios = [arg]
else:
sys.exit(1)
results = []
for name in run_scenarios:
passed, result = compare_scenario(name, SCENARIOS[name], base_dir)
results.append((name, passed, result))
# Summary
all_pass = True
for _name, passed, result in results:
if not result:
all_pass = False
else:
if not passed:
all_pass = False
if all_pass:
pass
else:
pass
sys.exit(0 if all_pass else 1)
if __name__ == '__main__':
main()
+46 -5
View File
@@ -126,17 +126,40 @@ def write_mem_file(filename, values):
with open(path, 'w') as f: with open(path, 'w') as f:
for v in values: for v in values:
f.write(to_hex16(v) + '\n') f.write(to_hex16(v) + '\n')
print(f" Wrote {filename}: {len(values)} entries")
def main(): def main():
print("=" * 60)
print("AERIS-10 Chirp .mem File Generator")
print("=" * 60)
print()
print("Parameters:")
print(f" CHIRP_BW = {CHIRP_BW/1e6:.1f} MHz")
print(f" FS_SYS = {FS_SYS/1e6:.1f} MHz")
print(f" T_LONG_CHIRP = {T_LONG_CHIRP*1e6:.1f} us")
print(f" T_SHORT_CHIRP = {T_SHORT_CHIRP*1e6:.1f} us")
print(f" LONG_CHIRP_SAMPLES = {LONG_CHIRP_SAMPLES}")
print(f" SHORT_CHIRP_SAMPLES = {SHORT_CHIRP_SAMPLES}")
print(f" FFT_SIZE = {FFT_SIZE}")
print(f" Chirp rate (long) = {CHIRP_BW/T_LONG_CHIRP:.3e} Hz/s")
print(f" Chirp rate (short) = {CHIRP_BW/T_SHORT_CHIRP:.3e} Hz/s")
print(f" Q15 scale = {SCALE}")
print()
# ---- Long chirp ---- # ---- Long chirp ----
print("Generating full long chirp (3000 samples)...")
long_i, long_q = generate_full_long_chirp() long_i, long_q = generate_full_long_chirp()
# Verify first sample matches generate_reference_chirp_q15() from radar_scene.py # Verify first sample matches generate_reference_chirp_q15() from radar_scene.py
# (which only generates the first 1024 samples) # (which only generates the first 1024 samples)
print(f" Sample[0]: I={long_i[0]:6d} Q={long_q[0]:6d}")
print(f" Sample[1023]: I={long_i[1023]:6d} Q={long_q[1023]:6d}")
print(f" Sample[2999]: I={long_i[2999]:6d} Q={long_q[2999]:6d}")
# Segment into 4 x 1024 blocks # Segment into 4 x 1024 blocks
print()
print("Segmenting into 4 x 1024 blocks...")
for seg in range(LONG_SEGMENTS): for seg in range(LONG_SEGMENTS):
start = seg * FFT_SIZE start = seg * FFT_SIZE
end = start + FFT_SIZE end = start + FFT_SIZE
@@ -154,18 +177,27 @@ def main():
seg_i.append(0) seg_i.append(0)
seg_q.append(0) seg_q.append(0)
FFT_SIZE - valid_count zero_count = FFT_SIZE - valid_count
print(f" Seg {seg}: indices [{start}:{end-1}], "
f"valid={valid_count}, zeros={zero_count}")
write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i) write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i)
write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q) write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q)
# ---- Short chirp ---- # ---- Short chirp ----
print()
print("Generating short chirp (50 samples)...")
short_i, short_q = generate_short_chirp() short_i, short_q = generate_short_chirp()
print(f" Sample[0]: I={short_i[0]:6d} Q={short_q[0]:6d}")
print(f" Sample[49]: I={short_i[49]:6d} Q={short_q[49]:6d}")
write_mem_file("short_chirp_i.mem", short_i) write_mem_file("short_chirp_i.mem", short_i)
write_mem_file("short_chirp_q.mem", short_q) write_mem_file("short_chirp_q.mem", short_q)
# ---- Verification summary ---- # ---- Verification summary ----
print()
print("=" * 60)
print("Verification:")
# Cross-check seg0 against radar_scene.py generate_reference_chirp_q15() # Cross-check seg0 against radar_scene.py generate_reference_chirp_q15()
# That function generates exactly the first 1024 samples of the chirp # That function generates exactly the first 1024 samples of the chirp
@@ -180,24 +212,33 @@ def main():
mismatches += 1 mismatches += 1
if mismatches == 0: if mismatches == 0:
pass print(" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()")
else: else:
print(f" [FAIL] Seg0 has {mismatches} mismatches vs generate_reference_chirp_q15()")
return 1 return 1
# Check magnitude envelope # Check magnitude envelope
max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q, strict=False)) max_mag = max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q, strict=False))
print(f" Max magnitude: {max_mag:.1f} (expected ~{Q15_MAX * SCALE:.1f})")
print(f" Magnitude ratio: {max_mag / (Q15_MAX * SCALE):.6f}")
# Check seg3 zero padding # Check seg3 zero padding
seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem') seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem')
with open(seg3_i_path) as f: with open(seg3_i_path) as f:
seg3_lines = [line.strip() for line in f if line.strip()] seg3_lines = [line.strip() for line in f if line.strip()]
nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000') nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000')
print(f" Seg3 non-zero entries: {nonzero_seg3}/{len(seg3_lines)} "
f"(expected 0 since chirp ends at sample 2999)")
if nonzero_seg3 == 0: if nonzero_seg3 == 0:
pass print(" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)")
else: else:
pass print(f" [WARN] Seg3 has {nonzero_seg3} non-zero entries")
print()
print(f"Generated 10 .mem files in {os.path.abspath(MEM_DIR)}")
print("Run validate_mem_files.py to do full validation.")
print("=" * 60)
return 0 return 0
@@ -51,6 +51,7 @@ def write_hex_32bit(filepath, samples):
for (i_val, q_val) in samples: for (i_val, q_val) in samples:
packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF) packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF)
f.write(f"{packed:08X}\n") f.write(f"{packed:08X}\n")
print(f" Wrote {len(samples)} packed samples to {filepath}")
def write_csv(filepath, headers, *columns): def write_csv(filepath, headers, *columns):
@@ -60,6 +61,7 @@ def write_csv(filepath, headers, *columns):
for i in range(len(columns[0])): for i in range(len(columns[0])):
row = ','.join(str(col[i]) for col in columns) row = ','.join(str(col[i]) for col in columns)
f.write(row + '\n') f.write(row + '\n')
print(f" Wrote {len(columns[0])} rows to {filepath}")
def write_hex_16bit(filepath, data): def write_hex_16bit(filepath, data):
@@ -116,10 +118,15 @@ SCENARIOS = {
def generate_scenario(name, targets, description, base_dir): def generate_scenario(name, targets, description, base_dir):
"""Generate input hex + golden output for one scenario.""" """Generate input hex + golden output for one scenario."""
print(f"\n{'='*60}")
print(f"Scenario: {name}{description}")
print("Model: CLEAN (dual 16-pt FFT)")
print(f"{'='*60}")
# Generate Doppler frame (32 chirps x 64 range bins) # Generate Doppler frame (32 chirps x 64 range bins)
frame_i, frame_q = generate_doppler_frame(targets, seed=42) frame_i, frame_q = generate_doppler_frame(targets, seed=42)
print(f" Generated frame: {len(frame_i)} chirps x {len(frame_i[0])} range bins")
# ---- Write input hex file (packed 32-bit: {Q, I}) ---- # ---- Write input hex file (packed 32-bit: {Q, I}) ----
# RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ... # RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ...
@@ -137,6 +144,8 @@ def generate_scenario(name, targets, description, base_dir):
dp = DopplerProcessor() dp = DopplerProcessor()
doppler_i, doppler_q = dp.process_frame(frame_i, frame_q) doppler_i, doppler_q = dp.process_frame(frame_i, frame_q)
print(f" Doppler output: {len(doppler_i)} range bins x "
f"{len(doppler_i[0])} doppler bins (2 sub-frames x {DOPPLER_FFT_SIZE})")
# ---- Write golden output CSV ---- # ---- Write golden output CSV ----
# Format: range_bin, doppler_bin, out_i, out_q # Format: range_bin, doppler_bin, out_i, out_q
@@ -164,6 +173,7 @@ def generate_scenario(name, targets, description, base_dir):
write_hex_32bit(golden_hex, list(zip(flat_i, flat_q, strict=False))) write_hex_32bit(golden_hex, list(zip(flat_i, flat_q, strict=False)))
# ---- Find peak per range bin ---- # ---- Find peak per range bin ----
print("\n Peak Doppler bins per range bin (top 5 by magnitude):")
peak_info = [] peak_info = []
for rbin in range(RANGE_BINS): for rbin in range(RANGE_BINS):
mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d]) mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d])
@@ -174,11 +184,13 @@ def generate_scenario(name, targets, description, base_dir):
# Sort by magnitude descending, show top 5 # Sort by magnitude descending, show top 5
peak_info.sort(key=lambda x: -x[2]) peak_info.sort(key=lambda x: -x[2])
for rbin, dbin, _mag in peak_info[:5]: for rbin, dbin, mag in peak_info[:5]:
doppler_i[rbin][dbin] i_val = doppler_i[rbin][dbin]
doppler_q[rbin][dbin] q_val = doppler_q[rbin][dbin]
dbin // DOPPLER_FFT_SIZE sf = dbin // DOPPLER_FFT_SIZE
dbin % DOPPLER_FFT_SIZE bin_in_sf = dbin % DOPPLER_FFT_SIZE
print(f" rbin={rbin:2d}, dbin={dbin:2d} (sf{sf}:{bin_in_sf:2d}), mag={mag:6d}, "
f"I={i_val:6d}, Q={q_val:6d}")
return { return {
'name': name, 'name': name,
@@ -190,6 +202,10 @@ def generate_scenario(name, targets, description, base_dir):
def main(): def main():
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Doppler Processor Co-Sim Golden Reference Generator")
print(f"Architecture: dual {DOPPLER_FFT_SIZE}-pt FFT ({DOPPLER_TOTAL_BINS} total bins)")
print("=" * 60)
scenarios_to_run = list(SCENARIOS.keys()) scenarios_to_run = list(SCENARIOS.keys())
@@ -207,9 +223,17 @@ def main():
r = generate_scenario(name, targets, description, base_dir) r = generate_scenario(name, targets, description, base_dir)
results.append(r) results.append(r)
for _ in results: print(f"\n{'='*60}")
pass print("Summary:")
print(f"{'='*60}")
for r in results:
print(f" {r['name']:<15s} top peak: "
f"rbin={r['peak_info'][0][0]}, dbin={r['peak_info'][0][1]}, "
f"mag={r['peak_info'][0][2]}")
print(f"\nGenerated {len(results)} scenarios.")
print(f"Files written to: {base_dir}")
print("=" * 60)
if __name__ == '__main__': if __name__ == '__main__':
@@ -75,6 +75,7 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
Returns dict with case info and results. Returns dict with case info and results.
""" """
print(f"\n--- {case_name}: {description} ---")
assert len(sig_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}" assert len(sig_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}"
assert len(sig_q) == FFT_SIZE assert len(sig_q) == FFT_SIZE
@@ -87,6 +88,8 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_q.hex"), sig_q) write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_q.hex"), sig_q)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_i.hex"), ref_i) write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_i.hex"), ref_i)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q) write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q)
print(f" Wrote input hex: mf_sig_{case_name}_{{i,q}}.hex, "
f"mf_ref_{case_name}_{{i,q}}.hex")
# Run through bit-accurate Python model # Run through bit-accurate Python model
mf = MatchedFilterChain(fft_size=FFT_SIZE) mf = MatchedFilterChain(fft_size=FFT_SIZE)
@@ -101,6 +104,9 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
peak_mag = mag peak_mag = mag
peak_bin = k peak_bin = k
print(f" Output: {len(out_i)} samples")
print(f" Peak bin: {peak_bin}, magnitude: {peak_mag}")
print(f" Peak I={out_i[peak_bin]}, Q={out_q[peak_bin]}")
# Save golden output hex # Save golden output hex
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i) write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i)
@@ -129,6 +135,10 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
def main(): def main():
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Matched Filter Co-Sim Golden Reference Generator")
print("Using bit-accurate Python model (fpga_model.py)")
print("=" * 60)
results = [] results = []
@@ -148,7 +158,8 @@ def main():
base_dir) base_dir)
results.append(r) results.append(r)
else: else:
pass print("\nWARNING: bb_mf_test / ref_chirp hex files not found.")
print("Run radar_scene.py first.")
# ---- Case 2: DC autocorrelation ---- # ---- Case 2: DC autocorrelation ----
dc_val = 0x1000 # 4096 dc_val = 0x1000 # 4096
@@ -190,9 +201,16 @@ def main():
results.append(r) results.append(r)
# ---- Summary ---- # ---- Summary ----
for _ in results: print("\n" + "=" * 60)
pass print("Summary:")
print("=" * 60)
for r in results:
print(f" {r['case_name']:10s}: peak at bin {r['peak_bin']}, "
f"mag={r['peak_mag']}, I={r['peak_i']}, Q={r['peak_q']}")
print(f"\nGenerated {len(results)} golden reference cases.")
print("Files written to:", base_dir)
print("=" * 60)
if __name__ == '__main__': if __name__ == '__main__':
+34 -5
View File
@@ -163,7 +163,7 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
return chirp_i, chirp_q return chirp_i, chirp_q
def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, _f_if=F_IF, _fs=FS_ADC): def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
""" """
Generate a reference chirp in Q15 format for the matched filter. Generate a reference chirp in Q15 format for the matched filter.
@@ -398,6 +398,7 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
for target in targets: for target in targets:
# Which range bin does this target fall in? # Which range bin does this target fall in?
# After matched filter + range decimation: # After matched filter + range decimation:
# range_bin = target_delay_in_baseband_samples / decimation_factor
delay_baseband_samples = target.delay_s * FS_SYS delay_baseband_samples = target.delay_s * FS_SYS
range_bin_float = delay_baseband_samples * n_range_bins / FFT_SIZE range_bin_float = delay_baseband_samples * n_range_bins / FFT_SIZE
range_bin = round(range_bin_float) range_bin = round(range_bin_float)
@@ -405,6 +406,7 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
if range_bin < 0 or range_bin >= n_range_bins: if range_bin < 0 or range_bin >= n_range_bins:
continue continue
# Amplitude (simplified)
amp = target.amplitude / 4.0 amp = target.amplitude / 4.0
# Doppler phase for this chirp. # Doppler phase for this chirp.
@@ -472,6 +474,7 @@ def write_hex_file(filepath, samples, bits=8):
val = s & ((1 << bits) - 1) val = s & ((1 << bits) - 1)
f.write(fmt.format(val) + "\n") f.write(fmt.format(val) + "\n")
print(f" Wrote {len(samples)} samples to {filepath}")
def write_csv_file(filepath, columns, headers=None): def write_csv_file(filepath, columns, headers=None):
@@ -491,6 +494,7 @@ def write_csv_file(filepath, columns, headers=None):
row = [str(col[i]) for col in columns] row = [str(col[i]) for col in columns]
f.write(",".join(row) + "\n") f.write(",".join(row) + "\n")
print(f" Wrote {n_rows} rows to {filepath}")
# ============================================================================= # =============================================================================
@@ -503,6 +507,10 @@ def scenario_single_target(range_m=500, velocity=0, rcs=0, n_adc_samples=16384):
Good for validating matched filter range response. Good for validating matched filter range response.
""" """
target = Target(range_m=range_m, velocity_mps=velocity, rcs_dbsm=rcs) target = Target(range_m=range_m, velocity_mps=velocity, rcs_dbsm=rcs)
print(f"Scenario: Single target at {range_m}m")
print(f" {target}")
print(f" Beat freq: {CHIRP_BW / T_LONG_CHIRP * target.delay_s:.0f} Hz")
print(f" Delay: {target.delay_samples:.1f} ADC samples")
adc = generate_adc_samples([target], n_adc_samples, noise_stddev=2.0) adc = generate_adc_samples([target], n_adc_samples, noise_stddev=2.0)
return adc, [target] return adc, [target]
@@ -517,8 +525,9 @@ def scenario_two_targets(n_adc_samples=16384):
Target(range_m=300, velocity_mps=0, rcs_dbsm=10, phase_deg=0), Target(range_m=300, velocity_mps=0, rcs_dbsm=10, phase_deg=0),
Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45), Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45),
] ]
for _t in targets: print("Scenario: Two targets (range resolution test)")
pass for t in targets:
print(f" {t}")
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0) adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0)
return adc, targets return adc, targets
@@ -535,8 +544,9 @@ def scenario_multi_target(n_adc_samples=16384):
Target(range_m=2000, velocity_mps=50, rcs_dbsm=0, phase_deg=45), Target(range_m=2000, velocity_mps=50, rcs_dbsm=0, phase_deg=45),
Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270), Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270),
] ]
for _t in targets: print("Scenario: Multi-target (5 targets)")
pass for t in targets:
print(f" {t}")
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0) adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0)
return adc, targets return adc, targets
@@ -546,6 +556,7 @@ def scenario_noise_only(n_adc_samples=16384, noise_stddev=5.0):
""" """
Noise-only scene — baseline for false alarm characterization. Noise-only scene — baseline for false alarm characterization.
""" """
print(f"Scenario: Noise only (stddev={noise_stddev})")
adc = generate_adc_samples([], n_adc_samples, noise_stddev=noise_stddev) adc = generate_adc_samples([], n_adc_samples, noise_stddev=noise_stddev)
return adc, [] return adc, []
@@ -554,6 +565,7 @@ def scenario_dc_tone(n_adc_samples=16384, adc_value=128):
""" """
DC input — validates CIC decimation and DC response. DC input — validates CIC decimation and DC response.
""" """
print(f"Scenario: DC tone (ADC value={adc_value})")
return [adc_value] * n_adc_samples, [] return [adc_value] * n_adc_samples, []
@@ -561,6 +573,7 @@ def scenario_sine_wave(n_adc_samples=16384, freq_hz=1e6, amplitude=50):
""" """
Pure sine wave at ADC input — validates NCO/mixer frequency response. Pure sine wave at ADC input — validates NCO/mixer frequency response.
""" """
print(f"Scenario: Sine wave at {freq_hz/1e6:.1f} MHz, amplitude={amplitude}")
adc = [] adc = []
for n in range(n_adc_samples): for n in range(n_adc_samples):
t = n / FS_ADC t = n / FS_ADC
@@ -590,35 +603,46 @@ def generate_all_test_vectors(output_dir=None):
if output_dir is None: if output_dir is None:
output_dir = os.path.dirname(os.path.abspath(__file__)) output_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Generating AERIS-10 Test Vectors")
print(f"Output directory: {output_dir}")
print("=" * 60)
n_adc = 16384 # ~41 us of ADC data n_adc = 16384 # ~41 us of ADC data
# --- Scenario 1: Single target --- # --- Scenario 1: Single target ---
print("\n--- Scenario 1: Single Target ---")
adc1, targets1 = scenario_single_target(range_m=500, n_adc_samples=n_adc) adc1, targets1 = scenario_single_target(range_m=500, n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_single_target.hex"), adc1, bits=8) write_hex_file(os.path.join(output_dir, "adc_single_target.hex"), adc1, bits=8)
# --- Scenario 2: Multi-target --- # --- Scenario 2: Multi-target ---
print("\n--- Scenario 2: Multi-Target ---")
adc2, targets2 = scenario_multi_target(n_adc_samples=n_adc) adc2, targets2 = scenario_multi_target(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_multi_target.hex"), adc2, bits=8) write_hex_file(os.path.join(output_dir, "adc_multi_target.hex"), adc2, bits=8)
# --- Scenario 3: Noise only --- # --- Scenario 3: Noise only ---
print("\n--- Scenario 3: Noise Only ---")
adc3, _ = scenario_noise_only(n_adc_samples=n_adc) adc3, _ = scenario_noise_only(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8) write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8)
# --- Scenario 4: DC --- # --- Scenario 4: DC ---
print("\n--- Scenario 4: DC Input ---")
adc4, _ = scenario_dc_tone(n_adc_samples=n_adc) adc4, _ = scenario_dc_tone(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8) write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8)
# --- Scenario 5: Sine wave --- # --- Scenario 5: Sine wave ---
print("\n--- Scenario 5: 1 MHz Sine ---")
adc5, _ = scenario_sine_wave(n_adc_samples=n_adc, freq_hz=1e6, amplitude=50) adc5, _ = scenario_sine_wave(n_adc_samples=n_adc, freq_hz=1e6, amplitude=50)
write_hex_file(os.path.join(output_dir, "adc_sine_1mhz.hex"), adc5, bits=8) write_hex_file(os.path.join(output_dir, "adc_sine_1mhz.hex"), adc5, bits=8)
# --- Reference chirp for matched filter --- # --- Reference chirp for matched filter ---
print("\n--- Reference Chirp ---")
ref_re, ref_im = generate_reference_chirp_q15() ref_re, ref_im = generate_reference_chirp_q15()
write_hex_file(os.path.join(output_dir, "ref_chirp_i.hex"), ref_re, bits=16) write_hex_file(os.path.join(output_dir, "ref_chirp_i.hex"), ref_re, bits=16)
write_hex_file(os.path.join(output_dir, "ref_chirp_q.hex"), ref_im, bits=16) write_hex_file(os.path.join(output_dir, "ref_chirp_q.hex"), ref_im, bits=16)
# --- Baseband samples for matched filter test (bypass DDC) --- # --- Baseband samples for matched filter test (bypass DDC) ---
print("\n--- Baseband Samples (bypass DDC) ---")
bb_targets = [ bb_targets = [
Target(range_m=500, velocity_mps=0, rcs_dbsm=10), Target(range_m=500, velocity_mps=0, rcs_dbsm=10),
Target(range_m=1500, velocity_mps=20, rcs_dbsm=5), Target(range_m=1500, velocity_mps=20, rcs_dbsm=5),
@@ -628,6 +652,7 @@ def generate_all_test_vectors(output_dir=None):
write_hex_file(os.path.join(output_dir, "bb_mf_test_q.hex"), bb_q, bits=16) write_hex_file(os.path.join(output_dir, "bb_mf_test_q.hex"), bb_q, bits=16)
# --- Scenario info CSV --- # --- Scenario info CSV ---
print("\n--- Scenario Info ---")
with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f: with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f:
f.write("AERIS-10 Test Vector Scenarios\n") f.write("AERIS-10 Test Vector Scenarios\n")
f.write("=" * 60 + "\n\n") f.write("=" * 60 + "\n\n")
@@ -657,7 +682,11 @@ def generate_all_test_vectors(output_dir=None):
for t in bb_targets: for t in bb_targets:
f.write(f" {t}\n") f.write(f" {t}\n")
print(f"\n Wrote scenario info to {os.path.join(output_dir, 'scenario_info.txt')}")
print("\n" + "=" * 60)
print("ALL TEST VECTORS GENERATED")
print("=" * 60)
return { return {
'adc_single': adc1, 'adc_single': adc1,
@@ -69,6 +69,7 @@ FIR_COEFFS_HEX = [
# DDC output interface # DDC output interface
DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation
# FFT (Range)
FFT_SIZE = 1024 FFT_SIZE = 1024
FFT_DATA_W = 16 FFT_DATA_W = 16
FFT_INTERNAL_W = 32 FFT_INTERNAL_W = 32
@@ -147,15 +148,21 @@ def load_and_quantize_adi_data(data_path, config_path, frame_idx=0):
4. Upconvert to 120 MHz IF (add I*cos - Q*sin) to create real signal 4. Upconvert to 120 MHz IF (add I*cos - Q*sin) to create real signal
5. Quantize to 8-bit unsigned (matching AD9484) 5. Quantize to 8-bit unsigned (matching AD9484)
""" """
print(f"[LOAD] Loading ADI dataset from {data_path}")
data = np.load(data_path, allow_pickle=True) data = np.load(data_path, allow_pickle=True)
config = np.load(config_path, allow_pickle=True) config = np.load(config_path, allow_pickle=True)
print(f" Shape: {data.shape}, dtype: {data.dtype}")
print(f" Config: sample_rate={config[0]:.0f}, IF={config[1]:.0f}, "
f"RF={config[2]:.0f}, chirps={config[3]:.0f}, BW={config[4]:.0f}, "
f"ramp={config[5]:.6f}s")
# Extract one frame # Extract one frame
frame = data[frame_idx] # (256, 1079) complex frame = data[frame_idx] # (256, 1079) complex
# Use first 32 chirps, first 1024 samples # Use first 32 chirps, first 1024 samples
iq_block = frame[:DOPPLER_CHIRPS, :FFT_SIZE] # (32, 1024) complex iq_block = frame[:DOPPLER_CHIRPS, :FFT_SIZE] # (32, 1024) complex
print(f" Using frame {frame_idx}: {DOPPLER_CHIRPS} chirps x {FFT_SIZE} samples")
# The ADI data is baseband complex IQ at 4 MSPS. # The ADI data is baseband complex IQ at 4 MSPS.
# AERIS-10 sees a real signal at 400 MSPS with 120 MHz IF. # AERIS-10 sees a real signal at 400 MSPS with 120 MHz IF.
@@ -190,6 +197,9 @@ def load_and_quantize_adi_data(data_path, config_path, frame_idx=0):
iq_i = np.clip(iq_i, -32768, 32767) iq_i = np.clip(iq_i, -32768, 32767)
iq_q = np.clip(iq_q, -32768, 32767) iq_q = np.clip(iq_q, -32768, 32767)
print(f" Scaled to 16-bit (peak target {INPUT_PEAK_TARGET}): "
f"I range [{iq_i.min()}, {iq_i.max()}], "
f"Q range [{iq_q.min()}, {iq_q.max()}]")
# Also create 8-bit ADC stimulus for DDC validation # Also create 8-bit ADC stimulus for DDC validation
# Use just one chirp of real-valued data (I channel only, shifted to unsigned) # Use just one chirp of real-valued data (I channel only, shifted to unsigned)
@@ -281,6 +291,7 @@ def run_ddc(adc_samples):
# Build FIR coefficients as signed integers # Build FIR coefficients as signed integers
fir_coeffs = np.array([hex_to_signed(c, 18) for c in FIR_COEFFS_HEX], dtype=np.int64) fir_coeffs = np.array([hex_to_signed(c, 18) for c in FIR_COEFFS_HEX], dtype=np.int64)
print(f"[DDC] Processing {n_samples} ADC samples at 400 MHz")
# --- NCO + Mixer --- # --- NCO + Mixer ---
phase_accum = np.int64(0) phase_accum = np.int64(0)
@@ -313,6 +324,7 @@ def run_ddc(adc_samples):
# Phase accumulator update (ignore dithering for bit-accuracy) # Phase accumulator update (ignore dithering for bit-accuracy)
phase_accum = (phase_accum + NCO_PHASE_INC) & 0xFFFFFFFF phase_accum = (phase_accum + NCO_PHASE_INC) & 0xFFFFFFFF
print(f" Mixer output: I range [{mixed_i.min()}, {mixed_i.max()}]")
# --- CIC Decimator (5-stage, decimate-by-4) --- # --- CIC Decimator (5-stage, decimate-by-4) ---
# Integrator section (at 400 MHz rate) # Integrator section (at 400 MHz rate)
@@ -320,9 +332,7 @@ def run_ddc(adc_samples):
for n in range(n_samples): for n in range(n_samples):
integrators[0][n + 1] = (integrators[0][n] + mixed_i[n]) & ((1 << CIC_ACC_WIDTH) - 1) integrators[0][n + 1] = (integrators[0][n] + mixed_i[n]) & ((1 << CIC_ACC_WIDTH) - 1)
for s in range(1, CIC_STAGES): for s in range(1, CIC_STAGES):
integrators[s][n + 1] = ( integrators[s][n + 1] = (integrators[s][n] + integrators[s - 1][n + 1]) & ((1 << CIC_ACC_WIDTH) - 1)
integrators[s][n] + integrators[s - 1][n + 1]
) & ((1 << CIC_ACC_WIDTH) - 1)
# Downsample by 4 # Downsample by 4
n_decimated = n_samples // CIC_DECIMATION n_decimated = n_samples // CIC_DECIMATION
@@ -356,6 +366,7 @@ def run_ddc(adc_samples):
scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT
cic_output[k] = saturate(scaled, CIC_OUT_BITS) cic_output[k] = saturate(scaled, CIC_OUT_BITS)
print(f" CIC output: {n_decimated} samples, range [{cic_output.min()}, {cic_output.max()}]")
# --- FIR Filter (32-tap) --- # --- FIR Filter (32-tap) ---
delay_line = np.zeros(FIR_TAPS, dtype=np.int64) delay_line = np.zeros(FIR_TAPS, dtype=np.int64)
@@ -377,6 +388,7 @@ def run_ddc(adc_samples):
if fir_output[k] >= (1 << 17): if fir_output[k] >= (1 << 17):
fir_output[k] -= (1 << 18) fir_output[k] -= (1 << 18)
print(f" FIR output: range [{fir_output.min()}, {fir_output.max()}]")
# --- DDC Interface (18 → 16 bit) --- # --- DDC Interface (18 → 16 bit) ---
ddc_output = np.zeros(n_decimated, dtype=np.int64) ddc_output = np.zeros(n_decimated, dtype=np.int64)
@@ -393,6 +405,7 @@ def run_ddc(adc_samples):
else: else:
ddc_output[k] = saturate(trunc + round_bit, 16) ddc_output[k] = saturate(trunc + round_bit, 16)
print(f" DDC output (16-bit): range [{ddc_output.min()}, {ddc_output.max()}]")
return ddc_output return ddc_output
@@ -465,6 +478,7 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
# Generate twiddle factors if file not available # Generate twiddle factors if file not available
cos_rom = np.round(32767 * np.cos(2 * np.pi * np.arange(N // 4) / N)).astype(np.int64) cos_rom = np.round(32767 * np.cos(2 * np.pi * np.arange(N // 4) / N)).astype(np.int64)
print(f"[FFT] Running {N}-point range FFT (bit-accurate)")
# Bit-reverse and sign-extend to 32-bit internal width # Bit-reverse and sign-extend to 32-bit internal width
def bit_reverse(val, bits): def bit_reverse(val, bits):
@@ -502,6 +516,9 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
b_re = mem_re[addr_odd] b_re = mem_re[addr_odd]
b_im = mem_im[addr_odd] b_im = mem_im[addr_odd]
# Twiddle multiply: forward FFT
# prod_re = b_re * tw_cos + b_im * tw_sin
# prod_im = b_im * tw_cos - b_re * tw_sin
prod_re = b_re * tw_cos + b_im * tw_sin prod_re = b_re * tw_cos + b_im * tw_sin
prod_im = b_im * tw_cos - b_re * tw_sin prod_im = b_im * tw_cos - b_re * tw_sin
@@ -524,6 +541,8 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
out_re[n] = saturate(mem_re[n], FFT_DATA_W) out_re[n] = saturate(mem_re[n], FFT_DATA_W)
out_im[n] = saturate(mem_im[n], FFT_DATA_W) out_im[n] = saturate(mem_im[n], FFT_DATA_W)
print(f" FFT output: re range [{out_re.min()}, {out_re.max()}], "
f"im range [{out_im.min()}, {out_im.max()}]")
return out_re, out_im return out_re, out_im
@@ -558,6 +577,8 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64) decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64)
decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64) decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64)
print(f"[DECIM] Decimating {n_in}{output_bins} bins, mode={'peak' if mode==1 else 'avg' if mode==2 else 'simple'}, "
f"start_bin={start_bin}, {n_chirps} chirps")
for c in range(n_chirps): for c in range(n_chirps):
# Index into input, skip start_bin # Index into input, skip start_bin
@@ -606,7 +627,7 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
# Averaging: sum group, then >> 4 (divide by 16) # Averaging: sum group, then >> 4 (divide by 16)
sum_i = np.int64(0) sum_i = np.int64(0)
sum_q = np.int64(0) sum_q = np.int64(0)
for _ in range(decimation_factor): for _s in range(decimation_factor):
if in_idx >= input_bins: if in_idx >= input_bins:
break break
sum_i += int(range_fft_i[c, in_idx]) sum_i += int(range_fft_i[c, in_idx])
@@ -616,6 +637,9 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
decimated_i[c, obin] = int(sum_i) >> 4 decimated_i[c, obin] = int(sum_i) >> 4
decimated_q[c, obin] = int(sum_q) >> 4 decimated_q[c, obin] = int(sum_q) >> 4
print(f" Decimated output: shape ({n_chirps}, {output_bins}), "
f"I range [{decimated_i.min()}, {decimated_i.max()}], "
f"Q range [{decimated_q.min()}, {decimated_q.max()}]")
return decimated_i, decimated_q return decimated_i, decimated_q
@@ -641,6 +665,7 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
n_total = DOPPLER_TOTAL_BINS n_total = DOPPLER_TOTAL_BINS
n_sf = CHIRPS_PER_SUBFRAME n_sf = CHIRPS_PER_SUBFRAME
print(f"[DOPPLER] Processing {n_range} range bins x {n_chirps} chirps → dual {n_fft}-point FFT")
# Build 16-point Hamming window as signed 16-bit # Build 16-point Hamming window as signed 16-bit
hamming = np.array([int(v) for v in HAMMING_Q15], dtype=np.int64) hamming = np.array([int(v) for v in HAMMING_Q15], dtype=np.int64)
@@ -650,9 +675,7 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
if twiddle_file_16 and os.path.exists(twiddle_file_16): if twiddle_file_16 and os.path.exists(twiddle_file_16):
cos_rom_16 = load_twiddle_rom(twiddle_file_16) cos_rom_16 = load_twiddle_rom(twiddle_file_16)
else: else:
cos_rom_16 = np.round( cos_rom_16 = np.round(32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)).astype(np.int64)
32767 * np.cos(2 * np.pi * np.arange(n_fft // 4) / n_fft)
).astype(np.int64)
LOG2N_16 = 4 LOG2N_16 = 4
doppler_map_i = np.zeros((n_range, n_total), dtype=np.int64) doppler_map_i = np.zeros((n_range, n_total), dtype=np.int64)
@@ -724,6 +747,8 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
doppler_map_i[rbin, bin_offset + n] = saturate(mem_re[n], 16) doppler_map_i[rbin, bin_offset + n] = saturate(mem_re[n], 16)
doppler_map_q[rbin, bin_offset + n] = saturate(mem_im[n], 16) doppler_map_q[rbin, bin_offset + n] = saturate(mem_im[n], 16)
print(f" Doppler map: shape ({n_range}, {n_total}), "
f"I range [{doppler_map_i.min()}, {doppler_map_i.max()}]")
return doppler_map_i, doppler_map_q return doppler_map_i, doppler_map_q
@@ -753,10 +778,12 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
mti_i = np.zeros_like(decim_i) mti_i = np.zeros_like(decim_i)
mti_q = np.zeros_like(decim_q) mti_q = np.zeros_like(decim_q)
print(f"[MTI] 2-pulse canceller, enable={enable}, {n_chirps} chirps x {n_bins} bins")
if not enable: if not enable:
mti_i[:] = decim_i mti_i[:] = decim_i
mti_q[:] = decim_q mti_q[:] = decim_q
print(" Pass-through mode (MTI disabled)")
return mti_i, mti_q return mti_i, mti_q
for c in range(n_chirps): for c in range(n_chirps):
@@ -772,6 +799,9 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
mti_i[c, r] = saturate(diff_i, 16) mti_i[c, r] = saturate(diff_i, 16)
mti_q[c, r] = saturate(diff_q, 16) mti_q[c, r] = saturate(diff_q, 16)
print(" Chirp 0: muted (zeros)")
print(f" Chirps 1-{n_chirps-1}: I range [{mti_i[1:].min()}, {mti_i[1:].max()}], "
f"Q range [{mti_q[1:].min()}, {mti_q[1:].max()}]")
return mti_i, mti_q return mti_i, mti_q
@@ -798,12 +828,14 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
dc_notch_active = (width != 0) && dc_notch_active = (width != 0) &&
(bin_within_sf < width || bin_within_sf > (15 - width + 1)) (bin_within_sf < width || bin_within_sf > (15 - width + 1))
""" """
_n_range, n_doppler = doppler_i.shape n_range, n_doppler = doppler_i.shape
notched_i = doppler_i.copy() notched_i = doppler_i.copy()
notched_q = doppler_q.copy() notched_q = doppler_q.copy()
print(f"[DC NOTCH] width={width}, {n_range} range bins x {n_doppler} Doppler bins (dual sub-frame)")
if width == 0: if width == 0:
print(" Pass-through (width=0)")
return notched_i, notched_q return notched_i, notched_q
zeroed_count = 0 zeroed_count = 0
@@ -815,6 +847,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
notched_q[:, dbin] = 0 notched_q[:, dbin] = 0
zeroed_count += 1 zeroed_count += 1
print(f" Zeroed {zeroed_count} Doppler bin columns")
return notched_i, notched_q return notched_i, notched_q
@@ -822,7 +855,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
# Stage 3e: CA-CFAR Detector (bit-accurate) # Stage 3e: CA-CFAR Detector (bit-accurate)
# =========================================================================== # ===========================================================================
def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8, def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
alpha_q44=0x30, mode='CA', _simple_threshold=500): alpha_q44=0x30, mode='CA', simple_threshold=500):
""" """
Bit-accurate model of cfar_ca.v — Cell-Averaging CFAR detector. Bit-accurate model of cfar_ca.v — Cell-Averaging CFAR detector.
@@ -860,6 +893,9 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
if train == 0: if train == 0:
train = 1 train = 1
print(f"[CFAR] mode={mode}, guard={guard}, train={train}, "
f"alpha=0x{alpha_q44:02X} (Q4.4={alpha_q44/16:.2f}), "
f"{n_range} range x {n_doppler} Doppler")
# Compute magnitudes: |I| + |Q| (17-bit unsigned, matching RTL L1 norm) # Compute magnitudes: |I| + |Q| (17-bit unsigned, matching RTL L1 norm)
# RTL: abs_i = I[15] ? (~I + 1) : I; abs_q = Q[15] ? (~Q + 1) : Q # RTL: abs_i = I[15] ? (~I + 1) : I; abs_q = Q[15] ? (~Q + 1) : Q
@@ -927,6 +963,10 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
else: else:
noise_sum = leading_sum + lagging_sum # Default to CA noise_sum = leading_sum + lagging_sum # Default to CA
# Threshold = (alpha * noise_sum) >> ALPHA_FRAC_BITS
# RTL: noise_product = r_alpha * noise_sum_reg (31-bit)
# threshold = noise_product[ALPHA_FRAC_BITS +: MAG_WIDTH]
# saturate if overflow
noise_product = alpha_q44 * noise_sum noise_product = alpha_q44 * noise_sum
threshold_raw = noise_product >> ALPHA_FRAC_BITS threshold_raw = noise_product >> ALPHA_FRAC_BITS
@@ -934,12 +974,15 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
MAX_MAG = (1 << 17) - 1 # 131071 MAX_MAG = (1 << 17) - 1 # 131071
threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw) threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw)
# Detection: magnitude > threshold
if int(col[cut_idx]) > threshold_val: if int(col[cut_idx]) > threshold_val:
detect_flags[cut_idx, dbin] = True detect_flags[cut_idx, dbin] = True
total_detections += 1 total_detections += 1
thresholds[cut_idx, dbin] = threshold_val thresholds[cut_idx, dbin] = threshold_val
print(f" Total detections: {total_detections}")
print(f" Magnitude range: [{magnitudes.min()}, {magnitudes.max()}]")
return detect_flags, magnitudes, thresholds return detect_flags, magnitudes, thresholds
@@ -953,16 +996,19 @@ def run_detection(doppler_i, doppler_q, threshold=10000):
cfar_mag = |I| + |Q| (17-bit) cfar_mag = |I| + |Q| (17-bit)
detection if cfar_mag > threshold detection if cfar_mag > threshold
""" """
print(f"[DETECT] Running magnitude threshold detection (threshold={threshold})")
mag = np.abs(doppler_i) + np.abs(doppler_q) # L1 norm (|I| + |Q|) mag = np.abs(doppler_i) + np.abs(doppler_q) # L1 norm (|I| + |Q|)
detections = np.argwhere(mag > threshold) detections = np.argwhere(mag > threshold)
print(f" {len(detections)} detections found")
for d in detections[:20]: # Print first 20 for d in detections[:20]: # Print first 20
rbin, dbin = d rbin, dbin = d
mag[rbin, dbin] m = mag[rbin, dbin]
print(f" Range bin {rbin}, Doppler bin {dbin}: magnitude {m}")
if len(detections) > 20: if len(detections) > 20:
pass print(f" ... and {len(detections) - 20} more")
return mag, detections return mag, detections
@@ -976,6 +1022,7 @@ def run_float_reference(iq_i, iq_q):
Uses the exact same RTL Hamming window coefficients (Q15) to isolate Uses the exact same RTL Hamming window coefficients (Q15) to isolate
only the FFT fixed-point quantization error. only the FFT fixed-point quantization error.
""" """
print("\n[FLOAT REF] Running floating-point reference pipeline")
n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i) n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i)
@@ -1023,6 +1070,8 @@ def write_hex_files(output_dir, iq_i, iq_q, prefix="stim"):
fi.write(signed_to_hex(int(iq_i[n]), 16) + '\n') fi.write(signed_to_hex(int(iq_i[n]), 16) + '\n')
fq.write(signed_to_hex(int(iq_q[n]), 16) + '\n') fq.write(signed_to_hex(int(iq_q[n]), 16) + '\n')
print(f" Wrote {fn_i} ({n_samples} samples)")
print(f" Wrote {fn_q} ({n_samples} samples)")
elif iq_i.ndim == 2: elif iq_i.ndim == 2:
n_rows, n_cols = iq_i.shape n_rows, n_cols = iq_i.shape
@@ -1036,6 +1085,8 @@ def write_hex_files(output_dir, iq_i, iq_q, prefix="stim"):
fi.write(signed_to_hex(int(iq_i[r, c]), 16) + '\n') fi.write(signed_to_hex(int(iq_i[r, c]), 16) + '\n')
fq.write(signed_to_hex(int(iq_q[r, c]), 16) + '\n') fq.write(signed_to_hex(int(iq_q[r, c]), 16) + '\n')
print(f" Wrote {fn_i} ({n_rows}x{n_cols} = {n_rows * n_cols} samples)")
print(f" Wrote {fn_q} ({n_rows}x{n_cols} = {n_rows * n_cols} samples)")
def write_adc_hex(output_dir, adc_data, prefix="adc_stim"): def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
@@ -1047,12 +1098,13 @@ def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
for n in range(len(adc_data)): for n in range(len(adc_data)):
f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n') f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n')
print(f" Wrote {fn} ({len(adc_data)} samples)")
# =========================================================================== # ===========================================================================
# Comparison metrics # Comparison metrics
# =========================================================================== # ===========================================================================
def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q): def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
"""Compare fixed-point outputs against floating-point reference. """Compare fixed-point outputs against floating-point reference.
Reports two metrics: Reports two metrics:
@@ -1068,7 +1120,7 @@ def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
# Count saturated bins # Count saturated bins
sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767) sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767)
np.sum(sat_mask) n_saturated = np.sum(sat_mask)
# Complex error — overall # Complex error — overall
fixed_complex = fi + 1j * fq fixed_complex = fi + 1j * fq
@@ -1077,8 +1129,8 @@ def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
signal_power = np.mean(np.abs(ref_complex) ** 2) + 1e-30 signal_power = np.mean(np.abs(ref_complex) ** 2) + 1e-30
noise_power = np.mean(np.abs(error) ** 2) + 1e-30 noise_power = np.mean(np.abs(error) ** 2) + 1e-30
10 * np.log10(signal_power / noise_power) snr_db = 10 * np.log10(signal_power / noise_power)
np.max(np.abs(error)) max_error = np.max(np.abs(error))
# Non-saturated comparison # Non-saturated comparison
non_sat = ~sat_mask non_sat = ~sat_mask
@@ -1087,10 +1139,17 @@ def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
sig_ns = np.mean(np.abs(ref_complex[non_sat]) ** 2) + 1e-30 sig_ns = np.mean(np.abs(ref_complex[non_sat]) ** 2) + 1e-30
noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30 noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30
snr_ns = 10 * np.log10(sig_ns / noise_ns) snr_ns = 10 * np.log10(sig_ns / noise_ns)
np.max(np.abs(error_ns)) max_err_ns = np.max(np.abs(error_ns))
else: else:
snr_ns = 0.0 snr_ns = 0.0
max_err_ns = 0.0
print(f"\n [{name}] Comparison ({n} points):")
print(f" Saturated: {n_saturated}/{n} ({100.0*n_saturated/n:.2f}%)")
print(f" Overall SNR: {snr_db:.1f} dB")
print(f" Overall max error: {max_error:.1f}")
print(f" Non-sat SNR: {snr_ns:.1f} dB")
print(f" Non-sat max error: {max_err_ns:.1f}")
return snr_ns # Return the meaningful metric return snr_ns # Return the meaningful metric
@@ -1102,12 +1161,7 @@ def main():
parser = argparse.ArgumentParser(description="AERIS-10 FPGA golden reference model") parser = argparse.ArgumentParser(description="AERIS-10 FPGA golden reference model")
parser.add_argument('--frame', type=int, default=0, help='Frame index to process') parser.add_argument('--frame', type=int, default=0, help='Frame index to process')
parser.add_argument('--plot', action='store_true', help='Show plots') parser.add_argument('--plot', action='store_true', help='Show plots')
parser.add_argument( parser.add_argument('--threshold', type=int, default=10000, help='Detection threshold (L1 magnitude)')
'--threshold',
type=int,
default=10000,
help='Detection threshold (L1 magnitude)'
)
args = parser.parse_args() args = parser.parse_args()
# Paths # Paths
@@ -1115,14 +1169,14 @@ def main():
fpga_dir = os.path.abspath(os.path.join(script_dir, '..', '..', '..')) fpga_dir = os.path.abspath(os.path.join(script_dir, '..', '..', '..'))
data_base = os.path.expanduser("~/Downloads/adi_radar_data") data_base = os.path.expanduser("~/Downloads/adi_radar_data")
amp_data = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB.npy") amp_data = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB.npy")
amp_config = os.path.join( amp_config = os.path.join(data_base, "amp_radar", "phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy")
data_base,
"amp_radar",
"phaser_amp_4MSPS_500M_300u_256_m3dB_config.npy"
)
twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem") twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem")
output_dir = os.path.join(script_dir, "hex") output_dir = os.path.join(script_dir, "hex")
print("=" * 72)
print("AERIS-10 FPGA Golden Reference Model")
print("Using ADI CN0566 Phaser Radar Data (10.525 GHz X-band FMCW)")
print("=" * 72)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Load and quantize ADI data # Load and quantize ADI data
@@ -1132,10 +1186,16 @@ def main():
) )
# iq_i, iq_q: (32, 1024) int64, 16-bit range — post-DDC equivalent # iq_i, iq_q: (32, 1024) int64, 16-bit range — post-DDC equivalent
print(f"\n{'=' * 72}")
print("Stage 0: Data loaded and quantized to 16-bit signed")
print(f" IQ block shape: ({iq_i.shape[0]}, {iq_i.shape[1]})")
print(f" ADC stimulus: {len(adc_8bit)} samples (8-bit unsigned)")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Write stimulus files # Write stimulus files
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Writing hex stimulus files for RTL testbenches")
# Post-DDC IQ for each chirp (for FFT + Doppler validation) # Post-DDC IQ for each chirp (for FFT + Doppler validation)
write_hex_files(output_dir, iq_i, iq_q, "post_ddc") write_hex_files(output_dir, iq_i, iq_q, "post_ddc")
@@ -1149,6 +1209,8 @@ def main():
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Run range FFT on first chirp (bit-accurate) # Run range FFT on first chirp (bit-accurate)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 2: Range FFT (1024-point, bit-accurate)")
range_fft_i, range_fft_q = run_range_fft(iq_i[0], iq_q[0], twiddle_1024) range_fft_i, range_fft_q = run_range_fft(iq_i[0], iq_q[0], twiddle_1024)
write_hex_files(output_dir, range_fft_i, range_fft_q, "range_fft_chirp0") write_hex_files(output_dir, range_fft_i, range_fft_q, "range_fft_chirp0")
@@ -1156,16 +1218,20 @@ def main():
all_range_i = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64) all_range_i = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
all_range_q = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64) all_range_q = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
print(f"\n Running range FFT for all {DOPPLER_CHIRPS} chirps...")
for c in range(DOPPLER_CHIRPS): for c in range(DOPPLER_CHIRPS):
ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024) ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024)
all_range_i[c] = ri all_range_i[c] = ri
all_range_q[c] = rq all_range_q[c] = rq
if (c + 1) % 8 == 0: if (c + 1) % 8 == 0:
pass print(f" Chirp {c + 1}/{DOPPLER_CHIRPS} done")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Run Doppler FFT (bit-accurate) — "direct" path (first 64 bins) # Run Doppler FFT (bit-accurate) — "direct" path (first 64 bins)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 3: Doppler FFT (dual 16-point with Hamming window)")
print(" [direct path: first 64 range bins, no decimation]")
twiddle_16 = os.path.join(fpga_dir, "fft_twiddle_16.mem") twiddle_16 = os.path.join(fpga_dir, "fft_twiddle_16.mem")
doppler_i, doppler_q = run_doppler_fft(all_range_i, all_range_q, twiddle_file_16=twiddle_16) doppler_i, doppler_q = run_doppler_fft(all_range_i, all_range_q, twiddle_file_16=twiddle_16)
write_hex_files(output_dir, doppler_i, doppler_q, "doppler_map") write_hex_files(output_dir, doppler_i, doppler_q, "doppler_map")
@@ -1175,6 +1241,8 @@ def main():
# This models the actual RTL data flow: # This models the actual RTL data flow:
# range FFT → range_bin_decimator (peak detection) → Doppler # range FFT → range_bin_decimator (peak detection) → Doppler
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 2b: Range Bin Decimator (1024 → 64, peak detection)")
decim_i, decim_q = run_range_bin_decimator( decim_i, decim_q = run_range_bin_decimator(
all_range_i, all_range_q, all_range_i, all_range_q,
@@ -1194,11 +1262,14 @@ def main():
q_val = int(all_range_q[c, b]) & 0xFFFF q_val = int(all_range_q[c, b]) & 0xFFFF
packed = (q_val << 16) | i_val packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n") f.write(f"{packed:08X}\n")
print(f" Wrote {fc_input_file} ({DOPPLER_CHIRPS * FFT_SIZE} packed IQ words)")
# Write decimated output reference for standalone decimator test # Write decimated output reference for standalone decimator test
write_hex_files(output_dir, decim_i, decim_q, "decimated_range") write_hex_files(output_dir, decim_i, decim_q, "decimated_range")
# Now run Doppler on the decimated data — this is the full-chain reference # Now run Doppler on the decimated data — this is the full-chain reference
print(f"\n{'=' * 72}")
print("Stage 3b: Doppler FFT on decimated data (full-chain path)")
fc_doppler_i, fc_doppler_q = run_doppler_fft( fc_doppler_i, fc_doppler_q = run_doppler_fft(
decim_i, decim_q, twiddle_file_16=twiddle_16 decim_i, decim_q, twiddle_file_16=twiddle_16
) )
@@ -1213,6 +1284,7 @@ def main():
q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n") f.write(f"{packed:08X}\n")
print(f" Wrote {fc_doppler_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)")
# Save numpy arrays for the full-chain path # Save numpy arrays for the full-chain path
np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i) np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i)
@@ -1225,12 +1297,16 @@ def main():
# This models the complete RTL data flow: # This models the complete RTL data flow:
# range FFT → decimator → MTI canceller → Doppler → DC notch → CFAR # range FFT → decimator → MTI canceller → Doppler → DC notch → CFAR
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 3c: MTI Canceller (2-pulse, on decimated data)")
mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=True) mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=True)
write_hex_files(output_dir, mti_i, mti_q, "fullchain_mti_ref") write_hex_files(output_dir, mti_i, mti_q, "fullchain_mti_ref")
np.save(os.path.join(output_dir, "fullchain_mti_i.npy"), mti_i) np.save(os.path.join(output_dir, "fullchain_mti_i.npy"), mti_i)
np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q) np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q)
# Doppler on MTI-filtered data # Doppler on MTI-filtered data
print(f"\n{'=' * 72}")
print("Stage 3b+c: Doppler FFT on MTI-filtered decimated data")
mti_doppler_i, mti_doppler_q = run_doppler_fft( mti_doppler_i, mti_doppler_q = run_doppler_fft(
mti_i, mti_q, twiddle_file_16=twiddle_16 mti_i, mti_q, twiddle_file_16=twiddle_16
) )
@@ -1240,6 +1316,8 @@ def main():
# DC notch on MTI-Doppler data # DC notch on MTI-Doppler data
DC_NOTCH_WIDTH = 2 # Default test value: zero bins {0, 1, 31} DC_NOTCH_WIDTH = 2 # Default test value: zero bins {0, 1, 31}
print(f"\n{'=' * 72}")
print(f"Stage 3d: DC Notch Filter (width={DC_NOTCH_WIDTH})")
notched_i, notched_q = run_dc_notch(mti_doppler_i, mti_doppler_q, width=DC_NOTCH_WIDTH) notched_i, notched_q = run_dc_notch(mti_doppler_i, mti_doppler_q, width=DC_NOTCH_WIDTH)
write_hex_files(output_dir, notched_i, notched_q, "fullchain_notched_ref") write_hex_files(output_dir, notched_i, notched_q, "fullchain_notched_ref")
@@ -1252,12 +1330,15 @@ def main():
q_val = int(notched_q[rbin, dbin]) & 0xFFFF q_val = int(notched_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n") f.write(f"{packed:08X}\n")
print(f" Wrote {fc_notched_packed_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)")
# CFAR on DC-notched data # CFAR on DC-notched data
CFAR_GUARD = 2 CFAR_GUARD = 2
CFAR_TRAIN = 8 CFAR_TRAIN = 8
CFAR_ALPHA = 0x30 # Q4.4 = 3.0 CFAR_ALPHA = 0x30 # Q4.4 = 3.0
CFAR_MODE = 'CA' CFAR_MODE = 'CA'
print(f"\n{'=' * 72}")
print(f"Stage 3e: CA-CFAR (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})")
cfar_flags, cfar_mag, cfar_thr = run_cfar_ca( cfar_flags, cfar_mag, cfar_thr = run_cfar_ca(
notched_i, notched_q, notched_i, notched_q,
guard=CFAR_GUARD, train=CFAR_TRAIN, guard=CFAR_GUARD, train=CFAR_TRAIN,
@@ -1272,6 +1353,7 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS): for dbin in range(DOPPLER_TOTAL_BINS):
m = int(cfar_mag[rbin, dbin]) & 0x1FFFF m = int(cfar_mag[rbin, dbin]) & 0x1FFFF
f.write(f"{m:05X}\n") f.write(f"{m:05X}\n")
print(f" Wrote {cfar_mag_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} mag values)")
# 2. Threshold map (17-bit unsigned) # 2. Threshold map (17-bit unsigned)
cfar_thr_file = os.path.join(output_dir, "fullchain_cfar_thr.hex") cfar_thr_file = os.path.join(output_dir, "fullchain_cfar_thr.hex")
@@ -1280,6 +1362,7 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS): for dbin in range(DOPPLER_TOTAL_BINS):
t = int(cfar_thr[rbin, dbin]) & 0x1FFFF t = int(cfar_thr[rbin, dbin]) & 0x1FFFF
f.write(f"{t:05X}\n") f.write(f"{t:05X}\n")
print(f" Wrote {cfar_thr_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} threshold values)")
# 3. Detection flags (1-bit per cell) # 3. Detection flags (1-bit per cell)
cfar_det_file = os.path.join(output_dir, "fullchain_cfar_det.hex") cfar_det_file = os.path.join(output_dir, "fullchain_cfar_det.hex")
@@ -1288,6 +1371,7 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS): for dbin in range(DOPPLER_TOTAL_BINS):
d = 1 if cfar_flags[rbin, dbin] else 0 d = 1 if cfar_flags[rbin, dbin] else 0
f.write(f"{d:01X}\n") f.write(f"{d:01X}\n")
print(f" Wrote {cfar_det_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} detection flags)")
# 4. Detection list (text) # 4. Detection list (text)
cfar_detections = np.argwhere(cfar_flags) cfar_detections = np.argwhere(cfar_flags)
@@ -1295,14 +1379,12 @@ def main():
with open(cfar_det_list_file, 'w') as f: with open(cfar_det_list_file, 'w') as f:
f.write("# AERIS-10 Full-Chain CFAR Detection List\n") f.write("# AERIS-10 Full-Chain CFAR Detection List\n")
f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n") f.write(f"# Chain: decim -> MTI -> Doppler -> DC notch(w={DC_NOTCH_WIDTH}) -> CA-CFAR\n")
f.write( f.write(f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n")
f"# CFAR: guard={CFAR_GUARD}, train={CFAR_TRAIN}, "
f"alpha=0x{CFAR_ALPHA:02X}, mode={CFAR_MODE}\n"
)
f.write("# Format: range_bin doppler_bin magnitude threshold\n") f.write("# Format: range_bin doppler_bin magnitude threshold\n")
for det in cfar_detections: for det in cfar_detections:
r, d = det r, d = det
f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n") f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n")
print(f" Wrote {cfar_det_list_file} ({len(cfar_detections)} detections)")
# Save numpy arrays # Save numpy arrays
np.save(os.path.join(output_dir, "fullchain_cfar_mag.npy"), cfar_mag) np.save(os.path.join(output_dir, "fullchain_cfar_mag.npy"), cfar_mag)
@@ -1310,6 +1392,8 @@ def main():
np.save(os.path.join(output_dir, "fullchain_cfar_flags.npy"), cfar_flags) np.save(os.path.join(output_dir, "fullchain_cfar_flags.npy"), cfar_flags)
# Run detection on full-chain Doppler map # Run detection on full-chain Doppler map
print(f"\n{'=' * 72}")
print("Stage 4: Detection on full-chain Doppler map")
fc_mag, fc_detections = run_detection(fc_doppler_i, fc_doppler_q, threshold=args.threshold) fc_mag, fc_detections = run_detection(fc_doppler_i, fc_doppler_q, threshold=args.threshold)
# Save full-chain detection reference # Save full-chain detection reference
@@ -1321,6 +1405,7 @@ def main():
for d in fc_detections: for d in fc_detections:
rbin, dbin = d rbin, dbin = d
f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n") f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n")
print(f" Wrote {fc_det_file} ({len(fc_detections)} detections)")
# Also write detection reference as hex for RTL comparison # Also write detection reference as hex for RTL comparison
fc_det_mag_file = os.path.join(output_dir, "fullchain_detection_mag.hex") fc_det_mag_file = os.path.join(output_dir, "fullchain_detection_mag.hex")
@@ -1329,10 +1414,13 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS): for dbin in range(DOPPLER_TOTAL_BINS):
m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned
f.write(f"{m:05X}\n") f.write(f"{m:05X}\n")
print(f" Wrote {fc_det_mag_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} magnitude values)")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Run detection on direct-path Doppler map (for backward compatibility) # Run detection on direct-path Doppler map (for backward compatibility)
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 4b: Detection on direct-path Doppler map")
mag, detections = run_detection(doppler_i, doppler_q, threshold=args.threshold) mag, detections = run_detection(doppler_i, doppler_q, threshold=args.threshold)
# Save detection list # Save detection list
@@ -1344,23 +1432,26 @@ def main():
for d in detections: for d in detections:
rbin, dbin = d rbin, dbin = d
f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n") f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n")
print(f" Wrote {det_file} ({len(detections)} detections)")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Float reference and comparison # Float reference and comparison
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Comparison: Fixed-point vs Float reference")
range_fft_float, doppler_float = run_float_reference(iq_i, iq_q) range_fft_float, doppler_float = run_float_reference(iq_i, iq_q)
# Compare range FFT (chirp 0) # Compare range FFT (chirp 0)
float_range_i = np.real(range_fft_float[0, :]).astype(np.float64) float_range_i = np.real(range_fft_float[0, :]).astype(np.float64)
float_range_q = np.imag(range_fft_float[0, :]).astype(np.float64) float_range_q = np.imag(range_fft_float[0, :]).astype(np.float64)
compare_outputs("Range FFT", range_fft_i, range_fft_q, snr_range = compare_outputs("Range FFT", range_fft_i, range_fft_q,
float_range_i, float_range_q) float_range_i, float_range_q)
# Compare Doppler map # Compare Doppler map
float_doppler_i = np.real(doppler_float).flatten().astype(np.float64) float_doppler_i = np.real(doppler_float).flatten().astype(np.float64)
float_doppler_q = np.imag(doppler_float).flatten().astype(np.float64) float_doppler_q = np.imag(doppler_float).flatten().astype(np.float64)
compare_outputs("Doppler FFT", snr_doppler = compare_outputs("Doppler FFT",
doppler_i.flatten(), doppler_q.flatten(), doppler_i.flatten(), doppler_q.flatten(),
float_doppler_i, float_doppler_q) float_doppler_i, float_doppler_q)
@@ -1372,10 +1463,26 @@ def main():
np.save(os.path.join(output_dir, "doppler_map_i.npy"), doppler_i) np.save(os.path.join(output_dir, "doppler_map_i.npy"), doppler_i)
np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q) np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q)
np.save(os.path.join(output_dir, "detection_mag.npy"), mag) np.save(os.path.join(output_dir, "detection_mag.npy"), mag)
print(f"\n Saved numpy reference files to {output_dir}/")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Summary # Summary
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("SUMMARY")
print(f"{'=' * 72}")
print(f" ADI dataset: frame {args.frame} of amp_radar (CN0566, 10.525 GHz)")
print(f" Chirps processed: {DOPPLER_CHIRPS}")
print(f" Samples/chirp: {FFT_SIZE}")
print(f" Range FFT: {FFT_SIZE}-point → {snr_range:.1f} dB vs float")
print(f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming → {snr_doppler:.1f} dB vs float")
print(f" Detections (direct): {len(detections)} (threshold={args.threshold})")
print(" Full-chain decimator: 1024→64 peak detection")
print(f" Full-chain detections: {len(fc_detections)} (threshold={args.threshold})")
print(f" MTI+CFAR chain: decim → MTI → Doppler → DC notch(w={DC_NOTCH_WIDTH}) → CA-CFAR")
print(f" CFAR detections: {len(cfar_detections)} (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})")
print(f" Hex stimulus files: {output_dir}/")
print(" Ready for RTL co-simulation with Icarus Verilog")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Optional plots # Optional plots
@@ -1426,10 +1533,11 @@ def main():
plt.tight_layout() plt.tight_layout()
plot_file = os.path.join(output_dir, "golden_reference_plots.png") plot_file = os.path.join(output_dir, "golden_reference_plots.png")
plt.savefig(plot_file, dpi=150) plt.savefig(plot_file, dpi=150)
print(f"\n Saved plots to {plot_file}")
plt.show() plt.show()
except ImportError: except ImportError:
pass print("\n [WARN] matplotlib not available, skipping plots")
if __name__ == "__main__": if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
@@ -1,569 +0,0 @@
#!/usr/bin/env python3
"""
validate_mem_files.py Validate all .mem files against AERIS-10 radar parameters.
Checks:
1. Structural: line counts, hex format, value ranges for all 12 .mem files
2. FFT twiddle files: bit-exact match against cos(2*pi*k/N) in Q15
3. Long chirp .mem files: reverse-engineer parameters, check for chirp structure
4. Short chirp .mem files: check length, value range, spectral content
5. latency_buffer LATENCY=3187 parameter validation
Usage:
python3 validate_mem_files.py
"""
import math
import os
import sys
# ============================================================================
# AERIS-10 System Parameters (from radar_scene.py)
# ============================================================================
F_CARRIER = 10.5e9 # 10.5 GHz carrier
C_LIGHT = 3.0e8
F_IF = 120e6 # IF frequency
CHIRP_BW = 20e6 # 20 MHz sweep
FS_ADC = 400e6 # ADC sample rate
FS_SYS = 100e6 # System clock (100 MHz, after CIC 4x)
T_LONG_CHIRP = 30e-6 # 30 us long chirp
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
CIC_DECIMATION = 4
FFT_SIZE = 1024
DOPPLER_FFT_SIZE = 16
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000 at 100 MHz
# Overlap-save parameters
OVERLAP_SAMPLES = 128
SEGMENT_ADVANCE = FFT_SIZE - OVERLAP_SAMPLES # 896
LONG_SEGMENTS = 4
MEM_DIR = os.path.join(os.path.dirname(__file__), '..', '..')
pass_count = 0
fail_count = 0
warn_count = 0
def check(condition, _label):
global pass_count, fail_count
if condition:
pass_count += 1
else:
fail_count += 1
def warn(_label):
global warn_count
warn_count += 1
def read_mem_hex(filename):
"""Read a .mem file, return list of integer values (16-bit signed)."""
path = os.path.join(MEM_DIR, filename)
values = []
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
continue
val = int(line, 16)
# Interpret as 16-bit signed
if val >= 0x8000:
val -= 0x10000
values.append(val)
return values
# ============================================================================
# TEST 1: Structural validation of all .mem files
# ============================================================================
def test_structural():
expected = {
# FFT twiddle files (quarter-wave cosine ROMs)
'fft_twiddle_1024.mem': {'lines': 256, 'desc': '1024-pt FFT quarter-wave cos ROM'},
'fft_twiddle_16.mem': {'lines': 4, 'desc': '16-pt FFT quarter-wave cos ROM'},
# Long chirp segments (4 segments x 1024 samples each)
'long_chirp_seg0_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 I'},
'long_chirp_seg0_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 Q'},
'long_chirp_seg1_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 I'},
'long_chirp_seg1_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 Q'},
'long_chirp_seg2_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 I'},
'long_chirp_seg2_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 Q'},
'long_chirp_seg3_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 I'},
'long_chirp_seg3_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 Q'},
# Short chirp (50 samples)
'short_chirp_i.mem': {'lines': 50, 'desc': 'Short chirp I'},
'short_chirp_q.mem': {'lines': 50, 'desc': 'Short chirp Q'},
}
for fname, info in expected.items():
path = os.path.join(MEM_DIR, fname)
exists = os.path.isfile(path)
check(exists, f"{fname} exists")
if not exists:
continue
vals = read_mem_hex(fname)
check(len(vals) == info['lines'],
f"{fname}: {len(vals)} data lines (expected {info['lines']})")
# Check all values are in 16-bit signed range
in_range = all(-32768 <= v <= 32767 for v in vals)
check(in_range, f"{fname}: all values in [-32768, 32767]")
# ============================================================================
# TEST 2: FFT Twiddle Factor Validation
# ============================================================================
def test_twiddle_1024():
vals = read_mem_hex('fft_twiddle_1024.mem')
max_err = 0
err_details = []
for k in range(min(256, len(vals))):
angle = 2.0 * math.pi * k / 1024.0
expected = round(math.cos(angle) * 32767.0)
expected = max(-32768, min(32767, expected))
actual = vals[k]
err = abs(actual - expected)
if err > max_err:
max_err = err
if err > 1:
err_details.append((k, actual, expected, err))
check(max_err <= 1,
f"fft_twiddle_1024.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
if err_details:
for _, _act, _exp, _e in err_details[:5]:
pass
def test_twiddle_16():
vals = read_mem_hex('fft_twiddle_16.mem')
max_err = 0
for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0
expected = round(math.cos(angle) * 32767.0)
expected = max(-32768, min(32767, expected))
actual = vals[k]
err = abs(actual - expected)
if err > max_err:
max_err = err
check(max_err <= 1,
f"fft_twiddle_16.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
# Print all 4 entries for reference
for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0
expected = round(math.cos(angle) * 32767.0)
# ============================================================================
# TEST 3: Long Chirp .mem File Analysis
# ============================================================================
def test_long_chirp():
# Load all 4 segments
all_i = []
all_q = []
for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
all_i.extend(seg_i)
all_q.extend(seg_q)
total_samples = len(all_i)
check(total_samples == 4096,
f"Total long chirp samples: {total_samples} (expected 4096 = 4 segs x 1024)")
# Compute magnitude envelope
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(all_i, all_q, strict=False)]
max_mag = max(magnitudes)
min(magnitudes)
sum(magnitudes) / len(magnitudes)
# Check if this looks like it came from generate_reference_chirp_q15
# That function uses 32767 * 0.9 scaling => max magnitude ~29490
expected_max_from_model = 32767 * 0.9
uses_model_scaling = max_mag > expected_max_from_model * 0.8
if uses_model_scaling:
pass
else:
warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model "
f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.")
# Check non-zero content: how many samples are non-zero?
sum(1 for v in all_i if v != 0)
sum(1 for v in all_q if v != 0)
# Analyze instantaneous frequency via phase differences
phases = []
for i_val, q_val in zip(all_i, all_q, strict=False):
if abs(i_val) > 5 or abs(q_val) > 5: # Skip near-zero samples
phases.append(math.atan2(q_val, i_val))
else:
phases.append(None)
# Compute phase differences (instantaneous frequency)
freq_estimates = []
for n in range(1, len(phases)):
if phases[n] is not None and phases[n-1] is not None:
dp = phases[n] - phases[n-1]
# Unwrap
while dp > math.pi:
dp -= 2 * math.pi
while dp < -math.pi:
dp += 2 * math.pi
# Frequency in Hz (at 100 MHz sample rate, since these are post-DDC)
f_inst = dp * FS_SYS / (2 * math.pi)
freq_estimates.append(f_inst)
if freq_estimates:
sum(freq_estimates[:50]) / 50 if len(freq_estimates) > 50 else freq_estimates[0]
sum(freq_estimates[-50:]) / 50 if len(freq_estimates) > 50 else freq_estimates[-1]
f_min = min(freq_estimates)
f_max = max(freq_estimates)
f_range = f_max - f_min
# A chirp should show frequency sweep
is_chirp = f_range > 0.5e6 # At least 0.5 MHz sweep
check(is_chirp,
f"Long chirp shows frequency sweep ({f_range/1e6:.2f} MHz > 0.5 MHz)")
# Check if bandwidth roughly matches expected
bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50%
if bw_match:
pass
else:
warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz")
# Compare segment boundaries for overlap-save consistency
# In proper overlap-save, the chirp data should be segmented at 896-sample boundaries
# with segments being 1024-sample FFT blocks
for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
seg_mags = [math.sqrt(i*i + q*q) for i, q in zip(seg_i, seg_q, strict=False)]
sum(seg_mags) / len(seg_mags)
max(seg_mags)
# Check segment 3 zero-padding (chirp is 3000 samples, seg3 starts at 3072)
# Samples 3000-4095 should be zero (or near-zero) if chirp is exactly 3000 samples
if seg == 3:
# Seg3 covers chirp samples 3072..4095
# If chirp is only 3000 samples, then only samples 0..(3000-3072) = NONE are valid
# Actually chirp has 3000 samples total. Seg3 starts at index 3*1024=3072.
# So seg3 should only have 3000-3072 = -72 -> no valid chirp data!
# Wait, but the .mem files have 1024 lines with non-trivial data...
# Let's check if seg3 has significant data
zero_count = sum(1 for m in seg_mags if m < 2)
if zero_count > 500:
pass
else:
pass
else:
pass
# ============================================================================
# TEST 4: Short Chirp .mem File Analysis
# ============================================================================
def test_short_chirp():
short_i = read_mem_hex('short_chirp_i.mem')
short_q = read_mem_hex('short_chirp_q.mem')
check(len(short_i) == 50, f"Short chirp I: {len(short_i)} samples (expected 50)")
check(len(short_q) == 50, f"Short chirp Q: {len(short_q)} samples (expected 50)")
# Expected: 0.5 us chirp at 100 MHz = 50 samples
expected_samples = int(T_SHORT_CHIRP * FS_SYS)
check(len(short_i) == expected_samples,
f"Short chirp length matches T_SHORT_CHIRP * FS_SYS = {expected_samples}")
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(short_i, short_q, strict=False)]
max(magnitudes)
sum(magnitudes) / len(magnitudes)
# Check non-zero
nonzero = sum(1 for m in magnitudes if m > 1)
check(nonzero == len(short_i), f"All {nonzero}/{len(short_i)} samples non-zero")
# Check it looks like a chirp (phase should be quadratic)
phases = [math.atan2(q, i) for i, q in zip(short_i, short_q, strict=False)]
freq_est = []
for n in range(1, len(phases)):
dp = phases[n] - phases[n-1]
while dp > math.pi:
dp -= 2 * math.pi
while dp < -math.pi:
dp += 2 * math.pi
freq_est.append(dp * FS_SYS / (2 * math.pi))
if freq_est:
freq_est[0]
freq_est[-1]
# ============================================================================
# TEST 5: Generate Expected Chirp .mem and Compare
# ============================================================================
def test_chirp_vs_model():
# Generate reference using the same method as radar_scene.py
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
model_i = []
model_q = []
n_chirp = min(FFT_SIZE, LONG_CHIRP_SAMPLES) # 1024
for n in range(n_chirp):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
re_val = round(32767 * 0.9 * math.cos(phase))
im_val = round(32767 * 0.9 * math.sin(phase))
model_i.append(max(-32768, min(32767, re_val)))
model_q.append(max(-32768, min(32767, im_val)))
# Read seg0 from .mem
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
# Compare magnitudes
model_mags = [math.sqrt(i*i + q*q) for i, q in zip(model_i, model_q, strict=False)]
mem_mags = [math.sqrt(i*i + q*q) for i, q in zip(mem_i, mem_q, strict=False)]
model_max = max(model_mags)
mem_max = max(mem_mags)
# Check if they match (they almost certainly won't based on magnitude analysis)
matches = sum(1 for a, b in zip(model_i, mem_i, strict=False) if a == b)
if matches > len(model_i) * 0.9:
pass
else:
warn(".mem files do NOT match Python model. They likely have different provenance.")
# Try to detect scaling
if mem_max > 0:
model_max / mem_max
# Check phase correlation (shape match regardless of scaling)
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
# Compute phase differences
phase_diffs = []
for mp, fp in zip(model_phases, mem_phases, strict=False):
d = mp - fp
while d > math.pi:
d -= 2 * math.pi
while d < -math.pi:
d += 2 * math.pi
phase_diffs.append(d)
sum(phase_diffs) / len(phase_diffs)
max_phase_diff = max(abs(d) for d in phase_diffs)
phase_match = max_phase_diff < 0.5 # within 0.5 rad
check(
phase_match,
f"Phase shape match: max diff = {math.degrees(max_phase_diff):.1f} deg "
f"(tolerance: 28.6 deg)",
)
# ============================================================================
# TEST 6: Latency Buffer LATENCY=3187 Validation
# ============================================================================
def test_latency_buffer():
# The latency buffer delays the reference chirp data to align with
# the matched filter processing chain output.
#
# The total latency through the processing chain depends on the branch:
#
# SYNTHESIS branch (fft_engine.v):
# - Load: 1024 cycles (input)
# - Forward FFT: LOG2N=10 stages x N/2=512 butterflies x 5-cycle pipeline = variable
# - Reference FFT: same
# - Conjugate multiply: 1024 cycles (4-stage pipeline in frequency_matched_filter)
# - Inverse FFT: same as forward
# - Output: 1024 cycles
# Total: roughly 3000-4000 cycles depending on pipeline fill
#
# The LATENCY=3187 value was likely determined empirically to align
# the reference chirp arriving at the processing chain with the
# correct time-domain position.
#
# Key constraint: LATENCY must be < 4096 (BRAM buffer size)
LATENCY = 3187
BRAM_SIZE = 4096
check(LATENCY < BRAM_SIZE,
f"LATENCY ({LATENCY}) < BRAM size ({BRAM_SIZE})")
# The fft_engine processes in stages:
# - LOAD: 1024 clocks (accepts input)
# - Per butterfly stage: 512 butterflies x 5 pipeline stages = ~2560 clocks + overhead
# Actually: 512 butterflies, each takes 5 cycles = 2560 per stage, 10 stages
# Total compute: 10 * 2560 = 25600 clocks
# But this is just for ONE FFT. The chain does 3 FFTs + multiply.
#
# For the SIMULATION branch, it's 1 clock per operation (behavioral).
# LATENCY=3187 doesn't apply to simulation branch behavior —
# it's the physical hardware pipeline latency.
#
# For synthesis: the latency_buffer feeds ref data to the chain via
# chirp_memory_loader_param → latency_buffer → chain.
# But wait — looking at radar_receiver_final.v:
# - mem_request drives valid_in on the latency buffer
# - The buffer delays {ref_i, ref_q} by LATENCY valid_in cycles
# - The delayed output feeds long_chirp_real/imag → chain
#
# The purpose: the chain in the SYNTHESIS branch reads reference data
# via the long_chirp_real/imag ports DURING ST_FWD_FFT (while collecting
# input samples). The reference data needs to arrive LATENCY cycles
# after the first mem_request, where LATENCY accounts for:
# - The fft_engine pipeline latency from input to output
# - Specifically, the chain processes: load 1024 → FFT → FFT → multiply → IFFT → output
# The reference is consumed during the second FFT (ST_REF_BITREV/BUTTERFLY)
# which starts after the first FFT completes.
# For now, validate that LATENCY is reasonable (between 1000 and 4095)
check(1000 < LATENCY < 4095,
f"LATENCY={LATENCY} in reasonable range [1000, 4095]")
# Check that the module name vs parameter is consistent
# Module name was renamed from latency_buffer_2159 to latency_buffer
# to match the actual parameterized LATENCY value. No warning needed.
# Validate address arithmetic won't overflow
min_read_ptr = 4096 + 0 - LATENCY
check(min_read_ptr >= 0 and min_read_ptr < 4096,
f"Min read_ptr after wrap = {min_read_ptr} (valid: 0..4095)")
# The latency buffer uses valid_in gated reads, so it only counts
# valid samples. The number of valid_in pulses between first write
# and first read is LATENCY.
# ============================================================================
# TEST 7: Cross-check chirp memory loader addressing
# ============================================================================
def test_memory_addressing():
# chirp_memory_loader_param uses: long_addr = {segment_select[1:0], sample_addr[9:0]}
# This creates a 12-bit address: seg[1:0] ++ addr[9:0]
# Segment 0: addresses 0x000..0x3FF (0..1023)
# Segment 1: addresses 0x400..0x7FF (1024..2047)
# Segment 2: addresses 0x800..0xBFF (2048..3071)
# Segment 3: addresses 0xC00..0xFFF (3072..4095)
for seg in range(4):
base = seg * 1024
end = base + 1023
addr_from_concat = (seg << 10) | 0 # {seg[1:0], 10'b0}
addr_end = (seg << 10) | 1023
check(
addr_from_concat == base,
f"Seg {seg} base address: {{{seg}[1:0], 10'b0}} = {addr_from_concat} "
f"(expected {base})",
)
check(addr_end == end,
f"Seg {seg} end address: {{{seg}[1:0], 10'h3FF}} = {addr_end} (expected {end})")
# Memory is declared as: reg [15:0] long_chirp_i [0:4095]
# $readmemh loads seg0 to [0:1023], seg1 to [1024:2047], etc.
# Addressing via {segment_select, sample_addr} maps correctly.
# ============================================================================
# TEST 8: Seg3 zero-padding analysis
# ============================================================================
def test_seg3_padding():
# The long chirp has 3000 samples (30 us at 100 MHz).
# With 4 segments of 1024 samples = 4096 total memory slots.
# Segments are loaded contiguously into memory:
# Seg0: chirp samples 0..1023
# Seg1: chirp samples 1024..2047
# Seg2: chirp samples 2048..3071
# Seg3: chirp samples 3072..4095
#
# But the chirp only has 3000 samples! So seg3 should have:
# Valid chirp data at indices 0..(3000-3072-1) = NEGATIVE
# Wait — 3072 > 3000, so seg3 has NO valid chirp samples if chirp is exactly 3000.
#
# However, the overlap-save algorithm in matched_filter_multi_segment.v
# collects data differently:
# Seg0: collect 896 DDC samples, buffer[0:895], zero-pad [896:1023]
# Seg1: overlap from seg0[768:895] → buffer[0:127], collect 896 → buffer[128:1023]
# ...
# The chirp reference is indexed by segment_select + sample_addr,
# so it reads ALL 1024 values for each segment regardless.
#
# If the chirp is 3000 samples but only 4*1024=4096 slots exist,
# the question is: do the .mem files contain 3000 samples of real chirp
# data spread across 4096 slots, or something else?
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
mags = [math.sqrt(i*i + q*q) for i, q in zip(seg3_i, seg3_q, strict=False)]
# Count trailing zeros (samples after chirp ends)
trailing_zeros = 0
for m in reversed(mags):
if m < 2:
trailing_zeros += 1
else:
break
nonzero = sum(1 for m in mags if m > 2)
if nonzero == 1024:
# This means the .mem files encode 4096 chirp samples, not 3000
# The chirp duration used for .mem generation was different from T_LONG_CHIRP
actual_chirp_samples = 4 * 1024 # = 4096
actual_duration = actual_chirp_samples / FS_SYS
warn(f"Chirp in .mem files appears to be {actual_chirp_samples} samples "
f"({actual_duration*1e6:.1f} us), not {LONG_CHIRP_SAMPLES} samples "
f"({T_LONG_CHIRP*1e6:.1f} us)")
elif trailing_zeros > 100:
# Some padding at end
3072 + (1024 - trailing_zeros)
# ============================================================================
# MAIN
# ============================================================================
def main():
test_structural()
test_twiddle_1024()
test_twiddle_16()
test_long_chirp()
test_short_chirp()
test_chirp_vs_model()
test_latency_buffer()
test_memory_addressing()
test_seg3_padding()
if fail_count == 0:
pass
else:
pass
return 0 if fail_count == 0 else 1
if __name__ == '__main__':
sys.exit(main())
+21 -4
View File
@@ -147,6 +147,7 @@ def main():
# ========================================================================= # =========================================================================
# Case 2: Tone autocorrelation at bin 5 # Case 2: Tone autocorrelation at bin 5
# Signal and reference: complex tone at bin 5, amplitude 8000 (Q15) # Signal and reference: complex tone at bin 5, amplitude 8000 (Q15)
# sig[n] = 8000 * exp(j * 2*pi*5*n/N)
# Autocorrelation of a tone => peak at bin 0 (lag 0) # Autocorrelation of a tone => peak at bin 0 (lag 0)
# ========================================================================= # =========================================================================
amp = 8000.0 amp = 8000.0
@@ -240,12 +241,28 @@ def main():
# ========================================================================= # =========================================================================
# Print summary to stdout # Print summary to stdout
# ========================================================================= # =========================================================================
print("=" * 72)
print("Matched Filter Golden Reference Generator")
print(f"Output directory: {outdir}")
print(f"FFT length: {N}")
print("=" * 72)
for _ in summaries: for s in summaries:
pass print()
print(f"Case {s['case']}: {s['description']}")
print(f" Peak bin: {s['peak_bin']}")
print(f" Peak magnitude (float):{s['peak_mag_float']:.6f}")
print(f" Peak I (float): {s['peak_i_float']:.6f}")
print(f" Peak Q (float): {s['peak_q_float']:.6f}")
print(f" Peak I (quantized): {s['peak_i_quant']}")
print(f" Peak Q (quantized): {s['peak_q_quant']}")
for _ in all_files: print()
pass print(f"Generated {len(all_files)} files:")
for fname in all_files:
print(f" {fname}")
print()
print("Done.")
if __name__ == "__main__": if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
+4 -515
View File
@@ -38,20 +38,10 @@ reg signed [15:0] data_q_in;
reg valid_in; reg valid_in;
reg [3:0] gain_shift; reg [3:0] gain_shift;
// AGC configuration (default: AGC disabled — manual mode)
reg agc_enable;
reg [7:0] agc_target;
reg [3:0] agc_attack;
reg [3:0] agc_decay;
reg [3:0] agc_holdoff;
reg frame_boundary;
wire signed [15:0] data_i_out; wire signed [15:0] data_i_out;
wire signed [15:0] data_q_out; wire signed [15:0] data_q_out;
wire valid_out; wire valid_out;
wire [7:0] saturation_count; wire [7:0] saturation_count;
wire [7:0] peak_magnitude;
wire [3:0] current_gain;
rx_gain_control dut ( rx_gain_control dut (
.clk(clk), .clk(clk),
@@ -60,18 +50,10 @@ rx_gain_control dut (
.data_q_in(data_q_in), .data_q_in(data_q_in),
.valid_in(valid_in), .valid_in(valid_in),
.gain_shift(gain_shift), .gain_shift(gain_shift),
.agc_enable(agc_enable),
.agc_target(agc_target),
.agc_attack(agc_attack),
.agc_decay(agc_decay),
.agc_holdoff(agc_holdoff),
.frame_boundary(frame_boundary),
.data_i_out(data_i_out), .data_i_out(data_i_out),
.data_q_out(data_q_out), .data_q_out(data_q_out),
.valid_out(valid_out), .valid_out(valid_out),
.saturation_count(saturation_count), .saturation_count(saturation_count)
.peak_magnitude(peak_magnitude),
.current_gain(current_gain)
); );
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -123,13 +105,6 @@ initial begin
data_q_in = 0; data_q_in = 0;
valid_in = 0; valid_in = 0;
gain_shift = 4'd0; gain_shift = 4'd0;
// AGC disabled for backward-compatible tests (Tests 1-12)
agc_enable = 0;
agc_target = 8'd200;
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd4;
frame_boundary = 0;
repeat (4) @(posedge clk); repeat (4) @(posedge clk);
reset_n = 1; reset_n = 1;
@@ -177,9 +152,6 @@ initial begin
"T3.1: I saturated to +32767"); "T3.1: I saturated to +32767");
check(data_q_out == -16'sd32768, check(data_q_out == -16'sd32768,
"T3.2: Q saturated to -32768"); "T3.2: Q saturated to -32768");
// Pulse frame_boundary to snapshot the per-frame saturation count
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd1, check(saturation_count == 8'd1,
"T3.3: Saturation counter = 1 (both channels clipped counts as 1)"); "T3.3: Saturation counter = 1 (both channels clipped counts as 1)");
@@ -201,9 +173,6 @@ initial begin
"T4.1: I attenuated 4000>>2 = 1000"); "T4.1: I attenuated 4000>>2 = 1000");
check(data_q_out == -16'sd500, check(data_q_out == -16'sd500,
"T4.2: Q attenuated -2000>>2 = -500"); "T4.2: Q attenuated -2000>>2 = -500");
// Pulse frame_boundary to snapshot (should be 0 — no clipping)
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd0, check(saturation_count == 8'd0,
"T4.3: No saturation on right shift"); "T4.3: No saturation on right shift");
@@ -346,18 +315,13 @@ initial begin
valid_in = 1'b0; valid_in = 1'b0;
@(posedge clk); #1; @(posedge clk); #1;
// Pulse frame_boundary to snapshot per-frame saturation count
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd255, check(saturation_count == 8'd255,
"T11.1: Counter capped at 255 after 256 saturating samples"); "T11.1: Counter capped at 255 after 256 saturating samples");
// One more sample + frame boundary — should still be capped at 1 (new frame) // One more sample — should stay at 255
send_sample(16'sd20000, 16'sd20000); send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1; check(saturation_count == 8'd255,
@(negedge clk); frame_boundary = 0; @(posedge clk); #1; "T11.2: Counter stays at 255 (no wrap)");
check(saturation_count == 8'd1,
"T11.2: New frame counter = 1 (single sample)");
// --------------------------------------------------------------- // ---------------------------------------------------------------
// TEST 12: Reset clears everything // TEST 12: Reset clears everything
@@ -365,8 +329,6 @@ initial begin
$display(""); $display("");
$display("--- Test 12: Reset clears all ---"); $display("--- Test 12: Reset clears all ---");
gain_shift = 4'd0; // Reset gain_shift to 0 so current_gain reads 0
agc_enable = 0;
reset_n = 0; reset_n = 0;
repeat (2) @(posedge clk); repeat (2) @(posedge clk);
reset_n = 1; reset_n = 1;
@@ -380,479 +342,6 @@ initial begin
"T12.3: valid_out cleared on reset"); "T12.3: valid_out cleared on reset");
check(saturation_count == 8'd0, check(saturation_count == 8'd0,
"T12.4: Saturation counter cleared on reset"); "T12.4: Saturation counter cleared on reset");
check(current_gain == 4'd0,
"T12.5: current_gain cleared on reset");
// ---------------------------------------------------------------
// TEST 13: current_gain reflects gain_shift in manual mode
// ---------------------------------------------------------------
$display("");
$display("--- Test 13: current_gain tracks gain_shift (manual) ---");
gain_shift = 4'b0_011; // amplify x8
@(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0011,
"T13.1: current_gain = 0x3 (amplify x8)");
gain_shift = 4'b1_010; // attenuate /4
@(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b1010,
"T13.2: current_gain = 0xA (attenuate /4)");
// ---------------------------------------------------------------
// TEST 14: Peak magnitude tracking
// ---------------------------------------------------------------
$display("");
$display("--- Test 14: Peak magnitude tracking ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_000; // pass-through
// Send samples with increasing magnitude
send_sample(16'sd100, 16'sd50);
send_sample(16'sd1000, 16'sd500);
send_sample(16'sd8000, 16'sd2000); // peak = 8000
send_sample(16'sd200, 16'sd100);
// Pulse frame_boundary to snapshot
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
// peak_magnitude = upper 8 bits of 15-bit peak (8000)
// 8000 = 0x1F40, 15-bit = 0x1F40, [14:7] = 0x3E = 62
check(peak_magnitude == 8'd62,
"T14.1: Peak magnitude = 62 (8000 >> 7)");
// ---------------------------------------------------------------
// TEST 15: AGC auto gain-down on saturation
// ---------------------------------------------------------------
$display("");
$display("--- Test 15: AGC gain-down on saturation ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
// Start with amplify x4 (gain_shift = 0x02), then enable AGC
gain_shift = 4'b0_010; // amplify x4, internal gain = +2
agc_enable = 0;
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100;
@(posedge clk); @(posedge clk);
// Enable AGC — should initialize from gain_shift
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0010,
"T15.1: AGC initialized from gain_shift (amplify x4)");
// Send saturating samples (will clip at x4 gain)
send_sample(16'sd20000, 16'sd20000);
send_sample(16'sd20000, 16'sd20000);
// Pulse frame_boundary — AGC should reduce gain by attack=1
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
// current_gain lags agc_gain by 1 cycle (NBA), wait one extra cycle
@(posedge clk); #1;
// Internal gain was +2, attack=1 → new gain = +1 (0x01)
check(current_gain == 4'b0001,
"T15.2: AGC reduced gain to x2 after saturation");
// Another frame with saturation (20000*2 = 40000 > 32767)
send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
// gain was +1, attack=1 → new gain = 0 (0x00)
check(current_gain == 4'b0000,
"T15.3: AGC reduced gain to x1 (pass-through)");
// At gain 0 (pass-through), 20000 does NOT overflow 16-bit range,
// so no saturation occurs. Signal peak = 20000 >> 7 = 156 > target(100),
// so AGC correctly holds gain at 0. This is expected behavior.
// To test crossing into attenuation: increase attack to 3.
agc_attack = 4'd3;
// Reset and start fresh with gain +2, attack=3
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_010; // amplify x4, internal gain = +2
agc_enable = 0;
@(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
// Send saturating samples
send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
// gain was +2, attack=3 → new gain = -1 → encoding 0x09
check(current_gain == 4'b1001,
"T15.4: Large attack step crosses to attenuation (gain +2 - 3 = -1 0x9)");
// ---------------------------------------------------------------
// TEST 16: AGC auto gain-up after holdoff
// ---------------------------------------------------------------
$display("");
$display("--- Test 16: AGC gain-up after holdoff ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
// Start with low gain, weak signal, holdoff=2
gain_shift = 4'b0_000; // pass-through (internal gain = 0)
agc_enable = 0;
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100; // target peak = 100 (in upper 8 bits = 12800 raw)
@(posedge clk); @(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); #1;
// Frame 1: send weak signal (peak < target), holdoff counter = 2
send_sample(16'sd100, 16'sd50); // peak=100, [14:7]=0 (very weak)
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0000,
"T16.1: Gain held during holdoff (frame 1, holdoff=2)");
// Frame 2: still weak, holdoff counter decrements to 1
send_sample(16'sd100, 16'sd50);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0000,
"T16.2: Gain held during holdoff (frame 2, holdoff=1)");
// Frame 3: holdoff expired (was 0 at start of frame) → gain up
send_sample(16'sd100, 16'sd50);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0001,
"T16.3: Gain increased after holdoff expired (gain 0->1)");
// ---------------------------------------------------------------
// TEST 17: Repeated attacks drive gain negative, clamp at -7,
// then decay recovers
// ---------------------------------------------------------------
$display("");
$display("--- Test 17: Repeated attack negative clamp decay recovery ---");
// ----- 17a: Walk gain from +7 down through zero via repeated attack -----
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_111; // amplify x128, internal gain = +7
agc_enable = 0;
agc_attack = 4'd2;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100;
@(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0_111,
"T17a.1: AGC initialized at gain +7 (0x7)");
// Frame 1: saturating at gain +7 → gain 7-2=5
send_sample(16'sd1000, 16'sd1000); // 1000<<7 = 128000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_101,
"T17a.2: After attack: gain +5 (0x5)");
// Frame 2: still saturating at gain +5 → gain 5-2=3
send_sample(16'sd1000, 16'sd1000); // 1000<<5 = 32000 → no overflow
send_sample(16'sd2000, 16'sd2000); // 2000<<5 = 64000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_011,
"T17a.3: After attack: gain +3 (0x3)");
// Frame 3: saturating at gain +3 → gain 3-2=1
send_sample(16'sd5000, 16'sd5000); // 5000<<3 = 40000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_001,
"T17a.4: After attack: gain +1 (0x1)");
// Frame 4: saturating at gain +1 → gain 1-2=-1 → encoding 0x9
send_sample(16'sd20000, 16'sd20000); // 20000<<1 = 40000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_001,
"T17a.5: Attack crossed zero: gain -1 (0x9)");
// Frame 5: at gain -1 (right shift 1), 20000>>>1=10000, NO overflow.
// peak = 20000 → [14:7]=156 > target(100) → HOLD, gain stays -1
send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_001,
"T17a.6: No overflow at -1, peak>target HOLD, gain stays -1");
// ----- 17b: Max attack step clamps at -7 -----
$display("");
$display("--- Test 17b: Max attack clamps at -7 ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_011; // amplify x8, internal gain = +3
agc_attack = 4'd15; // max attack step
agc_enable = 0;
@(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0_011,
"T17b.1: Initialized at gain +3");
// One saturating frame: gain = clamp(3 - 15) = clamp(-12) = -7 → 0xF
send_sample(16'sd5000, 16'sd5000); // 5000<<3 = 40000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_111,
"T17b.2: Gain clamped at -7 (0xF) after max attack");
// Another frame at gain -7: 5000>>>7 = 39, peak = 5000→[14:7]=39 < target(100)
// → decay path, but holdoff counter was reset to 2 by the attack above
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_111,
"T17b.3: Gain still -7 (holdoff active, 21)");
// ----- 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 10)");
// Now holdoff=0, next weak frame should trigger decay: -7 + 1 = -6 → 0xE
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_110,
"T17c.2: Decay from -7 to -6 (0xE) after holdoff expired");
// One more decay: -6 + 1 = -5 → 0xD
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_101,
"T17c.3: Decay from -6 to -5 (0xD)");
// Verify output is actually attenuated: at gain -5 (right shift 5),
// 5000 >>> 5 = 156
send_sample(16'sd5000, 16'sd0);
check(data_i_out == 16'sd156,
"T17c.4: Output correctly attenuated: 5000>>>5 = 156");
// =================================================================
// Test 18: valid_in + frame_boundary on the SAME cycle
// Verify the coincident sample is included in the frame snapshot
// (Bug #7 fix — previously lost due to NBA last-write-wins)
// =================================================================
$display("");
$display("--- Test 18: valid_in + frame_boundary simultaneous ---");
// ----- 18a: Coincident saturating sample included in sat count -----
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_011; // amplify x8 (shift left 3)
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100;
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
// Send one normal sample first (establishes a non-zero frame)
send_sample(16'sd100, 16'sd100); // small, no overflow at gain +3
// Now: assert valid_in AND frame_boundary on the SAME posedge.
// The sample is large enough to overflow at gain +3: 5000<<3 = 40000 > 32767
@(negedge clk);
data_i_in = 16'sd5000;
data_q_in = 16'sd5000;
valid_in = 1'b1;
frame_boundary = 1'b1;
@(posedge clk); #1; // DUT samples both signals
@(negedge clk);
valid_in = 1'b0;
frame_boundary = 1'b0;
@(posedge clk); #1; // let NBA settle
@(posedge clk); #1;
// Saturation count should be 1 (the coincident sample overflowed)
check(saturation_count == 8'd1,
"T18a.1: Coincident saturating sample counted in snapshot (sat_count=1)");
// Peak should reflect pre-gain max(|5000|,|5000|) = 5000 → [14:7] = 39
// (or at least >= the first sample's peak of 100→[14:7]=0)
check(peak_magnitude == 8'd39,
"T18a.2: Coincident sample peak included in snapshot (peak=39)");
// AGC should have attacked (sat > 0): gain +3 → +3-1 = +2
check(current_gain == 4'b0_010,
"T18a.3: AGC attacked on coincident saturation (gain +3 +2)");
// ----- 18b: Coincident non-saturating peak updates snapshot -----
$display("");
$display("--- Test 18b: Coincident peak-only sample ---");
reset_n = 0;
agc_enable = 0; // deassert so transition fires with NEW gain_shift
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_000; // no amplification (shift 0)
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd0;
agc_target = 8'd200; // high target so signal is "weak"
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
// Send a small sample
send_sample(16'sd50, 16'sd50);
// Coincident frame_boundary + valid_in with a LARGER sample (not saturating)
@(negedge clk);
data_i_in = 16'sd10000;
data_q_in = 16'sd10000;
valid_in = 1'b1;
frame_boundary = 1'b1;
@(posedge clk); #1;
@(negedge clk);
valid_in = 1'b0;
frame_boundary = 1'b0;
@(posedge clk); #1;
@(posedge clk); #1;
// Peak should be max(|10000|,|10000|) = 10000 → [14:7] = 78
check(peak_magnitude == 8'd78,
"T18b.1: Coincident larger peak included (peak=78)");
// No saturation at gain 0
check(saturation_count == 8'd0,
"T18b.2: No saturation (gain=0, no overflow)");
// =================================================================
// Test 19: AGC enable toggle mid-frame
// Verify gain initializes from gain_shift and holdoff resets
// =================================================================
$display("");
$display("--- Test 19: AGC enable toggle mid-frame ---");
// ----- 19a: Enable AGC mid-frame, verify gain init -----
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_101; // amplify x32 (shift left 5), internal = +5
agc_attack = 4'd2;
agc_decay = 4'd1;
agc_holdoff = 4'd3;
agc_target = 8'd100;
agc_enable = 0; // start disabled
@(posedge clk); #1;
// With AGC off, current_gain should follow gain_shift directly
check(current_gain == 4'b0_101,
"T19a.1: AGC disabled current_gain = gain_shift (0x5)");
// Send a few samples (building up frame metrics)
send_sample(16'sd1000, 16'sd1000);
send_sample(16'sd2000, 16'sd2000);
// Toggle AGC enable ON mid-frame
@(negedge clk);
agc_enable = 1;
@(posedge clk); #1;
@(posedge clk); #1; // let enable transition register
// Gain should initialize from gain_shift encoding (0b0_101 → +5)
check(current_gain == 4'b0_101,
"T19a.2: AGC enabled mid-frame gain initialized from gain_shift (+5)");
// Send a saturating sample, then boundary
send_sample(16'sd5000, 16'sd5000); // 5000<<5 overflows
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
// AGC should attack: gain +5 → +5-2 = +3
check(current_gain == 4'b0_011,
"T19a.3: After boundary, AGC attacked (gain +5 +3)");
// ----- 19b: Disable AGC mid-frame, verify passthrough -----
$display("");
$display("--- Test 19b: Disable AGC mid-frame ---");
// Change gain_shift to a new value
@(negedge clk);
gain_shift = 4'b1_010; // attenuate by 2 (right shift 2)
agc_enable = 0;
@(posedge clk); #1;
@(posedge clk); #1;
// With AGC off, current_gain should follow gain_shift
check(current_gain == 4'b1_010,
"T19b.1: AGC disabled current_gain = gain_shift (0xA, atten 2)");
// Send sample: 1000 >> 2 = 250
send_sample(16'sd1000, 16'sd0);
check(data_i_out == 16'sd250,
"T19b.2: Output uses host gain_shift when AGC off: 1000>>2=250");
// ----- 19c: Re-enable, verify gain re-initializes -----
@(negedge clk);
gain_shift = 4'b0_010; // amplify by 4 (shift left 2), internal = +2
agc_enable = 1;
@(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_010,
"T19c.1: AGC re-enabled gain re-initialized from gain_shift (+2)");
// --------------------------------------------------------------- // ---------------------------------------------------------------
// SUMMARY // SUMMARY
+3 -24
View File
@@ -79,12 +79,6 @@ module tb_usb_data_interface;
reg [7:0] status_self_test_detail; reg [7:0] status_self_test_detail;
reg status_self_test_busy; reg status_self_test_busy;
// AGC status readback inputs
reg [3:0] status_agc_current_gain;
reg [7:0] status_agc_peak_magnitude;
reg [7:0] status_agc_saturation_count;
reg status_agc_enable;
// ── Clock generators (asynchronous) ──────────────────────── // ── Clock generators (asynchronous) ────────────────────────
always #(CLK_PERIOD / 2) clk = ~clk; always #(CLK_PERIOD / 2) clk = ~clk;
always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in; always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in;
@@ -140,13 +134,7 @@ module tb_usb_data_interface;
// Self-test status readback // Self-test status readback
.status_self_test_flags (status_self_test_flags), .status_self_test_flags (status_self_test_flags),
.status_self_test_detail(status_self_test_detail), .status_self_test_detail(status_self_test_detail),
.status_self_test_busy (status_self_test_busy), .status_self_test_busy (status_self_test_busy)
// AGC status readback
.status_agc_current_gain (status_agc_current_gain),
.status_agc_peak_magnitude (status_agc_peak_magnitude),
.status_agc_saturation_count(status_agc_saturation_count),
.status_agc_enable (status_agc_enable)
); );
// ── Test bookkeeping ─────────────────────────────────────── // ── Test bookkeeping ───────────────────────────────────────
@@ -206,10 +194,6 @@ module tb_usb_data_interface;
status_self_test_flags = 5'b00000; status_self_test_flags = 5'b00000;
status_self_test_detail = 8'd0; status_self_test_detail = 8'd0;
status_self_test_busy = 1'b0; status_self_test_busy = 1'b0;
status_agc_current_gain = 4'd0;
status_agc_peak_magnitude = 8'd0;
status_agc_saturation_count = 8'd0;
status_agc_enable = 1'b0;
repeat (6) @(posedge ft601_clk_in); repeat (6) @(posedge ft601_clk_in);
reset_n = 1; reset_n = 1;
// Wait enough cycles for stream_control CDC to propagate // Wait enough cycles for stream_control CDC to propagate
@@ -918,11 +902,6 @@ module tb_usb_data_interface;
status_self_test_flags = 5'b11111; status_self_test_flags = 5'b11111;
status_self_test_detail = 8'hA5; status_self_test_detail = 8'hA5;
status_self_test_busy = 1'b0; status_self_test_busy = 1'b0;
// AGC status: gain=5, peak=180, sat_count=12, enabled
status_agc_current_gain = 4'd5;
status_agc_peak_magnitude = 8'd180;
status_agc_saturation_count = 8'd12;
status_agc_enable = 1'b1;
// Pulse status_request (1 cycle in clk domain — toggles status_req_toggle_100m) // Pulse status_request (1 cycle in clk domain — toggles status_req_toggle_100m)
@(posedge clk); @(posedge clk);
@@ -979,8 +958,8 @@ module tb_usb_data_interface;
"Status readback: word 2 = {guard, short_chirp}"); "Status readback: word 2 = {guard, short_chirp}");
check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32}, check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32},
"Status readback: word 3 = {short_listen, 0, chirps_per_elev}"); "Status readback: word 3 = {short_listen, 0, chirps_per_elev}");
check(uut.status_words[4] === {4'd5, 8'd180, 8'd12, 1'b1, 9'd0, 2'b10}, check(uut.status_words[4] === {30'd0, 2'b10},
"Status readback: word 4 = {agc_gain=5, peak=180, sat=12, en=1, range_mode=2}"); "Status readback: word 4 = range_mode=2'b10");
// status_words[5] = {7'd0, busy, 8'd0, detail[7:0], 3'd0, flags[4:0]} // status_words[5] = {7'd0, busy, 8'd0, detail[7:0], 3'd0, flags[4:0]}
// = {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111} // = {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111}
check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111}, check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111},
+5 -16
View File
@@ -20,8 +20,8 @@ module usb_data_interface (
// Control signals // Control signals
output reg ft601_txe_n, // Transmit enable (active low) output reg ft601_txe_n, // Transmit enable (active low)
output reg ft601_rxf_n, // Receive enable (active low) output reg ft601_rxf_n, // Receive enable (active low)
input wire ft601_txe, // TXE: Transmit FIFO Not Full (high = space available to write) input wire ft601_txe, // Transmit FIFO empty
input wire ft601_rxf, // RXF: Receive FIFO Not Empty (high = data available to read) input wire ft601_rxf, // Receive FIFO full
output reg ft601_wr_n, // Write strobe (active low) output reg ft601_wr_n, // Write strobe (active low)
output reg ft601_rd_n, // Read strobe (active low) output reg ft601_rd_n, // Read strobe (active low)
output reg ft601_oe_n, // Output enable (active low) output reg ft601_oe_n, // Output enable (active low)
@@ -77,13 +77,7 @@ module usb_data_interface (
// Self-test status readback (opcode 0x31 / included in 0xFF status packet) // Self-test status readback (opcode 0x31 / included in 0xFF status packet)
input wire [4:0] status_self_test_flags, // Per-test PASS(1)/FAIL(0) latched input wire [4:0] status_self_test_flags, // Per-test PASS(1)/FAIL(0) latched
input wire [7:0] status_self_test_detail, // Diagnostic detail byte latched input wire [7:0] status_self_test_detail, // Diagnostic detail byte latched
input wire status_self_test_busy, // Self-test FSM still running input wire status_self_test_busy // Self-test FSM still running
// AGC status readback
input wire [3:0] status_agc_current_gain,
input wire [7:0] status_agc_peak_magnitude,
input wire [7:0] status_agc_saturation_count,
input wire status_agc_enable
); );
// USB packet structure (same as before) // USB packet structure (same as before)
@@ -273,13 +267,8 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
status_words[2] <= {status_guard, status_short_chirp}; status_words[2] <= {status_guard, status_short_chirp};
// Word 3: {short_listen_cycles[15:0], chirps_per_elev[5:0], 10'b0} // Word 3: {short_listen_cycles[15:0], chirps_per_elev[5:0], 10'b0}
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev}; status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
// Word 4: AGC metrics + range_mode // Word 4: Fix 7 — range_mode in bits [1:0], rest reserved
status_words[4] <= {status_agc_current_gain, // [31:28] status_words[4] <= {30'd0, status_range_mode};
status_agc_peak_magnitude, // [27:20]
status_agc_saturation_count, // [19:12]
status_agc_enable, // [11]
9'd0, // [10:2] reserved
status_range_mode}; // [1:0]
// Word 5: Self-test results {reserved[6:0], busy, reserved[7:0], detail[7:0], reserved[2:0], flags[4:0]} // Word 5: Self-test results {reserved[6:0], busy, reserved[7:0], detail[7:0], reserved[2:0], flags[4:0]}
status_words[5] <= {7'd0, status_self_test_busy, status_words[5] <= {7'd0, status_self_test_busy,
8'd0, status_self_test_detail, 8'd0, status_self_test_detail,
@@ -90,13 +90,7 @@ module usb_data_interface_ft2232h (
// Self-test status readback // Self-test status readback
input wire [4:0] status_self_test_flags, input wire [4:0] status_self_test_flags,
input wire [7:0] status_self_test_detail, input wire [7:0] status_self_test_detail,
input wire status_self_test_busy, input wire status_self_test_busy
// AGC status readback
input wire [3:0] status_agc_current_gain,
input wire [7:0] status_agc_peak_magnitude,
input wire [7:0] status_agc_saturation_count,
input wire status_agc_enable
); );
// ============================================================================ // ============================================================================
@@ -287,12 +281,7 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
status_words[1] <= {status_long_chirp, status_long_listen}; status_words[1] <= {status_long_chirp, status_long_listen};
status_words[2] <= {status_guard, status_short_chirp}; status_words[2] <= {status_guard, status_short_chirp};
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev}; status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
status_words[4] <= {status_agc_current_gain, // [31:28] status_words[4] <= {30'd0, status_range_mode};
status_agc_peak_magnitude, // [27:20]
status_agc_saturation_count, // [19:12]
status_agc_enable, // [11]
9'd0, // [10:2] reserved
status_range_mode}; // [1:0]
status_words[5] <= {7'd0, status_self_test_busy, status_words[5] <= {7'd0, status_self_test_busy,
8'd0, status_self_test_detail, 8'd0, status_self_test_detail,
3'd0, status_self_test_flags}; 3'd0, status_self_test_flags};
-431
View File
@@ -1,431 +0,0 @@
# ruff: noqa: T201
#!/usr/bin/env python3
"""
One-off AGC saturation analysis for ADI CN0566 raw IQ captures.
Bit-accurate simulation of rx_gain_control.v AGC inner loop applied
to real captured IQ data. Three scenarios per dataset:
Row 1 AGC OFF: Fixed gain_shift=0 (pass-through). Shows raw clipping.
Row 2 AGC ON: Auto-adjusts from gain_shift=0. Clipping clears.
Row 3 AGC delayed: OFF for first half, ON at midpoint.
Shows the transition: clipping AGC activates clears.
Key RTL details modelled exactly:
- gain_shift[3]=direction (0=amplify/left, 1=attenuate/right), [2:0]=amount
- Internal agc_gain is signed -7..+7
- Peak is measured PRE-gain (raw input |sample|, upper 8 of 15 bits)
- Saturation is measured POST-gain (overflow from shift)
- Attack: gain -= agc_attack when any sample clips (immediate)
- Decay: gain += agc_decay when peak < target AND holdoff expired
- Hold: when peak >= target AND no saturation, hold gain, reset holdoff
Usage:
python adi_agc_analysis.py
python adi_agc_analysis.py --data /path/to/file.npy --label "my capture"
"""
import argparse
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
# ---------------------------------------------------------------------------
# FPGA AGC parameters (rx_gain_control.v reset defaults)
# ---------------------------------------------------------------------------
AGC_TARGET = 200 # host_agc_target (8-bit, default 200)
AGC_ATTACK = 1 # host_agc_attack (4-bit, default 1)
AGC_DECAY = 1 # host_agc_decay (4-bit, default 1)
AGC_HOLDOFF = 4 # host_agc_holdoff (4-bit, default 4)
ADC_RAIL = 4095 # 12-bit ADC max absolute value
# ---------------------------------------------------------------------------
# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed)
# ---------------------------------------------------------------------------
def signed_to_encoding(g: int) -> int:
"""Convert signed gain (-7..+7) to gain_shift[3:0] encoding.
[3]=0, [2:0]=N amplify (left shift) by N
[3]=1, [2:0]=N attenuate (right shift) by N
"""
if g >= 0:
return g & 0x07
return 0x08 | ((-g) & 0x07)
def encoding_to_signed(enc: int) -> int:
"""Convert gain_shift[3:0] encoding to signed gain."""
if (enc & 0x08) == 0:
return enc & 0x07
return -(enc & 0x07)
def clamp_gain(val: int) -> int:
"""Clamp to [-7, +7] (matches RTL clamp_gain function)."""
return max(-7, min(7, val))
# ---------------------------------------------------------------------------
# Apply gain shift to IQ data (matches RTL combinational logic)
# ---------------------------------------------------------------------------
def apply_gain_shift(frame_i: np.ndarray, frame_q: np.ndarray,
gain_enc: int) -> tuple[np.ndarray, np.ndarray, int]:
"""Apply gain_shift encoding to 16-bit signed IQ arrays.
Returns (shifted_i, shifted_q, overflow_count).
Matches the RTL: left shift = amplify, right shift = attenuate,
saturate to ±32767 on overflow.
"""
direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate
amount = gain_enc & 0x07
if amount == 0:
return frame_i.copy(), frame_q.copy(), 0
if direction == 0:
# Left shift (amplify)
si = frame_i.astype(np.int64) * (1 << amount)
sq = frame_q.astype(np.int64) * (1 << amount)
else:
# Arithmetic right shift (attenuate)
si = frame_i.astype(np.int64) >> amount
sq = frame_q.astype(np.int64) >> amount
# Count overflows (post-shift values outside 16-bit signed range)
overflow_i = (si > 32767) | (si < -32768)
overflow_q = (sq > 32767) | (sq < -32768)
overflow_count = int((overflow_i | overflow_q).sum())
# Saturate to ±32767
si = np.clip(si, -32768, 32767).astype(np.int16)
sq = np.clip(sq, -32768, 32767).astype(np.int16)
return si, sq, overflow_count
# ---------------------------------------------------------------------------
# Per-frame AGC simulation (bit-accurate to rx_gain_control.v)
# ---------------------------------------------------------------------------
def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
enable_at_frame: int = 0,
initial_gain_enc: int = 0x00) -> dict:
"""Simulate FPGA inner-loop AGC across all frames.
Parameters
----------
frames : (N, chirps, samples) complex raw ADC captures (12-bit range)
agc_enabled : if False, gain stays fixed
enable_at_frame : frame index where AGC activates
initial_gain_enc : gain_shift[3:0] encoding when AGC enables (default 0x00 = pass-through)
"""
n_frames = frames.shape[0]
# Output arrays
out_gain_enc = np.zeros(n_frames, dtype=int) # gain_shift encoding [3:0]
out_gain_signed = np.zeros(n_frames, dtype=int) # signed gain for plotting
out_peak_mag = np.zeros(n_frames, dtype=int) # peak_magnitude[7:0]
out_sat_count = np.zeros(n_frames, dtype=int) # saturation_count[7:0]
out_sat_rate = np.zeros(n_frames, dtype=float)
out_rms_post = np.zeros(n_frames, dtype=float) # RMS after gain shift
# AGC internal state
agc_gain = 0 # signed, -7..+7
holdoff_counter = 0
agc_was_enabled = False
for i in range(n_frames):
frame = frames[i]
# Quantize to 16-bit signed (ADC is 12-bit, sign-extended to 16)
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
# --- PRE-gain peak measurement (RTL lines 133-135, 211-213) ---
abs_i = np.abs(frame_i.astype(np.int32))
abs_q = np.abs(frame_q.astype(np.int32))
max_iq = np.maximum(abs_i, abs_q)
frame_peak_15bit = int(max_iq.max()) # 15-bit unsigned
peak_8bit = (frame_peak_15bit >> 7) & 0xFF # Upper 8 bits
# --- Determine effective gain ---
agc_active = agc_enabled and (i >= enable_at_frame)
# AGC enable transition (RTL lines 250-253)
if agc_active and not agc_was_enabled:
agc_gain = encoding_to_signed(initial_gain_enc)
holdoff_counter = AGC_HOLDOFF
effective_enc = signed_to_encoding(agc_gain) if agc_active else initial_gain_enc
agc_was_enabled = agc_active
# --- Apply gain shift + count POST-gain overflow (RTL lines 114-126, 207-209) ---
shifted_i, shifted_q, frame_overflow = apply_gain_shift(
frame_i, frame_q, effective_enc)
frame_sat = min(255, frame_overflow)
# RMS of shifted signal
rms = float(np.sqrt(np.mean(
shifted_i.astype(np.float64)**2 + shifted_q.astype(np.float64)**2)))
total_samples = frame_i.size + frame_q.size
sat_rate = frame_overflow / total_samples if total_samples > 0 else 0.0
# --- Record outputs ---
out_gain_enc[i] = effective_enc
out_gain_signed[i] = agc_gain if agc_active else encoding_to_signed(initial_gain_enc)
out_peak_mag[i] = peak_8bit
out_sat_count[i] = frame_sat
out_sat_rate[i] = sat_rate
out_rms_post[i] = rms
# --- AGC update at frame boundary (RTL lines 226-246) ---
if agc_active:
if frame_sat > 0:
# Clipping: reduce gain immediately (attack)
agc_gain = clamp_gain(agc_gain - AGC_ATTACK)
holdoff_counter = AGC_HOLDOFF
elif peak_8bit < AGC_TARGET:
# Signal too weak: increase gain after holdoff
if holdoff_counter == 0:
agc_gain = clamp_gain(agc_gain + AGC_DECAY)
else:
holdoff_counter -= 1
else:
# Good range (peak >= target, no sat): hold, reset holdoff
holdoff_counter = AGC_HOLDOFF
return {
"gain_enc": out_gain_enc,
"gain_signed": out_gain_signed,
"peak_mag": out_peak_mag,
"sat_count": out_sat_count,
"sat_rate": out_sat_rate,
"rms_post": out_rms_post,
}
# ---------------------------------------------------------------------------
# Range-Doppler processing for heatmap display
# ---------------------------------------------------------------------------
def process_frame_rd(frame: np.ndarray, gain_enc: int,
n_range: int = 64,
n_doppler: int = 32) -> np.ndarray:
"""Range-Doppler magnitude for one frame with gain applied."""
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
n_chirps, _ = iq.shape
range_fft = np.fft.fft(iq, axis=1)[:, :n_range]
doppler_fft = np.fft.fftshift(np.fft.fft(range_fft, axis=0), axes=0)
center = n_chirps // 2
half_d = n_doppler // 2
doppler_fft = doppler_fft[center - half_d:center + half_d, :]
rd_mag = np.abs(doppler_fft.real) + np.abs(doppler_fft.imag)
return rd_mag.T # (n_range, n_doppler)
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
def plot_scenario(axes, data: np.ndarray, agc: dict, title: str,
enable_frame: int = 0):
"""Plot one AGC scenario across 5 axes."""
n = data.shape[0]
xs = np.arange(n)
# Range-Doppler heatmap
if enable_frame > 0 and enable_frame < n:
f_before = max(0, enable_frame - 1)
f_after = min(n - 1, n - 2)
rd_before = process_frame_rd(data[f_before], int(agc["gain_enc"][f_before]))
rd_after = process_frame_rd(data[f_after], int(agc["gain_enc"][f_after]))
combined = np.hstack([rd_before, rd_after])
im = axes[0].imshow(
20 * np.log10(combined + 1), aspect="auto", origin="lower",
cmap="inferno", interpolation="nearest")
axes[0].axvline(x=rd_before.shape[1] - 0.5, color="cyan",
linewidth=2, linestyle="--")
axes[0].set_title(f"{title}\nL: f{f_before} (pre) | R: f{f_after} (post)")
else:
worst = int(np.argmax(agc["sat_count"]))
best = int(np.argmin(agc["sat_count"]))
f_show = worst if agc["sat_count"][worst] > 0 else best
rd = process_frame_rd(data[f_show], int(agc["gain_enc"][f_show]))
im = axes[0].imshow(
20 * np.log10(rd + 1), aspect="auto", origin="lower",
cmap="inferno", interpolation="nearest")
axes[0].set_title(f"{title}\nFrame {f_show}")
axes[0].set_xlabel("Doppler bin")
axes[0].set_ylabel("Range bin")
plt.colorbar(im, ax=axes[0], label="dB", shrink=0.8)
# Signed gain history (the real AGC state)
axes[1].plot(xs, agc["gain_signed"], color="#00ff88", linewidth=1.5)
axes[1].axhline(y=0, color="gray", linestyle=":", alpha=0.5,
label="Pass-through")
if enable_frame > 0:
axes[1].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", label="AGC ON")
axes[1].set_ylim(-8, 8)
axes[1].set_ylabel("Gain (signed)")
axes[1].set_title("AGC Internal Gain (-7=max atten, +7=max amp)")
axes[1].legend(fontsize=7, loc="upper right")
axes[1].grid(True, alpha=0.3)
# Peak magnitude (PRE-gain, 8-bit)
axes[2].plot(xs, agc["peak_mag"], color="#ffaa00", linewidth=1.0)
axes[2].axhline(y=AGC_TARGET, color="cyan", linestyle="--",
alpha=0.7, label=f"Target ({AGC_TARGET})")
axes[2].axhspan(240, 255, color="red", alpha=0.15, label="Clip zone")
if enable_frame > 0:
axes[2].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[2].set_ylim(0, 260)
axes[2].set_ylabel("Peak (8-bit)")
axes[2].set_title("Peak Magnitude (pre-gain, raw input)")
axes[2].legend(fontsize=7, loc="upper right")
axes[2].grid(True, alpha=0.3)
# Saturation count (POST-gain overflow)
axes[3].fill_between(xs, agc["sat_count"], color="red", alpha=0.4)
axes[3].plot(xs, agc["sat_count"], color="red", linewidth=0.8)
if enable_frame > 0:
axes[3].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[3].set_ylabel("Overflow Count")
total = int(agc["sat_count"].sum())
axes[3].set_title(f"Post-Gain Overflow (total={total})")
axes[3].grid(True, alpha=0.3)
# RMS signal level (post-gain)
axes[4].plot(xs, agc["rms_post"], color="#44aaff", linewidth=1.0)
if enable_frame > 0:
axes[4].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[4].set_ylabel("RMS")
axes[4].set_xlabel("Frame")
axes[4].set_title("Post-Gain RMS Level")
axes[4].grid(True, alpha=0.3)
def analyze_dataset(data: np.ndarray, label: str):
"""Run 3-scenario analysis for one dataset."""
n_frames = data.shape[0]
mid = n_frames // 2
print(f"\n{'='*60}")
print(f" {label} — shape {data.shape}")
print(f"{'='*60}")
# Raw ADC stats
raw_sat = np.sum((np.abs(data.real) >= ADC_RAIL) |
(np.abs(data.imag) >= ADC_RAIL))
print(f" Raw ADC saturation: {raw_sat} samples "
f"({100*raw_sat/(2*data.size):.2f}%)")
# Scenario 1: AGC OFF — pass-through (gain_shift=0x00)
print(" [1/3] AGC OFF (gain=0, pass-through) ...")
agc_off = simulate_agc(data, agc_enabled=False, initial_gain_enc=0x00)
print(f" Post-gain overflow: {agc_off['sat_count'].sum()} "
f"(should be 0 — no amplification)")
# Scenario 2: AGC ON from frame 0
print(" [2/3] AGC ON (from start) ...")
agc_on = simulate_agc(data, agc_enabled=True, enable_at_frame=0,
initial_gain_enc=0x00)
print(f" Final gain: {agc_on['gain_signed'][-1]} "
f"(enc=0x{agc_on['gain_enc'][-1]:X})")
print(f" Post-gain overflow: {agc_on['sat_count'].sum()}")
# Scenario 3: AGC delayed
print(f" [3/3] AGC delayed (ON at frame {mid}) ...")
agc_delayed = simulate_agc(data, agc_enabled=True,
enable_at_frame=mid,
initial_gain_enc=0x00)
pre_sat = int(agc_delayed["sat_count"][:mid].sum())
post_sat = int(agc_delayed["sat_count"][mid:].sum())
print(f" Pre-AGC overflow: {pre_sat} "
f"Post-AGC overflow: {post_sat}")
# Plot
fig, axes = plt.subplots(3, 5, figsize=(28, 14))
fig.suptitle(f"AERIS-10 AGC Analysis — {label}\n"
f"({n_frames} frames, {data.shape[1]} chirps, "
f"{data.shape[2]} samples/chirp, "
f"raw ADC sat={100*raw_sat/(2*data.size):.2f}%)",
fontsize=13, fontweight="bold", y=0.99)
plot_scenario(axes[0], data, agc_off, "AGC OFF (pass-through)")
plot_scenario(axes[1], data, agc_on, "AGC ON (from start)")
plot_scenario(axes[2], data, agc_delayed,
f"AGC delayed (ON at frame {mid})", enable_frame=mid)
for ax, lbl in zip(axes[:, 0],
["AGC OFF", "AGC ON", "AGC DELAYED"],
strict=True):
ax.annotate(lbl, xy=(-0.35, 0.5), xycoords="axes fraction",
fontsize=13, fontweight="bold", color="white",
ha="center", va="center", rotation=90)
plt.tight_layout(rect=[0.03, 0, 1, 0.95])
return fig
def main():
parser = argparse.ArgumentParser(
description="AGC analysis for ADI raw IQ captures "
"(bit-accurate rx_gain_control.v simulation)")
parser.add_argument("--amp", type=str,
default=str(Path.home() / "Downloads/adi_radar_data"
"/amp_radar"
"/phaser_amp_4MSPS_500M_300u_256_m3dB.npy"),
help="Path to amplified radar .npy")
parser.add_argument("--noamp", type=str,
default=str(Path.home() / "Downloads/adi_radar_data"
"/no_amp_radar"
"/phaser_NOamp_4MSPS_500M_300u_256.npy"),
help="Path to non-amplified radar .npy")
parser.add_argument("--data", type=str, default=None,
help="Single dataset mode")
parser.add_argument("--label", type=str, default="Custom Data")
args = parser.parse_args()
plt.style.use("dark_background")
if args.data:
data = np.load(args.data)
analyze_dataset(data, args.label)
plt.show()
return
figs = []
for path, label in [(args.amp, "With Amplifier (-3 dB)"),
(args.noamp, "No Amplifier")]:
if not Path(path).exists():
print(f"WARNING: {path} not found, skipping")
continue
data = np.load(path)
fig = analyze_dataset(data, label)
figs.append(fig)
if not figs:
print("No data found. Use --amp/--noamp or --data.")
sys.exit(1)
plt.show()
if __name__ == "__main__":
main()
+39 -285
View File
@@ -97,11 +97,6 @@ class RadarDashboard:
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8) self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
self._acq_thread: RadarAcquisition | None = None self._acq_thread: RadarAcquisition | None = None
# 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 # Display state
self._current_frame = RadarFrame() self._current_frame = RadarFrame()
self._waterfall = deque(maxlen=WATERFALL_DEPTH) self._waterfall = deque(maxlen=WATERFALL_DEPTH)
@@ -116,16 +111,6 @@ class RadarDashboard:
self._vmax_ema = 1000.0 self._vmax_ema = 1000.0
self._vmax_alpha = 0.15 # smoothing factor (lower = more stable) 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._build_ui()
self._schedule_update() self._schedule_update()
@@ -177,16 +162,13 @@ class RadarDashboard:
tab_display = ttk.Frame(nb) tab_display = ttk.Frame(nb)
tab_control = ttk.Frame(nb) tab_control = ttk.Frame(nb)
tab_agc = ttk.Frame(nb)
tab_log = ttk.Frame(nb) tab_log = ttk.Frame(nb)
nb.add(tab_display, text=" Display ") nb.add(tab_display, text=" Display ")
nb.add(tab_control, text=" Control ") nb.add(tab_control, text=" Control ")
nb.add(tab_agc, text=" AGC Monitor ")
nb.add(tab_log, text=" Log ") nb.add(tab_log, text=" Log ")
self._build_display_tab(tab_display) self._build_display_tab(tab_display)
self._build_control_tab(tab_control) self._build_control_tab(tab_control)
self._build_agc_tab(tab_agc)
self._build_log_tab(tab_log) self._build_log_tab(tab_log)
def _build_display_tab(self, parent): def _build_display_tab(self, parent):
@@ -360,15 +342,17 @@ class RadarDashboard:
grp_wf.pack(fill="x", pady=(0, 8)) grp_wf.pack(fill="x", pady=(0, 8))
wf_params = [ wf_params = [
("Long Chirp Cycles", 0x10, "3000", 16, "0-65535, rst=3000"), # label opcode default bits hint min max
("Long Listen Cycles", 0x11, "13700", 16, "0-65535, rst=13700"), ("Long Chirp Cycles", 0x10, "3000", 16, "0-65535, rst=3000", 0, None),
("Guard Cycles", 0x12, "17540", 16, "0-65535, rst=17540"), ("Long Listen Cycles", 0x11, "13700", 16, "0-65535, rst=13700", 0, None),
("Short Chirp Cycles", 0x13, "50", 16, "0-65535, rst=50"), ("Guard Cycles", 0x12, "17540", 16, "0-65535, rst=17540", 0, None),
("Short Listen Cycles", 0x14, "17450", 16, "0-65535, rst=17450"), ("Short Chirp Cycles", 0x13, "50", 16, "0-65535, rst=50", 0, None),
("Chirps Per Elevation", 0x15, "32", 6, "1-32, clamped"), ("Short Listen Cycles", 0x14, "17450", 16, "0-65535, rst=17450", 0, None),
("Chirps Per Elevation", 0x15, "32", 6, "1-32, clamped", 1, 32),
] ]
for label, opcode, default, bits, hint in wf_params: for label, opcode, default, bits, hint, min_v, max_v in wf_params:
self._add_param_row(grp_wf, label, opcode, default, bits, hint) self._add_param_row(grp_wf, label, opcode, default, bits, hint,
min_val=min_v, max_val=max_v)
# ── Right column: Detection (CFAR) + Custom ─────────────────── # ── Right column: Detection (CFAR) + Custom ───────────────────
right = ttk.Frame(outer) right = ttk.Frame(outer)
@@ -397,44 +381,6 @@ class RadarDashboard:
command=lambda: self._send_cmd(0x25, 0)).pack( command=lambda: self._send_cmd(0x25, 0)).pack(
side="left", expand=True, fill="x", padx=(2, 0)) side="left", expand=True, fill="x", padx=(2, 0))
# ── AGC (Automatic Gain Control) ──────────────────────────────
grp_agc = ttk.LabelFrame(right, text="AGC (Auto Gain)", padding=10)
grp_agc.pack(fill="x", pady=(0, 8))
agc_params = [
("AGC Enable", 0x28, "0", 1, "0=manual, 1=auto"),
("AGC Target", 0x29, "200", 8, "0-255, peak target"),
("AGC Attack", 0x2A, "1", 4, "0-15, atten step"),
("AGC Decay", 0x2B, "1", 4, "0-15, gain-up step"),
("AGC Holdoff", 0x2C, "4", 4, "0-15, frames"),
]
for label, opcode, default, bits, hint in agc_params:
self._add_param_row(grp_agc, label, opcode, default, bits, hint)
# AGC quick toggle
agc_row = ttk.Frame(grp_agc)
agc_row.pack(fill="x", pady=2)
ttk.Button(agc_row, text="Enable AGC",
command=lambda: self._send_cmd(0x28, 1)).pack(
side="left", expand=True, fill="x", padx=(0, 2))
ttk.Button(agc_row, text="Disable AGC",
command=lambda: self._send_cmd(0x28, 0)).pack(
side="left", expand=True, fill="x", padx=(2, 0))
# AGC status readback labels
agc_st = ttk.LabelFrame(grp_agc, text="AGC Status", padding=6)
agc_st.pack(fill="x", pady=(4, 0))
self._agc_labels = {}
for name, default_text in [
("enable", "AGC: --"),
("gain", "Gain: --"),
("peak", "Peak: --"),
("sat", "Sat Count: --"),
]:
lbl = ttk.Label(agc_st, text=default_text, font=("Menlo", 9))
lbl.pack(anchor="w")
self._agc_labels[name] = lbl
# ── Custom Command (advanced / debug) ───────────────────────── # ── Custom Command (advanced / debug) ─────────────────────────
grp_cust = ttk.LabelFrame(right, text="Custom Command", padding=10) grp_cust = ttk.LabelFrame(right, text="Custom Command", padding=10)
grp_cust.pack(fill="x", pady=(0, 8)) grp_cust.pack(fill="x", pady=(0, 8))
@@ -463,7 +409,8 @@ class RadarDashboard:
outer.rowconfigure(0, weight=1) outer.rowconfigure(0, weight=1)
def _add_param_row(self, parent, label: str, opcode: int, def _add_param_row(self, parent, label: str, opcode: int,
default: str, bits: int, hint: str): default: str, bits: int, hint: str,
min_val: int = 0, max_val: int | None = None):
"""Add a single parameter row: label, entry, hint, Set button with validation.""" """Add a single parameter row: label, entry, hint, Set button with validation."""
row = ttk.Frame(parent) row = ttk.Frame(parent)
row.pack(fill="x", pady=2) row.pack(fill="x", pady=2)
@@ -475,115 +422,32 @@ class RadarDashboard:
font=("Menlo", 9)).pack(side="left") font=("Menlo", 9)).pack(side="left")
ttk.Button(row, text="Set", ttk.Button(row, text="Set",
command=lambda: self._send_validated( command=lambda: self._send_validated(
opcode, var, bits=bits)).pack(side="right") opcode, var, bits=bits,
min_val=min_val, max_val=max_val)).pack(side="right")
def _send_validated(self, opcode: int, var: tk.StringVar, bits: int): def _send_validated(self, opcode: int, var: tk.StringVar, bits: int,
"""Parse, clamp to bit-width, send command, and update the entry.""" min_val: int = 0, max_val: int | None = None):
"""Parse, clamp to [min_val, max_val], send command, and update the entry."""
try: try:
raw = int(var.get()) raw = int(var.get())
except ValueError: except ValueError:
log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}") log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}")
return return
max_val = (1 << bits) - 1 ceiling = (1 << bits) - 1 if max_val is None else max_val
clamped = max(0, min(raw, max_val)) clamped = max(min_val, min(raw, ceiling))
if clamped != raw: if clamped != raw:
log.warning(f"Value {raw} clamped to {clamped} " log.warning(f"Value {raw} clamped to {clamped} "
f"({bits}-bit max={max_val}) for opcode 0x{opcode:02X}") f"(range {min_val}-{ceiling}) for opcode 0x{opcode:02X}")
var.set(str(clamped)) var.set(str(clamped))
self._send_cmd(opcode, 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): def _build_log_tab(self, parent):
self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10), self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10),
insertbackground=FG, wrap="word") insertbackground=FG, wrap="word")
self.log_text.pack(fill="both", expand=True, padx=8, pady=8) self.log_text.pack(fill="both", expand=True, padx=8, pady=8)
# Redirect log handler to text widget (via UI queue for thread safety) # Redirect log handler to text widget
handler = _TextHandler(self._ui_queue) handler = _TextHandler(self.log_text)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S")) datefmt="%H:%M:%S"))
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)
@@ -609,8 +473,8 @@ class RadarDashboard:
def _do_connect(): def _do_connect():
ok = self.conn.open(self.device_index) ok = self.conn.open(self.device_index)
# Post result to UI queue (drained by _schedule_update) # Schedule UI update back on the main thread
self._ui_queue.put(("connect", ok)) self.root.after(0, lambda: self._on_connect_done(ok))
threading.Thread(target=_do_connect, daemon=True).start() threading.Thread(target=_do_connect, daemon=True).start()
@@ -658,11 +522,11 @@ class RadarDashboard:
log.error("Invalid custom command values") log.error("Invalid custom command values")
def _on_status_received(self, status: StatusResponse): def _on_status_received(self, status: StatusResponse):
"""Called from acquisition thread — post to UI queue for main thread.""" """Called from acquisition thread — schedule UI update on main thread."""
self._ui_queue.put(("status", status)) self.root.after(0, self._update_self_test_labels, status)
def _update_self_test_labels(self, status: StatusResponse): def _update_self_test_labels(self, status: StatusResponse):
"""Update the self-test result labels and AGC status from a StatusResponse.""" """Update the self-test result labels from a StatusResponse."""
if not hasattr(self, '_st_labels'): if not hasattr(self, '_st_labels'):
return return
flags = status.self_test_flags flags = status.self_test_flags
@@ -697,124 +561,11 @@ class RadarDashboard:
self._st_labels[key].config( self._st_labels[key].config(
text=f"{name}: {result_str}", foreground=color) text=f"{name}: {result_str}", foreground=color)
# AGC status readback
if hasattr(self, '_agc_labels'):
agc_str = "AUTO" if status.agc_enable else "MANUAL"
agc_color = GREEN if status.agc_enable else FG
self._agc_labels["enable"].config(
text=f"AGC: {agc_str}", foreground=agc_color)
self._agc_labels["gain"].config(
text=f"Gain: {status.agc_current_gain}")
self._agc_labels["peak"].config(
text=f"Peak: {status.agc_peak_magnitude}")
sat_color = RED if status.agc_saturation_count > 0 else FG
self._agc_labels["sat"].config(
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 # --------------------------------------------------------- Display loop
def _schedule_update(self): def _schedule_update(self):
self._drain_ui_queue()
self._update_display() self._update_display()
self.root.after(self.UPDATE_INTERVAL_MS, self._schedule_update) 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): def _update_display(self):
"""Pull latest frame from queue and update plots.""" """Pull latest frame from queue and update plots."""
frame = None frame = None
@@ -879,21 +630,24 @@ class RadarDashboard:
class _TextHandler(logging.Handler): class _TextHandler(logging.Handler):
"""Logging handler that posts messages to a queue for main-thread append. """Logging handler that writes to a tkinter Text widget."""
Using widget.after() from background threads crashes Python 3.12 due to def __init__(self, text_widget: tk.Text):
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__() super().__init__()
self._ui_queue = ui_queue self._text = text_widget
def emit(self, record): def emit(self, record):
msg = self.format(record) msg = self.format(record)
with contextlib.suppress(Exception): with contextlib.suppress(Exception):
self._ui_queue.put(("log", msg)) 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")
# ============================================================================ # ============================================================================
+8 -39
View File
@@ -59,9 +59,9 @@ class Opcode(IntEnum):
0x03 host_detect_threshold 0x16 host_gain_shift 0x03 host_detect_threshold 0x16 host_gain_shift
0x04 host_stream_control 0x20 host_range_mode 0x04 host_stream_control 0x20 host_range_mode
0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch 0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch
0x11 host_long_listen_cycles 0x28-0x2C AGC control 0x11 host_long_listen_cycles 0x30 host_self_test_trigger
0x12 host_guard_cycles 0x30 host_self_test_trigger 0x12 host_guard_cycles 0x31 host_status_request
0x13 host_short_chirp_cycles 0x31/0xFF host_status_request 0x13 host_short_chirp_cycles 0xFF host_status_request
""" """
# --- Basic control (0x01-0x04) --- # --- Basic control (0x01-0x04) ---
RADAR_MODE = 0x01 # 2-bit mode select RADAR_MODE = 0x01 # 2-bit mode select
@@ -90,13 +90,6 @@ class Opcode(IntEnum):
MTI_ENABLE = 0x26 MTI_ENABLE = 0x26
DC_NOTCH_WIDTH = 0x27 DC_NOTCH_WIDTH = 0x27
# --- AGC (0x28-0x2C) ---
AGC_ENABLE = 0x28
AGC_TARGET = 0x29
AGC_ATTACK = 0x2A
AGC_DECAY = 0x2B
AGC_HOLDOFF = 0x2C
# --- Board self-test / status (0x30-0x31, 0xFF) --- # --- Board self-test / status (0x30-0x31, 0xFF) ---
SELF_TEST_TRIGGER = 0x30 SELF_TEST_TRIGGER = 0x30
SELF_TEST_STATUS = 0x31 SELF_TEST_STATUS = 0x31
@@ -142,11 +135,6 @@ class StatusResponse:
self_test_flags: int = 0 # 5-bit result flags [4:0] self_test_flags: int = 0 # 5-bit result flags [4:0]
self_test_detail: int = 0 # 8-bit detail code [7:0] self_test_detail: int = 0 # 8-bit detail code [7:0]
self_test_busy: int = 0 # 1-bit busy flag self_test_busy: int = 0 # 1-bit busy flag
# AGC metrics (word 4, added for hybrid AGC)
agc_current_gain: int = 0 # 4-bit current gain encoding [3:0]
agc_peak_magnitude: int = 0 # 8-bit peak magnitude [7:0]
agc_saturation_count: int = 0 # 8-bit saturation count [7:0]
agc_enable: int = 0 # 1-bit AGC enable readback
# ============================================================================ # ============================================================================
@@ -244,13 +232,8 @@ class RadarProtocol:
# Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]} # Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]}
sr.chirps_per_elev = words[3] & 0x3F sr.chirps_per_elev = words[3] & 0x3F
sr.short_listen = (words[3] >> 16) & 0xFFFF sr.short_listen = (words[3] >> 16) & 0xFFFF
# Word 4: {agc_current_gain[31:28], agc_peak_magnitude[27:20], # Word 4: {30'd0, range_mode[1:0]}
# agc_saturation_count[19:12], agc_enable[11], 9'd0, range_mode[1:0]}
sr.range_mode = words[4] & 0x03 sr.range_mode = words[4] & 0x03
sr.agc_enable = (words[4] >> 11) & 0x01
sr.agc_saturation_count = (words[4] >> 12) & 0xFF
sr.agc_peak_magnitude = (words[4] >> 20) & 0xFF
sr.agc_current_gain = (words[4] >> 28) & 0x0F
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0], # Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
# 3'd0, self_test_flags[4:0]} # 3'd0, self_test_flags[4:0]}
sr.self_test_flags = words[5] & 0x1F sr.self_test_flags = words[5] & 0x1F
@@ -452,7 +435,7 @@ class FT2232HConnection:
_HARDWARE_ONLY_OPCODES = { _HARDWARE_ONLY_OPCODES = {
0x01, # RADAR_MODE 0x01, # RADAR_MODE
0x02, # TRIGGER_PULSE 0x02, # TRIGGER_PULSE
# 0x03 (DETECT_THRESHOLD) is NOT hardware-only — it's in _REPLAY_ADJUSTABLE_OPCODES 0x03, # DETECT_THRESHOLD
0x04, # STREAM_CONTROL 0x04, # STREAM_CONTROL
0x10, # LONG_CHIRP 0x10, # LONG_CHIRP
0x11, # LONG_LISTEN 0x11, # LONG_LISTEN
@@ -462,11 +445,6 @@ _HARDWARE_ONLY_OPCODES = {
0x15, # CHIRPS_PER_ELEV 0x15, # CHIRPS_PER_ELEV
0x16, # GAIN_SHIFT 0x16, # GAIN_SHIFT
0x20, # RANGE_MODE 0x20, # RANGE_MODE
0x28, # AGC_ENABLE
0x29, # AGC_TARGET
0x2A, # AGC_ATTACK
0x2B, # AGC_DECAY
0x2C, # AGC_HOLDOFF
0x30, # SELF_TEST_TRIGGER 0x30, # SELF_TEST_TRIGGER
0x31, # SELF_TEST_STATUS 0x31, # SELF_TEST_STATUS
0xFF, # STATUS_REQUEST 0xFF, # STATUS_REQUEST
@@ -474,7 +452,6 @@ _HARDWARE_ONLY_OPCODES = {
# Replay-adjustable opcodes (re-run signal processing) # Replay-adjustable opcodes (re-run signal processing)
_REPLAY_ADJUSTABLE_OPCODES = { _REPLAY_ADJUSTABLE_OPCODES = {
0x03, # DETECT_THRESHOLD
0x21, # CFAR_GUARD 0x21, # CFAR_GUARD
0x22, # CFAR_TRAIN 0x22, # CFAR_TRAIN
0x23, # CFAR_ALPHA 0x23, # CFAR_ALPHA
@@ -618,7 +595,6 @@ class ReplayConnection:
self._cfar_alpha: int = 0x30 self._cfar_alpha: int = 0x30
self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO
self._cfar_enable: bool = True self._cfar_enable: bool = True
self._detect_threshold: int = 10000 # RTL default (host_detect_threshold)
# Raw source arrays (loaded once, reprocessed on param change) # Raw source arrays (loaded once, reprocessed on param change)
self._dop_mti_i: np.ndarray | None = None self._dop_mti_i: np.ndarray | None = None
self._dop_mti_q: np.ndarray | None = None self._dop_mti_q: np.ndarray | None = None
@@ -640,7 +616,7 @@ class ReplayConnection:
f"(MTI={'ON' if self._mti_enable else 'OFF'}, " f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
f"{self._frame_len} bytes/frame)") f"{self._frame_len} bytes/frame)")
return True return True
except (OSError, ValueError, IndexError, struct.error) as e: except (OSError, ValueError, struct.error) as e:
log.error(f"Replay open failed: {e}") log.error(f"Replay open failed: {e}")
return False return False
@@ -683,11 +659,7 @@ class ReplayConnection:
if opcode in _REPLAY_ADJUSTABLE_OPCODES: if opcode in _REPLAY_ADJUSTABLE_OPCODES:
changed = False changed = False
with self._lock: with self._lock:
if opcode == 0x03: # DETECT_THRESHOLD if opcode == 0x21: # CFAR_GUARD
if self._detect_threshold != value:
self._detect_threshold = value
changed = True
elif opcode == 0x21: # CFAR_GUARD
if self._cfar_guard != value: if self._cfar_guard != value:
self._cfar_guard = value self._cfar_guard = value
changed = True changed = True
@@ -779,10 +751,7 @@ class ReplayConnection:
mode=self._cfar_mode, mode=self._cfar_mode,
) )
else: else:
# Simple threshold fallback matching RTL cfar_ca.v: det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=bool)
# 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()) det_count = int(det.sum())
log.info(f"Replay: rebuilt {NUM_CELLS} packets (" log.info(f"Replay: rebuilt {NUM_CELLS} packets ("
-3
View File
@@ -17,6 +17,3 @@ scipy>=1.10
# Tracking / clustering (optional) # Tracking / clustering (optional)
scikit-learn>=1.2 scikit-learn>=1.2
filterpy>=1.4 filterpy>=1.4
# CRC validation (optional)
crcmod>=1.7
+3 -202
View File
@@ -125,8 +125,7 @@ class TestRadarProtocol(unittest.TestCase):
long_chirp=3000, long_listen=13700, long_chirp=3000, long_listen=13700,
guard=17540, short_chirp=50, guard=17540, short_chirp=50,
short_listen=17450, chirps=32, range_mode=0, short_listen=17450, chirps=32, range_mode=0,
st_flags=0, st_detail=0, st_busy=0, st_flags=0, st_detail=0, st_busy=0):
agc_gain=0, agc_peak=0, agc_sat=0, agc_enable=0):
"""Build a 26-byte status response matching FPGA format (Build 26).""" """Build a 26-byte status response matching FPGA format (Build 26)."""
pkt = bytearray() pkt = bytearray()
pkt.append(STATUS_HEADER_BYTE) pkt.append(STATUS_HEADER_BYTE)
@@ -147,11 +146,8 @@ class TestRadarProtocol(unittest.TestCase):
w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F) w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F)
pkt += struct.pack(">I", w3) pkt += struct.pack(">I", w3)
# Word 4: {agc_current_gain[3:0], agc_peak_magnitude[7:0], # Word 4: {30'd0, range_mode[1:0]}
# agc_saturation_count[7:0], agc_enable, 9'd0, range_mode[1:0]} w4 = range_mode & 0x03
w4 = (((agc_gain & 0x0F) << 28) | ((agc_peak & 0xFF) << 20) |
((agc_sat & 0xFF) << 12) | ((agc_enable & 0x01) << 11) |
(range_mode & 0x03))
pkt += struct.pack(">I", w4) pkt += struct.pack(">I", w4)
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0], # Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
@@ -727,7 +723,6 @@ class TestOpcodeEnum(unittest.TestCase):
expected = {0x01, 0x02, 0x03, 0x04, expected = {0x01, 0x02, 0x03, 0x04,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x28, 0x29, 0x2A, 0x2B, 0x2C,
0x30, 0x31, 0xFF} 0x30, 0x31, 0xFF}
enum_values = {int(m) for m in Opcode} enum_values = {int(m) for m in Opcode}
for op in expected: for op in expected:
@@ -752,199 +747,5 @@ class TestStatusResponseDefaults(unittest.TestCase):
self.assertEqual(sr.self_test_busy, 1) self.assertEqual(sr.self_test_busy, 1)
class TestAGCOpcodes(unittest.TestCase):
"""Verify AGC opcode enum members match FPGA RTL (0x28-0x2C)."""
def test_agc_enable_opcode(self):
self.assertEqual(Opcode.AGC_ENABLE, 0x28)
def test_agc_target_opcode(self):
self.assertEqual(Opcode.AGC_TARGET, 0x29)
def test_agc_attack_opcode(self):
self.assertEqual(Opcode.AGC_ATTACK, 0x2A)
def test_agc_decay_opcode(self):
self.assertEqual(Opcode.AGC_DECAY, 0x2B)
def test_agc_holdoff_opcode(self):
self.assertEqual(Opcode.AGC_HOLDOFF, 0x2C)
class TestAGCStatusParsing(unittest.TestCase):
"""Verify AGC fields in status_words[4] are parsed correctly."""
def _make_status_packet(self, **kwargs):
"""Delegate to TestRadarProtocol helper."""
helper = TestRadarProtocol()
return helper._make_status_packet(**kwargs)
def test_agc_fields_default_zero(self):
"""With no AGC fields set, all should be 0."""
raw = self._make_status_packet()
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 0)
self.assertEqual(sr.agc_peak_magnitude, 0)
self.assertEqual(sr.agc_saturation_count, 0)
self.assertEqual(sr.agc_enable, 0)
def test_agc_fields_nonzero(self):
"""AGC fields round-trip through status packet."""
raw = self._make_status_packet(agc_gain=7, agc_peak=200,
agc_sat=15, agc_enable=1)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 7)
self.assertEqual(sr.agc_peak_magnitude, 200)
self.assertEqual(sr.agc_saturation_count, 15)
self.assertEqual(sr.agc_enable, 1)
def test_agc_max_values(self):
"""AGC fields at max values."""
raw = self._make_status_packet(agc_gain=15, agc_peak=255,
agc_sat=255, agc_enable=1)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 15)
self.assertEqual(sr.agc_peak_magnitude, 255)
self.assertEqual(sr.agc_saturation_count, 255)
self.assertEqual(sr.agc_enable, 1)
def test_agc_and_range_mode_coexist(self):
"""AGC fields and range_mode occupy the same word without conflict."""
raw = self._make_status_packet(agc_gain=5, agc_peak=128,
agc_sat=42, agc_enable=1,
range_mode=2)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 5)
self.assertEqual(sr.agc_peak_magnitude, 128)
self.assertEqual(sr.agc_saturation_count, 42)
self.assertEqual(sr.agc_enable, 1)
self.assertEqual(sr.range_mode, 2)
class TestAGCStatusResponseDefaults(unittest.TestCase):
"""Verify StatusResponse AGC field defaults."""
def test_default_agc_fields(self):
sr = StatusResponse()
self.assertEqual(sr.agc_current_gain, 0)
self.assertEqual(sr.agc_peak_magnitude, 0)
self.assertEqual(sr.agc_saturation_count, 0)
self.assertEqual(sr.agc_enable, 0)
def test_agc_fields_set(self):
sr = StatusResponse(agc_current_gain=7, agc_peak_magnitude=200,
agc_saturation_count=15, agc_enable=1)
self.assertEqual(sr.agc_current_gain, 7)
self.assertEqual(sr.agc_peak_magnitude, 200)
self.assertEqual(sr.agc_saturation_count, 15)
self.assertEqual(sr.agc_enable, 1)
# =============================================================================
# AGC Visualization — ring buffer / data model tests
# =============================================================================
class TestAGCVisualizationHistory(unittest.TestCase):
"""Test the AGC visualization ring buffer logic (no GUI required)."""
def _make_deque(self, maxlen=256):
from collections import deque
return deque(maxlen=maxlen)
def test_ring_buffer_maxlen(self):
"""Ring buffer should evict oldest when full."""
d = self._make_deque(maxlen=4)
for i in range(6):
d.append(i)
self.assertEqual(list(d), [2, 3, 4, 5])
self.assertEqual(len(d), 4)
def test_gain_history_accumulation(self):
"""Gain values accumulate correctly in a deque."""
gain_hist = self._make_deque(maxlen=256)
statuses = [
StatusResponse(agc_current_gain=g)
for g in [0, 3, 7, 15, 8, 2]
]
for st in statuses:
gain_hist.append(st.agc_current_gain)
self.assertEqual(list(gain_hist), [0, 3, 7, 15, 8, 2])
def test_peak_history_accumulation(self):
"""Peak magnitude values accumulate correctly."""
peak_hist = self._make_deque(maxlen=256)
for p in [0, 50, 200, 255, 128]:
peak_hist.append(p)
self.assertEqual(list(peak_hist), [0, 50, 200, 255, 128])
def test_saturation_total_computation(self):
"""Sum of saturation ring buffer gives running total."""
sat_hist = self._make_deque(maxlen=256)
for s in [0, 0, 5, 0, 12, 3]:
sat_hist.append(s)
self.assertEqual(sum(sat_hist), 20)
def test_saturation_color_thresholds(self):
"""Color logic: green=0, yellow=1-10, red>10."""
def sat_color(total):
if total > 10:
return "red"
if total > 0:
return "yellow"
return "green"
self.assertEqual(sat_color(0), "green")
self.assertEqual(sat_color(1), "yellow")
self.assertEqual(sat_color(10), "yellow")
self.assertEqual(sat_color(11), "red")
self.assertEqual(sat_color(255), "red")
def test_ring_buffer_eviction_preserves_latest(self):
"""After overflow, only the most recent values remain."""
d = self._make_deque(maxlen=8)
for i in range(20):
d.append(i)
self.assertEqual(list(d), [12, 13, 14, 15, 16, 17, 18, 19])
def test_empty_history_safe(self):
"""Empty ring buffer should be safe for max/sum."""
d = self._make_deque(maxlen=256)
self.assertEqual(sum(d), 0)
self.assertEqual(len(d), 0)
# max() on empty would raise — test the guard pattern used in viz code
max_sat = max(d) if d else 0
self.assertEqual(max_sat, 0)
def test_agc_mode_string(self):
"""AGC mode display string from enable flag."""
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=1).agc_enable else "MANUAL",
"AUTO")
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=0).agc_enable else "MANUAL",
"MANUAL")
def test_xlim_scroll_logic(self):
"""X-axis scroll: when n >= history_len, xlim should expand."""
history_len = 8
d = self._make_deque(maxlen=history_len)
for i in range(10):
d.append(i)
n = len(d)
# After 10 pushes into maxlen=8, n=8
self.assertEqual(n, history_len)
# xlim should be (0, n) for static or (n-history_len, n) for scrolling
self.assertEqual(max(0, n - history_len), 0)
self.assertEqual(n, 8)
def test_sat_autoscale_ylim(self):
"""Saturation y-axis auto-scale: max(max_sat * 1.5, 5)."""
# No saturation
self.assertEqual(max(0 * 1.5, 5), 5)
# Some saturation
self.assertAlmostEqual(max(10 * 1.5, 5), 15.0)
# High saturation
self.assertAlmostEqual(max(200 * 1.5, 5), 300.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)
-67
View File
@@ -334,73 +334,6 @@ class TestV7Init(unittest.TestCase):
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}") self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
# =============================================================================
# Test: AGC Visualization data model
# =============================================================================
class TestAGCVisualizationV7(unittest.TestCase):
"""AGC visualization ring buffer and data model tests (no Qt required)."""
def _make_deque(self, maxlen=256):
from collections import deque
return deque(maxlen=maxlen)
def test_ring_buffer_basics(self):
d = self._make_deque(maxlen=4)
for i in range(6):
d.append(i)
self.assertEqual(list(d), [2, 3, 4, 5])
def test_gain_range_4bit(self):
"""AGC gain is 4-bit (0-15)."""
from radar_protocol import StatusResponse
for g in [0, 7, 15]:
sr = StatusResponse(agc_current_gain=g)
self.assertEqual(sr.agc_current_gain, g)
def test_peak_range_8bit(self):
"""Peak magnitude is 8-bit (0-255)."""
from radar_protocol import StatusResponse
for p in [0, 128, 255]:
sr = StatusResponse(agc_peak_magnitude=p)
self.assertEqual(sr.agc_peak_magnitude, p)
def test_saturation_accumulation(self):
"""Saturation ring buffer sum tracks total events."""
sat = self._make_deque(maxlen=256)
for s in [0, 5, 0, 10, 3]:
sat.append(s)
self.assertEqual(sum(sat), 18)
def test_mode_label_logic(self):
"""AGC mode string from enable field."""
from radar_protocol import StatusResponse
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=1).agc_enable else "MANUAL",
"AUTO")
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=0).agc_enable else "MANUAL",
"MANUAL")
def test_history_len_default(self):
"""Default history length should be 256."""
d = self._make_deque(maxlen=256)
self.assertEqual(d.maxlen, 256)
def test_color_thresholds(self):
"""Saturation color: green=0, warning=1-10, error>10."""
from v7.models import DARK_SUCCESS, DARK_WARNING, DARK_ERROR
def pick_color(total):
if total > 10:
return DARK_ERROR
if total > 0:
return DARK_WARNING
return DARK_SUCCESS
self.assertEqual(pick_color(0), DARK_SUCCESS)
self.assertEqual(pick_color(5), DARK_WARNING)
self.assertEqual(pick_color(11), DARK_ERROR)
# ============================================================================= # =============================================================================
# Helper: lazy import of v7.models # Helper: lazy import of v7.models
# ============================================================================= # =============================================================================
+17 -292
View File
@@ -1,16 +1,15 @@
""" """
v7.dashboard Main application window for the PLFM Radar GUI V7. v7.dashboard Main application window for the PLFM Radar GUI V7.
RadarDashboard is a QMainWindow with six tabs: RadarDashboard is a QMainWindow with five tabs:
1. Main View Range-Doppler matplotlib canvas (64x32), device combos, 1. Main View Range-Doppler matplotlib canvas (64x32), device combos,
Start/Stop, targets table Start/Stop, targets table
2. Map View Embedded Leaflet map + sidebar 2. Map View Embedded Leaflet map + sidebar
3. FPGA Control Full FPGA register control panel (all 27 opcodes incl. AGC, 3. FPGA Control Full FPGA register control panel (all 22 opcodes,
bit-width validation, grouped layout matching production) bit-width validation, grouped layout matching production)
4. AGC Monitor Real-time AGC strip charts (gain, peak magnitude, saturation) 4. Diagnostics Connection indicators, packet stats, dependency status,
5. Diagnostics Connection indicators, packet stats, dependency status,
self-test results, log viewer self-test results, log viewer
6. Settings Host-side DSP parameters + About section 5. Settings Host-side DSP parameters + About section
Uses production radar_protocol.py for all FPGA communication: Uses production radar_protocol.py for all FPGA communication:
- FT2232HConnection for real hardware - FT2232HConnection for real hardware
@@ -24,7 +23,6 @@ commands sent over FT2232H.
import time import time
import logging import logging
from collections import deque
import numpy as np import numpy as np
@@ -36,7 +34,7 @@ from PyQt6.QtWidgets import (
QTableWidget, QTableWidgetItem, QHeaderView, QTableWidget, QTableWidgetItem, QHeaderView,
QPlainTextEdit, QStatusBar, QMessageBox, QPlainTextEdit, QStatusBar, QMessageBox,
) )
from PyQt6.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot, QObject from PyQt6.QtCore import Qt, QTimer, pyqtSlot
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
from matplotlib.figure import Figure from matplotlib.figure import Figure
@@ -150,20 +148,11 @@ class RadarDashboard(QMainWindow):
self._last_status: StatusResponse | None = None self._last_status: StatusResponse | None = None
self._frame_count = 0 self._frame_count = 0
self._gps_packet_count = 0 self._gps_packet_count = 0
self._last_stats: dict = {}
self._current_targets: list[RadarTarget] = [] self._current_targets: list[RadarTarget] = []
# FPGA control parameter widgets # FPGA control parameter widgets
self._param_spins: dict = {} # opcode_hex -> QSpinBox self._param_spins: dict = {} # opcode_hex -> QSpinBox
# AGC visualization history (ring buffers)
self._agc_history_len = 256
self._agc_gain_history: deque[int] = deque(maxlen=self._agc_history_len)
self._agc_peak_history: deque[int] = deque(maxlen=self._agc_history_len)
self._agc_sat_history: deque[int] = deque(maxlen=self._agc_history_len)
self._agc_last_redraw: float = 0.0 # throttle chart redraws
self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws
# ---- Build UI ------------------------------------------------------ # ---- Build UI ------------------------------------------------------
self._apply_dark_theme() self._apply_dark_theme()
self._setup_ui() self._setup_ui()
@@ -174,10 +163,8 @@ class RadarDashboard(QMainWindow):
self._gui_timer.timeout.connect(self._refresh_gui) self._gui_timer.timeout.connect(self._refresh_gui)
self._gui_timer.start(100) self._gui_timer.start(100)
# Log handler for diagnostics (thread-safe via Qt signal) # Log handler for diagnostics
self._log_bridge = _LogSignalBridge(self) self._log_handler = _QtLogHandler(self._log_append)
self._log_bridge.log_message.connect(self._log_append)
self._log_handler = _QtLogHandler(self._log_bridge)
self._log_handler.setLevel(logging.INFO) self._log_handler.setLevel(logging.INFO)
logging.getLogger().addHandler(self._log_handler) logging.getLogger().addHandler(self._log_handler)
@@ -319,7 +306,6 @@ class RadarDashboard(QMainWindow):
self._create_main_tab() self._create_main_tab()
self._create_map_tab() self._create_map_tab()
self._create_fpga_control_tab() self._create_fpga_control_tab()
self._create_agc_monitor_tab()
self._create_diagnostics_tab() self._create_diagnostics_tab()
self._create_settings_tab() self._create_settings_tab()
@@ -406,7 +392,7 @@ class RadarDashboard(QMainWindow):
self._targets_table_main = QTableWidget() self._targets_table_main = QTableWidget()
self._targets_table_main.setColumnCount(5) self._targets_table_main.setColumnCount(5)
self._targets_table_main.setHorizontalHeaderLabels([ self._targets_table_main.setHorizontalHeaderLabels([
"Range (m)", "Velocity (m/s)", "Magnitude", "SNR (dB)", "Track ID", "Range Bin", "Doppler Bin", "Magnitude", "SNR (dB)", "Track ID",
]) ])
self._targets_table_main.setAlternatingRowColors(True) self._targets_table_main.setAlternatingRowColors(True)
self._targets_table_main.setSelectionBehavior( self._targets_table_main.setSelectionBehavior(
@@ -695,48 +681,6 @@ class RadarDashboard(QMainWindow):
right_layout.addWidget(grp_cfar) right_layout.addWidget(grp_cfar)
# ── AGC (Automatic Gain Control) ──────────────────────────────
grp_agc = QGroupBox("AGC (Auto Gain)")
agc_layout = QVBoxLayout(grp_agc)
agc_params = [
("AGC Enable", 0x28, 0, 1, "0=manual, 1=auto"),
("AGC Target", 0x29, 200, 8, "0-255, peak target"),
("AGC Attack", 0x2A, 1, 4, "0-15, atten step"),
("AGC Decay", 0x2B, 1, 4, "0-15, gain-up step"),
("AGC Holdoff", 0x2C, 4, 4, "0-15, frames"),
]
for label, opcode, default, bits, hint in agc_params:
self._add_fpga_param_row(agc_layout, label, opcode, default, bits, hint)
# AGC quick toggles
agc_row = QHBoxLayout()
btn_agc_on = QPushButton("Enable AGC")
btn_agc_on.clicked.connect(lambda: self._send_fpga_cmd(0x28, 1))
agc_row.addWidget(btn_agc_on)
btn_agc_off = QPushButton("Disable AGC")
btn_agc_off.clicked.connect(lambda: self._send_fpga_cmd(0x28, 0))
agc_row.addWidget(btn_agc_off)
agc_layout.addLayout(agc_row)
# AGC status readback labels
agc_st_group = QGroupBox("AGC Status")
agc_st_layout = QVBoxLayout(agc_st_group)
self._agc_labels: dict[str, QLabel] = {}
for name, default_text in [
("enable", "AGC: --"),
("gain", "Gain: --"),
("peak", "Peak: --"),
("sat", "Sat Count: --"),
]:
lbl = QLabel(default_text)
lbl.setStyleSheet(f"color: {DARK_INFO}; font-size: 10px;")
agc_st_layout.addWidget(lbl)
self._agc_labels[name] = lbl
agc_layout.addWidget(agc_st_group)
right_layout.addWidget(grp_agc)
# Custom Command # Custom Command
grp_custom = QGroupBox("Custom Command") grp_custom = QGroupBox("Custom Command")
cust_layout = QGridLayout(grp_custom) cust_layout = QGridLayout(grp_custom)
@@ -797,122 +741,7 @@ class RadarDashboard(QMainWindow):
parent_layout.addLayout(row) parent_layout.addLayout(row)
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# TAB 4: AGC Monitor # TAB 4: Diagnostics
# -----------------------------------------------------------------
def _create_agc_monitor_tab(self):
"""AGC Monitor — real-time strip charts for FPGA inner-loop AGC."""
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(8, 8, 8, 8)
# ---- Top indicator row ---------------------------------------------
indicator = QFrame()
indicator.setStyleSheet(
f"background-color: {DARK_ACCENT}; border-radius: 4px;")
ind_layout = QHBoxLayout(indicator)
ind_layout.setContentsMargins(12, 8, 12, 8)
self._agc_mode_lbl = QLabel("AGC: --")
self._agc_mode_lbl.setStyleSheet(
f"color: {DARK_FG}; font-size: 16px; font-weight: bold;")
ind_layout.addWidget(self._agc_mode_lbl)
self._agc_gain_lbl = QLabel("Gain: --")
self._agc_gain_lbl.setStyleSheet(
f"color: {DARK_INFO}; font-size: 14px;")
ind_layout.addWidget(self._agc_gain_lbl)
self._agc_peak_lbl = QLabel("Peak: --")
self._agc_peak_lbl.setStyleSheet(
f"color: {DARK_INFO}; font-size: 14px;")
ind_layout.addWidget(self._agc_peak_lbl)
self._agc_sat_total_lbl = QLabel("Total Saturations: 0")
self._agc_sat_total_lbl.setStyleSheet(
f"color: {DARK_SUCCESS}; font-size: 14px; font-weight: bold;")
ind_layout.addWidget(self._agc_sat_total_lbl)
ind_layout.addStretch()
layout.addWidget(indicator)
# ---- Matplotlib figure with 3 subplots -----------------------------
agc_fig = Figure(figsize=(12, 7), facecolor=DARK_BG)
agc_fig.subplots_adjust(
left=0.07, right=0.96, top=0.95, bottom=0.07,
hspace=0.32)
# Subplot 1: Gain history (4-bit, 0-15)
self._agc_ax_gain = agc_fig.add_subplot(3, 1, 1)
self._agc_ax_gain.set_facecolor(DARK_ACCENT)
self._agc_ax_gain.set_ylabel("Gain Code", color=DARK_FG, fontsize=10)
self._agc_ax_gain.set_title(
"FPGA Inner-Loop Gain (4-bit)", color=DARK_FG, fontsize=11)
self._agc_ax_gain.set_ylim(-0.5, 15.5)
self._agc_ax_gain.tick_params(colors=DARK_FG, labelsize=9)
self._agc_ax_gain.set_xlim(0, self._agc_history_len)
for spine in self._agc_ax_gain.spines.values():
spine.set_color(DARK_BORDER)
self._agc_gain_line, = self._agc_ax_gain.plot(
[], [], color="#89b4fa", linewidth=1.5, label="Gain")
self._agc_ax_gain.axhline(y=7.5, color=DARK_WARNING, linestyle="--",
linewidth=0.8, alpha=0.5, label="Midpoint")
self._agc_ax_gain.legend(
loc="upper right", fontsize=8,
facecolor=DARK_ACCENT, edgecolor=DARK_BORDER,
labelcolor=DARK_FG)
# Subplot 2: Peak magnitude (8-bit, 0-255)
self._agc_ax_peak = agc_fig.add_subplot(
3, 1, 2, sharex=self._agc_ax_gain)
self._agc_ax_peak.set_facecolor(DARK_ACCENT)
self._agc_ax_peak.set_ylabel("Peak Mag", color=DARK_FG, fontsize=10)
self._agc_ax_peak.set_title(
"ADC Peak Magnitude (8-bit)", color=DARK_FG, fontsize=11)
self._agc_ax_peak.set_ylim(-5, 260)
self._agc_ax_peak.tick_params(colors=DARK_FG, labelsize=9)
for spine in self._agc_ax_peak.spines.values():
spine.set_color(DARK_BORDER)
self._agc_peak_line, = self._agc_ax_peak.plot(
[], [], color=DARK_SUCCESS, linewidth=1.5, label="Peak")
self._agc_ax_peak.axhline(y=200, color=DARK_WARNING, linestyle="--",
linewidth=0.8, alpha=0.5,
label="Target (200)")
self._agc_ax_peak.axhspan(240, 255, alpha=0.15, color=DARK_ERROR,
label="Sat Zone")
self._agc_ax_peak.legend(
loc="upper right", fontsize=8,
facecolor=DARK_ACCENT, edgecolor=DARK_BORDER,
labelcolor=DARK_FG)
# Subplot 3: Saturation count per update (8-bit, 0-255)
self._agc_ax_sat = agc_fig.add_subplot(
3, 1, 3, sharex=self._agc_ax_gain)
self._agc_ax_sat.set_facecolor(DARK_ACCENT)
self._agc_ax_sat.set_ylabel("Sat Count", color=DARK_FG, fontsize=10)
self._agc_ax_sat.set_xlabel(
"Sample (newest right)", color=DARK_FG, fontsize=10)
self._agc_ax_sat.set_title(
"Saturation Events per Update", color=DARK_FG, fontsize=11)
self._agc_ax_sat.set_ylim(-1, 10)
self._agc_ax_sat.tick_params(colors=DARK_FG, labelsize=9)
for spine in self._agc_ax_sat.spines.values():
spine.set_color(DARK_BORDER)
self._agc_sat_line, = self._agc_ax_sat.plot(
[], [], color=DARK_ERROR, linewidth=1.0)
self._agc_sat_fill_artist = None
self._agc_ax_sat.legend(
loc="upper right", fontsize=8,
facecolor=DARK_ACCENT, edgecolor=DARK_BORDER,
labelcolor=DARK_FG)
self._agc_canvas = FigureCanvasQTAgg(agc_fig)
layout.addWidget(self._agc_canvas, stretch=1)
self._tabs.addTab(tab, "AGC Monitor")
# -----------------------------------------------------------------
# TAB 5: Diagnostics
# ----------------------------------------------------------------- # -----------------------------------------------------------------
def _create_diagnostics_tab(self): def _create_diagnostics_tab(self):
@@ -1313,13 +1142,7 @@ class RadarDashboard(QMainWindow):
self._simulator.stop() self._simulator.stop()
self._simulator = None self._simulator = None
self._demo_mode = False self._demo_mode = False
if not self._running: self._sb_mode.setText("Idle" if not self._running else "Live")
mode = "Idle"
elif isinstance(self._connection, ReplayConnection):
mode = "Replay"
else:
mode = "Live"
self._sb_mode.setText(mode)
self._sb_status.setText("Demo stopped") self._sb_status.setText("Demo stopped")
self._demo_btn_main.setText("Start Demo") self._demo_btn_main.setText("Start Demo")
self._demo_btn_map.setText("Start Demo") self._demo_btn_map.setText("Start Demo")
@@ -1366,7 +1189,7 @@ class RadarDashboard(QMainWindow):
@pyqtSlot(dict) @pyqtSlot(dict)
def _on_radar_stats(self, stats: dict): def _on_radar_stats(self, stats: dict):
self._last_stats = stats pass # Stats are displayed in _refresh_gui
@pyqtSlot(str) @pyqtSlot(str)
def _on_worker_error(self, msg: str): def _on_worker_error(self, msg: str):
@@ -1453,97 +1276,6 @@ class RadarDashboard(QMainWindow):
self._st_labels["t4"].setText( self._st_labels["t4"].setText(
f"T4 ADC: {'PASS' if flags & 0x10 else 'FAIL'}") f"T4 ADC: {'PASS' if flags & 0x10 else 'FAIL'}")
# AGC status readback
if hasattr(self, '_agc_labels'):
agc_str = "AUTO" if st.agc_enable else "MANUAL"
agc_color = DARK_SUCCESS if st.agc_enable else DARK_INFO
self._agc_labels["enable"].setStyleSheet(
f"color: {agc_color}; font-weight: bold;")
self._agc_labels["enable"].setText(f"AGC: {agc_str}")
self._agc_labels["gain"].setText(
f"Gain: {st.agc_current_gain}")
self._agc_labels["peak"].setText(
f"Peak: {st.agc_peak_magnitude}")
sat_color = DARK_ERROR if st.agc_saturation_count > 0 else DARK_INFO
self._agc_labels["sat"].setStyleSheet(
f"color: {sat_color}; font-weight: bold;")
self._agc_labels["sat"].setText(
f"Sat Count: {st.agc_saturation_count}")
# AGC Monitor tab visualization
self._update_agc_visualization(st)
def _update_agc_visualization(self, st: StatusResponse):
"""Push AGC metrics into ring buffers and redraw AGC Monitor charts.
Data is always accumulated (cheap), but matplotlib redraws are
throttled to ``_AGC_REDRAW_INTERVAL`` seconds to avoid saturating
the GUI event-loop when status packets arrive at 20 Hz.
"""
if not hasattr(self, '_agc_canvas'):
return
# Push data into ring buffers (always — O(1))
self._agc_gain_history.append(st.agc_current_gain)
self._agc_peak_history.append(st.agc_peak_magnitude)
self._agc_sat_history.append(st.agc_saturation_count)
# Update indicator labels (cheap Qt calls)
agc_str = "AUTO" if st.agc_enable else "MANUAL"
agc_color = DARK_SUCCESS if st.agc_enable else DARK_INFO
self._agc_mode_lbl.setStyleSheet(
f"color: {agc_color}; font-size: 16px; font-weight: bold;")
self._agc_mode_lbl.setText(f"AGC: {agc_str}")
self._agc_gain_lbl.setText(f"Gain: {st.agc_current_gain}")
self._agc_peak_lbl.setText(f"Peak: {st.agc_peak_magnitude}")
total_sat = sum(self._agc_sat_history)
if total_sat > 10:
sat_color = DARK_ERROR
elif total_sat > 0:
sat_color = DARK_WARNING
else:
sat_color = DARK_SUCCESS
self._agc_sat_total_lbl.setStyleSheet(
f"color: {sat_color}; font-size: 14px; font-weight: bold;")
self._agc_sat_total_lbl.setText(f"Total Saturations: {total_sat}")
# ---- Throttle matplotlib redraws ---------------------------------
now = time.monotonic()
if now - self._agc_last_redraw < self._AGC_REDRAW_INTERVAL:
return
self._agc_last_redraw = now
n = len(self._agc_gain_history)
xs = list(range(n))
# Update line plots
gain_data = list(self._agc_gain_history)
peak_data = list(self._agc_peak_history)
sat_data = list(self._agc_sat_history)
self._agc_gain_line.set_data(xs, gain_data)
self._agc_peak_line.set_data(xs, peak_data)
self._agc_sat_line.set_data(xs, sat_data)
# Update saturation fill
if self._agc_sat_fill_artist is not None:
self._agc_sat_fill_artist.remove()
if n > 0:
self._agc_sat_fill_artist = self._agc_ax_sat.fill_between(
xs, sat_data, color=DARK_ERROR, alpha=0.4)
else:
self._agc_sat_fill_artist = None
# Auto-scale saturation y-axis
max_sat = max(sat_data) if sat_data else 1
self._agc_ax_sat.set_ylim(-1, max(max_sat * 1.3, 5))
# Scroll x-axis
self._agc_ax_gain.set_xlim(max(0, n - self._agc_history_len), n)
self._agc_canvas.draw_idle()
# ===================================================================== # =====================================================================
# Position / coverage callbacks (map sidebar) # Position / coverage callbacks (map sidebar)
# ===================================================================== # =====================================================================
@@ -1677,7 +1409,7 @@ class RadarDashboard(QMainWindow):
str(self._frame_count), str(self._frame_count),
str(det), str(det),
str(gps_count), str(gps_count),
str(self._last_stats.get("errors", 0)), "0", # errors
f"{uptime:.0f}s", f"{uptime:.0f}s",
f"{frame_rate:.1f}/s", f"{frame_rate:.1f}/s",
] ]
@@ -1728,22 +1460,15 @@ class RadarDashboard(QMainWindow):
# ============================================================================= # =============================================================================
# Qt-compatible log handler (routes Python logging -> QTextEdit via signal) # Qt-compatible log handler (routes Python logging -> QTextEdit)
# ============================================================================= # =============================================================================
class _LogSignalBridge(QObject):
"""Thread-safe bridge: emits a Qt signal so the slot runs on the GUI thread."""
log_message = pyqtSignal(str)
class _QtLogHandler(logging.Handler): class _QtLogHandler(logging.Handler):
"""Sends log records to a QObject signal (safe from any thread).""" """Sends log records to a callback (called on the thread that emitted)."""
def __init__(self, bridge: _LogSignalBridge): def __init__(self, callback):
super().__init__() super().__init__()
self._bridge = bridge self._callback = callback
self.setFormatter(logging.Formatter( self.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-8s %(message)s", "%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
@@ -1752,6 +1477,6 @@ class _QtLogHandler(logging.Handler):
def emit(self, record): def emit(self, record):
try: try:
msg = self.format(record) msg = self.format(record)
self._bridge.log_message.emit(msg) self._callback(msg)
except RuntimeError: except RuntimeError:
pass pass
+40 -13
View File
@@ -13,9 +13,10 @@ and 'SET'...'END' binary settings protocol has been removed — it was
incompatible with the FPGA register interface. incompatible with the FPGA register interface.
""" """
import sys import importlib.util
import os
import logging import logging
import pathlib
import sys
from typing import ClassVar from typing import ClassVar
from .models import USB_AVAILABLE from .models import USB_AVAILABLE
@@ -24,18 +25,44 @@ if USB_AVAILABLE:
import usb.core import usb.core
import usb.util import usb.util
# Import production protocol layer — single source of truth for FPGA comms
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) def _load_radar_protocol():
from radar_protocol import ( # noqa: F401 — re-exported for v7 package """Load radar_protocol.py by absolute path without mutating sys.path."""
FT2232HConnection, mod_name = "radar_protocol"
ReplayConnection, if mod_name in sys.modules:
RadarProtocol, return sys.modules[mod_name]
Opcode, proto_path = pathlib.Path(__file__).resolve().parent.parent / "radar_protocol.py"
RadarAcquisition, if not proto_path.is_file():
RadarFrame, raise FileNotFoundError(
StatusResponse, f"radar_protocol.py not found at expected location: {proto_path}"
DataRecorder,
) )
spec = importlib.util.spec_from_file_location(mod_name, proto_path)
if spec is None or spec.loader is None:
raise ImportError(
f"Cannot create module spec for radar_protocol.py at {proto_path}"
)
mod = importlib.util.module_from_spec(spec)
# Register before exec so cyclic imports resolve correctly, but remove on failure
sys.modules[mod_name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(mod_name, None)
raise
return mod
_rp = _load_radar_protocol()
# Re-exported for the v7 package — single source of truth for FPGA comms
FT2232HConnection = _rp.FT2232HConnection
ReplayConnection = _rp.ReplayConnection
RadarProtocol = _rp.RadarProtocol
Opcode = _rp.Opcode
RadarAcquisition = _rp.RadarAcquisition
RadarFrame = _rp.RadarFrame
StatusResponse = _rp.StatusResponse
DataRecorder = _rp.DataRecorder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+2 -5
View File
@@ -64,7 +64,7 @@ class MapBridge(QObject):
@pyqtSlot(str) @pyqtSlot(str)
def logFromJS(self, message: str): def logFromJS(self, message: str):
logger.info(f"[JS] {message}") logger.debug(f"[JS] {message}")
@property @property
def is_ready(self) -> bool: def is_ready(self) -> bool:
@@ -578,10 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {{
return return
data = [t.to_dict() for t in targets] data = [t.to_dict() for t in targets]
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'") js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
logger.info( logger.debug("set_targets: %d targets", len(targets))
"set_targets: %d targets, JSON len=%d, first 200 chars: %s",
len(targets), len(js_payload), js_payload[:200],
)
self._status_label.setText(f"{len(targets)} targets tracked") self._status_label.setText(f"{len(targets)} targets tracked")
self._run_js(f"updateTargets('{js_payload}')") self._run_js(f"updateTargets('{js_payload}')")
+6 -1
View File
@@ -131,6 +131,10 @@ class RadarDataWorker(QThread):
self._byte_count = 0 self._byte_count = 0
self._error_count = 0 self._error_count = 0
# Monotonically increasing target ID — persisted across frames so map
# JS can key markers/trails by a stable ID.
self._next_target_id = 0
def stop(self): def stop(self):
self._running = False self._running = False
if self._acquisition: if self._acquisition:
@@ -244,7 +248,7 @@ class RadarDataWorker(QThread):
) )
target = RadarTarget( target = RadarTarget(
id=len(targets), id=self._next_target_id,
range=range_m, range=range_m,
velocity=velocity_ms, velocity=velocity_ms,
azimuth=azimuth, azimuth=azimuth,
@@ -254,6 +258,7 @@ class RadarDataWorker(QThread):
snr=snr, snr=snr,
timestamp=frame.timestamp, timestamp=frame.timestamp,
) )
self._next_target_id += 1
targets.append(target) targets.append(target)
# DBSCAN clustering # DBSCAN clustering
+1 -1
View File
@@ -6,7 +6,7 @@ status_packet.txt
*.vvp *.vvp
# Compiled C stub # Compiled C stub
stm32_stub stm32_settings_stub
# Python # Python
__pycache__/ __pycache__/
@@ -527,8 +527,6 @@ def parse_verilog_status_word_concats(
): ):
idx = int(m.group(1)) idx = int(m.group(1))
expr = m.group(2) expr = m.group(2)
# Strip single-line comments before normalizing whitespace
expr = re.sub(r'//[^\n]*', '', expr)
# Normalize whitespace # Normalize whitespace
expr = re.sub(r'\s+', ' ', expr).strip() expr = re.sub(r'\s+', ' ', expr).strip()
results[idx] = expr results[idx] = expr
@@ -86,10 +86,6 @@ module tb_cross_layer_ft2232h;
reg [4:0] status_self_test_flags; reg [4:0] status_self_test_flags;
reg [7:0] status_self_test_detail; reg [7:0] status_self_test_detail;
reg status_self_test_busy; reg status_self_test_busy;
reg [3:0] status_agc_current_gain;
reg [7:0] status_agc_peak_magnitude;
reg [7:0] status_agc_saturation_count;
reg status_agc_enable;
// ---- Clock generators ---- // ---- Clock generators ----
always #(CLK_PERIOD / 2) clk = ~clk; always #(CLK_PERIOD / 2) clk = ~clk;
@@ -134,11 +130,7 @@ module tb_cross_layer_ft2232h;
.status_range_mode (status_range_mode), .status_range_mode (status_range_mode),
.status_self_test_flags (status_self_test_flags), .status_self_test_flags (status_self_test_flags),
.status_self_test_detail(status_self_test_detail), .status_self_test_detail(status_self_test_detail),
.status_self_test_busy (status_self_test_busy), .status_self_test_busy (status_self_test_busy)
.status_agc_current_gain (status_agc_current_gain),
.status_agc_peak_magnitude (status_agc_peak_magnitude),
.status_agc_saturation_count(status_agc_saturation_count),
.status_agc_enable (status_agc_enable)
); );
// ---- Test bookkeeping ---- // ---- Test bookkeeping ----
@@ -196,10 +188,6 @@ module tb_cross_layer_ft2232h;
status_self_test_flags = 5'b00000; status_self_test_flags = 5'b00000;
status_self_test_detail = 8'd0; status_self_test_detail = 8'd0;
status_self_test_busy = 1'b0; status_self_test_busy = 1'b0;
status_agc_current_gain = 4'd0;
status_agc_peak_magnitude = 8'd0;
status_agc_saturation_count = 8'd0;
status_agc_enable = 1'b0;
repeat (6) @(posedge ft_clk); repeat (6) @(posedge ft_clk);
reset_n = 1; reset_n = 1;
ft_reset_n = 1; ft_reset_n = 1;
@@ -504,37 +492,6 @@ module tb_cross_layer_ft2232h;
check(cmd_opcode === 8'h27 && cmd_value === 16'h0003, check(cmd_opcode === 8'h27 && cmd_value === 16'h0003,
"Cmd 0x27: DC_NOTCH_WIDTH=3"); "Cmd 0x27: DC_NOTCH_WIDTH=3");
// AGC registers (0x28-0x2C)
send_command_ft2232h(8'h28, 8'h00, 8'h00, 8'h01); // AGC_ENABLE=1
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h28, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h28 && cmd_value === 16'h0001,
"Cmd 0x28: AGC_ENABLE=1");
send_command_ft2232h(8'h29, 8'h00, 8'h00, 8'hC8); // AGC_TARGET=200
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h29, 8'h00, 16'h00C8, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h29 && cmd_value === 16'h00C8,
"Cmd 0x29: AGC_TARGET=200");
send_command_ft2232h(8'h2A, 8'h00, 8'h00, 8'h02); // AGC_ATTACK=2
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h2A, 8'h00, 16'h0002, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h2A && cmd_value === 16'h0002,
"Cmd 0x2A: AGC_ATTACK=2");
send_command_ft2232h(8'h2B, 8'h00, 8'h00, 8'h03); // AGC_DECAY=3
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h2B, 8'h00, 16'h0003, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h2B && cmd_value === 16'h0003,
"Cmd 0x2B: AGC_DECAY=3");
send_command_ft2232h(8'h2C, 8'h00, 8'h00, 8'h06); // AGC_HOLDOFF=6
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h2C, 8'h00, 16'h0006, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h2C && cmd_value === 16'h0006,
"Cmd 0x2C: AGC_HOLDOFF=6");
// Self-test / status // Self-test / status
send_command_ft2232h(8'h30, 8'h00, 8'h00, 8'h01); // SELF_TEST_TRIGGER send_command_ft2232h(8'h30, 8'h00, 8'h00, 8'h01); // SELF_TEST_TRIGGER
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n", $fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
@@ -648,10 +605,6 @@ module tb_cross_layer_ft2232h;
status_self_test_flags = 5'b10101; status_self_test_flags = 5'b10101;
status_self_test_detail = 8'hA5; status_self_test_detail = 8'hA5;
status_self_test_busy = 1'b1; status_self_test_busy = 1'b1;
status_agc_current_gain = 4'd7;
status_agc_peak_magnitude = 8'd200;
status_agc_saturation_count = 8'd15;
status_agc_enable = 1'b1;
// Pulse status_request and capture bytes IN PARALLEL // Pulse status_request and capture bytes IN PARALLEL
// (same reason as Exercise B — write FSM starts before CDC wait ends) // (same reason as Exercise B — write FSM starts before CDC wait ends)
@@ -100,11 +100,6 @@ GROUND_TRUTH_OPCODES = {
0x25: ("host_cfar_enable", 1), 0x25: ("host_cfar_enable", 1),
0x26: ("host_mti_enable", 1), 0x26: ("host_mti_enable", 1),
0x27: ("host_dc_notch_width", 3), 0x27: ("host_dc_notch_width", 3),
0x28: ("host_agc_enable", 1),
0x29: ("host_agc_target", 8),
0x2A: ("host_agc_attack", 4),
0x2B: ("host_agc_decay", 4),
0x2C: ("host_agc_holdoff", 4),
0x30: ("host_self_test_trigger", 1), # pulse 0x30: ("host_self_test_trigger", 1), # pulse
0x31: ("host_status_request", 1), # pulse 0x31: ("host_status_request", 1), # pulse
0xFF: ("host_status_request", 1), # alias, pulse 0xFF: ("host_status_request", 1), # alias, pulse
@@ -129,11 +124,6 @@ GROUND_TRUTH_RESET_DEFAULTS = {
"host_cfar_enable": 0, "host_cfar_enable": 0,
"host_mti_enable": 0, "host_mti_enable": 0,
"host_dc_notch_width": 0, "host_dc_notch_width": 0,
"host_agc_enable": 0,
"host_agc_target": 200,
"host_agc_attack": 1,
"host_agc_decay": 1,
"host_agc_holdoff": 4,
} }
GROUND_TRUTH_PACKET_CONSTANTS = { GROUND_TRUTH_PACKET_CONSTANTS = {
@@ -614,10 +604,6 @@ class TestTier2VerilogCosim:
# status_self_test_flags = 5'b10101 = 21 # status_self_test_flags = 5'b10101 = 21
# status_self_test_detail = 0xA5 # status_self_test_detail = 0xA5
# status_self_test_busy = 1 # status_self_test_busy = 1
# status_agc_current_gain = 7
# status_agc_peak_magnitude = 200
# status_agc_saturation_count = 15
# status_agc_enable = 1
# Words 1-5 should be correct (no truncation bug) # Words 1-5 should be correct (no truncation bug)
assert sr.cfar_threshold == 0xABCD, f"cfar_threshold: 0x{sr.cfar_threshold:04X}" assert sr.cfar_threshold == 0xABCD, f"cfar_threshold: 0x{sr.cfar_threshold:04X}"
@@ -632,12 +618,6 @@ class TestTier2VerilogCosim:
assert sr.self_test_detail == 0xA5, f"self_test_detail: 0x{sr.self_test_detail:02X}" assert sr.self_test_detail == 0xA5, f"self_test_detail: 0x{sr.self_test_detail:02X}"
assert sr.self_test_busy == 1, f"self_test_busy: {sr.self_test_busy}" assert sr.self_test_busy == 1, f"self_test_busy: {sr.self_test_busy}"
# AGC fields (word 4)
assert sr.agc_current_gain == 7, f"agc_current_gain: {sr.agc_current_gain}"
assert sr.agc_peak_magnitude == 200, f"agc_peak_magnitude: {sr.agc_peak_magnitude}"
assert sr.agc_saturation_count == 15, f"agc_saturation_count: {sr.agc_saturation_count}"
assert sr.agc_enable == 1, f"agc_enable: {sr.agc_enable}"
# Word 0: stream_ctrl should be 5 (3'b101) # Word 0: stream_ctrl should be 5 (3'b101)
assert sr.stream_ctrl == 5, ( assert sr.stream_ctrl == 5, (
f"stream_ctrl: {sr.stream_ctrl} != 5. " f"stream_ctrl: {sr.stream_ctrl} != 5. "
@@ -0,0 +1,444 @@
"""
test_mem_validation.py Validate FPGA .mem files against AERIS-10 radar parameters.
Migrated from tb/cosim/validate_mem_files.py into CI-friendly pytest tests.
Checks:
1. Structural: line counts, hex format, value ranges for all 12+ .mem files
2. FFT twiddle files: bit-exact match against cos(2*pi*k/N) in Q15
3. Long chirp .mem files: frequency sweep, magnitude envelope, segment count
4. Short chirp .mem files: length, value range, non-zero content
5. Chirp vs independent model: phase shape agreement
6. Latency buffer LATENCY=3187 parameter validation
7. Chirp memory loader addressing: {segment_select, sample_addr} arithmetic
8. Seg3 zero-padding analysis
"""
import math
import os
import warnings
import pytest
# ============================================================================
# AERIS-10 System Parameters (independently derived from hardware specs)
# ============================================================================
F_CARRIER = 10.5e9 # 10.5 GHz carrier
C_LIGHT = 3.0e8
F_IF = 120e6 # IF frequency
CHIRP_BW = 20e6 # 20 MHz sweep bandwidth
FS_ADC = 400e6 # ADC sample rate
FS_SYS = 100e6 # System clock (100 MHz, after CIC 4x decimation)
T_LONG_CHIRP = 30e-6 # 30 us long chirp
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
CIC_DECIMATION = 4
FFT_SIZE = 1024
DOPPLER_FFT_SIZE = 16
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000 at 100 MHz
# Overlap-save parameters
OVERLAP_SAMPLES = 128
SEGMENT_ADVANCE = FFT_SIZE - OVERLAP_SAMPLES # 896
LONG_SEGMENTS = 4
# Path to FPGA RTL directory containing .mem files
MEM_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..', '9_2_FPGA'))
# Expected .mem file inventory
EXPECTED_MEM_FILES = {
'fft_twiddle_1024.mem': {'lines': 256, 'desc': '1024-pt FFT quarter-wave cos ROM'},
'fft_twiddle_16.mem': {'lines': 4, 'desc': '16-pt FFT quarter-wave cos ROM'},
'long_chirp_seg0_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 I'},
'long_chirp_seg0_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 Q'},
'long_chirp_seg1_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 I'},
'long_chirp_seg1_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 Q'},
'long_chirp_seg2_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 I'},
'long_chirp_seg2_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 Q'},
'long_chirp_seg3_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 I'},
'long_chirp_seg3_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 Q'},
'short_chirp_i.mem': {'lines': 50, 'desc': 'Short chirp I'},
'short_chirp_q.mem': {'lines': 50, 'desc': 'Short chirp Q'},
}
def read_mem_hex(filename: str) -> list[int]:
"""Read a .mem file, return list of integer values (16-bit signed)."""
path = os.path.join(MEM_DIR, filename)
values = []
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
continue
val = int(line, 16)
if val >= 0x8000:
val -= 0x10000
values.append(val)
return values
def compute_magnitudes(i_vals: list[int], q_vals: list[int]) -> list[float]:
"""Compute magnitude envelope from I/Q sample lists."""
return [math.sqrt(i * i + q * q) for i, q in zip(i_vals, q_vals, strict=False)]
def compute_inst_freq(i_vals: list[int], q_vals: list[int],
fs: float, mag_thresh: float = 5.0) -> list[float]:
"""Compute instantaneous frequency from I/Q via phase differencing."""
phases = []
for i_val, q_val in zip(i_vals, q_vals, strict=False):
if abs(i_val) > mag_thresh or abs(q_val) > mag_thresh:
phases.append(math.atan2(q_val, i_val))
else:
phases.append(None)
freq_estimates = []
for n in range(1, len(phases)):
if phases[n] is not None and phases[n - 1] is not None:
dp = phases[n] - phases[n - 1]
while dp > math.pi:
dp -= 2 * math.pi
while dp < -math.pi:
dp += 2 * math.pi
freq_estimates.append(dp * fs / (2 * math.pi))
return freq_estimates
# ============================================================================
# TEST 1: Structural validation — all .mem files exist with correct sizes
# ============================================================================
class TestStructural:
"""Verify every expected .mem file exists, has the right line count, and valid values."""
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
ids=EXPECTED_MEM_FILES.keys())
def test_file_exists(self, fname, info):
path = os.path.join(MEM_DIR, fname)
assert os.path.isfile(path), f"{fname} missing from {MEM_DIR}"
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
ids=EXPECTED_MEM_FILES.keys())
def test_line_count(self, fname, info):
vals = read_mem_hex(fname)
assert len(vals) == info['lines'], (
f"{fname}: got {len(vals)} data lines, expected {info['lines']}"
)
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
ids=EXPECTED_MEM_FILES.keys())
def test_value_range(self, fname, info):
vals = read_mem_hex(fname)
for i, v in enumerate(vals):
assert -32768 <= v <= 32767, (
f"{fname}[{i}]: value {v} out of 16-bit signed range"
)
# ============================================================================
# TEST 2: FFT Twiddle Factor Validation (bit-exact against cos formula)
# ============================================================================
class TestTwiddle:
"""Verify FFT twiddle .mem files match cos(2*pi*k/N) in Q15 to <=1 LSB."""
def test_twiddle_1024_bit_exact(self):
vals = read_mem_hex('fft_twiddle_1024.mem')
assert len(vals) == 256, f"Expected 256 quarter-wave entries, got {len(vals)}"
max_err = 0
worst_k = -1
for k in range(256):
angle = 2.0 * math.pi * k / 1024.0
expected = max(-32768, min(32767, round(math.cos(angle) * 32767.0)))
err = abs(vals[k] - expected)
if err > max_err:
max_err = err
worst_k = k
assert max_err <= 1, (
f"fft_twiddle_1024.mem: max error {max_err} LSB at k={worst_k} "
f"(got {vals[worst_k]}, expected "
f"{max(-32768, min(32767, round(math.cos(2*math.pi*worst_k/1024)*32767)))})"
)
def test_twiddle_16_bit_exact(self):
vals = read_mem_hex('fft_twiddle_16.mem')
assert len(vals) == 4, f"Expected 4 quarter-wave entries, got {len(vals)}"
max_err = 0
for k in range(4):
angle = 2.0 * math.pi * k / 16.0
expected = max(-32768, min(32767, round(math.cos(angle) * 32767.0)))
err = abs(vals[k] - expected)
if err > max_err:
max_err = err
assert max_err <= 1, f"fft_twiddle_16.mem: max error {max_err} LSB (tolerance: 1)"
def test_twiddle_1024_known_values(self):
"""Spot-check specific twiddle values against hand-calculated results."""
vals = read_mem_hex('fft_twiddle_1024.mem')
# k=0: cos(0) = 1.0 -> 32767
assert vals[0] == 32767, f"k=0: expected 32767, got {vals[0]}"
# k=128: cos(pi/4) = sqrt(2)/2 -> round(32767 * 0.7071) = 23170
expected_128 = round(math.cos(2 * math.pi * 128 / 1024) * 32767)
assert abs(vals[128] - expected_128) <= 1, (
f"k=128: expected ~{expected_128}, got {vals[128]}"
)
# k=255: last entry in quarter-wave table
expected_255 = round(math.cos(2 * math.pi * 255 / 1024) * 32767)
assert abs(vals[255] - expected_255) <= 1, (
f"k=255: expected ~{expected_255}, got {vals[255]}"
)
# ============================================================================
# TEST 3: Long Chirp .mem File Analysis
# ============================================================================
class TestLongChirp:
"""Validate long chirp .mem files show correct chirp characteristics."""
def test_total_sample_count(self):
"""4 segments x 1024 samples = 4096 total."""
all_i, all_q = [], []
for seg in range(4):
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
assert len(all_i) == 4096, f"Total I samples: {len(all_i)}, expected 4096"
assert len(all_q) == 4096, f"Total Q samples: {len(all_q)}, expected 4096"
def test_nonzero_magnitude(self):
"""Chirp should have significant non-zero content."""
all_i, all_q = [], []
for seg in range(4):
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
mags = compute_magnitudes(all_i, all_q)
max_mag = max(mags)
# Should use substantial dynamic range (at least 1000 out of 32767)
assert max_mag > 1000, f"Max magnitude {max_mag:.0f} is suspiciously low"
def test_frequency_sweep(self):
"""Chirp should show at least 0.5 MHz frequency sweep."""
all_i, all_q = [], []
for seg in range(4):
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
freq_est = compute_inst_freq(all_i, all_q, FS_SYS)
assert len(freq_est) > 100, "Not enough valid phase samples for frequency analysis"
f_range = max(freq_est) - min(freq_est)
assert f_range > 0.5e6, (
f"Frequency sweep {f_range / 1e6:.2f} MHz is too narrow "
f"(expected > 0.5 MHz for a chirp)"
)
def test_bandwidth_reasonable(self):
"""Chirp bandwidth should be within 50% of expected 20 MHz."""
all_i, all_q = [], []
for seg in range(4):
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
freq_est = compute_inst_freq(all_i, all_q, FS_SYS)
if not freq_est:
pytest.skip("No valid frequency estimates")
f_range = max(freq_est) - min(freq_est)
bw_error = abs(f_range - CHIRP_BW) / CHIRP_BW
if bw_error >= 0.5:
warnings.warn(
f"Bandwidth {f_range / 1e6:.2f} MHz differs from expected "
f"{CHIRP_BW / 1e6:.2f} MHz by {bw_error:.0%}",
stacklevel=1,
)
# ============================================================================
# TEST 4: Short Chirp .mem File Analysis
# ============================================================================
class TestShortChirp:
"""Validate short chirp .mem files."""
def test_sample_count_matches_duration(self):
"""0.5 us at 100 MHz = 50 samples."""
short_i = read_mem_hex('short_chirp_i.mem')
short_q = read_mem_hex('short_chirp_q.mem')
expected = int(T_SHORT_CHIRP * FS_SYS)
assert len(short_i) == expected, f"Short chirp I: {len(short_i)} != {expected}"
assert len(short_q) == expected, f"Short chirp Q: {len(short_q)} != {expected}"
def test_all_samples_nonzero(self):
"""Every sample in the short chirp should have non-trivial magnitude."""
short_i = read_mem_hex('short_chirp_i.mem')
short_q = read_mem_hex('short_chirp_q.mem')
mags = compute_magnitudes(short_i, short_q)
nonzero = sum(1 for m in mags if m > 1)
assert nonzero == len(short_i), (
f"Only {nonzero}/{len(short_i)} samples are non-zero"
)
# ============================================================================
# TEST 5: Chirp vs Independent Model (phase shape agreement)
# ============================================================================
class TestChirpVsModel:
"""Compare seg0 against independently generated chirp reference."""
def test_phase_shape_match(self):
"""Phase trajectory of .mem seg0 should match model within 0.5 rad."""
# Generate reference chirp independently from first principles
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
n_samples = FFT_SIZE # 1024
model_i, model_q = [], []
for n in range(n_samples):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
re_val = max(-32768, min(32767, round(32767 * 0.9 * math.cos(phase))))
im_val = max(-32768, min(32767, round(32767 * 0.9 * math.sin(phase))))
model_i.append(re_val)
model_q.append(im_val)
# Read seg0 from .mem
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
# Compare phase trajectories (shape match regardless of scaling)
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
phase_diffs = []
for mp, fp in zip(model_phases, mem_phases, strict=False):
d = mp - fp
while d > math.pi:
d -= 2 * math.pi
while d < -math.pi:
d += 2 * math.pi
phase_diffs.append(d)
max_phase_diff = max(abs(d) for d in phase_diffs)
assert max_phase_diff < 0.5, (
f"Max phase difference {math.degrees(max_phase_diff):.1f} deg "
f"exceeds 28.6 deg tolerance"
)
def test_magnitude_scaling(self):
"""Seg0 magnitude should be consistent with Q15 * 0.9 scaling."""
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
mags = compute_magnitudes(mem_i, mem_q)
max_mag = max(mags)
# Expected from 32767 * 0.9 scaling = ~29490
expected_max = 32767 * 0.9
# Should be at least 80% of expected (allows for different provenance)
if max_mag < expected_max * 0.8:
warnings.warn(
f"Seg0 max magnitude {max_mag:.0f} is below expected "
f"{expected_max:.0f} * 0.8 = {expected_max * 0.8:.0f}. "
f"The .mem files may have different provenance.",
stacklevel=1,
)
# ============================================================================
# TEST 6: Latency Buffer LATENCY=3187 Validation
# ============================================================================
class TestLatencyBuffer:
"""Validate latency buffer parameter constraints."""
LATENCY = 3187
BRAM_SIZE = 4096
def test_latency_within_bram(self):
assert self.LATENCY < self.BRAM_SIZE, (
f"LATENCY ({self.LATENCY}) must be < BRAM size ({self.BRAM_SIZE})"
)
def test_latency_in_reasonable_range(self):
"""LATENCY should be between 1000 and 4095 (empirically determined)."""
assert 1000 < self.LATENCY < 4095, (
f"LATENCY={self.LATENCY} outside reasonable range [1000, 4095]"
)
def test_read_ptr_no_overflow(self):
"""Address arithmetic for read_ptr after initial wrap must stay valid."""
min_read_ptr = self.BRAM_SIZE + 0 - self.LATENCY
assert 0 <= min_read_ptr < self.BRAM_SIZE, (
f"min_read_ptr after wrap = {min_read_ptr}, must be in [0, {self.BRAM_SIZE})"
)
# ============================================================================
# TEST 7: Chirp Memory Loader Addressing
# ============================================================================
class TestMemoryAddressing:
"""Validate {segment_select[1:0], sample_addr[9:0]} address mapping."""
@pytest.mark.parametrize("seg", range(4), ids=[f"seg{s}" for s in range(4)])
def test_segment_base_address(self, seg):
"""Concatenated address {seg, 10'b0} should equal seg * 1024."""
addr = (seg << 10) | 0
expected = seg * 1024
assert addr == expected, (
f"Seg {seg}: {{seg[1:0], 10'b0}} = {addr}, expected {expected}"
)
@pytest.mark.parametrize("seg", range(4), ids=[f"seg{s}" for s in range(4)])
def test_segment_end_address(self, seg):
"""Concatenated address {seg, 10'h3FF} should equal seg * 1024 + 1023."""
addr = (seg << 10) | 1023
expected = seg * 1024 + 1023
assert addr == expected, (
f"Seg {seg}: {{seg[1:0], 10'h3FF}} = {addr}, expected {expected}"
)
def test_full_address_space(self):
"""4 segments x 1024 = 4096 addresses, covering full 12-bit range."""
all_addrs = set()
for seg in range(4):
for sample in range(1024):
all_addrs.add((seg << 10) | sample)
assert len(all_addrs) == 4096
assert min(all_addrs) == 0
assert max(all_addrs) == 4095
# ============================================================================
# TEST 8: Seg3 Zero-Padding Analysis
# ============================================================================
class TestSeg3Padding:
"""Analyze seg3 content — chirp is 3000 samples but 4 segs x 1024 = 4096 slots."""
def test_seg3_content_analysis(self):
"""Seg3 should either be full (4096-sample chirp) or have trailing zeros."""
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
mags = compute_magnitudes(seg3_i, seg3_q)
# Count trailing zeros
trailing_zeros = 0
for m in reversed(mags):
if m < 2:
trailing_zeros += 1
else:
break
nonzero = sum(1 for m in mags if m > 2)
if nonzero == 1024:
# .mem files encode 4096 chirp samples, not 3000
# This means the chirp duration used for .mem generation differs
actual_samples = 4 * 1024
actual_us = actual_samples / FS_SYS * 1e6
warnings.warn(
f"Chirp in .mem files is {actual_samples} samples ({actual_us:.1f} us), "
f"not {LONG_CHIRP_SAMPLES} samples ({T_LONG_CHIRP * 1e6:.1f} us). "
f"The .mem files use a different chirp duration than the system parameter.",
stacklevel=1,
)
elif trailing_zeros > 100:
# Some zero-padding at end — chirp ends partway through seg3
effective_chirp_end = 3072 + (1024 - trailing_zeros)
assert effective_chirp_end <= 4096, "Chirp end calculation overflow"
+21 -8
View File
@@ -39,6 +39,7 @@ try:
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
except ImportError: except ImportError:
print("ERROR: pyserial not installed. Run: pip install pyserial", file=sys.stderr)
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -94,9 +95,12 @@ def list_ports():
"""Print available serial ports.""" """Print available serial ports."""
ports = serial.tools.list_ports.comports() ports = serial.tools.list_ports.comports()
if not ports: if not ports:
print("No serial ports found.")
return return
for _p in sorted(ports, key=lambda x: x.device): print(f"{'Port':<30} {'Description':<40} {'HWID'}")
pass print("-" * 100)
for p in sorted(ports, key=lambda x: x.device):
print(f"{p.device:<30} {p.description:<40} {p.hwid}")
def auto_detect_port(): def auto_detect_port():
@@ -224,7 +228,7 @@ class CaptureStats:
# Main capture loop # Main capture loop
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def capture(port, baud, log_file, filter_subsys, errors_only, _use_color): def capture(port, baud, log_file, filter_subsys, errors_only, use_color):
"""Open serial port and capture DIAG output.""" """Open serial port and capture DIAG output."""
stats = CaptureStats() stats = CaptureStats()
running = True running = True
@@ -245,15 +249,18 @@ def capture(port, baud, log_file, filter_subsys, errors_only, _use_color):
stopbits=serial.STOPBITS_ONE, stopbits=serial.STOPBITS_ONE,
timeout=0.1, # 100ms read timeout for responsive Ctrl-C timeout=0.1, # 100ms read timeout for responsive Ctrl-C
) )
except serial.SerialException: except serial.SerialException as e:
print(f"ERROR: Could not open {port}: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
print(f"Connected to {port} at {baud} baud")
if log_file: if log_file:
pass print(f"Logging to {log_file}")
if filter_subsys: if filter_subsys:
pass print(f"Filter: {', '.join(sorted(filter_subsys))}")
if errors_only: if errors_only:
pass print("Mode: errors/warnings only")
print("Press Ctrl-C to stop.\n")
if log_file: if log_file:
os.makedirs(os.path.dirname(log_file), exist_ok=True) os.makedirs(os.path.dirname(log_file), exist_ok=True)
@@ -300,13 +307,15 @@ def capture(port, baud, log_file, filter_subsys, errors_only, _use_color):
# Terminal display respects filters # Terminal display respects filters
if should_display(line, filter_subsys, errors_only): if should_display(line, filter_subsys, errors_only):
pass sys.stdout.write(colorize(line, use_color) + "\n")
sys.stdout.flush()
if flog: if flog:
flog.write(f"\n{stats.summary()}\n") flog.write(f"\n{stats.summary()}\n")
finally: finally:
ser.close() ser.close()
print(stats.summary())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -369,6 +378,10 @@ def main():
if not port: if not port:
port = auto_detect_port() port = auto_detect_port()
if not port: if not port:
print(
"ERROR: No serial port detected. Use -p to specify, or --list to see ports.",
file=sys.stderr,
)
sys.exit(1) sys.exit(1)
# Resolve log file # Resolve log file
+6 -2
View File
@@ -46,6 +46,10 @@ select = [
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
# Tests: allow unused args (fixtures), prints (debugging), commented code (examples) # Tests: allow unused args (fixtures), prints (debugging), commented code (examples)
"test_*.py" = ["ARG", "T20", "ERA"] "**/test_*.py" = ["ARG", "T20", "ERA"]
# Re-export modules: unused imports are intentional # Re-export modules: unused imports are intentional
"v7/hardware.py" = ["F401"] "**/v7/hardware.py" = ["F401"]
# CLI tools & cosim scripts: print() is the intentional output mechanism
"**/uart_capture.py" = ["T20"]
"**/tb/cosim/**" = ["T20", "ERA", "ARG", "E501"]
"**/tb/gen_mf_golden_ref.py" = ["T20", "ERA"]