mirror of
https://github.com/NawfalMotii79/PLFM_RADAR.git
synced 2026-06-09 23:17:33 +00:00
fix(gui): software_fpga revival post-e8b495c — port chain helpers to fpga_model
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.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user