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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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 1–3 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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user