feat(gui): PR-R — host control surface fill-in (audit M-2/M-3/M-4/M-6/M-7)

The RTL has been ahead of the host opcode/widget surface since PR-G:
several runtime knobs (MEDIUM PRI, soft-CFAR alpha, ADC power-down) are
fully wired in radar_system_top.v but had no enum / spinbox path, so
the operator could only reach them via raw _send_custom_command. This
PR closes the gap for everything except M-5 (status-packet medium PRI
readback, which needs an RTL change to add a status word).

M-2 — Opcode enum gains MEDIUM_CHIRP=0x17, MEDIUM_LISTEN=0x18,
       CFAR_ALPHA_SOFT=0x2D. Truth-table docstring refreshed.
       Two new spinboxes in Waveform Timing ("Medium Chirp Cycles",
       "Medium Listen Cycles") with the V2 defaults 500 / 15600 (5 us
       chirp, 161 us PRI). One new spinbox in Detection (CFAR)
       ("CFAR Alpha Soft (Q4.4)") with the RP_DEF_CFAR_ALPHA_SOFT=0x18
       default.

M-3 — ADC_PWDN=0x32 added to the enum (was previously commented as
       "reserved for S-25"; the fix landed at radar_system_top.v:1152
       routing to the physical adc_pwdn pin). New "ADC (AD9484)"
       group on the right column with two buttons: ADC Normal (0x32=0)
       and ADC Power Down (0x32=1). Buttons rather than a spinbox
       prevent accidental non-{0,1} values.

M-4 — ADC_FORMAT widget added to the same ADC group: a 2-choice combo
       ("Offset-binary (SJ1 1-2)" vs "Two's-complement (SJ1 2-3)") with
       a Set button, since AD9484 SPI is tied off (CSB high) and the
       only way to flip sign convention is via this opcode.

M-6 — Replay opcode dispatch in _dispatch_to_software_fpga() expanded:
       SoftwareFPGA gains cfar_alpha_soft mirror + setter; 0x2D wired
       through. RTL-only opcodes (chirp timing, range mode, ADC strap,
       self-test, status_request) are no longer silently dropped — they
       log at info-level "acknowledged (no effect on replay — RTL-only
       state)" so the operator gets visible feedback.

M-7 — Chirps Per Elevation widget default 32 -> 48; hint changed from
       "1-32, clamped" to "must be 48 (RTL clamps)". RTL latches
       chirps_mismatch_error in status word 4 bit 10 for any value != 48
       since PR-F. Bonus: SHORT defaults bumped 50/17450 -> 100/17400 to
       match RP_DEF_SHORT_*_CYCLES_V2 (PR-E 1-us SHORT chirp width).

Tests: +10 (TestOpcodeEnumFillIn 5, TestSoftwareFpgaCfarAlphaSoft 2,
       TestReplayOpcodeDispatch 3). 247/247 PASS. Ruff clean.

M-5 (status packet medium_chirp/medium_listen readback) deferred —
needs an RTL change to extend status_words from 7 to 8 (current word 3
has only 10 reserved bits, not enough for two 16-bit fields).
This commit is contained in:
Jason
2026-05-02 17:03:09 +05:45
parent 115c5f0778
commit c2637251b0
4 changed files with 198 additions and 32 deletions

View File

@@ -111,17 +111,19 @@ class Opcode(IntEnum):
"""Host register opcodes — must match radar_system_top.v case(usb_cmd_opcode).
FPGA truth table (from radar_system_top.v opcode dispatch case-block):
0x01 host_radar_mode 0x16 host_gain_shift
0x02 host_trigger_pulse 0x17 host_medium_chirp_cycles (M-2 — no enum yet)
0x03 host_detect_threshold 0x18 host_medium_listen_cycles (M-2 — no enum yet)
0x04 host_stream_control 0x20 host_range_mode
0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch
0x11 host_long_listen_cycles 0x28-0x2C AGC control
0x12 host_guard_cycles 0x2D host_cfar_alpha_soft (M-2 — no enum yet)
0x13 host_short_chirp_cycles 0x30 host_self_test_trigger
0x14 host_short_listen_cycles 0x31/0xFF host_status_request
0x15 host_chirps_per_elev 0x32 host_adc_pwdn (M-3 — no enum yet)
0x33 host_adc_format (AD9484 SCLK/DFS strap; AUDIT-C3)
0x01 host_radar_mode 0x20 host_range_mode
0x02 host_trigger_pulse 0x21-0x27 CFAR / MTI / DC-notch
0x03 host_detect_threshold 0x28-0x2C AGC control
0x04 host_stream_control 0x2D host_cfar_alpha_soft
0x10 host_long_chirp_cycles 0x30 host_self_test_trigger
0x11 host_long_listen_cycles 0x31/0xFF host_status_request
0x12 host_guard_cycles 0x32 host_adc_pwdn
0x13 host_short_chirp_cycles 0x33 host_adc_format
0x14 host_short_listen_cycles
0x15 host_chirps_per_elev
0x16 host_gain_shift
0x17 host_medium_chirp_cycles (PR-G G2)
0x18 host_medium_listen_cycles (PR-G G2)
"""
# --- Basic control (0x01-0x04) ---
RADAR_MODE = 0x01 # 2-bit mode select
@@ -132,13 +134,17 @@ class Opcode(IntEnum):
# --- Digital gain (0x16) ---
GAIN_SHIFT = 0x16 # 4-bit digital gain shift
# --- Chirp timing (0x10-0x15) ---
# --- Chirp timing (0x10-0x18) ---
LONG_CHIRP = 0x10
LONG_LISTEN = 0x11
GUARD = 0x12
SHORT_CHIRP = 0x13
SHORT_LISTEN = 0x14
CHIRPS_PER_ELEV = 0x15
# PR-G G2 / PR-Q.1: MEDIUM ladder. Defaults RP_DEF_MEDIUM_*_CYCLES_V2 give
# PRI = 161 us so the 3-PRI CRT unfolder has 3 distinct PRIs (175/161/167).
MEDIUM_CHIRP = 0x17
MEDIUM_LISTEN = 0x18
# --- Signal processing (0x20-0x27) ---
RANGE_MODE = 0x20
@@ -157,18 +163,25 @@ class Opcode(IntEnum):
AGC_DECAY = 0x2B
AGC_HOLDOFF = 0x2C
# --- 2-tier CFAR soft threshold (0x2D, PR-G G1) ---
# 8-bit Q4.4 alpha for the soft (CAND) tier of the 2-class CFAR. Default
# RP_DEF_CFAR_ALPHA_SOFT = 0x18 (1.5 in Q4.4) corresponds to ~Pfa 1e-5.
CFAR_ALPHA_SOFT = 0x2D
# --- Board self-test / status (0x30-0x31, 0xFF) ---
SELF_TEST_TRIGGER = 0x30
SELF_TEST_STATUS = 0x31
STATUS_REQUEST = 0xFF
# --- AD9484 ADC sign-convention (0x33, AUDIT-C3) ---
# 2'b00 = offset-binary (default; SJ1 jumper pins 1-2 bridged)
# 2'b01 = two's-complement (SJ1 jumper pins 2-3 bridged)
# --- AD9484 ADC power + sign convention (0x32, 0x33; AUDIT-C3 / S-25) ---
# 0x32 ADC_PWDN: 1-bit power-down driving the AD9484 PWDN pin
# (radar_system_top.v -> physical adc_pwdn). 0=normal, 1=PD.
# 0x33 ADC_FORMAT: 2'b00 = offset-binary (SJ1 pins 1-2 bridged, default),
# 2'b01 = two's-complement (SJ1 pins 2-3 bridged).
# AD9484 CSB is hard-tied HIGH on the Main Board (SPI unavailable);
# this opcode lets the host adapt the DDC to the physical strap
# 0x33 lets the host adapt the DDC sign convention to the physical strap
# without rebuilding the bitstream.
# (Opcode 0x32 is reserved for the future AUDIT-S25 adc_pwdn fix.)
ADC_PWDN = 0x32
ADC_FORMAT = 0x33

View File

@@ -1627,6 +1627,85 @@ class TestDashboardConfidenceDisplay(unittest.TestCase):
self.assertEqual(color.name().upper(), DARK_TEXT.upper())
# =============================================================================
# Test: PR-R / audit M-2..M-7 — host control surface fill-in
# =============================================================================
class TestOpcodeEnumFillIn(unittest.TestCase):
"""M-2 + M-3: enum gains MEDIUM_CHIRP, MEDIUM_LISTEN, CFAR_ALPHA_SOFT, ADC_PWDN."""
def test_medium_chirp_listen_opcodes(self):
from radar_protocol import Opcode
self.assertEqual(Opcode.MEDIUM_CHIRP.value, 0x17)
self.assertEqual(Opcode.MEDIUM_LISTEN.value, 0x18)
def test_cfar_alpha_soft_opcode(self):
from radar_protocol import Opcode
self.assertEqual(Opcode.CFAR_ALPHA_SOFT.value, 0x2D)
def test_adc_pwdn_opcode(self):
from radar_protocol import Opcode
self.assertEqual(Opcode.ADC_PWDN.value, 0x32)
def test_adc_format_opcode_unchanged(self):
from radar_protocol import Opcode
self.assertEqual(Opcode.ADC_FORMAT.value, 0x33)
def test_no_duplicate_opcodes(self):
"""All Opcode values are unique (catches accidental collisions)."""
from radar_protocol import Opcode
values = [op.value for op in Opcode]
self.assertEqual(len(values), len(set(values)),
"duplicate opcode values would silently shadow earlier entries")
class TestSoftwareFpgaCfarAlphaSoft(unittest.TestCase):
"""M-6: SoftwareFPGA mirrors the soft-tier alpha and clamps to 8 bits."""
def test_default(self):
from v7.software_fpga import SoftwareFPGA
fpga = SoftwareFPGA()
self.assertEqual(fpga.cfar_alpha_soft, 0x18) # RP_DEF_CFAR_ALPHA_SOFT
def test_setter_masks_to_8_bits(self):
from v7.software_fpga import SoftwareFPGA
fpga = SoftwareFPGA()
fpga.set_cfar_alpha_soft(0x1234)
self.assertEqual(fpga.cfar_alpha_soft, 0x34)
@unittest.skipUnless(_pyqt6_available(), "PyQt6 not installed")
class TestReplayOpcodeDispatch(unittest.TestCase):
"""M-6: replay dispatch routes 0x2D to SoftwareFPGA + acknowledges inert opcodes."""
def _dashboard_with_replay(self):
"""Build a minimal dashboard-like object: just what _dispatch_to_software_fpga needs."""
from v7.software_fpga import SoftwareFPGA
from v7.dashboard import RadarDashboard
# Bypass full QMainWindow init — call the unbound method against a
# fake `self` that only carries the two attributes the dispatch reads.
class _Fake:
pass
fake = _Fake()
fake._software_fpga = SoftwareFPGA()
return RadarDashboard._dispatch_to_software_fpga, fake
def test_0x2d_routed_to_set_cfar_alpha_soft(self):
dispatch, fake = self._dashboard_with_replay()
dispatch(fake, 0x2D, 42)
self.assertEqual(fake._software_fpga.cfar_alpha_soft, 42)
def test_inert_opcode_does_not_raise(self):
"""Inert opcodes (e.g. 0x32 ADC_PWDN) accepted without exception."""
dispatch, fake = self._dashboard_with_replay()
for inert in (0x10, 0x15, 0x17, 0x18, 0x20, 0x32, 0x33, 0xFF):
dispatch(fake, inert, 1) # should not raise
def test_unknown_opcode_does_not_raise(self):
dispatch, fake = self._dashboard_with_replay()
dispatch(fake, 0xEE, 0) # unmapped — debug-log only, no exception
# =============================================================================
# Helper: lazy import of v7.models
# =============================================================================

View File

@@ -759,13 +759,21 @@ class RadarDashboard(QMainWindow):
grp_wf = QGroupBox("Waveform Timing")
wf_layout = QVBoxLayout(grp_wf)
# PR-R / M-2: MEDIUM chirp+listen exposed (RTL has had 0x17/0x18 since
# PR-G G2; defaults RP_DEF_MEDIUM_*_CYCLES_V2 = 500 / 15600 give
# PRI = 161 us for the 3-PRI ladder).
# PR-R / M-7: CHIRPS_PER_ELEV default 32 -> 48 to match PR-F's
# RP_CHIRPS_PER_FRAME = 48; FPGA latches `chirps_mismatch_error` for
# any value other than 48, so the spinbox cannot offer 32 anymore.
wf_params = [
("Long Chirp Cycles", 0x10, 3000, 16, "0-65535, rst=3000"),
("Long Listen Cycles", 0x11, 13700, 16, "0-65535, rst=13700"),
("Guard Cycles", 0x12, 17540, 16, "0-65535, rst=17540"),
("Short Chirp Cycles", 0x13, 50, 16, "0-65535, rst=50"),
("Short Listen Cycles", 0x14, 17450, 16, "0-65535, rst=17450"),
("Chirps Per Elevation", 0x15, 32, 6, "1-32, clamped"),
("Long Chirp Cycles", 0x10, 3000, 16, "0-65535, rst=3000"),
("Long Listen Cycles", 0x11, 13700, 16, "0-65535, rst=13700"),
("Guard Cycles", 0x12, 17540, 16, "0-65535, rst=17540"),
("Short Chirp Cycles", 0x13, 100, 16, "0-65535, rst=100 (1us @100MHz)"),
("Short Listen Cycles", 0x14, 17400, 16, "0-65535, rst=17400 (175us PRI)"),
("Medium Chirp Cycles", 0x17, 500, 16, "0-65535, rst=500 (5us @100MHz)"),
("Medium Listen Cycles", 0x18, 15600, 16, "0-65535, rst=15600 (161us PRI)"),
("Chirps Per Elevation", 0x15, 48, 6, "must be 48 (RTL clamps)"),
]
for label, opcode, default, bits, hint in wf_params:
self._add_fpga_param_row(wf_layout, label, opcode, default, bits, hint)
@@ -782,12 +790,15 @@ class RadarDashboard(QMainWindow):
grp_cfar = QGroupBox("Detection (CFAR)")
cfar_layout = QVBoxLayout(grp_cfar)
# PR-R / M-2: CFAR_ALPHA_SOFT (0x2D) is the soft-tier (CAND) threshold
# of the 2-class CFAR; default RP_DEF_CFAR_ALPHA_SOFT = 0x18 (1.5 Q4.4).
cfar_params = [
("CFAR Enable", 0x25, 0, 1, "0=off, 1=on"),
("CFAR Guard Cells", 0x21, 2, 4, "0-15, rst=2"),
("CFAR Train Cells", 0x22, 8, 5, "1-31, rst=8"),
("CFAR Alpha (Q4.4)", 0x23, 48, 8, "0-255, rst=0x30=3.0"),
("CFAR Mode", 0x24, 0, 2, "0=CA 1=GO 2=SO"),
("CFAR Enable", 0x25, 0, 1, "0=off, 1=on"),
("CFAR Guard Cells", 0x21, 2, 4, "0-15, rst=2"),
("CFAR Train Cells", 0x22, 8, 5, "1-31, rst=8"),
("CFAR Alpha (Q4.4)", 0x23, 48, 8, "0-255, rst=0x30=3.0"),
("CFAR Alpha Soft (Q4.4)", 0x2D, 24, 8, "0-255, rst=0x18=1.5"),
("CFAR Mode", 0x24, 0, 2, "0=CA 1=GO 2=SO"),
]
for label, opcode, default, bits, hint in cfar_params:
self._add_fpga_param_row(cfar_layout, label, opcode, default, bits, hint)
@@ -846,6 +857,41 @@ class RadarDashboard(QMainWindow):
right_layout.addWidget(grp_agc)
# ── ADC (AD9484) ──────────────────────────────────────────────
# PR-R / M-3 + M-4: AD9484 has SPI tied off (CSB high) so all runtime
# control is via these two opcodes. PWDN is the physical AD9484
# power-down pin; FORMAT switches the DDC sign convention to match
# the SJ1 strap (offset-binary vs two's-complement).
grp_adc = QGroupBox("ADC (AD9484)")
adc_layout = QVBoxLayout(grp_adc)
# Power-down toggle (0x32). Two buttons rather than a spinbox so
# the operator cannot accidentally type a non-{0,1} value.
adc_pd_row = QHBoxLayout()
btn_adc_normal = QPushButton("ADC Normal")
btn_adc_normal.clicked.connect(lambda: self._send_fpga_cmd(0x32, 0))
adc_pd_row.addWidget(btn_adc_normal)
btn_adc_pd = QPushButton("ADC Power Down")
btn_adc_pd.clicked.connect(lambda: self._send_fpga_cmd(0x32, 1))
adc_pd_row.addWidget(btn_adc_pd)
adc_layout.addLayout(adc_pd_row)
# Sign-convention combo (0x33). 0 = offset-binary (default), 1 = two's-
# complement. _add_fpga_param_row would force a spinbox, so do it inline.
adc_fmt_row = QHBoxLayout()
adc_fmt_row.addWidget(QLabel("ADC Format:"))
self._adc_format_combo = QComboBox()
self._adc_format_combo.addItem("Offset-binary (SJ1 1-2)", 0)
self._adc_format_combo.addItem("Two's-complement (SJ1 2-3)", 1)
adc_fmt_row.addWidget(self._adc_format_combo, stretch=1)
btn_adc_fmt = QPushButton("Set")
btn_adc_fmt.clicked.connect(
lambda: self._send_fpga_cmd(0x33, self._adc_format_combo.currentData()))
adc_fmt_row.addWidget(btn_adc_fmt)
adc_layout.addLayout(adc_fmt_row)
right_layout.addWidget(grp_adc)
# Custom Command
grp_custom = QGroupBox("Custom Command")
cust_layout = QGridLayout(grp_custom)
@@ -1600,11 +1646,21 @@ class RadarDashboard(QMainWindow):
self._replay_frame_label.setText(f"{current} / {total}")
def _dispatch_to_software_fpga(self, opcode: int, value: int):
"""Route an FPGA opcode+value to the SoftwareFPGA setter."""
"""Route an FPGA opcode+value to the SoftwareFPGA setter.
PR-R / M-6: opcodes split into three classes:
- APPLIED — affect the host signal-processing chain in replay
(CFAR, MTI, DC-notch, AGC mirror, gain, threshold).
- INERT — RTL-only state with no effect on already-recorded
playback (chirp timing, range mode, ADC controls,
self-test, status). Logged at info so the operator
sees the change was acknowledged but not applied.
- UNKNOWN — unmapped opcode, debug-only.
"""
fpga = self._software_fpga
if fpga is None:
return
_opcode_dispatch = {
applied = {
0x03: lambda v: fpga.set_detect_threshold(v),
0x16: lambda v: fpga.set_gain_shift(v),
0x21: lambda v: fpga.set_cfar_guard(v),
@@ -1619,11 +1675,25 @@ class RadarDashboard(QMainWindow):
0x2A: lambda v: fpga.set_agc_params(attack=v),
0x2B: lambda v: fpga.set_agc_params(decay=v),
0x2C: lambda v: fpga.set_agc_params(holdoff=v),
0x2D: lambda v: fpga.set_cfar_alpha_soft(v),
}
handler = _opcode_dispatch.get(opcode)
# Inert in replay: RTL-only chirp timing / range mode / self-test /
# status / ADC strap. The recorded I/Q already reflects whatever
# values were active at capture time; changing them now would
# require re-running the chirp generator.
inert = {
0x01, 0x02, 0x04,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x17, 0x18,
0x20, 0x30, 0x31, 0x32, 0x33, 0xFF,
}
handler = applied.get(opcode)
if handler is not None:
handler(value)
logger.info(f"SoftwareFPGA: 0x{opcode:02X} = {value}")
elif opcode in inert:
logger.info(
f"SoftwareFPGA: 0x{opcode:02X} = {value} acknowledged "
f"(no effect on replay — RTL-only state)")
else:
logger.debug(f"SoftwareFPGA: opcode 0x{opcode:02X} not handled (no-op)")

View File

@@ -91,7 +91,8 @@ class SoftwareFPGA:
self.cfar_enable: bool = False # 0x25
self.cfar_guard: int = 2 # 0x21
self.cfar_train: int = 8 # 0x22
self.cfar_alpha: int = 0x30 # 0x23 Q4.4
self.cfar_alpha: int = 0x30 # 0x23 Q4.4 (CONFIRM tier)
self.cfar_alpha_soft: int = 0x18 # 0x2D Q4.4 (CAND tier, PR-G)
self.cfar_mode: int = 0 # 0x24 0=CA,1=GO,2=SO
# MTI
@@ -129,6 +130,9 @@ class SoftwareFPGA:
def set_cfar_alpha(self, val: int) -> None:
self.cfar_alpha = int(val) & 0xFF
def set_cfar_alpha_soft(self, val: int) -> None:
self.cfar_alpha_soft = int(val) & 0xFF
def set_cfar_mode(self, val: int) -> None:
self.cfar_mode = int(val) & 0x03