mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-06-10 07:27:23 +00:00
fix(gui): P-2/P-3 — bulk-frame parser + status packet caught up to PR-G v2
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.
This commit is contained in:
@@ -353,6 +353,177 @@ class TestRadarDataWorkerInit(unittest.TestCase):
|
||||
"set_waveform must not reset runtime counters")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: radar_protocol PR-G v2 bulk frame + status round-trip
|
||||
# Audit P-2/P-3: GUI parser must agree byte-for-byte with the FPGA emit
|
||||
# (usb_data_interface_ft2232h.v). Build synthetic frames the way the FPGA
|
||||
# does, then parse them back and check every field. Catches:
|
||||
# - 8 vs 9 byte header (version byte at offset 1)
|
||||
# - reserved-bit mask 0xC0 vs 0xF8
|
||||
# - 1-bit vs 2-bit detect packing
|
||||
# - 26 vs 30 byte status, 6 vs 7 status_words
|
||||
# =============================================================================
|
||||
|
||||
class TestBulkFrameV2RoundTrip(unittest.TestCase):
|
||||
"""PR-G v2 bulk frame: build synthetic FPGA emit, parse back, check."""
|
||||
|
||||
def _build_v2_frame(self, flags: int, frame_num: int = 0,
|
||||
doppler: np.ndarray | None = None,
|
||||
cfar_codes: np.ndarray | None = None,
|
||||
range_profile: np.ndarray | None = None) -> bytes:
|
||||
"""Construct a v2 frame the way usb_data_interface_ft2232h.v emits."""
|
||||
from radar_protocol import (
|
||||
HEADER_BYTE, FOOTER_BYTE, RP_USB_PROTOCOL_VERSION,
|
||||
NUM_RANGE_BINS, NUM_DOPPLER_BINS,
|
||||
BULK_FLAG_STREAM_RANGE, BULK_FLAG_STREAM_DOPPLER, BULK_FLAG_STREAM_CFAR,
|
||||
BULK_DETECT_BYTES_PER_RANGE,
|
||||
)
|
||||
parts = [
|
||||
bytes([HEADER_BYTE, RP_USB_PROTOCOL_VERSION, flags & 0xFF]),
|
||||
struct.pack(">H", frame_num),
|
||||
struct.pack(">H", NUM_RANGE_BINS),
|
||||
struct.pack(">H", NUM_DOPPLER_BINS),
|
||||
]
|
||||
if flags & BULK_FLAG_STREAM_RANGE:
|
||||
rp = (range_profile if range_profile is not None
|
||||
else np.arange(NUM_RANGE_BINS, dtype=np.uint16))
|
||||
parts.append(rp.astype(">u2").tobytes())
|
||||
if flags & BULK_FLAG_STREAM_DOPPLER:
|
||||
d = (doppler if doppler is not None
|
||||
else np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint16))
|
||||
parts.append(d.astype(">u2").tobytes())
|
||||
if flags & BULK_FLAG_STREAM_CFAR:
|
||||
codes = (cfar_codes if cfar_codes is not None
|
||||
else np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8))
|
||||
# Pack 4 cells per byte, MSB-first within byte
|
||||
packed = np.zeros((NUM_RANGE_BINS, BULK_DETECT_BYTES_PER_RANGE), dtype=np.uint8)
|
||||
for d_idx in range(NUM_DOPPLER_BINS):
|
||||
byte_idx = d_idx // 4
|
||||
shift = (3 - (d_idx % 4)) * 2 # MSB-first
|
||||
packed[:, byte_idx] |= ((codes[:, d_idx] & 0x03) << shift).astype(np.uint8)
|
||||
parts.append(packed.tobytes())
|
||||
parts.append(bytes([FOOTER_BYTE]))
|
||||
return b"".join(parts)
|
||||
|
||||
def test_full_frame_round_trip(self):
|
||||
from radar_protocol import (
|
||||
RadarProtocol, NUM_RANGE_BINS, NUM_DOPPLER_BINS,
|
||||
BULK_FLAG_STREAM_RANGE, BULK_FLAG_STREAM_DOPPLER, BULK_FLAG_STREAM_CFAR,
|
||||
BULK_FRAME_HEADER_SIZE,
|
||||
)
|
||||
# Synthetic detection map: scatter all 4 codes across the grid
|
||||
codes = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8)
|
||||
codes[10, 5] = 1 # CAND
|
||||
codes[100, 17] = 2 # CONFIRM
|
||||
codes[300, 47] = 3 # reserved (still legal on the wire)
|
||||
doppler = np.full((NUM_RANGE_BINS, NUM_DOPPLER_BINS), 1234, dtype=np.uint16)
|
||||
rp = np.arange(NUM_RANGE_BINS, dtype=np.uint16)
|
||||
|
||||
flags = (BULK_FLAG_STREAM_RANGE | BULK_FLAG_STREAM_DOPPLER
|
||||
| BULK_FLAG_STREAM_CFAR)
|
||||
frame = self._build_v2_frame(flags, frame_num=42,
|
||||
doppler=doppler, cfar_codes=codes,
|
||||
range_profile=rp)
|
||||
# 9 + 1024 + 49152 + 6144 + 1 = 56330
|
||||
self.assertEqual(len(frame), BULK_FRAME_HEADER_SIZE + 1024 + 49152 + 6144 + 1)
|
||||
self.assertEqual(BULK_FRAME_HEADER_SIZE, 9)
|
||||
|
||||
parsed = RadarProtocol.parse_bulk_frame(frame)
|
||||
self.assertIsNotNone(parsed)
|
||||
self.assertEqual(parsed["frame_number"], 42)
|
||||
self.assertEqual(parsed["flags"], flags)
|
||||
self.assertEqual(parsed["n_range"], NUM_RANGE_BINS)
|
||||
self.assertEqual(parsed["n_doppler"], NUM_DOPPLER_BINS)
|
||||
np.testing.assert_array_equal(parsed["range_profile"], rp)
|
||||
np.testing.assert_array_equal(parsed["doppler_mag"], doppler)
|
||||
np.testing.assert_array_equal(parsed["cfar_dense"], codes)
|
||||
|
||||
def test_reject_wrong_version_byte(self):
|
||||
from radar_protocol import RadarProtocol
|
||||
frame = self._build_v2_frame(0x07)
|
||||
# Corrupt the version byte
|
||||
bad = bytes([frame[0], 0x01]) + frame[2:]
|
||||
self.assertIsNone(RadarProtocol.parse_bulk_frame(bad))
|
||||
|
||||
def test_reject_reserved_flag_bits(self):
|
||||
from radar_protocol import RadarProtocol
|
||||
# Set bit 7 (reserved); byte order: HEADER, ver, flags
|
||||
frame = self._build_v2_frame(0x07)
|
||||
bad = bytes([frame[0], frame[1], frame[2] | 0x80]) + frame[3:]
|
||||
self.assertIsNone(RadarProtocol.parse_bulk_frame(bad))
|
||||
|
||||
def test_detect_2bit_codes_independently(self):
|
||||
"""Each cell decodes to the same 2-bit code that was packed."""
|
||||
from radar_protocol import (
|
||||
RadarProtocol, NUM_RANGE_BINS, NUM_DOPPLER_BINS,
|
||||
BULK_FLAG_STREAM_CFAR,
|
||||
)
|
||||
# All four codes in adjacent cells of the same byte
|
||||
codes = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8)
|
||||
codes[0, 0] = 0
|
||||
codes[0, 1] = 1
|
||||
codes[0, 2] = 2
|
||||
codes[0, 3] = 3
|
||||
frame = self._build_v2_frame(BULK_FLAG_STREAM_CFAR, cfar_codes=codes)
|
||||
parsed = RadarProtocol.parse_bulk_frame(frame)
|
||||
self.assertEqual(parsed["cfar_dense"][0, 0], 0)
|
||||
self.assertEqual(parsed["cfar_dense"][0, 1], 1)
|
||||
self.assertEqual(parsed["cfar_dense"][0, 2], 2)
|
||||
self.assertEqual(parsed["cfar_dense"][0, 3], 3)
|
||||
|
||||
def test_find_boundaries_on_back_to_back_frames(self):
|
||||
from radar_protocol import (
|
||||
RadarProtocol, BULK_FLAG_STREAM_DOPPLER, BULK_FLAG_STREAM_CFAR,
|
||||
)
|
||||
flags = BULK_FLAG_STREAM_DOPPLER | BULK_FLAG_STREAM_CFAR
|
||||
f1 = self._build_v2_frame(flags, frame_num=1)
|
||||
f2 = self._build_v2_frame(flags, frame_num=2)
|
||||
boundaries = RadarProtocol.find_bulk_frame_boundaries(f1 + f2)
|
||||
self.assertEqual(len(boundaries), 2)
|
||||
self.assertEqual(boundaries[0], (0, len(f1), "data"))
|
||||
self.assertEqual(boundaries[1], (len(f1), len(f1) + len(f2), "data"))
|
||||
|
||||
|
||||
class TestStatusPacketV2RoundTrip(unittest.TestCase):
|
||||
"""PR-G v2 status packet: 7 status_words / 30 bytes."""
|
||||
|
||||
def _build_status(self, words: list[int]) -> bytes:
|
||||
from radar_protocol import STATUS_HEADER_BYTE, FOOTER_BYTE
|
||||
assert len(words) == 7
|
||||
body = b"".join(struct.pack(">I", w & 0xFFFFFFFF) for w in words)
|
||||
return bytes([STATUS_HEADER_BYTE]) + body + bytes([FOOTER_BYTE])
|
||||
|
||||
def test_size_is_30(self):
|
||||
from radar_protocol import STATUS_PACKET_SIZE
|
||||
self.assertEqual(STATUS_PACKET_SIZE, 30)
|
||||
pkt = self._build_status([0] * 7)
|
||||
self.assertEqual(len(pkt), 30)
|
||||
|
||||
def test_word6_telemetry_decoded(self):
|
||||
"""word[6] = {detect_count_cand[31:16], detect_threshold_soft[15:0]}"""
|
||||
from radar_protocol import RadarProtocol
|
||||
word6 = (0x1234 << 16) | 0xABCD # cand=0x1234, thr_soft=0xABCD
|
||||
pkt = self._build_status([0, 0, 0, 0, 0, 0, word6])
|
||||
sr = RadarProtocol.parse_status_packet(pkt)
|
||||
self.assertIsNotNone(sr)
|
||||
self.assertEqual(sr.detect_count_cand, 0x1234)
|
||||
self.assertEqual(sr.detect_threshold_soft, 0xABCD)
|
||||
|
||||
def test_short_packet_returns_none(self):
|
||||
from radar_protocol import RadarProtocol
|
||||
pkt = self._build_status([0] * 7)
|
||||
self.assertIsNone(RadarProtocol.parse_status_packet(pkt[:25]))
|
||||
|
||||
def test_pre_PR_G_26byte_packet_rejected(self):
|
||||
"""Old 26-byte status packets must NOT silently parse — they're stale."""
|
||||
from radar_protocol import RadarProtocol, STATUS_HEADER_BYTE, FOOTER_BYTE
|
||||
# Build a 26-byte packet (legacy format).
|
||||
old_pkt = (bytes([STATUS_HEADER_BYTE])
|
||||
+ b"\x00" * 24 + bytes([FOOTER_BYTE]))
|
||||
self.assertEqual(len(old_pkt), 26)
|
||||
self.assertIsNone(RadarProtocol.parse_status_packet(old_pkt))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test: v7.__init__ — clean exports
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user