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.
265 lines
8.1 KiB
Python
265 lines
8.1 KiB
Python
"""
|
|
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
|