Refactor v7.WaveformConfig from single-PRI to PR-Q's 3-PRI staggered
ladder (SHORT 175 us / MEDIUM 161 us / LONG 167 us) and update the
host-side bulk-frame parser dimension to match the FPGA's 48-bin
Doppler output (RP_NUM_DOPPLER_BINS = 48). The parser was rejecting
every production frame with n_doppler != 32, masking the PR-F widening
end-to-end.
WaveformConfig:
- pri_short_s/pri_medium_s/pri_long_s replace single pri_s
- n_doppler_bins 32 -> 48; new num_subframes=3
- Per-subframe velocity_resolution_{short,medium,long}_mps
- Per-subframe max_velocity_{short,medium,long}_mps
- extended_max_velocity_mps_crt(K=6) for 3-PRI alias-resolution ceiling
- Drop pri_s, velocity_resolution_mps, max_velocity_mps (no aliases)
Other:
- radar_protocol.NUM_DOPPLER_BINS 32 -> 48 (NUM_CELLS auto 16384 -> 24576;
BULK_FRAME_MAX_SIZE flows from NUM_CELLS, no other edits needed)
- v7/dashboard.py constant + stale "(64x32)" title replaced with f-string
- v7/processing.py 32-bin fallback -> 48
- v7/workers.py: derive doppler_center from frame.shape; LONG-PRI v_res
used as conservative single-PRI placeholder until PR-Q.5 lands the
CRT extractor (markers in place at both call sites)
- test_v7.py: TestWaveformConfig rewritten (8 tests, per-subframe + CRT
extension); TestExtractTargetsFromFrame center 16 -> 24
Local tests:
TestWaveformConfig 8/8 PASS
TestExtractTargetsFromFrame 6/6 PASS
test_GUI_V65_Tk 117/0/2 PASS
The GUI's radar_protocol.py parsed 11-byte legacy packets only. The
production board (50T, USB_MODE=1) emits ~35 KB bulk frames from
usb_data_interface_ft2232h.v, so the legacy parser saw a random walk
of false 11-byte boundaries through bulk data — no usable display on
production hardware.
Bulk parser added (radar_protocol.py):
- parse_bulk_frame validates header, reserved bits, n_range=512,
n_doppler=32, footer-at-flag-derived-offset; unpacks range_profile
/ doppler_mag / cfar_dense per the format-flags byte.
- find_bulk_frame_boundaries is the bulk counterpart of
find_packet_boundaries; status packets (0xBB) handled in the same
stream since FT2232H emits them too.
- RadarAcquisition dispatches on isinstance(conn, FT2232HConnection):
bulk path skips the per-sample state machine and fills RadarFrame
in one shot. FT601 / 200T keeps legacy 11-byte (USB 3.0 has 50x
bandwidth headroom; per-sample format is correct and already works).
- RadarFrame.mag_only flag carries the wire's mag_only bit so
downstream consumers can skip I/Q panels cleanly.
- FT2232HConnection._mock_read now emits synthetic bulk frames
(was misleading legacy 11-byte).
RTL alignment (AUDIT-C9 RTL stub option):
- usb_data_interface_ft2232h.v header no longer promises the
unimplemented mag_only=0 (full-I/Q) and sparse_det=1 paths;
explicit INERT FLAGS note distinguishes the two reasons:
* Full-I/Q is constrained by hardware — needs ~28-BRAM18 I/Q
buffer (50T currently 78% BRAM utilised after FFT IP) AND
USB 2.0 bandwidth (12.21 MB/s vs 8 MB/s conservative budget).
* Sparse-list is feasible — smaller than dense for typical
scenes (<341 detections), ~1 BRAM18 cost. Just unimplemented
RTL work (small list BRAM + new WR_DETECT_SPARSE state).
- New SIMULATION-only assertion fires if stream_mag_only ever
becomes 0 or stream_sparse_det ever becomes 1 — backstop for
any future regression that bypasses the host-register clamp.
- radar_system_top.v opcode 0x04 force-clamps mag_only=1 and
sparse_det=0 in host_stream_control when USB_MODE=1, so a
Custom-Command host write can't push the FPGA into a wire-format
vs FSM divergence.
Bandwidth math (verified for 27c9c22+):
Frame rate = 1 / (16x167 us + 175.4 us + 16x175 us) = ~178 fps
Mag-only frame = 8+1024+32768+2048+1 = 35849 B = 6.38 MB/s
FT2232H 245-Sync-FIFO sustained budget (FTDI AN_232B-04
conservative): 8 MB/s. Headroom 20%.
Tests: test_GUI_V65_Tk.py TestBulkFrameParser — 18 new cases covering
round-trip per stream-flag combo, header/footer/n_range/n_doppler/
reserved-bit/truncation rejection, multi-frame boundaries, bulk+status
mixed streams, byte-drop resync, dispatch-by-connection-type,
ingest-to-RadarFrame end-to-end. GUI 117/117 PASS, v7 83/83 PASS,
FPGA quick regression 29/29 PASS, ruff clean.
Refs: AUDIT-C9 (GUI parses legacy 11-byte vs FT2232H bulk).
Follow-ups (separate patches):
- Sparse-detection write FSM (~1 BRAM18 + ~100 RTL lines).
Bandwidth- and memory-feasible; just unimplemented work.
- Full-I/Q write FSM. Constrained: needs ~28-BRAM18 I/Q buffer
AND USB 2.0 bandwidth headroom (50T post-FFT-IP at 78% BRAM).
The DDC hard-coded an offset-binary->2C subtract on the AD9484 path. The
chip's output format is selected by the SCLK/DFS strap (jumper SJ1 on
RADAR_Main_Board.sch), and CSB is hard-tied HIGH so SPI cannot be used
to confirm or change it from firmware. If the board is assembled with
SJ1 on pins 2-3 (two's-complement), the existing RTL silently mis-
converts every sample.
Add a 2-bit adc_format input to ddc_400m_enhanced (2-FF synchronized
clk_100m -> clk_400m, ASYNC_REG attribute), drive it from a new top-
level register host_adc_format written by host opcode 0x33, and wire
it through radar_receiver_final. Default 2'b00 matches the SJ1 default
strap (offset-binary) and preserves pre-patch behavior. Opcode 0x32 is
intentionally left unused; reserved for the future S-25 fix
(host-driven adc_pwdn).
Tests: tb/tb_ddc_400m.v Test Group 5 — 7 new assertions covering
offset-binary at {0x80, 0x00, 0xFF}, two's-complement at
{0x00, 0x80, 0x7F}, and reserved 2'b10 fallback. 14/14 PASS.
Refs: AUDIT-C3 (DDC offset-binary hardcoded).
Schematic ref: RADAR_Main_Board.sch:46719 (CSB on +1V8_CLOCK_F),
:46845 (SCLK/DFS via SJ1).
The packet-boundary scanner only checked header + footer bytes, so any
payload byte that happened to be 0xAA (or 0xBB) and which lined up with
a 0x55 at offset+10 (or +25) was accepted as a packet. A single corrupt
byte could permanently shift the binning until the next frame_start
re-sync.
Added two structural sentinel checks against fixed bits the FPGA
emitter always drives to known values:
- data byte 9 = {frame_start, 6'b0, cfar_detection} -> bits[6:1]==0
- status byte 1 = high byte of status_words[0] -> 0xFF
Combined with the existing footer check, false-match probability drops
from ~1/256 to ~1/16384 (data) and ~1/65536 (status). Mock generators
already produce conformant bit patterns, so existing parser/mock-read
tests pass unchanged.
New tests:
- test_find_boundaries_rejects_false_data_header (forged 0xAA...0x55)
- test_find_boundaries_rejects_false_status_header (forged 0xBB...0x55)
- test_find_boundaries_recovers_after_byte_drop (single-byte loss)
Tests: GUI 96/96 (was 93), test_v7 83/83, MCU 75/75, ruff clean.
No RTL change -- wire format is unchanged; this hardens the parser only.
The same RadarFrame is enqueued for the display consumer and handed to
DataRecorder.record_frame on the producer thread. h5py releases the GIL
during gzip compression, so any in-place mutation by the consumer (or a
future scaling/normalisation step) would tear the on-disk frame.
record_frame now copies all five numpy arrays into local snapshots
before passing them to h5py.create_dataset. Disk integrity no longer
depends on consumer behaviour.
New test test_record_frame_isolates_from_post_call_mutation asserts
that mutating every array in place after record_frame returns leaves
the HDF5 contents untouched.
Tests: GUI 93/93 (was 92), ruff clean repo-wide.
`chirps_mismatch_error` was set in radar_system_top when the host
requested chirps_per_elev != Doppler FFT size, but never wired into the
USB status response — a latent silent failure.
Wired the flag through both USB interfaces (FT601 + FT2232H) into bit
[10] of status word 4 (was reserved). GUI parser exposes it as
StatusResponse.chirps_mismatch.
- usb_data_interface*.v: new status_chirps_mismatch input, packed at [10]
- radar_system_top.v: connect chirps_mismatch_error to both USB instances
- radar_protocol.py + test_GUI_V65_Tk.py: parse new bit, +1 round-trip test
- tb_usb_data_interface.v: drive the new port, update word-4 expectation
Tests: GUI 92/92 (was 91), MCU 75/75, USB TB 91/91, ruff clean repo-wide.
The 2 remaining FPGA regression failures (Receiver Integration, MF Chain)
are the pre-existing iverilog-can't-link-Xilinx-IP issue tracked
separately as the open RX-NEW-3 follow-up.
MCU-N4: delay_us(us) reset TIM1 then waited for the counter to reach `us`,
but TIM1 ARR is 0xffff-1 (~65 ms at the 1 MHz tick). Any caller passing
us > 65534 spun forever after the first wrap — a real hazard with the PA
energized. Chunk requests larger than ARR into ARR-sized waits, then the
remainder in the existing single wait. Current callers (T1, PRI1-T1,
Guard, 500us spots) are all well under the bound; this is defensive.
GUI-S4: radar_protocol.STREAM_CONTROL was annotated "3-bit stream enable
mask"; the FPGA accepts usb_cmd_value[5:0] = 6 bits. The wire protocol
already carried the full 32-bit value field, so the upper bits were
reachable via Custom Command — only the comment was wrong. Updated to
match radar_system_top.v:1004.
Verified: 75/75 MCU tests pass; 83/83 v7 GUI tests pass (covered by GUI-C3 commit).
FPGA — RX chain
matched_filter_multi_segment.v: drop the gratuitous /4 scaling on
DDC sign-extended input (was ddc_i[17:2] + ddc_i[1]); use
ddc_i[15:0] directly. fft_engine has INTERNAL_W=32 with
saturating 16-bit output, so full 16-bit input is safe. Restores
~12 dB of MF input dynamic range.
radar_receiver_final.v: remove latency_buffer (count-N-pulses-then-
prime FIFO that left frame 1 with all-zero ref). Replaced with
a single-FF alignment register on ref_i/ref_q that matches the
1-FF stage multi_segment ST_PROCESSING uses on adc_data.
Verified by tb/tb_rxb_fullchain_latency.v — autocorrelation peak
at bin 0 with peak/mean ~88x.
doppler_processor.v / mti_canceller.v / cfar_ca.v /
range_bin_decimator.v / radar_receiver_final.v / radar_system_top.v
/ usb_data_interface_ft2232h.v: switch port and parameter widths
from RP_NUM_RANGE_BINS / RP_RANGE_BIN_BITS (always 512 / 9-bit)
to RP_MAX_OUTPUT_BINS / RP_RANGE_BIN_WIDTH_MAX (auto-scales:
50T 512 / 9-bit, 200T 4096 / 12-bit). Unblocks 200T 20 km mode
at the RX module boundary; USB wire-protocol extension still
pending.
radar_receiver_final.v: doppler_frame_done_prev reset value 0 -> 1
to prevent false done pulse on cycle 1 when level signal is
HIGH at reset.
matched_filter_processing_chain.v: delete the broken `ifdef
SIMULATION inline behavioural FFT (482 lines removed). It
produced wrong-bin peaks and 100-1000x weak magnitudes. Chain
now uses production fft_engine.v + frequency_matched_filter.v
in both iverilog and Vivado. Iverilog tests are ~38x slower per
chain pass but produce correct results. Misleading "OK with
Xilinx IP" comments at three test sites updated since the FFT
is in-house, not an IP placeholder.
FPGA — testbenches
tb/tb_rxb_latency_measure.v (new): measures chain internal pipeline
depth (~2057 cycles, chirp-agnostic).
tb/tb_rxb_fullchain_latency.v (new): full-chain autocorrelation
verification — drives ddc with the same chirp samples the loader
serves as ref, finds peak position and peak/mean.
tb/tb_matched_filter_processing_chain.v: wait timeouts bumped
50000 -> 500000 cycles to accommodate production FFT pipeline.
MCU
main.cpp checkSystemHealthStatus: latch system_emergency_state on
the error_count > 10 path so the SAFE-MODE blink loop in main()
actually engages (was bypassed because predicate was false).
main.cpp: move FPGA reset BEFORE the if(PowerAmplifier) block so
adar_tr_x is driven LOW (RX commanded externally) before PA Vdd
reaches 22 V. Old reset block at the original location removed.
main.cpp MX_GPIO_Init: add GPIO_PIN_12 (FPGA reset) to the
explicit WritePin(LOW) list so the safe initial state is no
longer implicit.
main.cpp checkSystemHealth: rate-limit ADAR1000
verifyDeviceCommunication (HAL_Delay 1ms x 4 devices = 4 ms
blocking SPI burst per main-loop iteration) from every-loop to
every 2 s. readTemperature stays per-loop so over-temp
detection latency is unchanged.
USBHandler.cpp processSettingsData: dispatch threshold bumped
74 -> 82 (matches parser minimum); buffer drained after parse
attempt (slide remaining bytes left) so a false END find no
longer sticks the buffer until 256-byte overflow.
GUI
radar_protocol.py: NUM_RANGE_BINS 64 -> 512 (matches FPGA
RP_NUM_RANGE_BINS); NUM_CELLS 2048 -> 16384.
radar_protocol.py _ingest_sample: honor FPGA frame_start bit for
resync after a USB drop; capture range_profile[rbin] once per
range bin at dbin == 0 (FPGA emits the same range_i/range_q for
all 32 Doppler cells of a given range bin; previous accumulator
inflated the profile 32x).
v7/models.py RadarSettings: range_resolution 24 -> 6 m (matches
c/(2*100MHz)*4); max_distance and coverage_radius 1536 -> 3072 m;
map_size 2000 -> 4000.
v7/models.py WaveformConfig: n_range_bins 64 -> 512, fft_size
1024 -> 2048, decimation_factor 16 -> 4.
GUI_V65_Tk.py: _RANGE_PER_BIN math and stale "~24 m / ~1536 m"
comments updated.
test_v7.py: assertion values updated to match new defaults.
Tests
test_ddc_cosim_fuzz.py: remove unused os/tempfile imports, wrap
three long lines for ruff E501 compliance.
- 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)
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 (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).
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.
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/).