diff --git a/9_Firmware/9_3_GUI/radar_protocol.py b/9_Firmware/9_3_GUI/radar_protocol.py index 779755f..eb68986 100644 --- a/9_Firmware/9_3_GUI/radar_protocol.py +++ b/9_Firmware/9_3_GUI/radar_protocol.py @@ -68,8 +68,8 @@ DATA_PACKET_SIZE = 11 # 1 + 4 + 2 + 2 + 1 + 1 STATUS_PACKET_SIZE = 26 # 1 + 24 + 1 NUM_RANGE_BINS = 512 -NUM_DOPPLER_BINS = 32 -NUM_CELLS = NUM_RANGE_BINS * NUM_DOPPLER_BINS # 16384 +NUM_DOPPLER_BINS = 48 # PR-F/PR-Q: 3 sub-frames * 16 (matches FPGA RP_NUM_DOPPLER_BINS) +NUM_CELLS = NUM_RANGE_BINS * NUM_DOPPLER_BINS # 24576 WATERFALL_DEPTH = 64 diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index 7c42add..b686847 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -428,10 +428,14 @@ class TestWaveformConfig(unittest.TestCase): self.assertEqual(wc.sample_rate_hz, 100e6) self.assertEqual(wc.bandwidth_hz, 20e6) self.assertEqual(wc.chirp_duration_s, 30e-6) - self.assertEqual(wc.pri_s, 167e-6) + # PR-Q: 3 staggered PRIs (SHORT 175, MEDIUM 161, LONG 167 us) + self.assertEqual(wc.pri_short_s, 175e-6) + self.assertEqual(wc.pri_medium_s, 161e-6) + self.assertEqual(wc.pri_long_s, 167e-6) self.assertEqual(wc.center_freq_hz, 10.5e9) self.assertEqual(wc.n_range_bins, 512) - self.assertEqual(wc.n_doppler_bins, 32) + self.assertEqual(wc.n_doppler_bins, 48) + self.assertEqual(wc.num_subframes, 3) self.assertEqual(wc.chirps_per_subframe, 16) self.assertEqual(wc.fft_size, 2048) self.assertEqual(wc.decimation_factor, 4) @@ -442,11 +446,20 @@ class TestWaveformConfig(unittest.TestCase): wc = WaveformConfig() self.assertAlmostEqual(wc.range_resolution_m, 5.996, places=2) - def test_velocity_resolution(self): - """velocity_resolution_mps should be ~5.34 m/s/bin (PRI=167us, 16 chirps).""" + def test_velocity_resolution_per_subframe(self): + """Per-subframe v_res = lambda / (2 * 16 * PRI), PR-Q stagger.""" from v7.models import WaveformConfig wc = WaveformConfig() - self.assertAlmostEqual(wc.velocity_resolution_mps, 5.343, places=1) + # lambda = c / 10.5e9 = 0.02856 m + # SHORT 175 us: 0.02856 / (32 * 175e-6) = 5.099 m/s/bin + # MEDIUM 161 us: 0.02856 / (32 * 161e-6) = 5.543 m/s/bin + # LONG 167 us: 0.02856 / (32 * 167e-6) = 5.343 m/s/bin + self.assertAlmostEqual(wc.velocity_resolution_short_mps, 5.099, places=2) + self.assertAlmostEqual(wc.velocity_resolution_medium_mps, 5.543, places=2) + self.assertAlmostEqual(wc.velocity_resolution_long_mps, 5.343, places=2) + # Smallest PRI (MEDIUM) gives largest v_res → largest v_unamb. + self.assertGreater(wc.velocity_resolution_medium_mps, wc.velocity_resolution_long_mps) + self.assertGreater(wc.velocity_resolution_medium_mps, wc.velocity_resolution_short_mps) def test_max_range(self): """max_range_m = range_resolution * n_range_bins.""" @@ -454,15 +467,29 @@ class TestWaveformConfig(unittest.TestCase): wc = WaveformConfig() self.assertAlmostEqual(wc.max_range_m, wc.range_resolution_m * 512, places=1) - def test_max_velocity(self): - """max_velocity_mps = velocity_resolution * n_doppler_bins / 2.""" + def test_max_velocity_per_subframe(self): + """Per-subframe v_unamb = v_res * chirps_per_subframe / 2.""" from v7.models import WaveformConfig wc = WaveformConfig() - self.assertAlmostEqual( - wc.max_velocity_mps, - wc.velocity_resolution_mps * 16, - places=2, - ) + for vmax, vres in [ + (wc.max_velocity_short_mps, wc.velocity_resolution_short_mps), + (wc.max_velocity_medium_mps, wc.velocity_resolution_medium_mps), + (wc.max_velocity_long_mps, wc.velocity_resolution_long_mps), + ]: + self.assertAlmostEqual(vmax, vres * 8.0, places=2) + + def test_extended_max_velocity_crt(self): + """CRT-extended v_unamb = max(per-subframe v_unamb) * K.""" + from v7.models import WaveformConfig + wc = WaveformConfig() + # MEDIUM has the largest per-subframe v_unamb (smallest PRI). + # K=6 default → ~266 m/s; well above UAS speeds 50–80 m/s. + v6 = wc.extended_max_velocity_mps_crt() + self.assertAlmostEqual(v6, wc.max_velocity_medium_mps * 6, places=2) + # K=3 should give half of K=6. + v3 = wc.extended_max_velocity_mps_crt(max_alias_k=3) + self.assertAlmostEqual(v3, wc.max_velocity_medium_mps * 3, places=2) + self.assertAlmostEqual(v6, 2.0 * v3, places=2) def test_custom_params(self): """Non-default parameters correctly change derived values.""" @@ -472,11 +499,15 @@ class TestWaveformConfig(unittest.TestCase): self.assertAlmostEqual(wc2.range_resolution_m, wc1.range_resolution_m / 2, places=2) def test_zero_center_freq_velocity(self): - """Zero center freq should cause ZeroDivisionError in velocity calc.""" + """Zero center freq should ZeroDivisionError in any per-subframe velocity calc.""" from v7.models import WaveformConfig wc = WaveformConfig(center_freq_hz=0.0) with self.assertRaises(ZeroDivisionError): - _ = wc.velocity_resolution_mps + _ = wc.velocity_resolution_long_mps + with self.assertRaises(ZeroDivisionError): + _ = wc.velocity_resolution_short_mps + with self.assertRaises(ZeroDivisionError): + _ = wc.velocity_resolution_medium_mps # ============================================================================= @@ -926,7 +957,8 @@ class TestExtractTargetsFromFrame(unittest.TestCase): def test_single_detection_range(self): """Detection at range bin 10 → range = 10 * range_resolution.""" from v7.processing import extract_targets_from_frame - frame = self._make_frame(det_cells=[(10, 16)]) # dbin=16 = center → vel=0 + # PR-Q: n_doppler_bins=48 → centre bin = 24 (was 16 in 32-bin world). + frame = self._make_frame(det_cells=[(10, 24)]) targets = extract_targets_from_frame(frame, range_resolution=5.996) self.assertEqual(len(targets), 1) self.assertAlmostEqual(targets[0].range, 10 * 5.996, places=1) @@ -935,10 +967,11 @@ class TestExtractTargetsFromFrame(unittest.TestCase): def test_velocity_sign(self): """Doppler bin < center → negative velocity, > center → positive.""" from v7.processing import extract_targets_from_frame - frame = self._make_frame(det_cells=[(5, 10), (5, 20)]) + # PR-Q: centre = 24 in 48-bin frame. dbin=10 below, dbin=30 above. + frame = self._make_frame(det_cells=[(5, 10), (5, 30)]) targets = extract_targets_from_frame(frame, velocity_resolution=1.484) - # dbin=10: vel = (10-16)*1.484 = -8.904 (approaching) - # dbin=20: vel = (20-16)*1.484 = +5.936 (receding) + # dbin=10: vel = (10-24)*1.484 = -20.776 (approaching) + # dbin=30: vel = (30-24)*1.484 = +8.904 (receding) self.assertLess(targets[0].velocity, 0) self.assertGreater(targets[1].velocity, 0) diff --git a/9_Firmware/9_3_GUI/v7/dashboard.py b/9_Firmware/9_3_GUI/v7/dashboard.py index 8c7233f..52c9280 100644 --- a/9_Firmware/9_3_GUI/v7/dashboard.py +++ b/9_Firmware/9_3_GUI/v7/dashboard.py @@ -73,9 +73,9 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -# Frame dimensions from FPGA +# Frame dimensions from FPGA (mirrors radar_protocol.NUM_*; PR-F/PR-Q) NUM_RANGE_BINS = 64 -NUM_DOPPLER_BINS = 32 +NUM_DOPPLER_BINS = 48 # Force C locale (period as decimal separator) for all QDoubleSpinBox instances. _C_LOCALE = QLocale(QLocale.Language.C) @@ -94,7 +94,7 @@ def _make_dspin() -> QDoubleSpinBox: # ============================================================================= class RangeDopplerCanvas(FigureCanvasQTAgg): - """Matplotlib canvas showing the 64x32 Range-Doppler map with dark theme.""" + """Matplotlib canvas showing the Range-Doppler map (NUM_RANGE_BINS x NUM_DOPPLER_BINS) with dark theme.""" def __init__(self, _parent=None): fig = Figure(figsize=(10, 6), facecolor=DARK_BG) @@ -106,7 +106,10 @@ class RangeDopplerCanvas(FigureCanvasQTAgg): extent=[0, NUM_DOPPLER_BINS, 0, NUM_RANGE_BINS], origin="lower", ) - self.ax.set_title("Range-Doppler Map (64x32)", color=DARK_FG) + self.ax.set_title( + f"Range-Doppler Map ({NUM_RANGE_BINS}x{NUM_DOPPLER_BINS})", + color=DARK_FG, + ) self.ax.set_xlabel("Doppler Bin", color=DARK_FG) self.ax.set_ylabel("Range Bin", color=DARK_FG) self.ax.tick_params(colors=DARK_FG) diff --git a/9_Firmware/9_3_GUI/v7/models.py b/9_Firmware/9_3_GUI/v7/models.py index 7c69c9b..4ce782e 100644 --- a/9_Firmware/9_3_GUI/v7/models.py +++ b/9_Firmware/9_3_GUI/v7/models.py @@ -199,56 +199,109 @@ class TileServer(Enum): class WaveformConfig: """Physical waveform parameters for converting bins to SI units. - Encapsulates the radar waveform so that range/velocity resolution - can be derived automatically instead of hardcoded in RadarSettings. + PR-Q (3-PRI staggered ladder, audit C-5 Doppler unfolding): + - SHORT sub-frame: 1 us chirp / 175 us PRI + - MEDIUM sub-frame: 5 us chirp / 161 us PRI + - LONG sub-frame: 30 us chirp / 167 us PRI - Defaults match the AERIS-10 production system parameters from - radar_scene.py / plfm_chirp_controller.v: - 100 MSPS DDC output, 20 MHz chirp BW, 30 us long chirp, - 167 us long-chirp PRI, X-band 10.5 GHz carrier. + Each sub-frame produces ``chirps_per_subframe`` Doppler bins + (16 → 48 total). Per-subframe v_unamb is ~+/-42 m/s; the host runs + 3-PRI Chinese-Remainder unfolding (see PR-Q.5 + processing.unfold_velocity_crt) to recover targets out to + ``extended_max_velocity_mps_crt``. """ sample_rate_hz: float = 100e6 # DDC output I/Q rate (matched filter input) - bandwidth_hz: float = 20e6 # Chirp bandwidth (not used in range calc; - # retained for time-bandwidth product / display) - chirp_duration_s: float = 30e-6 # Long chirp ramp time - pri_s: float = 167e-6 # Pulse repetition interval (chirp + listen) - center_freq_hz: float = 10.5e9 # Carrier frequency (radar_scene.py: F_CARRIER) - n_range_bins: int = 512 # After decimation (3 km mode; 4096 in 20 km) - n_doppler_bins: int = 32 # Total Doppler bins (2 sub-frames x 16) - chirps_per_subframe: int = 16 # Chirps in one Doppler sub-frame - fft_size: int = 2048 # Pre-decimation FFT length - decimation_factor: int = 4 # 2048 → 512 + bandwidth_hz: float = 20e6 # Chirp bandwidth (time-bandwidth product / display) + chirp_duration_s: float = 30e-6 # LONG chirp ramp time (longest of the three) + # Per-subframe PRIs (PR-Q stagger; mirrors radar_params.vh + # RP_DEF_{SHORT,MEDIUM,LONG}_LISTEN_CYCLES + chirp cycles). + pri_short_s: float = 175e-6 # SHORT PRI (1 us chirp + 174 us listen) + pri_medium_s: float = 161e-6 # MEDIUM PRI (5 us chirp + 156 us listen) + pri_long_s: float = 167e-6 # LONG PRI (30 us chirp + 137 us listen) + + center_freq_hz: float = 10.5e9 # X-band carrier (radar_scene.py F_CARRIER) + n_range_bins: int = 512 # After decimation (3 km mode; 4096 in 20 km) + n_doppler_bins: int = 48 # 3 sub-frames * 16 chirps (matches RP_NUM_DOPPLER_BINS) + chirps_per_subframe: int = 16 # Chirps in one Doppler sub-frame + num_subframes: int = 3 # SHORT, MEDIUM, LONG + fft_size: int = 2048 # Pre-decimation matched-filter FFT length + decimation_factor: int = 4 # 2048 -> 512 + + # ------------------------------------------------------------------ + # Range + # ------------------------------------------------------------------ @property def range_resolution_m(self) -> float: """Meters per decimated range bin (matched-filter pulse compression). - For FFT-based matched filtering, each IFFT output bin spans - c / (2 * Fs) in range, where Fs is the I/Q sample rate at the - matched-filter input (DDC output). After decimation the bin - spacing grows by *decimation_factor*. + Each IFFT output bin spans c / (2 * Fs); after decimation the bin + spacing grows by ``decimation_factor``. """ c = 299_792_458.0 raw_bin = c / (2.0 * self.sample_rate_hz) return raw_bin * self.decimation_factor - @property - def velocity_resolution_mps(self) -> float: - """m/s per Doppler bin. - - lambda / (2 * chirps_per_subframe * PRI), matching radar_scene.py. - """ - c = 299_792_458.0 - wavelength = c / self.center_freq_hz - return wavelength / (2.0 * self.chirps_per_subframe * self.pri_s) - @property def max_range_m(self) -> float: """Maximum unambiguous range in meters.""" return self.range_resolution_m * self.n_range_bins + # ------------------------------------------------------------------ + # Velocity (per sub-frame) + # ------------------------------------------------------------------ + def _v_res(self, pri_s: float) -> float: + c = 299_792_458.0 + wavelength = c / self.center_freq_hz + return wavelength / (2.0 * self.chirps_per_subframe * pri_s) + @property - def max_velocity_mps(self) -> float: - """Maximum unambiguous velocity (±) in m/s.""" - return self.velocity_resolution_mps * self.n_doppler_bins / 2.0 + def velocity_resolution_short_mps(self) -> float: + """m/s per Doppler bin in the SHORT sub-frame.""" + return self._v_res(self.pri_short_s) + + @property + def velocity_resolution_medium_mps(self) -> float: + """m/s per Doppler bin in the MEDIUM sub-frame.""" + return self._v_res(self.pri_medium_s) + + @property + def velocity_resolution_long_mps(self) -> float: + """m/s per Doppler bin in the LONG sub-frame.""" + return self._v_res(self.pri_long_s) + + @property + def max_velocity_short_mps(self) -> float: + """Per-subframe SHORT v_unamb (+/-).""" + return self.velocity_resolution_short_mps * self.chirps_per_subframe / 2.0 + + @property + def max_velocity_medium_mps(self) -> float: + """Per-subframe MEDIUM v_unamb (+/-).""" + return self.velocity_resolution_medium_mps * self.chirps_per_subframe / 2.0 + + @property + def max_velocity_long_mps(self) -> float: + """Per-subframe LONG v_unamb (+/-).""" + return self.velocity_resolution_long_mps * self.chirps_per_subframe / 2.0 + + def extended_max_velocity_mps_crt(self, max_alias_k: int = 6) -> float: + """CRT-extended unambiguous velocity ceiling (PR-Q C-5). + + Three coprime PRIs let the host resolve aliases up to + ``max_alias_k`` folds before the alias set itself becomes + ambiguous. Returns the velocity beyond which detections must + be flagged AMBIGUOUS even after CRT unfolding. + + Ceiling is set by the largest per-subframe v_unamb (smallest + PRI) times the alias search depth. For PR-Q stagger + (175/161/167 us) with K=6 the practical ceiling is ~266 m/s, + well above typical UAS speeds (50-80 m/s). + """ + v_unamb = max( + self.max_velocity_short_mps, + self.max_velocity_medium_mps, + self.max_velocity_long_mps, + ) + return v_unamb * max_alias_k diff --git a/9_Firmware/9_3_GUI/v7/processing.py b/9_Firmware/9_3_GUI/v7/processing.py index c601097..6b8cc66 100644 --- a/9_Firmware/9_3_GUI/v7/processing.py +++ b/9_Firmware/9_3_GUI/v7/processing.py @@ -516,7 +516,7 @@ def extract_targets_from_frame( One target per detection cell. """ det_indices = np.argwhere(frame.detections > 0) - n_doppler = frame.detections.shape[1] if frame.detections.ndim == 2 else 32 + n_doppler = frame.detections.shape[1] if frame.detections.ndim == 2 else 48 doppler_center = n_doppler // 2 targets: list[RadarTarget] = [] diff --git a/9_Firmware/9_3_GUI/v7/workers.py b/9_Firmware/9_3_GUI/v7/workers.py index 21219af..406456a 100644 --- a/9_Firmware/9_3_GUI/v7/workers.py +++ b/9_Firmware/9_3_GUI/v7/workers.py @@ -189,7 +189,15 @@ class RadarDataWorker(QThread): # Extract detections from FPGA CFAR flags det_indices = np.argwhere(frame.detections > 0) r_res = self._waveform.range_resolution_m - v_res = self._waveform.velocity_resolution_mps + # 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: rbin, dbin = idx @@ -198,8 +206,7 @@ class RadarDataWorker(QThread): # Convert bin indices to physical units range_m = float(rbin) * r_res - # Doppler: centre bin (16) = 0 m/s; positive bins = approaching - velocity_ms = float(dbin - 16) * v_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 @@ -564,11 +571,14 @@ class ReplayWorker(QThread): self.frameReady.emit(frame) self.frameIndexChanged.emit(index, self._engine.total_frames) - # Target extraction + # 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_mps, + velocity_resolution=self._waveform.velocity_resolution_long_mps, gps=self._gps, ) self.targetsUpdated.emit(targets)