feat: hybrid AGC (FPGA phases 1-3 + GUI phase 6) with timing fix

FPGA:
- rx_gain_control.v rewritten: per-frame peak/saturation tracking,
  auto-shift AGC with attack/decay/holdoff, signed gain -7 to +7
- New registers 0x28-0x2C (agc_enable/target/attack/decay/holdoff)
- status_words[4] carries AGC metrics (gain, peak, sat_count, enable)
- DIG_5 GPIO outputs saturation flag for STM32 outer loop
- Both USB interfaces (FT601 + FT2232H) updated with AGC status ports

Timing fix (WNS +0.001ns -> +0.045ns, 45x improvement):
- CIC max_fanout 4->16 on valid pipeline registers
- +200ps setup uncertainty on 400MHz domain
- ExtraNetDelay_high placement + AggressiveExplore routing

GUI:
- AGC opcodes + status parsing in radar_protocol.py
- AGC control groups in both tkinter and V7 PyQt dashboards
- 11 new AGC tests (103/103 GUI tests pass)

Cross-layer:
- AGC opcodes/defaults/status assertions added (29/29 pass)
- contract_parser.py: fixed comment stripping in concat parser

All tests green: 25 FPGA + 103 GUI + 29 cross-layer = 157 pass
This commit is contained in:
Jason
2026-04-13 19:24:11 +05:45
parent 23b2beee53
commit ffba27a10a
19 changed files with 863 additions and 69 deletions
+206 -4
View File
@@ -38,10 +38,20 @@ reg signed [15:0] data_q_in;
reg valid_in;
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_q_out;
wire valid_out;
wire [7:0] saturation_count;
wire [7:0] peak_magnitude;
wire [3:0] current_gain;
rx_gain_control dut (
.clk(clk),
@@ -50,10 +60,18 @@ rx_gain_control dut (
.data_q_in(data_q_in),
.valid_in(valid_in),
.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_q_out(data_q_out),
.valid_out(valid_out),
.saturation_count(saturation_count)
.saturation_count(saturation_count),
.peak_magnitude(peak_magnitude),
.current_gain(current_gain)
);
// ---------------------------------------------------------------
@@ -105,6 +123,13 @@ initial begin
data_q_in = 0;
valid_in = 0;
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);
reset_n = 1;
@@ -152,6 +177,9 @@ initial begin
"T3.1: I saturated to +32767");
check(data_q_out == -16'sd32768,
"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,
"T3.3: Saturation counter = 1 (both channels clipped counts as 1)");
@@ -173,6 +201,9 @@ initial begin
"T4.1: I attenuated 4000>>2 = 1000");
check(data_q_out == -16'sd500,
"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,
"T4.3: No saturation on right shift");
@@ -315,13 +346,18 @@ initial begin
valid_in = 1'b0;
@(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,
"T11.1: Counter capped at 255 after 256 saturating samples");
// One more sample should stay at 255
// One more sample + frame boundary should still be capped at 1 (new frame)
send_sample(16'sd20000, 16'sd20000);
check(saturation_count == 8'd255,
"T11.2: Counter stays at 255 (no wrap)");
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd1,
"T11.2: New frame counter = 1 (single sample)");
// ---------------------------------------------------------------
// TEST 12: Reset clears everything
@@ -329,6 +365,8 @@ initial begin
$display("");
$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;
repeat (2) @(posedge clk);
reset_n = 1;
@@ -342,6 +380,170 @@ initial begin
"T12.3: valid_out cleared on reset");
check(saturation_count == 8'd0,
"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)");
// ---------------------------------------------------------------
// SUMMARY
+24 -3
View File
@@ -79,6 +79,12 @@ module tb_usb_data_interface;
reg [7:0] status_self_test_detail;
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)
always #(CLK_PERIOD / 2) clk = ~clk;
always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in;
@@ -134,7 +140,13 @@ module tb_usb_data_interface;
// Self-test status readback
.status_self_test_flags (status_self_test_flags),
.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
@@ -194,6 +206,10 @@ module tb_usb_data_interface;
status_self_test_flags = 5'b00000;
status_self_test_detail = 8'd0;
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);
reset_n = 1;
// Wait enough cycles for stream_control CDC to propagate
@@ -902,6 +918,11 @@ module tb_usb_data_interface;
status_self_test_flags = 5'b11111;
status_self_test_detail = 8'hA5;
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)
@(posedge clk);
@@ -958,8 +979,8 @@ module tb_usb_data_interface;
"Status readback: word 2 = {guard, short_chirp}");
check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32},
"Status readback: word 3 = {short_listen, 0, chirps_per_elev}");
check(uut.status_words[4] === {30'd0, 2'b10},
"Status readback: word 4 = range_mode=2'b10");
check(uut.status_words[4] === {4'd5, 8'd180, 8'd12, 1'b1, 9'd0, 2'b10},
"Status readback: word 4 = {agc_gain=5, peak=180, sat=12, en=1, range_mode=2}");
// 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}
check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111},