test(gui): GUI-S3 — pin status word 4 bit layout in a co-spec test

Cross-verified status word 4 bit positions against the FPGA word builder
(usb_data_interface.v:376-380, usb_data_interface_ft2232h.v:675-679) and
the GUI parser (radar_protocol.py:252-257) — all positions match. No
production change needed; the gap was that nothing in the test suite
caught a future drift between the two sides.

Added test_parse_status_word4_layout_co_spec: a single canonical layout
table is the source of truth; for each field the test sets only that
field to its max value, builds the status packet via the existing
FPGA-builder-mirror _make_status_packet, parses, and asserts the field
round-trips exactly AND every other field reads back zero. Catches both
LSB drift and width drift on either side of the wire. Pre-checks that
widths plus the reserved [9:2] gap sum to 32 and that no two fields
overlap.

Also fixed test_default_shapes — stale (64,32)/(64,) literals predated
the GUI-C1 / Q3 alignment that bumped NUM_RANGE_BINS 64 -> 512. test_v7
was updated at the time, this one was missed. Replaced with references
to NUM_RANGE_BINS/NUM_DOPPLER_BINS so any future bin-count change
auto-updates the assertion. Suite now 180/180 PASS.
This commit is contained in:
Jason
2026-04-28 10:09:15 +05:45
parent 5d334bfdd6
commit c49092f52b

View File

@@ -207,6 +207,63 @@ class TestRadarProtocol(unittest.TestCase):
raw[25] = 0x00 # corrupt footer (was at index 21 in old 5-word format)
self.assertIsNone(RadarProtocol.parse_status_packet(bytes(raw)))
def test_parse_status_word4_layout_co_spec(self):
"""GUI-S3: pin status word 4 bit positions to the FPGA word builder.
Canonical layout per usb_data_interface.v:376-380 and
usb_data_interface_ft2232h.v:675-679 — exactly one source of truth
in this test, so any future drift between FPGA and GUI trips here:
[31:28] agc_current_gain (4-bit)
[27:20] agc_peak_magnitude (8-bit)
[19:12] agc_saturation_count (8-bit)
[11] agc_enable (1-bit)
[10] chirps_mismatch (1-bit, TX-G)
[9:2] reserved (8 bits, must be zero from builder)
[1:0] range_mode (2-bit)
For each field we set ONLY that field to its max, build the packet,
parse, and assert (a) the field reads back correctly and (b) every
other field reads back zero. Catches both LSB drift and width drift
on either side of the wire.
"""
layout = [
# (field_name, builder_kwarg, lsb, width, parsed_attr)
("agc_current_gain", "agc_gain", 28, 4, "agc_current_gain"),
("agc_peak_magnitude", "agc_peak", 20, 8, "agc_peak_magnitude"),
("agc_saturation_count", "agc_sat", 12, 8, "agc_saturation_count"),
("agc_enable", "agc_enable", 11, 1, "agc_enable"),
("chirps_mismatch", "chirps_mismatch", 10, 1, "chirps_mismatch"),
("range_mode", "range_mode", 0, 2, "range_mode"),
]
# Sanity: layout fields + reserved [9:2] must cover exactly 32 bits.
used = sum(width for _, _, _, width, _ in layout)
self.assertEqual(used + 8, 32,
"word 4 layout (incl. reserved [9:2]) must total 32 bits")
# No two fields may overlap.
occupied = set()
for name, _, lsb, width, _ in layout:
bits = set(range(lsb, lsb + width))
self.assertFalse(occupied & bits,
f"{name} bits {sorted(bits)} overlap previously-allocated bits")
occupied |= bits
other_attrs = [attr for _, _, _, _, attr in layout]
for name, kwarg, _lsb, width, attr in layout:
max_val = (1 << width) - 1
raw = self._make_status_packet(**{kwarg: max_val})
sr = RadarProtocol.parse_status_packet(raw)
self.assertIsNotNone(sr, f"{name}: parse failed")
self.assertEqual(getattr(sr, attr), max_val,
f"{name}: round-trip mismatch (set={max_val}, got={getattr(sr, attr)})")
for other in other_attrs:
if other == attr:
continue
self.assertEqual(getattr(sr, other), 0,
f"{name} max value bled into {other} -- bit-position drift?")
def test_parse_status_self_test_all_pass(self):
"""Status with all self-test flags set (all tests pass)."""
raw = self._make_status_packet(st_flags=0x1F, st_detail=0xA5, st_busy=0)
@@ -558,12 +615,15 @@ class TestRadarFrameDefaults(unittest.TestCase):
"""Test RadarFrame default initialization."""
def test_default_shapes(self):
# Stale literals (64,32)/(64,) predated the GUI-C1 / Q3 alignment that
# bumped NUM_RANGE_BINS 64 -> 512 to match FPGA truth. Reference the
# constants so any future bin-count change updates the assertion too.
f = RadarFrame()
self.assertEqual(f.range_doppler_i.shape, (64, 32))
self.assertEqual(f.range_doppler_q.shape, (64, 32))
self.assertEqual(f.magnitude.shape, (64, 32))
self.assertEqual(f.detections.shape, (64, 32))
self.assertEqual(f.range_profile.shape, (64,))
self.assertEqual(f.range_doppler_i.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS))
self.assertEqual(f.range_doppler_q.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS))
self.assertEqual(f.magnitude.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS))
self.assertEqual(f.detections.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS))
self.assertEqual(f.range_profile.shape, (NUM_RANGE_BINS,))
self.assertEqual(f.detection_count, 0)
def test_default_zeros(self):