PR-AB.b expanded commit 5: Beam-ready handshake (RTL + MCU + GUI)

Wire a per-frame MCU→FPGA "beam pattern ready" handshake so the chirp
scheduler can stall between 48-chirp frames until the MCU finishes
writing the next ADAR1000 pattern. The legacy unused stm32_new_chirp
input on PD8 is repurposed as stm32_beam_ready; chirp_scheduler.v gets
a new S_BEAM_WAIT state entered after each frame_pulse and an 80 ms
watchdog so a missed MCU toggle degrades to wall-clock cadence with a
sticky telemetry bit rather than stalling the radar.

Cold-reset defaults the handshake off (host_handshake_enable=0, new
opcode 0x1A); the GUI opts in once the MCU PD8 wiring is verified on
the bench. Both the FT601 and FT2232H status word 4 paths get the new
beam_handshake_watchdog_fired sticky at bit [1] (reclaimed from the
range_mode retirement in commit 1).

RTL:
- chirp_scheduler.v: 2-FF ASYNC_REG sync on beam_ready_async; 1-cycle
  edge detect (any transition, MCU side uses HAL_GPIO_TogglePin); new
  S_BEAM_WAIT state entered at frame_pulse when host_handshake_enable=1;
  23-bit beam_watchdog counter with BEAM_WATCHDOG_MAX = 8_000_000 (~80 ms
  at 100 MHz, ~10 nominal frames); beam_handshake_watchdog_fired output
  sticky across mixers_enable cycles, cleared only by reset_n; mid-wait
  disable releases the FSM so dropping the opcode never strands the
  radar between frames.
- radar_receiver_final.v: thread stm32_beam_ready_async +
  host_handshake_enable + beam_handshake_watchdog_fired through the
  scheduler instance.
- radar_system_top.v: rename input port stm32_new_chirp → stm32_beam_ready;
  add host_handshake_enable register (cold-reset = 1'b0); opcode 0x1A
  dispatch (value[0]); add rx_beam_handshake_watchdog wire; pack into
  status_words[4][1] in both USB paths.
- radar_system_top_50t.v: rename wrapper port + sub-instance wiring.
- usb_data_interface.v + usb_data_interface_ft2232h.v: add
  status_beam_handshake_watchdog input + 2-FF level CDC (same convention
  as F-6.4 / F-1.2 stickies); refresh word-4 layout doc comment; pack
  beam_handshake_wd_sync_1 into status_words[4][1].

XDC:
- xc7a50t_ftg256.xdc + xc7a200t_fbg484.xdc: rename stm32_new_chirp port
  references to stm32_beam_ready (same PD8 pin, F13 on 50T / L18 on
  200T).

MCU:
- main.h: add FPGA_BEAM_READY_Pin = GPIO_PIN_8 + FPGA_BEAM_READY_GPIO_Port
  = GPIOD alongside the existing FPGA_FRAME_PULSE alias.
- main.cpp:runRadarPulseSequence: insert HAL_GPIO_TogglePin(GPIOD,
  GPIO_PIN_8) after each setCustomBeamPattern16(RX) — once after the
  per-azimuth broadside (vector_0), once after matrix1, once after
  matrix2 — between the SPI burst completion and waitForFramePulse.

GUI:
- radar_protocol.py: Opcode.HANDSHAKE_ENABLE = 0x1A;
  StatusResponse.beam_handshake_watchdog = 0 default; parse word 4 bit
  [1] in parse_status_packet; update word-4 layout comment.
- test_GUI_V65_Tk.py: add beam_handshake_watchdog kwarg to
  _make_status_packet (sets bit [1] of word 4); refresh
  test_parse_status_word4_layout_co_spec to cover the new bit (used+9=32);
  add test_parse_status_beam_handshake_watchdog round-trip;
  test_handshake_enable_opcode pins 0x1A; defaults / chirps_mismatch /
  agc-coexist tests gain a watchdog==0 assertion; bump
  test_all_rtl_opcodes_present expected set to include 0x17/0x18/0x19/0x1A.

TB:
- new tb_chirp_scheduler_handshake.v (16 checks): legacy open-loop, edge
  exit (rising + falling), 200-cycle idle hold, watchdog auto-advance
  via force on dut.beam_watchdog, sticky-survives-mixers_disable,
  mid-wait disable release, reset_n clears sticky.
- run_regression.sh: register the new TB in PHASE 1.
- tb_radar_receiver_final.v: tie the 3 new receiver ports off
  (beam_ready_async=0, handshake_enable=0, watchdog unconnected).
- tb_system_mechanics.v / tb_system_opcodes.v: explicit
  .stm32_beam_ready(1'b0) connection (the cold-reset
  host_handshake_enable=0 keeps the FSM out of S_BEAM_WAIT).
- tb_usb_data_interface.v / tb_usb_protocol_v2.v / tb_e2e_dsp_to_host.v /
  tb_ft2232h_frame_drop.v: tie .status_beam_handshake_watchdog(1'b0).

Ride-along ruff sweep (14 → 0 across the repo):
- tb/cosim/compare_independent.py: RUF003 — '5×' → 'at least 5x'.
- tb/cosim/gen_e2e_expected.py: noqa: E402 on the post-sys.path import;
  drop unused EXPECTED_RANGE_BIN + EXPECTED_DOPPLER_BIN_PER_SF imports;
  fold the detect-class slot if/else into a ternary (SIM108).
- tb/cosim/gen_e2e_stimulus.py: drop int() wrapping round() at four
  call sites (RUF046 — round() already returns int in Python 3);
  rewrite the range-bin derivation comment block from code-like
  `# range_bin = ...` to prose (ERA001); strip stray f from
  placeholder-free error string (F541).
- tb/cosim/tb_e2e_dsp_to_host_parse.py: open(path, 'r') → open(path)
  (UP015).
- v7/dashboard.py: '3×' → '3x' (RUF003); drop quotes from
  'StatusResponse | None' annotation (UP037, file already has
  `from __future__ import annotations`).

CI summary (all suites green pre-commit):
- ruff: All checks passed!
- FPGA regression (iverilog): 43 / 0 / 0 (incl. new handshake TB 16/16).
- MCU tests: 51 / 0 + 34 / 0 + 13 / 13 ADAR1000_AGC.
- GUI Tk (test_GUI_V65_Tk): 120 / 0.
- GUI v7 (test_v7): 152 / 0.

Production rollout note: bitstream cold-resets with host_handshake_enable=0
so existing flashes keep their open-loop cadence until the GUI sends
opcode 0x1A=1. Once enabled, the per-pattern dwell tracks both the chirp
ladder (PD14 frame_pulse from commit-3 work) and the MCU pattern-write
completion (PD8 toggle from this commit), eliminating drift from the SPI
burst timing.
This commit is contained in:
Jason
2026-05-11 12:07:08 +05:45
parent 2e2c10baeb
commit 4989c33aa6
26 changed files with 642 additions and 86 deletions
+20 -4
View File
@@ -136,6 +136,7 @@ class Opcode(IntEnum):
0x17 host_medium_chirp_cycles (PR-G G2)
0x18 host_medium_listen_cycles (PR-G G2)
0x19 host_subframe_enable (PR-U / M-8 — 3-bit {LONG, MED, SHORT} mask)
0x1A host_handshake_enable (PR-AB.b expanded commit 5 — beam-ready stall)
PR-AB.b expanded retired opcodes 0x01 (host_radar_mode),
0x02 (host_trigger_pulse), 0x20 (host_range_mode).
@@ -165,6 +166,12 @@ class Opcode(IntEnum):
# host CRT downgrades confidence to UNKNOWN (dbin // 16 attribution would
# otherwise be wrong when the scheduler skips a sub-frame).
SUBFRAME_ENABLE = 0x19
# PR-AB.b expanded commit 5: beam-ready handshake enable. value[0]=1 makes
# chirp_scheduler stall in S_BEAM_WAIT after frame_pulse until the MCU
# toggles PD8, or the ~80 ms watchdog expires (status word 4 bit [1] is
# the sticky watchdog flag). FPGA cold-reset = 0 — host opts in once the
# MCU PD8 wiring is verified on the bench.
HANDSHAKE_ENABLE = 0x1A
# --- Signal processing (0x21-0x27;
# 0x20 host_range_mode retired in PR-AB.b expanded) ---
@@ -260,14 +267,20 @@ class StatusResponse:
agc_saturation_count: int = 0 # 8-bit saturation count [7:0]
agc_enable: int = 0 # 1-bit AGC enable readback
chirps_mismatch: int = 0 # TX-G: 1 if FPGA clamped/rejected host chirps_per_elev
# PR-AB.b expanded commit 5: sticky watchdog from chirp_scheduler S_BEAM_WAIT.
# 1 means at least one frame elapsed without an MCU PD8 ack within ~80 ms;
# cleared only by full reset_n on the FPGA. Reserved 0 when host has not
# enabled the handshake (opcode 0x1A=1).
beam_handshake_watchdog: int = 0 # word 4 bit [1]
# PR-G 2-tier CFAR telemetry (word 6)
detect_count_cand: int = 0 # 16-bit count of CAND-tier detections per frame
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)
# opcode 0x17/0x18 readback (16-bit each, default RP_DEF_MEDIUM_*_CYCLES).
medium_chirp: int = 0
medium_listen: int = 0
# ============================================================================
@@ -373,9 +386,12 @@ class RadarProtocol:
# Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]}
sr.chirps_per_elev = words[3] & 0x3F
sr.short_listen = (words[3] >> 16) & 0xFFFF
# Word 4 layout: gain[31:28] peak[27:20] sat[19:12] agc_en[11] mismatch[10] reserved[1:0]
# PR-AB.b expanded: bits [1:0] formerly range_mode, now reserved 0.
# Word 4 layout: gain[31:28] peak[27:20] sat[19:12] agc_en[11]
# mismatch[10] alpha_soft[9:2] beam_handshake_watchdog[1] reserved[0]
# PR-AB.b expanded commit 5 reclaimed bit [1] for the handshake watchdog
# sticky (FPGA chirp_scheduler S_BEAM_WAIT, ~80 ms timeout).
sr.chirps_mismatch = (words[4] >> 10) & 0x01
sr.beam_handshake_watchdog = (words[4] >> 1) & 0x01
sr.agc_enable = (words[4] >> 11) & 0x01
sr.agc_saturation_count = (words[4] >> 12) & 0xFF
sr.agc_peak_magnitude = (words[4] >> 20) & 0xFF
+52 -21
View File
@@ -133,6 +133,7 @@ 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,
beam_handshake_watchdog=0,
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."""
@@ -156,13 +157,18 @@ class TestRadarProtocol(unittest.TestCase):
w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F)
pkt += struct.pack(">I", w3)
# Word 4: {agc_current_gain[3:0], agc_peak_magnitude[7:0],
# agc_saturation_count[7:0], agc_enable,
# chirps_mismatch[10], 10'd0 reserved [9:0]}
# PR-AB.b expanded: bits [1:0] formerly range_mode, now reserved 0.
# Word 4: {agc_current_gain[31:28], agc_peak_magnitude[27:20],
# agc_saturation_count[19:12], agc_enable[11],
# chirps_mismatch[10], reserved[9:2],
# beam_handshake_watchdog[1], reserved[0]}
# PR-AB.b expanded commit 5: bit [1] is the chirp_scheduler
# S_BEAM_WAIT sticky watchdog flag (reset_n clear). The 8-bit
# alpha_soft echo at [9:2] is FT2232H-only; this co-spec
# builder leaves [9:2] reserved-0 like the FT601 path.
w4 = (((agc_gain & 0x0F) << 28) | ((agc_peak & 0xFF) << 20) |
((agc_sat & 0xFF) << 12) | ((agc_enable & 0x01) << 11) |
((chirps_mismatch & 0x01) << 10))
((chirps_mismatch & 0x01) << 10) |
((beam_handshake_watchdog & 0x01) << 1))
pkt += struct.pack(">I", w4)
# Word 5: {frame_drop[31:25], self_test_busy[24], 8'd0,
@@ -196,6 +202,7 @@ class TestRadarProtocol(unittest.TestCase):
self.assertEqual(sr.short_listen, 17450)
self.assertEqual(sr.chirps_per_elev, 32)
self.assertEqual(sr.chirps_mismatch, 0)
self.assertEqual(sr.beam_handshake_watchdog, 0)
def test_parse_status_chirps_mismatch(self):
# TX-G: bit 10 of word 4 must round-trip without disturbing neighbours.
@@ -203,6 +210,16 @@ class TestRadarProtocol(unittest.TestCase):
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.chirps_mismatch, 1)
self.assertEqual(sr.agc_enable, 1)
self.assertEqual(sr.beam_handshake_watchdog, 0)
def test_parse_status_beam_handshake_watchdog(self):
# PR-AB.b expanded commit 5: bit 1 of word 4 must round-trip without
# bleeding into chirps_mismatch (bit 10) or the reserved [0] bit.
raw = self._make_status_packet(beam_handshake_watchdog=1)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.beam_handshake_watchdog, 1)
self.assertEqual(sr.chirps_mismatch, 0)
self.assertEqual(sr.agc_enable, 0)
def test_parse_status_too_short(self):
# Anything under STATUS_PACKET_SIZE (34 post-M-5) must be rejected.
@@ -262,14 +279,20 @@ class TestRadarProtocol(unittest.TestCase):
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:0] reserved (10 bits, must be zero from builder)
(was [9:2] + range_mode[1:0]; range_mode retired in
PR-AB.b expanded)
[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 / alpha_soft (8 bits FT601 leaves zero; FT2232H
echoes host_cfar_alpha_soft. This
co-spec builder uses the FT601
layout so the builder remains
path-agnostic.)
[1] beam_handshake_watchdog (1-bit sticky, PR-AB.b expanded
commit 5)
[0] reserved (1-bit; was range_mode[0], retired
in PR-AB.b expanded)
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
@@ -278,16 +301,19 @@ class TestRadarProtocol(unittest.TestCase):
"""
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"),
("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"),
("beam_handshake_watchdog", "beam_handshake_watchdog",
1, 1, "beam_handshake_watchdog"),
]
# Sanity: layout fields + reserved [9:0] must cover exactly 32 bits.
# Sanity: layout fields + reserved [9:2] (8 bits) + reserved [0] (1 bit)
# must cover exactly 32 bits.
used = sum(width for _, _, _, width, _ in layout)
self.assertEqual(used + 10, 32,
"word 4 layout (incl. reserved [9:0]) must total 32 bits")
self.assertEqual(used + 9, 32,
"word 4 layout (incl. reserved [9:2] + [0]) must total 32 bits")
# No two fields may overlap.
occupied = set()
@@ -1002,6 +1028,10 @@ class TestOpcodeEnum(unittest.TestCase):
self.assertEqual(Opcode.DETECT_THRESHOLD, 0x03)
self.assertEqual(Opcode.STREAM_CONTROL, 0x04)
def test_handshake_enable_opcode(self):
"""PR-AB.b expanded commit 5: beam-ready handshake opcode = 0x1A."""
self.assertEqual(Opcode.HANDSHAKE_ENABLE, 0x1A)
def test_all_rtl_opcodes_present(self):
"""Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member.
@@ -1010,6 +1040,7 @@ class TestOpcodeEnum(unittest.TestCase):
"""
expected = {0x03, 0x04,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
0x17, 0x18, 0x19, 0x1A,
0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x28, 0x29, 0x2A, 0x2B, 0x2C,
0x30, 0x31, 0xFF}
+2 -2
View File
@@ -994,7 +994,7 @@ class RadarDashboard(QMainWindow):
spin.setValue(default)
# PR-AB.b: setFixedWidth (not min/max) — QHBoxLayout would otherwise
# squeeze the spinbox toward its minimum on rows where the hint is
# longer than its peers (the AGC Enable hint is ~3× longer than the
# longer than its peers (the AGC Enable hint is ~3x longer than the
# others and was rendering at ~90 px while siblings hit ~160).
spin.setFixedWidth(120)
row.addWidget(spin)
@@ -2119,7 +2119,7 @@ class RadarDashboard(QMainWindow):
# the last StatusResponse if any, otherwise the static defaults.
self._refresh_agc_mode_labels(self._last_status)
def _refresh_agc_mode_labels(self, st: "StatusResponse | None"):
def _refresh_agc_mode_labels(self, st: StatusResponse | None):
"""Update the AGC enable text on both the FPGA Control Status box
(self._agc_labels['enable']) and the AGC Monitor strip
(self._agc_mode_lbl). In production the firmware ignores the FPGA