From 3401d05eca50eca704fdb204f37e12e1701e5ba1 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Sat, 2 May 2026 16:47:05 +0545 Subject: [PATCH] =?UTF-8?q?fix(gui):=20P-6=20/=20PR-Q.6=20=E2=80=94=20work?= =?UTF-8?q?ers=20route=20detections=20through=20CRT=20extractor=20(C-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- 9_Firmware/9_3_GUI/test_v7.py | 89 +++++++++++++++++++++++++ 9_Firmware/9_3_GUI/v7/workers.py | 110 ++++++++----------------------- 2 files changed, 117 insertions(+), 82 deletions(-) diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index 8b8e330..cf786de 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -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 # ============================================================================= diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index 812dd83..95cfd54 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -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,