C-1: plfm_chirp_controller was not range_mode-aware; it always ran
LONG_CHIRP for CHIRP_MAX/2 chirps even when the host selected 3 km
mode, where the ~4.5 km blind zone exceeds the 3 km max range and
pollutes the RX window. IDLE now branches straight to SHORT_CHIRP
when range_mode == RP_RANGE_MODE_3KM. host_range_mode is passed from
radar_system_top through radar_transmitter, CDC'd per-bit from
clk_100m to clk_120m_dac (coherency-safe: reserved codes are clamped
at the source so only bit[0] toggles).
S-3: opcode 0x20 now clamps reserved range-mode codes (2'b10, 2'b11)
to the 3 km default so a garbled host write cannot silently enable
long-range TX behaviour.
Regression: tb_chirp_controller adds a 3 km-mode group (5 checks)
verifying IDLE->SHORT_CHIRP skip path and DONE after CHIRP_MAX short
chirps; tb_system_e2e G14 labels updated for clamped reserved codes.
32/32 regression PASS (50/50 on chirp TB).
Two bugs fixed recently had no tests that would have failed before the
fix. Add direct regressions so either cannot silently return:
1. tb_chirp_controller Group 3b (multi-frame, C-3): run a second full
frame back-to-back after DONE and assert chirp_counter returns to 0,
frame 2 reaches GUARD_TIME after exactly CHIRP_MAX/2 long chirps,
and frame 2 reaches DONE. Before the fix, chirp_counter held at
CHIRP_MAX after frame 1, the LONG_LISTEN -> GUARD guard (=CHIRP_MAX/2-1)
never matched, and frame 2 ran extra chirps until the 6-bit counter
wrapped — these checks fail loudly if that regresses.
2. tb_usb_data_interface frame-sync width + value pins: assert
$bits(uut.sample_counter) >= 15 and uut.NUM_CELLS == 15'd16384.
Protects against reintroducing the 12-bit / 2048-cell constants
that fired 8 false frame-start markers per real 512 x 32 frame.
Regression: 32/32 PASS; USB TB 89 -> 91 checks.
C-3: plfm_chirp_controller_enhanced never reset chirp_counter when the
frame completed. Counter sat at CHIRP_MAX after frame 1, so the
LONG_LISTEN -> GUARD transition guard (== CHIRP_MAX/2-1) never matched
correctly on subsequent frames and frame 2+ ran extra chirps until the
6-bit counter wrapped. Reset chirp_counter in the DONE state.
S-2: Replace hardcoded CHIRP_MAX = 32 with RP_CHIRPS_PER_FRAME from
radar_params.vh so the TX FSM tracks the single source of truth.
S-1: Correct misleading labels in tb_system_e2e G14.1-G14.3. Per
radar_params.vh the range_mode encoding is 2'b00 = 3 km, 2'b01 =
long-range, 2'b10/2'b11 = reserved. The TB strings previously called
2'b01 "short" and 2'b10 "long", which is inverted and inconsistent
with the RTL comments in radar_mode_controller.v.
Regression: 32/32 PASS.
usb_data_interface.v NUM_CELLS was still 12'd2048 (64 range x 32 doppler)
from the pre-2048-FFT architecture. With 512 range bins x 32 Doppler, the
12-bit counter wrapped every 2048 packets and the host received 8 false
frame-start markers per real frame via the sample_counter==0 bit packed
into the detection byte. Widen counter to 15 bits and set NUM_CELLS to
16384. Sister file usb_data_interface_ft2232h.v was already correct.
Remove three stale testbenches hardcoded to the old 1024-pt / 64-bin
architecture (tb_mf_chain_synth, tb_fullchain_mti_cfar_realdata,
tb_range_fft_realdata). Equivalent current-architecture coverage already
exists in tb_matched_filter_processing_chain, tb_fullchain_realdata,
tb_fft_engine, tb_multiseg_cosim, and tb_mf_cosim.
rx_final_doppler_out.csv is written by tb_radar_receiver_final.v on
every run via $fopen — it is a test-run artifact, not an oracle. It
was mistakenly tracked in an earlier commit, causing unnecessary
churn on every sim. Remove from the index and ignore going forward.
Also ignore stray a.out from iverilog one-shot compiles.
Golden references (.hex, .mem, doppler_golden_py_*.csv) remain
tracked — they are load-bearing oracles used by MF / Doppler /
receiver cosim testbenches.
RTL (P0 pre-bringup findings R-1/R-2/R-3/R-5/R-6):
- mti_canceller: add use_long_chirp input and waveform-boundary mute
so the long->short transition in mode 01 no longer subtracts across
heterogeneous waveforms (R-1). Prev buffer is overwritten in-flight
at the boundary so the next same-waveform chirp subtracts cleanly.
- ad9484_interface_400m: 2FF sync of mmcm_locked into the 400 MHz
domain before gating reset_n_gated (R-6).
- cic_decimator_4x_enhanced: correct max_fanout narrative (R-3).
- ad9484_interface_400m: strip stale pblock comment, note 3.0 ns
max_delay instead (R-2).
- mti_canceller / doppler_processor: 200T-20km WARNING banners
flagging the broken 4096-bin path (R-5). 9-bit BRAM address aliases
silently until rewritten.
- adc_clk_mmcm.xdc: relax set_max_delay from 2.700 -> 3.000 ns,
closes WNS with headroom on 50T build.
- radar_receiver_final: wire use_long_chirp into mti_inst.
Architecture-bump finalization (2048-pt range FFT, 512 range bins,
32 Doppler bins -> 16384 output cells per frame):
- tb/cosim/radar_scene.py: FFT_SIZE 1024 -> 2048, RANGE_BINS 64 -> 512.
- tb/gen_mf_golden_ref.py: N 1024 -> 2048.
- Regenerate all affected hex goldens (MF cases 1-4, Doppler inputs
+ py goldens, receiver integration golden_doppler.mem 2048 -> 16384).
- tb_radar_receiver_final: widen range_bin_out 6 -> 9 bits, bump
GOLDEN_ENTRIES 2048 -> 16384, expand bitmaps/arrays to 512 bins,
update all check messages and thresholds.
- tb_mti_canceller, tb_fullchain_mti_cfar_realdata: tie/pass
use_long_chirp so compile still works after RTL port add.
Test-suite hardening (coverage audit findings):
- tb_mti_canceller T12: 10 new assertions exercising R-1 waveform-
boundary mute across a long/long/short/short/long sequence. Catches
a regression that re-enables subtraction across the boundary.
- tb_fir_lowpass: replace tautological check(1'b1, ...) on coefficient
symmetry with a real hierarchical check coeff[k]===coeff[31-k];
replace always-pass overflow check with a well-driven (not X/Z)
assertion on filter_overflow.
- tb_matched_filter_processing_chain: replace three always-pass peak-
bin placeholders with peak-to-mean-|out| > 2x ratio checks (catches
flat/zero output that the old tautologies silently accepted).
- tb_cdc_modules M2: replace always-pass narrow-pulse check with a
well-defined-output assertion on the synchronizer.
- tb_nco_400m: replace always-pass freq-switch check with a swing +
no-X assertion across 200 post-switch samples.
- tb_system_e2e G12.1: replace check(1, ...) with test_num > 20 so
it catches a stalled TB that skipped prior groups.
- tb_multiseg_cosim TEST 4: replace always-pass placeholder with a
bitmap that asserts segment_request visited all 4 values.
- tb_mf_chain_synth and tb_fullchain_mti_cfar_realdata: add DEPRECATED
headers plus \$fatal guards (ifndef ALLOW_STALE_*) so they cannot
be silently re-enabled in CI with stale 1024-bin goldens against
current 2048-pt RTL.
Regression: 32 passed, 0 failed. MTI TB grew 30 -> 39 checks;
receiver integration grew 17 -> 18 checks with 16384/16384 golden
match at tolerance +/- 2 LSB.
Hand-merged files modified on both fix/pre-bringup-audit-p0 and
feat/fft-2048-upgrade. Wave 1 (commit 60e49c7) took 20 files from fft
verbatim; this wave resolves the overlap.
- run_regression.sh: 3-way merge. Adopts fft's ${RECEIVER_RTL[@]} array
refactor and drops the self-blessing golden pair from p0. Skip count
bumped to 5.
- usb_data_interface.v (FT601/200T): p0 FSM + clock-loss watchdog kept
wholesale; widened stream_control 3 -> 6 bits to carry fft's extended
mode bits through the CDC sync chain and the 0xFF status word.
- mti_canceller.v: fft's BRAM-inferred 512-range-bin implementation as
the base, with p0's F-6.3 saturation counter grafted onto the d1
pipeline stage. Overflow detection uses the top-two-bits disagreement
on diff_{i,q}_full (DATA_WIDTH+1 signed).
- radar_receiver_final.v: fft's 2048-pt / 512-bin structure + p0
diagnostic plumbing (ADC overrange sticky+CDC, DDC diagnostics,
tx_frame_start edge detector replacing chirp_counter frame sync,
mti_saturation_count, range_decim_watchdog).
- radar_system_top.v: clean 3-way merge, orthogonal regions
(+38 / -27).
- usb_data_interface_ft2232h.v (FT2232H/50T): fft's per-frame bulk BRAM
rewrite kept wholesale. Ported two p0 items that are orthogonal to
the write FSM:
* ft_clk-loss watchdog (heartbeat + 2FF ASYNC_REG sync + 16-bit
timeout) ORed into a 2FF sync'd ft_effective_reset_n for the FSM.
* rd_cmd_complete flag so RD_DEASSERT can distinguish a legitimate
3-byte completion from an ft_rxf_n abort that also zeros
rd_byte_cnt.
Deliberately NOT taken from 2401f5f: cic_decimator_4x_enhanced.v and
ddc_400m.v reset-strategy changes. Those conflict with p0's shipped
registered-sync-reset + max_fanout=25 distribution, which is already
timing-clean on the production build.
Per-chirp T/R switching is owned by the FPGA plfm_chirp_controller
driving adar_tr_x pins (TR_SOURCE=1 in REG_SW_CONTROL, already set by
initializeSingleDevice). The MCU's SPI RMW path via fastTXMode/
fastRXMode/pulseTXMode/pulseRXMode/setADTR1107Control was:
(a) architecturally redundant — raced the FPGA-driven TR line,
(b) toggled the wrong bit (TR_SOURCE instead of TR_SPI),
(c) in setFastSwitchMode(true) bundled a datasheet-violating
PA+LNA-simultaneously-biased side effect.
Removed methods and their backing state (fast_switch_mode_,
switch_settling_time_us_). Call sites in executeChirpSequence /
runRadarPulseSequence updated to rely on the FPGA chirp FSM (GPIOD_8
new_chirp trigger unchanged).
Tests: adds CMSIS-Core DWT/CoreDebug/SystemCoreClock stubs to
stm32_hal_mock so F-4.7's DWT-based delayUs() compiles under the host
mock build. SystemCoreClock=0 makes the busy-wait exit immediately.
adc_or_p (overrange pin, added in commit 70067c6 for audit finding F-0.1)
uses the same IBUFDS→BUFIO source-synchronous capture topology as the 8
data pins adc_d_p[*]. STA reports identical -1.913 ns hold on this path
for the same reason (clock insertion ~4.0 ns via BUFIO vs data IBUFDS
~0.9 ns). External PCB layout guarantees hold, not FPGA clock tree.
Extends the existing adc_d_p[*] false_path waiver to cover adc_or_p.
Post-route now clean: WNS +0.034 ns, WHS positive.
Previous output_delay of 11.667 ns was a synthetic back-calculation
(period − 5 ns), not a datasheet number. It over-constrained FPGA
launch by ~8 ns vs the actual FT2232H 245-Sync FIFO setup requirement.
Per FTDI TN_167:
- t_su (data to CLKOUT rising): 3.5 ns (was 11.667 — too tight)
- t_h (data hold after CLKOUT): 1.0 ns (was 0.0 — no hold check)
- t_co (CLKOUT to data valid): 10.0 ns (was 9.667 — close)
- t_coh (CLKOUT to data hold): 0.5 ns (was 0.0 — no hold check)
NB: values must be verified against the exact TN_167 revision in use
before shipping. If the engineer's revision differs, numbers change
but the direction (big relaxation of output_delay_max) is correct.
The previous attempt put BUFIO inside u_core/gen_ft_bufr, but the pad
(ft_clkout) and its inferred IBUF live at the top wrapper level. Vivado
shape-packs IBUF↔BUFIO into the same IOB tile, and it couldn't do that
across the wrapper→u_core hierarchy boundary — producing CRITICAL
WARNING [12-1411] "Illegal to place BUFIO on TIEOFF site" and WNS=-5.737
(worse than the CLOCK_DEDICATED_ROUTE=FALSE baseline).
Fix: instantiate IBUF+BUFIO+BUFR explicitly in radar_system_top_50t.v
and pass the BUFR output into u_core.ft601_clk_in. radar_system_top.v
now does a pass-through wire assign for USB_MODE=1 (no BUFG) so the
clock net doesn't get double-buffered.
C4 is an SRCC pin (IS_CLK_CAPABLE=1, IS_MASTER=0 in the Vivado device
model), not an MRCC as earlier comments claimed. SRCC cannot drive BUFG
through dedicated routing, so the previous CLOCK_DEDICATED_ROUTE=FALSE
override forced fabric routing and burned ~5 ns on the ft_clkout path
(WNS -5.362 ns in the d36a4c9 build).
Swap to BUFIO + BUFR for USB_MODE=1 (50T/FT2232H): SRCC → BUFIO → BUFR
is the standard 7-series path for regional clock distribution. All
ft_clkout-domain logic (FT2232H FSM, toggle CDCs, USB FIFO flops) is
contained in bank 35 / one clock region, so regional distribution is
sufficient. USB_MODE=0 (200T/FT601) keeps the BUFG because D17 is a
proper MRCC pin.
Removed CLOCK_DEDICATED_ROUTE=FALSE from both the XDC and the build
script — no longer needed with dedicated BUFIO/BUFR routing.
A: cic_decimator_4x_enhanced.v reset_h max_fanout 50→25. More replicas
mean each drives fewer DSP48 RSTB loads, letting Vivado place each
closer to its consumers. Targets the rep__24 → comb_reg[4]/RSTB path
that failed clk_mmcm_out0 intra by -10 ps (1.4 ns of pure routing).
B: adc_clk_mmcm.xdc BUFIO↔BUFG max_delay 2.500→2.700 ns. The 2.5 ns
target was tighter than achievable for the IDDR (ILOGIC) → FDRE (fabric
SLICE) re-registration. The effective window is the BUFIO↔BUFG phase
relationship (not the clock period), so 2.7 ns remains safe. Fixes the
adc_dco_p→clk_mmcm_out0 inter path -113 ps failure on lane 7.
Post-route WNS = -5.355 ns on path group ft_clkout, net
u_core/gen_ft2232h.usb_inst/ft_data_TRI[0]_repN_1
FT2232H 245-sync FIFO input setup (t_su = 11.667 ns on a 16.667 ns
CLKOUT) leaves the FPGA only ~5 ns from clock edge to pad. Without
IOB=TRUE, the output / tristate FFs live in fabric and FF→OBUFT
routing eats 2–3 ns, forcing Vivado to replicate the tristate
driver (ft_data_TRI[*]_repN) and still miss timing.
The FSM in usb_data_interface_ft2232h.v already registers
ft_data_out / ft_data_oe / ft_{rd,wr,oe}_n at the output boundary
in the ft_clk domain, so packing them into the IOB is safe with
no RTL change.
Build-blocking fixes surfaced by gpu-server synth:
1. radar_system_top_50t.v wrapper was missing adc_or_p/n ports and the
u_core instantiation left them unconnected. Every XDC line in the 50T
anchor block (PACKAGE_PIN M6/N6, IOSTANDARD, DIFF_TERM, set_input_delay)
therefore matched no ports and emitted CRITICAL WARNINGs, leaving the
overrange pin effectively tied off. Added the two inputs and wired them
through to the core.
2. adc_clk_mmcm.xdc used foreach / unset — Vivado's XDC parser only
accepts a restricted Tcl subset and rejected them as
[Designutils 20-1307]. Moved the clk_mmcm_out0 ↔ USB-clock false paths
into each board XDC (ft_clkout for 50T, ft601_clk_in for 200T) where
the clock name is already known.
A new SCENARIO_FUZZ branch in tb_ddc_cosim.v accepts +hex / +csv / +tag
plusargs so an external runner can pick stimulus and output paths per
iteration. The three path registers are widened to 4 kbit each so long
temp-directory paths (e.g. /private/var/folders/...) do not overflow
the MSB and emerge truncated — a real failure mode caught while writing
this runner.
test_ddc_cosim_fuzz.py is a pytest-driven fuzz harness:
- Generates a random plausible radar scene per seed (1-4 targets with
random range/velocity/RCS/phase, random noise level 0.5-6.0 LSB
stddev) via radar_scene.generate_adc_samples, fully deterministic.
- Compiles tb_ddc_cosim.v once per session (module-scope fixture),
then runs vvp per seed.
- Asserts sample-count bounds consistent with 4x CIC decimation,
signed-18 range on every baseband I/Q word, and non-zero output
(catches silent pipeline stalls).
- Ships with two tiers: test_ddc_fuzz_fast (8 seeds, default CI) and
test_ddc_fuzz_full (100 seeds, opt-in via -m slow) matching the
audit ask.
Registers the "slow" marker in pyproject.toml for the 100-seed opt-in.
G9B adds a 4-iteration reset sweep on top of the existing e2e harness:
- Reset is injected at four offsets (3/7/12/18 us) into a steady-state
auto-scan burst, with mixed short/long hold durations (20-120 clk_100m)
to exercise asynchronous assert paths through the FSM + CDCs.
- Each iteration asserts: system_status drops to 0 during reset,
new_chirp_frame resumes post-release, and obs_range_valid_count
advances — proving the full DDC->MF chain recovers, not just the
transmitter FSM.
The stub and three existing testbenches are updated to drive the new
adc_or_p/n ports tied to 1'b0/1'b1, matching the F-0.1 RTL change.
The AD9484 OR (overrange) LVDS pair is routed on the 50T main board to
xc7a50t-ftg256 bank-14 pins M6/N6 but was previously left unconnected at
the top level. Plumb it through the full stack so saturation at the raw
ADC boundary shows up in the existing overflow aggregation:
- ad9484_interface_400m: add adc_or_p/n inputs, IBUFDS + IDDR capture of
both phases in the BUFIO domain, re-register into the clk_400m BUFG
domain, OR rise|fall into adc_overrange_400m output.
- radar_receiver_final: stickify adc_overrange_400m in clk_400m, CDC to
clk_100m via a 2FF ASYNC_REG chain (same reasoning as F-1.2's
cdc_cic_fir_overrun — single-bit, latched low→high, GPIO-class
diagnostic), OR into the existing ddc_overflow_any aggregation.
- radar_system_top: expose adc_or_p/n top-level ports and pass through.
- xc7a50t_ftg256.xdc: anchor M6/N6 as LVDS_25 DIFF_TERM, with the same
DCO-relative input-delay constraints as adc_d_p[*].
- xc7a200t_fbg484.xdc: IOSTANDARD/DIFF_TERM set; PACKAGE_PIN left as a
documented TODO — the 200T dev-board schematic has not been checked
and the 200T build will need the anchor filled in before place/route.
The `broadcast=1` path on adarWrite() emitted the 0x08 broadcast opcode
but setChipSelect() only asserts one device's CS line, so only the single
selected chip ever saw the frame. The opcode path has also never been
validated on silicon. Until a HIL test confirms multi-CS semantics, route
broadcast=1 through a unicast loop over all devices so caller intent
(all four take the write) is preserved and the dead opcode path becomes
unreachable. Logs a DIAG_WARN on entry for visibility.
A new SCENARIO_FUZZ branch in tb_ddc_cosim.v accepts +hex / +csv / +tag
plusargs so an external runner can pick stimulus and output paths per
iteration. The three path registers are widened to 4 kbit each so long
temp-directory paths (e.g. /private/var/folders/...) do not overflow
the MSB and emerge truncated — a real failure mode caught while writing
this runner.
test_ddc_cosim_fuzz.py is a pytest-driven fuzz harness:
- Generates a random plausible radar scene per seed (1-4 targets with
random range/velocity/RCS/phase, random noise level 0.5-6.0 LSB
stddev) via radar_scene.generate_adc_samples, fully deterministic.
- Compiles tb_ddc_cosim.v once per session (module-scope fixture),
then runs vvp per seed.
- Asserts sample-count bounds consistent with 4x CIC decimation,
signed-18 range on every baseband I/Q word, and non-zero output
(catches silent pipeline stalls).
- Ships with two tiers: test_ddc_fuzz_fast (8 seeds, default CI) and
test_ddc_fuzz_full (100 seeds, opt-in via -m slow) matching the
audit ask.
Registers the "slow" marker in pyproject.toml for the 100-seed opt-in.
G9B adds a 4-iteration reset sweep on top of the existing e2e harness:
- Reset is injected at four offsets (3/7/12/18 us) into a steady-state
auto-scan burst, with mixed short/long hold durations (20-120 clk_100m)
to exercise asynchronous assert paths through the FSM + CDCs.
- Each iteration asserts: system_status drops to 0 during reset,
new_chirp_frame resumes post-release, and obs_range_valid_count
advances — proving the full DDC->MF chain recovers, not just the
transmitter FSM.
The stub and three existing testbenches are updated to drive the new
adc_or_p/n ports tied to 1'b0/1'b1, matching the F-0.1 RTL change.
The AD9484 OR (overrange) LVDS pair is routed on the 50T main board to
xc7a50t-ftg256 bank-14 pins M6/N6 but was previously left unconnected at
the top level. Plumb it through the full stack so saturation at the raw
ADC boundary shows up in the existing overflow aggregation:
- ad9484_interface_400m: add adc_or_p/n inputs, IBUFDS + IDDR capture of
both phases in the BUFIO domain, re-register into the clk_400m BUFG
domain, OR rise|fall into adc_overrange_400m output.
- radar_receiver_final: stickify adc_overrange_400m in clk_400m, CDC to
clk_100m via a 2FF ASYNC_REG chain (same reasoning as F-1.2's
cdc_cic_fir_overrun — single-bit, latched low→high, GPIO-class
diagnostic), OR into the existing ddc_overflow_any aggregation.
- radar_system_top: expose adc_or_p/n top-level ports and pass through.
- xc7a50t_ftg256.xdc: anchor M6/N6 as LVDS_25 DIFF_TERM, with the same
DCO-relative input-delay constraints as adc_d_p[*].
- xc7a200t_fbg484.xdc: IOSTANDARD/DIFF_TERM set; PACKAGE_PIN left as a
documented TODO — the 200T dev-board schematic has not been checked
and the 200T build will need the anchor filled in before place/route.
The `broadcast=1` path on adarWrite() emitted the 0x08 broadcast opcode
but setChipSelect() only asserts one device's CS line, so only the single
selected chip ever saw the frame. The opcode path has also never been
validated on silicon. Until a HIL test confirms multi-CS semantics, route
broadcast=1 through a unicast loop over all devices so caller intent
(all four take the write) is preserved and the dead opcode path becomes
unreachable. Logs a DIAG_WARN on entry for visibility.
Addresses the remaining actionable items from
docs/DEVELOP_AUDIT_2026-04-19.md after commit 3f47d1e.
XDC (dead waivers — F-0.4, F-0.5, F-0.6, F-0.7):
- ft_clkout_IBUF CLOCK_DEDICATED_ROUTE now uses hierarchical filter;
flat net name did not exist post-synth.
- reset_sync_reg[*] false-path rewritten to walk hierarchy and filter
on CLR/PRE pins.
- adc_clk_mmcm.xdc ft601_clk_in references replaced with foreach-loop
over real USB clock names, gated on -quiet existence.
- MMCM LOCKED waiver uses REF_PIN_NAME filter instead of the
previously-missing u_core/ literal path.
CDC (F-1.1, F-1.2, F-1.3):
- Documented the quasi-static-bus stability invariant above the
FT601 cmd_valid toggle block.
- cdc_adc_to_processing gains an `overrun` output; the two CIC->FIR
instances feed a sticky cdc_cic_fir_overrun flag surfaced on
gpio_dig5 so silent sample drops become visible to the MCU.
- Removed the dead mixers_enable synchronizer in ddc_400m.v; the _sync
output was unused and every caller ties the port to 1'b1.
Diagnostics (F-6.4):
- range_bin_decimator watchdog_timeout plumbed through receiver
and top-level, OR'd into gpio_dig5.
ADAR (F-4.7):
- delayUs() replaced with DWT cycle counter; self-initialising
TRCENA/CYCCNTENA, overflow-safe unsigned subtraction.
Regression: tb_cdc_modules.v 57/57 passes under iverilog after
the cdc_modules.v change. Remote Vivado verification in progress.
Addresses findings from docs/DEVELOP_AUDIT_2026-04-19.md:
P0 source-level:
- F-4.3 ADAR1000_Manager::adarSetTxPhase now writes REG_LOAD_WORKING
with LD_WRK_REGS_LDTX_OVERRIDE (0x02) instead of 0x01. Previous value
toggled the LDRX latch on a TX-phase write, so host TX phase updates
never reached the working registers.
- F-6.1 DDC mixer_saturation / filter_overflow / diagnostics were deleted
at the receiver boundary. Now plumbed to new outputs on
radar_receiver_final (ddc_overflow_any, ddc_saturation_count) and
aggregated into gpio_dig5 in radar_system_top. Added mark_debug
attributes for ILA visibility. Test/debug inputs tied low explicitly.
- F-0.8 adc_clk_mmcm.xdc set_clock_uncertainty: removed invalid -add
flag (Vivado silently rejected it, applying zero guardband). Now uses
absolute 0.150 ns which covers 53 ps jitter + ~100 ps PVT margin.
P1:
- F-4.2 adarSetBit / adarResetBit reject broadcast=ON — the RMW sampled
a single device but wrote to all four, clobbering the other three's
state.
- F-4.4 initializeSingleDevice returns false and leaves initialized=false
when scratchpad verification fails; previously marked the device
initialized anyway so downstream PA enable could drive a dead bus.
- F-6.2 FIR I/Q filter_overflow ports, previously unconnected, now OR'd
into the module-level filter_overflow output.
- F-6.3 mti_canceller exposes 8-bit saturation counter. Saturation was
previously invisible and produces spurious Doppler harmonics.
Verification:
- 27/27 iverilog testbenches pass
- 228/228 pytest pass (cross-layer contract + cosim)
- MCU unit tests 51/51 + 24/24 pass
- Remote Vivado 2025.2 build: bitstream writes; 400 MHz mixer pipeline
now shows WNS -0.109 ns which MATCHES the audit's F-0.9 prediction
that the design only closed because F-0.8's guardband was silently
dropped. ft_clkout F-0.9 remains a show-stopper (requires MRCC pin
move), tracked separately.
Not addressed in this PR (larger scope, follow-up tickets):
F-0.4, F-0.5, F-0.6, F-0.7, F-0.9, F-1.1, F-1.2, F-2.2, F-3.2, F-4.1,
F-4.7, F-6.4, F-6.5.
Three conflicts — all resolved in favor of develop, which has a more
refined version of the same work this branch introduced:
- radar_system_top.v: develop's cleaner USB_MODE=1 comment (same value).
- run_regression.sh: develop's ${SYSTEM_RTL[@]} refactor + added
USB_MODE=1 test variants.
- tb/radar_system_tb.v: develop's ifdef USB_MODE_1 to dump the correct
USB instance based on mode.
The 400 MHz reset fan-out fix (nco_400m_enhanced, cic_decimator_4x_enhanced,
ddc_400m) and ADAR1000 channel-indexing fix remain intact on this branch.
Replace direct !reset_n async sense with a registered active-high reset_h
(max_fanout=50) in nco_400m_enhanced, cic_decimator_4x_enhanced, and
ddc_400m. The prior single-LUT1 / 700+ load net was the root cause of
WNS=-0.626 ns in the 400 MHz clock domain on the xc7a50t build. Vivado
replicates the constrained register into ≈14 regional copies, each driving
≤50 loads, closing timing at 2.5 ns.
Change radar_system_top default USB_MODE from 0 (FT601) to 1 (FT2232H).
FT601 remains available for the 200T premium board via explicit parameter
override; the 50T production wrapper already hard-codes USB_MODE=1.
Regression: add usb_data_interface_ft2232h.v to PROD_RTL lint list and
both system-top TB compile commands; fix legacy radar_system_tb hierarchical
probe from gen_ft601.usb_inst to gen_ft2232h.usb_inst.
Golden reference files (rtl_bb_dc.csv, rx_final_doppler_out.csv,
golden_doppler.mem) regenerated to reflect the +1-cycle registered-reset
boundary behaviour; Receiver golden-compare passes 18/18 checks.
All 25 regression tests pass (0 failures, 0 skipped).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The four channel-indexed ADAR1000 setters (adarSetRxPhase, adarSetTxPhase,
adarSetRxVgaGain, adarSetTxVgaGain) computed their register offset as
`(channel & 0x03) * stride`, which silently aliased CH4 (channel=4 ->
mask=0) onto CH1 and shifted CH1..CH3 by one. The API contract (1-based
CH1..CH4) is documented in ADAR1000_AGC.cpp:76 and matches the ADI
datasheet; every existing caller already passes `ch + 1`.
Fix: subtract 1 before masking -- `((channel - 1) & 0x03) * stride` --
and reject `channel < 1 || channel > 4` early with a DIAG message so a
future stale 0-based caller fails loudly instead of writing to CH4.
Adds TestTier1Adar1000ChannelRegisterRoundTrip (9 tests) which closes
the loop independently of the driver:
- parses the ADI register map directly from ADAR1000_Manager.h,
- verifies the datasheet stride invariants (gain=1, phase=2),
- auto-discovers every C++ TU under MCU_LIB_DIR / MCU_CODE_DIR so a
new caller cannot silently escape the round-trip check,
- asserts every caller's channel argument evaluates to {1,2,3,4} for
ch in {0,1,2,3} (catches bare 0-based or literal-0 callers at CI
time before the runtime bounds-check would silently drop them),
- round-trips each (caller, ch) through the helper arithmetic and
checks the final address equals REG_CH{ch+1}_*.
Adversarially validated: reverting any one helper, all four helpers,
corrupting the parsed register map, injecting a bare-ch caller, and
auto-discovering a literal-0 caller in a fresh TU each cause the
expected (and only the expected) test to fail.
Stacked on fix/adar1000-vm-tables (PR #107).
The ADAR1000 vector-modulator I/Q lookup tables VM_I[128] and VM_Q[128]
were declared but defined as empty initialiser lists since the first
commit (5fbe97f). Every call to adarSetRxPhase / adarSetTxPhase therefore
wrote (I=0x00, Q=0x00) to registers 0x21/0x23 (Rx) and 0x32/0x34 (Tx)
regardless of the requested phase state, leaving beam steering completely
non-functional in firmware.
This commit:
* Populates VM_I[128] and VM_Q[128] from ADAR1000 datasheet Rev. B
Tables 13-16 (p.34) on a uniform 2.8125 deg grid (360 / 128 states).
Byte format: bits[7:6] reserved 0, bit[5] polarity (1 = positive
lobe), bits[4:0] 5-bit unsigned magnitude - exactly as specified.
* Removes VM_GAIN[128] declaration and (empty) definition. The
ADAR1000 has no separate VM gain register; per-channel VGA gain is
set via CHx_RX_GAIN (0x10-0x13) / CHx_TX_GAIN (0x1C-0x1F) by
adarSetRxVgaGain / adarSetTxVgaGain. VM_GAIN was never populated,
never read anywhere in the firmware, and its presence falsely
suggested a missing scaling step in the signal path.
* Adds 9_Firmware/tests/cross_layer/adar1000_vm_reference.py: an
independently-derived ground-truth module containing the full
datasheet table plus byte-format / uniform-grid / quadrant-symmetry
/ cardinal-point invariant checkers and a tolerant C array parser.
* Adds TestTier2Adar1000VmTableGroundTruth (9 tests) to
test_cross_layer_contract.py, including a tokenising C/C++
comment+string stripper used by the VM_GAIN reintroduction guard,
and an adversarial self-test that corrupts one byte and asserts
the comparison detects it (defends against silent bypass via
future fixture/parser refactors).
Adversarially validated: removing the firmware definitions, flipping
a single byte, or reintroducing VM_GAIN as code each cause the suite
to fail; restoring causes it to pass. VM_GAIN appearing inside string
literals or comments correctly does NOT trip the guard.
Closes the empty-table half of the ADAR1000 phase-control bug class.
The separate channel-rotation issue (#90) will be addressed in a
follow-up PR.
Refs: 7_Components Datasheets and Application notes/ADAR1000.pdf
Rev. B Tables 13-16 p.34
Brings in main-only commits that never reached develop:
754d919 Added silk screen and headers description (MainBoard .brd)
0443516 Added thermal vias (RF_PA .brd)
5fbe051 Added ABAC INDUSTRY web site (Project_Description.docx)
12b549d / 5d5e9ff Merge PR #101: README BOM sensor counts fix
No conflicts expected: develop has not touched any of these paths.
The STM32 peripheral list in the README disagreed with the production
BOM (4_7_Production Files/Gerber_Main_Board/RADAR_Main_Board_BOM_csv)
and with the firmware (9_1_Microcontroller/.../main.cpp). Corrections
based on origin/main commit 754d919:
- ADS7830 Idq ADCs: placed on the Main Board (U88 @ 0x48, U89 @ 0x4A),
not on the Power Amplifier Boards. Added the INA241A3 (x50) and 5 mOhm
shunt detail that completes the current-sense chain.
- DAC5578 Vg DACs: placed on the Main Board (U7 @ 0x48, U69 @ 0x49),
not on the Power Amplifier Boards. Noted closed-loop Idq calibration
at boot (main.cpp powerUpSequence).
- Temperature sensors: 1x ADS7830 (U10) with 8 single-ended channels
reading 8 thermistors -- not 8 separate ADS7830 chips. Cooling is a
single GPIO (EN_DIS_COOLING), bang-bang, not PWM.
- GPS: reflect the UM982 driver merged in #79 and its role in
per-detection position tagging beyond map centering.
Counts now match the 3x ADS7830 / 2x DAC5578 / 16x INA241A3 population
in the production BOM.
Resolve cross-layer AGC control mismatch where opcode 0x28 only
controlled the FPGA inner-loop AGC but the STM32 outer-loop AGC
(ADAR1000_AGC) ran independently with its own enable state.
FPGA: Drive gpio_dig6 from host_agc_enable instead of tied low,
making the FPGA register the single source of truth for AGC state.
MCU: Change ADAR1000_AGC constructor default from enabled(true) to
enabled(false) so boot state matches FPGA reset default (AGC off).
Read DIG_6 GPIO every frame with 2-frame confirmation debounce to
sync outerAgc.enabled — prevents single-sample glitch from causing
spurious AGC state transitions.
Tests: Update MCU unit tests for new default, add 6 cross-layer
contract tests verifying the FPGA-MCU-GUI AGC invariant chain.
- Bandwidth 500 MHz -> 20 MHz, sample rate 4 MHz -> 100 MHz (DDC output)
- Range formula: deramped FMCW -> matched-filter c/(2*Fs)*decimation
- Velocity formula: use PRI (167 us) and chirps_per_subframe (16)
- Carrier frequency: 10.525 GHz -> 10.5 GHz per radar_scene.py
- Range per bin: 4.8 m -> 24 m, max range: 307 m -> 1536 m
- Fix simulator target spawn range to match new coverage (50-1400 m)
- Remove dead BANDWIDTH constant, add SAMPLE_RATE to V65 Tk
- All 174 tests pass, ruff clean
- Add deprecation headers to GUI_V6.py and GUI_V6_Demo.py
- Mark V6 as deprecated in GUI_versions.txt
- Update README.md: replace V6 GIF reference with V65 PNG
- Add FT2232H production notice banner to docs/index.html
- Add FT601Connection in radar_protocol.py using ftd3xx library with
proper setChipConfiguration re-enumeration handling (close, wait 2s,
re-open) and 4-byte write alignment
- Add USB Interface dropdown to V65 Tk GUI (FT2232H default, FT601 option)
- Add USB Interface combo to V7 PyQt dashboard with Live/File mode toggle
- Fix mock frame_start bit 7 in both FT2232H and FT601 connections
- Use FPGA range data from USB packets instead of recomputing in Python
- Export FT601Connection from v7/hardware.py and v7/__init__.py
- Add 7 FT601Connection tests (91 total in test_GUI_V65_Tk.py)
golden_reference.py: update comment from 'Simplified' to 'Exact' to
match shaun0927's corrected formula.
fpga_model.py: fix adc_to_signed docstring that incorrectly derived
0x7F80 instead of 0xFF00. Verilog '/' binds tighter than '-', so
{1'b0,8'hFF,9'b0}/2 = 0x1FE00/2 = 0xFF00, not 0xFF<<8 = 0x7F80.
Silently skipping Tier 2/3 tests in CI defeats the purpose of running
them. Add a GITHUB_ACTIONS guard that raises RuntimeError at module
load if iverilog or C++ compiler is not found, preventing false-green
CI results from skipped tests.
- Add ERROR_COUNT sentinel to SystemError_t enum
- Change error_strings[] to static const char* const
- Add static_assert to enforce enum/array sync at compile time
- Add runtime bounds check with fallback for invalid error codes
- Add all missing test binary names to .gitignore
The gap3, agc, and gps test binaries (Mach-O executables compiled on macOS)
were accidentally tracked. CI runs on Linux and fails with 'Exec format error'.
Removed from index and added to .gitignore.
FPGA-001: The previous fix derived frame boundaries from chirp_counter==0,
but that counter comes from plfm_chirp_controller_enhanced which overflows
to N (not wrapping at chirps_per_elev). This caused frame pulses only on
6-bit rollover (every 64 chirps) instead of every N chirps. Now wires the
CDC-synchronized tx_new_chirp_frame_sync signal from the transmitter into
radar_receiver_final, giving correct per-frame timing for any N.
STM32-004: Changed ad9523_init() failure path from Error_Handler() to
return -1, matching the pattern used by ad9523_setup() and ad9523_status()
in the same function. Both halt the system, but return -1 keeps IRQs
enabled for diagnostic output.
STM32-006: Remove blocking do-while loop that waited for legacy GUI start
flag — production V7 PyQt GUI never sends it, hanging the MCU at boot.
STM32-004: Check ad9523_init() return code and call Error_Handler() on
failure, matching the pattern used by all other hardware init calls.
FPGA-001: Simplify frame boundary detection to only trigger on
chirp_counter wrap-to-zero. Previous conditions checking == N and == 2N
were unreachable dead code (counter wraps at N-1). Now correct for any
chirps_per_elev value.
- Rename ERROR_STEPPER_FAULT → ERROR_STEPPER_MOTOR to match main.cpp enum
- Update critical-error predicate to include ERROR_TEMPERATURE_HIGH and
ERROR_WATCHDOG_TIMEOUT (was testing stale pre-fix logic)
- Test 4 now asserts overtemp DOES trigger e-stop (previously asserted opposite)
- Add Test 5 (watchdog triggers e-stop) and Test 6 (memory alloc does not)
- Add ERROR_MEMORY_ALLOC and ERROR_WATCHDOG_TIMEOUT to local enum
- 7 tests, all pass
Rename radar_dashboard.py -> GUI_V65_Tk.py and add core feature parity
with the v7 PyQt dashboard while keeping Tkinter as the framework:
Replay mode:
- _ReplayController with threading.Event-based play/pause/stop
- Reuses v7.ReplayEngine and v7.SoftwareFPGA for all 3 input formats
- Dual dispatch routes FPGA control opcodes to SoftwareFPGA during
raw IQ replay; non-routable opcodes show user-visible status message
- Seek slider with re-emit guard, speed combo, loop checkbox
- close() properly releases engine file handles on stop/reload
Demo mode:
- DemoTarget kinematics scaled to physical range grid (~307m max)
- DemoSimulator generates synthetic RadarFrames with Gaussian blobs
- Targets table (ttk.Treeview) updates from demo target list
Mode exclusion (bidirectional):
- Connect stops active demo/replay before starting acquisition
- Replay load stops previous controller and demo before loading
- Demo start stops active replay; refuses if live-connected
- --live/--replay/--demo in mutually exclusive CLI arg group
Bug fixes:
- seek() now increments past emitted frame to prevent re-emit on resume
- Failed replay load nulls controller ref to prevent dangling state
Tests: 17 new tests for DemoTarget, DemoSimulator, _ReplayController
CI: all 4 jobs pass (167+21+25+29 = 242 tests)
Add SoftwareFPGA class that imports golden_reference functions to
replicate the FPGA pipeline in software, enabling bit-accurate replay
of raw IQ, FPGA co-sim, and HDF5 recordings through the same
dashboard path as live data.
New modules: software_fpga.py, replay.py (ReplayEngine + 3 loaders)
Enhanced: WaveformConfig model, extract_targets_from_frame() in
processing, ReplayWorker with thread-safe playback controls,
dashboard replay UI with transport controls and dual-dispatch
FPGA parameter routing.
Removed: ReplayConnection (from radar_protocol, hardware, dashboard,
tests) — replaced by the unified replay architecture.
150/150 tests pass, ruff clean.
Bug #1 — Range calibration for Raw IQ Replay:
- Add WaveformConfig dataclass (models.py) with FMCW waveform params
(fs, BW, T_chirp, fc) and methods to compute range/velocity resolution
- Add waveform parameter spinboxes to playback controls (dashboard.py)
- Auto-parse waveform params from ADI phaser filename convention
- Create replay-specific RadarSettings with correct calibration instead
of using FPGA defaults (781.25 m/bin → 0.334 m/bin for ADI phaser)
- Add 4 unit tests validating WaveformConfig math
Bug #2 — Demo + radar mutual exclusion:
- _start_demo() now refuses if radar is running (_running=True)
- _start_radar() stops demo first if _demo_mode is active
- Demo buttons disabled while radar/replay is running, re-enabled on stop
Bug #3 — Refactor adi_agc_analysis.py:
- Remove 60+ lines of duplicated AGC functions (signed_to_encoding,
encoding_to_signed, clamp_gain, apply_gain_shift)
- Import from v7.agc_sim canonical implementation
- Rewrite simulate_agc() to use process_agc_frame() in a loop
- Rewrite process_frame_rd() to use quantize_iq() from agc_sim
- workers.py: Only emit playbackStateChanged on state transitions to
prevent stale 'playing' signal from overwriting pause button text
- dashboard.py: Force C locale on all QDoubleSpinBox instances so
comma-decimal locales don't break numeric input; add missing
'Saturation' legend label to AGC chart
- map_widget.py: Enable LocalContentCanAccessRemoteUrls and set HTTP
base URL so Leaflet CDN tiles/scripts load correctly in QtWebEngine
State machine fixes:
1. Raw IQ replay EOF now calls _stop_radar() to fully restore UI
2. Worker thread finished signal triggers UI recovery on crash/exit
3. _stop_radar() stops demo simulator to prevent cross-mode interference
4. _stop_demo() correctly identifies Mock mode via combo text
5. Demo start no longer clobbers status bar when acquisition is running
6. _stop_radar() resets playback button text, frame counter, file label
7. _start_raw_iq_replay() error path cleans up stale controller/worker
8. _refresh_gui() preserves Raw IQ paused status instead of overwriting
Map/location:
- RawIQReplayWorker now receives _radar_position (GPSData ref) so
targets get real lat/lon projected from the virtual radar position
- Added heading control to Map tab sidebar (0-360 deg, wrapping)
- Manual lat/lon/heading changes in Map tab apply to replay targets
Ruff clean, 120/120 tests pass.
Add a 4th connection mode to the V7 dashboard that loads raw complex IQ
captures (.npy) and runs the full FPGA signal processing chain in software:
quantize → AGC → Range FFT → Doppler FFT → MTI → DC notch → CFAR.
Implementation (7 steps):
- v7/agc_sim.py: bit-accurate AGC runtime extracted from adi_agc_analysis.py
- v7/processing.py: RawIQFrameProcessor (full signal chain) + shared
extract_targets_from_frame() for bin-to-physical conversion
- v7/raw_iq_replay.py: RawIQReplayController with thread-safe playback
state machine (play/pause/stop/step/seek/loop/FPS)
- v7/workers.py: RawIQReplayWorker (QThread) emitting same signals as
RadarDataWorker + playback state/index signals
- v7/dashboard.py: mode combo entry, playback controls UI, dynamic
RangeDopplerCanvas that adapts to any frame size
Bug fixes included:
- RangeDopplerCanvas no longer hardcodes 64x32; resizes dynamically
- Doppler centre bin uses n_doppler//2 instead of hardcoded 16
- Shared target extraction eliminates duplicate code between workers
Ruff clean, 120/120 tests pass.
v7/__init__.py: wrap workers/map_widget/dashboard imports in try/except
so CI runners without PyQt6 can still test models, processing, hardware.
test_v7.py: skip TestPolarToGeographic when PyQt6 unavailable, split
TestV7Init.test_key_exports into core vs PyQt6-dependent assertions.
Replace all cross-thread root.after() calls with a queue.Queue drained by
the main thread's _schedule_update() timer. _TextHandler no longer holds a
widget reference; log append runs on the main thread via _drain_ui_queue().
Also adds adi_agc_analysis.py — one-off bit-accurate RTL AGC simulation
for ADI CN0566 raw IQ captures (throwaway diagnostic script).
Implements the STM32 outer-loop AGC (ADAR1000_AGC) that reads the FPGA
saturation flag on DIG_5/PD13 once per radar frame and adjusts the
ADAR1000 VGA common gain across all 16 RX channels.
Phase 4 — ADAR1000_AGC class (new files):
- ADAR1000_AGC.h/.cpp: attack/recovery/holdoff logic, per-channel
calibration offsets, effectiveGain() with OOB safety
- test_agc_outer_loop.cpp: 13 tests covering saturation, holdoff,
recovery, clamping, calibration, SPI spy, reset, mixed sequences
Phase 5 — main.cpp integration:
- Added #include and global outerAgc instance
- AGC update+applyGain call between runRadarPulseSequence() and
HAL_IWDG_Refresh() in main loop
Build system & shim fixes:
- Makefile: added CXX/CXXFLAGS, C++ object rules, TESTS_WITH_CXX in
ALL_TESTS (21 total tests)
- stm32_hal_mock.h: const uint8_t* for HAL_UART_Transmit (C++ compat),
__NOP() macro for host builds
- shims/main.h + real main.h: FPGA_DIG5_SAT pin defines
All tests passing: MCU 21/21, GUI 92/92, cross-layer 29/29.
Bug 1 (FPGA): status_words[0] was 37 bits (8+3+2+5+3+16), silently
truncated to 32. Restructured to {0xFF, mode[1:0], stream[2:0],
3'b000, threshold[15:0]} = 32 bits exactly. Fixed in both
usb_data_interface_ft2232h.v and usb_data_interface.v.
Bug 2 (Python): radar_mode extracted at bit 21 but was actually at
bit 24 after truncation — always returned 0. Updated shift/mask in
parse_status_packet() to match new layout (mode>>22, stream>>19).
Bug 3 (STM32): parseFromUSB() minimum size check was 74 bytes but
9 doubles + uint32 + markers = 82 bytes. Buffer overread on last
fields when 74-81 bytes passed.
All 166 tests pass (29 cross-layer, 92 GUI, 20 MCU, 25 FPGA).
Three-tier test orchestrator validates opcode maps, bit widths, packet
layouts, and round-trip correctness across FPGA RTL, Python GUI, and
STM32 firmware. Catches 3 real bugs:
- status_words[0] 37-bit truncation in both USB interfaces
- Python radar_mode readback at wrong bit position (bit 21 vs 24)
- RadarSettings.cpp buffer overread (min check 74 vs required 82)
29 tests: 24 pass, 5 xfail (documenting confirmed bugs).
4th CI job added: cross-layer-tests (Python + iverilog + cc).
The replay _replay_dc_notch() was treating all 32 Doppler bins as a
single frame, only zeroing bins at the global edges ({0,1,31} for
width=2). The RTL uses dual 16-point sub-frames where each sub-frame
has its own DC, so the notch must use bin_within_sf = dbin & 0xF.
This fixes test_replay_packets_parseable which was seeing 5 detections
instead of the expected 4, due to a spurious hit at (range=2, doppler=15)
surviving CFAR.
Regenerate all real-data golden reference hex files against the current
dual 16-point FFT Doppler architecture (staggered-PRI sub-frames).
The old hex files were generated against the previous 32-point single-FFT
architecture and caused 2048/2048 mismatches in both strict real-data TBs.
Changes:
- Regenerate doppler_ref_i/q.hex, fullchain_doppler_ref_i/q.hex, and all
downstream golden files (MTI, DC notch, CFAR) via golden_reference.py
- Add tb_doppler_realdata (exact-match, ADI CN0566 data) to regression
- Add tb_fullchain_realdata (exact-match, decim->Doppler chain) to regression
- Both TBs now pass: 2048/2048 bins exact match, MAX_ERROR=0
- Update CI comment: 23 -> 25 testbenches
- Fill in STALE_NOTICE.md with regeneration instructions
Regression: 25/25 pass, 0 fail, 0 skip. ruff check: 0 errors.
Resolve all 374 ruff errors across 36 Python files (E501, E702, E722,
E741, F821, F841, invalid-syntax) bringing `ruff check .` to zero
errors repo-wide with line-length=100.
Rewrite CI workflow to use uv for dependency management, whole-repo
`ruff check .`, py_compile syntax gate, and merged python-tests job.
Add pyproject.toml with ruff config and uv dependency groups.
CI structure proposed by hcm444.
Remove unused imports (deque, sys, Opcode, struct, _REPLAY_ADJUSTABLE_OPCODES)
across 4 active Python files and refactor semicolons to separate statements
in radar_protocol.py. Add ruff lint job to CI workflow targeting only the
active files (excludes legacy GUI_V*.py and v7/).