feat(fpga,gui): PR-AC.1 — M-5 status pkt 30→34 B for medium_chirp/medium_listen readback

Closes the 161-µs MEDIUM PRI visibility gap from the 2026-05-02 e2e audit.
PR-G ran out of reserved bits in status_words[3] to fit a second 16-bit
pair, so this PR adds status_words[7] = {medium_chirp[15:0], medium_listen[15:0]}
and bumps STATUS_PKT_LEN 30→34, STATUS_PACKET_SIZE 30→34.

RTL (usb_data_interface_ft2232h.v):
  - Two new input ports `status_medium_chirp` / `status_medium_listen`.
  - status_words array bound 6→7, init loop 7→8, snapshot block packs word 7.
  - STATUS_PKT_LEN width 5→6 bits to hold 34, byte-mux index widened
    [4:0]→[5:0] with 4 new entries for word 7 + footer at index 33.
  - Header docstring + length-localparam comment refreshed.

Top-level (radar_system_top.v): wire `host_medium_chirp_cycles` /
`host_medium_listen_cycles` into the FT2232H usb_inst (gen_ft2232h branch
only; legacy FT601 path retains its pre-PR-G 6-word / 26-byte layout —
no host code reads it today).

Host parser (radar_protocol.py):
  - STATUS_PACKET_SIZE 30→34. Module docstring + parse_status_packet
    docstring + find_bulk_frame_boundaries note refreshed.
  - StatusResponse gains `medium_chirp` / `medium_listen` fields.
  - Word-loop bounds 7→8; word-7 decode added.

Tests:
  - tb_usb_protocol_v2 — drive default RP_DEF_MEDIUM_*_CYCLES (500/15600),
    move T3.2 footer assertion 29→33, add T3.8..T3.11 for word-7 bytes.
    Manual run: 31/31 PASS (TB not in run_regression.sh).
  - tb_ft2232h_frame_drop, tb_e2e_dsp_to_host — tie new DUT ports to
    16'd0 (these TBs don't exercise status reads).
  - test_GUI_V65_Tk — extend _make_status_packet builder kwargs, rename
    test_status_packet_is_30_bytes → _is_34_bytes, add round-trip +
    16-bit-max tests for word 7.
  - test_v7 — TestStatusPacketV2RoundTrip rewired for 8 words / 34 B,
    add test_word7_medium_pri_decoded + test_pre_M5_30byte_packet_rejected.

Verification:
  - FPGA regression (iverilog + xsim cosim): 42/0/0.
  - tb_usb_protocol_v2 standalone: 31/0.
  - Host test_GUI_V65_Tk: 119/119.
  - Host test_v7: 152/152.
This commit is contained in:
Jason
2026-05-07 15:20:09 +05:45
parent a19359aeab
commit 21bf7a0228
8 changed files with 187 additions and 75 deletions
+25 -13
View File
@@ -35,9 +35,12 @@ USB transports + wire formats (these intentionally diverge):
[0xAA][range_q 2B][range_i 2B][dop_re 2B][dop_im 2B][det 1B][0x55]
where det byte = {frame_start, 6'b0, cfar_detection}.
Status (both transports, PR-G v2)
[0xBB][7 x 32-bit status_words][0x55] = 30 B. status_words[6] carries
2-tier-CFAR telemetry: {detect_count_cand[31:16], detect_threshold_soft[15:0]}.
Status (FT2232H production, M-5)
[0xBB][8 x 32-bit status_words][0x55] = 34 B. status_words[6] carries
2-tier-CFAR telemetry (PR-G); status_words[7] carries
{medium_chirp[31:16], medium_listen[15:0]} (M-5). Legacy FT601 path
still emits the pre-PR-G 26-byte 6-word layout (no host code reads it
today; the production transport is FT2232H).
RX (Host → FPGA, both transports)
4 bytes per command: {opcode[7:0], addr[7:0], value[15:8], value[7:0]}.
@@ -71,7 +74,7 @@ STATUS_HEADER_BYTE = 0xBB
# Packet sizes
DATA_PACKET_SIZE = 11 # 1 + 4 + 2 + 2 + 1 + 1 (FT601 legacy)
STATUS_PACKET_SIZE = 30 # 1 + 28 + 1 (PR-G v2: 7 status_words)
STATUS_PACKET_SIZE = 34 # 1 + 32 + 1 (M-5: 8 status_words; was 30 / PR-G 7 words)
NUM_RANGE_BINS = 512
NUM_DOPPLER_BINS = 48 # PR-F/PR-Q: 3 sub-frames * 16 (= FPGA RP_NUM_DOPPLER_BINS)
@@ -239,7 +242,7 @@ class RadarFrame:
@dataclass
class StatusResponse:
"""Parsed status response from FPGA (PR-G v2: 7-word / 30-byte packet)."""
"""Parsed status response from FPGA (M-5: 8-word / 34-byte packet)."""
radar_mode: int = 0
stream_ctrl: int = 0
cfar_threshold: int = 0
@@ -265,6 +268,9 @@ class StatusResponse:
detect_threshold_soft: int = 0 # 16-bit soft-CFAR threshold readback (saturates 0xFFFF)
# AUDIT-S10 control-fault flags (word 5 high half)
frame_drop_count: int = 0 # frame-drop counter from RTL
# M-5 MEDIUM PRI readback (word 7) — closes 161-µs MEDIUM visibility gap.
medium_chirp: int = 0 # opcode 0x17 readback (16-bit, default RP_DEF_MEDIUM_CHIRP_CYCLES)
medium_listen: int = 0 # opcode 0x18 readback (16-bit, default RP_DEF_MEDIUM_LISTEN_CYCLES)
# ============================================================================
@@ -337,9 +343,11 @@ class RadarProtocol:
"""
Parse a status response packet.
PR-G v2 format: [0xBB] [7 x 4B status_words] [0x55] = 1 + 28 + 1 = 30 bytes.
Audit P-3: pre-PR-G GUI used 26 (six words); FPGA `STATUS_PKT_LEN=30` since
PR-G added word[6] for 2-tier-CFAR telemetry.
M-5 format: [0xBB] [8 x 4B status_words] [0x55] = 1 + 32 + 1 = 34 bytes.
History: pre-PR-G was 26 B (6 words); PR-G bumped to 30 B (7 words) for
2-tier-CFAR telemetry; M-5 bumped to 34 B (8 words) for medium_chirp /
medium_listen readback (PR-G ran out of reserved bits in word 3 to fit
a second 16-bit pair).
"""
if len(raw) < STATUS_PACKET_SIZE:
return None
@@ -347,7 +355,7 @@ class RadarProtocol:
return None
words = []
for i in range(7):
for i in range(8):
w = struct.unpack_from(">I", raw, 1 + i * 4)[0]
words.append(w)
@@ -386,6 +394,9 @@ class RadarProtocol:
# (saturated to 0xFFFF when the 17-bit RTL value exceeds 16-bit range).
sr.detect_threshold_soft = words[6] & 0xFFFF
sr.detect_count_cand = (words[6] >> 16) & 0xFFFF
# Word 7 (M-5 MEDIUM PRI readback): {medium_chirp[31:16], medium_listen[15:0]}
sr.medium_listen = words[7] & 0xFFFF
sr.medium_chirp = (words[7] >> 16) & 0xFFFF
return sr
@staticmethod
@@ -565,10 +576,11 @@ class RadarProtocol:
def find_bulk_frame_boundaries(buf: bytes) -> list[tuple[int, int, str]]:
"""Scan a byte stream for FT2232H bulk frames and status packets.
Status packets (0xBB header, 26 B) are unchanged between transports
— the WR_STATUS_SEND state in usb_data_interface_ft2232h.v emits the
same layout as the legacy FT601 path. Bulk data frames (0xAA header)
are variable length per `_bulk_frame_size_from_flags`.
Status packets (0xBB header, 34 B post M-5) come from
WR_STATUS_SEND in usb_data_interface_ft2232h.v. (Legacy FT601 path
emits a different 26-byte status layout but is not used by current
host code.) Bulk data frames (0xAA header) are variable length per
`_bulk_frame_size_from_flags`.
Returns a list of (start, end, ptype) tuples like
find_packet_boundaries, where ptype is "data" or "status". On a
+41 -7
View File
@@ -133,8 +133,9 @@ class TestRadarProtocol(unittest.TestCase):
st_flags=0, st_detail=0, st_busy=0,
agc_gain=0, agc_peak=0, agc_sat=0, agc_enable=0,
chirps_mismatch=0,
cand_count=0, thr_soft=0, frame_drop=0):
"""Build a PR-G v2 30-byte status response matching FPGA format."""
cand_count=0, thr_soft=0, frame_drop=0,
medium_chirp=0, medium_listen=0):
"""Build an M-5 34-byte status response matching FPGA format."""
pkt = bytearray()
pkt.append(STATUS_HEADER_BYTE)
@@ -174,6 +175,10 @@ class TestRadarProtocol(unittest.TestCase):
w6 = ((cand_count & 0xFFFF) << 16) | (thr_soft & 0xFFFF)
pkt += struct.pack(">I", w6)
# Word 7 (M-5 MEDIUM PRI readback): {medium_chirp[31:16], medium_listen[15:0]}
w7 = ((medium_chirp & 0xFFFF) << 16) | (medium_listen & 0xFFFF)
pkt += struct.pack(">I", w7)
pkt.append(FOOTER_BYTE)
return bytes(pkt)
@@ -207,8 +212,9 @@ class TestRadarProtocol(unittest.TestCase):
self.assertEqual(sr.range_mode, 2)
def test_parse_status_too_short(self):
# Anything under STATUS_PACKET_SIZE (30) must be rejected.
self.assertIsNone(RadarProtocol.parse_status_packet(b"\xBB" + b"\x00" * 28))
# Anything under STATUS_PACKET_SIZE (34 post-M-5) must be rejected.
# 33-byte input = header + 32 bytes (one short of valid).
self.assertIsNone(RadarProtocol.parse_status_packet(b"\xBB" + b"\x00" * 32))
def test_parse_status_wrong_header(self):
raw = self._make_status_packet()
@@ -228,6 +234,34 @@ class TestRadarProtocol(unittest.TestCase):
self.assertEqual(sr.detect_count_cand, 0x0A5C)
self.assertEqual(sr.detect_threshold_soft, 0x1234)
def test_parse_status_word7_medium_pri_readback(self):
"""M-5: word[7] high-half = medium_chirp, low-half = medium_listen.
Closes the 161-µs MEDIUM PRI visibility gap left by PR-G (status word 3
had only 10 reserved bits, not enough for a second 16-bit pair). Default
production values: 500 cycles MEDIUM_CHIRP / 15600 cycles MEDIUM_LISTEN
per RP_DEF_MEDIUM_*_CYCLES — picked here as the round-trip canary.
"""
raw = self._make_status_packet(medium_chirp=500, medium_listen=15600)
sr = RadarProtocol.parse_status_packet(raw)
self.assertIsNotNone(sr)
self.assertEqual(sr.medium_chirp, 500)
self.assertEqual(sr.medium_listen, 15600)
# Defaults for unrelated fields must still be zero — guards against
# bit-stealing into word 7 from neighbours.
self.assertEqual(sr.detect_count_cand, 0)
self.assertEqual(sr.detect_threshold_soft, 0)
# Packet length matches new STATUS_PACKET_SIZE.
self.assertEqual(len(raw), STATUS_PACKET_SIZE)
self.assertEqual(STATUS_PACKET_SIZE, 34)
def test_parse_status_word7_max_values(self):
"""M-5: 16-bit max in both halves of word 7 round-trips clean (no overflow)."""
raw = self._make_status_packet(medium_chirp=0xFFFF, medium_listen=0xFFFF)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.medium_chirp, 0xFFFF)
self.assertEqual(sr.medium_listen, 0xFFFF)
def test_parse_status_word4_layout_co_spec(self):
"""GUI-S3: pin status word 4 bit positions to the FPGA word builder.
@@ -326,11 +360,11 @@ class TestRadarProtocol(unittest.TestCase):
self.assertEqual(sr.self_test_detail, 0)
self.assertEqual(sr.self_test_busy, 0)
def test_status_packet_is_30_bytes(self):
"""PR-G v2: status packet is 30 bytes (1 + 7*4 + 1)."""
def test_status_packet_is_34_bytes(self):
"""M-5: status packet is 34 bytes (1 + 8*4 + 1; was 30 / PR-G 7 words)."""
raw = self._make_status_packet()
self.assertEqual(len(raw), STATUS_PACKET_SIZE)
self.assertEqual(len(raw), 30)
self.assertEqual(len(raw), 34)
# ----------------------------------------------------------------
# Boundary detection
+27 -8
View File
@@ -549,33 +549,44 @@ class TestSubframeEnableRoundTrip(TestBulkFrameV2RoundTrip):
class TestStatusPacketV2RoundTrip(unittest.TestCase):
"""PR-G v2 status packet: 7 status_words / 30 bytes."""
"""M-5 status packet: 8 status_words / 34 bytes."""
def _build_status(self, words: list[int]) -> bytes:
from radar_protocol import STATUS_HEADER_BYTE, FOOTER_BYTE
assert len(words) == 7
assert len(words) == 8
body = b"".join(struct.pack(">I", w & 0xFFFFFFFF) for w in words)
return bytes([STATUS_HEADER_BYTE]) + body + bytes([FOOTER_BYTE])
def test_size_is_30(self):
def test_size_is_34(self):
from radar_protocol import STATUS_PACKET_SIZE
self.assertEqual(STATUS_PACKET_SIZE, 30)
pkt = self._build_status([0] * 7)
self.assertEqual(len(pkt), 30)
self.assertEqual(STATUS_PACKET_SIZE, 34)
pkt = self._build_status([0] * 8)
self.assertEqual(len(pkt), 34)
def test_word6_telemetry_decoded(self):
"""word[6] = {detect_count_cand[31:16], detect_threshold_soft[15:0]}"""
from radar_protocol import RadarProtocol
word6 = (0x1234 << 16) | 0xABCD # cand=0x1234, thr_soft=0xABCD
pkt = self._build_status([0, 0, 0, 0, 0, 0, word6])
pkt = self._build_status([0, 0, 0, 0, 0, 0, word6, 0])
sr = RadarProtocol.parse_status_packet(pkt)
self.assertIsNotNone(sr)
self.assertEqual(sr.detect_count_cand, 0x1234)
self.assertEqual(sr.detect_threshold_soft, 0xABCD)
def test_word7_medium_pri_decoded(self):
"""M-5: word[7] = {medium_chirp[31:16], medium_listen[15:0]}"""
from radar_protocol import RadarProtocol
# Production defaults: 500 chirp / 15600 listen (RP_DEF_MEDIUM_*).
word7 = (500 << 16) | 15600
pkt = self._build_status([0, 0, 0, 0, 0, 0, 0, word7])
sr = RadarProtocol.parse_status_packet(pkt)
self.assertIsNotNone(sr)
self.assertEqual(sr.medium_chirp, 500)
self.assertEqual(sr.medium_listen, 15600)
def test_short_packet_returns_none(self):
from radar_protocol import RadarProtocol
pkt = self._build_status([0] * 7)
pkt = self._build_status([0] * 8)
self.assertIsNone(RadarProtocol.parse_status_packet(pkt[:25]))
def test_pre_PR_G_26byte_packet_rejected(self):
@@ -587,6 +598,14 @@ class TestStatusPacketV2RoundTrip(unittest.TestCase):
self.assertEqual(len(old_pkt), 26)
self.assertIsNone(RadarProtocol.parse_status_packet(old_pkt))
def test_pre_M5_30byte_packet_rejected(self):
"""Pre-M-5 30-byte (PR-G 7-word) status packets must reject after the bump."""
from radar_protocol import RadarProtocol, STATUS_HEADER_BYTE, FOOTER_BYTE
old_pkt = (bytes([STATUS_HEADER_BYTE])
+ b"\x00" * 28 + bytes([FOOTER_BYTE]))
self.assertEqual(len(old_pkt), 30)
self.assertIsNone(RadarProtocol.parse_status_packet(old_pkt))
# =============================================================================
# Test: v7.__init__ — clean exports