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:
Jason
2026-05-02 15:22:54 +05:45
parent 71afa96d68
commit 54627bbbe3
3 changed files with 276 additions and 43 deletions
+212
View File
@@ -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
# =============================================================================