fix(gui): P-6 / PR-Q.6 — workers route detections through CRT extractor (C-5)

Both live and replay paths used the legacy single-PRI extractor with the
LONG-PRI v_res placeholder, which yielded wrong velocities for the SHORT
and MEDIUM sub-frames. PR-Q.5 already provided extract_targets_from_frame_crt
(48-bin, 3-PRI Chinese-Remainder-Theorem unfolding) — this PR wires it in.

Changes:
  - workers.py imports extract_targets_from_frame_crt at module scope.
  - RadarDataWorker._run_host_dsp delegates to the CRT extractor and then
    applies GPS pitch correction + DBSCAN clustering + Kalman tracking
    on the returned targets. Inline det_indices loop and
    velocity_resolution_long_mps placeholder removed.
  - ReplayWorker.__init__ binds _extract_targets to the CRT extractor;
    _emit_frame call simplifies to (frame, waveform, gps=).
  - 32-bin legacy recordings still work via the CRT extractor's internal
    fallback to extract_targets_from_frame.
  - Module docstring stale "(64x32)" -> "(512x48)".
  - Dropped unused `import numpy as np` from workers.py (no remaining users).

Tests (TestWorkersRouteThroughCrt, +4):
  - 3-PRI detection produces CONFIRMED + alias_set (was UNKNOWN before).
  - GPS pitch correction applied post-CRT to elevation.
  - Both clustering+tracking off → returns [] (no DSP work).
  - ReplayWorker._extract_targets is exactly the CRT function reference.

Regression: 232/232 (test_v7 115 + test_GUI_V65_Tk 117). Ruff clean.
Closes audit P-6 / task PR-Q.6 — C-5 host wiring complete (PR-Q.7 dashboard
display column is the remaining piece).
This commit is contained in:
Jason
2026-05-02 16:47:05 +05:45
parent b505266f33
commit 3401d05eca
2 changed files with 117 additions and 82 deletions
+89
View File
@@ -1492,6 +1492,95 @@ class TestExtractTargetsFromFrameCrt(unittest.TestCase):
self.assertGreater(targets[0].longitude, 12.5) self.assertGreater(targets[0].longitude, 12.5)
# =============================================================================
# Test: PR-Q.6 — workers route through extract_targets_from_frame_crt
# RadarDataWorker._run_host_dsp + ReplayWorker._extract_targets must use the
# 3-PRI CRT extractor, not the legacy single-PRI placeholder.
# =============================================================================
@unittest.skipUnless(_pyqt6_available(), "PyQt6 not installed")
class TestWorkersRouteThroughCrt(unittest.TestCase):
"""Audit P-6: live and replay paths must use CRT extractor on 48-bin frames."""
def _make_48bin_frame(self, det_cells_with_mag):
from radar_protocol import RadarFrame
frame = RadarFrame()
for rbin, dbin, mag in det_cells_with_mag:
frame.detections[rbin, dbin] = 1
frame.magnitude[rbin, dbin] = mag
frame.detection_count = int(frame.detections.sum())
frame.timestamp = 1.0
return frame
def test_radar_data_worker_run_host_dsp_uses_crt(self):
"""3-sub-frame detection → target with CRT confidence (not UNKNOWN)."""
from v7.workers import RadarDataWorker
from v7.processing import RadarProcessor
from v7.models import ProcessingConfig
proc = RadarProcessor()
cfg = ProcessingConfig()
cfg.clustering_enabled = False
cfg.tracking_enabled = True
proc.set_config(cfg)
worker = RadarDataWorker(connection=None, processor=proc)
frame = self._make_48bin_frame([
(10, 3, 1000.0), (10, 19, 800.0), (10, 35, 1200.0),
])
targets = worker._run_host_dsp(frame)
self.assertEqual(len(targets), 1)
# Legacy path returned UNKNOWN; CRT returns CONFIRMED for 3-PRI.
self.assertEqual(targets[0].velocity_confidence, "CONFIRMED")
self.assertIsNotNone(targets[0].alias_set)
def test_radar_data_worker_pitch_correction_applied_post_crt(self):
"""GPS pitch is applied to elevation after CRT extraction."""
from v7.workers import RadarDataWorker
from v7.processing import RadarProcessor
from v7.models import ProcessingConfig, GPSData
proc = RadarProcessor()
cfg = ProcessingConfig()
cfg.clustering_enabled = False
cfg.tracking_enabled = True
proc.set_config(cfg)
gps = GPSData(latitude=0.0, longitude=0.0, altitude=0.0,
pitch=12.5, heading=0.0)
worker = RadarDataWorker(connection=None, processor=proc,
gps_data_ref=gps)
frame = self._make_48bin_frame([
(10, 3, 1000.0), (10, 19, 800.0), (10, 35, 1200.0),
])
targets = worker._run_host_dsp(frame)
self.assertEqual(len(targets), 1)
# apply_pitch_correction(raw=0.0, pitch=12.5) = raw - pitch = -12.5.
self.assertAlmostEqual(targets[0].elevation, -12.5, places=2)
def test_radar_data_worker_skips_dsp_when_both_disabled(self):
"""When clustering and tracking are off, _run_host_dsp returns []."""
from v7.workers import RadarDataWorker
from v7.processing import RadarProcessor
from v7.models import ProcessingConfig
proc = RadarProcessor()
cfg = ProcessingConfig()
cfg.clustering_enabled = False
cfg.tracking_enabled = False
proc.set_config(cfg)
worker = RadarDataWorker(connection=None, processor=proc)
frame = self._make_48bin_frame([
(10, 3, 1000.0), (10, 19, 800.0), (10, 35, 1200.0),
])
self.assertEqual(worker._run_host_dsp(frame), [])
def test_replay_worker_extract_bound_to_crt(self):
"""ReplayWorker._extract_targets must be the CRT function, not legacy."""
from v7.workers import ReplayWorker
from v7.processing import extract_targets_from_frame_crt
class _DummyEngine:
total_frames = 0
worker = ReplayWorker(replay_engine=_DummyEngine())
self.assertIs(worker._extract_targets, extract_targets_from_frame_crt)
# ============================================================================= # =============================================================================
# Helper: lazy import of v7.models # Helper: lazy import of v7.models
# ============================================================================= # =============================================================================
+28 -82
View File
@@ -3,7 +3,7 @@ v7.workers — QThread-based workers and demo target simulator.
Classes: Classes:
- RadarDataWorker reads from FT2232H via production RadarAcquisition, - RadarDataWorker reads from FT2232H via production RadarAcquisition,
parses 0xAA/0xBB packets, assembles 64x32 frames, parses bulk-frame v2 packets, assembles 512x48 frames,
runs host-side DSP, emits PyQt signals. runs host-side DSP, emits PyQt signals.
- GPSDataWorker reads GPS frames from STM32 CDC, emits GPSData signals. - GPSDataWorker reads GPS frames from STM32 CDC, emits GPSData signals.
- TargetSimulator QTimer-based demo target generator. - TargetSimulator QTimer-based demo target generator.
@@ -19,8 +19,6 @@ import queue
import struct import struct
import logging import logging
import numpy as np
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
from .models import RadarTarget, GPSData, RadarSettings from .models import RadarTarget, GPSData, RadarSettings
@@ -36,6 +34,7 @@ from .processing import (
USBPacketParser, USBPacketParser,
apply_pitch_correction, apply_pitch_correction,
polar_to_geographic, polar_to_geographic,
extract_targets_from_frame_crt,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,11 +51,11 @@ class RadarDataWorker(QThread):
and emits PyQt signals with results. and emits PyQt signals with results.
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). assembly (bulk-frame v2 512x48 RadarFrame). For replay, use
For replay, use ReplayWorker instead. ReplayWorker instead.
Signals: Signals:
frameReady(RadarFrame) a complete 64x32 radar frame frameReady(RadarFrame) a complete 512x48 radar frame
statusReceived(object) StatusResponse from FPGA statusReceived(object) StatusResponse from FPGA
targetsUpdated(list) list of RadarTarget after host-side DSP targetsUpdated(list) list of RadarTarget after host-side DSP
errorOccurred(str) error message errorOccurred(str) error message
@@ -171,81 +170,35 @@ class RadarDataWorker(QThread):
def _run_host_dsp(self, frame: RadarFrame) -> list[RadarTarget]: def _run_host_dsp(self, frame: RadarFrame) -> list[RadarTarget]:
""" """
Run host-side DSP on a complete frame. Run host-side DSP on a complete frame.
This is where DBSCAN clustering, Kalman tracking, and other
non-timing-critical processing happens.
The FPGA already does: FFT, MTI, CFAR, DC notch. FPGA already provides: FFT, MTI, CFAR, DC notch (48 doppler bins =
Host-side DSP adds: clustering, tracking, geo-coordinate mapping. 3 sub-frames * 16). Host-side adds: 3-PRI CRT Doppler unfolding,
geo-mapping, pitch correction, DBSCAN clustering, Kalman tracking.
Bin-to-physical conversion uses RadarSettings.range_resolution PR-Q.6: detections route through ``extract_targets_from_frame_crt``
and velocity_resolution (should be calibrated to actual waveform). which derives the per-target velocity from the high 2 bits of the
Doppler bin (sub-frame ID) and runs the Chinese-Remainder Theorem
unfolder when 2 sub-frames see the same range. The legacy LONG-PRI
placeholder path is gone.
""" """
targets: list[RadarTarget] = []
cfg = self._processor.config cfg = self._processor.config
if not (cfg.clustering_enabled or cfg.tracking_enabled): if not (cfg.clustering_enabled or cfg.tracking_enabled):
return targets return []
# Extract detections from FPGA CFAR flags targets = extract_targets_from_frame_crt(
det_indices = np.argwhere(frame.detections > 0) frame, self._waveform, gps=self._gps,
r_res = self._waveform.range_resolution_m
# PR-Q.4: per-subframe Doppler velocity is unfolded by the CRT
# extractor in PR-Q.5; until that lands, treat the 48-bin output
# as a single-PRI grid using the LONG-PRI v_res (most conservative
# — smallest v_unamb). This intentionally yields wrong velocities
# for SHORT/MEDIUM sub-frame bins until PR-Q.5 replaces this path
# with extract_targets_from_frame_crt.
v_res = self._waveform.velocity_resolution_long_mps
n_doppler = (
frame.detections.shape[1]
if frame.detections.ndim == 2
else self._waveform.n_doppler_bins
) )
doppler_center = n_doppler // 2
for idx in det_indices: # Pitch correction: extract_targets_from_frame_crt sets elevation=0.0
rbin, dbin = idx # because the array is single-beam. Adjust by GPS pitch when known.
mag = frame.magnitude[rbin, dbin] if self._gps and targets:
snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0 corrected_pitch = apply_pitch_correction(0.0, self._gps.pitch)
for t in targets:
t.elevation = corrected_pitch
# Convert bin indices to physical units if cfg.clustering_enabled and targets:
range_m = float(rbin) * r_res
velocity_ms = float(dbin - doppler_center) * v_res
# Apply pitch correction if GPS data available
raw_elev = 0.0 # FPGA doesn't send elevation per-detection
corr_elev = raw_elev
if self._gps:
corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
# Compute geographic position if GPS available
lat, lon = 0.0, 0.0
azimuth = 0.0 # No azimuth from single-beam; set to heading
if self._gps:
azimuth = self._gps.heading
lat, lon = polar_to_geographic(
self._gps.latitude, self._gps.longitude,
range_m, azimuth,
)
target = RadarTarget(
id=len(targets),
range=range_m,
velocity=velocity_ms,
azimuth=azimuth,
elevation=corr_elev,
latitude=lat,
longitude=lon,
snr=snr,
timestamp=frame.timestamp,
)
targets.append(target)
# DBSCAN clustering
if cfg.clustering_enabled and len(targets) > 0:
clusters = self._processor.clustering( clusters = self._processor.clustering(
targets, cfg.clustering_eps, cfg.clustering_min_samples) targets, cfg.clustering_eps, cfg.clustering_min_samples)
# Associate and track
if cfg.tracking_enabled: if cfg.tracking_enabled:
targets = self._processor.association(targets, clusters) targets = self._processor.association(targets, clusters)
self._processor.tracking(targets) self._processor.tracking(targets)
@@ -462,7 +415,6 @@ class ReplayWorker(QThread):
super().__init__(parent) super().__init__(parent)
import threading import threading
from .processing import extract_targets_from_frame
from .models import WaveformConfig from .models import WaveformConfig
self._engine = replay_engine self._engine = replay_engine
@@ -470,7 +422,10 @@ class ReplayWorker(QThread):
self._gps = gps self._gps = gps
self._waveform = WaveformConfig() self._waveform = WaveformConfig()
self._frame_interval_ms = frame_interval_ms self._frame_interval_ms = frame_interval_ms
self._extract_targets = extract_targets_from_frame # PR-Q.6: replay path uses the same 3-PRI CRT extractor as the live
# worker so 48-bin frames yield CRT-unfolded velocities; 32-bin
# legacy recordings fall back to single-PRI inside the extractor.
self._extract_targets = extract_targets_from_frame_crt
self._current_index = 0 self._current_index = 0
self._last_emitted_index = 0 self._last_emitted_index = 0
@@ -575,16 +530,7 @@ class ReplayWorker(QThread):
self.frameReady.emit(frame) self.frameReady.emit(frame)
self.frameIndexChanged.emit(index, self._engine.total_frames) self.frameIndexChanged.emit(index, self._engine.total_frames)
# Target extraction. PR-Q.4: single LONG-PRI v_res placeholder; targets = self._extract_targets(frame, self._waveform, gps=self._gps)
# PR-Q.5 replaces this call with extract_targets_from_frame_crt
# which derives per-subframe velocity from the high 2 bits of
# doppler_bin and runs 3-PRI CRT unfolding.
targets = self._extract_targets(
frame,
range_resolution=self._waveform.range_resolution_m,
velocity_resolution=self._waveform.velocity_resolution_long_mps,
gps=self._gps,
)
self.targetsUpdated.emit(targets) self.targetsUpdated.emit(targets)
self.statsUpdated.emit({ self.statsUpdated.emit({
"frame_number": frame.frame_number, "frame_number": frame.frame_number,