mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-05-14 03:42:00 +00:00
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:
@@ -1492,6 +1492,95 @@ class TestExtractTargetsFromFrameCrt(unittest.TestCase):
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
@@ -3,7 +3,7 @@ v7.workers — QThread-based workers and demo target simulator.
|
||||
|
||||
Classes:
|
||||
- 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.
|
||||
- GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals.
|
||||
- TargetSimulator — QTimer-based demo target generator.
|
||||
@@ -19,8 +19,6 @@ import queue
|
||||
import struct
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
|
||||
|
||||
from .models import RadarTarget, GPSData, RadarSettings
|
||||
@@ -36,6 +34,7 @@ from .processing import (
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
polar_to_geographic,
|
||||
extract_targets_from_frame_crt,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -52,11 +51,11 @@ class RadarDataWorker(QThread):
|
||||
and emits PyQt signals with results.
|
||||
|
||||
Uses production radar_protocol.py for all packet parsing and frame
|
||||
assembly (11-byte 0xAA data packets → 64x32 RadarFrame).
|
||||
For replay, use ReplayWorker instead.
|
||||
assembly (bulk-frame v2 → 512x48 RadarFrame). For replay, use
|
||||
ReplayWorker instead.
|
||||
|
||||
Signals:
|
||||
frameReady(RadarFrame) — a complete 64x32 radar frame
|
||||
frameReady(RadarFrame) — a complete 512x48 radar frame
|
||||
statusReceived(object) — StatusResponse from FPGA
|
||||
targetsUpdated(list) — list of RadarTarget after host-side DSP
|
||||
errorOccurred(str) — error message
|
||||
@@ -171,81 +170,35 @@ class RadarDataWorker(QThread):
|
||||
def _run_host_dsp(self, frame: RadarFrame) -> list[RadarTarget]:
|
||||
"""
|
||||
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.
|
||||
Host-side DSP adds: clustering, tracking, geo-coordinate mapping.
|
||||
FPGA already provides: FFT, MTI, CFAR, DC notch (48 doppler bins =
|
||||
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
|
||||
and velocity_resolution (should be calibrated to actual waveform).
|
||||
PR-Q.6: detections route through ``extract_targets_from_frame_crt``
|
||||
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
|
||||
if not (cfg.clustering_enabled or cfg.tracking_enabled):
|
||||
return targets
|
||||
return []
|
||||
|
||||
# Extract detections from FPGA CFAR flags
|
||||
det_indices = np.argwhere(frame.detections > 0)
|
||||
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
|
||||
targets = extract_targets_from_frame_crt(
|
||||
frame, self._waveform, gps=self._gps,
|
||||
)
|
||||
doppler_center = n_doppler // 2
|
||||
|
||||
for idx in det_indices:
|
||||
rbin, dbin = idx
|
||||
mag = frame.magnitude[rbin, dbin]
|
||||
snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0
|
||||
# Pitch correction: extract_targets_from_frame_crt sets elevation=0.0
|
||||
# because the array is single-beam. Adjust by GPS pitch when known.
|
||||
if self._gps and targets:
|
||||
corrected_pitch = apply_pitch_correction(0.0, self._gps.pitch)
|
||||
for t in targets:
|
||||
t.elevation = corrected_pitch
|
||||
|
||||
# Convert bin indices to physical units
|
||||
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:
|
||||
if cfg.clustering_enabled and targets:
|
||||
clusters = self._processor.clustering(
|
||||
targets, cfg.clustering_eps, cfg.clustering_min_samples)
|
||||
# Associate and track
|
||||
if cfg.tracking_enabled:
|
||||
targets = self._processor.association(targets, clusters)
|
||||
self._processor.tracking(targets)
|
||||
@@ -462,7 +415,6 @@ class ReplayWorker(QThread):
|
||||
super().__init__(parent)
|
||||
import threading
|
||||
|
||||
from .processing import extract_targets_from_frame
|
||||
from .models import WaveformConfig
|
||||
|
||||
self._engine = replay_engine
|
||||
@@ -470,7 +422,10 @@ class ReplayWorker(QThread):
|
||||
self._gps = gps
|
||||
self._waveform = WaveformConfig()
|
||||
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._last_emitted_index = 0
|
||||
@@ -575,16 +530,7 @@ class ReplayWorker(QThread):
|
||||
self.frameReady.emit(frame)
|
||||
self.frameIndexChanged.emit(index, self._engine.total_frames)
|
||||
|
||||
# Target extraction. PR-Q.4: single LONG-PRI v_res placeholder;
|
||||
# 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,
|
||||
)
|
||||
targets = self._extract_targets(frame, self._waveform, gps=self._gps)
|
||||
self.targetsUpdated.emit(targets)
|
||||
self.statsUpdated.emit({
|
||||
"frame_number": frame.frame_number,
|
||||
|
||||
Reference in New Issue
Block a user