feat: Raw IQ Replay mode — software FPGA signal chain with playback controls
Add a 4th connection mode to the V7 dashboard that loads raw complex IQ captures (.npy) and runs the full FPGA signal processing chain in software: quantize → AGC → Range FFT → Doppler FFT → MTI → DC notch → CFAR. Implementation (7 steps): - v7/agc_sim.py: bit-accurate AGC runtime extracted from adi_agc_analysis.py - v7/processing.py: RawIQFrameProcessor (full signal chain) + shared extract_targets_from_frame() for bin-to-physical conversion - v7/raw_iq_replay.py: RawIQReplayController with thread-safe playback state machine (play/pause/stop/step/seek/loop/FPS) - v7/workers.py: RawIQReplayWorker (QThread) emitting same signals as RadarDataWorker + playback state/index signals - v7/dashboard.py: mode combo entry, playback controls UI, dynamic RangeDopplerCanvas that adapts to any frame size Bug fixes included: - RangeDopplerCanvas no longer hardcodes 64x32; resizes dynamically - Doppler centre bin uses n_doppler//2 instead of hardcoded 16 - Shared target extraction eliminates duplicate code between workers Ruff clean, 120/120 tests pass.
This commit is contained in:
1 parent
77496ccc88
commit
2cb56e8b13
5 files changed
+1280
-65
No files matched your search
@@ -0,0 +1,264 @@
|
||||
"""
|
||||
v7.raw_iq_replay -- Raw IQ replay controller for the V7 dashboard.
|
||||
|
||||
Manages loading of raw complex IQ .npy captures, playback state
|
||||
(play/pause/step/speed/loop), and delivers frames to a worker thread.
|
||||
|
||||
The controller is thread-safe: the worker calls ``next_frame()`` which
|
||||
blocks until a frame is available or playback is stopped.
|
||||
|
||||
Supported file formats:
|
||||
- 3-D .npy: (n_frames, n_chirps, n_samples) complex
|
||||
- 2-D .npy: (n_chirps, n_samples) complex -> treated as single frame
|
||||
|
||||
Classes:
|
||||
- RawIQReplayController -- playback state machine + frame delivery
|
||||
- RawIQFileInfo -- metadata about the loaded file
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Playback state enum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PlaybackState(Enum):
|
||||
"""Playback state for the replay controller."""
|
||||
STOPPED = auto()
|
||||
PLAYING = auto()
|
||||
PAUSED = auto()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# File metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class RawIQFileInfo:
|
||||
"""Metadata about a loaded raw IQ .npy file."""
|
||||
path: str
|
||||
n_frames: int
|
||||
n_chirps: int
|
||||
n_samples: int
|
||||
dtype: str
|
||||
file_size_mb: float
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Replay Controller
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class RawIQReplayController:
|
||||
"""Manages raw IQ file loading and playback state.
|
||||
|
||||
Thread-safety: the controller uses a condition variable so the worker
|
||||
thread can block on ``next_frame()`` waiting for play/step events,
|
||||
while the GUI thread calls ``play()``, ``pause()``, ``step()``, etc.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._data: np.ndarray | None = None
|
||||
self._info: RawIQFileInfo | None = None
|
||||
|
||||
# Playback state
|
||||
self._state = PlaybackState.STOPPED
|
||||
self._frame_index: int = 0
|
||||
self._fps: float = 10.0 # target frames per second
|
||||
self._loop: bool = True
|
||||
|
||||
# Thread synchronisation
|
||||
self._lock = threading.Lock()
|
||||
self._cond = threading.Condition(self._lock)
|
||||
|
||||
# Step request flag (set by GUI, consumed by worker)
|
||||
self._step_requested: bool = False
|
||||
|
||||
# Stop signal
|
||||
self._stop_requested: bool = False
|
||||
|
||||
# ---- File loading ------------------------------------------------------
|
||||
|
||||
def load_file(self, path: str) -> RawIQFileInfo:
|
||||
"""Load a .npy file containing raw IQ data.
|
||||
|
||||
Raises ValueError if the file is not a valid raw IQ capture.
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
msg = f"File not found: {path}"
|
||||
raise ValueError(msg)
|
||||
if p.suffix.lower() != ".npy":
|
||||
msg = f"Expected .npy file, got: {p.suffix}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Memory-map for large files
|
||||
data = np.load(str(p), mmap_mode="r")
|
||||
|
||||
if not np.iscomplexobj(data):
|
||||
msg = f"Expected complex data, got dtype={data.dtype}"
|
||||
raise ValueError(msg)
|
||||
|
||||
# Normalise shape
|
||||
if data.ndim == 2:
|
||||
# Single frame: (chirps, samples) -> (1, chirps, samples)
|
||||
data = data[np.newaxis, :, :]
|
||||
elif data.ndim == 3:
|
||||
pass # (frames, chirps, samples) — expected
|
||||
else:
|
||||
msg = f"Expected 2-D or 3-D array, got {data.ndim}-D"
|
||||
raise ValueError(msg)
|
||||
|
||||
with self._lock:
|
||||
self._data = data
|
||||
self._frame_index = 0
|
||||
self._state = PlaybackState.PAUSED
|
||||
self._stop_requested = False
|
||||
|
||||
self._info = RawIQFileInfo(
|
||||
path=str(p),
|
||||
n_frames=data.shape[0],
|
||||
n_chirps=data.shape[1],
|
||||
n_samples=data.shape[2],
|
||||
dtype=str(data.dtype),
|
||||
file_size_mb=p.stat().st_size / (1024 * 1024),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Loaded raw IQ: {p.name} — {self._info.n_frames} frames, "
|
||||
f"{self._info.n_chirps} chirps, {self._info.n_samples} samples, "
|
||||
f"{self._info.file_size_mb:.1f} MB"
|
||||
)
|
||||
return self._info
|
||||
|
||||
def unload(self) -> None:
|
||||
"""Unload the current file and stop playback."""
|
||||
with self._lock:
|
||||
self._state = PlaybackState.STOPPED
|
||||
self._stop_requested = True
|
||||
self._data = None
|
||||
self._info = None
|
||||
self._frame_index = 0
|
||||
self._cond.notify_all()
|
||||
|
||||
# ---- Playback control (called from GUI thread) -------------------------
|
||||
|
||||
def play(self) -> None:
|
||||
with self._lock:
|
||||
if self._data is None:
|
||||
return
|
||||
self._state = PlaybackState.PLAYING
|
||||
self._stop_requested = False
|
||||
self._cond.notify_all()
|
||||
|
||||
def pause(self) -> None:
|
||||
with self._lock:
|
||||
if self._state == PlaybackState.PLAYING:
|
||||
self._state = PlaybackState.PAUSED
|
||||
|
||||
def stop(self) -> None:
|
||||
with self._lock:
|
||||
self._state = PlaybackState.STOPPED
|
||||
self._stop_requested = True
|
||||
self._cond.notify_all()
|
||||
|
||||
def step_forward(self) -> None:
|
||||
"""Advance one frame (works in PAUSED state)."""
|
||||
with self._lock:
|
||||
if self._data is None:
|
||||
return
|
||||
self._step_requested = True
|
||||
self._cond.notify_all()
|
||||
|
||||
def seek(self, frame_index: int) -> None:
|
||||
"""Jump to a specific frame index."""
|
||||
with self._lock:
|
||||
if self._data is None:
|
||||
return
|
||||
self._frame_index = max(0, min(frame_index, self._data.shape[0] - 1))
|
||||
|
||||
def set_fps(self, fps: float) -> None:
|
||||
with self._lock:
|
||||
self._fps = max(0.1, min(60.0, fps))
|
||||
|
||||
def set_loop(self, loop: bool) -> None:
|
||||
with self._lock:
|
||||
self._loop = loop
|
||||
|
||||
# ---- State queries (thread-safe) ---------------------------------------
|
||||
|
||||
@property
|
||||
def state(self) -> PlaybackState:
|
||||
with self._lock:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def frame_index(self) -> int:
|
||||
with self._lock:
|
||||
return self._frame_index
|
||||
|
||||
@property
|
||||
def info(self) -> RawIQFileInfo | None:
|
||||
with self._lock:
|
||||
return self._info
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
with self._lock:
|
||||
return self._fps
|
||||
|
||||
@property
|
||||
def is_loaded(self) -> bool:
|
||||
with self._lock:
|
||||
return self._data is not None
|
||||
|
||||
# ---- Frame delivery (called from worker thread) ------------------------
|
||||
|
||||
def next_frame(self) -> np.ndarray | None:
|
||||
"""Block until the next frame is available, then return it.
|
||||
|
||||
Returns None when playback is stopped or file is unloaded.
|
||||
The caller (worker thread) should use this in a loop.
|
||||
"""
|
||||
with self._cond:
|
||||
while True:
|
||||
if self._stop_requested or self._data is None:
|
||||
return None
|
||||
|
||||
if self._state == PlaybackState.PLAYING:
|
||||
return self._deliver_frame()
|
||||
|
||||
if self._step_requested:
|
||||
self._step_requested = False
|
||||
return self._deliver_frame()
|
||||
|
||||
# PAUSED or STOPPED — wait for signal
|
||||
self._cond.wait(timeout=0.1)
|
||||
|
||||
def _deliver_frame(self) -> np.ndarray | None:
|
||||
"""Return current frame and advance index. Must hold lock."""
|
||||
if self._data is None:
|
||||
return None
|
||||
|
||||
n_frames = self._data.shape[0]
|
||||
if self._frame_index >= n_frames:
|
||||
if self._loop:
|
||||
self._frame_index = 0
|
||||
else:
|
||||
self._state = PlaybackState.PAUSED
|
||||
return None
|
||||
|
||||
# Read the frame (memory-mapped, so this is cheap)
|
||||
frame = np.array(self._data[self._frame_index]) # copy from mmap
|
||||
self._frame_index += 1
|
||||
return frame
|
||||
Reference in new issue
Block a user