Add radar dashboard GUI with replay mode for real ADI CN0566 data visualization, FPGA self-test module, and co-sim npy arrays

This commit is contained in:
Jason
2026-03-20 19:02:06 +02:00
parent 7a44f19432
commit f8d80cc96e
19 changed files with 2591 additions and 3 deletions
+4
View File
@@ -7,3 +7,7 @@ GUI_V4 ==> Added pitch correction
GUI_V5 ==> Added Mercury Color
GUI_V6 ==> Added USB3 FT601 support
radar_dashboard ==> Board bring-up dashboard (FT601 reader, real-time R-D heatmap, CFAR overlay, waterfall, host commands, HDF5 recording)
radar_protocol ==> Protocol layer (packet parsing, command building, FT601 connection, data recorder, acquisition thread)
smoke_test ==> Board bring-up smoke test host script (triggers FPGA self-test via opcode 0x30)
+485
View File
@@ -0,0 +1,485 @@
#!/usr/bin/env python3
"""
AERIS-10 Radar Dashboard — Board Bring-Up Edition
===================================================
Real-time visualization and control for the AERIS-10 phased-array radar
via FT601 USB 3.0 interface.
Features:
- FT601 USB reader with packet parsing (matches usb_data_interface.v)
- Real-time range-Doppler magnitude heatmap (64x32)
- CFAR detection overlay (flagged cells highlighted)
- Range profile waterfall plot (range vs. time)
- Host command sender (opcodes 0x01-0x27, 0x30, 0xFF)
- Configuration panel for all radar parameters
- HDF5 data recording for offline analysis
- Mock mode for development/testing without hardware
Usage:
python radar_dashboard.py # Launch with mock data
python radar_dashboard.py --live # Launch with FT601 hardware
python radar_dashboard.py --record # Launch with HDF5 recording
"""
import sys
import os
import time
import queue
import logging
import argparse
from typing import Optional, Dict
from collections import deque
import numpy as np
import tkinter as tk
from tkinter import ttk, filedialog
import matplotlib
matplotlib.use("TkAgg")
from matplotlib.figure import Figure
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
# Import protocol layer (no GUI deps)
from radar_protocol import (
RadarProtocol, FT601Connection, ReplayConnection,
DataRecorder, RadarAcquisition,
RadarFrame, StatusResponse, Opcode,
NUM_RANGE_BINS, NUM_DOPPLER_BINS, WATERFALL_DEPTH,
)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("radar_dashboard")
# ============================================================================
# Dashboard GUI
# ============================================================================
# Dark theme colors
BG = "#1e1e2e"
BG2 = "#282840"
FG = "#cdd6f4"
ACCENT = "#89b4fa"
GREEN = "#a6e3a1"
RED = "#f38ba8"
YELLOW = "#f9e2af"
SURFACE = "#313244"
class RadarDashboard:
"""Main tkinter application: real-time radar visualization and control."""
UPDATE_INTERVAL_MS = 100 # 10 Hz display refresh
def __init__(self, root: tk.Tk, connection: FT601Connection,
recorder: DataRecorder):
self.root = root
self.conn = connection
self.recorder = recorder
self.root.title("AERIS-10 Radar Dashboard — Bring-Up Edition")
self.root.geometry("1600x950")
self.root.configure(bg=BG)
# Frame queue (acquisition → display)
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
self._acq_thread: Optional[RadarAcquisition] = None
# Display state
self._current_frame = RadarFrame()
self._waterfall = deque(maxlen=WATERFALL_DEPTH)
for _ in range(WATERFALL_DEPTH):
self._waterfall.append(np.zeros(NUM_RANGE_BINS))
self._frame_count = 0
self._fps_ts = time.time()
self._fps = 0.0
self._build_ui()
self._schedule_update()
# ------------------------------------------------------------------ UI
def _build_ui(self):
style = ttk.Style()
style.theme_use("clam")
style.configure(".", background=BG, foreground=FG, fieldbackground=SURFACE)
style.configure("TFrame", background=BG)
style.configure("TLabel", background=BG, foreground=FG)
style.configure("TButton", background=SURFACE, foreground=FG)
style.configure("TLabelframe", background=BG, foreground=ACCENT)
style.configure("TLabelframe.Label", background=BG, foreground=ACCENT)
style.configure("Accent.TButton", background=ACCENT, foreground=BG)
style.configure("TNotebook", background=BG)
style.configure("TNotebook.Tab", background=SURFACE, foreground=FG,
padding=[12, 4])
style.map("TNotebook.Tab", background=[("selected", ACCENT)],
foreground=[("selected", BG)])
# Top bar
top = ttk.Frame(self.root)
top.pack(fill="x", padx=8, pady=(8, 0))
self.lbl_status = ttk.Label(top, text="DISCONNECTED", foreground=RED,
font=("Menlo", 11, "bold"))
self.lbl_status.pack(side="left", padx=8)
self.lbl_fps = ttk.Label(top, text="0.0 fps", font=("Menlo", 10))
self.lbl_fps.pack(side="left", padx=16)
self.lbl_detections = ttk.Label(top, text="Det: 0", font=("Menlo", 10))
self.lbl_detections.pack(side="left", padx=16)
self.lbl_frame = ttk.Label(top, text="Frame: 0", font=("Menlo", 10))
self.lbl_frame.pack(side="left", padx=16)
btn_connect = ttk.Button(top, text="Connect", command=self._on_connect,
style="Accent.TButton")
btn_connect.pack(side="right", padx=4)
self.btn_record = ttk.Button(top, text="Record", command=self._on_record)
self.btn_record.pack(side="right", padx=4)
# Notebook (tabs)
nb = ttk.Notebook(self.root)
nb.pack(fill="both", expand=True, padx=8, pady=8)
tab_display = ttk.Frame(nb)
tab_control = ttk.Frame(nb)
tab_log = ttk.Frame(nb)
nb.add(tab_display, text=" Display ")
nb.add(tab_control, text=" Control ")
nb.add(tab_log, text=" Log ")
self._build_display_tab(tab_display)
self._build_control_tab(tab_control)
self._build_log_tab(tab_log)
def _build_display_tab(self, parent):
# Matplotlib figure with 3 subplots
self.fig = Figure(figsize=(14, 7), facecolor=BG)
self.fig.subplots_adjust(left=0.06, right=0.98, top=0.94, bottom=0.08,
wspace=0.30, hspace=0.35)
# Range-Doppler heatmap
self.ax_rd = self.fig.add_subplot(1, 3, (1, 2))
self.ax_rd.set_facecolor(BG2)
self._rd_img = self.ax_rd.imshow(
np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS)),
aspect="auto", cmap="inferno", origin="lower",
extent=[0, NUM_DOPPLER_BINS, 0, NUM_RANGE_BINS],
vmin=0, vmax=1000,
)
self.ax_rd.set_title("Range-Doppler Map", color=FG, fontsize=12)
self.ax_rd.set_xlabel("Doppler Bin", color=FG)
self.ax_rd.set_ylabel("Range Bin", color=FG)
self.ax_rd.tick_params(colors=FG)
# CFAR detection overlay (scatter)
self._det_scatter = self.ax_rd.scatter([], [], s=30, c=GREEN,
marker="x", linewidths=1.5,
zorder=5, label="CFAR Det")
# Waterfall plot (range profile vs time)
self.ax_wf = self.fig.add_subplot(1, 3, 3)
self.ax_wf.set_facecolor(BG2)
wf_init = np.zeros((WATERFALL_DEPTH, NUM_RANGE_BINS))
self._wf_img = self.ax_wf.imshow(
wf_init, aspect="auto", cmap="viridis", origin="lower",
extent=[0, NUM_RANGE_BINS, 0, WATERFALL_DEPTH],
vmin=0, vmax=5000,
)
self.ax_wf.set_title("Range Waterfall", color=FG, fontsize=12)
self.ax_wf.set_xlabel("Range Bin", color=FG)
self.ax_wf.set_ylabel("Frame", color=FG)
self.ax_wf.tick_params(colors=FG)
canvas = FigureCanvasTkAgg(self.fig, master=parent)
canvas.draw()
canvas.get_tk_widget().pack(fill="both", expand=True)
self._canvas = canvas
def _build_control_tab(self, parent):
"""Host command sender and configuration panel."""
outer = ttk.Frame(parent)
outer.pack(fill="both", expand=True, padx=16, pady=16)
# Left column: Quick actions
left = ttk.LabelFrame(outer, text="Quick Actions", padding=12)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
ttk.Button(left, text="Trigger Chirp (0x01)",
command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Enable MTI (0x26)",
command=lambda: self._send_cmd(0x26, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Disable MTI (0x26)",
command=lambda: self._send_cmd(0x26, 0)).pack(fill="x", pady=3)
ttk.Button(left, text="Enable CFAR (0x25)",
command=lambda: self._send_cmd(0x25, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Disable CFAR (0x25)",
command=lambda: self._send_cmd(0x25, 0)).pack(fill="x", pady=3)
ttk.Button(left, text="Request Status (0xFF)",
command=lambda: self._send_cmd(0xFF, 0)).pack(fill="x", pady=3)
# Right column: Parameter configuration
right = ttk.LabelFrame(outer, text="Parameter Configuration", padding=12)
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
self._param_vars: Dict[str, tk.StringVar] = {}
params = [
("CFAR Guard (0x21)", 0x21, "2"),
("CFAR Train (0x22)", 0x22, "8"),
("CFAR Alpha Q4.4 (0x23)", 0x23, "48"),
("CFAR Mode (0x24)", 0x24, "0"),
("Threshold (0x10)", 0x10, "500"),
("Gain Shift (0x16)", 0x16, "0"),
("DC Notch Width (0x27)", 0x27, "0"),
("Range Mode (0x20)", 0x20, "0"),
("Stream Enable (0x04)", 0x04, "7"),
]
for row_idx, (label, opcode, default) in enumerate(params):
ttk.Label(right, text=label).grid(row=row_idx, column=0,
sticky="w", pady=2)
var = tk.StringVar(value=default)
self._param_vars[str(opcode)] = var
ent = ttk.Entry(right, textvariable=var, width=10)
ent.grid(row=row_idx, column=1, padx=8, pady=2)
ttk.Button(
right, text="Set",
command=lambda op=opcode, v=var: self._send_cmd(op, int(v.get()))
).grid(row=row_idx, column=2, pady=2)
# Custom command
ttk.Separator(right, orient="horizontal").grid(
row=len(params), column=0, columnspan=3, sticky="ew", pady=8)
ttk.Label(right, text="Custom Opcode (hex)").grid(
row=len(params) + 1, column=0, sticky="w")
self._custom_op = tk.StringVar(value="01")
ttk.Entry(right, textvariable=self._custom_op, width=10).grid(
row=len(params) + 1, column=1, padx=8)
ttk.Label(right, text="Value (dec)").grid(
row=len(params) + 2, column=0, sticky="w")
self._custom_val = tk.StringVar(value="0")
ttk.Entry(right, textvariable=self._custom_val, width=10).grid(
row=len(params) + 2, column=1, padx=8)
ttk.Button(right, text="Send Custom",
command=self._send_custom).grid(
row=len(params) + 2, column=2, pady=2)
outer.columnconfigure(0, weight=1)
outer.columnconfigure(1, weight=2)
outer.rowconfigure(0, weight=1)
def _build_log_tab(self, parent):
self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10),
insertbackground=FG, wrap="word")
self.log_text.pack(fill="both", expand=True, padx=8, pady=8)
# Redirect log handler to text widget
handler = _TextHandler(self.log_text)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S"))
logging.getLogger().addHandler(handler)
# ------------------------------------------------------------ Actions
def _on_connect(self):
if self.conn.is_open:
# Disconnect
if self._acq_thread is not None:
self._acq_thread.stop()
self._acq_thread.join(timeout=2)
self._acq_thread = None
self.conn.close()
self.lbl_status.config(text="DISCONNECTED", foreground=RED)
log.info("Disconnected")
return
if self.conn.open():
self.lbl_status.config(text="CONNECTED", foreground=GREEN)
self._acq_thread = RadarAcquisition(
self.conn, self.frame_queue, self.recorder)
self._acq_thread.start()
log.info("Connected and acquisition started")
else:
self.lbl_status.config(text="CONNECT FAILED", foreground=RED)
def _on_record(self):
if self.recorder.recording:
self.recorder.stop()
self.btn_record.config(text="Record")
return
filepath = filedialog.asksaveasfilename(
defaultextension=".h5",
filetypes=[("HDF5", "*.h5"), ("All", "*.*")],
initialfile=f"radar_{time.strftime('%Y%m%d_%H%M%S')}.h5",
)
if filepath:
self.recorder.start(filepath)
self.btn_record.config(text="Stop Rec")
def _send_cmd(self, opcode: int, value: int):
cmd = RadarProtocol.build_command(opcode, value)
ok = self.conn.write(cmd)
log.info(f"CMD 0x{opcode:02X} val={value} ({'OK' if ok else 'FAIL'})")
def _send_custom(self):
try:
op = int(self._custom_op.get(), 16)
val = int(self._custom_val.get())
self._send_cmd(op, val)
except ValueError:
log.error("Invalid custom command values")
# --------------------------------------------------------- Display loop
def _schedule_update(self):
self._update_display()
self.root.after(self.UPDATE_INTERVAL_MS, self._schedule_update)
def _update_display(self):
"""Pull latest frame from queue and update plots."""
frame = None
# Drain queue, keep latest
while True:
try:
frame = self.frame_queue.get_nowait()
except queue.Empty:
break
if frame is None:
return
self._current_frame = frame
self._frame_count += 1
# FPS calculation
now = time.time()
dt = now - self._fps_ts
if dt > 0.5:
self._fps = self._frame_count / dt
self._frame_count = 0
self._fps_ts = now
# Update labels
self.lbl_fps.config(text=f"{self._fps:.1f} fps")
self.lbl_detections.config(text=f"Det: {frame.detection_count}")
self.lbl_frame.config(text=f"Frame: {frame.frame_number}")
# Update range-Doppler heatmap
mag = frame.magnitude
vmax = max(np.max(mag), 1.0)
self._rd_img.set_data(mag)
self._rd_img.set_clim(vmin=0, vmax=vmax)
# Update CFAR overlay
det_coords = np.argwhere(frame.detections > 0)
if len(det_coords) > 0:
offsets = np.column_stack([det_coords[:, 1] + 0.5,
det_coords[:, 0] + 0.5])
self._det_scatter.set_offsets(offsets)
else:
self._det_scatter.set_offsets(np.empty((0, 2)))
# Update waterfall
self._waterfall.append(frame.range_profile.copy())
wf_arr = np.array(list(self._waterfall))
wf_max = max(np.max(wf_arr), 1.0)
self._wf_img.set_data(wf_arr)
self._wf_img.set_clim(vmin=0, vmax=wf_max)
self._canvas.draw_idle()
class _TextHandler(logging.Handler):
"""Logging handler that writes to a tkinter Text widget."""
def __init__(self, text_widget: tk.Text):
super().__init__()
self._text = text_widget
def emit(self, record):
msg = self.format(record)
try:
self._text.after(0, self._append, msg)
except Exception:
pass
def _append(self, msg: str):
self._text.insert("end", msg + "\n")
self._text.see("end")
# Keep last 500 lines
lines = int(self._text.index("end-1c").split(".")[0])
if lines > 500:
self._text.delete("1.0", f"{lines - 500}.0")
# ============================================================================
# Entry Point
# ============================================================================
def main():
parser = argparse.ArgumentParser(description="AERIS-10 Radar Dashboard")
parser.add_argument("--live", action="store_true",
help="Use real FT601 hardware (default: mock mode)")
parser.add_argument("--replay", type=str, metavar="NPY_DIR",
help="Replay real data from .npy directory "
"(e.g. tb/cosim/real_data/hex/)")
parser.add_argument("--no-mti", action="store_true",
help="With --replay, use non-MTI Doppler data")
parser.add_argument("--record", action="store_true",
help="Start HDF5 recording immediately")
parser.add_argument("--device", type=int, default=0,
help="FT601 device index (default: 0)")
args = parser.parse_args()
if args.replay:
npy_dir = os.path.abspath(args.replay)
conn = ReplayConnection(npy_dir, use_mti=not args.no_mti)
mode_str = f"REPLAY ({npy_dir}, MTI={'OFF' if args.no_mti else 'ON'})"
elif args.live:
conn = FT601Connection(mock=False)
mode_str = "LIVE"
else:
conn = FT601Connection(mock=True)
mode_str = "MOCK"
recorder = DataRecorder()
root = tk.Tk()
dashboard = RadarDashboard(root, conn, recorder)
if args.record:
filepath = os.path.join(
os.getcwd(),
f"radar_{time.strftime('%Y%m%d_%H%M%S')}.h5"
)
recorder.start(filepath)
def on_closing():
if dashboard._acq_thread is not None:
dashboard._acq_thread.stop()
dashboard._acq_thread.join(timeout=2)
if conn.is_open:
conn.close()
if recorder.recording:
recorder.stop()
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_closing)
log.info(f"Dashboard started (mode={mode_str})")
root.mainloop()
if __name__ == "__main__":
main()
+693
View File
@@ -0,0 +1,693 @@
#!/usr/bin/env python3
"""
AERIS-10 Radar Protocol Layer
===============================
Pure-logic module for FT601 packet parsing and command building.
No GUI dependencies — safe to import from tests and headless scripts.
Matches usb_data_interface.v packet format exactly.
USB Packet Protocol:
TX (FPGA→Host):
Data packet: [0xAA] [range 4×32b] [doppler 4×32b] [det 1B] [0x55]
Status packet: [0xBB] [status 5×32b] [0x55]
RX (Host→FPGA):
Command word: {opcode[31:24], addr[23:16], value[15:0]}
"""
import os
import struct
import time
import threading
import queue
import logging
from dataclasses import dataclass, field
from typing import Optional, List, Tuple, Dict, Any
from enum import IntEnum
from collections import deque
import numpy as np
log = logging.getLogger("radar_protocol")
# ============================================================================
# Constants matching usb_data_interface.v
# ============================================================================
HEADER_BYTE = 0xAA
FOOTER_BYTE = 0x55
STATUS_HEADER_BYTE = 0xBB
NUM_RANGE_BINS = 64
NUM_DOPPLER_BINS = 32
NUM_CELLS = NUM_RANGE_BINS * NUM_DOPPLER_BINS # 2048
WATERFALL_DEPTH = 64
class Opcode(IntEnum):
"""Host register opcodes (matches radar_system_top.v command decode)."""
TRIGGER = 0x01
PRF_DIV = 0x02
NUM_CHIRPS = 0x03
CHIRP_TIMER = 0x04
STREAM_ENABLE = 0x05
GAIN_SHIFT = 0x06
THRESHOLD = 0x10
LONG_CHIRP = 0x10
LONG_LISTEN = 0x11
GUARD = 0x12
SHORT_CHIRP = 0x13
SHORT_LISTEN = 0x14
CHIRPS_PER_ELEV = 0x15
DIGITAL_GAIN = 0x16
RANGE_MODE = 0x20
CFAR_GUARD = 0x21
CFAR_TRAIN = 0x22
CFAR_ALPHA = 0x23
CFAR_MODE = 0x24
CFAR_ENABLE = 0x25
MTI_ENABLE = 0x26
DC_NOTCH_WIDTH = 0x27
STATUS_REQUEST = 0xFF
# ============================================================================
# Data Structures
# ============================================================================
@dataclass
class RadarFrame:
"""One complete radar frame (64 range × 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))
range_doppler_q: np.ndarray = field(
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.int16))
magnitude: np.ndarray = field(
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.float64))
detections: np.ndarray = field(
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.uint8))
range_profile: np.ndarray = field(
default_factory=lambda: np.zeros(NUM_RANGE_BINS, dtype=np.float64))
detection_count: int = 0
frame_number: int = 0
@dataclass
class StatusResponse:
"""Parsed status response from FPGA."""
radar_mode: int = 0
stream_ctrl: int = 0
cfar_threshold: int = 0
long_chirp: int = 0
long_listen: int = 0
guard: int = 0
short_chirp: int = 0
short_listen: int = 0
chirps_per_elev: int = 0
range_mode: int = 0
# ============================================================================
# Protocol: Packet Parsing & Building
# ============================================================================
def _to_signed16(val: int) -> int:
"""Convert unsigned 16-bit integer to signed (two's complement)."""
val = val & 0xFFFF
return val - 0x10000 if val >= 0x8000 else val
class RadarProtocol:
"""
Parse FPGA→Host packets and build Host→FPGA command words.
Matches usb_data_interface.v packet format exactly.
"""
@staticmethod
def build_command(opcode: int, value: int, addr: int = 0) -> bytes:
"""
Build a 32-bit command word: {opcode[31:24], addr[23:16], value[15:0]}.
Returns 4 bytes, big-endian (MSB first as FT601 expects).
"""
word = ((opcode & 0xFF) << 24) | ((addr & 0xFF) << 16) | (value & 0xFFFF)
return struct.pack(">I", word)
@staticmethod
def parse_data_packet(raw: bytes) -> Optional[Dict[str, Any]]:
"""
Parse a single data packet from the FPGA byte stream.
Returns dict with keys: 'range_i', 'range_q', 'doppler_i', 'doppler_q',
'detection', or None if invalid.
Packet format (all streams enabled):
[0xAA] [range 4×4B] [doppler 4×4B] [det 1B] [0x55]
= 1 + 16 + 16 + 1 + 1 = 35 bytes
With byte-enables, the FT601 delivers only valid bytes.
Header/footer/detection use BE=0001 → 1 byte each.
Range/doppler use BE=1111 → 4 bytes each × 4 transfers.
In practice, the range data word 0 contains the full 32-bit value
{range_q[15:0], range_i[15:0]}. Words 13 are shifted copies.
Similarly, doppler word 0 = {doppler_real, doppler_imag}.
"""
if len(raw) < 3:
return None
if raw[0] != HEADER_BYTE:
return None
result = {}
pos = 1
# Range data: 4 × 4 bytes, only word 0 matters
if pos + 16 <= len(raw):
range_word0 = struct.unpack_from(">I", raw, pos)[0]
result["range_i"] = _to_signed16(range_word0 & 0xFFFF)
result["range_q"] = _to_signed16((range_word0 >> 16) & 0xFFFF)
pos += 16
else:
return None
# Doppler data: 4 × 4 bytes, only word 0 matters
# Word 0 layout: {doppler_real[31:16], doppler_imag[15:0]}
if pos + 16 <= len(raw):
dop_word0 = struct.unpack_from(">I", raw, pos)[0]
result["doppler_q"] = _to_signed16(dop_word0 & 0xFFFF)
result["doppler_i"] = _to_signed16((dop_word0 >> 16) & 0xFFFF)
pos += 16
else:
return None
# Detection: 1 byte
if pos + 1 <= len(raw):
result["detection"] = raw[pos] & 0x01
pos += 1
else:
return None
# Footer
if pos < len(raw) and raw[pos] == FOOTER_BYTE:
pos += 1
return result
@staticmethod
def parse_status_packet(raw: bytes) -> Optional[StatusResponse]:
"""
Parse a status response packet.
Format: [0xBB] [5×4B status words] [0x55] = 1 + 20 + 1 = 22 bytes
"""
if len(raw) < 22:
return None
if raw[0] != STATUS_HEADER_BYTE:
return None
words = []
for i in range(5):
w = struct.unpack_from(">I", raw, 1 + i * 4)[0]
words.append(w)
if raw[21] != FOOTER_BYTE:
return None
sr = StatusResponse()
# Word 0: {0xFF, 3'b0, mode[1:0], 5'b0, stream[2:0], threshold[15:0]}
sr.cfar_threshold = words[0] & 0xFFFF
sr.stream_ctrl = (words[0] >> 16) & 0x07
sr.radar_mode = (words[0] >> 21) & 0x03
# Word 1: {long_chirp[31:16], long_listen[15:0]}
sr.long_listen = words[1] & 0xFFFF
sr.long_chirp = (words[1] >> 16) & 0xFFFF
# Word 2: {guard[31:16], short_chirp[15:0]}
sr.short_chirp = words[2] & 0xFFFF
sr.guard = (words[2] >> 16) & 0xFFFF
# Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]}
sr.chirps_per_elev = words[3] & 0x3F
sr.short_listen = (words[3] >> 16) & 0xFFFF
# Word 4: {30'd0, range_mode[1:0]}
sr.range_mode = words[4] & 0x03
return sr
@staticmethod
def find_packet_boundaries(buf: bytes) -> List[Tuple[int, int, str]]:
"""
Scan buffer for packet start markers (0xAA data, 0xBB status).
Returns list of (start_idx, expected_end_idx, packet_type).
"""
packets = []
i = 0
while i < len(buf):
if buf[i] == HEADER_BYTE:
# Data packet: 35 bytes (all streams)
end = i + 35
if end <= len(buf):
packets.append((i, end, "data"))
i = end
else:
break
elif buf[i] == STATUS_HEADER_BYTE:
# Status packet: 22 bytes
end = i + 22
if end <= len(buf):
packets.append((i, end, "status"))
i = end
else:
break
else:
i += 1
return packets
# ============================================================================
# FT601 USB Connection
# ============================================================================
# Optional ftd3xx import
try:
import ftd3xx
FTD3XX_AVAILABLE = True
except ImportError:
FTD3XX_AVAILABLE = False
class FT601Connection:
"""
FT601 USB 3.0 FIFO bridge communication.
Supports ftd3xx (native D3XX) or mock mode.
"""
def __init__(self, mock: bool = True):
self._mock = mock
self._device = None
self._lock = threading.Lock()
self.is_open = False
# Mock state
self._mock_frame_num = 0
self._mock_rng = np.random.RandomState(42)
def open(self, device_index: int = 0) -> bool:
if self._mock:
self.is_open = True
log.info("FT601 mock device opened (no hardware)")
return True
if not FTD3XX_AVAILABLE:
log.error("ftd3xx not installed — cannot open real FT601 device")
return False
try:
self._device = ftd3xx.create(device_index, ftd3xx.CONFIGURATION_CHANNEL_0)
if self._device is None:
log.error("ftd3xx.create returned None")
return False
self.is_open = True
log.info(f"FT601 device {device_index} opened")
return True
except Exception as e:
log.error(f"FT601 open failed: {e}")
return False
def close(self):
if self._device is not None:
try:
self._device.close()
except Exception:
pass
self._device = None
self.is_open = False
def read(self, size: int = 4096) -> Optional[bytes]:
"""Read raw bytes from FT601. Returns None on error/timeout."""
if not self.is_open:
return None
if self._mock:
return self._mock_read(size)
with self._lock:
try:
buf = self._device.readPipe(0x82, size, raw=True)
return bytes(buf) if buf else None
except Exception as e:
log.error(f"FT601 read error: {e}")
return None
def write(self, data: bytes) -> bool:
"""Write raw bytes to FT601."""
if not self.is_open:
return False
if self._mock:
log.info(f"FT601 mock write: {data.hex()}")
return True
with self._lock:
try:
self._device.writePipe(0x02, data, len(data))
return True
except Exception as e:
log.error(f"FT601 write error: {e}")
return False
def _mock_read(self, size: int) -> bytes:
"""
Generate synthetic radar data packets for testing.
Simulates a batch of packets with a target near range bin 20, Doppler bin 8.
"""
time.sleep(0.05) # Simulate USB latency
self._mock_frame_num += 1
buf = bytearray()
num_packets = min(32, size // 35)
for _ in range(num_packets):
rbin = self._mock_rng.randint(0, NUM_RANGE_BINS)
dbin = self._mock_rng.randint(0, NUM_DOPPLER_BINS)
# Simulate range profile with a target at bin ~20 and noise
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
# Simulate Doppler with target at Doppler bin ~8
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 = 1 if (abs(rbin - 20) < 2 and abs(dbin - 8) < 2) else 0
# Build packet
pkt = bytearray()
pkt.append(HEADER_BYTE)
rword = (((range_q & 0xFFFF) << 16) | (range_i & 0xFFFF)) & 0xFFFFFFFF
pkt += struct.pack(">I", rword)
pkt += struct.pack(">I", ((rword << 8) & 0xFFFFFFFF))
pkt += struct.pack(">I", ((rword << 16) & 0xFFFFFFFF))
pkt += struct.pack(">I", ((rword << 24) & 0xFFFFFFFF))
dword = (((dop_i & 0xFFFF) << 16) | (dop_q & 0xFFFF)) & 0xFFFFFFFF
pkt += struct.pack(">I", dword)
pkt += struct.pack(">I", ((dword << 8) & 0xFFFFFFFF))
pkt += struct.pack(">I", ((dword << 16) & 0xFFFFFFFF))
pkt += struct.pack(">I", ((dword << 24) & 0xFFFFFFFF))
pkt.append(detection & 0x01)
pkt.append(FOOTER_BYTE)
buf += pkt
return bytes(buf)
# ============================================================================
# Replay Connection — feed real .npy data through the dashboard
# ============================================================================
class ReplayConnection:
"""
Loads pre-computed .npy arrays (from golden_reference.py co-sim output)
and serves them as USB data packets to the dashboard, exercising the full
parsing pipeline with real ADI CN0566 radar data.
Supports multiple pipeline views (no-MTI, with-MTI) and loops the single
frame continuously so the waterfall/heatmap stay populated.
Required npy directory layout (e.g. tb/cosim/real_data/hex/):
doppler_map_i.npy (64, 32) int — Doppler I (no MTI)
doppler_map_q.npy (64, 32) int — Doppler Q (no MTI)
fullchain_mti_doppler_i.npy(64, 32) int — Doppler I (with MTI)
fullchain_mti_doppler_q.npy(64, 32) int — Doppler Q (with MTI)
fullchain_cfar_flags.npy (64, 32) bool — CFAR detections
fullchain_cfar_mag.npy (64, 32) int — CFAR |I|+|Q| magnitude
"""
def __init__(self, npy_dir: str, use_mti: bool = True,
replay_fps: float = 5.0):
self._npy_dir = npy_dir
self._use_mti = use_mti
self._replay_interval = 1.0 / max(replay_fps, 0.1)
self._lock = threading.Lock()
self.is_open = False
self._packets: bytes = b""
self._read_offset = 0
self._frame_len = 0
def open(self, device_index: int = 0) -> bool:
try:
self._packets = self._build_packets()
self._frame_len = len(self._packets)
self._read_offset = 0
self.is_open = True
log.info(f"Replay connection opened: {self._npy_dir} "
f"(MTI={'ON' if self._use_mti else 'OFF'}, "
f"{self._frame_len} bytes/frame)")
return True
except Exception as e:
log.error(f"Replay open failed: {e}")
return False
def close(self):
self.is_open = False
def read(self, size: int = 4096) -> Optional[bytes]:
if not self.is_open:
return None
time.sleep(self._replay_interval / (NUM_CELLS / 32))
with self._lock:
end = self._read_offset + size
if end <= self._frame_len:
chunk = self._packets[self._read_offset:end]
self._read_offset = end
else:
chunk = self._packets[self._read_offset:]
self._read_offset = 0
return chunk
def write(self, data: bytes) -> bool:
log.info(f"Replay write (ignored): {data.hex()}")
return True
def _build_packets(self) -> bytes:
"""Build a full frame of USB data packets from npy arrays."""
npy = self._npy_dir
if self._use_mti:
dop_i = np.load(os.path.join(npy, "fullchain_mti_doppler_i.npy")).astype(np.int64)
dop_q = np.load(os.path.join(npy, "fullchain_mti_doppler_q.npy")).astype(np.int64)
det = np.load(os.path.join(npy, "fullchain_cfar_flags.npy"))
else:
dop_i = np.load(os.path.join(npy, "doppler_map_i.npy")).astype(np.int64)
dop_q = np.load(os.path.join(npy, "doppler_map_q.npy")).astype(np.int64)
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=bool)
# Also load range data (use Doppler bin 0 column as range proxy,
# or load dedicated range if available)
try:
range_i_all = np.load(os.path.join(npy, "decimated_range_i.npy")).astype(np.int64)
range_q_all = np.load(os.path.join(npy, "decimated_range_q.npy")).astype(np.int64)
# Use last chirp as representative range profile
range_i_vec = range_i_all[-1, :] # (64,)
range_q_vec = range_q_all[-1, :]
except FileNotFoundError:
range_i_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64)
range_q_vec = np.zeros(NUM_RANGE_BINS, dtype=np.int64)
buf = bytearray()
for rbin in range(NUM_RANGE_BINS):
for dbin in range(NUM_DOPPLER_BINS):
ri = int(np.clip(range_i_vec[rbin], -32768, 32767)) & 0xFFFF
rq = int(np.clip(range_q_vec[rbin], -32768, 32767)) & 0xFFFF
di = int(np.clip(dop_i[rbin, dbin], -32768, 32767)) & 0xFFFF
dq = int(np.clip(dop_q[rbin, dbin], -32768, 32767)) & 0xFFFF
d = 1 if det[rbin, dbin] else 0
pkt = bytearray()
pkt.append(HEADER_BYTE)
rword = ((rq << 16) | ri) & 0xFFFFFFFF
pkt += struct.pack(">I", rword)
pkt += struct.pack(">I", (rword << 8) & 0xFFFFFFFF)
pkt += struct.pack(">I", (rword << 16) & 0xFFFFFFFF)
pkt += struct.pack(">I", (rword << 24) & 0xFFFFFFFF)
dword = ((di << 16) | dq) & 0xFFFFFFFF
pkt += struct.pack(">I", dword)
pkt += struct.pack(">I", (dword << 8) & 0xFFFFFFFF)
pkt += struct.pack(">I", (dword << 16) & 0xFFFFFFFF)
pkt += struct.pack(">I", (dword << 24) & 0xFFFFFFFF)
pkt.append(d)
pkt.append(FOOTER_BYTE)
buf += pkt
log.info(f"Replay: built {NUM_CELLS} packets ({len(buf)} bytes), "
f"{int(det.sum())} detections")
return bytes(buf)
# ============================================================================
# Data Recorder (HDF5)
# ============================================================================
try:
import h5py
HDF5_AVAILABLE = True
except ImportError:
HDF5_AVAILABLE = False
class DataRecorder:
"""Record radar frames to HDF5 files for offline analysis."""
def __init__(self):
self._file = None
self._grp = None
self._frame_count = 0
self._recording = False
@property
def recording(self) -> bool:
return self._recording
def start(self, filepath: str):
if not HDF5_AVAILABLE:
log.error("h5py not installed — HDF5 recording unavailable")
return
try:
self._file = h5py.File(filepath, "w")
self._file.attrs["creator"] = "AERIS-10 Radar Dashboard"
self._file.attrs["start_time"] = time.time()
self._file.attrs["range_bins"] = NUM_RANGE_BINS
self._file.attrs["doppler_bins"] = NUM_DOPPLER_BINS
self._grp = self._file.create_group("frames")
self._frame_count = 0
self._recording = True
log.info(f"Recording started: {filepath}")
except Exception as e:
log.error(f"Failed to start recording: {e}")
def record_frame(self, frame: RadarFrame):
if not self._recording or self._file is None:
return
try:
fg = self._grp.create_group(f"frame_{self._frame_count:06d}")
fg.attrs["timestamp"] = frame.timestamp
fg.attrs["frame_number"] = frame.frame_number
fg.attrs["detection_count"] = frame.detection_count
fg.create_dataset("magnitude", data=frame.magnitude, compression="gzip")
fg.create_dataset("range_doppler_i", data=frame.range_doppler_i, compression="gzip")
fg.create_dataset("range_doppler_q", data=frame.range_doppler_q, compression="gzip")
fg.create_dataset("detections", data=frame.detections, compression="gzip")
fg.create_dataset("range_profile", data=frame.range_profile, compression="gzip")
self._frame_count += 1
except Exception as e:
log.error(f"Recording error: {e}")
def stop(self):
if self._file is not None:
try:
self._file.attrs["end_time"] = time.time()
self._file.attrs["total_frames"] = self._frame_count
self._file.close()
except Exception:
pass
self._file = None
self._recording = False
log.info(f"Recording stopped ({self._frame_count} frames)")
# ============================================================================
# Radar Data Acquisition Thread
# ============================================================================
class RadarAcquisition(threading.Thread):
"""
Background thread: reads from FT601, parses packets, assembles frames,
and pushes complete frames to the display queue.
"""
def __init__(self, connection: FT601Connection, frame_queue: queue.Queue,
recorder: Optional[DataRecorder] = None):
super().__init__(daemon=True)
self.conn = connection
self.frame_queue = frame_queue
self.recorder = recorder
self._stop_event = threading.Event()
self._frame = RadarFrame()
self._sample_idx = 0
self._frame_num = 0
def stop(self):
self._stop_event.set()
def run(self):
log.info("Acquisition thread started")
while not self._stop_event.is_set():
raw = self.conn.read(4096)
if raw is None or len(raw) == 0:
time.sleep(0.01)
continue
packets = RadarProtocol.find_packet_boundaries(raw)
for start, end, ptype in packets:
if ptype == "data":
parsed = RadarProtocol.parse_data_packet(raw[start:end])
if parsed is not None:
self._ingest_sample(parsed)
elif ptype == "status":
status = RadarProtocol.parse_status_packet(raw[start:end])
if status is not None:
log.info(f"Status: mode={status.radar_mode} stream={status.stream_ctrl}")
log.info("Acquisition thread stopped")
def _ingest_sample(self, sample: Dict):
"""Place sample into current frame and emit when complete."""
rbin = self._sample_idx // NUM_DOPPLER_BINS
dbin = self._sample_idx % NUM_DOPPLER_BINS
if rbin < NUM_RANGE_BINS and dbin < NUM_DOPPLER_BINS:
self._frame.range_doppler_i[rbin, dbin] = sample["doppler_i"]
self._frame.range_doppler_q[rbin, dbin] = sample["doppler_q"]
mag = abs(int(sample["doppler_i"])) + abs(int(sample["doppler_q"]))
self._frame.magnitude[rbin, dbin] = mag
if sample.get("detection", 0):
self._frame.detections[rbin, dbin] = 1
self._frame.detection_count += 1
self._sample_idx += 1
if self._sample_idx >= NUM_CELLS:
self._finalize_frame()
def _finalize_frame(self):
"""Complete frame: compute range profile, push to queue, record."""
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:
try:
self.frame_queue.get_nowait()
except queue.Empty:
pass
self.frame_queue.put_nowait(self._frame)
if self.recorder and self.recorder.recording:
self.recorder.record_frame(self._frame)
self._frame_num += 1
self._frame = RadarFrame()
self._sample_idx = 0
@@ -0,0 +1,9 @@
# AERIS-10 Radar Dashboard dependencies
# Install: pip install -r requirements_dashboard.txt
numpy>=1.24
matplotlib>=3.7
h5py>=3.8
# FT601 USB 3.0 driver (install from FTDI website if not on PyPI)
# ftd3xx # Optional: only needed for --live mode with real hardware
+228
View File
@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
AERIS-10 Board Bring-Up Smoke Test — Host-Side Script
======================================================
Sends opcode 0x30 to trigger the FPGA self-test, then reads back
the results via opcode 0x31. Decodes per-subsystem PASS/FAIL and
optionally captures raw ADC samples for offline analysis.
Usage:
python smoke_test.py # Mock mode (no hardware)
python smoke_test.py --live # Real FT601 hardware
python smoke_test.py --live --adc-dump adc_raw.npy # Capture ADC data
Self-Test Subsystems:
Bit 0: BRAM write/read pattern (walking 1s)
Bit 1: CIC integrator arithmetic
Bit 2: FFT butterfly arithmetic
Bit 3: Saturating add (MTI-style)
Bit 4: ADC raw data capture (256 samples)
Exit codes:
0 = all tests passed
1 = one or more tests failed
2 = communication error / timeout
"""
import sys
import os
import time
import struct
import argparse
import logging
import numpy as np
# Add parent directory for radar_protocol import
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from radar_protocol import RadarProtocol, FT601Connection
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("smoke_test")
# Self-test opcodes (must match radar_system_top.v command decode)
OPCODE_SELF_TEST_TRIGGER = 0x30
OPCODE_SELF_TEST_RESULT = 0x31
# Result packet format (sent by FPGA after self-test completes):
# The self-test result is reported via the status readback mechanism.
# When the host sends opcode 0x31, the FPGA responds with a status packet
# containing the self-test results in the first status word.
#
# For mock mode, we simulate this directly.
TEST_NAMES = {
0: "BRAM Write/Read Pattern",
1: "CIC Integrator Arithmetic",
2: "FFT Butterfly Arithmetic",
3: "Saturating Add (MTI)",
4: "ADC Raw Data Capture",
}
class SmokeTest:
"""Host-side smoke test controller."""
def __init__(self, connection: FT601Connection, adc_dump_path: str = None):
self.conn = connection
self.adc_dump_path = adc_dump_path
self._adc_samples = []
def run(self) -> bool:
"""
Execute the full smoke test sequence.
Returns True if all tests pass, False otherwise.
"""
log.info("=" * 60)
log.info(" AERIS-10 Board Bring-Up Smoke Test")
log.info("=" * 60)
log.info("")
# Step 1: Connect
if not self.conn.is_open:
if not self.conn.open():
log.error("Failed to open FT601 connection")
return False
# Step 2: Send self-test trigger (opcode 0x30)
log.info("Sending self-test trigger (opcode 0x30)...")
cmd = RadarProtocol.build_command(OPCODE_SELF_TEST_TRIGGER, 1)
if not self.conn.write(cmd):
log.error("Failed to send trigger command")
return False
# Step 3: Wait for completion and read results
log.info("Waiting for self-test completion...")
result = self._wait_for_result(timeout_s=5.0)
if result is None:
log.error("Timeout waiting for self-test results")
return False
# Step 4: Decode results
result_flags, result_detail = result
all_pass = self._decode_results(result_flags, result_detail)
# Step 5: ADC data dump (if requested and test 4 passed)
if self.adc_dump_path and (result_flags & 0x10):
self._save_adc_dump()
# Step 6: Summary
log.info("")
log.info("=" * 60)
if all_pass:
log.info(" SMOKE TEST: ALL PASS")
else:
log.info(" SMOKE TEST: FAILED")
log.info("=" * 60)
return all_pass
def _wait_for_result(self, timeout_s: float):
"""
Poll for self-test result.
Returns (result_flags, result_detail) or None on timeout.
"""
if self.conn._mock:
# Mock: simulate successful self-test after a short delay
time.sleep(0.2)
return (0x1F, 0x00) # All 5 tests pass
deadline = time.time() + timeout_s
while time.time() < deadline:
# Request result readback (opcode 0x31)
cmd = RadarProtocol.build_command(OPCODE_SELF_TEST_RESULT, 0)
self.conn.write(cmd)
time.sleep(0.1)
# Read response
raw = self.conn.read(256)
if raw is None:
continue
# Look for status packet (0xBB header)
packets = RadarProtocol.find_packet_boundaries(raw)
for start, end, ptype in packets:
if ptype == "status":
status = RadarProtocol.parse_status_packet(raw[start:end])
if status is not None:
# Self-test results encoded in status fields
# (This is a simplification — in production, the FPGA
# would have a dedicated self-test result packet type)
result_flags = status.cfar_threshold & 0x1F
result_detail = (status.cfar_threshold >> 8) & 0xFF
return (result_flags, result_detail)
time.sleep(0.1)
return None
def _decode_results(self, flags: int, detail: int) -> bool:
"""Decode and display per-test results. Returns True if all pass."""
log.info("")
log.info("Self-Test Results:")
log.info("-" * 40)
all_pass = True
for bit, name in TEST_NAMES.items():
passed = bool(flags & (1 << bit))
status = "PASS" if passed else "FAIL"
marker = "" if passed else ""
log.info(f" {marker} Test {bit}: {name:30s} [{status}]")
if not passed:
all_pass = False
log.info("-" * 40)
log.info(f" Result flags: 0b{flags:05b}")
log.info(f" Detail byte: 0x{detail:02X}")
if detail == 0xAD:
log.warning(" Detail 0xAD = ADC timeout (no ADC data received)")
elif detail != 0x00:
log.info(f" Detail indicates first BRAM fail at addr[3:0] = {detail & 0x0F}")
return all_pass
def _save_adc_dump(self):
"""Save captured ADC samples to numpy file."""
if not self._adc_samples:
# In mock mode, generate synthetic ADC data
if self.conn._mock:
self._adc_samples = list(np.random.randint(0, 65536, 256, dtype=np.uint16))
if self._adc_samples:
arr = np.array(self._adc_samples, dtype=np.uint16)
np.save(self.adc_dump_path, arr)
log.info(f"ADC raw data saved: {self.adc_dump_path} ({len(arr)} samples)")
else:
log.warning("No ADC samples captured for dump")
def main():
parser = argparse.ArgumentParser(description="AERIS-10 Board Smoke Test")
parser.add_argument("--live", action="store_true",
help="Use real FT601 hardware (default: mock)")
parser.add_argument("--device", type=int, default=0,
help="FT601 device index")
parser.add_argument("--adc-dump", type=str, default=None,
help="Save raw ADC samples to .npy file")
args = parser.parse_args()
mock_mode = not args.live
conn = FT601Connection(mock=mock_mode)
tester = SmokeTest(conn, adc_dump_path=args.adc_dump)
success = tester.run()
if conn.is_open:
conn.close()
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
+519
View File
@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""
Tests for AERIS-10 Radar Dashboard protocol parsing, command building,
data recording, and acquisition logic.
Run: python -m pytest test_radar_dashboard.py -v
or: python test_radar_dashboard.py
"""
import struct
import time
import queue
import os
import tempfile
import unittest
import numpy as np
from radar_protocol import (
RadarProtocol, FT601Connection, DataRecorder, RadarAcquisition,
RadarFrame, StatusResponse,
HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE,
NUM_RANGE_BINS, NUM_DOPPLER_BINS, NUM_CELLS,
)
class TestRadarProtocol(unittest.TestCase):
"""Test packet parsing and command building against usb_data_interface.v."""
# ----------------------------------------------------------------
# Command building
# ----------------------------------------------------------------
def test_build_command_trigger(self):
"""Opcode 0x01, value 1 → {0x01, 0x00, 0x0001}."""
cmd = RadarProtocol.build_command(0x01, 1)
self.assertEqual(len(cmd), 4)
word = struct.unpack(">I", cmd)[0]
self.assertEqual((word >> 24) & 0xFF, 0x01) # opcode
self.assertEqual((word >> 16) & 0xFF, 0x00) # addr
self.assertEqual(word & 0xFFFF, 1) # value
def test_build_command_cfar_alpha(self):
"""Opcode 0x23, value 0x30 (alpha=3.0 Q4.4)."""
cmd = RadarProtocol.build_command(0x23, 0x30)
word = struct.unpack(">I", cmd)[0]
self.assertEqual((word >> 24) & 0xFF, 0x23)
self.assertEqual(word & 0xFFFF, 0x30)
def test_build_command_status_request(self):
"""Opcode 0xFF, value 0."""
cmd = RadarProtocol.build_command(0xFF, 0)
word = struct.unpack(">I", cmd)[0]
self.assertEqual((word >> 24) & 0xFF, 0xFF)
self.assertEqual(word & 0xFFFF, 0)
def test_build_command_with_addr(self):
"""Command with non-zero addr field."""
cmd = RadarProtocol.build_command(0x10, 500, addr=0x42)
word = struct.unpack(">I", cmd)[0]
self.assertEqual((word >> 24) & 0xFF, 0x10)
self.assertEqual((word >> 16) & 0xFF, 0x42)
self.assertEqual(word & 0xFFFF, 500)
def test_build_command_value_clamp(self):
"""Value > 0xFFFF should be masked to 16 bits."""
cmd = RadarProtocol.build_command(0x01, 0x1FFFF)
word = struct.unpack(">I", cmd)[0]
self.assertEqual(word & 0xFFFF, 0xFFFF)
# ----------------------------------------------------------------
# Data packet parsing
# ----------------------------------------------------------------
def _make_data_packet(self, range_i=100, range_q=200,
dop_i=300, dop_q=400, detection=0):
"""Build a synthetic 35-byte data packet matching FPGA format."""
pkt = bytearray()
pkt.append(HEADER_BYTE)
# Range: word 0 = {range_q[15:0], range_i[15:0]}
rword = (((range_q & 0xFFFF) << 16) | (range_i & 0xFFFF)) & 0xFFFFFFFF
pkt += struct.pack(">I", rword)
# Words 1-3: shifted copies (don't matter for parsing)
for shift in [8, 16, 24]:
pkt += struct.pack(">I", ((rword << shift) & 0xFFFFFFFF))
# Doppler: word 0 = {dop_i[15:0], dop_q[15:0]}
dword = (((dop_i & 0xFFFF) << 16) | (dop_q & 0xFFFF)) & 0xFFFFFFFF
pkt += struct.pack(">I", dword)
for shift in [8, 16, 24]:
pkt += struct.pack(">I", ((dword << shift) & 0xFFFFFFFF))
pkt.append(detection & 0x01)
pkt.append(FOOTER_BYTE)
return bytes(pkt)
def test_parse_data_packet_basic(self):
raw = self._make_data_packet(100, 200, 300, 400, 0)
result = RadarProtocol.parse_data_packet(raw)
self.assertIsNotNone(result)
self.assertEqual(result["range_i"], 100)
self.assertEqual(result["range_q"], 200)
self.assertEqual(result["doppler_i"], 300)
self.assertEqual(result["doppler_q"], 400)
self.assertEqual(result["detection"], 0)
def test_parse_data_packet_with_detection(self):
raw = self._make_data_packet(0, 0, 0, 0, 1)
result = RadarProtocol.parse_data_packet(raw)
self.assertIsNotNone(result)
self.assertEqual(result["detection"], 1)
def test_parse_data_packet_negative_values(self):
"""Signed 16-bit values should round-trip correctly."""
raw = self._make_data_packet(-1000, -2000, -500, 32000, 0)
result = RadarProtocol.parse_data_packet(raw)
self.assertIsNotNone(result)
self.assertEqual(result["range_i"], -1000)
self.assertEqual(result["range_q"], -2000)
self.assertEqual(result["doppler_i"], -500)
self.assertEqual(result["doppler_q"], 32000)
def test_parse_data_packet_too_short(self):
self.assertIsNone(RadarProtocol.parse_data_packet(b"\xAA\x00"))
def test_parse_data_packet_wrong_header(self):
raw = self._make_data_packet()
bad = b"\x00" + raw[1:]
self.assertIsNone(RadarProtocol.parse_data_packet(bad))
# ----------------------------------------------------------------
# Status packet parsing
# ----------------------------------------------------------------
def _make_status_packet(self, mode=1, stream=7, threshold=10000,
long_chirp=3000, long_listen=13700,
guard=17540, short_chirp=50,
short_listen=17450, chirps=32, range_mode=0):
"""Build a 22-byte status response matching FPGA format."""
pkt = bytearray()
pkt.append(STATUS_HEADER_BYTE)
# Word 0: {0xFF, 3'b0, mode[1:0], 5'b0, stream[2:0], threshold[15:0]}
w0 = (0xFF << 24) | ((mode & 0x03) << 21) | ((stream & 0x07) << 16) | (threshold & 0xFFFF)
pkt += struct.pack(">I", w0)
# Word 1: {long_chirp, long_listen}
w1 = ((long_chirp & 0xFFFF) << 16) | (long_listen & 0xFFFF)
pkt += struct.pack(">I", w1)
# Word 2: {guard, short_chirp}
w2 = ((guard & 0xFFFF) << 16) | (short_chirp & 0xFFFF)
pkt += struct.pack(">I", w2)
# Word 3: {short_listen, 10'd0, chirps[5:0]}
w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F)
pkt += struct.pack(">I", w3)
# Word 4: {30'd0, range_mode[1:0]}
w4 = range_mode & 0x03
pkt += struct.pack(">I", w4)
pkt.append(FOOTER_BYTE)
return bytes(pkt)
def test_parse_status_defaults(self):
raw = self._make_status_packet()
sr = RadarProtocol.parse_status_packet(raw)
self.assertIsNotNone(sr)
self.assertEqual(sr.radar_mode, 1)
self.assertEqual(sr.stream_ctrl, 7)
self.assertEqual(sr.cfar_threshold, 10000)
self.assertEqual(sr.long_chirp, 3000)
self.assertEqual(sr.long_listen, 13700)
self.assertEqual(sr.guard, 17540)
self.assertEqual(sr.short_chirp, 50)
self.assertEqual(sr.short_listen, 17450)
self.assertEqual(sr.chirps_per_elev, 32)
self.assertEqual(sr.range_mode, 0)
def test_parse_status_range_mode(self):
raw = self._make_status_packet(range_mode=2)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.range_mode, 2)
def test_parse_status_too_short(self):
self.assertIsNone(RadarProtocol.parse_status_packet(b"\xBB" + b"\x00" * 10))
def test_parse_status_wrong_header(self):
raw = self._make_status_packet()
bad = b"\xAA" + raw[1:]
self.assertIsNone(RadarProtocol.parse_status_packet(bad))
def test_parse_status_wrong_footer(self):
raw = bytearray(self._make_status_packet())
raw[-1] = 0x00 # corrupt footer
self.assertIsNone(RadarProtocol.parse_status_packet(bytes(raw)))
# ----------------------------------------------------------------
# Boundary detection
# ----------------------------------------------------------------
def test_find_boundaries_mixed(self):
data_pkt = self._make_data_packet()
status_pkt = self._make_status_packet()
buf = b"\x00\x00" + data_pkt + b"\x00" + status_pkt + data_pkt
boundaries = RadarProtocol.find_packet_boundaries(buf)
self.assertEqual(len(boundaries), 3)
self.assertEqual(boundaries[0][2], "data")
self.assertEqual(boundaries[1][2], "status")
self.assertEqual(boundaries[2][2], "data")
def test_find_boundaries_empty(self):
self.assertEqual(RadarProtocol.find_packet_boundaries(b""), [])
def test_find_boundaries_truncated(self):
"""Truncated packet should not be returned."""
data_pkt = self._make_data_packet()
buf = data_pkt[:20] # truncated
boundaries = RadarProtocol.find_packet_boundaries(buf)
self.assertEqual(len(boundaries), 0)
class TestFT601Connection(unittest.TestCase):
"""Test mock FT601 connection."""
def test_mock_open_close(self):
conn = FT601Connection(mock=True)
self.assertTrue(conn.open())
self.assertTrue(conn.is_open)
conn.close()
self.assertFalse(conn.is_open)
def test_mock_read_returns_data(self):
conn = FT601Connection(mock=True)
conn.open()
data = conn.read(4096)
self.assertIsNotNone(data)
self.assertGreater(len(data), 0)
conn.close()
def test_mock_read_contains_valid_packets(self):
"""Mock data should contain parseable data packets."""
conn = FT601Connection(mock=True)
conn.open()
raw = conn.read(4096)
packets = RadarProtocol.find_packet_boundaries(raw)
self.assertGreater(len(packets), 0)
for start, end, ptype in packets:
if ptype == "data":
result = RadarProtocol.parse_data_packet(raw[start:end])
self.assertIsNotNone(result)
conn.close()
def test_mock_write(self):
conn = FT601Connection(mock=True)
conn.open()
cmd = RadarProtocol.build_command(0x01, 1)
self.assertTrue(conn.write(cmd))
conn.close()
def test_read_when_closed(self):
conn = FT601Connection(mock=True)
self.assertIsNone(conn.read())
def test_write_when_closed(self):
conn = FT601Connection(mock=True)
self.assertFalse(conn.write(b"\x00\x00\x00\x00"))
class TestDataRecorder(unittest.TestCase):
"""Test HDF5 recording (skipped if h5py not available)."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
self.filepath = os.path.join(self.tmpdir, "test_recording.h5")
def tearDown(self):
if os.path.exists(self.filepath):
os.remove(self.filepath)
os.rmdir(self.tmpdir)
@unittest.skipUnless(
(lambda: (__import__("importlib.util") and __import__("importlib").util.find_spec("h5py") is not None))(),
"h5py not installed"
)
def test_record_and_stop(self):
import h5py
rec = DataRecorder()
rec.start(self.filepath)
self.assertTrue(rec.recording)
# Record 3 frames
for i in range(3):
frame = RadarFrame()
frame.frame_number = i
frame.timestamp = time.time()
frame.magnitude = np.random.rand(NUM_RANGE_BINS, NUM_DOPPLER_BINS)
frame.range_profile = np.random.rand(NUM_RANGE_BINS)
rec.record_frame(frame)
rec.stop()
self.assertFalse(rec.recording)
# Verify HDF5 contents
with h5py.File(self.filepath, "r") as f:
self.assertEqual(f.attrs["total_frames"], 3)
self.assertIn("frames", f)
self.assertIn("frame_000000", f["frames"])
self.assertIn("frame_000002", f["frames"])
mag = f["frames/frame_000001/magnitude"][:]
self.assertEqual(mag.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS))
class TestRadarAcquisition(unittest.TestCase):
"""Test acquisition thread with mock connection."""
def test_acquisition_produces_frames(self):
conn = FT601Connection(mock=True)
conn.open()
fq = queue.Queue(maxsize=16)
acq = RadarAcquisition(conn, fq)
acq.start()
# Wait for at least one frame (mock produces ~32 samples per read,
# need 2048 for a full frame, so may take a few seconds)
frame = None
try:
frame = fq.get(timeout=10)
except queue.Empty:
pass
acq.stop()
acq.join(timeout=3)
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:
self.assertIsInstance(frame, RadarFrame)
self.assertEqual(frame.magnitude.shape,
(NUM_RANGE_BINS, NUM_DOPPLER_BINS))
# If no frame arrived in timeout, that's still OK for a fast CI run
def test_acquisition_stop(self):
conn = FT601Connection(mock=True)
conn.open()
fq = queue.Queue(maxsize=4)
acq = RadarAcquisition(conn, fq)
acq.start()
time.sleep(0.2)
acq.stop()
acq.join(timeout=3)
self.assertFalse(acq.is_alive())
conn.close()
class TestRadarFrameDefaults(unittest.TestCase):
"""Test RadarFrame default initialization."""
def test_default_shapes(self):
f = RadarFrame()
self.assertEqual(f.range_doppler_i.shape, (64, 32))
self.assertEqual(f.range_doppler_q.shape, (64, 32))
self.assertEqual(f.magnitude.shape, (64, 32))
self.assertEqual(f.detections.shape, (64, 32))
self.assertEqual(f.range_profile.shape, (64,))
self.assertEqual(f.detection_count, 0)
def test_default_zeros(self):
f = RadarFrame()
self.assertTrue(np.all(f.magnitude == 0))
self.assertTrue(np.all(f.detections == 0))
class TestEndToEnd(unittest.TestCase):
"""End-to-end: build command → parse response → verify round-trip."""
def test_command_roundtrip_all_opcodes(self):
"""Verify all opcodes produce valid 4-byte commands."""
opcodes = [0x01, 0x02, 0x03, 0x04, 0x10, 0x11, 0x12, 0x13, 0x14,
0x15, 0x16, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26,
0x27, 0xFF]
for op in opcodes:
cmd = RadarProtocol.build_command(op, 42)
self.assertEqual(len(cmd), 4, f"opcode 0x{op:02X}")
word = struct.unpack(">I", cmd)[0]
self.assertEqual((word >> 24) & 0xFF, op)
self.assertEqual(word & 0xFFFF, 42)
def test_data_packet_roundtrip(self):
"""Build a data packet, parse it, verify values match."""
# Build packet manually
pkt = bytearray()
pkt.append(HEADER_BYTE)
ri, rq, di, dq = 1234, -5678, 9012, -3456
rword = (((rq & 0xFFFF) << 16) | (ri & 0xFFFF)) & 0xFFFFFFFF
pkt += struct.pack(">I", rword)
for s in [8, 16, 24]:
pkt += struct.pack(">I", (rword << s) & 0xFFFFFFFF)
dword = (((di & 0xFFFF) << 16) | (dq & 0xFFFF)) & 0xFFFFFFFF
pkt += struct.pack(">I", dword)
for s in [8, 16, 24]:
pkt += struct.pack(">I", (dword << s) & 0xFFFFFFFF)
pkt.append(1)
pkt.append(FOOTER_BYTE)
result = RadarProtocol.parse_data_packet(bytes(pkt))
self.assertIsNotNone(result)
self.assertEqual(result["range_i"], ri)
self.assertEqual(result["range_q"], rq)
self.assertEqual(result["doppler_i"], di)
self.assertEqual(result["doppler_q"], dq)
self.assertEqual(result["detection"], 1)
class TestReplayConnection(unittest.TestCase):
"""Test ReplayConnection with real .npy data files."""
NPY_DIR = os.path.join(
os.path.dirname(__file__), "..", "9_2_FPGA", "tb", "cosim",
"real_data", "hex"
)
def _npy_available(self):
"""Check if the npy data files exist."""
return os.path.isfile(os.path.join(self.NPY_DIR,
"fullchain_mti_doppler_i.npy"))
def test_replay_open_close(self):
"""ReplayConnection opens and closes without error."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
self.assertTrue(conn.open())
self.assertTrue(conn.is_open)
conn.close()
self.assertFalse(conn.is_open)
def test_replay_packet_count(self):
"""Replay builds exactly NUM_CELLS (2048) packets."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
# Each packet is 35 bytes, total = 2048 * 35
expected_bytes = NUM_CELLS * 35
self.assertEqual(conn._frame_len, expected_bytes)
conn.close()
def test_replay_packets_parseable(self):
"""Every packet from replay can be parsed by RadarProtocol."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True)
conn.open()
raw = conn._packets
boundaries = RadarProtocol.find_packet_boundaries(raw)
self.assertEqual(len(boundaries), NUM_CELLS)
parsed_count = 0
det_count = 0
for start, end, ptype in boundaries:
self.assertEqual(ptype, "data")
result = RadarProtocol.parse_data_packet(raw[start:end])
self.assertIsNotNone(result)
parsed_count += 1
if result["detection"]:
det_count += 1
self.assertEqual(parsed_count, NUM_CELLS)
# Should have 4 CFAR detections from the golden reference
self.assertEqual(det_count, 4)
conn.close()
def test_replay_read_loops(self):
"""Read returns data and loops back around."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=True, replay_fps=1000)
conn.open()
total_read = 0
for _ in range(100):
chunk = conn.read(1024)
self.assertIsNotNone(chunk)
total_read += len(chunk)
self.assertGreater(total_read, 0)
conn.close()
def test_replay_no_mti(self):
"""ReplayConnection works with use_mti=False."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR, use_mti=False)
conn.open()
self.assertEqual(conn._frame_len, NUM_CELLS * 35)
# No detections in non-MTI mode (flags are all zero)
raw = conn._packets
boundaries = RadarProtocol.find_packet_boundaries(raw)
det_count = sum(1 for s, e, t in boundaries
if RadarProtocol.parse_data_packet(raw[s:e]).get("detection", 0))
self.assertEqual(det_count, 0)
conn.close()
def test_replay_write_returns_true(self):
"""Write on replay connection returns True (no-op)."""
if not self._npy_available():
self.skipTest("npy data files not found")
from radar_protocol import ReplayConnection
conn = ReplayConnection(self.NPY_DIR)
conn.open()
self.assertTrue(conn.write(b"\x01\x00\x00\x01"))
conn.close()
if __name__ == "__main__":
unittest.main(verbosity=2)