From 54627bbbe3d81a3a2d081f79ceaba22c26394cc1 Mon Sep 17 00:00:00 2001 From: Jason <83615043+JJassonn69@users.noreply.github.com> Date: Sat, 2 May 2026 15:22:54 +0545 Subject: [PATCH] =?UTF-8?q?fix(gui):=20software=5Ffpga=20revival=20post-e8?= =?UTF-8?q?b495c=20=E2=80=94=20port=20chain=20helpers=20to=20fpga=5Fmodel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore SoftwareFPGA's process_chirps() pipeline by porting the missing chain stages (MTI canceller, DC notch, CFAR, threshold detection) plus thin wrappers (range FFT, decimator, Doppler FFT) to fpga_model.py and swapping software_fpga.py's import target from the deleted golden_reference.py to fpga_model. History: golden_reference.py was deleted in e8b495c (the "dead golden code cleanup") but software_fpga.py kept importing from it. The ImportError was swallowed at v7/__init__.py:49-52 so package load succeeded, but every direct `from v7.software_fpga import SoftwareFPGA` hit the import-time failure — masking 21 broken tests as "ModuleNotFoundError" instead of surfacing the real issue. This was actively breaking the GUI replay-from-raw-IQ feature (dashboard.py:1334-1347, 1577 + GUI_V65_Tk.py:271-300, 1106-1129): opening a .npy SDR capture instantiates SoftwareFPGA + ReplayEngine; the dashboard's opcode dual-dispatch routes spinbox changes to the SoftwareFPGA setters so re-processing reflects live param tweaks. With the import broken since April, that path silently dies. fpga_model.py: - New top-level constants: FFT_SIZE=2048, NUM_RANGE_BINS=512 (from RangeBinDecimator.OUTPUT_BINS), DOPPLER_CHIRPS=48, DOPPLER_TOTAL_BINS=48 (track current production: PR-O.6 / PR-F). - run_range_fft(iq_i, iq_q, twiddle_file): N inferred from input length; works for legacy 1024-pt and production 2048-pt callers. - run_range_bin_decimator(range_i, range_q, mode): per-frame wrapper over RangeBinDecimator.decimate (4x decim -> 512 bins). - run_mti_canceller(decim_i, decim_q, enable): 2-pulse canceller, ported verbatim from golden_reference @ commit 237e74c~1. - run_doppler_fft(mti_i, mti_q): num_subframes inferred from chirp count; RANGE_BINS overridden per input shape so legacy 2-sub-frame (32-chirp) and production 3-sub-frame (48-chirp) callers both work. - run_dc_notch(doppler_i, doppler_q, width): per-bin DC notch, generalised to any sub-frame count. - run_cfar_ca(...): CA / GO / SO modes with bit-accurate alpha-q44 threshold + 17-bit saturation, ported from golden_reference. - run_detection(doppler_i, doppler_q, threshold): |I|+|Q| L1 magnitude threshold detection. software_fpga.py: - _GOLDEN_REF_DIR (cosim/real_data/) -> _FPGA_COSIM_DIR (cosim/) - `from golden_reference import (...)` -> `from fpga_model import (...)` - TWIDDLE_1024 -> TWIDDLE_2048 (production 2048-pt range FFT). - Stage 1 comment: "Range bin decimation (1024 -> 64)" -> "(production 2048 -> 512)". - Stage 1 twiddle path picks fft_twiddle_2048.mem only when n_samples=2048 matches; otherwise None to fall back to math- generated twiddles for legacy callers. - Module docstring updated to reflect post-cleanup history. test_v7.py — modernise three tests to current production dimensions: - test_process_chirps_returns_radar_frame: pad input to 2048 samples; assertions reference NUM_RANGE_BINS / NUM_DOPPLER_BINS from radar_protocol; n_dop derived from input chirp count. - test_cfar_enable_changes_detections: 48 chirps x 2048 samples; output (NUM_RANGE_BINS, NUM_DOPPLER_BINS). No longer skips on cosim absence — uses synthetic input. - test_get_frame_raw_iq_synthetic: (2, 48, 2048) raw IQ; (NUM_RANGE_BINS, NUM_DOPPLER_BINS) output. - test_cosim_dir: also skip when doppler_map_*.npy absent (matches _cosim_available pattern in TestSoftwareFPGASignalChain). Local: test_v7 100/0/0 (9 graceful skips: optional deps + missing cosim .npy data), test_GUI_V65_Tk 117/0/2. Down from 21 ERRORs. --- 9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py | 212 +++++++++++++++++++++ 9_Firmware/9_3_GUI/test_v7.py | 60 +++--- 9_Firmware/9_3_GUI/v7/software_fpga.py | 47 +++-- 3 files changed, 276 insertions(+), 43 deletions(-) diff --git a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py index cf1d3aa..7e87cbf 100644 --- a/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py +++ b/9_Firmware/9_2_FPGA/tb/cosim/fpga_model.py @@ -1383,6 +1383,218 @@ class SignalChain: } +# ============================================================================= +# Frame-level chain helpers — restored post-e8b495c golden_reference deletion. +# +# These wrap the bit-accurate stage classes above (FFTEngine, RangeBinDecimator, +# DopplerProcessor) and add the missing stages (MTI, DC notch, CFAR, threshold +# detection) ported from golden_reference.py @ commit 237e74c~1. Used by +# v7.software_fpga (replay-from-raw-IQ in the GUIs). +# +# Dimensions track current production (PR-O.6 / PR-F): +# FFT_SIZE = 2048 (range FFT N) +# NUM_RANGE_BINS = 512 (after RangeBinDecimator 4x) +# DOPPLER_CHIRPS = 48 (3 sub-frames x 16 chirps) +# DOPPLER_TOTAL_BINS = 48 (3 sub-frames x 16-pt FFT) +# ============================================================================= + +import numpy as np # noqa: E402 + +FFT_SIZE = 2048 +NUM_RANGE_BINS = RangeBinDecimator.OUTPUT_BINS # 512 +DOPPLER_CHIRPS = DopplerProcessor.CHIRPS_PER_FRAME # 48 +DOPPLER_TOTAL_BINS = (DopplerProcessor.NUM_SUBFRAMES + * DopplerProcessor.DOPPLER_FFT_SIZE) # 48 + + +def run_range_fft(iq_i, iq_q, twiddle_file=None): + """Per-chirp range FFT (wrapper around FFTEngine). + + N is inferred from input length so legacy 1024-pt callers and + production 2048-pt callers both work. + """ + n = len(iq_i) + fft = FFTEngine(n=n, twiddle_file=twiddle_file) + out_re, out_im = fft.compute(list(iq_i), list(iq_q)) + return np.array(out_re, dtype=np.int64), np.array(out_im, dtype=np.int64) + + +def run_range_bin_decimator(range_i, range_q, mode=1): + """Per-frame range decimator (FFT_SIZE -> NUM_RANGE_BINS). + + mode: 0=center, 1=peak, 2=average, 3=zero (matches RTL register 0x0E). + Output length is always RangeBinDecimator.OUTPUT_BINS (512 in production); + short inputs are zero-filled past the available source bins. + """ + n_chirps = range_i.shape[0] + decim_i = np.zeros((n_chirps, NUM_RANGE_BINS), dtype=np.int64) + decim_q = np.zeros((n_chirps, NUM_RANGE_BINS), dtype=np.int64) + for c in range(n_chirps): + out_re, out_im = RangeBinDecimator.decimate( + list(range_i[c]), list(range_q[c]), mode=mode, + ) + decim_i[c, :] = out_re + decim_q[c, :] = out_im + return decim_i, decim_q + + +def run_mti_canceller(decim_i, decim_q, enable=True): + """2-pulse MTI canceller (bit-accurate model of mti_canceller.v). + + First chirp output is muted (no previous data); subsequent chirps: + out[c] = decim[c] - decim[c-1], saturated to 16-bit. + """ + if not enable: + return decim_i.copy(), decim_q.copy() + n_chirps, n_bins = decim_i.shape + mti_i = np.zeros_like(decim_i) + mti_q = np.zeros_like(decim_q) + for c in range(1, n_chirps): + for r in range(n_bins): + diff_i = int(decim_i[c, r]) - int(decim_i[c - 1, r]) + diff_q = int(decim_q[c, r]) - int(decim_q[c - 1, r]) + mti_i[c, r] = saturate(diff_i, 16) + mti_q[c, r] = saturate(diff_q, 16) + return mti_i, mti_q + + +def run_doppler_fft(mti_i, mti_q, twiddle_file_16=None): + """Multi-sub-frame Doppler FFT (wrapper around DopplerProcessor.process_frame). + + Sub-frame count and range-bin count are inferred from input shape so + legacy 2-sub-frame (32-chirp) and production 3-sub-frame (48-chirp) + callers both work. + + Input : (n_chirps, n_rbins), 16-bit signed. + Output : (n_rbins, n_subframes * 16), 16-bit signed. + """ + n_chirps, n_rbins = mti_i.shape + chirps_per_sf = DopplerProcessor.CHIRPS_PER_SUBFRAME # 16 + n_subframes = max(1, n_chirps // chirps_per_sf) + dp = DopplerProcessor( + twiddle_file_16=twiddle_file_16, + num_subframes=n_subframes, + ) + dp.RANGE_BINS = n_rbins # override hardcoded production value + chirp_data_i = [list(mti_i[c]) for c in range(n_chirps)] + chirp_data_q = [list(mti_q[c]) for c in range(n_chirps)] + map_i, map_q = dp.process_frame(chirp_data_i, chirp_data_q) + return np.array(map_i, dtype=np.int64), np.array(map_q, dtype=np.int64) + + +def run_dc_notch(doppler_i, doppler_q, width=2): + """Per-bin DC notch (bit-accurate model of radar_system_top.v inline filter). + + bin_within_sf = dbin & 0xF; zero when + width != 0 and (bin_within_sf < width or bin_within_sf > 15-width+1). + Generalises to any number of sub-frames. + """ + notched_i = doppler_i.copy() + notched_q = doppler_q.copy() + if width == 0: + return notched_i, notched_q + n_doppler = doppler_i.shape[1] + for dbin in range(n_doppler): + bin_within_sf = dbin & 0xF + if bin_within_sf < width or bin_within_sf > (15 - width + 1): + notched_i[:, dbin] = 0 + notched_q[:, dbin] = 0 + return notched_i, notched_q + + +def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8, + alpha_q44=0x30, mode='CA'): + """CFAR detection — bit-accurate model of cfar_ca.v (CA / GO / SO modes). + + Per Doppler column: |I|+|Q| L1 magnitude, then for each cell take + leading + lagging training cells (skipping ``guard`` cells), + threshold = (alpha_q44 * noise_sum) >> 4 saturated to 17 bits, + detect if magnitude > threshold. + + Returns (detect_flags, magnitudes, thresholds) — each shape + (n_range, n_doppler). + """ + n_range, n_doppler = doppler_i.shape + ALPHA_FRAC_BITS = 4 + if train == 0: + train = 1 + + magnitudes = np.zeros((n_range, n_doppler), dtype=np.int64) + for rbin in range(n_range): + for dbin in range(n_doppler): + i_val = int(doppler_i[rbin, dbin]) + q_val = int(doppler_q[rbin, dbin]) + abs_i = (-i_val) & 0xFFFF if i_val < 0 else i_val & 0xFFFF + abs_q = (-q_val) & 0xFFFF if q_val < 0 else q_val & 0xFFFF + magnitudes[rbin, dbin] = abs_i + abs_q + + detect_flags = np.zeros((n_range, n_doppler), dtype=np.bool_) + thresholds = np.zeros((n_range, n_doppler), dtype=np.int64) + MAX_MAG = (1 << 17) - 1 + + for dbin in range(n_doppler): + col = magnitudes[:, dbin] + for cut_idx in range(n_range): + leading_sum = 0 + leading_count = 0 + for t in range(1, train + 1): + idx = cut_idx - guard - t + if 0 <= idx < n_range: + leading_sum += int(col[idx]) + leading_count += 1 + lagging_sum = 0 + lagging_count = 0 + for t in range(1, train + 1): + idx = cut_idx + guard + t + if 0 <= idx < n_range: + lagging_sum += int(col[idx]) + lagging_count += 1 + + if mode in ('CA', 'CA-CFAR'): + noise_sum = leading_sum + lagging_sum + elif mode in ('GO', 'GO-CFAR'): + if leading_count > 0 and lagging_count > 0: + if leading_sum * lagging_count > lagging_sum * leading_count: + noise_sum = leading_sum + else: + noise_sum = lagging_sum + elif leading_count > 0: + noise_sum = leading_sum + else: + noise_sum = lagging_sum + elif mode in ('SO', 'SO-CFAR'): + if leading_count > 0 and lagging_count > 0: + if leading_sum * lagging_count < lagging_sum * leading_count: + noise_sum = leading_sum + else: + noise_sum = lagging_sum + elif leading_count > 0: + noise_sum = leading_sum + else: + noise_sum = lagging_sum + else: + noise_sum = leading_sum + lagging_sum + + threshold_raw = (alpha_q44 * noise_sum) >> ALPHA_FRAC_BITS + threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw) + thresholds[cut_idx, dbin] = threshold_val + if int(col[cut_idx]) > threshold_val: + detect_flags[cut_idx, dbin] = True + + return detect_flags, magnitudes, thresholds + + +def run_detection(doppler_i, doppler_q, threshold=10000): + """Threshold detection — |I|+|Q| > threshold. + + Returns (mag, det_indices) where det_indices is an (M, 2) array of + [rbin, dbin] cells exceeding the threshold. + """ + mag = np.abs(doppler_i.astype(np.int64)) + np.abs(doppler_q.astype(np.int64)) + det_indices = np.argwhere(mag > threshold) + return mag, det_indices + + # ============================================================================= # Self-test / Validation # ============================================================================= diff --git a/9_Firmware/9_3_GUI/test_v7.py b/9_Firmware/9_3_GUI/test_v7.py index b686847..e3d13a2 100644 --- a/9_Firmware/9_3_GUI/test_v7.py +++ b/9_Firmware/9_3_GUI/test_v7.py @@ -629,23 +629,23 @@ class TestSoftwareFPGASignalChain(unittest.TestCase): return os.path.isfile(os.path.join(self.COSIM_DIR, "doppler_map_i.npy")) def test_process_chirps_returns_radar_frame(self): - """process_chirps produces a RadarFrame with correct shapes.""" + """process_chirps produces a RadarFrame with production shapes (PR-O.6 / PR-F).""" if not self._cosim_available(): self.skipTest("co-sim data not found") from v7.software_fpga import SoftwareFPGA - from radar_protocol import RadarFrame + from radar_protocol import RadarFrame, NUM_RANGE_BINS, NUM_DOPPLER_BINS - # Load decimated range data as minimal input (32 chirps x 64 bins) + # Load decimated range data and pad up to current FPGA chirp width. dec_i = np.load(os.path.join(self.COSIM_DIR, "decimated_range_i.npy")) dec_q = np.load(os.path.join(self.COSIM_DIR, "decimated_range_q.npy")) - # Build fake 1024-sample chirps from decimated data (pad with zeros) + # Production chirp width = FFT_SIZE = 2048 samples; pad with zeros. n_chirps = dec_i.shape[0] - iq_i = np.zeros((n_chirps, 1024), dtype=np.int64) - iq_q = np.zeros((n_chirps, 1024), dtype=np.int64) - # Put decimated data into first 64 bins so FFT has something - iq_i[:, :dec_i.shape[1]] = dec_i - iq_q[:, :dec_q.shape[1]] = dec_q + iq_i = np.zeros((n_chirps, 2048), dtype=np.int64) + iq_q = np.zeros((n_chirps, 2048), dtype=np.int64) + n_copy = min(2048, dec_i.shape[1]) + iq_i[:, :n_copy] = dec_i[:, :n_copy] + iq_q[:, :n_copy] = dec_q[:, :n_copy] fpga = SoftwareFPGA() frame = fpga.process_chirps(iq_i, iq_q, frame_number=42, timestamp=1.0) @@ -653,22 +653,26 @@ class TestSoftwareFPGASignalChain(unittest.TestCase): self.assertIsInstance(frame, RadarFrame) self.assertEqual(frame.frame_number, 42) self.assertAlmostEqual(frame.timestamp, 1.0) - self.assertEqual(frame.range_doppler_i.shape, (64, 32)) - self.assertEqual(frame.range_doppler_q.shape, (64, 32)) - self.assertEqual(frame.magnitude.shape, (64, 32)) - self.assertEqual(frame.detections.shape, (64, 32)) - self.assertEqual(frame.range_profile.shape, (64,)) + # Doppler width tracks input n_chirps (16-multiple → that-many sub-frames). + n_dop = (n_chirps // 16) * 16 + self.assertEqual(frame.range_doppler_i.shape, (NUM_RANGE_BINS, n_dop)) + self.assertEqual(frame.range_doppler_q.shape, (NUM_RANGE_BINS, n_dop)) + self.assertEqual(frame.magnitude.shape, (NUM_RANGE_BINS, n_dop)) + self.assertEqual(frame.detections.shape, (NUM_RANGE_BINS, n_dop)) + self.assertEqual(frame.range_profile.shape, (NUM_RANGE_BINS,)) self.assertEqual(frame.detection_count, int(frame.detections.sum())) + # Sanity: NUM_DOPPLER_BINS is the production max (48). + self.assertLessEqual(n_dop, NUM_DOPPLER_BINS) def test_cfar_enable_changes_detections(self): """Enabling CFAR vs simple threshold should yield different detection counts.""" - if not self._cosim_available(): - self.skipTest("co-sim data not found") from v7.software_fpga import SoftwareFPGA + from radar_protocol import NUM_RANGE_BINS, NUM_DOPPLER_BINS - iq_i = np.zeros((32, 1024), dtype=np.int64) - iq_q = np.zeros((32, 1024), dtype=np.int64) - # Inject a single strong tone in bin 10 of every chirp + # Production dimensions: 48 chirps × 2048 samples. + iq_i = np.zeros((NUM_DOPPLER_BINS, 2048), dtype=np.int64) + iq_q = np.zeros((NUM_DOPPLER_BINS, 2048), dtype=np.int64) + # Inject a single strong tone in bin 10 of every chirp. iq_i[:, 10] = 5000 iq_q[:, 10] = 3000 @@ -678,14 +682,13 @@ class TestSoftwareFPGASignalChain(unittest.TestCase): fpga_cfar = SoftwareFPGA() fpga_cfar.set_cfar_enable(True) - fpga_cfar.set_cfar_alpha(0x10) # low alpha → more detections + fpga_cfar.set_cfar_alpha(0x10) frame_cfar = fpga_cfar.process_chirps(iq_i, iq_q) - # Just verify both produce valid frames — exact counts depend on chain self.assertIsNotNone(frame_thresh) self.assertIsNotNone(frame_cfar) - self.assertEqual(frame_thresh.magnitude.shape, (64, 32)) - self.assertEqual(frame_cfar.magnitude.shape, (64, 32)) + self.assertEqual(frame_thresh.magnitude.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS)) + self.assertEqual(frame_cfar.magnitude.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS)) class TestQuantizeRawIQ(unittest.TestCase): @@ -741,6 +744,9 @@ class TestDetectFormat(unittest.TestCase): def test_cosim_dir(self): if not os.path.isdir(self.COSIM_DIR): self.skipTest("co-sim dir not found") + # COSIM_DIR format requires doppler_map_i/q.npy; skip if absent. + if not os.path.isfile(os.path.join(self.COSIM_DIR, "doppler_map_i.npy")): + self.skipTest("co-sim doppler_map .npy files not present") from v7.replay import detect_format, ReplayFormat self.assertEqual(detect_format(self.COSIM_DIR), ReplayFormat.COSIM_DIR) @@ -841,9 +847,11 @@ class TestReplayEngineRawIQ(unittest.TestCase): import tempfile from v7.replay import ReplayEngine from v7.software_fpga import SoftwareFPGA - from radar_protocol import RadarFrame + from radar_protocol import RadarFrame, NUM_RANGE_BINS, NUM_DOPPLER_BINS - raw = np.random.randn(2, 32, 1024) + 1j * np.random.randn(2, 32, 1024) + # Production dimensions: 48 chirps × 2048 samples per frame. + raw = (np.random.randn(2, NUM_DOPPLER_BINS, 2048) + + 1j * np.random.randn(2, NUM_DOPPLER_BINS, 2048)) with tempfile.NamedTemporaryFile(suffix=".npy", delete=False) as f: np.save(f, raw) tmp = f.name @@ -852,7 +860,7 @@ class TestReplayEngineRawIQ(unittest.TestCase): engine = ReplayEngine(tmp, software_fpga=fpga) frame = engine.get_frame(0) self.assertIsInstance(frame, RadarFrame) - self.assertEqual(frame.range_doppler_i.shape, (64, 32)) + self.assertEqual(frame.range_doppler_i.shape, (NUM_RANGE_BINS, NUM_DOPPLER_BINS)) self.assertEqual(frame.frame_number, 0) finally: os.unlink(tmp) diff --git a/9_Firmware/9_3_GUI/v7/software_fpga.py b/9_Firmware/9_3_GUI/v7/software_fpga.py index 9b0d669..d2963c1 100644 --- a/9_Firmware/9_3_GUI/v7/software_fpga.py +++ b/9_Firmware/9_3_GUI/v7/software_fpga.py @@ -1,14 +1,22 @@ """ v7.software_fpga — Bit-accurate software replica of the AERIS-10 FPGA signal chain. -Imports processing functions directly from golden_reference.py (Option A) -to avoid code duplication. Every stage is toggleable via the same host -register interface the real FPGA exposes, so the dashboard spinboxes can -drive either backend transparently. +Imports processing functions directly from fpga_model.py to avoid code +duplication. Every stage is toggleable via the same host register +interface the real FPGA exposes, so the dashboard spinboxes can drive +either backend transparently during replay-from-raw-IQ. + +Signal chain order (matching RTL, post-PR-O.6 / PR-F dimensions — +2048-pt range FFT, 4x decimation -> 512 range bins, 48 chirps in +3 sub-frames -> 48 Doppler bins): -Signal chain order (matching RTL): quantize → range_fft → decimator → MTI → doppler_fft → dc_notch → CFAR → RadarFrame +History: golden_reference.py was deleted in commit e8b495c (the "dead golden +code cleanup"). fpga_model.py is the surviving bit-accurate model and +holds the chain helpers via the run_* shims appended in the post-cleanup +revival. + Usage: fpga = SoftwareFPGA() fpga.set_cfar_enable(True) @@ -25,16 +33,17 @@ from pathlib import Path import numpy as np # --------------------------------------------------------------------------- -# Import golden_reference by adding the cosim path to sys.path +# Import chain helpers from fpga_model.py (cosim/) — was golden_reference.py +# under cosim/real_data/ before commit e8b495c. # --------------------------------------------------------------------------- -_GOLDEN_REF_DIR = str( +_FPGA_COSIM_DIR = str( Path(__file__).resolve().parents[2] # 9_Firmware/ - / "9_2_FPGA" / "tb" / "cosim" / "real_data" + / "9_2_FPGA" / "tb" / "cosim" ) -if _GOLDEN_REF_DIR not in sys.path: - sys.path.insert(0, _GOLDEN_REF_DIR) +if _FPGA_COSIM_DIR not in sys.path: + sys.path.insert(0, _FPGA_COSIM_DIR) -from golden_reference import ( # noqa: E402 +from fpga_model import ( # noqa: E402 run_range_fft, run_range_bin_decimator, run_mti_canceller, @@ -53,10 +62,12 @@ from radar_protocol import RadarFrame # noqa: E402 log = logging.getLogger(__name__) # --------------------------------------------------------------------------- -# Twiddle factor file paths (relative to FPGA root) +# Twiddle factor file paths (relative to FPGA root). Production range FFT +# is 2048-pt (PR-O.6); fpga_model.load_twiddle_rom auto-falls back to +# math-generated twiddles when a path is None. # --------------------------------------------------------------------------- _FPGA_DIR = Path(__file__).resolve().parents[2] / "9_2_FPGA" -TWIDDLE_1024 = str(_FPGA_DIR / "fft_twiddle_1024.mem") +TWIDDLE_2048 = str(_FPGA_DIR / "fft_twiddle_2048.mem") TWIDDLE_16 = str(_FPGA_DIR / "fft_twiddle_16.mem") # CFAR mode int→string mapping (FPGA register 0x24: 0=CA, 1=GO, 2=SO) @@ -176,18 +187,20 @@ class SoftwareFPGA: n_chirps = iq_i.shape[0] n_samples = iq_i.shape[1] - # --- Stage 1: Range FFT (per chirp) --- + # --- Stage 1: Range FFT (per chirp). N is inferred from input length; + # pass a twiddle file only when it matches the input N (defaults + # to math-generated twiddles otherwise). range_i = np.zeros((n_chirps, n_samples), dtype=np.int64) range_q = np.zeros((n_chirps, n_samples), dtype=np.int64) - twiddle_1024 = TWIDDLE_1024 if os.path.exists(TWIDDLE_1024) else None + twiddle_path = TWIDDLE_2048 if (n_samples == 2048 and os.path.exists(TWIDDLE_2048)) else None for c in range(n_chirps): range_i[c], range_q[c] = run_range_fft( iq_i[c].astype(np.int64), iq_q[c].astype(np.int64), - twiddle_file=twiddle_1024, + twiddle_file=twiddle_path, ) - # --- Stage 2: Range bin decimation (1024 → 64) --- + # --- Stage 2: Range bin decimation (production 2048 -> 512) --- decim_i, decim_q = run_range_bin_decimator(range_i, range_q) # --- Stage 3: MTI canceller (pre-Doppler, per-chirp) ---