feat: unified replay with SoftwareFPGA bit-accurate signal chain
Add SoftwareFPGA class that imports golden_reference functions to replicate the FPGA pipeline in software, enabling bit-accurate replay of raw IQ, FPGA co-sim, and HDF5 recordings through the same dashboard path as live data. New modules: software_fpga.py, replay.py (ReplayEngine + 3 loaders) Enhanced: WaveformConfig model, extract_targets_from_frame() in processing, ReplayWorker with thread-safe playback controls, dashboard replay UI with transport controls and dual-dispatch FPGA parameter routing. Removed: ReplayConnection (from radar_protocol, hardware, dashboard, tests) — replaced by the unified replay architecture. 150/150 tests pass, ruff clean.
This commit is contained in:
@@ -14,6 +14,7 @@ from .models import (
|
||||
GPSData,
|
||||
ProcessingConfig,
|
||||
TileServer,
|
||||
WaveformConfig,
|
||||
DARK_BG, DARK_FG, DARK_ACCENT, DARK_HIGHLIGHT, DARK_BORDER,
|
||||
DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER,
|
||||
DARK_TREEVIEW, DARK_TREEVIEW_ALT,
|
||||
@@ -25,7 +26,6 @@ from .models import (
|
||||
# Hardware interfaces — production protocol via radar_protocol.py
|
||||
from .hardware import (
|
||||
FT2232HConnection,
|
||||
ReplayConnection,
|
||||
RadarProtocol,
|
||||
Opcode,
|
||||
RadarAcquisition,
|
||||
@@ -40,8 +40,22 @@ from .processing import (
|
||||
RadarProcessor,
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
polar_to_geographic,
|
||||
extract_targets_from_frame,
|
||||
)
|
||||
|
||||
# Software FPGA (depends on golden_reference.py in FPGA cosim tree)
|
||||
try: # noqa: SIM105
|
||||
from .software_fpga import SoftwareFPGA, quantize_raw_iq
|
||||
except ImportError: # golden_reference.py not available (e.g. deployment without FPGA tree)
|
||||
pass
|
||||
|
||||
# Replay engine (no PyQt6 dependency, but needs SoftwareFPGA for raw IQ path)
|
||||
try: # noqa: SIM105
|
||||
from .replay import ReplayEngine, ReplayFormat
|
||||
except ImportError: # software_fpga unavailable → replay also unavailable
|
||||
pass
|
||||
|
||||
# Workers, map widget, and dashboard require PyQt6 — import lazily so that
|
||||
# tests/CI environments without PyQt6 can still access models/hardware/processing.
|
||||
try:
|
||||
@@ -49,7 +63,7 @@ try:
|
||||
RadarDataWorker,
|
||||
GPSDataWorker,
|
||||
TargetSimulator,
|
||||
polar_to_geographic,
|
||||
ReplayWorker,
|
||||
)
|
||||
|
||||
from .map_widget import (
|
||||
@@ -67,6 +81,7 @@ except ImportError: # PyQt6 not installed (e.g. CI headless runner)
|
||||
__all__ = [ # noqa: RUF022
|
||||
# models
|
||||
"RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
|
||||
"WaveformConfig",
|
||||
"DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
|
||||
"DARK_TEXT", "DARK_BUTTON", "DARK_BUTTON_HOVER",
|
||||
"DARK_TREEVIEW", "DARK_TREEVIEW_ALT",
|
||||
@@ -74,15 +89,18 @@ __all__ = [ # noqa: RUF022
|
||||
"USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE",
|
||||
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE",
|
||||
# hardware — production FPGA protocol
|
||||
"FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode",
|
||||
"FT2232HConnection", "RadarProtocol", "Opcode",
|
||||
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
|
||||
"STM32USBInterface",
|
||||
# processing
|
||||
"RadarProcessor", "USBPacketParser",
|
||||
"apply_pitch_correction",
|
||||
"apply_pitch_correction", "polar_to_geographic",
|
||||
"extract_targets_from_frame",
|
||||
# software FPGA + replay
|
||||
"SoftwareFPGA", "quantize_raw_iq",
|
||||
"ReplayEngine", "ReplayFormat",
|
||||
# workers
|
||||
"RadarDataWorker", "GPSDataWorker", "TargetSimulator",
|
||||
"polar_to_geographic",
|
||||
"RadarDataWorker", "GPSDataWorker", "TargetSimulator", "ReplayWorker",
|
||||
# map
|
||||
"MapBridge", "RadarMapWidget",
|
||||
# dashboard
|
||||
|
||||
@@ -14,7 +14,7 @@ RadarDashboard is a QMainWindow with six tabs:
|
||||
|
||||
Uses production radar_protocol.py for all FPGA communication:
|
||||
- FT2232HConnection for real hardware
|
||||
- ReplayConnection for offline .npy replay
|
||||
- Unified replay via SoftwareFPGA + ReplayEngine + ReplayWorker
|
||||
- Mock mode (FT2232HConnection(mock=True)) for development
|
||||
|
||||
The old STM32 magic-packet start flow has been removed. FPGA registers
|
||||
@@ -22,9 +22,12 @@ are controlled directly via 4-byte {opcode, addr, value_hi, value_lo}
|
||||
commands sent over FT2232H.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging
|
||||
from collections import deque
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -32,7 +35,7 @@ from PyQt6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QTabWidget, QSplitter, QGroupBox, QFrame, QScrollArea,
|
||||
QLabel, QPushButton, QComboBox, QCheckBox,
|
||||
QDoubleSpinBox, QSpinBox, QLineEdit,
|
||||
QDoubleSpinBox, QSpinBox, QLineEdit, QSlider, QFileDialog,
|
||||
QTableWidget, QTableWidgetItem, QHeaderView,
|
||||
QPlainTextEdit, QStatusBar, QMessageBox,
|
||||
)
|
||||
@@ -52,7 +55,6 @@ from .models import (
|
||||
)
|
||||
from .hardware import (
|
||||
FT2232HConnection,
|
||||
ReplayConnection,
|
||||
RadarProtocol,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
@@ -60,9 +62,13 @@ from .hardware import (
|
||||
STM32USBInterface,
|
||||
)
|
||||
from .processing import RadarProcessor, USBPacketParser
|
||||
from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator
|
||||
from .workers import RadarDataWorker, GPSDataWorker, TargetSimulator, ReplayWorker
|
||||
from .map_widget import RadarMapWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .software_fpga import SoftwareFPGA
|
||||
from .replay import ReplayEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Frame dimensions from FPGA
|
||||
@@ -153,6 +159,12 @@ class RadarDashboard(QMainWindow):
|
||||
self._gps_worker: GPSDataWorker | None = None
|
||||
self._simulator: TargetSimulator | None = None
|
||||
|
||||
# Replay-specific objects (created when entering replay mode)
|
||||
self._replay_worker: ReplayWorker | None = None
|
||||
self._replay_engine: ReplayEngine | None = None
|
||||
self._software_fpga: SoftwareFPGA | None = None
|
||||
self._replay_mode = False
|
||||
|
||||
# State
|
||||
self._running = False
|
||||
self._demo_mode = False
|
||||
@@ -352,7 +364,7 @@ class RadarDashboard(QMainWindow):
|
||||
# Row 0: connection mode + device combos + buttons
|
||||
ctrl_layout.addWidget(QLabel("Mode:"), 0, 0)
|
||||
self._mode_combo = QComboBox()
|
||||
self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay (.npy)"])
|
||||
self._mode_combo.addItems(["Mock", "Live FT2232H", "Replay"])
|
||||
self._mode_combo.setCurrentIndex(0)
|
||||
ctrl_layout.addWidget(self._mode_combo, 0, 1)
|
||||
|
||||
@@ -401,6 +413,55 @@ class RadarDashboard(QMainWindow):
|
||||
self._status_label_main.setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
ctrl_layout.addWidget(self._status_label_main, 1, 5, 1, 5)
|
||||
|
||||
# Row 2: replay transport controls (hidden until replay mode)
|
||||
self._replay_file_label = QLabel("No file loaded")
|
||||
self._replay_file_label.setMinimumWidth(200)
|
||||
ctrl_layout.addWidget(self._replay_file_label, 2, 0, 1, 2)
|
||||
|
||||
self._replay_browse_btn = QPushButton("Browse...")
|
||||
self._replay_browse_btn.clicked.connect(self._browse_replay_file)
|
||||
ctrl_layout.addWidget(self._replay_browse_btn, 2, 2)
|
||||
|
||||
self._replay_play_btn = QPushButton("Play")
|
||||
self._replay_play_btn.clicked.connect(self._replay_play_pause)
|
||||
ctrl_layout.addWidget(self._replay_play_btn, 2, 3)
|
||||
|
||||
self._replay_stop_btn = QPushButton("Stop")
|
||||
self._replay_stop_btn.clicked.connect(self._replay_stop)
|
||||
ctrl_layout.addWidget(self._replay_stop_btn, 2, 4)
|
||||
|
||||
self._replay_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self._replay_slider.setMinimum(0)
|
||||
self._replay_slider.setMaximum(0)
|
||||
self._replay_slider.valueChanged.connect(self._replay_seek)
|
||||
ctrl_layout.addWidget(self._replay_slider, 2, 5, 1, 2)
|
||||
|
||||
self._replay_frame_label = QLabel("0 / 0")
|
||||
ctrl_layout.addWidget(self._replay_frame_label, 2, 7)
|
||||
|
||||
self._replay_speed_combo = QComboBox()
|
||||
self._replay_speed_combo.addItems(["50 ms", "100 ms", "200 ms", "500 ms"])
|
||||
self._replay_speed_combo.setCurrentIndex(1)
|
||||
self._replay_speed_combo.currentIndexChanged.connect(self._replay_speed_changed)
|
||||
ctrl_layout.addWidget(self._replay_speed_combo, 2, 8)
|
||||
|
||||
self._replay_loop_cb = QCheckBox("Loop")
|
||||
self._replay_loop_cb.stateChanged.connect(self._replay_loop_changed)
|
||||
ctrl_layout.addWidget(self._replay_loop_cb, 2, 9)
|
||||
|
||||
# Collect replay widgets to toggle visibility
|
||||
self._replay_controls = [
|
||||
self._replay_file_label, self._replay_browse_btn,
|
||||
self._replay_play_btn, self._replay_stop_btn,
|
||||
self._replay_slider, self._replay_frame_label,
|
||||
self._replay_speed_combo, self._replay_loop_cb,
|
||||
]
|
||||
for w in self._replay_controls:
|
||||
w.setVisible(False)
|
||||
|
||||
# Show/hide replay row when mode changes
|
||||
self._mode_combo.currentTextChanged.connect(self._on_mode_changed)
|
||||
|
||||
layout.addWidget(ctrl)
|
||||
|
||||
# ---- Display area (range-doppler + targets table) ------------------
|
||||
@@ -1175,7 +1236,11 @@ class RadarDashboard(QMainWindow):
|
||||
logger.error(f"Failed to send FPGA cmd: 0x{opcode:02X}")
|
||||
|
||||
def _send_fpga_validated(self, opcode: int, value: int, bits: int):
|
||||
"""Clamp value to bit-width and send."""
|
||||
"""Clamp value to bit-width and send.
|
||||
|
||||
In replay mode, also dispatch to the SoftwareFPGA setter and
|
||||
re-process the current frame so the user sees immediate effect.
|
||||
"""
|
||||
max_val = (1 << bits) - 1
|
||||
clamped = max(0, min(value, max_val))
|
||||
if clamped != value:
|
||||
@@ -1185,7 +1250,18 @@ class RadarDashboard(QMainWindow):
|
||||
key = f"0x{opcode:02X}"
|
||||
if key in self._param_spins:
|
||||
self._param_spins[key].setValue(clamped)
|
||||
self._send_fpga_cmd(opcode, clamped)
|
||||
|
||||
# Dispatch to real FPGA (live/mock mode)
|
||||
if not self._replay_mode:
|
||||
self._send_fpga_cmd(opcode, clamped)
|
||||
return
|
||||
|
||||
# Dispatch to SoftwareFPGA (replay mode)
|
||||
if self._software_fpga is not None:
|
||||
self._dispatch_to_software_fpga(opcode, clamped)
|
||||
# Re-process current frame so the effect is visible immediately
|
||||
if self._replay_worker is not None:
|
||||
self._replay_worker.seek(self._replay_worker.current_index)
|
||||
|
||||
def _send_custom_command(self):
|
||||
"""Send custom opcode + value from the FPGA Control tab."""
|
||||
@@ -1210,32 +1286,104 @@ class RadarDashboard(QMainWindow):
|
||||
mode = self._mode_combo.currentText()
|
||||
|
||||
if "Mock" in mode:
|
||||
self._replay_mode = False
|
||||
self._connection = FT2232HConnection(mock=True)
|
||||
if not self._connection.open():
|
||||
QMessageBox.critical(self, "Error", "Failed to open mock connection.")
|
||||
return
|
||||
elif "Live" in mode:
|
||||
self._replay_mode = False
|
||||
self._connection = FT2232HConnection(mock=False)
|
||||
if not self._connection.open():
|
||||
QMessageBox.critical(self, "Error",
|
||||
"Failed to open FT2232H. Check USB connection.")
|
||||
return
|
||||
elif "Replay" in mode:
|
||||
from PyQt6.QtWidgets import QFileDialog
|
||||
npy_dir = QFileDialog.getExistingDirectory(
|
||||
self, "Select .npy replay directory")
|
||||
if not npy_dir:
|
||||
self._replay_mode = True
|
||||
replay_path = self._replay_file_label.text()
|
||||
if replay_path == "No file loaded" or not replay_path:
|
||||
QMessageBox.warning(
|
||||
self, "Replay",
|
||||
"Use 'Browse...' to select a replay"
|
||||
" file or directory first.")
|
||||
return
|
||||
self._connection = ReplayConnection(npy_dir)
|
||||
if not self._connection.open():
|
||||
QMessageBox.critical(self, "Error",
|
||||
"Failed to open replay connection.")
|
||||
|
||||
from .software_fpga import SoftwareFPGA
|
||||
from .replay import ReplayEngine
|
||||
|
||||
self._software_fpga = SoftwareFPGA()
|
||||
# Enable CFAR by default for raw IQ replay (avoids 2000+ detections)
|
||||
self._software_fpga.set_cfar_enable(True)
|
||||
|
||||
try:
|
||||
self._replay_engine = ReplayEngine(
|
||||
replay_path, self._software_fpga)
|
||||
except (OSError, ValueError, RuntimeError) as exc:
|
||||
QMessageBox.critical(self, "Replay Error",
|
||||
f"Failed to open replay data:\n{exc}")
|
||||
self._software_fpga = None
|
||||
return
|
||||
|
||||
if self._replay_engine.total_frames == 0:
|
||||
QMessageBox.warning(self, "Replay", "No frames found in the selected source.")
|
||||
self._replay_engine.close()
|
||||
self._replay_engine = None
|
||||
self._software_fpga = None
|
||||
return
|
||||
|
||||
speed_map = {0: 50, 1: 100, 2: 200, 3: 500}
|
||||
interval = speed_map.get(self._replay_speed_combo.currentIndex(), 100)
|
||||
|
||||
self._replay_worker = ReplayWorker(
|
||||
replay_engine=self._replay_engine,
|
||||
settings=self._settings,
|
||||
gps=self._radar_position,
|
||||
frame_interval_ms=interval,
|
||||
)
|
||||
self._replay_worker.frameReady.connect(self._on_frame_ready)
|
||||
self._replay_worker.targetsUpdated.connect(self._on_radar_targets)
|
||||
self._replay_worker.statsUpdated.connect(self._on_radar_stats)
|
||||
self._replay_worker.errorOccurred.connect(self._on_worker_error)
|
||||
self._replay_worker.playbackStateChanged.connect(
|
||||
self._on_playback_state_changed)
|
||||
self._replay_worker.frameIndexChanged.connect(
|
||||
self._on_frame_index_changed)
|
||||
self._replay_worker.set_loop(self._replay_loop_cb.isChecked())
|
||||
|
||||
self._replay_slider.setMaximum(
|
||||
self._replay_engine.total_frames - 1)
|
||||
self._replay_slider.setValue(0)
|
||||
self._replay_frame_label.setText(
|
||||
f"0 / {self._replay_engine.total_frames}")
|
||||
|
||||
self._replay_worker.start()
|
||||
# Update CFAR enable spinbox to reflect default-on for replay
|
||||
if "0x25" in self._param_spins:
|
||||
self._param_spins["0x25"].setValue(1)
|
||||
|
||||
# UI state
|
||||
self._running = True
|
||||
self._start_time = time.time()
|
||||
self._frame_count = 0
|
||||
self._start_btn.setEnabled(False)
|
||||
self._stop_btn.setEnabled(True)
|
||||
self._mode_combo.setEnabled(False)
|
||||
self._demo_btn_main.setEnabled(False)
|
||||
self._demo_btn_map.setEnabled(False)
|
||||
n_frames = self._replay_engine.total_frames
|
||||
self._status_label_main.setText(
|
||||
f"Status: Replay ({n_frames} frames)")
|
||||
self._sb_status.setText(f"Replay ({n_frames} frames)")
|
||||
self._sb_mode.setText("Replay")
|
||||
logger.info(
|
||||
"Replay started: %s (%d frames)",
|
||||
replay_path, n_frames)
|
||||
return
|
||||
else:
|
||||
QMessageBox.warning(self, "Warning", "Unknown connection mode.")
|
||||
return
|
||||
|
||||
# Start radar worker
|
||||
# Start radar worker (mock / live — NOT replay)
|
||||
self._radar_worker = RadarDataWorker(
|
||||
connection=self._connection,
|
||||
processor=self._processor,
|
||||
@@ -1288,6 +1436,18 @@ class RadarDashboard(QMainWindow):
|
||||
self._radar_worker.wait(2000)
|
||||
self._radar_worker = None
|
||||
|
||||
if self._replay_worker:
|
||||
self._replay_worker.stop()
|
||||
self._replay_worker.wait(2000)
|
||||
self._replay_worker = None
|
||||
|
||||
if self._replay_engine:
|
||||
self._replay_engine.close()
|
||||
self._replay_engine = None
|
||||
|
||||
self._software_fpga = None
|
||||
self._replay_mode = False
|
||||
|
||||
if self._gps_worker:
|
||||
self._gps_worker.stop()
|
||||
self._gps_worker.wait(2000)
|
||||
@@ -1309,6 +1469,113 @@ class RadarDashboard(QMainWindow):
|
||||
self._sb_mode.setText("Idle")
|
||||
logger.info("Radar system stopped")
|
||||
|
||||
# =====================================================================
|
||||
# Replay helpers
|
||||
# =====================================================================
|
||||
|
||||
def _on_mode_changed(self, text: str):
|
||||
"""Show/hide replay transport controls based on mode selection."""
|
||||
is_replay = "Replay" in text
|
||||
for w in self._replay_controls:
|
||||
w.setVisible(is_replay)
|
||||
|
||||
def _browse_replay_file(self):
|
||||
"""Open file/directory picker for replay source."""
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Select replay file",
|
||||
"",
|
||||
"All supported (*.npy *.h5);;NumPy files (*.npy);;HDF5 files (*.h5);;All files (*)",
|
||||
)
|
||||
if path:
|
||||
self._replay_file_label.setText(path)
|
||||
return
|
||||
# If no file selected, try directory (for co-sim)
|
||||
dir_path = QFileDialog.getExistingDirectory(
|
||||
self, "Select co-sim replay directory")
|
||||
if dir_path:
|
||||
self._replay_file_label.setText(dir_path)
|
||||
|
||||
def _replay_play_pause(self):
|
||||
"""Toggle play/pause on the replay worker."""
|
||||
if self._replay_worker is None:
|
||||
return
|
||||
if self._replay_worker.is_playing:
|
||||
self._replay_worker.pause()
|
||||
self._replay_play_btn.setText("Play")
|
||||
else:
|
||||
self._replay_worker.play()
|
||||
self._replay_play_btn.setText("Pause")
|
||||
|
||||
def _replay_stop(self):
|
||||
"""Stop replay playback (keeps data loaded)."""
|
||||
if self._replay_worker is not None:
|
||||
self._replay_worker.pause()
|
||||
self._replay_worker.seek(0)
|
||||
self._replay_play_btn.setText("Play")
|
||||
|
||||
def _replay_seek(self, value: int):
|
||||
"""Seek to a specific frame from the slider."""
|
||||
if self._replay_worker is not None and not self._replay_worker.is_playing:
|
||||
self._replay_worker.seek(value)
|
||||
|
||||
def _replay_speed_changed(self, index: int):
|
||||
"""Update replay frame interval from speed combo."""
|
||||
speed_map = {0: 50, 1: 100, 2: 200, 3: 500}
|
||||
ms = speed_map.get(index, 100)
|
||||
if self._replay_worker is not None:
|
||||
self._replay_worker.set_frame_interval(ms)
|
||||
|
||||
def _replay_loop_changed(self, state: int):
|
||||
"""Update replay loop setting."""
|
||||
if self._replay_worker is not None:
|
||||
self._replay_worker.set_loop(state == Qt.CheckState.Checked.value)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_playback_state_changed(self, state: str):
|
||||
"""Update UI when replay playback state changes."""
|
||||
if state == "playing":
|
||||
self._replay_play_btn.setText("Pause")
|
||||
elif state in ("paused", "stopped"):
|
||||
self._replay_play_btn.setText("Play")
|
||||
if state == "stopped" and self._replay_worker is not None:
|
||||
self._status_label_main.setText("Status: Replay finished")
|
||||
|
||||
@pyqtSlot(int, int)
|
||||
def _on_frame_index_changed(self, current: int, total: int):
|
||||
"""Update slider and frame label from replay worker."""
|
||||
self._replay_slider.blockSignals(True)
|
||||
self._replay_slider.setValue(current)
|
||||
self._replay_slider.blockSignals(False)
|
||||
self._replay_frame_label.setText(f"{current} / {total}")
|
||||
|
||||
def _dispatch_to_software_fpga(self, opcode: int, value: int):
|
||||
"""Route an FPGA opcode+value to the SoftwareFPGA setter."""
|
||||
fpga = self._software_fpga
|
||||
if fpga is None:
|
||||
return
|
||||
_opcode_dispatch = {
|
||||
0x03: lambda v: fpga.set_detect_threshold(v),
|
||||
0x16: lambda v: fpga.set_gain_shift(v),
|
||||
0x21: lambda v: fpga.set_cfar_guard(v),
|
||||
0x22: lambda v: fpga.set_cfar_train(v),
|
||||
0x23: lambda v: fpga.set_cfar_alpha(v),
|
||||
0x24: lambda v: fpga.set_cfar_mode(v),
|
||||
0x25: lambda v: fpga.set_cfar_enable(bool(v)),
|
||||
0x26: lambda v: fpga.set_mti_enable(bool(v)),
|
||||
0x27: lambda v: fpga.set_dc_notch_width(v),
|
||||
0x28: lambda v: fpga.set_agc_enable(bool(v)),
|
||||
0x29: lambda v: fpga.set_agc_params(target=v),
|
||||
0x2A: lambda v: fpga.set_agc_params(attack=v),
|
||||
0x2B: lambda v: fpga.set_agc_params(decay=v),
|
||||
0x2C: lambda v: fpga.set_agc_params(holdoff=v),
|
||||
}
|
||||
handler = _opcode_dispatch.get(opcode)
|
||||
if handler is not None:
|
||||
handler(value)
|
||||
logger.info(f"SoftwareFPGA: 0x{opcode:02X} = {value}")
|
||||
else:
|
||||
logger.debug(f"SoftwareFPGA: opcode 0x{opcode:02X} not handled (no-op)")
|
||||
|
||||
# =====================================================================
|
||||
# Demo mode
|
||||
# =====================================================================
|
||||
@@ -1338,7 +1605,7 @@ class RadarDashboard(QMainWindow):
|
||||
self._demo_mode = False
|
||||
if not self._running:
|
||||
mode = "Idle"
|
||||
elif isinstance(self._connection, ReplayConnection):
|
||||
elif self._replay_mode:
|
||||
mode = "Replay"
|
||||
else:
|
||||
mode = "Live"
|
||||
|
||||
@@ -3,14 +3,11 @@ v7.hardware — Hardware interface classes for the PLFM Radar GUI V7.
|
||||
|
||||
Provides:
|
||||
- FT2232H radar data + command interface via production radar_protocol module
|
||||
- ReplayConnection for offline .npy replay via production radar_protocol module
|
||||
- STM32USBInterface for GPS data only (USB CDC)
|
||||
|
||||
The FT2232H interface uses the production protocol layer (radar_protocol.py)
|
||||
which sends 4-byte {opcode, addr, value_hi, value_lo} register commands and
|
||||
parses 0xAA data / 0xBB status packets from the FPGA. The old magic-packet
|
||||
and 'SET'...'END' binary settings protocol has been removed — it was
|
||||
incompatible with the FPGA register interface.
|
||||
parses 0xAA data / 0xBB status packets from the FPGA.
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -28,7 +25,6 @@ if USB_AVAILABLE:
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from radar_protocol import ( # noqa: F401 — re-exported for v7 package
|
||||
FT2232HConnection,
|
||||
ReplayConnection,
|
||||
RadarProtocol,
|
||||
Opcode,
|
||||
RadarAcquisition,
|
||||
|
||||
@@ -186,3 +186,59 @@ class TileServer(Enum):
|
||||
GOOGLE_SATELLITE = "google_sat"
|
||||
GOOGLE_HYBRID = "google_hybrid"
|
||||
ESRI_SATELLITE = "esri_sat"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Waveform configuration (physical parameters for bin→unit conversion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class WaveformConfig:
|
||||
"""Physical waveform parameters for converting bins to SI units.
|
||||
|
||||
Encapsulates the radar waveform so that range/velocity resolution
|
||||
can be derived automatically instead of hardcoded in RadarSettings.
|
||||
|
||||
Defaults match the ADI CN0566 Phaser capture parameters used in
|
||||
the golden_reference cosim (4 MSPS, 500 MHz BW, 300 us chirp).
|
||||
"""
|
||||
|
||||
sample_rate_hz: float = 4e6 # ADC sample rate
|
||||
bandwidth_hz: float = 500e6 # Chirp bandwidth
|
||||
chirp_duration_s: float = 300e-6 # Chirp ramp time
|
||||
center_freq_hz: float = 10.525e9 # Carrier frequency
|
||||
n_range_bins: int = 64 # After decimation
|
||||
n_doppler_bins: int = 32 # After Doppler FFT
|
||||
fft_size: int = 1024 # Pre-decimation FFT length
|
||||
decimation_factor: int = 16 # 1024 → 64
|
||||
|
||||
@property
|
||||
def range_resolution_m(self) -> float:
|
||||
"""Meters per decimated range bin (FMCW deramped baseband).
|
||||
|
||||
For deramped FMCW: bin spacing = c * Fs * T / (2 * N_FFT * BW).
|
||||
After decimation the bin spacing grows by *decimation_factor*.
|
||||
"""
|
||||
c = 299_792_458.0
|
||||
raw_bin = (
|
||||
c * self.sample_rate_hz * self.chirp_duration_s
|
||||
/ (2.0 * self.fft_size * self.bandwidth_hz)
|
||||
)
|
||||
return raw_bin * self.decimation_factor
|
||||
|
||||
@property
|
||||
def velocity_resolution_mps(self) -> float:
|
||||
"""m/s per Doppler bin. lambda / (2 * n_doppler * chirp_duration)."""
|
||||
c = 299_792_458.0
|
||||
wavelength = c / self.center_freq_hz
|
||||
return wavelength / (2.0 * self.n_doppler_bins * self.chirp_duration_s)
|
||||
|
||||
@property
|
||||
def max_range_m(self) -> float:
|
||||
"""Maximum unambiguous range in meters."""
|
||||
return self.range_resolution_m * self.n_range_bins
|
||||
|
||||
@property
|
||||
def max_velocity_mps(self) -> float:
|
||||
"""Maximum unambiguous velocity (±) in m/s."""
|
||||
return self.velocity_resolution_mps * self.n_doppler_bins / 2.0
|
||||
|
||||
@@ -451,3 +451,103 @@ class USBPacketParser:
|
||||
except (ValueError, struct.error) as e:
|
||||
logger.error(f"Error parsing binary GPS: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Utility: polar → geographic coordinate conversion
|
||||
# ============================================================================
|
||||
|
||||
def polar_to_geographic(
|
||||
radar_lat: float,
|
||||
radar_lon: float,
|
||||
range_m: float,
|
||||
azimuth_deg: float,
|
||||
) -> tuple:
|
||||
"""Convert polar (range, azimuth) relative to radar → (lat, lon).
|
||||
|
||||
azimuth_deg: 0 = North, clockwise.
|
||||
"""
|
||||
r_earth = 6_371_000.0 # Earth radius in metres
|
||||
|
||||
lat1 = math.radians(radar_lat)
|
||||
lon1 = math.radians(radar_lon)
|
||||
bearing = math.radians(azimuth_deg)
|
||||
|
||||
lat2 = math.asin(
|
||||
math.sin(lat1) * math.cos(range_m / r_earth)
|
||||
+ math.cos(lat1) * math.sin(range_m / r_earth) * math.cos(bearing)
|
||||
)
|
||||
lon2 = lon1 + math.atan2(
|
||||
math.sin(bearing) * math.sin(range_m / r_earth) * math.cos(lat1),
|
||||
math.cos(range_m / r_earth) - math.sin(lat1) * math.sin(lat2),
|
||||
)
|
||||
return (math.degrees(lat2), math.degrees(lon2))
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shared target extraction (used by both RadarDataWorker and ReplayWorker)
|
||||
# ============================================================================
|
||||
|
||||
def extract_targets_from_frame(
|
||||
frame,
|
||||
range_resolution: float = 1.0,
|
||||
velocity_resolution: float = 1.0,
|
||||
gps: GPSData | None = None,
|
||||
) -> list[RadarTarget]:
|
||||
"""Extract RadarTarget list from a RadarFrame's detection mask.
|
||||
|
||||
This is the bin-to-physical conversion + geo-mapping shared between
|
||||
the live and replay data paths.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
frame : RadarFrame
|
||||
Frame with populated ``detections``, ``magnitude``, ``range_doppler_i/q``.
|
||||
range_resolution : float
|
||||
Meters per range bin.
|
||||
velocity_resolution : float
|
||||
m/s per Doppler bin.
|
||||
gps : GPSData | None
|
||||
GPS position for geo-mapping (latitude/longitude).
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[RadarTarget]
|
||||
One target per detection cell.
|
||||
"""
|
||||
det_indices = np.argwhere(frame.detections > 0)
|
||||
n_doppler = frame.detections.shape[1] if frame.detections.ndim == 2 else 32
|
||||
doppler_center = n_doppler // 2
|
||||
|
||||
targets: list[RadarTarget] = []
|
||||
for idx in det_indices:
|
||||
rbin, dbin = int(idx[0]), int(idx[1])
|
||||
mag = float(frame.magnitude[rbin, dbin])
|
||||
snr = 10.0 * math.log10(max(mag, 1.0)) if mag > 0 else 0.0
|
||||
|
||||
range_m = float(rbin) * range_resolution
|
||||
velocity_ms = float(dbin - doppler_center) * velocity_resolution
|
||||
|
||||
lat, lon, azimuth, elevation = 0.0, 0.0, 0.0, 0.0
|
||||
if gps is not None:
|
||||
azimuth = gps.heading
|
||||
# Spread detections across ±15° sector for single-beam radar
|
||||
if len(det_indices) > 1:
|
||||
spread = (dbin - doppler_center) / max(doppler_center, 1) * 15.0
|
||||
azimuth = gps.heading + spread
|
||||
lat, lon = polar_to_geographic(
|
||||
gps.latitude, gps.longitude, range_m, azimuth,
|
||||
)
|
||||
|
||||
targets.append(RadarTarget(
|
||||
id=len(targets),
|
||||
range=range_m,
|
||||
velocity=velocity_ms,
|
||||
azimuth=azimuth,
|
||||
elevation=elevation,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
snr=snr,
|
||||
timestamp=frame.timestamp,
|
||||
))
|
||||
return targets
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
v7.replay — ReplayEngine: auto-detect format, load, and iterate RadarFrames.
|
||||
|
||||
Supports three data sources:
|
||||
1. **FPGA co-sim directory** — pre-computed ``.npy`` files from golden_reference
|
||||
2. **Raw IQ cube** ``.npy`` — complex baseband capture (e.g. ADI Phaser)
|
||||
3. **HDF5 recording** ``.h5`` — frames captured by ``DataRecorder``
|
||||
|
||||
For raw IQ data the engine uses :class:`SoftwareFPGA` to run the full
|
||||
bit-accurate signal chain, so changing FPGA control registers in the
|
||||
dashboard re-processes the data.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .software_fpga import SoftwareFPGA
|
||||
|
||||
# radar_protocol is a sibling module (not inside v7/)
|
||||
import sys as _sys
|
||||
|
||||
_GUI_DIR = str(Path(__file__).resolve().parent.parent)
|
||||
if _GUI_DIR not in _sys.path:
|
||||
_sys.path.insert(0, _GUI_DIR)
|
||||
from radar_protocol import RadarFrame # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Lazy import — h5py is optional
|
||||
try:
|
||||
import h5py
|
||||
|
||||
HDF5_AVAILABLE = True
|
||||
except ImportError:
|
||||
HDF5_AVAILABLE = False
|
||||
|
||||
|
||||
class ReplayFormat(Enum):
|
||||
"""Detected input format."""
|
||||
|
||||
COSIM_DIR = auto()
|
||||
RAW_IQ_NPY = auto()
|
||||
HDF5 = auto()
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
# Format detection
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
|
||||
_COSIM_REQUIRED = {"doppler_map_i.npy", "doppler_map_q.npy"}
|
||||
|
||||
|
||||
def detect_format(path: str) -> ReplayFormat:
|
||||
"""Auto-detect the replay data format from *path*.
|
||||
|
||||
Raises
|
||||
------
|
||||
ValueError
|
||||
If the format cannot be determined.
|
||||
"""
|
||||
p = Path(path)
|
||||
|
||||
if p.is_dir():
|
||||
children = {f.name for f in p.iterdir()}
|
||||
if _COSIM_REQUIRED.issubset(children):
|
||||
return ReplayFormat.COSIM_DIR
|
||||
msg = f"Directory {p} does not contain required co-sim files: {_COSIM_REQUIRED - children}"
|
||||
raise ValueError(msg)
|
||||
|
||||
if p.suffix == ".h5":
|
||||
return ReplayFormat.HDF5
|
||||
|
||||
if p.suffix == ".npy":
|
||||
return ReplayFormat.RAW_IQ_NPY
|
||||
|
||||
msg = f"Cannot determine replay format for: {p}"
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
# ReplayEngine
|
||||
# ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReplayEngine:
|
||||
"""Load replay data and serve RadarFrames on demand.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
path : str
|
||||
File or directory path to load.
|
||||
software_fpga : SoftwareFPGA | None
|
||||
Required only for ``RAW_IQ_NPY`` format. For other formats the
|
||||
data is already processed and the FPGA instance is ignored.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, software_fpga: SoftwareFPGA | None = None) -> None:
|
||||
self.path = path
|
||||
self.fmt = detect_format(path)
|
||||
self.software_fpga = software_fpga
|
||||
|
||||
# Populated by _load_*
|
||||
self._total_frames: int = 0
|
||||
self._raw_iq: np.ndarray | None = None # for RAW_IQ_NPY
|
||||
self._h5_file = None
|
||||
self._h5_keys: list[str] = []
|
||||
self._cosim_frame = None # single RadarFrame for co-sim
|
||||
|
||||
self._load()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load(self) -> None:
|
||||
if self.fmt is ReplayFormat.COSIM_DIR:
|
||||
self._load_cosim()
|
||||
elif self.fmt is ReplayFormat.RAW_IQ_NPY:
|
||||
self._load_raw_iq()
|
||||
elif self.fmt is ReplayFormat.HDF5:
|
||||
self._load_hdf5()
|
||||
|
||||
def _load_cosim(self) -> None:
|
||||
"""Load FPGA co-sim directory (already-processed .npy arrays).
|
||||
|
||||
Prefers fullchain (MTI-enabled) files when CFAR outputs are present,
|
||||
so that I/Q data is consistent with the detection mask. Falls back
|
||||
to the non-MTI ``doppler_map`` files when fullchain data is absent.
|
||||
"""
|
||||
d = Path(self.path)
|
||||
|
||||
# CFAR outputs (from the MTI→Doppler→DC-notch→CFAR chain)
|
||||
cfar_flags = d / "fullchain_cfar_flags.npy"
|
||||
cfar_mag = d / "fullchain_cfar_mag.npy"
|
||||
has_cfar = cfar_flags.exists() and cfar_mag.exists()
|
||||
|
||||
# MTI-consistent I/Q (same chain that produced CFAR outputs)
|
||||
mti_dop_i = d / "fullchain_mti_doppler_i.npy"
|
||||
mti_dop_q = d / "fullchain_mti_doppler_q.npy"
|
||||
has_mti_doppler = mti_dop_i.exists() and mti_dop_q.exists()
|
||||
|
||||
# Choose I/Q: prefer MTI-chain when CFAR data comes from that chain
|
||||
if has_cfar and has_mti_doppler:
|
||||
dop_i = np.load(mti_dop_i).astype(np.int16)
|
||||
dop_q = np.load(mti_dop_q).astype(np.int16)
|
||||
log.info("Co-sim: using fullchain MTI+Doppler I/Q (matches CFAR chain)")
|
||||
else:
|
||||
dop_i = np.load(d / "doppler_map_i.npy").astype(np.int16)
|
||||
dop_q = np.load(d / "doppler_map_q.npy").astype(np.int16)
|
||||
log.info("Co-sim: using non-MTI doppler_map I/Q")
|
||||
|
||||
frame = RadarFrame()
|
||||
frame.range_doppler_i = dop_i
|
||||
frame.range_doppler_q = dop_q
|
||||
|
||||
if has_cfar:
|
||||
frame.detections = np.load(cfar_flags).astype(np.uint8)
|
||||
frame.magnitude = np.load(cfar_mag).astype(np.float64)
|
||||
else:
|
||||
frame.magnitude = np.sqrt(
|
||||
dop_i.astype(np.float64) ** 2 + dop_q.astype(np.float64) ** 2
|
||||
)
|
||||
frame.detections = np.zeros_like(dop_i, dtype=np.uint8)
|
||||
|
||||
frame.range_profile = frame.magnitude[:, 0]
|
||||
frame.detection_count = int(frame.detections.sum())
|
||||
frame.frame_number = 0
|
||||
frame.timestamp = time.time()
|
||||
|
||||
self._cosim_frame = frame
|
||||
self._total_frames = 1
|
||||
log.info("Loaded co-sim directory: %s (1 frame)", self.path)
|
||||
|
||||
def _load_raw_iq(self) -> None:
|
||||
"""Load raw complex IQ cube (.npy)."""
|
||||
data = np.load(self.path, mmap_mode="r")
|
||||
if data.ndim == 2:
|
||||
# (chirps, samples) — single frame
|
||||
data = data[np.newaxis, ...]
|
||||
if data.ndim != 3:
|
||||
msg = f"Expected 3-D array (frames, chirps, samples), got shape {data.shape}"
|
||||
raise ValueError(msg)
|
||||
self._raw_iq = data
|
||||
self._total_frames = data.shape[0]
|
||||
log.info(
|
||||
"Loaded raw IQ: %s, shape %s (%d frames)",
|
||||
self.path,
|
||||
data.shape,
|
||||
self._total_frames,
|
||||
)
|
||||
|
||||
def _load_hdf5(self) -> None:
|
||||
"""Load HDF5 recording (.h5)."""
|
||||
if not HDF5_AVAILABLE:
|
||||
msg = "h5py is required to load HDF5 recordings"
|
||||
raise ImportError(msg)
|
||||
self._h5_file = h5py.File(self.path, "r")
|
||||
frames_grp = self._h5_file.get("frames")
|
||||
if frames_grp is None:
|
||||
msg = f"HDF5 file {self.path} has no 'frames' group"
|
||||
raise ValueError(msg)
|
||||
self._h5_keys = sorted(frames_grp.keys())
|
||||
self._total_frames = len(self._h5_keys)
|
||||
log.info("Loaded HDF5: %s (%d frames)", self.path, self._total_frames)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def total_frames(self) -> int:
|
||||
return self._total_frames
|
||||
|
||||
def get_frame(self, index: int) -> RadarFrame:
|
||||
"""Return the RadarFrame at *index* (0-based).
|
||||
|
||||
For ``RAW_IQ_NPY`` format, this runs the SoftwareFPGA chain
|
||||
on the requested frame's chirps.
|
||||
"""
|
||||
if index < 0 or index >= self._total_frames:
|
||||
msg = f"Frame index {index} out of range [0, {self._total_frames})"
|
||||
raise IndexError(msg)
|
||||
|
||||
if self.fmt is ReplayFormat.COSIM_DIR:
|
||||
return self._get_cosim(index)
|
||||
if self.fmt is ReplayFormat.RAW_IQ_NPY:
|
||||
return self._get_raw_iq(index)
|
||||
return self._get_hdf5(index)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release any open file handles."""
|
||||
if self._h5_file is not None:
|
||||
self._h5_file.close()
|
||||
self._h5_file = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Per-format frame getters
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_cosim(self, _index: int) -> RadarFrame:
|
||||
"""Co-sim: single static frame (index ignored).
|
||||
|
||||
Uses deepcopy so numpy arrays are not shared with the source,
|
||||
preventing in-place mutation from corrupting cached data.
|
||||
"""
|
||||
import copy
|
||||
frame = copy.deepcopy(self._cosim_frame)
|
||||
frame.timestamp = time.time()
|
||||
return frame
|
||||
|
||||
def _get_raw_iq(self, index: int) -> RadarFrame:
|
||||
"""Raw IQ: quantize one frame and run through SoftwareFPGA."""
|
||||
if self.software_fpga is None:
|
||||
msg = "SoftwareFPGA is required for raw IQ replay"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
from .software_fpga import quantize_raw_iq
|
||||
|
||||
raw = self._raw_iq[index] # (chirps, samples) complex
|
||||
iq_i, iq_q = quantize_raw_iq(raw[np.newaxis, ...])
|
||||
return self.software_fpga.process_chirps(
|
||||
iq_i, iq_q, frame_number=index, timestamp=time.time()
|
||||
)
|
||||
|
||||
def _get_hdf5(self, index: int) -> RadarFrame:
|
||||
"""HDF5: reconstruct RadarFrame from stored datasets."""
|
||||
key = self._h5_keys[index]
|
||||
grp = self._h5_file["frames"][key]
|
||||
|
||||
frame = RadarFrame()
|
||||
frame.timestamp = float(grp.attrs.get("timestamp", time.time()))
|
||||
frame.frame_number = int(grp.attrs.get("frame_number", index))
|
||||
frame.detection_count = int(grp.attrs.get("detection_count", 0))
|
||||
|
||||
frame.range_doppler_i = np.array(grp["range_doppler_i"], dtype=np.int16)
|
||||
frame.range_doppler_q = np.array(grp["range_doppler_q"], dtype=np.int16)
|
||||
frame.magnitude = np.array(grp["magnitude"], dtype=np.float64)
|
||||
frame.detections = np.array(grp["detections"], dtype=np.uint8)
|
||||
frame.range_profile = np.array(grp["range_profile"], dtype=np.float64)
|
||||
|
||||
return frame
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
v7.software_fpga — Bit-accurate software replica of the AERIS-10 FPGA signal chain.
|
||||
|
||||
Imports processing functions directly from golden_reference.py (Option A)
|
||||
to avoid code duplication. Every stage is toggleable via the same host
|
||||
register interface the real FPGA exposes, so the dashboard spinboxes can
|
||||
drive either backend transparently.
|
||||
|
||||
Signal chain order (matching RTL):
|
||||
quantize → range_fft → decimator → MTI → doppler_fft → dc_notch → CFAR → RadarFrame
|
||||
|
||||
Usage:
|
||||
fpga = SoftwareFPGA()
|
||||
fpga.set_cfar_enable(True)
|
||||
frame = fpga.process_chirps(iq_i, iq_q, frame_number=0)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Import golden_reference by adding the cosim path to sys.path
|
||||
# ---------------------------------------------------------------------------
|
||||
_GOLDEN_REF_DIR = str(
|
||||
Path(__file__).resolve().parents[2] # 9_Firmware/
|
||||
/ "9_2_FPGA" / "tb" / "cosim" / "real_data"
|
||||
)
|
||||
if _GOLDEN_REF_DIR not in sys.path:
|
||||
sys.path.insert(0, _GOLDEN_REF_DIR)
|
||||
|
||||
from golden_reference import ( # noqa: E402
|
||||
run_range_fft,
|
||||
run_range_bin_decimator,
|
||||
run_mti_canceller,
|
||||
run_doppler_fft,
|
||||
run_dc_notch,
|
||||
run_cfar_ca,
|
||||
run_detection,
|
||||
FFT_SIZE,
|
||||
DOPPLER_CHIRPS,
|
||||
)
|
||||
|
||||
# RadarFrame lives in radar_protocol (no circular dep — protocol has no GUI)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from radar_protocol import RadarFrame # noqa: E402
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Twiddle factor file paths (relative to FPGA root)
|
||||
# ---------------------------------------------------------------------------
|
||||
_FPGA_DIR = Path(__file__).resolve().parents[2] / "9_2_FPGA"
|
||||
TWIDDLE_1024 = str(_FPGA_DIR / "fft_twiddle_1024.mem")
|
||||
TWIDDLE_16 = str(_FPGA_DIR / "fft_twiddle_16.mem")
|
||||
|
||||
# CFAR mode int→string mapping (FPGA register 0x24: 0=CA, 1=GO, 2=SO)
|
||||
_CFAR_MODE_MAP = {0: "CA", 1: "GO", 2: "SO", 3: "CA"}
|
||||
|
||||
|
||||
class SoftwareFPGA:
|
||||
"""Bit-accurate replica of the AERIS-10 FPGA signal processing chain.
|
||||
|
||||
All registers mirror FPGA reset defaults from ``radar_system_top.v``.
|
||||
Setters accept the same integer values as the FPGA host commands.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# --- FPGA register mirror (reset defaults) ---
|
||||
# Detection
|
||||
self.detect_threshold: int = 10_000 # 0x03
|
||||
self.gain_shift: int = 0 # 0x16
|
||||
|
||||
# CFAR
|
||||
self.cfar_enable: bool = False # 0x25
|
||||
self.cfar_guard: int = 2 # 0x21
|
||||
self.cfar_train: int = 8 # 0x22
|
||||
self.cfar_alpha: int = 0x30 # 0x23 Q4.4
|
||||
self.cfar_mode: int = 0 # 0x24 0=CA,1=GO,2=SO
|
||||
|
||||
# MTI
|
||||
self.mti_enable: bool = False # 0x26
|
||||
|
||||
# DC notch
|
||||
self.dc_notch_width: int = 0 # 0x27
|
||||
|
||||
# AGC (tracked but not applied in software chain — AGC operates
|
||||
# on the analog front-end gain, which doesn't exist in replay)
|
||||
self.agc_enable: bool = False # 0x28
|
||||
self.agc_target: int = 200 # 0x29
|
||||
self.agc_attack: int = 1 # 0x2A
|
||||
self.agc_decay: int = 1 # 0x2B
|
||||
self.agc_holdoff: int = 4 # 0x2C
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Register setters (same interface as UART commands to real FPGA)
|
||||
# ------------------------------------------------------------------
|
||||
def set_detect_threshold(self, val: int) -> None:
|
||||
self.detect_threshold = int(val) & 0xFFFF
|
||||
|
||||
def set_gain_shift(self, val: int) -> None:
|
||||
self.gain_shift = int(val) & 0x0F
|
||||
|
||||
def set_cfar_enable(self, val: bool) -> None:
|
||||
self.cfar_enable = bool(val)
|
||||
|
||||
def set_cfar_guard(self, val: int) -> None:
|
||||
self.cfar_guard = int(val) & 0x0F
|
||||
|
||||
def set_cfar_train(self, val: int) -> None:
|
||||
self.cfar_train = max(1, int(val) & 0x1F)
|
||||
|
||||
def set_cfar_alpha(self, val: int) -> None:
|
||||
self.cfar_alpha = int(val) & 0xFF
|
||||
|
||||
def set_cfar_mode(self, val: int) -> None:
|
||||
self.cfar_mode = int(val) & 0x03
|
||||
|
||||
def set_mti_enable(self, val: bool) -> None:
|
||||
self.mti_enable = bool(val)
|
||||
|
||||
def set_dc_notch_width(self, val: int) -> None:
|
||||
self.dc_notch_width = int(val) & 0x07
|
||||
|
||||
def set_agc_enable(self, val: bool) -> None:
|
||||
self.agc_enable = bool(val)
|
||||
|
||||
def set_agc_params(
|
||||
self,
|
||||
target: int | None = None,
|
||||
attack: int | None = None,
|
||||
decay: int | None = None,
|
||||
holdoff: int | None = None,
|
||||
) -> None:
|
||||
if target is not None:
|
||||
self.agc_target = int(target) & 0xFF
|
||||
if attack is not None:
|
||||
self.agc_attack = int(attack) & 0x0F
|
||||
if decay is not None:
|
||||
self.agc_decay = int(decay) & 0x0F
|
||||
if holdoff is not None:
|
||||
self.agc_holdoff = int(holdoff) & 0x0F
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core processing: raw IQ chirps → RadarFrame
|
||||
# ------------------------------------------------------------------
|
||||
def process_chirps(
|
||||
self,
|
||||
iq_i: np.ndarray,
|
||||
iq_q: np.ndarray,
|
||||
frame_number: int = 0,
|
||||
timestamp: float = 0.0,
|
||||
) -> RadarFrame:
|
||||
"""Run the full FPGA signal chain on pre-quantized 16-bit I/Q chirps.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
iq_i, iq_q : ndarray, shape (n_chirps, n_samples), int16/int64
|
||||
Post-DDC I/Q samples. For ADI phaser data, use
|
||||
``quantize_raw_iq()`` first.
|
||||
frame_number : int
|
||||
Frame counter for the output RadarFrame.
|
||||
timestamp : float
|
||||
Timestamp for the output RadarFrame.
|
||||
|
||||
Returns
|
||||
-------
|
||||
RadarFrame
|
||||
Populated frame identical to what the real FPGA would produce.
|
||||
"""
|
||||
n_chirps = iq_i.shape[0]
|
||||
n_samples = iq_i.shape[1]
|
||||
|
||||
# --- Stage 1: Range FFT (per chirp) ---
|
||||
range_i = np.zeros((n_chirps, n_samples), dtype=np.int64)
|
||||
range_q = np.zeros((n_chirps, n_samples), dtype=np.int64)
|
||||
twiddle_1024 = TWIDDLE_1024 if os.path.exists(TWIDDLE_1024) else None
|
||||
for c in range(n_chirps):
|
||||
range_i[c], range_q[c] = run_range_fft(
|
||||
iq_i[c].astype(np.int64),
|
||||
iq_q[c].astype(np.int64),
|
||||
twiddle_file=twiddle_1024,
|
||||
)
|
||||
|
||||
# --- Stage 2: Range bin decimation (1024 → 64) ---
|
||||
decim_i, decim_q = run_range_bin_decimator(range_i, range_q)
|
||||
|
||||
# --- Stage 3: MTI canceller (pre-Doppler, per-chirp) ---
|
||||
mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=self.mti_enable)
|
||||
|
||||
# --- Stage 4: Doppler FFT (dual 16-pt Hamming) ---
|
||||
twiddle_16 = TWIDDLE_16 if os.path.exists(TWIDDLE_16) else None
|
||||
doppler_i, doppler_q = run_doppler_fft(mti_i, mti_q, twiddle_file_16=twiddle_16)
|
||||
|
||||
# --- Stage 5: DC notch (bin zeroing) ---
|
||||
notch_i, notch_q = run_dc_notch(doppler_i, doppler_q, width=self.dc_notch_width)
|
||||
|
||||
# --- Stage 6: Detection ---
|
||||
if self.cfar_enable:
|
||||
mode_str = _CFAR_MODE_MAP.get(self.cfar_mode, "CA")
|
||||
detect_flags, magnitudes, _thresholds = run_cfar_ca(
|
||||
notch_i,
|
||||
notch_q,
|
||||
guard=self.cfar_guard,
|
||||
train=self.cfar_train,
|
||||
alpha_q44=self.cfar_alpha,
|
||||
mode=mode_str,
|
||||
)
|
||||
det_mask = detect_flags.astype(np.uint8)
|
||||
mag = magnitudes.astype(np.float64)
|
||||
else:
|
||||
mag_raw, det_indices = run_detection(
|
||||
notch_i, notch_q, threshold=self.detect_threshold
|
||||
)
|
||||
mag = mag_raw.astype(np.float64)
|
||||
det_mask = np.zeros_like(mag, dtype=np.uint8)
|
||||
for idx in det_indices:
|
||||
det_mask[idx[0], idx[1]] = 1
|
||||
|
||||
# --- Assemble RadarFrame ---
|
||||
frame = RadarFrame()
|
||||
frame.timestamp = timestamp
|
||||
frame.frame_number = frame_number
|
||||
frame.range_doppler_i = np.clip(notch_i, -32768, 32767).astype(np.int16)
|
||||
frame.range_doppler_q = np.clip(notch_q, -32768, 32767).astype(np.int16)
|
||||
frame.magnitude = mag
|
||||
frame.detections = det_mask
|
||||
frame.range_profile = np.sqrt(
|
||||
notch_i[:, 0].astype(np.float64) ** 2
|
||||
+ notch_q[:, 0].astype(np.float64) ** 2
|
||||
)
|
||||
frame.detection_count = int(det_mask.sum())
|
||||
return frame
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utility: quantize arbitrary complex IQ to 16-bit post-DDC format
|
||||
# ---------------------------------------------------------------------------
|
||||
def quantize_raw_iq(
|
||||
raw_complex: np.ndarray,
|
||||
n_chirps: int = DOPPLER_CHIRPS,
|
||||
n_samples: int = FFT_SIZE,
|
||||
peak_target: int = 200,
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Quantize complex IQ data to 16-bit signed, matching DDC output level.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
raw_complex : ndarray, shape (chirps, samples) or (frames, chirps, samples)
|
||||
Complex64/128 baseband IQ from SDR capture. If 3-D, the first
|
||||
axis is treated as frame index and only the first frame is used.
|
||||
n_chirps : int
|
||||
Number of chirps to keep (default 32, matching FPGA).
|
||||
n_samples : int
|
||||
Number of samples per chirp to keep (default 1024, matching FFT).
|
||||
peak_target : int
|
||||
Target peak magnitude after scaling (default 200, matching
|
||||
golden_reference INPUT_PEAK_TARGET).
|
||||
|
||||
Returns
|
||||
-------
|
||||
iq_i, iq_q : ndarray, each (n_chirps, n_samples) int64
|
||||
"""
|
||||
if raw_complex.ndim == 3:
|
||||
# (frames, chirps, samples) — take first frame
|
||||
raw_complex = raw_complex[0]
|
||||
|
||||
# Truncate to FPGA dimensions
|
||||
block = raw_complex[:n_chirps, :n_samples]
|
||||
|
||||
max_abs = np.max(np.abs(block))
|
||||
if max_abs == 0:
|
||||
return (
|
||||
np.zeros((n_chirps, n_samples), dtype=np.int64),
|
||||
np.zeros((n_chirps, n_samples), dtype=np.int64),
|
||||
)
|
||||
|
||||
scale = peak_target / max_abs
|
||||
scaled = block * scale
|
||||
iq_i = np.clip(np.round(np.real(scaled)).astype(np.int64), -32768, 32767)
|
||||
iq_q = np.clip(np.round(np.imag(scaled)).astype(np.int64), -32768, 32767)
|
||||
return iq_i, iq_q
|
||||
@@ -13,7 +13,6 @@ All packet parsing now uses the production radar_protocol.py which matches
|
||||
the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte).
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
import queue
|
||||
@@ -36,58 +35,25 @@ from .processing import (
|
||||
RadarProcessor,
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
polar_to_geographic,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Utility: polar → geographic
|
||||
# =============================================================================
|
||||
|
||||
def polar_to_geographic(
|
||||
radar_lat: float,
|
||||
radar_lon: float,
|
||||
range_m: float,
|
||||
azimuth_deg: float,
|
||||
) -> tuple:
|
||||
"""
|
||||
Convert polar coordinates (range, azimuth) relative to radar
|
||||
to geographic (latitude, longitude).
|
||||
|
||||
azimuth_deg: 0 = North, clockwise.
|
||||
Returns (lat, lon).
|
||||
"""
|
||||
R = 6_371_000 # Earth radius in meters
|
||||
|
||||
lat1 = math.radians(radar_lat)
|
||||
lon1 = math.radians(radar_lon)
|
||||
bearing = math.radians(azimuth_deg)
|
||||
|
||||
lat2 = math.asin(
|
||||
math.sin(lat1) * math.cos(range_m / R)
|
||||
+ math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing)
|
||||
)
|
||||
lon2 = lon1 + math.atan2(
|
||||
math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1),
|
||||
math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2),
|
||||
)
|
||||
return (math.degrees(lat2), math.degrees(lon2))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Radar Data Worker (QThread) — production protocol
|
||||
# =============================================================================
|
||||
|
||||
class RadarDataWorker(QThread):
|
||||
"""
|
||||
Background worker that reads radar data from FT2232H (or ReplayConnection),
|
||||
parses 0xAA/0xBB packets via production RadarAcquisition, runs optional
|
||||
host-side DSP, and emits PyQt signals with results.
|
||||
Background worker that reads radar data from FT2232H, parses 0xAA/0xBB
|
||||
packets via production RadarAcquisition, runs optional host-side DSP,
|
||||
and emits PyQt signals with results.
|
||||
|
||||
This replaces the old V7 worker which used an incompatible packet format.
|
||||
Now uses production radar_protocol.py for all packet parsing and frame
|
||||
Uses production radar_protocol.py for all packet parsing and frame
|
||||
assembly (11-byte 0xAA data packets → 64x32 RadarFrame).
|
||||
For replay, use ReplayWorker instead.
|
||||
|
||||
Signals:
|
||||
frameReady(RadarFrame) — a complete 64x32 radar frame
|
||||
@@ -105,7 +71,7 @@ class RadarDataWorker(QThread):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection, # FT2232HConnection or ReplayConnection
|
||||
connection, # FT2232HConnection
|
||||
processor: RadarProcessor | None = None,
|
||||
recorder: DataRecorder | None = None,
|
||||
gps_data_ref: GPSData | None = None,
|
||||
@@ -436,3 +402,172 @@ class TargetSimulator(QObject):
|
||||
|
||||
self._targets = updated
|
||||
self.targetsUpdated.emit(updated)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Replay Worker (QThread) — unified replay playback
|
||||
# =============================================================================
|
||||
|
||||
class ReplayWorker(QThread):
|
||||
"""Background worker for replay data playback.
|
||||
|
||||
Emits the same signals as ``RadarDataWorker`` so the dashboard
|
||||
treats live and replay identically. Additionally emits playback
|
||||
state and frame-index signals for the transport controls.
|
||||
|
||||
Signals
|
||||
-------
|
||||
frameReady(object) RadarFrame
|
||||
targetsUpdated(list) list[RadarTarget]
|
||||
statsUpdated(dict) processing stats
|
||||
errorOccurred(str) error message
|
||||
playbackStateChanged(str) "playing" | "paused" | "stopped"
|
||||
frameIndexChanged(int, int) (current_index, total_frames)
|
||||
"""
|
||||
|
||||
frameReady = pyqtSignal(object)
|
||||
targetsUpdated = pyqtSignal(list)
|
||||
statsUpdated = pyqtSignal(dict)
|
||||
errorOccurred = pyqtSignal(str)
|
||||
playbackStateChanged = pyqtSignal(str)
|
||||
frameIndexChanged = pyqtSignal(int, int)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
replay_engine,
|
||||
settings: RadarSettings | None = None,
|
||||
gps: GPSData | None = None,
|
||||
frame_interval_ms: int = 100,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
import threading
|
||||
|
||||
from .processing import extract_targets_from_frame
|
||||
from .models import WaveformConfig
|
||||
|
||||
self._engine = replay_engine
|
||||
self._settings = settings or RadarSettings()
|
||||
self._gps = gps
|
||||
self._waveform = WaveformConfig()
|
||||
self._frame_interval_ms = frame_interval_ms
|
||||
self._extract_targets = extract_targets_from_frame
|
||||
|
||||
self._current_index = 0
|
||||
self._last_emitted_index = 0
|
||||
self._playing = False
|
||||
self._stop_flag = False
|
||||
self._loop = False
|
||||
self._lock = threading.Lock() # guards _current_index and _emit_frame
|
||||
|
||||
# -- Public control API --
|
||||
|
||||
@property
|
||||
def current_index(self) -> int:
|
||||
"""Index of the last frame emitted (for re-seek on param change)."""
|
||||
return self._last_emitted_index
|
||||
|
||||
@property
|
||||
def total_frames(self) -> int:
|
||||
return self._engine.total_frames
|
||||
|
||||
def set_gps(self, gps: GPSData | None) -> None:
|
||||
self._gps = gps
|
||||
|
||||
def set_waveform(self, wf) -> None:
|
||||
self._waveform = wf
|
||||
|
||||
def set_loop(self, loop: bool) -> None:
|
||||
self._loop = loop
|
||||
|
||||
def set_frame_interval(self, ms: int) -> None:
|
||||
self._frame_interval_ms = max(10, ms)
|
||||
|
||||
def play(self) -> None:
|
||||
self._playing = True
|
||||
# If at EOF, rewind so play actually does something
|
||||
with self._lock:
|
||||
if self._current_index >= self._engine.total_frames:
|
||||
self._current_index = 0
|
||||
self.playbackStateChanged.emit("playing")
|
||||
|
||||
def pause(self) -> None:
|
||||
self._playing = False
|
||||
self.playbackStateChanged.emit("paused")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._playing = False
|
||||
self._stop_flag = True
|
||||
self.playbackStateChanged.emit("stopped")
|
||||
|
||||
@property
|
||||
def is_playing(self) -> bool:
|
||||
"""Thread-safe read of playback state (for GUI queries)."""
|
||||
return self._playing
|
||||
|
||||
def seek(self, index: int) -> None:
|
||||
"""Jump to a specific frame and emit it (thread-safe)."""
|
||||
with self._lock:
|
||||
idx = max(0, min(index, self._engine.total_frames - 1))
|
||||
self._current_index = idx
|
||||
self._emit_frame(idx)
|
||||
self._last_emitted_index = idx
|
||||
|
||||
# -- Thread entry --
|
||||
|
||||
def run(self) -> None:
|
||||
self._stop_flag = False
|
||||
self._playing = True
|
||||
self.playbackStateChanged.emit("playing")
|
||||
|
||||
try:
|
||||
while not self._stop_flag:
|
||||
if self._playing:
|
||||
with self._lock:
|
||||
if self._current_index < self._engine.total_frames:
|
||||
self._emit_frame(self._current_index)
|
||||
self._last_emitted_index = self._current_index
|
||||
self._current_index += 1
|
||||
|
||||
# Loop or pause at end
|
||||
if self._current_index >= self._engine.total_frames:
|
||||
if self._loop:
|
||||
self._current_index = 0
|
||||
else:
|
||||
# Pause — keep thread alive for restart
|
||||
self._playing = False
|
||||
self.playbackStateChanged.emit("stopped")
|
||||
|
||||
self.msleep(self._frame_interval_ms)
|
||||
except (OSError, ValueError, RuntimeError, IndexError) as exc:
|
||||
self.errorOccurred.emit(str(exc))
|
||||
|
||||
self.playbackStateChanged.emit("stopped")
|
||||
|
||||
# -- Internal --
|
||||
|
||||
def _emit_frame(self, index: int) -> None:
|
||||
try:
|
||||
frame = self._engine.get_frame(index)
|
||||
except (OSError, ValueError, RuntimeError, IndexError) as exc:
|
||||
self.errorOccurred.emit(f"Frame {index}: {exc}")
|
||||
return
|
||||
|
||||
self.frameReady.emit(frame)
|
||||
self.frameIndexChanged.emit(index, self._engine.total_frames)
|
||||
|
||||
# Target extraction
|
||||
targets = self._extract_targets(
|
||||
frame,
|
||||
range_resolution=self._waveform.range_resolution_m,
|
||||
velocity_resolution=self._waveform.velocity_resolution_mps,
|
||||
gps=self._gps,
|
||||
)
|
||||
self.targetsUpdated.emit(targets)
|
||||
self.statsUpdated.emit({
|
||||
"frame_number": frame.frame_number,
|
||||
"detection_count": frame.detection_count,
|
||||
"target_count": len(targets),
|
||||
"replay_index": index,
|
||||
"replay_total": self._engine.total_frames,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user