feat: 2048-pt FFT upgrade with decimation=4, 512 output bins, 6m spacing

Complete cross-layer upgrade from 1024-pt/64-bin to 2048-pt/512-bin FFT:

FPGA RTL (14+ modules):
- radar_params.vh: FFT_SIZE=2048, RANGE_BINS=512, 9-bit range, 6-bit stream
- fft_engine.v: 2048-pt FFT with XPM BRAM
- chirp_memory_loader_param.v: 2 segments x 2048 (was 4 x 1024)
- matched_filter_multi_segment.v: BRAM inference for overlap_cache, explicit ov_waddr
- mti_canceller.v: BRAM inference for prev_i/q arrays (was fabric FFs)
- doppler_processor.v: 16384-deep memory, 14-bit addressing
- cfar_ca.v: 512 rows, indentation fix
- radar_receiver_final.v: rising-edge detector for frame_complete, 11-bit sample_addr
- range_bin_decimator.v: 512 output bins
- usb_data_interface_ft2232h.v: bulk per-frame with Manhattan magnitude
- radar_mode_controller.v: XOR edge detector for toggle signals
- rx_gain_control.v: updated for new bin count

Python GUI + Protocol (8 files):
- radar_protocol.py: 512-bin bulk frame parser, LSB-first bitmap
- GUI_V65_Tk.py, v7/*.py: updated for 512 bins, 6m range resolution

Golden data + tests:
- All .hex/.csv/.npy golden references regenerated for 2048/512
- fft_twiddle_2048.mem added
- Deleted stale seg2/seg3 chirp mem files
- 9 new bulk frame cross-layer tests, deleted 6 stale per-sample tests
- Deleted stale tb_cross_layer_ft2232h.v and dead contract_parser functions
- Updated validate_mem_files.py for 2048/2-segment config

MCU: RadarSettings.cpp max_distance/map_size 1536->3072

All 4 CI jobs pass: 285 tests, 0 failures, 0 skips
This commit is contained in:
Jason
2026-04-16 17:27:55 +05:45
parent affa40a9d3
commit e9705e40b7
178 changed files with 687738 additions and 122880 deletions
+250 -66
View File
@@ -7,9 +7,11 @@ No GUI dependencies — safe to import from tests and headless scripts.
USB Interface: FT2232H USB 2.0 (8-bit, 50T production board) via pyftdi
USB Packet Protocol (11-byte):
USB Packet Protocol:
TX (FPGA→Host):
Data packet: [0xAA] [range_q 2B] [range_i 2B] [dop_re 2B] [dop_im 2B] [det 1B] [0x55]
Bulk frame (FT2232H):
[0xAA] [flags 1B] [frame# 2B] [range_bins 2B] [doppler_bins 2B]
[range profile (opt)] [doppler mag/IQ (opt)] [detect flags (opt)] [0x55]
Status packet: [0xBB] [status 6x32b] [0x55]
RX (Host→FPGA):
Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo}
@@ -39,12 +41,15 @@ FOOTER_BYTE = 0x55
STATUS_HEADER_BYTE = 0xBB
# Packet sizes
DATA_PACKET_SIZE = 11 # 1 + 4 + 2 + 2 + 1 + 1
BULK_HEADER_SIZE = 8 # 1(AA)+1(flags)+2(frame#)+2(Rbins)+2(Dbins)
STATUS_PACKET_SIZE = 26 # 1 + 24 + 1
NUM_RANGE_BINS = 64
# Legacy per-sample protocol (FT601 USB 3.0 only)
DATA_PACKET_SIZE = 11 # 1 + 4 + 2 + 2 + 1 + 1
NUM_RANGE_BINS = 512
NUM_DOPPLER_BINS = 32
NUM_CELLS = NUM_RANGE_BINS * NUM_DOPPLER_BINS # 2048
NUM_CELLS = NUM_RANGE_BINS * NUM_DOPPLER_BINS # 16384
WATERFALL_DEPTH = 64
@@ -66,7 +71,7 @@ class Opcode(IntEnum):
RADAR_MODE = 0x01 # 2-bit mode select
TRIGGER_PULSE = 0x02 # self-clearing one-shot trigger
DETECT_THRESHOLD = 0x03 # 16-bit detection threshold value
STREAM_CONTROL = 0x04 # 3-bit stream enable mask
STREAM_CONTROL = 0x04 # 6-bit stream/format control
# --- Digital gain (0x16) ---
GAIN_SHIFT = 0x16 # 4-bit digital gain shift
@@ -108,7 +113,7 @@ class Opcode(IntEnum):
@dataclass
class RadarFrame:
"""One complete radar frame (64 range x 32 Doppler)."""
"""One complete radar frame (512 range x 32 Doppler)."""
timestamp: float = 0.0
range_doppler_i: np.ndarray = field(
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.int16))
@@ -230,9 +235,9 @@ class RadarProtocol:
return None
sr = StatusResponse()
# Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
# Word 0: {0xFF[31:24], mode[23:22], stream[21:16], threshold[15:0]}
sr.cfar_threshold = words[0] & 0xFFFF
sr.stream_ctrl = (words[0] >> 19) & 0x07
sr.stream_ctrl = (words[0] >> 16) & 0x3F
sr.radar_mode = (words[0] >> 22) & 0x03
# Word 1: {long_chirp[31:16], long_listen[15:0]}
sr.long_listen = words[1] & 0xFFFF
@@ -257,24 +262,185 @@ class RadarProtocol:
sr.self_test_busy = (words[5] >> 24) & 0x01
return sr
@staticmethod
def parse_bulk_frame(raw: bytes) -> RadarFrame | None:
"""Parse a bulk per-frame transfer from the FT2232H USB interface.
Frame format (from usb_data_interface_ft2232h.v):
Byte 0: 0xAA (header)
Byte 1: Format flags {2'b0, sparse_det, mag_only,
stream_cfar, stream_doppler, stream_range}
Byte 2-3: Frame number (16-bit, MSB first)
Byte 4-5: Range bin count (16-bit, MSB first)
Byte 6-7: Doppler bin count (16-bit, MSB first)
[variable payload based on flags]
Last byte: 0x55 (footer)
"""
if len(raw) < BULK_HEADER_SIZE + 1: # header + footer minimum
return None
if raw[0] != HEADER_BYTE:
return None
flags = raw[1]
frame_num = struct.unpack_from(">H", raw, 2)[0]
n_range = struct.unpack_from(">H", raw, 4)[0]
n_doppler = struct.unpack_from(">H", raw, 6)[0]
stream_range = bool(flags & 0x01)
stream_doppler = bool(flags & 0x02)
stream_cfar = bool(flags & 0x04)
mag_only = bool(flags & 0x08)
sparse_det = bool(flags & 0x10)
offset = BULK_HEADER_SIZE
frame = RadarFrame()
frame.frame_number = frame_num
frame.timestamp = time.time()
# --- Range profile section ---
if stream_range:
nbytes = n_range * 2
if offset + nbytes > len(raw):
return None
range_data = np.frombuffer(raw[offset:offset + nbytes],
dtype=">u2").astype(np.float64)
frame.range_profile = range_data
offset += nbytes
# --- Doppler magnitude or I/Q section ---
if stream_doppler:
if mag_only:
nbytes = n_range * n_doppler * 2
if offset + nbytes > len(raw):
return None
mag_flat = np.frombuffer(raw[offset:offset + nbytes],
dtype=">u2").astype(np.float64)
frame.magnitude = mag_flat.reshape(n_range, n_doppler)
# No I/Q available in mag-only mode
frame.range_doppler_i = np.zeros((n_range, n_doppler), dtype=np.int16)
frame.range_doppler_q = np.zeros((n_range, n_doppler), dtype=np.int16)
offset += nbytes
else:
# Full I/Q: 32-bit per cell (I16, Q16)
nbytes = n_range * n_doppler * 4
if offset + nbytes > len(raw):
return None
iq_data = np.frombuffer(raw[offset:offset + nbytes], dtype=">i2")
iq_data = iq_data.reshape(n_range, n_doppler, 2)
frame.range_doppler_i = iq_data[:, :, 0].astype(np.int16)
frame.range_doppler_q = iq_data[:, :, 1].astype(np.int16)
frame.magnitude = (
np.abs(frame.range_doppler_i.astype(np.float64))
+ np.abs(frame.range_doppler_q.astype(np.float64))
)
offset += nbytes
# --- Detection flags section ---
if stream_cfar:
if sparse_det:
if offset + 2 > len(raw):
return None
det_count = struct.unpack_from(">H", raw, offset)[0]
offset += 2
nbytes = det_count * 6
if offset + nbytes > len(raw):
return None
det_arr = np.zeros((n_range, n_doppler), dtype=np.uint8)
for d in range(det_count):
base = offset + d * 6
rbin = struct.unpack_from(">H", raw, base)[0]
dbin = struct.unpack_from(">H", raw, base + 2)[0]
if rbin < n_range and dbin < n_doppler:
det_arr[rbin, dbin] = 1
frame.detections = det_arr
frame.detection_count = det_count
offset += nbytes
else:
# Packed bitmap: n_range * n_doppler bits, LSB-first per byte
# RTL packs: byte_addr = {range_bin, doppler[4:3]}, bit = doppler[2:0]
nbytes = (n_range * n_doppler + 7) // 8
if offset + nbytes > len(raw):
return None
det_bytes = raw[offset:offset + nbytes]
det_arr = np.zeros((n_range, n_doppler), dtype=np.uint8)
for r in range(n_range):
for db in range(n_doppler):
byte_idx = r * (n_doppler // 8) + db // 8
bit_pos = db % 8 # LSB-first: doppler[2:0] = bit position
if byte_idx < len(det_bytes) and (det_bytes[byte_idx] >> bit_pos) & 1:
det_arr[r, db] = 1
frame.detections = det_arr
frame.detection_count = int(det_arr.sum())
offset += nbytes
# Footer check
if offset >= len(raw) or raw[offset] != FOOTER_BYTE:
return None
# Derive range_profile from magnitude if not streamed directly
if not stream_range and stream_doppler:
frame.range_profile = np.sum(frame.magnitude, axis=1)
return frame
@staticmethod
def compute_bulk_frame_size(flags: int, n_range: int = NUM_RANGE_BINS,
n_doppler: int = NUM_DOPPLER_BINS) -> int:
"""Compute expected bulk frame size in bytes for given flags."""
size = BULK_HEADER_SIZE # header
if flags & 0x01: # stream_range
size += n_range * 2
if flags & 0x02: # stream_doppler
if flags & 0x08: # mag_only
size += n_range * n_doppler * 2
else:
size += n_range * n_doppler * 4
if flags & 0x04: # stream_cfar
if flags & 0x10: # sparse_det — variable, use bitmap estimate
size += 2 # count field minimum
else:
size += (n_range * n_doppler + 7) // 8
size += 1 # footer
return size
@staticmethod
def find_packet_boundaries(buf: bytes) -> list[tuple[int, int, str]]:
"""
Scan buffer for packet start markers (0xAA data, 0xBB status).
Scan buffer for packet start markers.
Supports bulk frames (0xAA with 8-byte header), status (0xBB),
and legacy 11-byte data packets (0xAA with footer at offset 10).
Returns list of (start_idx, expected_end_idx, packet_type).
"""
packets = []
i = 0
while i < len(buf):
if buf[i] == HEADER_BYTE:
# Try bulk frame first (8-byte header with range/doppler counts)
if i + BULK_HEADER_SIZE <= len(buf):
flags = buf[i + 1]
n_range = struct.unpack_from(">H", buf, i + 4)[0]
n_doppler = struct.unpack_from(">H", buf, i + 6)[0]
# Sanity: valid bulk frame has reasonable dimensions
if (1 <= n_range <= 2048 and 1 <= n_doppler <= 64
and flags <= 0x3F):
expected_size = RadarProtocol.compute_bulk_frame_size(
flags, n_range, n_doppler)
end = i + expected_size
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
packets.append((i, end, "bulk"))
i = end
continue
if end > len(buf):
break # partial bulk frame
# Fallback: legacy 11-byte per-sample packet (FT601)
end = i + DATA_PACKET_SIZE
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
packets.append((i, end, "data"))
i = end
else:
if end > len(buf):
break # partial packet at end — leave for residual
i += 1 # footer mismatch — skip this false header
break
i += 1
elif buf[i] == STATUS_HEADER_BYTE:
end = i + STATUS_PACKET_SIZE
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
@@ -392,53 +558,62 @@ class FT2232HConnection:
log.error(f"FT2232H write error: {e}")
return False
def _mock_read(self, size: int) -> bytes:
def _mock_read(self, _size: int) -> bytes:
"""
Generate synthetic 11-byte radar data packets for testing.
Emits packets in sequential FPGA order (range_bin 0..63, doppler_bin
0..31 within each range bin) so that RadarAcquisition._ingest_sample()
places them correctly. A target is injected near range bin 20,
Doppler bin 8.
Generate a synthetic bulk radar frame for testing.
Matches the FT2232H sectioned transfer format: header (8B) →
range profile → doppler magnitude → detection flags → footer.
A target is injected near range bin 100, Doppler bin 8.
"""
time.sleep(0.05)
self._mock_frame_num += 1
# Stream flags: range + doppler + cfar, mag-only, bitmap detections
flags = 0x0F # stream_range | stream_doppler | stream_cfar | mag_only
buf = bytearray()
num_packets = min(NUM_CELLS, size // DATA_PACKET_SIZE)
start_idx = getattr(self, '_mock_seq_idx', 0)
# --- Header (8 bytes) ---
buf.append(HEADER_BYTE)
buf.append(flags)
buf += struct.pack(">H", self._mock_frame_num & 0xFFFF)
buf += struct.pack(">H", NUM_RANGE_BINS)
buf += struct.pack(">H", NUM_DOPPLER_BINS)
for n in range(num_packets):
idx = (start_idx + n) % NUM_CELLS
rbin = idx // NUM_DOPPLER_BINS
dbin = idx % NUM_DOPPLER_BINS
# --- Range profile (512 x 16-bit) ---
range_profile = self._mock_rng.randint(50, 200, size=NUM_RANGE_BINS).astype(np.uint16)
# Inject target peak at range bin ~100
for rb in range(98, 103):
range_profile[rb] = 8000
buf += range_profile.astype(">u2").tobytes()
range_i = int(self._mock_rng.normal(0, 100))
range_q = int(self._mock_rng.normal(0, 100))
if abs(rbin - 20) < 3:
range_i += 5000
range_q += 3000
# --- Doppler magnitude (512 x 32 x 16-bit, mag-only) ---
mag = self._mock_rng.randint(
10, 100, size=(NUM_RANGE_BINS, NUM_DOPPLER_BINS),
).astype(np.uint16)
for rb in range(98, 103):
for db in range(7, 10):
mag[rb, db] = 8000
buf += mag.astype(">u2").tobytes()
dop_i = int(self._mock_rng.normal(0, 50))
dop_q = int(self._mock_rng.normal(0, 50))
if abs(rbin - 20) < 3 and abs(dbin - 8) < 2:
dop_i += 8000
dop_q += 4000
# --- Detection flags (bitmap: 512*32/8 = 2048 bytes) ---
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8)
for rb in range(99, 102):
for db in range(7, 10):
det[rb, db] = 1
det_bytes = bytearray((NUM_RANGE_BINS * NUM_DOPPLER_BINS + 7) // 8)
for r in range(NUM_RANGE_BINS):
for d in range(NUM_DOPPLER_BINS):
if det[r, d]:
# LSB-first per byte, matching RTL: byte_addr = {range_bin, doppler[4:3]},
# bit = doppler[2:0]. This matches the parser at line ~368.
byte_idx = r * (NUM_DOPPLER_BINS // 8) + d // 8
bit_pos = d % 8
det_bytes[byte_idx] |= 1 << bit_pos
buf += det_bytes
detection = 1 if (abs(rbin - 20) < 2 and abs(dbin - 8) < 2) else 0
# --- Footer ---
buf.append(FOOTER_BYTE)
# Build compact 11-byte packet
pkt = bytearray()
pkt.append(HEADER_BYTE)
pkt += struct.pack(">h", np.clip(range_q, -32768, 32767))
pkt += struct.pack(">h", np.clip(range_i, -32768, 32767))
pkt += struct.pack(">h", np.clip(dop_i, -32768, 32767))
pkt += struct.pack(">h", np.clip(dop_q, -32768, 32767))
pkt.append(detection & 0x01)
pkt.append(FOOTER_BYTE)
buf += pkt
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
return bytes(buf)
@@ -523,8 +698,8 @@ class DataRecorder:
class RadarAcquisition(threading.Thread):
"""
Background thread: reads from USB (FT2232H), parses 11-byte packets,
assembles frames, and pushes complete frames to the display queue.
Background thread: reads from USB (FT2232H), parses bulk frames
or legacy 11-byte packets, and pushes complete frames to the display queue.
"""
def __init__(self, connection, frame_queue: queue.Queue,
@@ -547,7 +722,7 @@ class RadarAcquisition(threading.Thread):
log.info("Acquisition thread started")
residual = b""
while not self._stop_event.is_set():
chunk = self.conn.read(4096)
chunk = self.conn.read(65536)
if chunk is None or len(chunk) == 0:
time.sleep(0.01)
continue
@@ -561,11 +736,16 @@ class RadarAcquisition(threading.Thread):
residual = raw[last_end:]
else:
# No packets found — keep entire buffer as residual
# but cap at 2x max packet size to avoid unbounded growth
max_residual = 2 * max(DATA_PACKET_SIZE, STATUS_PACKET_SIZE)
# but cap to avoid unbounded growth
max_residual = 65536
residual = raw[-max_residual:] if len(raw) > max_residual else raw
for start, end, ptype in packets:
if ptype == "data":
if ptype == "bulk":
frame = RadarProtocol.parse_bulk_frame(raw[start:end])
if frame is not None:
self._emit_frame(frame)
elif ptype == "data":
# Legacy per-sample protocol (FT601)
parsed = RadarProtocol.parse_data_packet(
raw[start:end])
if parsed is not None:
@@ -587,8 +767,21 @@ class RadarAcquisition(threading.Thread):
log.info("Acquisition thread stopped")
def _emit_frame(self, frame: RadarFrame):
"""Push a complete bulk frame to the display queue."""
# Push to display queue (drop old if backed up)
try:
self.frame_queue.put_nowait(frame)
except queue.Full:
with contextlib.suppress(queue.Empty):
self.frame_queue.get_nowait()
self.frame_queue.put_nowait(frame)
if self.recorder and self.recorder.recording:
self.recorder.record_frame(frame)
def _ingest_sample(self, sample: dict):
"""Place sample into current frame and emit when complete."""
"""Place sample into current frame and emit when complete (legacy FT601 path)."""
rbin = self._sample_idx // NUM_DOPPLER_BINS
dbin = self._sample_idx % NUM_DOPPLER_BINS
@@ -607,22 +800,13 @@ class RadarAcquisition(threading.Thread):
self._finalize_frame()
def _finalize_frame(self):
"""Complete frame: compute range profile, push to queue, record."""
"""Complete frame: compute range profile, push to queue, record (legacy path)."""
self._frame.timestamp = time.time()
self._frame.frame_number = self._frame_num
# Range profile = sum of magnitude across Doppler bins
self._frame.range_profile = np.sum(self._frame.magnitude, axis=1)
# Push to display queue (drop old if backed up)
try:
self.frame_queue.put_nowait(self._frame)
except queue.Full:
with contextlib.suppress(queue.Empty):
self.frame_queue.get_nowait()
self.frame_queue.put_nowait(self._frame)
if self.recorder and self.recorder.recording:
self.recorder.record_frame(self._frame)
self._emit_frame(self._frame)
self._frame_num += 1
self._frame = RadarFrame()