feat(fpga,mcu,gui): PR-AB.b — drift-free dwell sync via DIG_6 frame_pulse + AGC always-on policy

FPGA (Phase 1+2):
- gpio_dig6 (PD14) now carries chirp_scheduler frame_pulse, FPGA-stretched
  to ~100 ns so the STM32 EXTI on PD14 can latch reliably.
- gpio_dig7 (PD15) returns to its pre-PR-AB.b role: control-fault OR
  (range_decim_watchdog | CDC overrun); MCU stuck-high sampler unchanged.
- rx_range_decim_watchdog gains a sticky in source clock domain so a slow
  status poll cannot miss a 1-cycle assertion (Phase 1).
- New tb_dig6_frame_pulse.v (13 checks); tb_status_words_stickies.v extended
  with DIG_7 fault-OR coverage (14 checks); retired tb_audit_s10_gpio_split.v.
- Port comments in radar_system_top.v / _50t.v and XDC roles refreshed.

MCU (Phase 3):
- PD14 reconfigured to GPIO_MODE_IT_RISING + GPIO_PULLDOWN; new
  EXTI15_10_IRQHandler in stm32f7xx_it.c dispatches to HAL_GPIO_EXTI_Callback
  that bumps a volatile g_frame_pulse_count.
- runRadarPulseSequence dwell loop replaces 3x HAL_Delay(8) with
  waitForFramePulse(20) — per-pattern dwell now tracks the actual mask-aware
  ladder length (drift-free, mask-aware), with a 20 ms timeout safety net.
- AGC outer loop is ALWAYS-ON in production (compile-time policy); bench
  builds compile the body out via -DMCU_AGC_FORCE_DISABLED. The runtime
  enable/debounce + DIG_6 polling that previously gated AGC are removed.
- main.h adds FPGA_FRAME_PULSE_* aliases pointing at FPGA_DIG6_*.

GUI (Phase 4):
- Settings tab gains a Bench / Diagnostics group with a BENCH-MODE checkbox
  (off by default, persisted via QSettings).
- AGC group header swaps between a green "AGC: ALWAYS-ON" badge (production)
  and Enable/Disable AGC buttons (bench), pinned to the top of the group.
  The redundant 0/1 spinbox row for opcode 0x28 is removed — buttons send
  the same opcode and cannot accept invalid input.
- Both the FPGA Control AGC Status box and the AGC Monitor strip share a
  helper that honours bench-mode in production (always shows ALWAYS-ON in
  green so the two views never disagree with the badge).
- _add_fpga_param_row uses setFixedWidth on label and Set button + explicit
  stretch=1 on the hint, so all rows align column-wise whether they sit
  directly in a QVBoxLayout or inside a wrapper QWidget.

Regression: FPGA 42/0/0 (PR-M.4 baseline) - MCU 34/34 - GPS extended 51/51
- GUI v7 150/150 - BENCH-MODE flip behaviorally verified.
Hardware-blocked steps deferred: bench-scope verify (PD14 dwell pulse,
counter advance, PD15 stuck-high recovery still triggers).

Closes #182.
This commit is contained in:
Jason
2026-05-07 13:29:48 +05:45
parent b215caa294
commit ada170ef1f
11 changed files with 655 additions and 257 deletions
+161 -28
View File
@@ -40,7 +40,7 @@ from PyQt6.QtWidgets import (
QTableWidget, QTableWidgetItem, QHeaderView,
QPlainTextEdit, QStatusBar, QMessageBox,
)
from PyQt6.QtCore import Qt, QLocale, QTimer, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtCore import Qt, QLocale, QTimer, QSettings, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtGui import QColor
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg
@@ -208,6 +208,18 @@ class RadarDashboard(QMainWindow):
# FPGA control parameter widgets
self._param_spins: dict = {} # opcode_hex -> QSpinBox
# PR-AB.b: BENCH-MODE persistent flag (advanced settings checkbox).
# OFF (default, production): AGC is ALWAYS-ON in MCU firmware; AGC
# Enable spinbox + Enable/Disable AGC buttons are hidden so an operator
# cannot send opcode 0x28 and confuse themselves about whether the MCU
# is honouring the register (it doesn't — see main.cpp #ifndef
# MCU_AGC_FORCE_DISABLED). A coloured "ALWAYS-ON" badge replaces them.
# ON: bench / debug build assumed, AGC controls are exposed; the user
# is expected to know the MCU is compiled with MCU_AGC_FORCE_DISABLED
# and that opcode 0x28 only sets the FPGA register (display-only).
self._qsettings = QSettings("AERIS-10", "RadarDashboardV7")
self._bench_mode: bool = bool(self._qsettings.value("bench_mode", False, type=bool))
# AGC visualization history (ring buffers)
self._agc_history_len = 256
self._agc_gain_history: deque[int] = deque(maxlen=self._agc_history_len)
@@ -375,6 +387,11 @@ class RadarDashboard(QMainWindow):
self._create_diagnostics_tab()
self._create_settings_tab()
# PR-AB.b: apply persisted BENCH-MODE state to AGC widget visibility.
# Has to run AFTER _create_fpga_control_tab (creates the AGC widgets)
# AND _create_settings_tab (creates the checkbox).
self._apply_bench_mode_visibility()
# -----------------------------------------------------------------
# TAB 1: Main View
# -----------------------------------------------------------------
@@ -816,11 +833,48 @@ class RadarDashboard(QMainWindow):
right_layout.addWidget(grp_cfar)
# ── AGC (Automatic Gain Control) ──────────────────────────────
# PR-AB.b: The MCU's outer-loop AGC is ALWAYS-ON in production builds
# (compile-time policy, see main.cpp #ifndef MCU_AGC_FORCE_DISABLED).
# Bench/debug builds compile the AGC body out, and the runtime enable
# bit becomes meaningful only as an FPGA-side display register. We
# therefore hide the AGC Enable spinbox + Enable/Disable buttons in
# production (BENCH-MODE OFF) and show a "ALWAYS-ON" badge instead.
# Bench engineers tick the BENCH-MODE checkbox in Settings to expose
# the controls.
grp_agc = QGroupBox("AGC (Auto Gain)")
agc_layout = QVBoxLayout(grp_agc)
# Header row — exactly one of these is visible at a time:
# production: ALWAYS-ON badge.
# bench: Enable/Disable AGC buttons (send opcode 0x28 with 0|1;
# the bit's only valid values, so a spinbox would add
# nothing but typo-risk).
# Tuning knobs sit below regardless, so the AGC group's "header"
# control is always at the top of the box.
self._agc_always_on_badge = QLabel(
"AGC: ALWAYS-ON (production policy — MCU runs every frame)"
)
self._agc_always_on_badge.setStyleSheet(
f"background-color: {DARK_SUCCESS}; color: white; "
"padding: 6px; font-weight: bold; border-radius: 3px;"
)
self._agc_always_on_badge.setWordWrap(True)
agc_layout.addWidget(self._agc_always_on_badge)
self._agc_toggle_container = QWidget()
agc_toggle_inner = QHBoxLayout(self._agc_toggle_container)
agc_toggle_inner.setContentsMargins(0, 0, 0, 0)
self._btn_agc_on = QPushButton("Enable AGC")
self._btn_agc_on.clicked.connect(lambda: self._send_fpga_cmd(0x28, 1))
agc_toggle_inner.addWidget(self._btn_agc_on)
self._btn_agc_off = QPushButton("Disable AGC")
self._btn_agc_off.clicked.connect(lambda: self._send_fpga_cmd(0x28, 0))
agc_toggle_inner.addWidget(self._btn_agc_off)
agc_layout.addWidget(self._agc_toggle_container)
# Tuning knobs — always visible. Target/attack/decay/holdoff drive the
# FPGA inner-loop register fields regardless of the MCU AGC build flag.
agc_params = [
("AGC Enable", 0x28, 0, 1, "0=manual, 1=auto"),
("AGC Target", 0x29, 200, 8, "0-255, peak target"),
("AGC Attack", 0x2A, 1, 4, "0-15, atten step"),
("AGC Decay", 0x2B, 1, 4, "0-15, gain-up step"),
@@ -829,16 +883,6 @@ class RadarDashboard(QMainWindow):
for label, opcode, default, bits, hint in agc_params:
self._add_fpga_param_row(agc_layout, label, opcode, default, bits, hint)
# AGC quick toggles
agc_row = QHBoxLayout()
btn_agc_on = QPushButton("Enable AGC")
btn_agc_on.clicked.connect(lambda: self._send_fpga_cmd(0x28, 1))
agc_row.addWidget(btn_agc_on)
btn_agc_off = QPushButton("Disable AGC")
btn_agc_off.clicked.connect(lambda: self._send_fpga_cmd(0x28, 0))
agc_row.addWidget(btn_agc_off)
agc_layout.addLayout(agc_row)
# AGC status readback labels
agc_st_group = QGroupBox("AGC Status")
agc_st_layout = QVBoxLayout(agc_st_group)
@@ -927,23 +971,38 @@ class RadarDashboard(QMainWindow):
row = QHBoxLayout()
lbl = QLabel(label)
lbl.setMinimumWidth(140)
# PR-AB.b: setFixedWidth (not min) so labels line up across rows
# regardless of whether the row sits directly in the group's
# QVBoxLayout or inside an intermediate QWidget container (the AGC
# Enable row uses _agc_enable_container so it can be hidden in
# production — its inner layout reflows differently and the
# minimum-width hint was being ignored, leaving the spinbox start
# ~67 px to the left of its peers).
lbl.setFixedWidth(140)
row.addWidget(lbl)
max_val = (1 << bits) - 1
spin = QSpinBox()
spin.setRange(0, max_val)
spin.setValue(default)
spin.setMinimumWidth(80)
# 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
# others and was rendering at ~90 px while siblings hit ~160).
spin.setFixedWidth(120)
row.addWidget(spin)
self._param_spins[f"0x{opcode:02X}"] = spin
# PR-AB.b: hint is the only flex element in the row — explicit stretch=1
# so it absorbs leftover space instead of competing with the Set button.
# Without this, a long hint (e.g. AGC Enable's "0=manual, 1=auto …")
# would shrink the Set button below its 60 px cap on the same row.
hint_lbl = QLabel(hint)
hint_lbl.setStyleSheet(f"color: {DARK_INFO}; font-size: 10px;")
row.addWidget(hint_lbl)
row.addWidget(hint_lbl, 1)
btn = QPushButton("Set")
btn.setMaximumWidth(60)
btn.setFixedWidth(60)
# Capture opcode and spin by value
btn.clicked.connect(lambda _, op=opcode, sp=spin, b=bits:
self._send_fpga_validated(op, sp.value(), b))
@@ -1245,6 +1304,41 @@ class RadarDashboard(QMainWindow):
layout.addWidget(proc_group)
# ---- Bench / Diagnostics ------------------------------------------
# PR-AB.b: tucked-away toggle that only matters when the MCU is built
# with -DMCU_AGC_FORCE_DISABLED (bench / debug build). Production
# operators should leave this OFF — the AGC runs always-on at the
# firmware level and exposing the Enable/Disable buttons would let
# someone send opcode 0x28 without changing observable behaviour,
# which would just create confusion.
bench_group = QGroupBox("Bench / Diagnostics")
bench_layout = QVBoxLayout(bench_group)
self._bench_mode_check = QCheckBox(
"BENCH-MODE: expose AGC Enable controls (debug-build firmware only)"
)
self._bench_mode_check.setChecked(self._bench_mode)
self._bench_mode_check.setToolTip(
"OFF (default): production firmware runs AGC every frame. "
"AGC Enable buttons are hidden so opcode 0x28 cannot be sent.\n"
"ON: bench / debug firmware (built with -DMCU_AGC_FORCE_DISABLED) "
"is assumed. AGC Enable buttons become visible — they only set "
"the FPGA-side display register, the MCU does not honour them."
)
self._bench_mode_check.toggled.connect(self._on_bench_mode_toggled)
bench_layout.addWidget(self._bench_mode_check)
bench_note = QLabel(
"<i>Future: this checkbox will go away once the MCU broadcasts "
"a one-shot USB-CDC boot announce identifying production vs "
"bench firmware build.</i>"
)
bench_note.setStyleSheet(f"color: {DARK_INFO}; font-size: 10px;")
bench_note.setWordWrap(True)
bench_layout.addWidget(bench_note)
layout.addWidget(bench_group)
# ---- About group ---------------------------------------------------
about_group = QGroupBox("About")
about_layout = QVBoxLayout(about_group)
@@ -1868,13 +1962,10 @@ class RadarDashboard(QMainWindow):
self._st_labels["t4"].setText(
f"T4 ADC: {'PASS' if flags & 0x10 else 'FAIL'}")
# AGC status readback
# AGC status readback. The 'enable' line is owned by
# _refresh_agc_mode_labels so production stays honest with the badge.
if hasattr(self, '_agc_labels'):
agc_str = "AUTO" if st.agc_enable else "MANUAL"
agc_color = DARK_SUCCESS if st.agc_enable else DARK_INFO
self._agc_labels["enable"].setStyleSheet(
f"color: {agc_color}; font-weight: bold;")
self._agc_labels["enable"].setText(f"AGC: {agc_str}")
self._refresh_agc_mode_labels(st)
self._agc_labels["gain"].setText(
f"Gain: {st.agc_current_gain}")
self._agc_labels["peak"].setText(
@@ -1903,12 +1994,9 @@ class RadarDashboard(QMainWindow):
self._agc_peak_history.append(st.agc_peak_magnitude)
self._agc_sat_history.append(st.agc_saturation_count)
# Update indicator labels (cheap Qt calls)
agc_str = "AUTO" if st.agc_enable else "MANUAL"
agc_color = DARK_SUCCESS if st.agc_enable else DARK_INFO
self._agc_mode_lbl.setStyleSheet(
f"color: {agc_color}; font-size: 16px; font-weight: bold;")
self._agc_mode_lbl.setText(f"AGC: {agc_str}")
# The mode label honours bench-mode in production — same shared helper
# the FPGA Control tab uses, so the two views can never disagree.
self._refresh_agc_mode_labels(st)
self._agc_gain_lbl.setText(f"Gain: {st.agc_current_gain}")
self._agc_peak_lbl.setText(f"Peak: {st.agc_peak_magnitude}")
@@ -2001,6 +2089,51 @@ class RadarDashboard(QMainWindow):
f"Failed to apply DSP settings: {e}")
logger.error(f"DSP config error: {e}")
def _on_bench_mode_toggled(self, checked: bool):
"""Persist BENCH-MODE flag and refresh AGC widget visibility."""
self._bench_mode = bool(checked)
self._qsettings.setValue("bench_mode", self._bench_mode)
self._apply_bench_mode_visibility()
logger.info(f"BENCH-MODE {'ON' if self._bench_mode else 'OFF'}")
def _apply_bench_mode_visibility(self):
"""Show or hide AGC Enable controls based on self._bench_mode and
re-sync the AGC mode labels so they don't contradict the badge."""
production = not self._bench_mode
self._agc_always_on_badge.setVisible(production)
self._agc_toggle_container.setVisible(self._bench_mode)
# Push current bench-mode state through the AGC mode labels — uses
# 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"):
"""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
register and runs AGC every frame, so both labels show 'ALWAYS-ON'
in green — keeps them honest with the production badge. In bench
the labels follow the StatusResponse register, falling back to '--'
before the first status arrives."""
if self._bench_mode:
if st is None:
text, color = "AGC: --", DARK_INFO
elif st.agc_enable:
text, color = "AGC: AUTO", DARK_SUCCESS
else:
text, color = "AGC: MANUAL", DARK_INFO
else:
text, color = "AGC: ALWAYS-ON", DARK_SUCCESS
if hasattr(self, "_agc_labels") and "enable" in self._agc_labels:
self._agc_labels["enable"].setStyleSheet(
f"color: {color}; font-weight: bold;")
self._agc_labels["enable"].setText(text)
if hasattr(self, "_agc_mode_lbl"):
self._agc_mode_lbl.setStyleSheet(
f"color: {color}; font-size: 16px; font-weight: bold;")
self._agc_mode_lbl.setText(text)
# =====================================================================
# Periodic GUI refresh (100 ms timer)
# =====================================================================