mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-06-10 23:41:18 +00:00
fix(usb): C-9 — GUI bulk-frame parser for FT2232H + clamp inert flag bits
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).
This commit is contained in:
@@ -1018,7 +1018,24 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
|
|||||||
8'h01: host_radar_mode <= usb_cmd_value[1:0];
|
8'h01: host_radar_mode <= usb_cmd_value[1:0];
|
||||||
8'h02: host_trigger_pulse <= 1'b1;
|
8'h02: host_trigger_pulse <= 1'b1;
|
||||||
8'h03: host_detect_threshold <= usb_cmd_value;
|
8'h03: host_detect_threshold <= usb_cmd_value;
|
||||||
8'h04: host_stream_control <= usb_cmd_value[5:0];
|
// AUDIT-C9: stream_control bits [3] (mag_only) and [4]
|
||||||
|
// (sparse_det) are documented in the FT2232H bulk-frame
|
||||||
|
// header but the write FSM does not implement the alternate
|
||||||
|
// encodings yet (see usb_data_interface_ft2232h.v "INERT
|
||||||
|
// FLAGS" note). Force-clamp them to the only encodings the
|
||||||
|
// FSM actually emits so a host write of 0x04 cannot create
|
||||||
|
// a wire-format vs FSM divergence on the production board.
|
||||||
|
8'h04: begin
|
||||||
|
if (USB_MODE == 1) begin
|
||||||
|
// FT2232H production: mag_only stuck at 1, sparse_det stuck at 0.
|
||||||
|
host_stream_control <= {usb_cmd_value[5],
|
||||||
|
1'b0, // sparse_det
|
||||||
|
1'b1, // mag_only
|
||||||
|
usb_cmd_value[2:0]}; // stream r/d/c
|
||||||
|
end else begin
|
||||||
|
host_stream_control <= usb_cmd_value[5:0];
|
||||||
|
end
|
||||||
|
end
|
||||||
// Gap 2: chirp timing configuration
|
// Gap 2: chirp timing configuration
|
||||||
8'h10: host_long_chirp_cycles <= usb_cmd_value;
|
8'h10: host_long_chirp_cycles <= usb_cmd_value;
|
||||||
8'h11: host_long_listen_cycles <= usb_cmd_value;
|
8'h11: host_long_listen_cycles <= usb_cmd_value;
|
||||||
|
|||||||
@@ -20,21 +20,42 @@
|
|||||||
* [If stream_range (bit 0):]
|
* [If stream_range (bit 0):]
|
||||||
* Next 1024 bytes: Range profile, 512 × 16-bit magnitude, MSB first
|
* Next 1024 bytes: Range profile, 512 × 16-bit magnitude, MSB first
|
||||||
*
|
*
|
||||||
* [If stream_doppler (bit 1) AND mag_only (bit 3):]
|
* [If stream_doppler (bit 1):]
|
||||||
* Next 32768 bytes: Doppler magnitude, 512×32 × 16-bit, row-major, MSB first
|
* Next 32768 bytes: Doppler magnitude, 512×32 × 16-bit, row-major, MSB first
|
||||||
*
|
*
|
||||||
* [If stream_doppler (bit 1) AND NOT mag_only:]
|
* [If stream_cfar (bit 2):]
|
||||||
* Next 65536 bytes: Doppler I/Q, 512×32 × 32-bit (I16,Q16), row-major, MSB first
|
|
||||||
*
|
|
||||||
* [If stream_cfar (bit 2) AND NOT sparse_det (bit 4):]
|
|
||||||
* Next 2048 bytes: Detection flags, 512×32 bits packed into bytes, MSB-first bit order
|
* Next 2048 bytes: Detection flags, 512×32 bits packed into bytes, MSB-first bit order
|
||||||
*
|
*
|
||||||
* [If stream_cfar (bit 2) AND sparse_det (bit 4):]
|
|
||||||
* Next 2 bytes: Detection count N (16-bit, MSB first)
|
|
||||||
* Next N×6 bytes: Each = {range_bin[16], doppler_bin[16], magnitude[16]}, MSB first
|
|
||||||
*
|
|
||||||
* Last byte: 0x55 (frame end footer)
|
* Last byte: 0x55 (frame end footer)
|
||||||
*
|
*
|
||||||
|
* INERT FLAGS — mag_only (bit 3) and sparse_det (bit 4) (AUDIT-C9):
|
||||||
|
* The wire format byte 1 reserves these two bits for future encodings:
|
||||||
|
* - mag_only=0 was meant to switch the doppler section to 65536 B
|
||||||
|
* full-I/Q (16-bit I + 16-bit Q per cell, row-major, MSB first).
|
||||||
|
* - sparse_det=1 was meant to switch the CFAR section to a
|
||||||
|
* variable-length list: 2 B count N + N×6 B (range, doppler, mag).
|
||||||
|
* Neither encoding is implemented in the write FSM below — the FSM
|
||||||
|
* always emits 32768 B mag and 2048 B dense bitmap regardless of the
|
||||||
|
* flag bits. To eliminate the foot-gun, `radar_system_top.v` opcode
|
||||||
|
* 0x04 force-clamps mag_only=1 and sparse_det=0 in `host_stream_control`
|
||||||
|
* when USB_MODE=1. A SIMULATION-only assertion at the bottom of this
|
||||||
|
* module fires if either bit ever leaves its clamped value, in case a
|
||||||
|
* future patch adds a path that bypasses the host register clamp.
|
||||||
|
*
|
||||||
|
* Reasons differ between the two:
|
||||||
|
* - Full-I/Q is constrained by FPGA resources: it needs a new
|
||||||
|
* ~28-BRAM18 I/Q buffer (16384 cells × 32-bit) which may not fit
|
||||||
|
* on the 50T (currently ~78% BRAM18 utilisation after wiring the
|
||||||
|
* Xilinx FFT IP). USB 2.0 bandwidth is also tight: 12.21 MB/s vs
|
||||||
|
* the conservative 8 MB/s sustained budget. Both gating items.
|
||||||
|
* - Sparse-list is feasible — bandwidth-wise it's smaller than the
|
||||||
|
* dense bitmap for any frame with fewer than ~341 detections
|
||||||
|
* (typical scenes produce 10-200), and memory-wise it costs
|
||||||
|
* ~1 BRAM18 with MAX_DETECTIONS=256. The absence is just
|
||||||
|
* unimplemented RTL work (a small detection-list BRAM + a new
|
||||||
|
* WR_DETECT_SPARSE FSM state), not a hardware constraint.
|
||||||
|
* See the open-defects ledger for the follow-up work items.
|
||||||
|
*
|
||||||
* Status packet (FPGA→Host): 26 bytes (unchanged from legacy)
|
* Status packet (FPGA→Host): 26 bytes (unchanged from legacy)
|
||||||
* Byte 0: 0xBB (status header)
|
* Byte 0: 0xBB (status header)
|
||||||
* Bytes 1-24: 6 × 32-bit status words, MSB first
|
* Bytes 1-24: 6 × 32-bit status words, MSB first
|
||||||
@@ -54,13 +75,13 @@
|
|||||||
* Written in clk domain from range_valid events.
|
* Written in clk domain from range_valid events.
|
||||||
* - Detection flag buffer: 512×32 = 16384 bits = 2048 bytes (~1 BRAM18)
|
* - Detection flag buffer: 512×32 = 16384 bits = 2048 bytes (~1 BRAM18)
|
||||||
* Written in clk domain from cfar_valid events.
|
* Written in clk domain from cfar_valid events.
|
||||||
* - Doppler I/Q BRAM: 16384 × 32-bit = 64 KB (~28 BRAM18) — only when mag_only=0
|
|
||||||
* NOTE: For 50T (75 BRAM18 total), I/Q mode may not fit alongside processing
|
|
||||||
* chain BRAMs. Default to mag_only=1. I/Q mode is a stretch goal.
|
|
||||||
*
|
*
|
||||||
* BANDWIDTH BUDGET (mag_only=1, all streams):
|
* BANDWIDTH BUDGET (current production: mag_only=1, all streams):
|
||||||
* Header: 8 B + Range: 1024 B + Doppler: 32768 B + CFAR: 2048 B + Footer: 1 B
|
* Header: 8 B + Range: 1024 B + Doppler: 32768 B + CFAR: 2048 B + Footer: 1 B
|
||||||
* = 35,849 bytes/frame × 178 fps = 6.38 MB/s (80% of USB 2.0 Hi-Speed 8 MB/s)
|
* = 35,849 bytes/frame × ~178 fps = 6.38 MB/s
|
||||||
|
* FT2232H 245-Sync-FIFO sustained budget ~8 MB/s conservative (FTDI
|
||||||
|
* AN_232B-04). 80% utilisation; full-I/Q (12.21 MB/s) would not fit at
|
||||||
|
* the conservative budget and is why mag_only is force-clamped to 1.
|
||||||
*
|
*
|
||||||
* CDC STRATEGY:
|
* CDC STRATEGY:
|
||||||
* - Frame data: Written to dual-port BRAM at 100 MHz, read at 60 MHz (inherently CDC-safe)
|
* - Frame data: Written to dual-port BRAM at 100 MHz, read at 60 MHz (inherently CDC-safe)
|
||||||
@@ -1025,4 +1046,28 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
|
|||||||
end
|
end
|
||||||
`endif
|
`endif
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AUDIT-C9: inert-flag checker (simulation only)
|
||||||
|
//
|
||||||
|
// stream_mag_only and stream_sparse_det are documented in the wire format
|
||||||
|
// but the write FSM does not act on them — see the "INERT FLAGS" note in
|
||||||
|
// the module header. radar_system_top.v opcode 0x04 force-clamps these
|
||||||
|
// bits when USB_MODE=1 so production firmware cannot reach an unsupported
|
||||||
|
// state. This checker is the backstop: it fires `[ASSERT FAIL]` if either
|
||||||
|
// bit ever escapes its clamped value, catching any future patch that
|
||||||
|
// bypasses the host register clamp (e.g. a different opcode that writes
|
||||||
|
// stream_control directly, or a stream_control source other than the
|
||||||
|
// host). Synthesis-inert.
|
||||||
|
// ============================================================================
|
||||||
|
`ifdef SIMULATION
|
||||||
|
always @(posedge clk) begin
|
||||||
|
if (reset_n) begin
|
||||||
|
if (stream_mag_only !== 1'b1)
|
||||||
|
$display("[ASSERT FAIL] AUDIT-C9: stream_mag_only=0; full-I/Q write FSM not implemented");
|
||||||
|
if (stream_sparse_det !== 1'b0)
|
||||||
|
$display("[ASSERT FAIL] AUDIT-C9: stream_sparse_det=1; sparse-list write FSM not implemented");
|
||||||
|
end
|
||||||
|
end
|
||||||
|
`endif
|
||||||
|
|
||||||
endmodule
|
endmodule
|
||||||
|
|||||||
@@ -5,15 +5,39 @@ AERIS-10 Radar Protocol Layer
|
|||||||
Pure-logic module for USB packet parsing and command building.
|
Pure-logic module for USB packet parsing and command building.
|
||||||
No GUI dependencies — safe to import from tests and headless scripts.
|
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 transports + wire formats (these intentionally diverge):
|
||||||
FT601 USB 3.0 (32-bit, 200T premium board) via ftd3xx
|
|
||||||
|
|
||||||
USB Packet Protocol (11-byte):
|
FT2232H USB 2.0 (50T production board, USB_MODE=1, default)
|
||||||
TX (FPGA→Host):
|
Bulk per-frame format from `usb_data_interface_ft2232h.v`. One header
|
||||||
Data packet: [0xAA] [range_q 2B] [range_i 2B] [dop_re 2B] [dop_im 2B] [det 1B] [0x55]
|
+ variable-length sections + footer per Doppler frame. The bulk format
|
||||||
Status packet: [0xBB] [status 6x32b] [0x55]
|
exists because USB 2.0's ~8 MB/s sustained ceiling cannot carry the
|
||||||
RX (Host→FPGA):
|
production frame rate (~178 fps x 35849 B = 6.4 MB/s) at per-sample
|
||||||
Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo}
|
granularity. Wire layout:
|
||||||
|
[0xAA][flags 1B][frame_num 2B][n_range 2B][n_doppler 2B]
|
||||||
|
[range_profile 1024 B if flags.stream_range]
|
||||||
|
[doppler_mag 32768 B if flags.stream_doppler] # mag_only=1 only
|
||||||
|
[cfar_dense 2048 B if flags.stream_cfar] # sparse_det=0 only
|
||||||
|
[0x55]
|
||||||
|
Production FPGA today only emits mag_only=1 + dense-bitmap CFAR; the
|
||||||
|
flag bits for full-I/Q (mag_only=0) and sparse-detection-list
|
||||||
|
(sparse_det=1) are reserved for a future RTL extension and currently
|
||||||
|
force-clamped to 1 and 0 respectively in `radar_system_top.v` opcode
|
||||||
|
0x04 handler when USB_MODE=1.
|
||||||
|
|
||||||
|
FT601 USB 3.0 (200T premium board, USB_MODE=0)
|
||||||
|
Per-sample 11-byte legacy format from `usb_data_interface.v`. USB 3.0
|
||||||
|
has ~50x the bandwidth headroom (~360 MB/s practical), so the lighter
|
||||||
|
per-sample format is fine and offers easier resync after byte drops.
|
||||||
|
Wire layout (per sample, 16384 samples per frame):
|
||||||
|
[0xAA][range_q 2B][range_i 2B][dop_re 2B][dop_im 2B][det 1B][0x55]
|
||||||
|
where det byte = {frame_start, 6'b0, cfar_detection}.
|
||||||
|
Status (both transports): [0xBB][6x32b status words][0x55] = 26 B.
|
||||||
|
|
||||||
|
RX (Host → FPGA, both transports)
|
||||||
|
4 bytes per command: {opcode[7:0], addr[7:0], value[15:8], value[7:0]}.
|
||||||
|
|
||||||
|
The GUI parser handles both formats; `RadarAcquisition` dispatches on
|
||||||
|
connection type (FT2232HConnection → bulk; FT601Connection → legacy).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import struct
|
import struct
|
||||||
@@ -49,6 +73,25 @@ NUM_CELLS = NUM_RANGE_BINS * NUM_DOPPLER_BINS # 16384
|
|||||||
|
|
||||||
WATERFALL_DEPTH = 64
|
WATERFALL_DEPTH = 64
|
||||||
|
|
||||||
|
# AUDIT-C9: FT2232H bulk-frame wire format constants. Mirrors
|
||||||
|
# usb_data_interface_ft2232h.v; if the RTL header changes, update both sides.
|
||||||
|
BULK_FRAME_HEADER_SIZE = 8 # AA + flags + fnum2 + nr2 + nd2
|
||||||
|
BULK_RANGE_SECTION_BYTES = NUM_RANGE_BINS * 2 # 512 x 2 = 1024
|
||||||
|
BULK_DOPPLER_MAG_BYTES = NUM_CELLS * 2 # 16384 x 2 = 32768
|
||||||
|
BULK_DETECT_DENSE_BYTES = NUM_CELLS // 8 # 16384 / 8 = 2048
|
||||||
|
BULK_FOOTER_SIZE = 1
|
||||||
|
BULK_FRAME_MIN_SIZE = BULK_FRAME_HEADER_SIZE + BULK_FOOTER_SIZE # 9
|
||||||
|
BULK_FRAME_MAX_SIZE = (BULK_FRAME_HEADER_SIZE + BULK_RANGE_SECTION_BYTES
|
||||||
|
+ BULK_DOPPLER_MAG_BYTES + BULK_DETECT_DENSE_BYTES
|
||||||
|
+ BULK_FOOTER_SIZE) # 35849
|
||||||
|
|
||||||
|
# Bulk-frame format flag bits (matches stream_ctrl_sync_1 layout in RTL).
|
||||||
|
BULK_FLAG_STREAM_RANGE = 0x01
|
||||||
|
BULK_FLAG_STREAM_DOPPLER = 0x02
|
||||||
|
BULK_FLAG_STREAM_CFAR = 0x04
|
||||||
|
BULK_FLAG_MAG_ONLY = 0x08 # Forced 1 by RTL; full-I/Q write FSM not implemented.
|
||||||
|
BULK_FLAG_SPARSE_DET = 0x10 # Forced 0 by RTL; sparse-list write FSM not implemented.
|
||||||
|
|
||||||
|
|
||||||
class Opcode(IntEnum):
|
class Opcode(IntEnum):
|
||||||
"""Host register opcodes — must match radar_system_top.v case(usb_cmd_opcode).
|
"""Host register opcodes — must match radar_system_top.v case(usb_cmd_opcode).
|
||||||
@@ -134,6 +177,10 @@ class RadarFrame:
|
|||||||
default_factory=lambda: np.zeros(NUM_RANGE_BINS, dtype=np.float64))
|
default_factory=lambda: np.zeros(NUM_RANGE_BINS, dtype=np.float64))
|
||||||
detection_count: int = 0
|
detection_count: int = 0
|
||||||
frame_number: int = 0
|
frame_number: int = 0
|
||||||
|
# AUDIT-C9: True when this frame came from FT2232H bulk format with
|
||||||
|
# mag_only=1 (the only mode FPGA emits today). I/Q arrays will be zero;
|
||||||
|
# `magnitude` carries the per-cell Manhattan magnitude from the FPGA.
|
||||||
|
mag_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -316,6 +363,164 @@ class RadarProtocol:
|
|||||||
i += 1
|
i += 1
|
||||||
return packets
|
return packets
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# AUDIT-C9: FT2232H bulk-frame parsing (production board path)
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
@staticmethod
|
||||||
|
def _bulk_frame_size_from_flags(flags: int) -> int:
|
||||||
|
"""Compute the on-wire size of a bulk frame from its flags byte.
|
||||||
|
|
||||||
|
Tracks the FPGA write FSM in usb_data_interface_ft2232h.v: header
|
||||||
|
(8 B) + per-stream payload + footer (1 B). The mag_only and
|
||||||
|
sparse_det bits are documented in the wire format but the FPGA
|
||||||
|
write FSM does not implement the alternate encodings yet — it
|
||||||
|
always emits 32768 B mag and 2048 B dense bitmap. The host-side
|
||||||
|
register handler in radar_system_top.v force-clamps these flags
|
||||||
|
when USB_MODE=1, so any frame the parser sees in production will
|
||||||
|
have mag_only=1 and sparse_det=0.
|
||||||
|
"""
|
||||||
|
size = BULK_FRAME_HEADER_SIZE
|
||||||
|
if flags & BULK_FLAG_STREAM_RANGE:
|
||||||
|
size += BULK_RANGE_SECTION_BYTES
|
||||||
|
if flags & BULK_FLAG_STREAM_DOPPLER:
|
||||||
|
size += BULK_DOPPLER_MAG_BYTES
|
||||||
|
if flags & BULK_FLAG_STREAM_CFAR:
|
||||||
|
size += BULK_DETECT_DENSE_BYTES
|
||||||
|
size += BULK_FOOTER_SIZE
|
||||||
|
return size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_bulk_frame(raw: bytes, offset: int = 0) -> dict[str, Any] | None:
|
||||||
|
"""Parse one FT2232H bulk frame starting at `offset`.
|
||||||
|
|
||||||
|
Returns a dict with keys: frame_number, flags, n_range, n_doppler,
|
||||||
|
range_profile (np.ndarray | None), doppler_mag (np.ndarray | None,
|
||||||
|
shape n_rangexn_doppler), cfar_dense (np.ndarray | None, shape
|
||||||
|
n_rangexn_doppler, uint8 0/1), and frame_size (total bytes consumed
|
||||||
|
including header + sections + footer). Returns None on any
|
||||||
|
structural error (bad header/footer, wrong bin counts, reserved
|
||||||
|
bits set, unimplemented flag combo).
|
||||||
|
"""
|
||||||
|
n = len(raw)
|
||||||
|
if n - offset < BULK_FRAME_MIN_SIZE:
|
||||||
|
return None
|
||||||
|
if raw[offset] != HEADER_BYTE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
flags = raw[offset + 1]
|
||||||
|
# Reserved high bits must be zero (FPGA emits {2'b00, 6-bit flags}).
|
||||||
|
if flags & 0xC0:
|
||||||
|
return None
|
||||||
|
# Production FPGA only emits mag_only=1 + dense bitmap. Any other
|
||||||
|
# encoding is either a corrupt frame or a future RTL revision the
|
||||||
|
# parser hasn't been updated for; reject so the caller can resync.
|
||||||
|
if (flags & BULK_FLAG_STREAM_DOPPLER) and not (flags & BULK_FLAG_MAG_ONLY):
|
||||||
|
return None
|
||||||
|
if (flags & BULK_FLAG_STREAM_CFAR) and (flags & BULK_FLAG_SPARSE_DET):
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame_number = (raw[offset + 2] << 8) | raw[offset + 3]
|
||||||
|
n_range = (raw[offset + 4] << 8) | raw[offset + 5]
|
||||||
|
n_doppler = (raw[offset + 6] << 8) | raw[offset + 7]
|
||||||
|
if n_range != NUM_RANGE_BINS or n_doppler != NUM_DOPPLER_BINS:
|
||||||
|
return None
|
||||||
|
|
||||||
|
size = RadarProtocol._bulk_frame_size_from_flags(flags)
|
||||||
|
if n - offset < size:
|
||||||
|
return None
|
||||||
|
if raw[offset + size - 1] != FOOTER_BYTE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cursor = offset + BULK_FRAME_HEADER_SIZE
|
||||||
|
range_profile = None
|
||||||
|
doppler_mag = None
|
||||||
|
cfar_dense = None
|
||||||
|
|
||||||
|
if flags & BULK_FLAG_STREAM_RANGE:
|
||||||
|
range_profile = np.frombuffer(
|
||||||
|
raw, dtype=">u2", count=n_range, offset=cursor,
|
||||||
|
).astype(np.uint16, copy=True)
|
||||||
|
cursor += BULK_RANGE_SECTION_BYTES
|
||||||
|
|
||||||
|
if flags & BULK_FLAG_STREAM_DOPPLER:
|
||||||
|
doppler_mag = np.frombuffer(
|
||||||
|
raw, dtype=">u2", count=n_range * n_doppler, offset=cursor,
|
||||||
|
).astype(np.uint16, copy=True).reshape(n_range, n_doppler)
|
||||||
|
cursor += BULK_DOPPLER_MAG_BYTES
|
||||||
|
|
||||||
|
if flags & BULK_FLAG_STREAM_CFAR:
|
||||||
|
packed = np.frombuffer(
|
||||||
|
raw, dtype=np.uint8, count=BULK_DETECT_DENSE_BYTES, offset=cursor,
|
||||||
|
)
|
||||||
|
# Each byte holds 8 cells, MSB-first bit order (matches FPGA
|
||||||
|
# WR_DETECT_DATA emission). np.unpackbits keeps that order.
|
||||||
|
cfar_dense = np.unpackbits(packed).reshape(n_range, n_doppler)
|
||||||
|
cursor += BULK_DETECT_DENSE_BYTES
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_number": frame_number,
|
||||||
|
"flags": flags,
|
||||||
|
"n_range": n_range,
|
||||||
|
"n_doppler": n_doppler,
|
||||||
|
"range_profile": range_profile,
|
||||||
|
"doppler_mag": doppler_mag,
|
||||||
|
"cfar_dense": cfar_dense,
|
||||||
|
"frame_size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def find_bulk_frame_boundaries(buf: bytes) -> list[tuple[int, int, str]]:
|
||||||
|
"""Scan a byte stream for FT2232H bulk frames and status packets.
|
||||||
|
|
||||||
|
Status packets (0xBB header, 26 B) are unchanged between transports
|
||||||
|
— the WR_STATUS_SEND state in usb_data_interface_ft2232h.v emits the
|
||||||
|
same layout as the legacy FT601 path. Bulk data frames (0xAA header)
|
||||||
|
are variable length per `_bulk_frame_size_from_flags`.
|
||||||
|
|
||||||
|
Returns a list of (start, end, ptype) tuples like
|
||||||
|
find_packet_boundaries, where ptype is "data" or "status". On a
|
||||||
|
false header (any structural mismatch) the cursor advances by 1 and
|
||||||
|
keeps scanning, mirroring the legacy parser's resync semantics.
|
||||||
|
"""
|
||||||
|
out: list[tuple[int, int, str]] = []
|
||||||
|
i = 0
|
||||||
|
n = len(buf)
|
||||||
|
while i < n:
|
||||||
|
b = buf[i]
|
||||||
|
if b == HEADER_BYTE:
|
||||||
|
# Need at least the 8-byte header to compute the frame size.
|
||||||
|
if n - i < BULK_FRAME_HEADER_SIZE:
|
||||||
|
break # partial header — caller keeps as residual
|
||||||
|
flags = buf[i + 1]
|
||||||
|
# Quick reject before the more expensive size compute. The
|
||||||
|
# full validation lives in parse_bulk_frame.
|
||||||
|
if flags & 0xC0:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
size = RadarProtocol._bulk_frame_size_from_flags(flags)
|
||||||
|
if n - i < size:
|
||||||
|
break # partial frame — keep as residual
|
||||||
|
# Validate footer + bin counts before accepting the boundary.
|
||||||
|
if (buf[i + size - 1] == FOOTER_BYTE
|
||||||
|
and ((buf[i + 4] << 8) | buf[i + 5]) == NUM_RANGE_BINS
|
||||||
|
and ((buf[i + 6] << 8) | buf[i + 7]) == NUM_DOPPLER_BINS):
|
||||||
|
out.append((i, i + size, "data"))
|
||||||
|
i += size
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
elif b == STATUS_HEADER_BYTE:
|
||||||
|
end = i + STATUS_PACKET_SIZE
|
||||||
|
if end > n:
|
||||||
|
break
|
||||||
|
if buf[end - 1] == FOOTER_BYTE and buf[i + 1] == 0xFF:
|
||||||
|
out.append((i, end, "status"))
|
||||||
|
i = end
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FT2232H USB 2.0 Connection (pyftdi, 245 Synchronous FIFO)
|
# FT2232H USB 2.0 Connection (pyftdi, 245 Synchronous FIFO)
|
||||||
@@ -421,55 +626,57 @@ class FT2232HConnection:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def _mock_read(self, size: int) -> bytes:
|
def _mock_read(self, size: int) -> bytes:
|
||||||
"""
|
"""Generate one synthetic FT2232H bulk frame per call.
|
||||||
Generate synthetic 11-byte radar data packets for testing.
|
|
||||||
Emits packets in sequential FPGA order (range_bin 0..63, doppler_bin
|
Mirrors `usb_data_interface_ft2232h.v` production behavior: mag-only
|
||||||
0..31 within each range bin) so that RadarAcquisition._ingest_sample()
|
Doppler section + dense-bitmap CFAR, all three streams enabled
|
||||||
places them correctly. A target is injected near range bin 20,
|
(matches `RP_STREAM_CTRL_DEFAULT = 6'b001_111`). A target is injected
|
||||||
Doppler bin 8.
|
near range bin 20, Doppler bin 8 so dashboards have something to draw.
|
||||||
"""
|
"""
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
self._mock_frame_num += 1
|
self._mock_frame_num += 1
|
||||||
|
flags = (BULK_FLAG_STREAM_RANGE | BULK_FLAG_STREAM_DOPPLER
|
||||||
|
| BULK_FLAG_STREAM_CFAR | BULK_FLAG_MAG_ONLY)
|
||||||
|
|
||||||
buf = bytearray()
|
# Synthesize per-cell magnitudes once (vectorised).
|
||||||
num_packets = min(NUM_CELLS, size // DATA_PACKET_SIZE)
|
rbins = np.arange(NUM_RANGE_BINS).reshape(-1, 1)
|
||||||
start_idx = getattr(self, '_mock_seq_idx', 0)
|
dbins = np.arange(NUM_DOPPLER_BINS).reshape(1, -1)
|
||||||
|
noise = np.abs(self._mock_rng.normal(0, 50, size=(NUM_RANGE_BINS, NUM_DOPPLER_BINS)))
|
||||||
|
target_mask = (np.abs(rbins - 20) < 3) & (np.abs(dbins - 8) < 2)
|
||||||
|
mag = noise + target_mask * 12000.0
|
||||||
|
mag_u16 = np.clip(mag, 0, 65535).astype(np.uint16)
|
||||||
|
|
||||||
for n in range(num_packets):
|
range_profile = np.clip(
|
||||||
idx = (start_idx + n) % NUM_CELLS
|
np.abs(self._mock_rng.normal(0, 100, size=NUM_RANGE_BINS))
|
||||||
rbin = idx // NUM_DOPPLER_BINS
|
+ (np.abs(rbins.flatten() - 20) < 3) * 8000,
|
||||||
dbin = idx % NUM_DOPPLER_BINS
|
0, 65535,
|
||||||
|
).astype(np.uint16)
|
||||||
|
|
||||||
range_i = int(self._mock_rng.normal(0, 100))
|
det = (target_mask & (np.abs(dbins - 8) < 2) & (np.abs(rbins - 20) < 2)).astype(np.uint8)
|
||||||
range_q = int(self._mock_rng.normal(0, 100))
|
det_packed = np.packbits(det.flatten()) # MSB-first bit order matches FPGA
|
||||||
if abs(rbin - 20) < 3:
|
|
||||||
range_i += 5000
|
|
||||||
range_q += 3000
|
|
||||||
|
|
||||||
dop_i = int(self._mock_rng.normal(0, 50))
|
buf = bytearray(BULK_FRAME_MAX_SIZE)
|
||||||
dop_q = int(self._mock_rng.normal(0, 50))
|
buf[0] = HEADER_BYTE
|
||||||
if abs(rbin - 20) < 3 and abs(dbin - 8) < 2:
|
buf[1] = flags & 0x3F # reserved high bits zero, matches RTL
|
||||||
dop_i += 8000
|
buf[2] = (self._mock_frame_num >> 8) & 0xFF
|
||||||
dop_q += 4000
|
buf[3] = self._mock_frame_num & 0xFF
|
||||||
|
buf[4] = (NUM_RANGE_BINS >> 8) & 0xFF
|
||||||
|
buf[5] = NUM_RANGE_BINS & 0xFF
|
||||||
|
buf[6] = (NUM_DOPPLER_BINS >> 8) & 0xFF
|
||||||
|
buf[7] = NUM_DOPPLER_BINS & 0xFF
|
||||||
|
cursor = BULK_FRAME_HEADER_SIZE
|
||||||
|
# Range profile (>u2 = big-endian uint16, matches FPGA MSB-first).
|
||||||
|
buf[cursor:cursor + BULK_RANGE_SECTION_BYTES] = range_profile.astype(">u2").tobytes()
|
||||||
|
cursor += BULK_RANGE_SECTION_BYTES
|
||||||
|
buf[cursor:cursor + BULK_DOPPLER_MAG_BYTES] = mag_u16.astype(">u2").tobytes()
|
||||||
|
cursor += BULK_DOPPLER_MAG_BYTES
|
||||||
|
buf[cursor:cursor + BULK_DETECT_DENSE_BYTES] = det_packed.tobytes()
|
||||||
|
cursor += BULK_DETECT_DENSE_BYTES
|
||||||
|
buf[cursor] = FOOTER_BYTE
|
||||||
|
|
||||||
detection = 1 if (abs(rbin - 20) < 2 and abs(dbin - 8) < 2) else 0
|
# `size` is the host's read budget; emit at most one frame per call
|
||||||
|
# (matches typical FT2232H driver semantics).
|
||||||
# Build compact 11-byte packet
|
return bytes(buf[:min(size, BULK_FRAME_MAX_SIZE)])
|
||||||
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))
|
|
||||||
# Bit 7 = frame_start (sample_counter == 0), bit 0 = detection
|
|
||||||
det_byte = (detection & 0x01) | (0x80 if idx == 0 else 0x00)
|
|
||||||
pkt.append(det_byte)
|
|
||||||
pkt.append(FOOTER_BYTE)
|
|
||||||
|
|
||||||
buf += pkt
|
|
||||||
|
|
||||||
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
|
|
||||||
return bytes(buf)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -744,9 +951,15 @@ class DataRecorder:
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class RadarAcquisition(threading.Thread):
|
class RadarAcquisition(threading.Thread):
|
||||||
"""
|
"""Background thread: reads USB bytes, parses frames, queues them.
|
||||||
Background thread: reads from USB (FT2232H), parses 11-byte packets,
|
|
||||||
assembles frames, and pushes complete frames to the display queue.
|
Dispatches between two wire formats based on connection type:
|
||||||
|
- FT2232HConnection -> bulk per-frame format (parses 35 KB frames in
|
||||||
|
one shot via parse_bulk_frame; fills RadarFrame.magnitude directly).
|
||||||
|
- FT601Connection -> legacy 11-byte per-sample format (count-based
|
||||||
|
sample placement via _ingest_sample, the original behavior).
|
||||||
|
|
||||||
|
See module docstring for why both formats exist.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, connection, frame_queue: queue.Queue,
|
def __init__(self, connection, frame_queue: queue.Queue,
|
||||||
@@ -761,37 +974,51 @@ class RadarAcquisition(threading.Thread):
|
|||||||
self._frame = RadarFrame()
|
self._frame = RadarFrame()
|
||||||
self._sample_idx = 0
|
self._sample_idx = 0
|
||||||
self._frame_num = 0
|
self._frame_num = 0
|
||||||
|
# AUDIT-C9: dispatch on connection type. The bulk path skips the
|
||||||
|
# per-sample state machine entirely.
|
||||||
|
self._is_bulk = isinstance(connection, FT2232HConnection)
|
||||||
|
self._read_chunk = (2 * BULK_FRAME_MAX_SIZE) if self._is_bulk else 4096
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
self._stop_event.set()
|
self._stop_event.set()
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
log.info("Acquisition thread started")
|
log.info(
|
||||||
|
"Acquisition thread started (%s wire format)",
|
||||||
|
"FT2232H bulk" if self._is_bulk else "FT601 legacy 11-byte",
|
||||||
|
)
|
||||||
residual = b""
|
residual = b""
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
chunk = self.conn.read(4096)
|
chunk = self.conn.read(self._read_chunk)
|
||||||
if chunk is None or len(chunk) == 0:
|
if chunk is None or len(chunk) == 0:
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw = residual + chunk
|
raw = residual + chunk
|
||||||
|
if self._is_bulk:
|
||||||
|
packets = RadarProtocol.find_bulk_frame_boundaries(raw)
|
||||||
|
max_residual = BULK_FRAME_MAX_SIZE
|
||||||
|
else:
|
||||||
packets = RadarProtocol.find_packet_boundaries(raw)
|
packets = RadarProtocol.find_packet_boundaries(raw)
|
||||||
|
max_residual = 2 * max(DATA_PACKET_SIZE, STATUS_PACKET_SIZE)
|
||||||
|
|
||||||
# Keep unparsed tail bytes for next iteration
|
# Keep unparsed tail bytes for next iteration.
|
||||||
if packets:
|
if packets:
|
||||||
last_end = packets[-1][1]
|
last_end = packets[-1][1]
|
||||||
residual = raw[last_end:]
|
residual = raw[last_end:]
|
||||||
else:
|
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)
|
|
||||||
residual = raw[-max_residual:] if len(raw) > max_residual else raw
|
residual = raw[-max_residual:] if len(raw) > max_residual else raw
|
||||||
|
|
||||||
for start, end, ptype in packets:
|
for start, end, ptype in packets:
|
||||||
if ptype == "data":
|
if ptype == "data":
|
||||||
parsed = RadarProtocol.parse_data_packet(
|
if self._is_bulk:
|
||||||
raw[start:end])
|
parsed = RadarProtocol.parse_bulk_frame(raw, offset=start)
|
||||||
if parsed is not None:
|
if parsed is not None:
|
||||||
self._ingest_sample(parsed)
|
self._ingest_bulk_frame(parsed)
|
||||||
|
else:
|
||||||
|
sample = RadarProtocol.parse_data_packet(raw[start:end])
|
||||||
|
if sample is not None:
|
||||||
|
self._ingest_sample(sample)
|
||||||
elif ptype == "status":
|
elif ptype == "status":
|
||||||
status = RadarProtocol.parse_status_packet(raw[start:end])
|
status = RadarProtocol.parse_status_packet(raw[start:end])
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@@ -809,6 +1036,40 @@ class RadarAcquisition(threading.Thread):
|
|||||||
|
|
||||||
log.info("Acquisition thread stopped")
|
log.info("Acquisition thread stopped")
|
||||||
|
|
||||||
|
def _ingest_bulk_frame(self, parsed: dict):
|
||||||
|
"""Build a RadarFrame from one parsed bulk frame and emit it."""
|
||||||
|
frame = RadarFrame()
|
||||||
|
frame.timestamp = time.time()
|
||||||
|
frame.frame_number = parsed["frame_number"]
|
||||||
|
frame.mag_only = bool(parsed["flags"] & BULK_FLAG_MAG_ONLY)
|
||||||
|
|
||||||
|
rprof = parsed["range_profile"]
|
||||||
|
if rprof is not None:
|
||||||
|
# Wire is uint16; RadarFrame.range_profile is float64.
|
||||||
|
frame.range_profile[:] = rprof.astype(np.float64)
|
||||||
|
|
||||||
|
dmag = parsed["doppler_mag"]
|
||||||
|
if dmag is not None:
|
||||||
|
frame.magnitude[:] = dmag.astype(np.float64)
|
||||||
|
# I/Q arrays stay zero in mag-only mode (the only mode FPGA
|
||||||
|
# emits today). Future RTL may populate them; for now flag is
|
||||||
|
# the source of truth.
|
||||||
|
|
||||||
|
cdense = parsed["cfar_dense"]
|
||||||
|
if cdense is not None:
|
||||||
|
frame.detections[:] = cdense.astype(np.uint8)
|
||||||
|
frame.detection_count = int(cdense.sum())
|
||||||
|
|
||||||
|
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):
|
def _ingest_sample(self, sample: dict):
|
||||||
"""Place sample into current frame and emit when complete."""
|
"""Place sample into current frame and emit when complete."""
|
||||||
# [GUI-C2 FIX] Use FPGA frame_start bit as the authoritative sync token.
|
# [GUI-C2 FIX] Use FPGA frame_start bit as the authoritative sync token.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ from radar_protocol import (
|
|||||||
HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE,
|
HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE,
|
||||||
NUM_RANGE_BINS, NUM_DOPPLER_BINS,
|
NUM_RANGE_BINS, NUM_DOPPLER_BINS,
|
||||||
DATA_PACKET_SIZE,
|
DATA_PACKET_SIZE,
|
||||||
|
BULK_FRAME_HEADER_SIZE, BULK_FRAME_MAX_SIZE,
|
||||||
|
BULK_RANGE_SECTION_BYTES, BULK_FOOTER_SIZE,
|
||||||
|
BULK_FLAG_STREAM_RANGE, BULK_FLAG_STREAM_DOPPLER, BULK_FLAG_STREAM_CFAR,
|
||||||
|
BULK_FLAG_MAG_ONLY, BULK_FLAG_SPARSE_DET,
|
||||||
)
|
)
|
||||||
from GUI_V65_Tk import DemoTarget, DemoSimulator, _ReplayController
|
from GUI_V65_Tk import DemoTarget, DemoSimulator, _ReplayController
|
||||||
|
|
||||||
@@ -376,6 +380,240 @@ class TestRadarProtocol(unittest.TestCase):
|
|||||||
self.assertIsNotNone(RadarProtocol.parse_data_packet(buf[start:end]))
|
self.assertIsNotNone(RadarProtocol.parse_data_packet(buf[start:end]))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkFrameParser(unittest.TestCase):
|
||||||
|
"""AUDIT-C9: parser for the FT2232H bulk per-frame wire format."""
|
||||||
|
|
||||||
|
def _build_bulk_frame(
|
||||||
|
self,
|
||||||
|
flags: int = (BULK_FLAG_STREAM_RANGE | BULK_FLAG_STREAM_DOPPLER
|
||||||
|
| BULK_FLAG_STREAM_CFAR | BULK_FLAG_MAG_ONLY),
|
||||||
|
frame_number: int = 0xBEEF,
|
||||||
|
n_range: int = NUM_RANGE_BINS,
|
||||||
|
n_doppler: int = NUM_DOPPLER_BINS,
|
||||||
|
range_seed: int = 1,
|
||||||
|
doppler_seed: int = 2,
|
||||||
|
cfar_seed: int = 3,
|
||||||
|
bad_footer: bool = False,
|
||||||
|
) -> tuple[bytes, np.ndarray, np.ndarray, np.ndarray]:
|
||||||
|
"""Synthesize a bulk frame matching usb_data_interface_ft2232h.v.
|
||||||
|
|
||||||
|
Returns (frame_bytes, range_profile, doppler_mag, cfar_dense). The
|
||||||
|
latter three are the source-of-truth arrays used to generate the
|
||||||
|
bytes; tests can assert round-trip equality.
|
||||||
|
"""
|
||||||
|
rng_r = np.random.RandomState(range_seed)
|
||||||
|
rng_d = np.random.RandomState(doppler_seed)
|
||||||
|
rng_c = np.random.RandomState(cfar_seed)
|
||||||
|
|
||||||
|
range_profile = (rng_r.randint(0, 65535, size=n_range)
|
||||||
|
.astype(np.uint16) if (flags & BULK_FLAG_STREAM_RANGE)
|
||||||
|
else None)
|
||||||
|
doppler_mag = (rng_d.randint(0, 65535, size=(n_range, n_doppler))
|
||||||
|
.astype(np.uint16) if (flags & BULK_FLAG_STREAM_DOPPLER)
|
||||||
|
else None)
|
||||||
|
cfar_dense = (rng_c.randint(0, 2, size=(n_range, n_doppler))
|
||||||
|
.astype(np.uint8) if (flags & BULK_FLAG_STREAM_CFAR)
|
||||||
|
else None)
|
||||||
|
|
||||||
|
out = bytearray()
|
||||||
|
out.append(HEADER_BYTE)
|
||||||
|
# Don't mask reserved bits here — the parser must reject any byte
|
||||||
|
# with bits [7:6] set, and the rejection test relies on those bits
|
||||||
|
# actually surviving into the synthesized frame.
|
||||||
|
out.append(flags & 0xFF)
|
||||||
|
out.append((frame_number >> 8) & 0xFF)
|
||||||
|
out.append(frame_number & 0xFF)
|
||||||
|
out.append((n_range >> 8) & 0xFF)
|
||||||
|
out.append(n_range & 0xFF)
|
||||||
|
out.append((n_doppler >> 8) & 0xFF)
|
||||||
|
out.append(n_doppler & 0xFF)
|
||||||
|
if range_profile is not None:
|
||||||
|
out += range_profile.astype(">u2").tobytes()
|
||||||
|
if doppler_mag is not None:
|
||||||
|
out += doppler_mag.astype(">u2").tobytes()
|
||||||
|
if cfar_dense is not None:
|
||||||
|
out += np.packbits(cfar_dense.flatten()).tobytes()
|
||||||
|
out.append(0x00 if bad_footer else FOOTER_BYTE)
|
||||||
|
return bytes(out), range_profile, doppler_mag, cfar_dense
|
||||||
|
|
||||||
|
def test_parse_full_frame_round_trip(self):
|
||||||
|
"""All-streams mag-only round trip: every cell exact."""
|
||||||
|
raw, rprof, dmag, cdense = self._build_bulk_frame()
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(raw)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["frame_number"], 0xBEEF)
|
||||||
|
self.assertEqual(parsed["n_range"], NUM_RANGE_BINS)
|
||||||
|
self.assertEqual(parsed["n_doppler"], NUM_DOPPLER_BINS)
|
||||||
|
self.assertEqual(parsed["frame_size"], BULK_FRAME_MAX_SIZE)
|
||||||
|
np.testing.assert_array_equal(parsed["range_profile"], rprof)
|
||||||
|
np.testing.assert_array_equal(parsed["doppler_mag"], dmag)
|
||||||
|
np.testing.assert_array_equal(parsed["cfar_dense"], cdense)
|
||||||
|
|
||||||
|
def test_parse_range_only(self):
|
||||||
|
flags = BULK_FLAG_STREAM_RANGE | BULK_FLAG_MAG_ONLY
|
||||||
|
raw, rprof, _dmag, _cdense = self._build_bulk_frame(flags=flags)
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(raw)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
np.testing.assert_array_equal(parsed["range_profile"], rprof)
|
||||||
|
self.assertIsNone(parsed["doppler_mag"])
|
||||||
|
self.assertIsNone(parsed["cfar_dense"])
|
||||||
|
self.assertEqual(
|
||||||
|
parsed["frame_size"],
|
||||||
|
BULK_FRAME_HEADER_SIZE + BULK_RANGE_SECTION_BYTES + BULK_FOOTER_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_parse_doppler_only(self):
|
||||||
|
flags = BULK_FLAG_STREAM_DOPPLER | BULK_FLAG_MAG_ONLY
|
||||||
|
raw, _rprof, dmag, _cdense = self._build_bulk_frame(flags=flags)
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(raw)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertIsNone(parsed["range_profile"])
|
||||||
|
np.testing.assert_array_equal(parsed["doppler_mag"], dmag)
|
||||||
|
self.assertIsNone(parsed["cfar_dense"])
|
||||||
|
|
||||||
|
def test_parse_cfar_only(self):
|
||||||
|
flags = BULK_FLAG_STREAM_CFAR | BULK_FLAG_MAG_ONLY
|
||||||
|
raw, _rprof, _dmag, cdense = self._build_bulk_frame(flags=flags)
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(raw)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
np.testing.assert_array_equal(parsed["cfar_dense"], cdense)
|
||||||
|
|
||||||
|
def test_parse_no_streams(self):
|
||||||
|
"""Header + footer only (8 + 1 = 9 bytes)."""
|
||||||
|
flags = BULK_FLAG_MAG_ONLY # streams off, mag_only still set
|
||||||
|
raw, *_ = self._build_bulk_frame(flags=flags)
|
||||||
|
self.assertEqual(len(raw), 9)
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(raw)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["frame_size"], 9)
|
||||||
|
|
||||||
|
def test_reject_full_iq_until_rtl_lands(self):
|
||||||
|
"""mag_only=0 must be rejected — FPGA write FSM doesn't emit it."""
|
||||||
|
flags = BULK_FLAG_STREAM_DOPPLER # NB: mag_only bit cleared
|
||||||
|
raw, *_ = self._build_bulk_frame(flags=flags)
|
||||||
|
# Frame is structurally consistent; rejection comes from the
|
||||||
|
# mag_only=0 + stream_doppler=1 combination check in parse_bulk_frame.
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw))
|
||||||
|
|
||||||
|
def test_reject_sparse_det(self):
|
||||||
|
"""sparse_det=1 must be rejected — FPGA emits dense bitmap only."""
|
||||||
|
flags = (BULK_FLAG_STREAM_CFAR | BULK_FLAG_MAG_ONLY | BULK_FLAG_SPARSE_DET)
|
||||||
|
raw, *_ = self._build_bulk_frame(flags=flags)
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw))
|
||||||
|
|
||||||
|
def test_reject_reserved_bits_set(self):
|
||||||
|
"""Reserved high bits in flags byte must be zero."""
|
||||||
|
raw, *_ = self._build_bulk_frame(flags=0x80 | BULK_FLAG_MAG_ONLY)
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw))
|
||||||
|
|
||||||
|
def test_reject_wrong_n_range(self):
|
||||||
|
raw, *_ = self._build_bulk_frame(n_range=999)
|
||||||
|
# Note: the synthesized payload size is wrong too but the n_range
|
||||||
|
# check fires before any payload-size checks.
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw))
|
||||||
|
|
||||||
|
def test_reject_wrong_n_doppler(self):
|
||||||
|
raw, *_ = self._build_bulk_frame(n_doppler=64)
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw))
|
||||||
|
|
||||||
|
def test_reject_missing_footer(self):
|
||||||
|
raw, *_ = self._build_bulk_frame(bad_footer=True)
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw))
|
||||||
|
|
||||||
|
def test_reject_wrong_header(self):
|
||||||
|
raw, *_ = self._build_bulk_frame()
|
||||||
|
bad = b"\x00" + raw[1:]
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(bad))
|
||||||
|
|
||||||
|
def test_reject_truncated(self):
|
||||||
|
raw, *_ = self._build_bulk_frame()
|
||||||
|
self.assertIsNone(RadarProtocol.parse_bulk_frame(raw[:1000]))
|
||||||
|
|
||||||
|
def test_find_boundaries_two_frames(self):
|
||||||
|
f1, *_ = self._build_bulk_frame(frame_number=1)
|
||||||
|
f2, *_ = self._build_bulk_frame(frame_number=2)
|
||||||
|
buf = b"\x00\x12" + f1 + b"\x33" + f2 # garbage between/around frames
|
||||||
|
out = RadarProtocol.find_bulk_frame_boundaries(buf)
|
||||||
|
data = [(s, e, t) for (s, e, t) in out if t == "data"]
|
||||||
|
self.assertEqual(len(data), 2)
|
||||||
|
# Round-trip both
|
||||||
|
for s, _e, _t in data:
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(buf, offset=s)
|
||||||
|
self.assertIsNotNone(parsed)
|
||||||
|
|
||||||
|
def test_find_boundaries_with_status(self):
|
||||||
|
"""Status packets coexist with bulk frames in the same stream."""
|
||||||
|
f1, *_ = self._build_bulk_frame()
|
||||||
|
# Build a minimal valid status packet (byte 1 = 0xFF, footer = 0x55).
|
||||||
|
status = bytes([STATUS_HEADER_BYTE, 0xFF] + [0x00] * 23 + [FOOTER_BYTE])
|
||||||
|
buf = f1 + status
|
||||||
|
out = RadarProtocol.find_bulk_frame_boundaries(buf)
|
||||||
|
types = [t for _s, _e, t in out]
|
||||||
|
self.assertIn("data", types)
|
||||||
|
self.assertIn("status", types)
|
||||||
|
|
||||||
|
def test_find_boundaries_truncated_residual(self):
|
||||||
|
"""A partial frame at the buffer tail is not returned (kept as residual)."""
|
||||||
|
f1, *_ = self._build_bulk_frame()
|
||||||
|
# Cut off the last 100 bytes — find_bulk_frame_boundaries must not
|
||||||
|
# return this frame; the caller keeps the bytes for next iteration.
|
||||||
|
buf = f1[:-100]
|
||||||
|
out = RadarProtocol.find_bulk_frame_boundaries(buf)
|
||||||
|
self.assertEqual([t for _s, _e, t in out], [])
|
||||||
|
|
||||||
|
def test_resync_after_byte_drop(self):
|
||||||
|
"""A dropped byte at the head must not lock the parser onto false positives."""
|
||||||
|
f1, *_ = self._build_bulk_frame()
|
||||||
|
# Single garbage byte before the real frame.
|
||||||
|
buf = b"\x99" + f1
|
||||||
|
out = RadarProtocol.find_bulk_frame_boundaries(buf)
|
||||||
|
data = [(s, e, t) for (s, e, t) in out if t == "data"]
|
||||||
|
self.assertEqual(len(data), 1)
|
||||||
|
self.assertEqual(data[0][0], 1) # frame starts at offset 1
|
||||||
|
|
||||||
|
def test_acquisition_dispatches_bulk_for_ft2232h(self):
|
||||||
|
"""RadarAcquisition must select bulk format for FT2232H connections."""
|
||||||
|
ft = FT2232HConnection(mock=True)
|
||||||
|
ft.open()
|
||||||
|
q: queue.Queue = queue.Queue()
|
||||||
|
acq = RadarAcquisition(ft, q)
|
||||||
|
self.assertTrue(acq._is_bulk)
|
||||||
|
ft.close()
|
||||||
|
|
||||||
|
def test_acquisition_dispatches_legacy_for_ft601(self):
|
||||||
|
"""RadarAcquisition must select legacy format for FT601 connections."""
|
||||||
|
ft = FT601Connection(mock=True)
|
||||||
|
ft.open()
|
||||||
|
q: queue.Queue = queue.Queue()
|
||||||
|
acq = RadarAcquisition(ft, q)
|
||||||
|
self.assertFalse(acq._is_bulk)
|
||||||
|
ft.close()
|
||||||
|
|
||||||
|
def test_ingest_bulk_frame_populates_radarframe(self):
|
||||||
|
"""End-to-end: bulk parse → RadarFrame in queue with correct fields."""
|
||||||
|
ft = FT2232HConnection(mock=True)
|
||||||
|
ft.open()
|
||||||
|
q: queue.Queue = queue.Queue(maxsize=4)
|
||||||
|
acq = RadarAcquisition(ft, q)
|
||||||
|
# Drive one tick of run() manually instead of starting the thread.
|
||||||
|
chunk = ft.read(BULK_FRAME_MAX_SIZE * 2)
|
||||||
|
packets = RadarProtocol.find_bulk_frame_boundaries(chunk)
|
||||||
|
self.assertGreater(len(packets), 0)
|
||||||
|
for s, _e, t in packets:
|
||||||
|
if t == "data":
|
||||||
|
parsed = RadarProtocol.parse_bulk_frame(chunk, offset=s)
|
||||||
|
acq._ingest_bulk_frame(parsed)
|
||||||
|
ft.close()
|
||||||
|
|
||||||
|
frame = q.get_nowait()
|
||||||
|
self.assertIsInstance(frame, RadarFrame)
|
||||||
|
self.assertTrue(frame.mag_only)
|
||||||
|
# Mag-only mode: I/Q stay zero, magnitude carries data.
|
||||||
|
self.assertTrue((frame.range_doppler_i == 0).all())
|
||||||
|
self.assertTrue((frame.range_doppler_q == 0).all())
|
||||||
|
self.assertGreater(frame.magnitude.max(), 0)
|
||||||
|
|
||||||
|
|
||||||
class TestFT2232HConnection(unittest.TestCase):
|
class TestFT2232HConnection(unittest.TestCase):
|
||||||
"""Test mock FT2232H connection."""
|
"""Test mock FT2232H connection."""
|
||||||
|
|
||||||
@@ -395,16 +633,20 @@ class TestFT2232HConnection(unittest.TestCase):
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_mock_read_contains_valid_packets(self):
|
def test_mock_read_contains_valid_packets(self):
|
||||||
"""Mock data should contain parseable data packets."""
|
"""Mock data should contain a parseable bulk frame (AUDIT-C9)."""
|
||||||
conn = FT2232HConnection(mock=True)
|
conn = FT2232HConnection(mock=True)
|
||||||
conn.open()
|
conn.open()
|
||||||
raw = conn.read(4096)
|
raw = conn.read(BULK_FRAME_MAX_SIZE * 2)
|
||||||
packets = RadarProtocol.find_packet_boundaries(raw)
|
packets = RadarProtocol.find_bulk_frame_boundaries(raw)
|
||||||
self.assertGreater(len(packets), 0)
|
self.assertGreater(len(packets), 0)
|
||||||
for start, end, ptype in packets:
|
for start, _end, ptype in packets:
|
||||||
if ptype == "data":
|
if ptype == "data":
|
||||||
result = RadarProtocol.parse_data_packet(raw[start:end])
|
parsed = RadarProtocol.parse_bulk_frame(raw, offset=start)
|
||||||
self.assertIsNotNone(result)
|
self.assertIsNotNone(parsed)
|
||||||
|
self.assertEqual(parsed["n_range"], NUM_RANGE_BINS)
|
||||||
|
self.assertEqual(parsed["n_doppler"], NUM_DOPPLER_BINS)
|
||||||
|
self.assertTrue(parsed["flags"] & BULK_FLAG_MAG_ONLY)
|
||||||
|
self.assertFalse(parsed["flags"] & BULK_FLAG_SPARSE_DET)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
def test_mock_write(self):
|
def test_mock_write(self):
|
||||||
@@ -578,8 +820,8 @@ class TestRadarAcquisition(unittest.TestCase):
|
|||||||
acq = RadarAcquisition(conn, fq)
|
acq = RadarAcquisition(conn, fq)
|
||||||
acq.start()
|
acq.start()
|
||||||
|
|
||||||
# Wait for at least one frame (mock produces ~32 samples per read,
|
# AUDIT-C9: FT2232H mock now emits one full bulk frame per read
|
||||||
# need 2048 for a full frame, so may take a few seconds)
|
# (50 ms cadence in the mock), so a frame should land within ~100 ms.
|
||||||
frame = None
|
frame = None
|
||||||
try: # noqa: SIM105
|
try: # noqa: SIM105
|
||||||
frame = fq.get(timeout=10)
|
frame = fq.get(timeout=10)
|
||||||
@@ -590,12 +832,11 @@ class TestRadarAcquisition(unittest.TestCase):
|
|||||||
acq.join(timeout=3)
|
acq.join(timeout=3)
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
# With mock data producing 32 packets per read at 50ms interval,
|
|
||||||
# a full frame (2048 samples) takes ~3.2s. Allow up to 10s.
|
|
||||||
if frame is not None:
|
if frame is not None:
|
||||||
self.assertIsInstance(frame, RadarFrame)
|
self.assertIsInstance(frame, RadarFrame)
|
||||||
self.assertEqual(frame.magnitude.shape,
|
self.assertEqual(frame.magnitude.shape,
|
||||||
(NUM_RANGE_BINS, NUM_DOPPLER_BINS))
|
(NUM_RANGE_BINS, NUM_DOPPLER_BINS))
|
||||||
|
self.assertTrue(frame.mag_only)
|
||||||
# If no frame arrived in timeout, that's still OK for a fast CI run
|
# If no frame arrived in timeout, that's still OK for a fast CI run
|
||||||
|
|
||||||
def test_acquisition_stop(self):
|
def test_acquisition_stop(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user