diff --git a/9_Firmware/9_3_GUI/radar_protocol.py b/9_Firmware/9_3_GUI/radar_protocol.py index 09f4443..e496008 100644 --- a/9_Firmware/9_3_GUI/radar_protocol.py +++ b/9_Firmware/9_3_GUI/radar_protocol.py @@ -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 diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index 95a06a9..8a445ee 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -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 # ============================================================================= diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index 1a1ad77..be475d6 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -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)") diff --git a/9_Firmware/9_3_GUI/v7/software_fpga.py b/9_Firmware/9_3_GUI/v7/software_fpga.py index ca44972..0f19b7b 100644 --- a/9_Firmware/9_3_GUI/v7/software_fpga.py +++ b/9_Firmware/9_3_GUI/v7/software_fpga.py @@ -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