The chirp_scheduler had a 3-bit host_subframe_enable input {LONG, MEDIUM, SHORT}
that was tied to the constant RP_DEF_SUBFRAME_ENABLE at the receiver instance,
so the host could neither change it nor know what mask was active. With the
mask not at 3'b111 the scheduler skips a sub-frame at TX but doppler_processor
still writes 48 chirp slots, so the host CRT (`dbin // 16 → {SHORT, MED, LONG}`)
silently mis-attributes the SF axis and unfolds to the wrong velocity.
Plumb the mask through:
- radar_system_top.v: new reg [2:0] host_subframe_enable, cold-reset
RP_DEF_SUBFRAME_ENABLE, opcode 0x19 setter, wired to rx_inst and usb_inst.
- radar_receiver_final.v: new host_subframe_enable[2:0] input port; the
chirp_scheduler instance is untied from the constant.
- usb_data_interface_ft2232h.v: new subframe_enable[2:0] input + per-frame
snapshot reg latched at frame_complete (stable for ft_clk read, same
pattern as stream_flags_snapshot). Byte 2 emission is now
{2'b00, subframe_enable[2:0], stream_flags[2:0]} — was {5'b00000, stream}.
- radar_protocol.py: Opcode.SUBFRAME_ENABLE = 0x19; RadarFrame.subframe_enable
field; parse_bulk_frame surfaces bits[5:3]; reserved-mask 0xF8 → 0xC0.
Bulk-frame mock encodes the mask in its emit so dashboard replay is correct.
- v7/processing.py: extract_targets_from_frame_crt forces every target to
AMBIGUOUS when frame.subframe_enable != 0b111. Operator sees the red `?`
flag in the targets table instead of a silently-wrong velocity.
- v7/software_fpga.py + v7/dashboard.py: subframe_enable mirror + setter, and
replay dispatch routes 0x19 to set_subframe_enable.
Tests (test_v7.py): TestSubframeEnableRoundTrip (4), TestSoftwareFpgaSubframeEnable
(2), TestCrtSubframeMaskGating (3), 0x19 added to TestOpcodeEnumFillIn and
TestReplayOpcodeDispatch. Existing test_full_frame_round_trip updated to expect
byte 2 = 0x3F (mask 0b111 default + stream 0x07).
Cosim TBs (tb/tb_usb_protocol_v2.v, tb/tb_ft2232h_frame_drop.v) drive the new
input with 3'b111 and assert the new byte-2 layout (T2.3: 0x00 → 0x38).
Regression: test_v7 146/146, test_GUI_V65_Tk 117/117, ruff clean.
iverilog: tb_usb_protocol_v2 27/27 PASS, tb_ft2232h_frame_drop 10/10 PASS.
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).
Audit P-2 and P-3 (2026-05-02): GUI radar_protocol.py was still on the
pre-PR-G wire format. Production frames were rejected 100% before they
reached the dashboard.
Bulk frame (P-2):
- BULK_FRAME_HEADER_SIZE 8 -> 9 (FPGA emits byte[1] = RP_USB_PROTOCOL_VERSION
= 0x02). All field offsets shift by 1 (frame_num at +3,+4; n_range at
+5,+6; n_doppler at +7,+8). Parser now validates the version byte.
- Detect packing: 1 bit/cell (np.unpackbits) -> 2 bits/cell, 4 cells per
byte MSB-first per PR-F. BULK_DETECT_DENSE_BYTES 3072 -> 6144 (= 512 *
ceil(48*2/8)). New _unpack_detect_2bit returns uint8 codes 0..3
(NONE/CAND/CONFIRM/RSVD) instead of a 0/1 bitmap.
- Reserved-bit mask 0xC0 -> 0xF8 (only low 3 stream-enable bits valid;
bits 3-7 reserved). Drop dead BULK_FLAG_MAG_ONLY/SPARSE_DET constants
and the rejection logic gated on them — the FPGA emit path always emits
mag-only / dense, so flag-driven variants were never on the wire.
- find_bulk_frame_boundaries: 9-byte minimum, validate version, bin
counts at +5,+6 and +7,+8.
- _mock_read updated to emit v2 frames so FT2232HConnection(mock=True)
produces parseable data for tests and replay.
Status (P-3):
- STATUS_PACKET_SIZE 26 -> 30 (PR-G adds status_words[6] for 2-tier CFAR
telemetry: detect_count_cand[31:16] + detect_threshold_soft[15:0]).
StatusResponse gains detect_count_cand, detect_threshold_soft, and
frame_drop_count fields.
Bonus: m-3 fixed in passing — Opcode docstring line refs were stale
(902-944 -> current ranges), now also documents 0x17/0x18/0x2D/0x32 as
"M-2/M-3 — no enum yet" so a reader knows what's wired but unreachable.
RadarFrame docstring "(64 range x 32 Doppler)" -> production dims.
Tests:
- TestBulkFrameV2RoundTrip (5 cases) — synthetic v2 frame round-trip,
version-byte rejection, reserved-bit rejection, 2-bit code decode,
back-to-back boundary scan.
- TestStatusPacketV2RoundTrip (4 cases) — 30-byte size, word[6] decode,
short-packet rejection, legacy-26B packet rejection.
- test_GUI_V65_Tk: _make_status_packet emits 30 B w/ word[6];
_build_bulk_frame emits v2 w/ version byte + 2-bit detect packing.
Pre-PR-G assertions on MAG_ONLY/SPARSE_DET dropped; new
test_reject_wrong_version_byte + test_parse_status_word6_2tier_cfar.
Test result: test_v7 111/111 + test_GUI_V65_Tk 117/117 = 228/228 PASS in
radar_venv. Ruff clean.
cosim_dir revival:
- gen_realdata_hex.py: also emit decimated_range_{i,q}.npy (48x512)
and doppler_map_{i,q}.npy (512x48) at production dimensions; the
same Python pipeline that produces the RTL .hex stimuli now writes
the .npy intermediates v7.replay COSIM_DIR loads. Replaces the
workflow lost when golden_reference.py was deleted in e8b495c
- test_v7.py: update test_get_frame_cosim shape from pre-PR-O.6
(64,32) to (NUM_RANGE_BINS, NUM_DOPPLER_BINS)
- check in 4 .npy reference files (~400 KB, deterministic SCENE_SEED=42)
Ruff lint cleanup (was 66 errors; now 0):
- pyproject.toml: ignore T20 in tb/cosim/**.py (CLI tools)
- compare_independent.py: drop redundant int() casts (RUF046),
swap try/except scipy import for importlib.util.find_spec,
remove dead duplicate np import, ASCII-ize comment unicode,
wrap E501 format strings
- fpga_reference.py: drop unused fs arg from nco_reference,
collapse if/else to ternary, mark _out_im unused
- v7/processing.py: ASCII-ize x in docstring, collapse if-branches
- {dashboard,software_fpga,workers,radar_protocol}.py: wrap E501
- test_v7.py: ASCII-ize comment unicode, _alias renames where unused
Result: test_v7 100/100 (0 skips on radar_venv, was 9 graceful
skips); 5 cosim_dir orphan tests now active and passing.
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/).