fix(fpga): pre-bringup RTL hardening + test-suite hardening

RTL (P0 pre-bringup findings R-1/R-2/R-3/R-5/R-6):

- mti_canceller: add use_long_chirp input and waveform-boundary mute
  so the long->short transition in mode 01 no longer subtracts across
  heterogeneous waveforms (R-1). Prev buffer is overwritten in-flight
  at the boundary so the next same-waveform chirp subtracts cleanly.
- ad9484_interface_400m: 2FF sync of mmcm_locked into the 400 MHz
  domain before gating reset_n_gated (R-6).
- cic_decimator_4x_enhanced: correct max_fanout narrative (R-3).
- ad9484_interface_400m: strip stale pblock comment, note 3.0 ns
  max_delay instead (R-2).
- mti_canceller / doppler_processor: 200T-20km WARNING banners
  flagging the broken 4096-bin path (R-5). 9-bit BRAM address aliases
  silently until rewritten.
- adc_clk_mmcm.xdc: relax set_max_delay from 2.700 -> 3.000 ns,
  closes WNS with headroom on 50T build.
- radar_receiver_final: wire use_long_chirp into mti_inst.

Architecture-bump finalization (2048-pt range FFT, 512 range bins,
32 Doppler bins -> 16384 output cells per frame):

- tb/cosim/radar_scene.py: FFT_SIZE 1024 -> 2048, RANGE_BINS 64 -> 512.
- tb/gen_mf_golden_ref.py: N 1024 -> 2048.
- Regenerate all affected hex goldens (MF cases 1-4, Doppler inputs
  + py goldens, receiver integration golden_doppler.mem 2048 -> 16384).
- tb_radar_receiver_final: widen range_bin_out 6 -> 9 bits, bump
  GOLDEN_ENTRIES 2048 -> 16384, expand bitmaps/arrays to 512 bins,
  update all check messages and thresholds.
- tb_mti_canceller, tb_fullchain_mti_cfar_realdata: tie/pass
  use_long_chirp so compile still works after RTL port add.

Test-suite hardening (coverage audit findings):

- tb_mti_canceller T12: 10 new assertions exercising R-1 waveform-
  boundary mute across a long/long/short/short/long sequence. Catches
  a regression that re-enables subtraction across the boundary.
- tb_fir_lowpass: replace tautological check(1'b1, ...) on coefficient
  symmetry with a real hierarchical check coeff[k]===coeff[31-k];
  replace always-pass overflow check with a well-driven (not X/Z)
  assertion on filter_overflow.
- tb_matched_filter_processing_chain: replace three always-pass peak-
  bin placeholders with peak-to-mean-|out| > 2x ratio checks (catches
  flat/zero output that the old tautologies silently accepted).
- tb_cdc_modules M2: replace always-pass narrow-pulse check with a
  well-defined-output assertion on the synchronizer.
- tb_nco_400m: replace always-pass freq-switch check with a swing +
  no-X assertion across 200 post-switch samples.
- tb_system_e2e G12.1: replace check(1, ...) with test_num > 20 so
  it catches a stalled TB that skipped prior groups.
- tb_multiseg_cosim TEST 4: replace always-pass placeholder with a
  bitmap that asserts segment_request visited all 4 values.
- tb_mf_chain_synth and tb_fullchain_mti_cfar_realdata: add DEPRECATED
  headers plus \$fatal guards (ifndef ALLOW_STALE_*) so they cannot
  be silently re-enabled in CI with stale 1024-bin goldens against
  current 2048-pt RTL.

Regression: 32 passed, 0 failed. MTI TB grew 30 -> 39 checks;
receiver integration grew 17 -> 18 checks with 16384/16384 golden
match at tolerance +/- 2 LSB.
This commit is contained in:
Jason
2026-04-22 13:23:38 +05:45
parent c668652ba8
commit 8865e9a0ef
54 changed files with 204966 additions and 35813 deletions
+24 -1
View File
@@ -118,6 +118,14 @@ endgenerate
// frequency-matched. This single register stage transfers from IOB (BUFIO)
// to fabric (BUFG) with guaranteed timing.
// ============================================================================
// Timing on the BUFIOBUFG CDC edge is governed by a 3.000 ns
// set_max_delay in constraints/adc_clk_mmcm.xdc (1.2× the 2.500 ns period),
// which leaves the placer free and still fits inside the ADC data-valid
// window. IOB=TRUE and a pblock around the IDDR column were both tried
// and rejected: IOB packing fails because the BUFG clock on these
// capture FFs can't share the ILOGIC clock mux with the BUFIO-clocked
// IDDR, and the pblock pulled fanout logic into the I/O region and
// triggered router congestion on 51 unrelated paths.
reg [7:0] adc_data_rise_bufg;
reg [7:0] adc_data_fall_bufg;
@@ -139,9 +147,24 @@ reg dco_phase;
//
// mmcm_locked gates de-assertion: the 400 MHz domain stays in reset until
// the MMCM PLL has locked and the jitter-cleaned clock is stable.
// mmcm_locked is a combinational MMCME2 output and can glitch; sync it
// into the 400 MHz domain with a 2-FF chain before using it in the
// async-reset branch below so a LOCKED blip doesn't asynchronously
// re-reset the domain. The chain is itself async-reset by the raw
// reset_n so it forces reset_n_gated=0 at power-up (no valid adc_dco
// edges exist yet to clock the sync chain).
(* ASYNC_REG = "TRUE" *) reg [1:0] mmcm_locked_sync_400m;
always @(posedge adc_dco_buffered or negedge reset_n) begin
if (!reset_n)
mmcm_locked_sync_400m <= 2'b00;
else
mmcm_locked_sync_400m <= {mmcm_locked_sync_400m[0], mmcm_locked};
end
wire mmcm_locked_400m = mmcm_locked_sync_400m[1];
(* ASYNC_REG = "TRUE" *) reg [1:0] reset_sync_400m;
wire reset_n_400m;
wire reset_n_gated = reset_n & mmcm_locked;
wire reset_n_gated = reset_n & mmcm_locked_400m;
always @(posedge adc_dco_buffered or negedge reset_n_gated) begin
if (!reset_n_gated)
@@ -74,7 +74,15 @@ localparam COMB_WIDTH = 28;
// DSP output) = 4 cycles at 400 MHz = 10 ns.
// Negligible vs system reset assertion duration.
// ----------------------------------------------------------------------------
(* max_fanout = 25 *) reg reset_h = 1'b1; // INIT=1'b1: registers start in reset state on power-up
// max_fanout = 16 (reduced from 25): forces Vivado to replicate reset_h into
// ~45 copies instead of ~28 across the DSP48E1 RST* + fabric loads, so each
// replica drives a smaller cluster and places closer to its loads. Kept
// because it shortens the longest reset_h_reg_rep__*/C → integrator_*/RSTP
// route on a 95%-packed XC7A50T, but NOTE: the 52 ps WNS miss that first
// motivated this tweak was ultimately closed by relaxing the BUFIO↔MMCM
// set_max_delay from 2.700 ns to 3.000 ns in constraints/adc_clk_mmcm.xdc,
// not by the fan-out change alone.
(* max_fanout = 16 *) reg reset_h = 1'b1; // INIT=1'b1: registers start in reset state on power-up
always @(posedge clk) reset_h <= ~reset_n;
// Sign-extended input for integrator_0 C port (48-bit)
@@ -30,13 +30,25 @@
# into the MMCM BUFG domain in ad9484_interface_400m.v.
# These clocks are frequency-matched and phase-related (MMCM is locked to
# adc_dco_p), so the single register transfer is safe. We use max_delay
# (one period) to ensure the tools verify the transfer fits within one cycle
# to ensure the tools verify the transfer fits within the valid data window
# without over-constraining with full inter-clock setup/hold analysis.
#
# 3.000 ns = 1.2× the 2.500 ns clock period. On a 95%-packed XC7A50T the
# placer cannot keep the capture FFs (adc_data_{rise,fall}_bufg) next to
# the IDDR column (observed routes ~2.28 ns IDDR → SLICE_X0Y123); the old
# 2.700 ns window failed by ~120 ps. A pblock attempt pulled fanout logic
# into the I/O region and triggered router-congestion on 51 other paths,
# confirming that the right lever is the constraint, not placement.
# 3.000 ns is safe: (a) IDDR Q outputs are valid for ~1 full adc_dco_p
# period, (b) MMCM-locked phase relation keeps launch/capture edges
# deterministic, (c) 0 logic levels on the datapath, (d) even with worst-
# case route and skew, 300 ps of extra budget still fits inside the ADC
# output-valid window (AD9484 datasheet: data valid 100 ps after DCO edge).
set_max_delay -datapath_only -from [get_clocks adc_dco_p] \
-to [get_clocks clk_mmcm_out0] 2.700
-to [get_clocks clk_mmcm_out0] 3.000
set_max_delay -datapath_only -from [get_clocks clk_mmcm_out0] \
-to [get_clocks adc_dco_p] 2.700
-to [get_clocks adc_dco_p] 3.000
# --------------------------------------------------------------------------
# CDC: MMCM output domain ↔ other clock domains
+13
View File
@@ -34,6 +34,19 @@
`include "radar_params.vh"
// ----------------------------------------------------------------------------
// !!! 200T 20 km MODE BROKEN — FIX BEFORE 200T BRING-UP !!!
// RANGE_BINS and the range_bin output port default to `RP_NUM_RANGE_BINS
// (512) / `RP_RANGE_BIN_BITS (9). In 20 km mode the upstream pipeline
// emits `RP_OUTPUT_RANGE_BINS_20KM = 4096 bins/chirp, which the internal
// range-bin BRAMs and address counters here cannot represent — bins
// 512..4095 alias onto bins 0..511 and the Doppler FFT collects a
// scrambled slow-time vector per aliased range cell.
// Latent on XC7A50T (SUPPORT_LONG_RANGE undefined → 3 km only); will
// corrupt all 20 km output on XC7A200T. Before 200T bring-up: scale
// RANGE_BINS with `RP_MAX_OUTPUT_BINS, widen range_bin, and resize the
// per-range chirp buffers, or route 20 km mode around this block.
// ----------------------------------------------------------------------------
module doppler_processor_optimized #(
parameter DOPPLER_FFT_SIZE = `RP_DOPPLER_FFT_SIZE, // 16
parameter RANGE_BINS = `RP_NUM_RANGE_BINS, // 512
+65 -17
View File
@@ -43,6 +43,19 @@
`include "radar_params.vh"
// ----------------------------------------------------------------------------
// !!! 200T 20 km MODE BROKEN — FIX BEFORE 200T BRING-UP !!!
// The prev-chirp BRAM buffer is sized to NUM_RANGE_BINS (512) and the
// range_bin_in port is 9 bits (`RP_RANGE_BIN_BITS). In 20 km mode the
// upstream range_bin_decimator emits `RP_OUTPUT_RANGE_BINS_20KM = 4096
// bins per chirp (8 segments × 512 decimated bins), which aliases into
// the 9-bit address space and collapses bins 512..4095 onto bins 0..511.
// On XC7A50T this is latent (SUPPORT_LONG_RANGE undefined → 3 km only),
// but on XC7A200T with SUPPORT_LONG_RANGE the 20 km data path will
// silently corrupt every range cell above 3 km.
// Fix before 200T bring-up: scale NUM_RANGE_BINS/range_bin width with
// `RP_MAX_OUTPUT_BINS, or gate MTI off entirely in 20 km mode.
// ----------------------------------------------------------------------------
module mti_canceller #(
parameter NUM_RANGE_BINS = `RP_NUM_RANGE_BINS, // 512
parameter DATA_WIDTH = `RP_DATA_WIDTH // 16
@@ -65,6 +78,14 @@ module mti_canceller #(
// ========== CONFIGURATION ==========
input wire mti_enable, // 1=MTI active, 0=pass-through
// Current chirp's waveform selector (from radar_mode_controller). Used
// to mute MTI output across the long↔short chirp boundary in range
// mode 01 (long-range interleave) — without this, the first chirp of
// a new waveform subtracts the previous waveform's range profile,
// injecting a per-range-bin impulse into slow-time sample 0 of the
// new Doppler sub-frame that spreads across all Doppler bins.
input wire use_long_chirp,
// ========== STATUS ==========
output reg mti_first_chirp, // 1 during first chirp (output muted)
@@ -92,20 +113,23 @@ reg signed [DATA_WIDTH-1:0] range_i_d1, range_q_d1;
reg range_valid_d1;
reg [`RP_RANGE_BIN_BITS-1:0] range_bin_d1;
reg mti_enable_d1;
reg use_long_chirp_d1;
always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin
range_i_d1 <= {DATA_WIDTH{1'b0}};
range_q_d1 <= {DATA_WIDTH{1'b0}};
range_valid_d1 <= 1'b0;
range_bin_d1 <= {`RP_RANGE_BIN_BITS{1'b0}};
mti_enable_d1 <= 1'b0;
range_i_d1 <= {DATA_WIDTH{1'b0}};
range_q_d1 <= {DATA_WIDTH{1'b0}};
range_valid_d1 <= 1'b0;
range_bin_d1 <= {`RP_RANGE_BIN_BITS{1'b0}};
mti_enable_d1 <= 1'b0;
use_long_chirp_d1 <= 1'b0;
end else begin
range_i_d1 <= range_i_in;
range_q_d1 <= range_q_in;
range_valid_d1 <= range_valid_in;
range_bin_d1 <= range_bin_in;
mti_enable_d1 <= mti_enable;
range_i_d1 <= range_i_in;
range_q_d1 <= range_q_in;
range_valid_d1 <= range_valid_in;
range_bin_d1 <= range_bin_in;
mti_enable_d1 <= mti_enable;
use_long_chirp_d1 <= use_long_chirp;
end
end
@@ -139,6 +163,14 @@ end
// Track whether we have valid previous data
reg has_previous;
// Waveform of the chirp whose profile currently lives in prev_i/prev_q.
// Latched at end-of-chirp when we mark has_previous=1. Compared against
// the incoming chirp's waveform at its first bin (range_bin_d1 == 0) to
// detect a long↔short transition and re-mute.
reg prev_chirp_was_long;
wire waveform_changed = has_previous
&& (use_long_chirp_d1 != prev_chirp_was_long);
// ============================================================================
// MTI PROCESSING (operates on d1 pipeline stage + BRAM read data)
// ============================================================================
@@ -182,6 +214,7 @@ always @(posedge clk or negedge reset_n) begin
range_bin_out <= {`RP_RANGE_BIN_BITS{1'b0}};
has_previous <= 1'b0;
mti_first_chirp <= 1'b1;
prev_chirp_was_long <= 1'b0;
mti_saturation_count <= 8'd0;
end else begin
// Count saturated MTI-active samples (F-6.3). Clamp at 0xFF.
@@ -206,24 +239,39 @@ always @(posedge clk or negedge reset_n) begin
// Reset first-chirp state when MTI is disabled
has_previous <= 1'b0;
mti_first_chirp <= 1'b1;
end else if (!has_previous) begin
// First chirp after enable: mute output (no subtraction possible).
// Still emit valid=1 with zero data so Doppler processor gets
// the expected number of samples per frame.
end else if (!has_previous || waveform_changed) begin
// No valid previous chirp to subtract from — either the very
// first chirp after reset/enable, or the long↔short boundary
// in range_mode=01 where the prev buffer holds a different
// waveform's profile. Mute output (emit zeros with valid=1
// so Doppler still sees the expected chirp count), overwrite
// prev_i/prev_q as this chirp streams through the write port,
// then re-arm at end-of-chirp with the CURRENT waveform tag.
range_i_out <= {DATA_WIDTH{1'b0}};
range_q_out <= {DATA_WIDTH{1'b0}};
range_valid_out <= 1'b1;
mti_first_chirp <= 1'b1;
// After last range bin of first chirp, mark previous as valid
// After last range bin of this chirp, the prev buffer now
// holds a full copy of THIS chirp's profile — arm for the
// next chirp and remember which waveform was written.
if (range_bin_d1 == NUM_RANGE_BINS - 1) begin
has_previous <= 1'b1;
mti_first_chirp <= 1'b0;
has_previous <= 1'b1;
mti_first_chirp <= 1'b0;
prev_chirp_was_long <= use_long_chirp_d1;
end
end else begin
// Normal MTI: subtract previous from current
range_i_out <= diff_i_sat;
range_q_out <= diff_q_sat;
range_valid_out <= 1'b1;
// Refresh the waveform tag at end-of-chirp so the compare
// on the next chirp stays correct (same-waveform runs are
// the common case and the tag must track them).
if (range_bin_d1 == NUM_RANGE_BINS - 1) begin
prev_chirp_was_long <= use_long_chirp_d1;
end
end
end
end
@@ -485,6 +485,7 @@ mti_canceller #(
.range_valid_out(mti_range_valid),
.range_bin_out(mti_range_bin),
.mti_enable(host_mti_enable),
.use_long_chirp(use_long_chirp),
.mti_first_chirp(mti_first_chirp),
.mti_saturation_count(mti_saturation_count_out)
);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -52,9 +52,11 @@ T_PRI_SHORT = 175e-6 # staggered short-PRI sub-frame
N_SAMPLES_LISTEN = int(T_LISTEN_LONG * FS_ADC) # 54800 samples
# Processing chain
# Updated for 2048-pt range FFT + 4x decimation → 512 range bins per chirp.
# Must stay in sync with radar_params.vh: RP_FFT_SIZE=2048, RP_NUM_RANGE_BINS=512.
CIC_DECIMATION = 4
FFT_SIZE = 1024
RANGE_BINS = 64
FFT_SIZE = 2048
RANGE_BINS = 512
DOPPLER_FFT_SIZE = 16 # Per sub-frame
DOPPLER_TOTAL_BINS = 32 # Total output bins (2 sub-frames x 16)
CHIRPS_PER_SUBFRAME = 16
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -22,7 +22,7 @@ Usage:
import os
import numpy as np
N = 1024 # FFT length
N = 2048 # FFT length — matches `RP_FFT_SIZE` (radar_params.vh)
def to_q15(value):
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -1,15 +1,15 @@
========================================================================
Matched Filter Golden Reference Summary
Operation: output = IFFT( FFT(signal) * conj(FFT(reference)) )
FFT length: 1024
FFT length: 2048
========================================================================
------------------------------------------------------------------------
Case 1: DC autocorrelation: signal=ref=DC(I=0x1000,Q=0). Expected: large peak at bin 0, zero elsewhere. Peak will saturate to 32767 due to 16-bit clamp.
------------------------------------------------------------------------
Peak bin: 0
Peak magnitude (float):17179869184.000000
Peak I (float): 17179869184.000000
Peak magnitude (float):34359738368.000000
Peak I (float): 34359738368.000000
Peak Q (float): 0.000000
Peak I (quantized): 32767
Peak Q (quantized): 0
@@ -25,8 +25,8 @@ Case 1: DC autocorrelation: signal=ref=DC(I=0x1000,Q=0). Expected: large peak at
Case 2: Tone autocorrelation: signal=ref=tone(bin 5, amp 8000). Expected: peak at bin 0 (autocorrelation peak at zero lag).
------------------------------------------------------------------------
Peak bin: 0
Peak magnitude (float):65536183223.999985
Peak I (float): 65536183223.999985
Peak magnitude (float):131072740064.000046
Peak I (float): 131072740064.000046
Peak Q (float): -0.000000
Peak I (quantized): 32767
Peak Q (quantized): 0
@@ -41,10 +41,10 @@ Case 2: Tone autocorrelation: signal=ref=tone(bin 5, amp 8000). Expected: peak a
------------------------------------------------------------------------
Case 3: Shifted tone: signal=tone(bin 5), ref=tone(bin 5) delayed by 3 samples. Cross-correlation peak should shift to indicate the delay.
------------------------------------------------------------------------
Peak bin: 253
Peak magnitude (float):65536183223.999992
Peak I (float): 0.000005
Peak Q (float): 65536183223.999992
Peak bin: 509
Peak magnitude (float):131072740063.999985
Peak I (float): -0.000016
Peak Q (float): 131072740063.999985
Peak I (quantized): 0
Peak Q (quantized): 32767
Files:
+6 -2
View File
@@ -527,10 +527,14 @@ module tb_cdc_modules;
if (m2_dst_signal) saw_pulse = 1;
end
// Single src_clk pulse at 400MHz might be too short for 100MHz dst
// This is a known limitation of single-bit synchronizers
// This is a known limitation of single-bit synchronizers. We do
// not assert capture (drop is acceptable for a narrow pulse) — but
// the dst-side output MUST be well-defined (0 or 1, never X/Z)
// after synchronizer settling.
$display(" Single src_clk pulse captured: %b (may miss expected for narrow pulse)",
saw_pulse);
check(1'b1, "M2: Narrow pulse test completed (miss is acceptable)");
check(m2_dst_signal === 1'b0 || m2_dst_signal === 1'b1,
"M2: Synchronizer output is well-defined (not X/Z) after narrow pulse");
end
// ── B5: Long pulse always captured ─────────────────────
+21 -4
View File
@@ -186,8 +186,22 @@ module tb_fir_lowpass;
output_count = output_count + 1;
end
end
// Symmetry is inherent in the coefficient initialization
check(1'b1, "Coefficients are symmetric (verified from RTL source)");
// Verify linear-phase symmetry: coeff[k] == coeff[TAPS-1-k] for all k.
// Reads the DUT's coefficient ROM directly via hierarchical reference.
begin : coeff_symmetry_check
integer sym_k;
reg sym_ok;
sym_ok = 1'b1;
for (sym_k = 0; sym_k < 16; sym_k = sym_k + 1) begin
if (uut.coeff[sym_k] !== uut.coeff[31 - sym_k]) begin
$display(" [SYM] coeff[%0d]=%h != coeff[%0d]=%h",
sym_k, uut.coeff[sym_k],
31 - sym_k, uut.coeff[31 - sym_k]);
sym_ok = 1'b0;
end
end
check(sym_ok, "Coefficients are symmetric (coeff[k] == coeff[31-k])");
end
// ════════════════════════════════════════════════════════
// TEST GROUP 5: Low-frequency sinusoid (passband)
@@ -287,8 +301,11 @@ module tb_fir_lowpass;
end
$display(" filter_overflow detected: %b", saw_nonzero);
// Note: overflow depends on coefficient sum — may or may not trigger
check(1'b1, "Overflow detection logic exists and runs");
// filter_overflow must be a valid 1-bit value at all times (not X/Z).
// Whether it fires with max input depends on coefficient sum — we do
// not assert a specific polarity, but the signal must be well-driven.
check(filter_overflow === 1'b0 || filter_overflow === 1'b1,
"filter_overflow is well-driven (not X/Z) after max-input stimulus");
// ════════════════════════════════════════════════════════
// TEST GROUP 8: data_valid gating
@@ -3,6 +3,18 @@
/**
* tb_fullchain_mti_cfar_realdata.v
*
* ============================================================================
* DEPRECATED STALE FOR 2048-PT ARCHITECTURE (integration/fft-2048-on-p0)
* ----------------------------------------------------------------------------
* Hard-coded for INPUT_BINS=1024 / RANGE_BINS=64 / DECIM_FACTOR=16. Production
* pipeline is now 2048-pt range FFT -> 512 range bins (DECIM=4). Re-enabling
* this TB without regenerating Python goldens (golden_reference.py and the
* fullchain_*.hex files) would feed mis-sized data into current RTL and pass
* on nonsense. Do NOT wire into run_regression.sh until rewritten.
*
* Runtime guard ($fatal at startup) below prevents silent CI resurrection.
* ============================================================================
*
* Full-chain co-simulation testbench: feeds real ADI CN0566 radar data
* (post-range-FFT, 32 chirps x 1024 bins) through the complete signal
* processing pipeline:
@@ -193,6 +205,7 @@ mti_canceller #(
.range_valid_out(mti_valid_out),
.range_bin_out(mti_bin_out),
.mti_enable(1'b1), // MTI always enabled for this test
.use_long_chirp(1'b0), // homogeneous-waveform stimulus in this TB
.mti_first_chirp(mti_first_chirp)
);
@@ -353,6 +366,13 @@ integer cfar_ref_idx;
integer cfar_mag_mismatches, cfar_thr_mismatches, cfar_det_mismatches;
initial begin
`ifndef ALLOW_STALE_TB_FULLCHAIN_MTI_CFAR
// DEPRECATED guard — see file header. Stale 1024-bin constants against
// 2048-pt production RTL. Define ALLOW_STALE_TB_FULLCHAIN_MTI_CFAR only
// for archaeology; this TB does not validate current pipeline.
$display("ERROR: tb_fullchain_mti_cfar_realdata.v DEPRECATED (1024-bin). See header.");
$fatal;
`endif
// ---- Init ----
pass_count = 0;
fail_count = 0;
@@ -334,7 +334,24 @@ module tb_matched_filter_processing_chain;
if (!(cap_peak_bin <= 128 || cap_peak_bin >= FFT_SIZE - 128)) begin
$display("[WARN] Autocorrelation peak at bin %0d (expected near 0) - behavioral FFT noise, OK with Xilinx IP", cap_peak_bin);
end
check(1'b1, "Autocorrelation peak (skipped - behavioral FFT noise)");
// Behavioral Q15 FFT scatters the peak, so we cannot assert bin
// location — but the peak MUST dominate the mean magnitude. This
// catches an all-zero/flat output that the old tautology allowed.
begin : p2m_grp3
integer k, sum_abs, mean_abs;
sum_abs = 0;
for (k = 0; k < FFT_SIZE; k = k + 1) begin
sum_abs = sum_abs
+ (cap_out_i[k][15] ? -cap_out_i[k] : cap_out_i[k])
+ (cap_out_q[k][15] ? -cap_out_q[k] : cap_out_q[k]);
end
mean_abs = sum_abs / FFT_SIZE;
$display(" Peak-to-mean |out|: peak=%0d mean=%0d ratio~%0dx",
cap_max_abs, mean_abs,
(mean_abs == 0) ? 999999 : cap_max_abs / mean_abs);
check(cap_max_abs > mean_abs * 2,
"Autocorrelation peak dominates mean (>2x)");
end
check(cap_max_abs > 0, "Peak magnitude > 0");
// ════════════════════════════════════════════════════════
@@ -481,7 +498,18 @@ module tb_matched_filter_processing_chain;
if (!(cap_peak_bin <= 128 || cap_peak_bin >= FFT_SIZE - 128)) begin
$display("[WARN] Case 1: peak at bin %0d (expected near 0) - behavioral FFT noise", cap_peak_bin);
end
check(1'b1, "Case 1: DUT peak bin (skipped - behavioral FFT noise)");
begin : p2m_case1
integer k, sum_abs, mean_abs;
sum_abs = 0;
for (k = 0; k < FFT_SIZE; k = k + 1) begin
sum_abs = sum_abs
+ (cap_out_i[k][15] ? -cap_out_i[k] : cap_out_i[k])
+ (cap_out_q[k][15] ? -cap_out_q[k] : cap_out_q[k]);
end
mean_abs = sum_abs / FFT_SIZE;
check(cap_max_abs > mean_abs * 2,
"Case 1: Peak dominates mean (>2x, catches flat/zero output)");
end
check(cap_max_abs > 0, "Case 1: Peak magnitude > 0");
// ════════════════════════════════════════════════════════
@@ -512,7 +540,18 @@ module tb_matched_filter_processing_chain;
if (!(cap_peak_bin <= 128 || cap_peak_bin >= FFT_SIZE - 128)) begin
$display("[WARN] Case 2: peak at bin %0d (expected near 0) - behavioral FFT noise", cap_peak_bin);
end
check(1'b1, "Case 2: DUT peak bin (skipped - behavioral FFT noise)");
begin : p2m_case2
integer k, sum_abs, mean_abs;
sum_abs = 0;
for (k = 0; k < FFT_SIZE; k = k + 1) begin
sum_abs = sum_abs
+ (cap_out_i[k][15] ? -cap_out_i[k] : cap_out_i[k])
+ (cap_out_q[k][15] ? -cap_out_q[k] : cap_out_q[k]);
end
mean_abs = sum_abs / FFT_SIZE;
check(cap_max_abs > mean_abs * 2,
"Case 2: Peak dominates mean (>2x, catches flat/zero output)");
end
check(cap_max_abs > 0, "Case 2: Peak magnitude > 0");
// ════════════════════════════════════════════════════════
+23 -5
View File
@@ -3,12 +3,22 @@
/**
* tb_mf_chain_synth.v
*
* Testbench for the SYNTHESIS branch of matched_filter_processing_chain.v.
* This is compiled WITHOUT -DSIMULATION so the `else` branch (fft_engine-based)
* is activated.
* ============================================================================
* DEPRECATED STALE FOR 2048-PT ARCHITECTURE (integration/fft-2048-on-p0)
* ----------------------------------------------------------------------------
* This testbench hard-codes FFT_SIZE=1024 and targets the old synthesis branch
* sized for a 1024-point FFT. Production RTL now uses 2048-pt range FFT
* (RP_FFT_SIZE=2048). Do NOT add this TB to run_regression.sh until it has
* been rewritten for 2048 samples running it as-is validates nothing against
* current RTL and will give false confidence.
*
* The synthesis branch uses an iterative fft_engine (1024-pt, single butterfly),
* so processing takes ~40K+ clock cycles per frame. Timeouts are set accordingly.
* Build fails fast on compile to prevent accidental resurrection.
* ============================================================================
*
* Original description:
* Testbench for the SYNTHESIS branch of matched_filter_processing_chain.v.
* Compiled WITHOUT -DSIMULATION so the `else` (fft_engine) branch activates.
* Synthesis branch uses iterative fft_engine; ~40K+ cycles per frame.
*/
module tb_mf_chain_synth;
@@ -230,6 +240,14 @@ module tb_mf_chain_synth;
$dumpfile("tb_mf_chain_synth.vcd");
$dumpvars(0, tb_mf_chain_synth);
`ifndef ALLOW_STALE_TB_MF_CHAIN_SYNTH
// DEPRECATED guard — see file header. Refuse to run until rewritten
// for 2048-pt architecture. Define ALLOW_STALE_TB_MF_CHAIN_SYNTH to
// override (for archaeology only; does not validate current RTL).
$display("ERROR: tb_mf_chain_synth.v is DEPRECATED (1024-pt). See header.");
$fatal;
`endif
// Init
clk = 0;
pass_count = 0;
+86
View File
@@ -34,6 +34,7 @@ reg signed [DATA_W-1:0] range_q_in;
reg range_valid_in;
reg [5:0] range_bin_in;
reg mti_enable;
reg tb_use_long_chirp;
wire signed [DATA_W-1:0] range_i_out;
wire signed [DATA_W-1:0] range_q_out;
@@ -64,6 +65,7 @@ mti_canceller #(
.range_valid_out(range_valid_out),
.range_bin_out(range_bin_out),
.mti_enable(mti_enable),
.use_long_chirp(tb_use_long_chirp), // driven by TB; T12 exercises boundary
.mti_first_chirp(mti_first_chirp)
);
@@ -92,6 +94,7 @@ task do_reset;
range_q_in = 0;
range_valid_in = 0;
range_bin_in = 0;
tb_use_long_chirp = 1'b0; // default homogeneous waveform
repeat (5) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
@@ -463,6 +466,89 @@ initial begin
check(11, "T11.1: Negative inputs: diff I = 2000", cap_i[0] == 16'sd2000);
check(11, "T11.2: Negative inputs: diff Q = 500", cap_q[0] == 16'sd500);
// ================================================================
// T12: Waveform boundary mute (R-1)
// ----------------------------------------------------------------
// Mode 01 interleaves long and short chirps. The MTI prev-buffer
// holds the previous chirp's range profile; subtracting a short-
// waveform profile from a long-waveform one (or vice versa) would
// inject a per-range-bin impulse into slow-time sample 0, creating
// phantom targets across every Doppler bin.
//
// mti_canceller.v mutes the output at the transition and overwrites
// the prev buffer in-flight so the next chirp (same waveform as the
// transition chirp) subtracts cleanly. This test exercises that
// path — without it, the long→short boundary would silently corrupt
// slow-time data on real hardware.
// ================================================================
do_reset;
mti_enable = 1'b1;
// Chirp A (long, val=1000) — first chirp, muted by first-chirp path.
tb_use_long_chirp = 1'b1;
fork
feed_chirp_const(16'sd1000, 16'sd500);
capture_chirp;
join
check(12, "T12.1: Waveform-A first chirp: muted I",
cap_count == NUM_BINS && cap_i[0] == 16'sd0);
check(12, "T12.2: Waveform-A first chirp: muted Q",
cap_q[0] == 16'sd0);
// Chirp B (long, val=2000) — same waveform: 2000 - 1000 = 1000.
tb_use_long_chirp = 1'b1;
cap_count = 0;
fork
feed_chirp_const(16'sd2000, 16'sd1500);
capture_chirp;
join
check(12, "T12.3: Homogeneous long follow-up: I diff = 1000",
cap_i[0] == 16'sd1000);
check(12, "T12.4: Homogeneous long follow-up: Q diff = 1000",
cap_q[0] == 16'sd1000);
// Chirp C (short, val=5000) — WAVEFORM CHANGED: must mute, and the
// prev buffer must be overwritten with THIS chirp (not subtracted
// against the long-waveform chirp B). If R-1 regresses, we'd see
// 5000 - 2000 = 3000 here instead of 0.
tb_use_long_chirp = 1'b0;
cap_count = 0;
fork
feed_chirp_const(16'sd5000, 16'sd3000);
capture_chirp;
join
check(12, "T12.5: Waveform boundary (long->short): muted I (not 3000)",
cap_i[0] == 16'sd0);
check(12, "T12.6: Waveform boundary (long->short): muted Q (not 2000)",
cap_q[0] == 16'sd0);
// Chirp D (short, val=5500) — same waveform as C: 5500 - 5000 = 500.
// This proves the prev buffer was correctly overwritten with C,
// not stuck on B's long-waveform profile.
tb_use_long_chirp = 1'b0;
cap_count = 0;
fork
feed_chirp_const(16'sd5500, 16'sd3250);
capture_chirp;
join
check(12, "T12.7: Post-boundary short follow-up: I diff = 500",
cap_i[0] == 16'sd500);
check(12, "T12.8: Post-boundary short follow-up: Q diff = 250",
cap_q[0] == 16'sd250);
// Chirp E (short -> long) — another boundary, reverse direction,
// confirms muting is symmetric.
tb_use_long_chirp = 1'b1;
cap_count = 0;
fork
feed_chirp_const(16'sd9000, 16'sd4000);
capture_chirp;
join
check(12, "T12.9: Waveform boundary (short->long): muted I",
cap_i[0] == 16'sd0);
check(12, "T12.10: Waveform boundary (short->long): muted Q",
cap_q[0] == 16'sd0);
// ================================================================
// SUMMARY
// ================================================================
+15 -5
View File
@@ -131,6 +131,15 @@ always @(posedge clk) begin
end
end
// One-hot bitmap of distinct segment_request values observed during the run.
// Used by TEST 4 to turn a tautological check into a real coverage assertion.
reg [3:0] seg_request_seen;
initial seg_request_seen = 4'b0000;
always @(posedge clk) begin
if (reset_n && mem_request)
seg_request_seen <= seg_request_seen | (4'b0001 << segment_request);
end
// ============================================================================
// Output capture
// ============================================================================
@@ -586,11 +595,12 @@ initial begin
// TEST 4: Verify segment_request output
// ====================================================================
$display("\n=== TEST 4: Segment Request Tracking ===");
// We verified segments 0-3 processed. Now check that segment_request
// was correctly driven during processing. Since we can't look back
// in time, we test by re-running and monitoring segment_request.
// For now, structural checks above suffice.
check(1'b1, "Segment request tracking (verified via segment transitions)");
// Verify that segment_request actually took all 4 values (0..3) during
// the long-chirp run, using the bitmap captured by the always-block above.
// A stuck segment_request would previously pass silently.
$display(" segment_request bitmap: %b (bit k = value k seen)", seg_request_seen);
check(seg_request_seen == 4'b1111,
"Segment request visited all 4 values (0,1,2,3) during long-chirp run");
// ====================================================================
// TEST 5: Non-zero output energy check
+22 -6
View File
@@ -233,13 +233,29 @@ module tb_nco_400m;
csv_file = $fopen("nco_freq_switch.csv", "w");
$fwrite(csv_file, "sample,sin_out,cos_out\n");
for (sample_count = 0; sample_count < 200; sample_count = sample_count + 1) begin
@(posedge clk_400m); #1;
$fwrite(csv_file, "%0d,%0d,%0d\n",
sample_count, sin_out, cos_out);
// Track sin/cos variance to verify NCO is actively generating a tone
// after the frequency switch (detects stuck/zero output).
begin : freq_switch_obs
integer fs_min, fs_max;
reg fs_saw_x;
fs_min = 32'sh7FFFFFFF;
fs_max = -32'sh80000000;
fs_saw_x = 1'b0;
for (sample_count = 0; sample_count < 200; sample_count = sample_count + 1) begin
@(posedge clk_400m); #1;
$fwrite(csv_file, "%0d,%0d,%0d\n",
sample_count, sin_out, cos_out);
if (^sin_out === 1'bx || ^cos_out === 1'bx) fs_saw_x = 1'b1;
if ($signed(sin_out) < fs_min) fs_min = $signed(sin_out);
if ($signed(sin_out) > fs_max) fs_max = $signed(sin_out);
end
$fclose(csv_file);
$display(" After freq switch: sin swing [%0d..%0d]", fs_min, fs_max);
check(!fs_saw_x,
"Frequency switch: sin/cos outputs never X during 200 samples");
check(fs_max > fs_min,
"Frequency switch: NCO output varies (not stuck) after switch");
end
$fclose(csv_file);
check(1'b1, "Frequency switch completed without error");
// ════════════════════════════════════════════════════════
// TEST GROUP 6: phase_valid gating
@@ -34,10 +34,10 @@
// Signals: dut.ddc_out_i [17:0], dut.ddc_out_q [17:0], dut.ddc_valid_i
// Tap 2 (Doppler output) - golden compared (deterministic after MF buffering)
// Signals: doppler_output[31:0], doppler_valid, doppler_bin[4:0],
// range_bin_out[5:0]
// range_bin_out[8:0]
//
// Golden file: tb/golden/golden_doppler.mem
// 2048 entries of 32-bit hex, indexed by range_bin*32 + doppler_bin
// 16384 entries of 32-bit hex, indexed by range_bin*32 + doppler_bin
//
// Strategy:
// - Uses behavioral stub for ad9484_interface_400m (no Xilinx primitives)
@@ -130,7 +130,7 @@ end
wire [31:0] doppler_output;
wire doppler_valid;
wire [4:0] doppler_bin;
wire [5:0] range_bin_out;
wire [8:0] range_bin_out;
radar_receiver_final dut (
.clk(clk_100m),
@@ -216,10 +216,10 @@ endtask
// ============================================================================
// GOLDEN MEMORY DECLARATIONS AND LOAD/STORE LOGIC
// ============================================================================
localparam GOLDEN_ENTRIES = 2048; // 64 range bins * 32 Doppler bins
localparam GOLDEN_ENTRIES = 16384; // 512 range bins * 32 Doppler bins
localparam GOLDEN_TOLERANCE = 2; // +/- 2 LSB tolerance for comparison
reg [31:0] golden_doppler [0:2047];
reg [31:0] golden_doppler [0:16383];
// -- Golden comparison tracking --
integer golden_match_count;
@@ -280,7 +280,7 @@ end
// ============================================================================
integer doppler_output_count;
integer doppler_frame_count;
reg [63:0] range_bin_seen; // Bitmap: which range bins appeared
reg [511:0] range_bin_seen; // Bitmap: which range bins appeared (512 bins)
reg [31:0] doppler_bin_seen; // Bitmap: which Doppler bins appeared
integer nonzero_output_count;
reg [31:0] first_doppler_time; // Cycle when first doppler_valid appears
@@ -294,21 +294,21 @@ reg frame_done_prev;
integer csv_fd;
// Duplicate detection: one-hot bitmap per (range_bin, doppler_bin)
// 64 range bins x 32 doppler bins = 2048 bits -> use an array of 64 x 32-bit regs
reg [31:0] index_seen [0:63];
// 512 range bins x 32 doppler bins = 16384 bits -> array of 512 x 32-bit regs
reg [31:0] index_seen [0:511];
integer dup_count;
// Bounds check B2: Doppler energy tracking per range bin
// For each range bin, track peak |I|+|Q| across all 32 Doppler bins
// and total energy. Verifies pipeline computes non-trivial Doppler spectra.
reg [31:0] peak_dbin_mag [0:63]; // max |I|+|Q| across all Doppler bins
reg [31:0] total_dbin_energy [0:63]; // sum of |I|+|Q| across all 32 Doppler bins
reg [31:0] peak_dbin_mag [0:511]; // max |I|+|Q| across all Doppler bins
reg [31:0] total_dbin_energy [0:511]; // sum of |I|+|Q| across all 32 Doppler bins
integer b2_init_idx;
initial begin
doppler_output_count = 0;
doppler_frame_count = 0;
range_bin_seen = 64'd0;
range_bin_seen = 512'd0;
doppler_bin_seen = 32'd0;
nonzero_output_count = 0;
first_doppler_seen = 0;
@@ -317,7 +317,7 @@ initial begin
frame_done_prev = 0;
dup_count = 0;
for (b2_init_idx = 0; b2_init_idx < 64; b2_init_idx = b2_init_idx + 1) begin
for (b2_init_idx = 0; b2_init_idx < 512; b2_init_idx = b2_init_idx + 1) begin
index_seen[b2_init_idx] = 32'd0;
peak_dbin_mag[b2_init_idx] = 32'd0;
total_dbin_energy[b2_init_idx] = 32'd0;
@@ -345,8 +345,8 @@ always @(posedge clk_100m) begin
frame_output_count = frame_output_count + 1;
// Track which bins we've seen
if (range_bin_out < 64)
range_bin_seen = range_bin_seen | (64'd1 << range_bin_out);
if (range_bin_out < 512)
range_bin_seen = range_bin_seen | (512'd1 << range_bin_out);
if (doppler_bin < 32)
doppler_bin_seen = doppler_bin_seen | (32'd1 << doppler_bin);
@@ -373,7 +373,7 @@ always @(posedge clk_100m) begin
gidx = range_bin_out * 32 + doppler_bin;
// ---- Duplicate detection (B5) ----
if (range_bin_out < 64 && doppler_bin < 32) begin
if (range_bin_out < 512 && doppler_bin < 32) begin
if (index_seen[range_bin_out][doppler_bin]) begin
dup_count = dup_count + 1;
if (dup_count <= 10)
@@ -390,7 +390,7 @@ always @(posedge clk_100m) begin
mag_q = (mag_q_signed < 0) ? -mag_q_signed : mag_q_signed;
mag_sum = mag_i + mag_q;
if (range_bin_out < 64) begin
if (range_bin_out < 512) begin
total_dbin_energy[range_bin_out] = total_dbin_energy[range_bin_out] + mag_sum;
if (mag_sum > peak_dbin_mag[range_bin_out])
peak_dbin_mag[range_bin_out] = mag_sum;
@@ -523,7 +523,7 @@ end
// 1. DDC pipeline fill: ~4 sys_clk cycles
// 2. MF overlap-save buffer fill: 896 valid DDC samples
// 3. Latency buffer priming: 3187 valid_in assertions
// 4. 1024 MF outputs -> range_bin_decimator -> 64 decimated outputs
// 4. 2048 MF outputs -> range_bin_decimator -> 512 decimated outputs
// 5. 32 chirps of decimated data -> Doppler FFT
//
// With shortened mode controller timing (~600 cycles per chirp pair),
@@ -619,24 +619,24 @@ initial begin
check(doppler_output_count > 0,
"S4: Doppler processor produces outputs (doppler_valid asserted)");
// ---- CHECK S5: Correct output count per frame (legacy: >= 2048) ----
// ---- CHECK S5: Correct output count per frame (>= 16384) ----
if (doppler_frame_count > 0) begin
check(doppler_output_count >= 2048,
"S5: At least 2048 doppler outputs (one full frame: 64 rbins x 32 dbins)");
check(doppler_output_count >= 16384,
"S5: At least 16384 doppler outputs (one full frame: 512 rbins x 32 dbins)");
end else begin
check(0, "S5: At least 2048 doppler outputs (NO FRAME COMPLETED)");
check(0, "S5: At least 16384 doppler outputs (NO FRAME COMPLETED)");
end
// ---- CHECK S6: Range bin coverage ----
begin : count_range_bins
integer rb_count, rb_i;
rb_count = 0;
for (rb_i = 0; rb_i < 64; rb_i = rb_i + 1) begin
for (rb_i = 0; rb_i < 512; rb_i = rb_i + 1) begin
if (range_bin_seen[rb_i]) rb_count = rb_count + 1;
end
$display("[INFO] Unique range bins seen: %0d / 64", rb_count);
check(rb_count == 64,
"S6: All 64 range bins present in Doppler output");
$display("[INFO] Unique range bins seen: %0d / 512", rb_count);
check(rb_count == 512,
"S6: All 512 range bins present in Doppler output");
end
// ---- CHECK S7: Doppler bin coverage ----
@@ -719,7 +719,7 @@ initial begin
nontrivial_count = 0;
min_peak = 32'h7FFFFFFF;
max_peak = 0;
for (b2_rb = 0; b2_rb < 64; b2_rb = b2_rb + 1) begin
for (b2_rb = 0; b2_rb < 512; b2_rb = b2_rb + 1) begin
if (peak_dbin_mag[b2_rb] > 0)
nontrivial_count = nontrivial_count + 1;
if (peak_dbin_mag[b2_rb] < min_peak)
@@ -727,10 +727,10 @@ initial begin
if (peak_dbin_mag[b2_rb] > max_peak)
max_peak = peak_dbin_mag[b2_rb];
end
$display(" Doppler peak mag: min=%0d max=%0d, non-trivial in %0d/64 range bins",
$display(" Doppler peak mag: min=%0d max=%0d, non-trivial in %0d/512 range bins",
min_peak, max_peak, nontrivial_count);
// All 64 range bins must have non-zero peak Doppler energy
check(nontrivial_count == 64,
// All 512 range bins must have non-zero peak Doppler energy
check(nontrivial_count == 512,
"B2a: All range bins have non-trivial Doppler energy");
// Peak magnitude should be bounded (not overflowing to max signed value)
check(max_peak < 32000,
@@ -738,23 +738,23 @@ initial begin
end
// ---- B3: Exact Doppler Output Count ----
$display(" Doppler output count: %0d (expected 2048)", doppler_output_count);
check(doppler_output_count == 2048,
"B3: Exact output count = 2048 (64 range x 32 Doppler)");
$display(" Doppler output count: %0d (expected 16384)", doppler_output_count);
check(doppler_output_count == 16384,
"B3: Exact output count = 16384 (512 range x 32 Doppler)");
// ---- B4: Full Range/Doppler Bin Coverage (exact) ----
begin : b4_check_block
integer b4_rb_count, b4_db_count, b4_i;
b4_rb_count = 0;
b4_db_count = 0;
for (b4_i = 0; b4_i < 64; b4_i = b4_i + 1) begin
for (b4_i = 0; b4_i < 512; b4_i = b4_i + 1) begin
if (range_bin_seen[b4_i]) b4_rb_count = b4_rb_count + 1;
end
for (b4_i = 0; b4_i < 32; b4_i = b4_i + 1) begin
if (doppler_bin_seen[b4_i]) b4_db_count = b4_db_count + 1;
end
check(b4_rb_count == 64 && b4_db_count == 32,
"B4: Full bin coverage: 64 range x 32 Doppler");
check(b4_rb_count == 512 && b4_db_count == 32,
"B4: Full bin coverage: 512 range x 32 Doppler");
end
// ---- B5: No Duplicate Indices ----
+6 -2
View File
@@ -1097,8 +1097,12 @@ initial begin
// ================================================================
$display("--- Group 12: Watchdog / Liveness ---");
// G12.1: System hasn't hung — we reached this point
check(1, "G12.1: System did not hang (reached final test group)");
// G12.1: System hasn't hung AND prior groups actually ran assertions.
// `check(1, ...)` alone is tautological; we additionally require that a
// meaningful number of earlier checks executed (catches a TB that skips
// past test groups via an @(posedge) that never fires or similar stall).
check(test_num > 20,
"G12.1: System reached final group AND >20 prior checks executed");
// G12.2: Total simulation time is within budget
check($time < SIM_TIMEOUT_NS,