Files
overwatch/9_Firmware/9_3_GUI/v7/raw_iq_replay.py
T
Jason 2cb56e8b13 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.
2026-04-14 01:25:25 +05:45

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