fix: enforce strict ruff lint (17 rule sets) across entire repo

- Expand ruff config from E/F to 17 rule sets (B, RUF, SIM, PIE, T20,
  ARG, ERA, A, BLE, RET, ISC, TCH, UP, C4, PERF)
- Fix 907 lint errors across all Python files (GUI, FPGA cosim,
  schematics scripts, simulations, utilities, tools)
- Replace all blind except-Exception with specific exception types
- Remove commented-out dead code (ERA001) from cosim/simulation files
- Modernize typing: deprecated typing.List/Dict/Tuple to builtins
- Fix unused args/loop vars, ambiguous unicode, perf anti-patterns
- Delete legacy GUI files V1-V4
- Add V7 test suite, requirements files
- All CI jobs pass: ruff (0 errors), py_compile, pytest (92/92),
  MCU tests (20/20), FPGA regression (25/25)
This commit is contained in:
Jason
2026-04-12 14:18:34 +05:45
parent b6e8eda130
commit 2106e24952
54 changed files with 4619 additions and 9063 deletions
+17 -74
View File
@@ -93,7 +93,7 @@ SCENARIOS = {
def load_adc_hex(filepath):
"""Load 8-bit unsigned ADC samples from hex file."""
samples = []
with open(filepath, 'r') as f:
with open(filepath) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
@@ -106,7 +106,7 @@ def load_rtl_csv(filepath):
"""Load RTL baseband output CSV (sample_idx, baseband_i, baseband_q)."""
bb_i = []
bb_q = []
with open(filepath, 'r') as f:
with open(filepath) as f:
f.readline() # Skip header
for line in f:
line = line.strip()
@@ -125,7 +125,6 @@ def run_python_model(adc_samples):
because the RTL testbench captures the FIR output directly
(baseband_i_reg <= fir_i_out in ddc_400m.v).
"""
print(" Running Python model...")
chain = SignalChain()
result = chain.process_adc_block(adc_samples)
@@ -135,7 +134,6 @@ def run_python_model(adc_samples):
bb_i = result['fir_i_raw']
bb_q = result['fir_q_raw']
print(f" Python model: {len(bb_i)} baseband I, {len(bb_q)} baseband Q outputs")
return bb_i, bb_q
@@ -145,7 +143,7 @@ def compute_rms_error(a, b):
raise ValueError(f"Length mismatch: {len(a)} vs {len(b)}")
if len(a) == 0:
return 0.0
sum_sq = sum((x - y) ** 2 for x, y in zip(a, b))
sum_sq = sum((x - y) ** 2 for x, y in zip(a, b, strict=False))
return math.sqrt(sum_sq / len(a))
@@ -153,7 +151,7 @@ def compute_max_abs_error(a, b):
"""Compute maximum absolute error between two equal-length lists."""
if len(a) != len(b) or len(a) == 0:
return 0
return max(abs(x - y) for x, y in zip(a, b))
return max(abs(x - y) for x, y in zip(a, b, strict=False))
def compute_correlation(a, b):
@@ -235,44 +233,29 @@ def compute_signal_stats(samples):
def compare_scenario(scenario_name):
"""Run comparison for one scenario. Returns True if passed."""
if scenario_name not in SCENARIOS:
print(f"ERROR: Unknown scenario '{scenario_name}'")
print(f"Available: {', '.join(SCENARIOS.keys())}")
return False
cfg = SCENARIOS[scenario_name]
base_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print(f"Co-simulation Comparison: {cfg['description']}")
print(f"Scenario: {scenario_name}")
print("=" * 60)
# ---- Load ADC data ----
adc_path = os.path.join(base_dir, cfg['adc_hex'])
if not os.path.exists(adc_path):
print(f"ERROR: ADC hex file not found: {adc_path}")
print("Run radar_scene.py first to generate test vectors.")
return False
adc_samples = load_adc_hex(adc_path)
print(f"\nADC samples loaded: {len(adc_samples)}")
# ---- Load RTL output ----
rtl_path = os.path.join(base_dir, cfg['rtl_csv'])
if not os.path.exists(rtl_path):
print(f"ERROR: RTL CSV not found: {rtl_path}")
print("Run the RTL simulation first:")
print(f" iverilog -g2001 -DSIMULATION -DSCENARIO_{scenario_name.upper()} ...")
return False
rtl_i, rtl_q = load_rtl_csv(rtl_path)
print(f"RTL outputs loaded: {len(rtl_i)} I, {len(rtl_q)} Q samples")
# ---- Run Python model ----
py_i, py_q = run_python_model(adc_samples)
# ---- Length comparison ----
print(f"\nOutput lengths: RTL={len(rtl_i)}, Python={len(py_i)}")
len_diff = abs(len(rtl_i) - len(py_i))
print(f"Length difference: {len_diff} samples")
# ---- Signal statistics ----
rtl_i_stats = compute_signal_stats(rtl_i)
@@ -280,20 +263,10 @@ def compare_scenario(scenario_name):
py_i_stats = compute_signal_stats(py_i)
py_q_stats = compute_signal_stats(py_q)
print("\nSignal Statistics:")
print(f" RTL I: mean={rtl_i_stats['mean']:.1f}, rms={rtl_i_stats['rms']:.1f}, "
f"range=[{rtl_i_stats['min']}, {rtl_i_stats['max']}]")
print(f" RTL Q: mean={rtl_q_stats['mean']:.1f}, rms={rtl_q_stats['rms']:.1f}, "
f"range=[{rtl_q_stats['min']}, {rtl_q_stats['max']}]")
print(f" Py I: mean={py_i_stats['mean']:.1f}, rms={py_i_stats['rms']:.1f}, "
f"range=[{py_i_stats['min']}, {py_i_stats['max']}]")
print(f" Py Q: mean={py_q_stats['mean']:.1f}, rms={py_q_stats['rms']:.1f}, "
f"range=[{py_q_stats['min']}, {py_q_stats['max']}]")
# ---- Trim to common length ----
common_len = min(len(rtl_i), len(py_i))
if common_len < 10:
print(f"ERROR: Too few common samples ({common_len})")
return False
rtl_i_trim = rtl_i[:common_len]
@@ -302,18 +275,14 @@ def compare_scenario(scenario_name):
py_q_trim = py_q[:common_len]
# ---- Cross-correlation to find latency offset ----
print(f"\nLatency alignment (cross-correlation, max lag=±{MAX_LATENCY_DRIFT}):")
lag_i, corr_i = cross_correlate_lag(rtl_i_trim, py_i_trim,
lag_i, _corr_i = cross_correlate_lag(rtl_i_trim, py_i_trim,
max_lag=MAX_LATENCY_DRIFT)
lag_q, corr_q = cross_correlate_lag(rtl_q_trim, py_q_trim,
lag_q, _corr_q = cross_correlate_lag(rtl_q_trim, py_q_trim,
max_lag=MAX_LATENCY_DRIFT)
print(f" I-channel: best lag={lag_i}, correlation={corr_i:.6f}")
print(f" Q-channel: best lag={lag_q}, correlation={corr_q:.6f}")
# ---- Apply latency correction ----
best_lag = lag_i # Use I-channel lag (should be same as Q)
if abs(lag_i - lag_q) > 1:
print(f" WARNING: I and Q latency offsets differ ({lag_i} vs {lag_q})")
# Use the average
best_lag = (lag_i + lag_q) // 2
@@ -341,32 +310,20 @@ def compare_scenario(scenario_name):
aligned_py_i = aligned_py_i[:aligned_len]
aligned_py_q = aligned_py_q[:aligned_len]
print(f" Applied lag correction: {best_lag} samples")
print(f" Aligned length: {aligned_len} samples")
# ---- Error metrics (after alignment) ----
rms_i = compute_rms_error(aligned_rtl_i, aligned_py_i)
rms_q = compute_rms_error(aligned_rtl_q, aligned_py_q)
max_err_i = compute_max_abs_error(aligned_rtl_i, aligned_py_i)
max_err_q = compute_max_abs_error(aligned_rtl_q, aligned_py_q)
compute_max_abs_error(aligned_rtl_i, aligned_py_i)
compute_max_abs_error(aligned_rtl_q, aligned_py_q)
corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i)
corr_q_aligned = compute_correlation(aligned_rtl_q, aligned_py_q)
print("\nError Metrics (after alignment):")
print(f" I-channel: RMS={rms_i:.2f} LSB, max={max_err_i} LSB, corr={corr_i_aligned:.6f}")
print(f" Q-channel: RMS={rms_q:.2f} LSB, max={max_err_q} LSB, corr={corr_q_aligned:.6f}")
# ---- First/last sample comparison ----
print("\nFirst 10 samples (after alignment):")
print(
f" {'idx':>4s} {'RTL_I':>8s} {'Py_I':>8s} {'Err_I':>6s} "
f"{'RTL_Q':>8s} {'Py_Q':>8s} {'Err_Q':>6s}"
)
for k in range(min(10, aligned_len)):
ei = aligned_rtl_i[k] - aligned_py_i[k]
eq = aligned_rtl_q[k] - aligned_py_q[k]
print(f" {k:4d} {aligned_rtl_i[k]:8d} {aligned_py_i[k]:8d} {ei:6d} "
f"{aligned_rtl_q[k]:8d} {aligned_py_q[k]:8d} {eq:6d}")
# ---- Write detailed comparison CSV ----
compare_csv_path = os.path.join(base_dir, f"compare_{scenario_name}.csv")
@@ -377,7 +334,6 @@ def compare_scenario(scenario_name):
eq = aligned_rtl_q[k] - aligned_py_q[k]
f.write(f"{k},{aligned_rtl_i[k]},{aligned_py_i[k]},{ei},"
f"{aligned_rtl_q[k]},{aligned_py_q[k]},{eq}\n")
print(f"\nDetailed comparison written to: {compare_csv_path}")
# ---- Pass/Fail ----
max_rms = cfg.get('max_rms', MAX_RMS_ERROR_LSB)
@@ -443,21 +399,15 @@ def compare_scenario(scenario_name):
f"|{best_lag}| <= {MAX_LATENCY_DRIFT}"))
# ---- Report ----
print(f"\n{'' * 60}")
print("PASS/FAIL Results:")
all_pass = True
for name, ok, detail in results:
mark = "[PASS]" if ok else "[FAIL]"
print(f" {mark} {name}: {detail}")
for _name, ok, _detail in results:
if not ok:
all_pass = False
print(f"\n{'=' * 60}")
if all_pass:
print(f"SCENARIO {scenario_name.upper()}: ALL CHECKS PASSED")
pass
else:
print(f"SCENARIO {scenario_name.upper()}: SOME CHECKS FAILED")
print(f"{'=' * 60}")
pass
return all_pass
@@ -481,25 +431,18 @@ def main():
pass_count += 1
else:
overall_pass = False
print()
else:
print(f"Skipping {name}: RTL CSV not found ({cfg['rtl_csv']})")
pass
print("=" * 60)
print(f"OVERALL: {pass_count}/{run_count} scenarios passed")
if overall_pass:
print("ALL SCENARIOS PASSED")
pass
else:
print("SOME SCENARIOS FAILED")
print("=" * 60)
pass
return 0 if overall_pass else 1
else:
ok = compare_scenario(scenario)
return 0 if ok else 1
else:
# Default: DC
ok = compare_scenario('dc')
ok = compare_scenario(scenario)
return 0 if ok else 1
ok = compare_scenario('dc')
return 0 if ok else 1
if __name__ == '__main__':
@@ -4085,4 +4085,3 @@ idx,rtl_i,py_i,err_i,rtl_q,py_q,err_q
4083,21,20,1,-6,-6,0
4084,20,21,-1,-6,-6,0
4085,20,20,0,-5,-6,1
4086,20,20,0,-5,-5,0
1 idx rtl_i py_i err_i rtl_q py_q err_q
4085 4083 21 20 1 -6 -6 0
4086 4084 20 21 -1 -6 -6 0
4087 4085 20 20 0 -5 -6 1
4086 20 20 0 -5 -5 0
+14 -72
View File
@@ -73,7 +73,7 @@ def load_doppler_csv(filepath):
Returns dict: {rbin: [(dbin, i, q), ...]}
"""
data = {}
with open(filepath, 'r') as f:
with open(filepath) as f:
f.readline() # Skip header
for line in f:
line = line.strip()
@@ -117,7 +117,7 @@ def pearson_correlation(a, b):
def magnitude_l1(i_arr, q_arr):
"""L1 magnitude: |I| + |Q|."""
return [abs(i) + abs(q) for i, q in zip(i_arr, q_arr)]
return [abs(i) + abs(q) for i, q in zip(i_arr, q_arr, strict=False)]
def find_peak_bin(i_arr, q_arr):
@@ -143,7 +143,7 @@ def total_energy(data_dict):
"""Sum of I^2 + Q^2 across all range bins and Doppler bins."""
total = 0
for rbin in data_dict:
for (dbin, i_val, q_val) in data_dict[rbin]:
for (_dbin, i_val, q_val) in data_dict[rbin]:
total += i_val * i_val + q_val * q_val
return total
@@ -154,44 +154,30 @@ def total_energy(data_dict):
def compare_scenario(name, config, base_dir):
"""Compare one Doppler scenario. Returns (passed, result_dict)."""
print(f"\n{'='*60}")
print(f"Scenario: {name}{config['description']}")
print(f"{'='*60}")
golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path):
print(f" ERROR: Golden CSV not found: {golden_path}")
print(" Run: python3 gen_doppler_golden.py")
return False, {}
if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
print(" Run the Verilog testbench first")
return False, {}
py_data = load_doppler_csv(golden_path)
rtl_data = load_doppler_csv(rtl_path)
py_rbins = sorted(py_data.keys())
rtl_rbins = sorted(rtl_data.keys())
sorted(py_data.keys())
sorted(rtl_data.keys())
print(f" Python: {len(py_rbins)} range bins, "
f"{sum(len(v) for v in py_data.values())} total samples")
print(f" RTL: {len(rtl_rbins)} range bins, "
f"{sum(len(v) for v in rtl_data.values())} total samples")
# ---- Check 1: Both have data ----
py_total = sum(len(v) for v in py_data.values())
rtl_total = sum(len(v) for v in rtl_data.values())
if py_total == 0 or rtl_total == 0:
print(" ERROR: One or both outputs are empty")
return False, {}
# ---- Check 2: Output count ----
count_ok = (rtl_total == TOTAL_OUTPUTS)
print(f"\n Output count: RTL={rtl_total}, expected={TOTAL_OUTPUTS} "
f"{'OK' if count_ok else 'MISMATCH'}")
# ---- Check 3: Global energy ----
py_energy = total_energy(py_data)
@@ -201,10 +187,6 @@ def compare_scenario(name, config, base_dir):
else:
energy_ratio = 1.0 if rtl_energy == 0 else float('inf')
print("\n Global energy:")
print(f" Python: {py_energy}")
print(f" RTL: {rtl_energy}")
print(f" Ratio: {energy_ratio:.4f}")
# ---- Check 4: Per-range-bin analysis ----
peak_agreements = 0
@@ -236,8 +218,8 @@ def compare_scenario(name, config, base_dir):
i_correlations.append(corr_i)
q_correlations.append(corr_q)
py_rbin_energy = sum(i*i + q*q for i, q in zip(py_i, py_q))
rtl_rbin_energy = sum(i*i + q*q for i, q in zip(rtl_i, rtl_q))
py_rbin_energy = sum(i*i + q*q for i, q in zip(py_i, py_q, strict=False))
rtl_rbin_energy = sum(i*i + q*q for i, q in zip(rtl_i, rtl_q, strict=False))
peak_details.append({
'rbin': rbin,
@@ -255,20 +237,11 @@ def compare_scenario(name, config, base_dir):
avg_corr_i = sum(i_correlations) / len(i_correlations)
avg_corr_q = sum(q_correlations) / len(q_correlations)
print("\n Per-range-bin metrics:")
print(f" Peak Doppler bin agreement (+/-1 within sub-frame): {peak_agreements}/{RANGE_BINS} "
f"({peak_agreement_frac:.0%})")
print(f" Avg magnitude correlation: {avg_mag_corr:.4f}")
print(f" Avg I-channel correlation: {avg_corr_i:.4f}")
print(f" Avg Q-channel correlation: {avg_corr_q:.4f}")
# Show top 5 range bins by Python energy
print("\n Top 5 range bins by Python energy:")
top_rbins = sorted(peak_details, key=lambda x: -x['py_energy'])[:5]
for d in top_rbins:
print(f" rbin={d['rbin']:2d}: py_peak={d['py_peak']:2d}, "
f"rtl_peak={d['rtl_peak']:2d}, mag_corr={d['mag_corr']:.3f}, "
f"I_corr={d['corr_i']:.3f}, Q_corr={d['corr_q']:.3f}")
for _d in top_rbins:
pass
# ---- Pass/Fail ----
checks = []
@@ -291,11 +264,8 @@ def compare_scenario(name, config, base_dir):
checks.append((f'High-energy rbin avg mag_corr >= {MAG_CORR_MIN:.2f} '
f'(actual={he_mag_corr:.3f})', he_ok))
print("\n Pass/Fail Checks:")
all_pass = True
for check_name, passed in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {check_name}")
for _check_name, passed in checks:
if not passed:
all_pass = False
@@ -310,7 +280,6 @@ def compare_scenario(name, config, base_dir):
f.write(f'{rbin},{dbin},{py_i[dbin]},{py_q[dbin]},'
f'{rtl_i[dbin]},{rtl_q[dbin]},'
f'{rtl_i[dbin]-py_i[dbin]},{rtl_q[dbin]-py_q[dbin]}\n')
print(f"\n Detailed comparison: {compare_csv}")
result = {
'scenario': name,
@@ -333,25 +302,15 @@ def compare_scenario(name, config, base_dir):
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
else:
arg = 'stationary'
arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'stationary'
if arg == 'all':
run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS:
run_scenarios = [arg]
else:
print(f"Unknown scenario: {arg}")
print(f"Valid: {', '.join(SCENARIOS.keys())}, all")
sys.exit(1)
print("=" * 60)
print("Doppler Processor Co-Simulation Comparison")
print("RTL vs Python model (clean, no pipeline bug replication)")
print(f"Scenarios: {', '.join(run_scenarios)}")
print("=" * 60)
results = []
for name in run_scenarios:
@@ -359,37 +318,20 @@ def main():
results.append((name, passed, result))
# Summary
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")
print(f"\n {'Scenario':<15} {'Energy Ratio':>13} {'Mag Corr':>10} "
f"{'Peak Agree':>11} {'I Corr':>8} {'Q Corr':>8} {'Status':>8}")
print(f" {'-'*15} {'-'*13} {'-'*10} {'-'*11} {'-'*8} {'-'*8} {'-'*8}")
all_pass = True
for name, passed, result in results:
for _name, passed, result in results:
if not result:
print(f" {name:<15} {'ERROR':>13} {'':>10} {'':>11} "
f"{'':>8} {'':>8} {'FAIL':>8}")
all_pass = False
else:
status = "PASS" if passed else "FAIL"
print(f" {name:<15} {result['energy_ratio']:>13.4f} "
f"{result['avg_mag_corr']:>10.4f} "
f"{result['peak_agreement']:>10.0%} "
f"{result['avg_corr_i']:>8.4f} "
f"{result['avg_corr_q']:>8.4f} "
f"{status:>8}")
if not passed:
all_pass = False
print()
if all_pass:
print("ALL TESTS PASSED")
pass
else:
print("SOME TESTS FAILED")
print(f"{'='*60}")
pass
sys.exit(0 if all_pass else 1)
+13 -70
View File
@@ -79,7 +79,7 @@ def load_csv(filepath):
"""Load CSV with columns (bin, out_i/range_profile_i, out_q/range_profile_q)."""
vals_i = []
vals_q = []
with open(filepath, 'r') as f:
with open(filepath) as f:
f.readline() # Skip header
for line in f:
line = line.strip()
@@ -93,17 +93,17 @@ def load_csv(filepath):
def magnitude_spectrum(vals_i, vals_q):
"""Compute magnitude = |I| + |Q| for each bin (L1 norm, matches RTL)."""
return [abs(i) + abs(q) for i, q in zip(vals_i, vals_q)]
return [abs(i) + abs(q) for i, q in zip(vals_i, vals_q, strict=False)]
def magnitude_l2(vals_i, vals_q):
"""Compute magnitude = sqrt(I^2 + Q^2) for each bin."""
return [math.sqrt(i*i + q*q) for i, q in zip(vals_i, vals_q)]
return [math.sqrt(i*i + q*q) for i, q in zip(vals_i, vals_q, strict=False)]
def total_energy(vals_i, vals_q):
"""Compute total energy (sum of I^2 + Q^2)."""
return sum(i*i + q*q for i, q in zip(vals_i, vals_q))
return sum(i*i + q*q for i, q in zip(vals_i, vals_q, strict=False))
def rms_magnitude(vals_i, vals_q):
@@ -111,7 +111,7 @@ def rms_magnitude(vals_i, vals_q):
n = len(vals_i)
if n == 0:
return 0.0
return math.sqrt(sum(i*i + q*q for i, q in zip(vals_i, vals_q)) / n)
return math.sqrt(sum(i*i + q*q for i, q in zip(vals_i, vals_q, strict=False)) / n)
def pearson_correlation(a, b):
@@ -144,7 +144,7 @@ def find_peak(vals_i, vals_q):
def top_n_peaks(mags, n=10):
"""Find the top-N peak bins by magnitude. Returns set of bin indices."""
indexed = sorted(enumerate(mags), key=lambda x: -x[1])
return set(idx for idx, _ in indexed[:n])
return {idx for idx, _ in indexed[:n]}
def spectral_peak_overlap(mags_a, mags_b, n=10):
@@ -163,30 +163,20 @@ def spectral_peak_overlap(mags_a, mags_b, n=10):
def compare_scenario(scenario_name, config, base_dir):
"""Compare one scenario. Returns (pass/fail, result_dict)."""
print(f"\n{'='*60}")
print(f"Scenario: {scenario_name}{config['description']}")
print(f"{'='*60}")
golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path):
print(f" ERROR: Golden CSV not found: {golden_path}")
print(" Run: python3 gen_mf_cosim_golden.py")
return False, {}
if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
print(" Run the RTL testbench first")
return False, {}
py_i, py_q = load_csv(golden_path)
rtl_i, rtl_q = load_csv(rtl_path)
print(f" Python model: {len(py_i)} samples")
print(f" RTL output: {len(rtl_i)} samples")
if len(py_i) != FFT_SIZE or len(rtl_i) != FFT_SIZE:
print(f" ERROR: Expected {FFT_SIZE} samples from each")
return False, {}
# ---- Metric 1: Energy ----
@@ -205,28 +195,17 @@ def compare_scenario(scenario_name, config, base_dir):
energy_ratio = float('inf') if py_energy == 0 else 0.0
rms_ratio = float('inf') if py_rms == 0 else 0.0
print("\n Energy:")
print(f" Python total energy: {py_energy}")
print(f" RTL total energy: {rtl_energy}")
print(f" Energy ratio (RTL/Py): {energy_ratio:.4f}")
print(f" Python RMS: {py_rms:.2f}")
print(f" RTL RMS: {rtl_rms:.2f}")
print(f" RMS ratio (RTL/Py): {rms_ratio:.4f}")
# ---- Metric 2: Peak location ----
py_peak_bin, py_peak_mag = find_peak(py_i, py_q)
rtl_peak_bin, rtl_peak_mag = find_peak(rtl_i, rtl_q)
py_peak_bin, _py_peak_mag = find_peak(py_i, py_q)
rtl_peak_bin, _rtl_peak_mag = find_peak(rtl_i, rtl_q)
print("\n Peak location:")
print(f" Python: bin={py_peak_bin}, mag={py_peak_mag}")
print(f" RTL: bin={rtl_peak_bin}, mag={rtl_peak_mag}")
# ---- Metric 3: Magnitude spectrum correlation ----
py_mag = magnitude_l2(py_i, py_q)
rtl_mag = magnitude_l2(rtl_i, rtl_q)
mag_corr = pearson_correlation(py_mag, rtl_mag)
print(f"\n Magnitude spectrum correlation: {mag_corr:.6f}")
# ---- Metric 4: Top-N peak overlap ----
# Use L1 magnitudes for peak finding (matches RTL)
@@ -235,16 +214,11 @@ def compare_scenario(scenario_name, config, base_dir):
peak_overlap_10 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=10)
peak_overlap_20 = spectral_peak_overlap(py_mag_l1, rtl_mag_l1, n=20)
print(f" Top-10 peak overlap: {peak_overlap_10:.2%}")
print(f" Top-20 peak overlap: {peak_overlap_20:.2%}")
# ---- Metric 5: I and Q channel correlation ----
corr_i = pearson_correlation(py_i, rtl_i)
corr_q = pearson_correlation(py_q, rtl_q)
print("\n Channel correlation:")
print(f" I-channel: {corr_i:.6f}")
print(f" Q-channel: {corr_q:.6f}")
# ---- Pass/Fail Decision ----
# The SIMULATION branch uses floating-point twiddles ($cos/$sin) while
@@ -278,11 +252,8 @@ def compare_scenario(scenario_name, config, base_dir):
energy_ok))
# Print checks
print("\n Pass/Fail Checks:")
all_pass = True
for name, passed in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {name}")
for _name, passed in checks:
if not passed:
all_pass = False
@@ -310,7 +281,6 @@ def compare_scenario(scenario_name, config, base_dir):
f.write(f'{k},{py_i[k]},{py_q[k]},{rtl_i[k]},{rtl_q[k]},'
f'{py_mag_l1[k]},{rtl_mag_l1[k]},'
f'{rtl_i[k]-py_i[k]},{rtl_q[k]-py_q[k]}\n')
print(f"\n Detailed comparison: {compare_csv}")
return all_pass, result
@@ -322,25 +292,15 @@ def compare_scenario(scenario_name, config, base_dir):
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
if len(sys.argv) > 1:
arg = sys.argv[1].lower()
else:
arg = 'chirp'
arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'chirp'
if arg == 'all':
run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS:
run_scenarios = [arg]
else:
print(f"Unknown scenario: {arg}")
print(f"Valid: {', '.join(SCENARIOS.keys())}, all")
sys.exit(1)
print("=" * 60)
print("Matched Filter Co-Simulation Comparison")
print("RTL (synthesis branch) vs Python model (bit-accurate)")
print(f"Scenarios: {', '.join(run_scenarios)}")
print("=" * 60)
results = []
for name in run_scenarios:
@@ -348,37 +308,20 @@ def main():
results.append((name, passed, result))
# Summary
print(f"\n{'='*60}")
print("SUMMARY")
print(f"{'='*60}")
print(f"\n {'Scenario':<12} {'Energy Ratio':>13} {'Mag Corr':>10} "
f"{'Peak Ovlp':>10} {'Py Peak':>8} {'RTL Peak':>9} {'Status':>8}")
print(f" {'-'*12} {'-'*13} {'-'*10} {'-'*10} {'-'*8} {'-'*9} {'-'*8}")
all_pass = True
for name, passed, result in results:
for _name, passed, result in results:
if not result:
print(f" {name:<12} {'ERROR':>13} {'':>10} {'':>10} "
f"{'':>8} {'':>9} {'FAIL':>8}")
all_pass = False
else:
status = "PASS" if passed else "FAIL"
print(f" {name:<12} {result['energy_ratio']:>13.4f} "
f"{result['mag_corr']:>10.4f} "
f"{result['peak_overlap_10']:>9.0%} "
f"{result['py_peak_bin']:>8d} "
f"{result['rtl_peak_bin']:>9d} "
f"{status:>8}")
if not passed:
all_pass = False
print()
if all_pass:
print("ALL TESTS PASSED")
pass
else:
print("SOME TESTS FAILED")
print(f"{'='*60}")
pass
sys.exit(0 if all_pass else 1)
+23 -76
View File
@@ -50,7 +50,7 @@ def saturate(value, bits):
return value
def arith_rshift(value, shift, width=None):
def arith_rshift(value, shift, _width=None):
"""Arithmetic right shift. Python >> on signed int is already arithmetic."""
return value >> shift
@@ -129,10 +129,7 @@ class NCO:
raw_index = lut_address & 0x3F
# RTL: lut_index = (quadrant[0] ^ quadrant[1]) ? ~lut_address[5:0] : lut_address[5:0]
if (quadrant & 1) ^ ((quadrant >> 1) & 1):
lut_index = (~raw_index) & 0x3F
else:
lut_index = raw_index
lut_index = ~raw_index & 63 if quadrant & 1 ^ quadrant >> 1 & 1 else raw_index
return quadrant, lut_index
@@ -175,7 +172,7 @@ class NCO:
# OLD phase_accum_reg (the value from the PREVIOUS call).
# We stored self.phase_accum_reg at the start of this call as the
# value from last cycle. So:
pass # phase_with_offset computed below from OLD values
# phase_with_offset computed below from OLD values
# Compute all NBA assignments from OLD state:
# Save old state for NBA evaluation
@@ -195,16 +192,8 @@ class NCO:
if phase_valid:
# Stage 1 NBA: phase_accum_reg <= phase_accumulator (old value)
_new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF # noqa: F841 — old accum before add (derivation reference)
_new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF
# Wait - let me re-derive. The Verilog is:
# phase_accumulator <= phase_accumulator + frequency_tuning_word;
# phase_accum_reg <= phase_accumulator; // OLD value (NBA)
# phase_with_offset <= phase_accum_reg + {phase_offset, 16'b0};
# // OLD phase_accum_reg
# Since all are NBA (<=), they all read the values from BEFORE this edge.
# So: new_phase_accumulator = old_phase_accumulator + ftw
# new_phase_accum_reg = old_phase_accumulator
# new_phase_with_offset = old_phase_accum_reg + offset
old_phase_accumulator = (self.phase_accumulator - ftw) & 0xFFFFFFFF # reconstruct
self.phase_accum_reg = old_phase_accumulator
self.phase_with_offset = (
@@ -706,7 +695,6 @@ class DDCInputInterface:
if old_valid_sync:
ddc_i = sign_extend(ddc_i_18 & 0x3FFFF, 18)
ddc_q = sign_extend(ddc_q_18 & 0x3FFFF, 18)
# adc_i = ddc_i[17:2] + ddc_i[1] (rounding)
trunc_i = (ddc_i >> 2) & 0xFFFF # bits [17:2]
round_i = (ddc_i >> 1) & 1 # bit [1]
trunc_q = (ddc_q >> 2) & 0xFFFF
@@ -732,7 +720,7 @@ def load_twiddle_rom(filepath=None):
filepath = os.path.join(base, '..', '..', 'fft_twiddle_1024.mem')
values = []
with open(filepath, 'r') as f:
with open(filepath) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
@@ -760,12 +748,11 @@ def _twiddle_lookup(k, n, cos_rom):
if k == 0:
return cos_rom[0], 0
elif k == n4:
if k == n4:
return 0, cos_rom[0]
elif k < n4:
if k < n4:
return cos_rom[k], cos_rom[n4 - k]
else:
return sign_extend((-cos_rom[n2 - k]) & 0xFFFF, 16), cos_rom[k - n4]
return sign_extend((-cos_rom[n2 - k]) & 0xFFFF, 16), cos_rom[k - n4]
class FFTEngine:
@@ -840,11 +827,9 @@ class FFTEngine:
# Multiply (49-bit products)
if not inverse:
# Forward: t = b * (cos + j*sin)
prod_re = b_re * tw_cos + b_im * tw_sin
prod_im = b_im * tw_cos - b_re * tw_sin
else:
# Inverse: t = b * (cos - j*sin)
prod_re = b_re * tw_cos - b_im * tw_sin
prod_im = b_im * tw_cos + b_re * tw_sin
@@ -923,10 +908,9 @@ class FreqMatchedFilter:
# Saturation check
if rounded > 0x3FFF8000:
return 0x7FFF
elif rounded < -0x3FFF8000:
if rounded < -0x3FFF8000:
return sign_extend(0x8000, 16)
else:
return sign_extend((rounded >> 15) & 0xFFFF, 16)
return sign_extend((rounded >> 15) & 0xFFFF, 16)
out_re = round_sat_extract(real_sum)
out_im = round_sat_extract(imag_sum)
@@ -1061,7 +1045,6 @@ class RangeBinDecimator:
out_im.append(best_im)
elif mode == 2:
# Averaging: sum >> 4
sum_re = 0
sum_im = 0
for s in range(df):
@@ -1351,69 +1334,48 @@ def _self_test():
"""Quick sanity checks for each module."""
import math
print("=" * 60)
print("FPGA Model Self-Test")
print("=" * 60)
# --- NCO test ---
print("\n--- NCO Test ---")
nco = NCO()
ftw = 0x4CCCCCCD # 120 MHz at 400 MSPS
# Run 20 cycles to fill pipeline
results = []
for i in range(20):
for _ in range(20):
s, c, ready = nco.step(ftw)
if ready:
results.append((s, c))
if results:
print(f" First valid output: sin={results[0][0]}, cos={results[0][1]}")
print(f" Got {len(results)} valid outputs from 20 cycles")
# Check quadrature: sin^2 + cos^2 should be approximately 32767^2
s, c = results[-1]
mag_sq = s * s + c * c
expected = 32767 * 32767
error_pct = abs(mag_sq - expected) / expected * 100
print(
f" Quadrature check: sin^2+cos^2={mag_sq}, "
f"expected~{expected}, error={error_pct:.2f}%"
)
print(" NCO: OK")
abs(mag_sq - expected) / expected * 100
# --- Mixer test ---
print("\n--- Mixer Test ---")
mixer = Mixer()
# Test with mid-scale ADC (128) and known cos/sin
for i in range(5):
mi, mq, mv = mixer.step(128, 0x7FFF, 0, True, True)
print(f" Mixer with adc=128, cos=max, sin=0: I={mi}, Q={mq}, valid={mv}")
print(" Mixer: OK")
for _ in range(5):
_mi, _mq, _mv = mixer.step(128, 0x7FFF, 0, True, True)
# --- CIC test ---
print("\n--- CIC Test ---")
cic = CICDecimator()
dc_val = sign_extend(0x1000, 18) # Small positive DC
out_count = 0
for i in range(100):
out, valid = cic.step(dc_val, True)
for _ in range(100):
_, valid = cic.step(dc_val, True)
if valid:
out_count += 1
print(f" CIC: {out_count} outputs from 100 inputs (expect ~25 with 4x decimation + pipeline)")
print(" CIC: OK")
# --- FIR test ---
print("\n--- FIR Test ---")
fir = FIRFilter()
out_count = 0
for i in range(50):
out, valid = fir.step(1000, True)
for _ in range(50):
_out, valid = fir.step(1000, True)
if valid:
out_count += 1
print(f" FIR: {out_count} outputs from 50 inputs (expect ~43 with 7-cycle latency)")
print(" FIR: OK")
# --- FFT test ---
print("\n--- FFT Test (1024-pt) ---")
try:
fft = FFTEngine(n=1024)
# Single tone at bin 10
@@ -1425,43 +1387,28 @@ def _self_test():
out_re, out_im = fft.compute(in_re, in_im, inverse=False)
# Find peak bin
max_mag = 0
peak_bin = 0
for i in range(512):
mag = abs(out_re[i]) + abs(out_im[i])
if mag > max_mag:
max_mag = mag
peak_bin = i
print(f" FFT peak at bin {peak_bin} (expected 10), magnitude={max_mag}")
# IFFT roundtrip
rt_re, rt_im = fft.compute(out_re, out_im, inverse=True)
max_err = max(abs(rt_re[i] - in_re[i]) for i in range(1024))
print(f" FFT->IFFT roundtrip max error: {max_err} LSBs")
print(" FFT: OK")
rt_re, _rt_im = fft.compute(out_re, out_im, inverse=True)
max(abs(rt_re[i] - in_re[i]) for i in range(1024))
except FileNotFoundError:
print(" FFT: SKIPPED (twiddle file not found)")
pass
# --- Conjugate multiply test ---
print("\n--- Conjugate Multiply Test ---")
# (1+j0) * conj(1+j0) = 1+j0
# In Q15: 32767 * 32767 -> should get close to 32767
r, m = FreqMatchedFilter.conjugate_multiply_sample(0x7FFF, 0, 0x7FFF, 0)
print(f" (32767+j0) * conj(32767+j0) = {r}+j{m} (expect ~32767+j0)")
_r, _m = FreqMatchedFilter.conjugate_multiply_sample(0x7FFF, 0, 0x7FFF, 0)
# (0+j32767) * conj(0+j32767) = (0+j32767)(0-j32767) = 32767^2 -> ~32767
r2, m2 = FreqMatchedFilter.conjugate_multiply_sample(0, 0x7FFF, 0, 0x7FFF)
print(f" (0+j32767) * conj(0+j32767) = {r2}+j{m2} (expect ~32767+j0)")
print(" Conjugate Multiply: OK")
_r2, _m2 = FreqMatchedFilter.conjugate_multiply_sample(0, 0x7FFF, 0, 0x7FFF)
# --- Range decimator test ---
print("\n--- Range Bin Decimator Test ---")
test_re = list(range(1024))
test_im = [0] * 1024
out_re, out_im = RangeBinDecimator.decimate(test_re, test_im, mode=0)
print(f" Mode 0 (center): first 5 bins = {out_re[:5]} (expect [8, 24, 40, 56, 72])")
print(" Range Decimator: OK")
print("\n" + "=" * 60)
print("ALL SELF-TESTS PASSED")
print("=" * 60)
if __name__ == '__main__':
+12 -53
View File
@@ -82,8 +82,8 @@ def generate_full_long_chirp():
for n in range(LONG_CHIRP_SAMPLES):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
re_val = int(round(Q15_MAX * SCALE * math.cos(phase)))
im_val = int(round(Q15_MAX * SCALE * math.sin(phase)))
re_val = round(Q15_MAX * SCALE * math.cos(phase))
im_val = round(Q15_MAX * SCALE * math.sin(phase))
chirp_i.append(max(-32768, min(32767, re_val)))
chirp_q.append(max(-32768, min(32767, im_val)))
@@ -105,8 +105,8 @@ def generate_short_chirp():
for n in range(SHORT_CHIRP_SAMPLES):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
re_val = int(round(Q15_MAX * SCALE * math.cos(phase)))
im_val = int(round(Q15_MAX * SCALE * math.sin(phase)))
re_val = round(Q15_MAX * SCALE * math.cos(phase))
im_val = round(Q15_MAX * SCALE * math.sin(phase))
chirp_i.append(max(-32768, min(32767, re_val)))
chirp_q.append(max(-32768, min(32767, im_val)))
@@ -126,40 +126,17 @@ def write_mem_file(filename, values):
with open(path, 'w') as f:
for v in values:
f.write(to_hex16(v) + '\n')
print(f" Wrote {filename}: {len(values)} entries")
def main():
print("=" * 60)
print("AERIS-10 Chirp .mem File Generator")
print("=" * 60)
print()
print("Parameters:")
print(f" CHIRP_BW = {CHIRP_BW/1e6:.1f} MHz")
print(f" FS_SYS = {FS_SYS/1e6:.1f} MHz")
print(f" T_LONG_CHIRP = {T_LONG_CHIRP*1e6:.1f} us")
print(f" T_SHORT_CHIRP = {T_SHORT_CHIRP*1e6:.1f} us")
print(f" LONG_CHIRP_SAMPLES = {LONG_CHIRP_SAMPLES}")
print(f" SHORT_CHIRP_SAMPLES = {SHORT_CHIRP_SAMPLES}")
print(f" FFT_SIZE = {FFT_SIZE}")
print(f" Chirp rate (long) = {CHIRP_BW/T_LONG_CHIRP:.3e} Hz/s")
print(f" Chirp rate (short) = {CHIRP_BW/T_SHORT_CHIRP:.3e} Hz/s")
print(f" Q15 scale = {SCALE}")
print()
# ---- Long chirp ----
print("Generating full long chirp (3000 samples)...")
long_i, long_q = generate_full_long_chirp()
# Verify first sample matches generate_reference_chirp_q15() from radar_scene.py
# (which only generates the first 1024 samples)
print(f" Sample[0]: I={long_i[0]:6d} Q={long_q[0]:6d}")
print(f" Sample[1023]: I={long_i[1023]:6d} Q={long_q[1023]:6d}")
print(f" Sample[2999]: I={long_i[2999]:6d} Q={long_q[2999]:6d}")
# Segment into 4 x 1024 blocks
print()
print("Segmenting into 4 x 1024 blocks...")
for seg in range(LONG_SEGMENTS):
start = seg * FFT_SIZE
end = start + FFT_SIZE
@@ -177,27 +154,18 @@ def main():
seg_i.append(0)
seg_q.append(0)
zero_count = FFT_SIZE - valid_count
print(f" Seg {seg}: indices [{start}:{end-1}], "
f"valid={valid_count}, zeros={zero_count}")
FFT_SIZE - valid_count
write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i)
write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q)
# ---- Short chirp ----
print()
print("Generating short chirp (50 samples)...")
short_i, short_q = generate_short_chirp()
print(f" Sample[0]: I={short_i[0]:6d} Q={short_q[0]:6d}")
print(f" Sample[49]: I={short_i[49]:6d} Q={short_q[49]:6d}")
write_mem_file("short_chirp_i.mem", short_i)
write_mem_file("short_chirp_q.mem", short_q)
# ---- Verification summary ----
print()
print("=" * 60)
print("Verification:")
# Cross-check seg0 against radar_scene.py generate_reference_chirp_q15()
# That function generates exactly the first 1024 samples of the chirp
@@ -206,39 +174,30 @@ def main():
for n in range(FFT_SIZE):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
expected_i = max(-32768, min(32767, int(round(Q15_MAX * SCALE * math.cos(phase)))))
expected_q = max(-32768, min(32767, int(round(Q15_MAX * SCALE * math.sin(phase)))))
expected_i = max(-32768, min(32767, round(Q15_MAX * SCALE * math.cos(phase))))
expected_q = max(-32768, min(32767, round(Q15_MAX * SCALE * math.sin(phase))))
if long_i[n] != expected_i or long_q[n] != expected_q:
mismatches += 1
if mismatches == 0:
print(" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()")
pass
else:
print(f" [FAIL] Seg0 has {mismatches} mismatches vs generate_reference_chirp_q15()")
return 1
# Check magnitude envelope
max_mag = max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q))
print(f" Max magnitude: {max_mag:.1f} (expected ~{Q15_MAX * SCALE:.1f})")
print(f" Magnitude ratio: {max_mag / (Q15_MAX * SCALE):.6f}")
max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q, strict=False))
# Check seg3 zero padding
seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem')
with open(seg3_i_path, 'r') as f:
with open(seg3_i_path) as f:
seg3_lines = [line.strip() for line in f if line.strip()]
nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000')
print(f" Seg3 non-zero entries: {nonzero_seg3}/{len(seg3_lines)} "
f"(expected 0 since chirp ends at sample 2999)")
if nonzero_seg3 == 0:
print(" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)")
pass
else:
print(f" [WARN] Seg3 has {nonzero_seg3} non-zero entries")
pass
print()
print(f"Generated 10 .mem files in {os.path.abspath(MEM_DIR)}")
print("Run validate_mem_files.py to do full validation.")
print("=" * 60)
return 0
@@ -51,7 +51,6 @@ def write_hex_32bit(filepath, samples):
for (i_val, q_val) in samples:
packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF)
f.write(f"{packed:08X}\n")
print(f" Wrote {len(samples)} packed samples to {filepath}")
def write_csv(filepath, headers, *columns):
@@ -61,7 +60,6 @@ def write_csv(filepath, headers, *columns):
for i in range(len(columns[0])):
row = ','.join(str(col[i]) for col in columns)
f.write(row + '\n')
print(f" Wrote {len(columns[0])} rows to {filepath}")
def write_hex_16bit(filepath, data):
@@ -118,22 +116,19 @@ SCENARIOS = {
def generate_scenario(name, targets, description, base_dir):
"""Generate input hex + golden output for one scenario."""
print(f"\n{'='*60}")
print(f"Scenario: {name}{description}")
print("Model: CLEAN (dual 16-pt FFT)")
print(f"{'='*60}")
# Generate Doppler frame (32 chirps x 64 range bins)
frame_i, frame_q = generate_doppler_frame(targets, seed=42)
print(f" Generated frame: {len(frame_i)} chirps x {len(frame_i[0])} range bins")
# ---- Write input hex file (packed 32-bit: {Q, I}) ----
# RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ...
packed_samples = []
for chirp in range(CHIRPS_PER_FRAME):
for rb in range(RANGE_BINS):
packed_samples.append((frame_i[chirp][rb], frame_q[chirp][rb]))
packed_samples.extend(
(frame_i[chirp][rb], frame_q[chirp][rb])
for rb in range(RANGE_BINS)
)
input_hex = os.path.join(base_dir, f"doppler_input_{name}.hex")
write_hex_32bit(input_hex, packed_samples)
@@ -142,8 +137,6 @@ def generate_scenario(name, targets, description, base_dir):
dp = DopplerProcessor()
doppler_i, doppler_q = dp.process_frame(frame_i, frame_q)
print(f" Doppler output: {len(doppler_i)} range bins x "
f"{len(doppler_i[0])} doppler bins (2 sub-frames x {DOPPLER_FFT_SIZE})")
# ---- Write golden output CSV ----
# Format: range_bin, doppler_bin, out_i, out_q
@@ -168,10 +161,9 @@ def generate_scenario(name, targets, description, base_dir):
# ---- Write golden hex (for optional RTL $readmemh comparison) ----
golden_hex = os.path.join(base_dir, f"doppler_golden_py_{name}.hex")
write_hex_32bit(golden_hex, list(zip(flat_i, flat_q)))
write_hex_32bit(golden_hex, list(zip(flat_i, flat_q, strict=False)))
# ---- Find peak per range bin ----
print("\n Peak Doppler bins per range bin (top 5 by magnitude):")
peak_info = []
for rbin in range(RANGE_BINS):
mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d])
@@ -182,13 +174,11 @@ def generate_scenario(name, targets, description, base_dir):
# Sort by magnitude descending, show top 5
peak_info.sort(key=lambda x: -x[2])
for rbin, dbin, mag in peak_info[:5]:
i_val = doppler_i[rbin][dbin]
q_val = doppler_q[rbin][dbin]
sf = dbin // DOPPLER_FFT_SIZE
bin_in_sf = dbin % DOPPLER_FFT_SIZE
print(f" rbin={rbin:2d}, dbin={dbin:2d} (sf{sf}:{bin_in_sf:2d}), mag={mag:6d}, "
f"I={i_val:6d}, Q={q_val:6d}")
for rbin, dbin, _mag in peak_info[:5]:
doppler_i[rbin][dbin]
doppler_q[rbin][dbin]
dbin // DOPPLER_FFT_SIZE
dbin % DOPPLER_FFT_SIZE
return {
'name': name,
@@ -200,10 +190,6 @@ def generate_scenario(name, targets, description, base_dir):
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Doppler Processor Co-Sim Golden Reference Generator")
print(f"Architecture: dual {DOPPLER_FFT_SIZE}-pt FFT ({DOPPLER_TOTAL_BINS} total bins)")
print("=" * 60)
scenarios_to_run = list(SCENARIOS.keys())
@@ -221,17 +207,9 @@ def main():
r = generate_scenario(name, targets, description, base_dir)
results.append(r)
print(f"\n{'='*60}")
print("Summary:")
print(f"{'='*60}")
for r in results:
print(f" {r['name']:<15s} top peak: "
f"rbin={r['peak_info'][0][0]}, dbin={r['peak_info'][0][1]}, "
f"mag={r['peak_info'][0][2]}")
for _ in results:
pass
print(f"\nGenerated {len(results)} scenarios.")
print(f"Files written to: {base_dir}")
print("=" * 60)
if __name__ == '__main__':
@@ -36,7 +36,7 @@ FFT_SIZE = 1024
def load_hex_16bit(filepath):
"""Load 16-bit hex file (one value per line, with optional // comments)."""
values = []
with open(filepath, 'r') as f:
with open(filepath) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
@@ -75,7 +75,6 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
Returns dict with case info and results.
"""
print(f"\n--- {case_name}: {description} ---")
assert len(sig_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}"
assert len(sig_q) == FFT_SIZE
@@ -88,8 +87,6 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
write_hex_16bit(os.path.join(outdir, f"mf_sig_{case_name}_q.hex"), sig_q)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_i.hex"), ref_i)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q)
print(f" Wrote input hex: mf_sig_{case_name}_{{i,q}}.hex, "
f"mf_ref_{case_name}_{{i,q}}.hex")
# Run through bit-accurate Python model
mf = MatchedFilterChain(fft_size=FFT_SIZE)
@@ -104,9 +101,6 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
peak_mag = mag
peak_bin = k
print(f" Output: {len(out_i)} samples")
print(f" Peak bin: {peak_bin}, magnitude: {peak_mag}")
print(f" Peak I={out_i[peak_bin]}, Q={out_q[peak_bin]}")
# Save golden output hex
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i)
@@ -135,10 +129,6 @@ def generate_case(case_name, sig_i, sig_q, ref_i, ref_q, description, outdir,
def main():
base_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Matched Filter Co-Sim Golden Reference Generator")
print("Using bit-accurate Python model (fpga_model.py)")
print("=" * 60)
results = []
@@ -158,8 +148,7 @@ def main():
base_dir)
results.append(r)
else:
print("\nWARNING: bb_mf_test / ref_chirp hex files not found.")
print("Run radar_scene.py first.")
pass
# ---- Case 2: DC autocorrelation ----
dc_val = 0x1000 # 4096
@@ -191,8 +180,8 @@ def main():
sig_q = []
for n in range(FFT_SIZE):
angle = 2.0 * math.pi * k * n / FFT_SIZE
sig_i.append(saturate(int(round(amp * math.cos(angle))), 16))
sig_q.append(saturate(int(round(amp * math.sin(angle))), 16))
sig_i.append(saturate(round(amp * math.cos(angle)), 16))
sig_q.append(saturate(round(amp * math.sin(angle)), 16))
ref_i = list(sig_i)
ref_q = list(sig_q)
r = generate_case("tone5", sig_i, sig_q, ref_i, ref_q,
@@ -201,16 +190,9 @@ def main():
results.append(r)
# ---- Summary ----
print("\n" + "=" * 60)
print("Summary:")
print("=" * 60)
for r in results:
print(f" {r['case_name']:10s}: peak at bin {r['peak_bin']}, "
f"mag={r['peak_mag']}, I={r['peak_i']}, Q={r['peak_q']}")
for _ in results:
pass
print(f"\nGenerated {len(results)} golden reference cases.")
print("Files written to:", base_dir)
print("=" * 60)
if __name__ == '__main__':
@@ -5,7 +5,7 @@ gen_multiseg_golden.py
Generate golden reference data for matched_filter_multi_segment co-simulation.
Tests the overlap-save segmented convolution wrapper:
- Long chirp: 3072 samples (4 segments × 1024, with 128-sample overlap)
- Long chirp: 3072 samples (4 segments x 1024, with 128-sample overlap)
- Short chirp: 50 samples zero-padded to 1024 (1 segment)
The matched_filter_processing_chain is already verified bit-perfect.
@@ -234,7 +234,6 @@ def generate_long_chirp_test():
# In radar_receiver_final.v, the DDC output is sign-extended:
# .ddc_i({{2{adc_i_scaled[15]}}, adc_i_scaled})
# So 16-bit -> 18-bit sign-extend -> then multi_segment does:
# ddc_i[17:2] + ddc_i[1]
# For sign-extended 18-bit from 16-bit:
# ddc_i[17:2] = original 16-bit value (since bits [17:16] = sign extension)
# ddc_i[1] = bit 1 of original value
@@ -277,9 +276,6 @@ def generate_long_chirp_test():
out_re, out_im = mf_chain.process(seg_data_i, seg_data_q, ref_i, ref_q)
segment_results.append((out_re, out_im))
print(f" Segment {seg}: collected {buffer_write_ptr} buffer samples, "
f"total chirp samples = {chirp_samples_collected}, "
f"input_idx = {input_idx}")
# Write hex files for the testbench
out_dir = os.path.dirname(os.path.abspath(__file__))
@@ -317,7 +313,6 @@ def generate_long_chirp_test():
for b in range(1024):
f.write(f'{seg},{b},{out_re[b]},{out_im[b]}\n')
print(f"\n Written {LONG_SEGMENTS * 1024} golden samples to {csv_path}")
return TOTAL_SAMPLES, LONG_SEGMENTS, segment_results
@@ -343,8 +338,8 @@ def generate_short_chirp_test():
# Zero-pad to 1024 (as RTL does in ST_ZERO_PAD)
# Note: padding computed here for documentation; actual buffer uses buf_i/buf_q below
_padded_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841
_padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841
_padded_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES)
_padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES)
# The buffer truncation: ddc_i[17:2] + ddc_i[1]
# For data already 16-bit sign-extended to 18: result is (val >> 2) + bit1
@@ -381,7 +376,6 @@ def generate_short_chirp_test():
# Write hex files
out_dir = os.path.dirname(os.path.abspath(__file__))
# Input (18-bit)
all_input_i_18 = []
all_input_q_18 = []
for n in range(SHORT_SAMPLES):
@@ -403,19 +397,12 @@ def generate_short_chirp_test():
for b in range(1024):
f.write(f'{b},{out_re[b]},{out_im[b]}\n')
print(f" Written 1024 short chirp golden samples to {csv_path}")
return out_re, out_im
if __name__ == '__main__':
print("=" * 60)
print("Multi-Segment Matched Filter Golden Reference Generator")
print("=" * 60)
print("\n--- Long Chirp (4 segments, overlap-save) ---")
total_samples, num_segs, seg_results = generate_long_chirp_test()
print(f" Total input samples: {total_samples}")
print(f" Segments: {num_segs}")
for seg in range(num_segs):
out_re, out_im = seg_results[seg]
@@ -427,9 +414,7 @@ if __name__ == '__main__':
if mag > max_mag:
max_mag = mag
peak_bin = b
print(f" Seg {seg}: peak at bin {peak_bin}, magnitude {max_mag}")
print("\n--- Short Chirp (1 segment, zero-padded) ---")
short_re, short_im = generate_short_chirp_test()
max_mag = 0
peak_bin = 0
@@ -438,8 +423,3 @@ if __name__ == '__main__':
if mag > max_mag:
max_mag = mag
peak_bin = b
print(f" Short chirp: peak at bin {peak_bin}, magnitude {max_mag}")
print("\n" + "=" * 60)
print("ALL GOLDEN FILES GENERATED")
print("=" * 60)
+17 -49
View File
@@ -155,7 +155,7 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
t = n / fs
# Instantaneous frequency: f_if - chirp_bw/2 + chirp_rate * t
# Phase: integral of 2*pi*f(t)*dt
_f_inst = f_if - chirp_bw / 2 + chirp_rate * t # noqa: F841 — documents instantaneous frequency formula
_f_inst = f_if - chirp_bw / 2 + chirp_rate * t
phase = 2 * math.pi * (f_if - chirp_bw / 2) * t + math.pi * chirp_rate * t * t
chirp_i.append(math.cos(phase))
chirp_q.append(math.sin(phase))
@@ -163,7 +163,7 @@ def generate_if_chirp(n_samples, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
return chirp_i, chirp_q
def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, f_if=F_IF, fs=FS_ADC):
def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, _f_if=F_IF, _fs=FS_ADC):
"""
Generate a reference chirp in Q15 format for the matched filter.
@@ -190,8 +190,8 @@ def generate_reference_chirp_q15(n_fft=FFT_SIZE, chirp_bw=CHIRP_BW, f_if=F_IF, f
# The beat frequency from a target at delay tau is: f_beat = chirp_rate * tau
# Reference chirp is the TX chirp at baseband (zero delay)
phase = math.pi * chirp_rate * t * t
re_val = int(round(32767 * 0.9 * math.cos(phase)))
im_val = int(round(32767 * 0.9 * math.sin(phase)))
re_val = round(32767 * 0.9 * math.cos(phase))
im_val = round(32767 * 0.9 * math.sin(phase))
ref_re[n] = max(-32768, min(32767, re_val))
ref_im[n] = max(-32768, min(32767, im_val))
@@ -284,7 +284,7 @@ def generate_adc_samples(targets, n_samples, noise_stddev=3.0,
# Quantize to 8-bit unsigned (0-255), centered at 128
adc_samples = []
for val in adc_float:
quantized = int(round(val + 128))
quantized = round(val + 128)
quantized = max(0, min(255, quantized))
adc_samples.append(quantized)
@@ -346,8 +346,8 @@ def generate_baseband_samples(targets, n_samples_baseband, noise_stddev=0.5,
bb_i = []
bb_q = []
for n in range(n_samples_baseband):
i_val = int(round(bb_i_float[n] + noise_stddev * rand_gaussian()))
q_val = int(round(bb_q_float[n] + noise_stddev * rand_gaussian()))
i_val = round(bb_i_float[n] + noise_stddev * rand_gaussian())
q_val = round(bb_q_float[n] + noise_stddev * rand_gaussian())
bb_i.append(max(-32768, min(32767, i_val)))
bb_q.append(max(-32768, min(32767, q_val)))
@@ -398,15 +398,13 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
for target in targets:
# Which range bin does this target fall in?
# After matched filter + range decimation:
# range_bin = target_delay_in_baseband_samples / decimation_factor
delay_baseband_samples = target.delay_s * FS_SYS
range_bin_float = delay_baseband_samples * n_range_bins / FFT_SIZE
range_bin = int(round(range_bin_float))
range_bin = round(range_bin_float)
if range_bin < 0 or range_bin >= n_range_bins:
continue
# Amplitude (simplified)
amp = target.amplitude / 4.0
# Doppler phase for this chirp.
@@ -426,10 +424,7 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
rb = range_bin + delta
if 0 <= rb < n_range_bins:
# sinc-like weighting
if delta == 0:
weight = 1.0
else:
weight = 0.2 / abs(delta)
weight = 1.0 if delta == 0 else 0.2 / abs(delta)
chirp_i[rb] += amp * weight * math.cos(total_phase)
chirp_q[rb] += amp * weight * math.sin(total_phase)
@@ -437,8 +432,8 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
row_i = []
row_q = []
for rb in range(n_range_bins):
i_val = int(round(chirp_i[rb] + noise_stddev * rand_gaussian()))
q_val = int(round(chirp_q[rb] + noise_stddev * rand_gaussian()))
i_val = round(chirp_i[rb] + noise_stddev * rand_gaussian())
q_val = round(chirp_q[rb] + noise_stddev * rand_gaussian())
row_i.append(max(-32768, min(32767, i_val)))
row_q.append(max(-32768, min(32767, q_val)))
@@ -466,7 +461,7 @@ def write_hex_file(filepath, samples, bits=8):
with open(filepath, 'w') as f:
f.write(f"// {len(samples)} samples, {bits}-bit, hex format for $readmemh\n")
for i, s in enumerate(samples):
for _i, s in enumerate(samples):
if bits <= 8:
val = s & 0xFF
elif bits <= 16:
@@ -477,7 +472,6 @@ def write_hex_file(filepath, samples, bits=8):
val = s & ((1 << bits) - 1)
f.write(fmt.format(val) + "\n")
print(f" Wrote {len(samples)} samples to {filepath}")
def write_csv_file(filepath, columns, headers=None):
@@ -497,7 +491,6 @@ def write_csv_file(filepath, columns, headers=None):
row = [str(col[i]) for col in columns]
f.write(",".join(row) + "\n")
print(f" Wrote {n_rows} rows to {filepath}")
# =============================================================================
@@ -510,10 +503,6 @@ def scenario_single_target(range_m=500, velocity=0, rcs=0, n_adc_samples=16384):
Good for validating matched filter range response.
"""
target = Target(range_m=range_m, velocity_mps=velocity, rcs_dbsm=rcs)
print(f"Scenario: Single target at {range_m}m")
print(f" {target}")
print(f" Beat freq: {CHIRP_BW / T_LONG_CHIRP * target.delay_s:.0f} Hz")
print(f" Delay: {target.delay_samples:.1f} ADC samples")
adc = generate_adc_samples([target], n_adc_samples, noise_stddev=2.0)
return adc, [target]
@@ -528,9 +517,8 @@ def scenario_two_targets(n_adc_samples=16384):
Target(range_m=300, velocity_mps=0, rcs_dbsm=10, phase_deg=0),
Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45),
]
print("Scenario: Two targets (range resolution test)")
for t in targets:
print(f" {t}")
for _t in targets:
pass
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0)
return adc, targets
@@ -547,9 +535,8 @@ def scenario_multi_target(n_adc_samples=16384):
Target(range_m=2000, velocity_mps=50, rcs_dbsm=0, phase_deg=45),
Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270),
]
print("Scenario: Multi-target (5 targets)")
for t in targets:
print(f" {t}")
for _t in targets:
pass
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0)
return adc, targets
@@ -559,7 +546,6 @@ def scenario_noise_only(n_adc_samples=16384, noise_stddev=5.0):
"""
Noise-only scene — baseline for false alarm characterization.
"""
print(f"Scenario: Noise only (stddev={noise_stddev})")
adc = generate_adc_samples([], n_adc_samples, noise_stddev=noise_stddev)
return adc, []
@@ -568,7 +554,6 @@ def scenario_dc_tone(n_adc_samples=16384, adc_value=128):
"""
DC input — validates CIC decimation and DC response.
"""
print(f"Scenario: DC tone (ADC value={adc_value})")
return [adc_value] * n_adc_samples, []
@@ -576,11 +561,10 @@ def scenario_sine_wave(n_adc_samples=16384, freq_hz=1e6, amplitude=50):
"""
Pure sine wave at ADC input — validates NCO/mixer frequency response.
"""
print(f"Scenario: Sine wave at {freq_hz/1e6:.1f} MHz, amplitude={amplitude}")
adc = []
for n in range(n_adc_samples):
t = n / FS_ADC
val = int(round(128 + amplitude * math.sin(2 * math.pi * freq_hz * t)))
val = round(128 + amplitude * math.sin(2 * math.pi * freq_hz * t))
adc.append(max(0, min(255, val)))
return adc, []
@@ -606,46 +590,35 @@ def generate_all_test_vectors(output_dir=None):
if output_dir is None:
output_dir = os.path.dirname(os.path.abspath(__file__))
print("=" * 60)
print("Generating AERIS-10 Test Vectors")
print(f"Output directory: {output_dir}")
print("=" * 60)
n_adc = 16384 # ~41 us of ADC data
# --- Scenario 1: Single target ---
print("\n--- Scenario 1: Single Target ---")
adc1, targets1 = scenario_single_target(range_m=500, n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_single_target.hex"), adc1, bits=8)
# --- Scenario 2: Multi-target ---
print("\n--- Scenario 2: Multi-Target ---")
adc2, targets2 = scenario_multi_target(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_multi_target.hex"), adc2, bits=8)
# --- Scenario 3: Noise only ---
print("\n--- Scenario 3: Noise Only ---")
adc3, _ = scenario_noise_only(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8)
# --- Scenario 4: DC ---
print("\n--- Scenario 4: DC Input ---")
adc4, _ = scenario_dc_tone(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8)
# --- Scenario 5: Sine wave ---
print("\n--- Scenario 5: 1 MHz Sine ---")
adc5, _ = scenario_sine_wave(n_adc_samples=n_adc, freq_hz=1e6, amplitude=50)
write_hex_file(os.path.join(output_dir, "adc_sine_1mhz.hex"), adc5, bits=8)
# --- Reference chirp for matched filter ---
print("\n--- Reference Chirp ---")
ref_re, ref_im = generate_reference_chirp_q15()
write_hex_file(os.path.join(output_dir, "ref_chirp_i.hex"), ref_re, bits=16)
write_hex_file(os.path.join(output_dir, "ref_chirp_q.hex"), ref_im, bits=16)
# --- Baseband samples for matched filter test (bypass DDC) ---
print("\n--- Baseband Samples (bypass DDC) ---")
bb_targets = [
Target(range_m=500, velocity_mps=0, rcs_dbsm=10),
Target(range_m=1500, velocity_mps=20, rcs_dbsm=5),
@@ -655,7 +628,6 @@ def generate_all_test_vectors(output_dir=None):
write_hex_file(os.path.join(output_dir, "bb_mf_test_q.hex"), bb_q, bits=16)
# --- Scenario info CSV ---
print("\n--- Scenario Info ---")
with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f:
f.write("AERIS-10 Test Vector Scenarios\n")
f.write("=" * 60 + "\n\n")
@@ -685,11 +657,7 @@ def generate_all_test_vectors(output_dir=None):
for t in bb_targets:
f.write(f" {t}\n")
print(f"\n Wrote scenario info to {os.path.join(output_dir, 'scenario_info.txt')}")
print("\n" + "=" * 60)
print("ALL TEST VECTORS GENERATED")
print("=" * 60)
return {
'adc_single': adc1,
@@ -69,7 +69,6 @@ FIR_COEFFS_HEX = [
# DDC output interface
DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation
# FFT (Range)
FFT_SIZE = 1024
FFT_DATA_W = 16
FFT_INTERNAL_W = 32
@@ -148,21 +147,15 @@ def load_and_quantize_adi_data(data_path, config_path, frame_idx=0):
4. Upconvert to 120 MHz IF (add I*cos - Q*sin) to create real signal
5. Quantize to 8-bit unsigned (matching AD9484)
"""
print(f"[LOAD] Loading ADI dataset from {data_path}")
data = np.load(data_path, allow_pickle=True)
config = np.load(config_path, allow_pickle=True)
print(f" Shape: {data.shape}, dtype: {data.dtype}")
print(f" Config: sample_rate={config[0]:.0f}, IF={config[1]:.0f}, "
f"RF={config[2]:.0f}, chirps={config[3]:.0f}, BW={config[4]:.0f}, "
f"ramp={config[5]:.6f}s")
# Extract one frame
frame = data[frame_idx] # (256, 1079) complex
# Use first 32 chirps, first 1024 samples
iq_block = frame[:DOPPLER_CHIRPS, :FFT_SIZE] # (32, 1024) complex
print(f" Using frame {frame_idx}: {DOPPLER_CHIRPS} chirps x {FFT_SIZE} samples")
# The ADI data is baseband complex IQ at 4 MSPS.
# AERIS-10 sees a real signal at 400 MSPS with 120 MHz IF.
@@ -197,9 +190,6 @@ def load_and_quantize_adi_data(data_path, config_path, frame_idx=0):
iq_i = np.clip(iq_i, -32768, 32767)
iq_q = np.clip(iq_q, -32768, 32767)
print(f" Scaled to 16-bit (peak target {INPUT_PEAK_TARGET}): "
f"I range [{iq_i.min()}, {iq_i.max()}], "
f"Q range [{iq_q.min()}, {iq_q.max()}]")
# Also create 8-bit ADC stimulus for DDC validation
# Use just one chirp of real-valued data (I channel only, shifted to unsigned)
@@ -243,10 +233,7 @@ def nco_lookup(phase_accum, sin_lut):
quadrant = (lut_address >> 6) & 0x3
# Mirror index for odd quadrants
if (quadrant & 1) ^ ((quadrant >> 1) & 1):
lut_idx = (~lut_address) & 0x3F
else:
lut_idx = lut_address & 0x3F
lut_idx = ~lut_address & 63 if quadrant & 1 ^ quadrant >> 1 & 1 else lut_address & 63
sin_abs = int(sin_lut[lut_idx])
cos_abs = int(sin_lut[63 - lut_idx])
@@ -294,7 +281,6 @@ def run_ddc(adc_samples):
# Build FIR coefficients as signed integers
fir_coeffs = np.array([hex_to_signed(c, 18) for c in FIR_COEFFS_HEX], dtype=np.int64)
print(f"[DDC] Processing {n_samples} ADC samples at 400 MHz")
# --- NCO + Mixer ---
phase_accum = np.int64(0)
@@ -327,7 +313,6 @@ def run_ddc(adc_samples):
# Phase accumulator update (ignore dithering for bit-accuracy)
phase_accum = (phase_accum + NCO_PHASE_INC) & 0xFFFFFFFF
print(f" Mixer output: I range [{mixed_i.min()}, {mixed_i.max()}]")
# --- CIC Decimator (5-stage, decimate-by-4) ---
# Integrator section (at 400 MHz rate)
@@ -371,7 +356,6 @@ def run_ddc(adc_samples):
scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT
cic_output[k] = saturate(scaled, CIC_OUT_BITS)
print(f" CIC output: {n_decimated} samples, range [{cic_output.min()}, {cic_output.max()}]")
# --- FIR Filter (32-tap) ---
delay_line = np.zeros(FIR_TAPS, dtype=np.int64)
@@ -393,7 +377,6 @@ def run_ddc(adc_samples):
if fir_output[k] >= (1 << 17):
fir_output[k] -= (1 << 18)
print(f" FIR output: range [{fir_output.min()}, {fir_output.max()}]")
# --- DDC Interface (18 → 16 bit) ---
ddc_output = np.zeros(n_decimated, dtype=np.int64)
@@ -410,7 +393,6 @@ def run_ddc(adc_samples):
else:
ddc_output[k] = saturate(trunc + round_bit, 16)
print(f" DDC output (16-bit): range [{ddc_output.min()}, {ddc_output.max()}]")
return ddc_output
@@ -421,7 +403,7 @@ def run_ddc(adc_samples):
def load_twiddle_rom(twiddle_file):
"""Load the quarter-wave cosine ROM from .mem file."""
rom = []
with open(twiddle_file, 'r') as f:
with open(twiddle_file) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
@@ -483,7 +465,6 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
# Generate twiddle factors if file not available
cos_rom = np.round(32767 * np.cos(2 * np.pi * np.arange(N // 4) / N)).astype(np.int64)
print(f"[FFT] Running {N}-point range FFT (bit-accurate)")
# Bit-reverse and sign-extend to 32-bit internal width
def bit_reverse(val, bits):
@@ -521,9 +502,6 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
b_re = mem_re[addr_odd]
b_im = mem_im[addr_odd]
# Twiddle multiply: forward FFT
# prod_re = b_re * tw_cos + b_im * tw_sin
# prod_im = b_im * tw_cos - b_re * tw_sin
prod_re = b_re * tw_cos + b_im * tw_sin
prod_im = b_im * tw_cos - b_re * tw_sin
@@ -546,8 +524,6 @@ def run_range_fft(iq_i, iq_q, twiddle_file=None):
out_re[n] = saturate(mem_re[n], FFT_DATA_W)
out_im[n] = saturate(mem_im[n], FFT_DATA_W)
print(f" FFT output: re range [{out_re.min()}, {out_re.max()}], "
f"im range [{out_im.min()}, {out_im.max()}]")
return out_re, out_im
@@ -582,11 +558,6 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
decimated_i = np.zeros((n_chirps, output_bins), dtype=np.int64)
decimated_q = np.zeros((n_chirps, output_bins), dtype=np.int64)
mode_str = 'peak' if mode == 1 else 'avg' if mode == 2 else 'simple'
print(
f"[DECIM] Decimating {n_in}{output_bins} bins, mode={mode_str}, "
f"start_bin={start_bin}, {n_chirps} chirps"
)
for c in range(n_chirps):
# Index into input, skip start_bin
@@ -635,7 +606,7 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
# Averaging: sum group, then >> 4 (divide by 16)
sum_i = np.int64(0)
sum_q = np.int64(0)
for s in range(decimation_factor):
for _ in range(decimation_factor):
if in_idx >= input_bins:
break
sum_i += int(range_fft_i[c, in_idx])
@@ -645,9 +616,6 @@ def run_range_bin_decimator(range_fft_i, range_fft_q,
decimated_i[c, obin] = int(sum_i) >> 4
decimated_q[c, obin] = int(sum_q) >> 4
print(f" Decimated output: shape ({n_chirps}, {output_bins}), "
f"I range [{decimated_i.min()}, {decimated_i.max()}], "
f"Q range [{decimated_q.min()}, {decimated_q.max()}]")
return decimated_i, decimated_q
@@ -673,7 +641,6 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
n_total = DOPPLER_TOTAL_BINS
n_sf = CHIRPS_PER_SUBFRAME
print(f"[DOPPLER] Processing {n_range} range bins x {n_chirps} chirps → dual {n_fft}-point FFT")
# Build 16-point Hamming window as signed 16-bit
hamming = np.array([int(v) for v in HAMMING_Q15], dtype=np.int64)
@@ -757,8 +724,6 @@ def run_doppler_fft(range_data_i, range_data_q, twiddle_file_16=None):
doppler_map_i[rbin, bin_offset + n] = saturate(mem_re[n], 16)
doppler_map_q[rbin, bin_offset + n] = saturate(mem_im[n], 16)
print(f" Doppler map: shape ({n_range}, {n_total}), "
f"I range [{doppler_map_i.min()}, {doppler_map_i.max()}]")
return doppler_map_i, doppler_map_q
@@ -788,12 +753,10 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
mti_i = np.zeros_like(decim_i)
mti_q = np.zeros_like(decim_q)
print(f"[MTI] 2-pulse canceller, enable={enable}, {n_chirps} chirps x {n_bins} bins")
if not enable:
mti_i[:] = decim_i
mti_q[:] = decim_q
print(" Pass-through mode (MTI disabled)")
return mti_i, mti_q
for c in range(n_chirps):
@@ -809,9 +772,6 @@ def run_mti_canceller(decim_i, decim_q, enable=True):
mti_i[c, r] = saturate(diff_i, 16)
mti_q[c, r] = saturate(diff_q, 16)
print(" Chirp 0: muted (zeros)")
print(f" Chirps 1-{n_chirps-1}: I range [{mti_i[1:].min()}, {mti_i[1:].max()}], "
f"Q range [{mti_q[1:].min()}, {mti_q[1:].max()}]")
return mti_i, mti_q
@@ -838,17 +798,12 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
dc_notch_active = (width != 0) &&
(bin_within_sf < width || bin_within_sf > (15 - width + 1))
"""
n_range, n_doppler = doppler_i.shape
_n_range, n_doppler = doppler_i.shape
notched_i = doppler_i.copy()
notched_q = doppler_q.copy()
print(
f"[DC NOTCH] width={width}, {n_range} range bins x "
f"{n_doppler} Doppler bins (dual sub-frame)"
)
if width == 0:
print(" Pass-through (width=0)")
return notched_i, notched_q
zeroed_count = 0
@@ -860,7 +815,6 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
notched_q[:, dbin] = 0
zeroed_count += 1
print(f" Zeroed {zeroed_count} Doppler bin columns")
return notched_i, notched_q
@@ -868,7 +822,7 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
# Stage 3e: CA-CFAR Detector (bit-accurate)
# ===========================================================================
def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
alpha_q44=0x30, mode='CA', simple_threshold=500):
alpha_q44=0x30, mode='CA', _simple_threshold=500):
"""
Bit-accurate model of cfar_ca.v — Cell-Averaging CFAR detector.
@@ -906,9 +860,6 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
if train == 0:
train = 1
print(f"[CFAR] mode={mode}, guard={guard}, train={train}, "
f"alpha=0x{alpha_q44:02X} (Q4.4={alpha_q44/16:.2f}), "
f"{n_range} range x {n_doppler} Doppler")
# Compute magnitudes: |I| + |Q| (17-bit unsigned, matching RTL L1 norm)
# RTL: abs_i = I[15] ? (~I + 1) : I; abs_q = Q[15] ? (~Q + 1) : Q
@@ -976,29 +927,19 @@ def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8,
else:
noise_sum = leading_sum + lagging_sum # Default to CA
# Threshold = (alpha * noise_sum) >> ALPHA_FRAC_BITS
# RTL: noise_product = r_alpha * noise_sum_reg (31-bit)
# threshold = noise_product[ALPHA_FRAC_BITS +: MAG_WIDTH]
# saturate if overflow
noise_product = alpha_q44 * noise_sum
threshold_raw = noise_product >> ALPHA_FRAC_BITS
# Saturate to MAG_WIDTH=17 bits
MAX_MAG = (1 << 17) - 1 # 131071
if threshold_raw > MAX_MAG:
threshold_val = MAX_MAG
else:
threshold_val = int(threshold_raw)
threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw)
# Detection: magnitude > threshold
if int(col[cut_idx]) > threshold_val:
detect_flags[cut_idx, dbin] = True
total_detections += 1
thresholds[cut_idx, dbin] = threshold_val
print(f" Total detections: {total_detections}")
print(f" Magnitude range: [{magnitudes.min()}, {magnitudes.max()}]")
return detect_flags, magnitudes, thresholds
@@ -1012,19 +953,16 @@ def run_detection(doppler_i, doppler_q, threshold=10000):
cfar_mag = |I| + |Q| (17-bit)
detection if cfar_mag > threshold
"""
print(f"[DETECT] Running magnitude threshold detection (threshold={threshold})")
mag = np.abs(doppler_i) + np.abs(doppler_q) # L1 norm (|I| + |Q|)
detections = np.argwhere(mag > threshold)
print(f" {len(detections)} detections found")
for d in detections[:20]: # Print first 20
rbin, dbin = d
m = mag[rbin, dbin]
print(f" Range bin {rbin}, Doppler bin {dbin}: magnitude {m}")
mag[rbin, dbin]
if len(detections) > 20:
print(f" ... and {len(detections) - 20} more")
pass
return mag, detections
@@ -1038,7 +976,6 @@ def run_float_reference(iq_i, iq_q):
Uses the exact same RTL Hamming window coefficients (Q15) to isolate
only the FFT fixed-point quantization error.
"""
print("\n[FLOAT REF] Running floating-point reference pipeline")
n_chirps, n_samples = iq_i.shape[0], iq_i.shape[1] if iq_i.ndim == 2 else len(iq_i)
@@ -1086,8 +1023,6 @@ def write_hex_files(output_dir, iq_i, iq_q, prefix="stim"):
fi.write(signed_to_hex(int(iq_i[n]), 16) + '\n')
fq.write(signed_to_hex(int(iq_q[n]), 16) + '\n')
print(f" Wrote {fn_i} ({n_samples} samples)")
print(f" Wrote {fn_q} ({n_samples} samples)")
elif iq_i.ndim == 2:
n_rows, n_cols = iq_i.shape
@@ -1101,8 +1036,6 @@ def write_hex_files(output_dir, iq_i, iq_q, prefix="stim"):
fi.write(signed_to_hex(int(iq_i[r, c]), 16) + '\n')
fq.write(signed_to_hex(int(iq_q[r, c]), 16) + '\n')
print(f" Wrote {fn_i} ({n_rows}x{n_cols} = {n_rows * n_cols} samples)")
print(f" Wrote {fn_q} ({n_rows}x{n_cols} = {n_rows * n_cols} samples)")
def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
@@ -1114,13 +1047,12 @@ def write_adc_hex(output_dir, adc_data, prefix="adc_stim"):
for n in range(len(adc_data)):
f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n')
print(f" Wrote {fn} ({len(adc_data)} samples)")
# ===========================================================================
# Comparison metrics
# ===========================================================================
def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
def compare_outputs(_name, fixed_i, fixed_q, float_i, float_q):
"""Compare fixed-point outputs against floating-point reference.
Reports two metrics:
@@ -1136,7 +1068,7 @@ def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
# Count saturated bins
sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767)
n_saturated = np.sum(sat_mask)
np.sum(sat_mask)
# Complex error — overall
fixed_complex = fi + 1j * fq
@@ -1145,8 +1077,8 @@ def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
signal_power = np.mean(np.abs(ref_complex) ** 2) + 1e-30
noise_power = np.mean(np.abs(error) ** 2) + 1e-30
snr_db = 10 * np.log10(signal_power / noise_power)
max_error = np.max(np.abs(error))
10 * np.log10(signal_power / noise_power)
np.max(np.abs(error))
# Non-saturated comparison
non_sat = ~sat_mask
@@ -1155,17 +1087,10 @@ def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
sig_ns = np.mean(np.abs(ref_complex[non_sat]) ** 2) + 1e-30
noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30
snr_ns = 10 * np.log10(sig_ns / noise_ns)
max_err_ns = np.max(np.abs(error_ns))
np.max(np.abs(error_ns))
else:
snr_ns = 0.0
max_err_ns = 0.0
print(f"\n [{name}] Comparison ({n} points):")
print(f" Saturated: {n_saturated}/{n} ({100.0*n_saturated/n:.2f}%)")
print(f" Overall SNR: {snr_db:.1f} dB")
print(f" Overall max error: {max_error:.1f}")
print(f" Non-sat SNR: {snr_ns:.1f} dB")
print(f" Non-sat max error: {max_err_ns:.1f}")
return snr_ns # Return the meaningful metric
@@ -1198,29 +1123,19 @@ def main():
twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem")
output_dir = os.path.join(script_dir, "hex")
print("=" * 72)
print("AERIS-10 FPGA Golden Reference Model")
print("Using ADI CN0566 Phaser Radar Data (10.525 GHz X-band FMCW)")
print("=" * 72)
# -----------------------------------------------------------------------
# Load and quantize ADI data
# -----------------------------------------------------------------------
iq_i, iq_q, adc_8bit, config = load_and_quantize_adi_data(
iq_i, iq_q, adc_8bit, _config = load_and_quantize_adi_data(
amp_data, amp_config, frame_idx=args.frame
)
# iq_i, iq_q: (32, 1024) int64, 16-bit range — post-DDC equivalent
print(f"\n{'=' * 72}")
print("Stage 0: Data loaded and quantized to 16-bit signed")
print(f" IQ block shape: ({iq_i.shape[0]}, {iq_i.shape[1]})")
print(f" ADC stimulus: {len(adc_8bit)} samples (8-bit unsigned)")
# -----------------------------------------------------------------------
# Write stimulus files
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Writing hex stimulus files for RTL testbenches")
# Post-DDC IQ for each chirp (for FFT + Doppler validation)
write_hex_files(output_dir, iq_i, iq_q, "post_ddc")
@@ -1234,8 +1149,6 @@ def main():
# -----------------------------------------------------------------------
# Run range FFT on first chirp (bit-accurate)
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 2: Range FFT (1024-point, bit-accurate)")
range_fft_i, range_fft_q = run_range_fft(iq_i[0], iq_q[0], twiddle_1024)
write_hex_files(output_dir, range_fft_i, range_fft_q, "range_fft_chirp0")
@@ -1243,20 +1156,16 @@ def main():
all_range_i = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
all_range_q = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
print(f"\n Running range FFT for all {DOPPLER_CHIRPS} chirps...")
for c in range(DOPPLER_CHIRPS):
ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024)
all_range_i[c] = ri
all_range_q[c] = rq
if (c + 1) % 8 == 0:
print(f" Chirp {c + 1}/{DOPPLER_CHIRPS} done")
pass
# -----------------------------------------------------------------------
# Run Doppler FFT (bit-accurate) — "direct" path (first 64 bins)
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 3: Doppler FFT (dual 16-point with Hamming window)")
print(" [direct path: first 64 range bins, no decimation]")
twiddle_16 = os.path.join(fpga_dir, "fft_twiddle_16.mem")
doppler_i, doppler_q = run_doppler_fft(all_range_i, all_range_q, twiddle_file_16=twiddle_16)
write_hex_files(output_dir, doppler_i, doppler_q, "doppler_map")
@@ -1266,8 +1175,6 @@ def main():
# This models the actual RTL data flow:
# range FFT → range_bin_decimator (peak detection) → Doppler
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 2b: Range Bin Decimator (1024 → 64, peak detection)")
decim_i, decim_q = run_range_bin_decimator(
all_range_i, all_range_q,
@@ -1287,14 +1194,11 @@ def main():
q_val = int(all_range_q[c, b]) & 0xFFFF
packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n")
print(f" Wrote {fc_input_file} ({DOPPLER_CHIRPS * FFT_SIZE} packed IQ words)")
# Write decimated output reference for standalone decimator test
write_hex_files(output_dir, decim_i, decim_q, "decimated_range")
# Now run Doppler on the decimated data — this is the full-chain reference
print(f"\n{'=' * 72}")
print("Stage 3b: Doppler FFT on decimated data (full-chain path)")
fc_doppler_i, fc_doppler_q = run_doppler_fft(
decim_i, decim_q, twiddle_file_16=twiddle_16
)
@@ -1309,10 +1213,6 @@ def main():
q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n")
print(
f" Wrote {fc_doppler_packed_file} ("
f"{DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)"
)
# Save numpy arrays for the full-chain path
np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i)
@@ -1325,16 +1225,12 @@ def main():
# This models the complete RTL data flow:
# range FFT → decimator → MTI canceller → Doppler → DC notch → CFAR
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 3c: MTI Canceller (2-pulse, on decimated data)")
mti_i, mti_q = run_mti_canceller(decim_i, decim_q, enable=True)
write_hex_files(output_dir, mti_i, mti_q, "fullchain_mti_ref")
np.save(os.path.join(output_dir, "fullchain_mti_i.npy"), mti_i)
np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q)
# Doppler on MTI-filtered data
print(f"\n{'=' * 72}")
print("Stage 3b+c: Doppler FFT on MTI-filtered decimated data")
mti_doppler_i, mti_doppler_q = run_doppler_fft(
mti_i, mti_q, twiddle_file_16=twiddle_16
)
@@ -1344,8 +1240,6 @@ def main():
# DC notch on MTI-Doppler data
DC_NOTCH_WIDTH = 2 # Default test value: zero bins {0, 1, 31}
print(f"\n{'=' * 72}")
print(f"Stage 3d: DC Notch Filter (width={DC_NOTCH_WIDTH})")
notched_i, notched_q = run_dc_notch(mti_doppler_i, mti_doppler_q, width=DC_NOTCH_WIDTH)
write_hex_files(output_dir, notched_i, notched_q, "fullchain_notched_ref")
@@ -1358,18 +1252,12 @@ def main():
q_val = int(notched_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n")
print(
f" Wrote {fc_notched_packed_file} ("
f"{DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} packed IQ words)"
)
# CFAR on DC-notched data
CFAR_GUARD = 2
CFAR_TRAIN = 8
CFAR_ALPHA = 0x30 # Q4.4 = 3.0
CFAR_MODE = 'CA'
print(f"\n{'=' * 72}")
print(f"Stage 3e: CA-CFAR (guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})")
cfar_flags, cfar_mag, cfar_thr = run_cfar_ca(
notched_i, notched_q,
guard=CFAR_GUARD, train=CFAR_TRAIN,
@@ -1384,7 +1272,6 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS):
m = int(cfar_mag[rbin, dbin]) & 0x1FFFF
f.write(f"{m:05X}\n")
print(f" Wrote {cfar_mag_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} mag values)")
# 2. Threshold map (17-bit unsigned)
cfar_thr_file = os.path.join(output_dir, "fullchain_cfar_thr.hex")
@@ -1393,7 +1280,6 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS):
t = int(cfar_thr[rbin, dbin]) & 0x1FFFF
f.write(f"{t:05X}\n")
print(f" Wrote {cfar_thr_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} threshold values)")
# 3. Detection flags (1-bit per cell)
cfar_det_file = os.path.join(output_dir, "fullchain_cfar_det.hex")
@@ -1402,7 +1288,6 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS):
d = 1 if cfar_flags[rbin, dbin] else 0
f.write(f"{d:01X}\n")
print(f" Wrote {cfar_det_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} detection flags)")
# 4. Detection list (text)
cfar_detections = np.argwhere(cfar_flags)
@@ -1418,7 +1303,6 @@ def main():
for det in cfar_detections:
r, d = det
f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n")
print(f" Wrote {cfar_det_list_file} ({len(cfar_detections)} detections)")
# Save numpy arrays
np.save(os.path.join(output_dir, "fullchain_cfar_mag.npy"), cfar_mag)
@@ -1426,8 +1310,6 @@ def main():
np.save(os.path.join(output_dir, "fullchain_cfar_flags.npy"), cfar_flags)
# Run detection on full-chain Doppler map
print(f"\n{'=' * 72}")
print("Stage 4: Detection on full-chain Doppler map")
fc_mag, fc_detections = run_detection(fc_doppler_i, fc_doppler_q, threshold=args.threshold)
# Save full-chain detection reference
@@ -1439,7 +1321,6 @@ def main():
for d in fc_detections:
rbin, dbin = d
f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n")
print(f" Wrote {fc_det_file} ({len(fc_detections)} detections)")
# Also write detection reference as hex for RTL comparison
fc_det_mag_file = os.path.join(output_dir, "fullchain_detection_mag.hex")
@@ -1448,13 +1329,10 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS):
m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned
f.write(f"{m:05X}\n")
print(f" Wrote {fc_det_mag_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} magnitude values)")
# -----------------------------------------------------------------------
# Run detection on direct-path Doppler map (for backward compatibility)
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Stage 4b: Detection on direct-path Doppler map")
mag, detections = run_detection(doppler_i, doppler_q, threshold=args.threshold)
# Save detection list
@@ -1466,26 +1344,23 @@ def main():
for d in detections:
rbin, dbin = d
f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n")
print(f" Wrote {det_file} ({len(detections)} detections)")
# -----------------------------------------------------------------------
# Float reference and comparison
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("Comparison: Fixed-point vs Float reference")
range_fft_float, doppler_float = run_float_reference(iq_i, iq_q)
# Compare range FFT (chirp 0)
float_range_i = np.real(range_fft_float[0, :]).astype(np.float64)
float_range_q = np.imag(range_fft_float[0, :]).astype(np.float64)
snr_range = compare_outputs("Range FFT", range_fft_i, range_fft_q,
compare_outputs("Range FFT", range_fft_i, range_fft_q,
float_range_i, float_range_q)
# Compare Doppler map
float_doppler_i = np.real(doppler_float).flatten().astype(np.float64)
float_doppler_q = np.imag(doppler_float).flatten().astype(np.float64)
snr_doppler = compare_outputs("Doppler FFT",
compare_outputs("Doppler FFT",
doppler_i.flatten(), doppler_q.flatten(),
float_doppler_i, float_doppler_q)
@@ -1497,32 +1372,10 @@ def main():
np.save(os.path.join(output_dir, "doppler_map_i.npy"), doppler_i)
np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q)
np.save(os.path.join(output_dir, "detection_mag.npy"), mag)
print(f"\n Saved numpy reference files to {output_dir}/")
# -----------------------------------------------------------------------
# Summary
# -----------------------------------------------------------------------
print(f"\n{'=' * 72}")
print("SUMMARY")
print(f"{'=' * 72}")
print(f" ADI dataset: frame {args.frame} of amp_radar (CN0566, 10.525 GHz)")
print(f" Chirps processed: {DOPPLER_CHIRPS}")
print(f" Samples/chirp: {FFT_SIZE}")
print(f" Range FFT: {FFT_SIZE}-point → {snr_range:.1f} dB vs float")
print(
f" Doppler FFT (direct): {DOPPLER_FFT_SIZE}-point Hamming "
f"{snr_doppler:.1f} dB vs float"
)
print(f" Detections (direct): {len(detections)} (threshold={args.threshold})")
print(" Full-chain decimator: 1024→64 peak detection")
print(f" Full-chain detections: {len(fc_detections)} (threshold={args.threshold})")
print(f" MTI+CFAR chain: decim → MTI → Doppler → DC notch(w={DC_NOTCH_WIDTH}) → CA-CFAR")
print(
f" CFAR detections: {len(cfar_detections)} "
f"(guard={CFAR_GUARD}, train={CFAR_TRAIN}, alpha=0x{CFAR_ALPHA:02X})"
)
print(f" Hex stimulus files: {output_dir}/")
print(" Ready for RTL co-simulation with Icarus Verilog")
# -----------------------------------------------------------------------
# Optional plots
@@ -1531,7 +1384,7 @@ def main():
try:
import matplotlib.pyplot as plt
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
_fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# Range FFT magnitude (chirp 0)
range_mag = np.sqrt(range_fft_i.astype(float)**2 + range_fft_q.astype(float)**2)
@@ -1573,11 +1426,10 @@ def main():
plt.tight_layout()
plot_file = os.path.join(output_dir, "golden_reference_plots.png")
plt.savefig(plot_file, dpi=150)
print(f"\n Saved plots to {plot_file}")
plt.show()
except ImportError:
print("\n [WARN] matplotlib not available, skipping plots")
pass
if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
@@ -44,25 +44,22 @@ pass_count = 0
fail_count = 0
warn_count = 0
def check(condition, label):
def check(condition, _label):
global pass_count, fail_count
if condition:
print(f" [PASS] {label}")
pass_count += 1
else:
print(f" [FAIL] {label}")
fail_count += 1
def warn(label):
def warn(_label):
global warn_count
print(f" [WARN] {label}")
warn_count += 1
def read_mem_hex(filename):
"""Read a .mem file, return list of integer values (16-bit signed)."""
path = os.path.join(MEM_DIR, filename)
values = []
with open(path, 'r') as f:
with open(path) as f:
for line in f:
line = line.strip()
if not line or line.startswith('//'):
@@ -79,7 +76,6 @@ def read_mem_hex(filename):
# TEST 1: Structural validation of all .mem files
# ============================================================================
def test_structural():
print("\n=== TEST 1: Structural Validation ===")
expected = {
# FFT twiddle files (quarter-wave cosine ROMs)
@@ -119,16 +115,13 @@ def test_structural():
# TEST 2: FFT Twiddle Factor Validation
# ============================================================================
def test_twiddle_1024():
print("\n=== TEST 2a: FFT Twiddle 1024 Validation ===")
vals = read_mem_hex('fft_twiddle_1024.mem')
# Expected: cos(2*pi*k/1024) for k=0..255, in Q15 format
# Q15: value = round(cos(angle) * 32767)
max_err = 0
err_details = []
for k in range(min(256, len(vals))):
angle = 2.0 * math.pi * k / 1024.0
expected = int(round(math.cos(angle) * 32767.0))
expected = round(math.cos(angle) * 32767.0)
expected = max(-32768, min(32767, expected))
actual = vals[k]
err = abs(actual - expected)
@@ -140,19 +133,17 @@ def test_twiddle_1024():
check(max_err <= 1,
f"fft_twiddle_1024.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
if err_details:
for k, act, exp, e in err_details[:5]:
print(f" k={k}: got {act} (0x{act & 0xFFFF:04x}), expected {exp}, err={e}")
print(f" Max twiddle error: {max_err} LSB across {len(vals)} entries")
for _, _act, _exp, _e in err_details[:5]:
pass
def test_twiddle_16():
print("\n=== TEST 2b: FFT Twiddle 16 Validation ===")
vals = read_mem_hex('fft_twiddle_16.mem')
max_err = 0
for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0
expected = int(round(math.cos(angle) * 32767.0))
expected = round(math.cos(angle) * 32767.0)
expected = max(-32768, min(32767, expected))
actual = vals[k]
err = abs(actual - expected)
@@ -161,23 +152,17 @@ def test_twiddle_16():
check(max_err <= 1,
f"fft_twiddle_16.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
print(f" Max twiddle error: {max_err} LSB across {len(vals)} entries")
# Print all 4 entries for reference
print(" Twiddle 16 entries:")
for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0
expected = int(round(math.cos(angle) * 32767.0))
print(f" k={k}: file=0x{vals[k] & 0xFFFF:04x} ({vals[k]:6d}), "
f"expected=0x{expected & 0xFFFF:04x} ({expected:6d}), "
f"err={abs(vals[k] - expected)}")
expected = round(math.cos(angle) * 32767.0)
# ============================================================================
# TEST 3: Long Chirp .mem File Analysis
# ============================================================================
def test_long_chirp():
print("\n=== TEST 3: Long Chirp .mem File Analysis ===")
# Load all 4 segments
all_i = []
@@ -193,36 +178,29 @@ def test_long_chirp():
f"Total long chirp samples: {total_samples} (expected 4096 = 4 segs x 1024)")
# Compute magnitude envelope
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(all_i, all_q)]
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(all_i, all_q, strict=False)]
max_mag = max(magnitudes)
min_mag = min(magnitudes)
avg_mag = sum(magnitudes) / len(magnitudes)
min(magnitudes)
sum(magnitudes) / len(magnitudes)
print(f" Magnitude: min={min_mag:.1f}, max={max_mag:.1f}, avg={avg_mag:.1f}")
print(
f" Max magnitude as fraction of Q15 range: "
f"{max_mag/32767:.4f} ({max_mag/32767*100:.2f}%)"
)
# Check if this looks like it came from generate_reference_chirp_q15
# That function uses 32767 * 0.9 scaling => max magnitude ~29490
expected_max_from_model = 32767 * 0.9
uses_model_scaling = max_mag > expected_max_from_model * 0.8
if uses_model_scaling:
print(" Scaling: CONSISTENT with radar_scene.py model (0.9 * Q15)")
pass
else:
warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model "
f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.")
# Check non-zero content: how many samples are non-zero?
nonzero_i = sum(1 for v in all_i if v != 0)
nonzero_q = sum(1 for v in all_q if v != 0)
print(f" Non-zero samples: I={nonzero_i}/{total_samples}, Q={nonzero_q}/{total_samples}")
sum(1 for v in all_i if v != 0)
sum(1 for v in all_q if v != 0)
# Analyze instantaneous frequency via phase differences
# Phase = atan2(Q, I)
phases = []
for i_val, q_val in zip(all_i, all_q):
for i_val, q_val in zip(all_i, all_q, strict=False):
if abs(i_val) > 5 or abs(q_val) > 5: # Skip near-zero samples
phases.append(math.atan2(q_val, i_val))
else:
@@ -243,19 +221,12 @@ def test_long_chirp():
freq_estimates.append(f_inst)
if freq_estimates:
f_start = sum(freq_estimates[:50]) / 50 if len(freq_estimates) > 50 else freq_estimates[0]
f_end = sum(freq_estimates[-50:]) / 50 if len(freq_estimates) > 50 else freq_estimates[-1]
sum(freq_estimates[:50]) / 50 if len(freq_estimates) > 50 else freq_estimates[0]
sum(freq_estimates[-50:]) / 50 if len(freq_estimates) > 50 else freq_estimates[-1]
f_min = min(freq_estimates)
f_max = max(freq_estimates)
f_range = f_max - f_min
print("\n Instantaneous frequency analysis (post-DDC baseband):")
print(f" Start freq: {f_start/1e6:.3f} MHz")
print(f" End freq: {f_end/1e6:.3f} MHz")
print(f" Min freq: {f_min/1e6:.3f} MHz")
print(f" Max freq: {f_max/1e6:.3f} MHz")
print(f" Freq range: {f_range/1e6:.3f} MHz")
print(f" Expected BW: {CHIRP_BW/1e6:.3f} MHz")
# A chirp should show frequency sweep
is_chirp = f_range > 0.5e6 # At least 0.5 MHz sweep
@@ -265,23 +236,19 @@ def test_long_chirp():
# Check if bandwidth roughly matches expected
bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50%
if bw_match:
print(
f" Bandwidth {f_range/1e6:.2f} MHz roughly matches expected "
f"{CHIRP_BW/1e6:.2f} MHz"
)
pass
else:
warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz")
# Compare segment boundaries for overlap-save consistency
# In proper overlap-save, the chirp data should be segmented at 896-sample boundaries
# with segments being 1024-sample FFT blocks
print("\n Segment boundary analysis:")
for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem')
seg_q = read_mem_hex(f'long_chirp_seg{seg}_q.mem')
seg_mags = [math.sqrt(i*i + q*q) for i, q in zip(seg_i, seg_q)]
seg_avg = sum(seg_mags) / len(seg_mags)
seg_max = max(seg_mags)
seg_mags = [math.sqrt(i*i + q*q) for i, q in zip(seg_i, seg_q, strict=False)]
sum(seg_mags) / len(seg_mags)
max(seg_mags)
# Check segment 3 zero-padding (chirp is 3000 samples, seg3 starts at 3072)
# Samples 3000-4095 should be zero (or near-zero) if chirp is exactly 3000 samples
@@ -293,21 +260,18 @@ def test_long_chirp():
# Wait, but the .mem files have 1024 lines with non-trivial data...
# Let's check if seg3 has significant data
zero_count = sum(1 for m in seg_mags if m < 2)
print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}, "
f"near-zero={zero_count}/{len(seg_mags)}")
if zero_count > 500:
print(" -> Seg 3 mostly zeros (chirp shorter than 4096 samples)")
pass
else:
print(" -> Seg 3 has significant data throughout")
pass
else:
print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}")
pass
# ============================================================================
# TEST 4: Short Chirp .mem File Analysis
# ============================================================================
def test_short_chirp():
print("\n=== TEST 4: Short Chirp .mem File Analysis ===")
short_i = read_mem_hex('short_chirp_i.mem')
short_q = read_mem_hex('short_chirp_q.mem')
@@ -320,19 +284,17 @@ def test_short_chirp():
check(len(short_i) == expected_samples,
f"Short chirp length matches T_SHORT_CHIRP * FS_SYS = {expected_samples}")
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(short_i, short_q)]
max_mag = max(magnitudes)
avg_mag = sum(magnitudes) / len(magnitudes)
magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(short_i, short_q, strict=False)]
max(magnitudes)
sum(magnitudes) / len(magnitudes)
print(f" Magnitude: max={max_mag:.1f}, avg={avg_mag:.1f}")
print(f" Max as fraction of Q15: {max_mag/32767:.4f} ({max_mag/32767*100:.2f}%)")
# Check non-zero
nonzero = sum(1 for m in magnitudes if m > 1)
check(nonzero == len(short_i), f"All {nonzero}/{len(short_i)} samples non-zero")
# Check it looks like a chirp (phase should be quadratic)
phases = [math.atan2(q, i) for i, q in zip(short_i, short_q)]
phases = [math.atan2(q, i) for i, q in zip(short_i, short_q, strict=False)]
freq_est = []
for n in range(1, len(phases)):
dp = phases[n] - phases[n-1]
@@ -343,17 +305,14 @@ def test_short_chirp():
freq_est.append(dp * FS_SYS / (2 * math.pi))
if freq_est:
f_start = freq_est[0]
f_end = freq_est[-1]
print(f" Freq start: {f_start/1e6:.3f} MHz, end: {f_end/1e6:.3f} MHz")
print(f" Freq range: {abs(f_end - f_start)/1e6:.3f} MHz")
freq_est[0]
freq_est[-1]
# ============================================================================
# TEST 5: Generate Expected Chirp .mem and Compare
# ============================================================================
def test_chirp_vs_model():
print("\n=== TEST 5: Compare .mem Files vs Python Model ===")
# Generate reference using the same method as radar_scene.py
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
@@ -365,8 +324,8 @@ def test_chirp_vs_model():
for n in range(n_chirp):
t = n / FS_SYS
phase = math.pi * chirp_rate * t * t
re_val = int(round(32767 * 0.9 * math.cos(phase)))
im_val = int(round(32767 * 0.9 * math.sin(phase)))
re_val = round(32767 * 0.9 * math.cos(phase))
im_val = round(32767 * 0.9 * math.sin(phase))
model_i.append(max(-32768, min(32767, re_val)))
model_q.append(max(-32768, min(32767, im_val)))
@@ -375,37 +334,31 @@ def test_chirp_vs_model():
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
# Compare magnitudes
model_mags = [math.sqrt(i*i + q*q) for i, q in zip(model_i, model_q)]
mem_mags = [math.sqrt(i*i + q*q) for i, q in zip(mem_i, mem_q)]
model_mags = [math.sqrt(i*i + q*q) for i, q in zip(model_i, model_q, strict=False)]
mem_mags = [math.sqrt(i*i + q*q) for i, q in zip(mem_i, mem_q, strict=False)]
model_max = max(model_mags)
mem_max = max(mem_mags)
print(f" Python model seg0: max_mag={model_max:.1f} (Q15 * 0.9)")
print(f" .mem file seg0: max_mag={mem_max:.1f}")
print(f" Ratio (mem/model): {mem_max/model_max:.4f}")
# Check if they match (they almost certainly won't based on magnitude analysis)
matches = sum(1 for a, b in zip(model_i, mem_i) if a == b)
print(f" Exact I matches: {matches}/{len(model_i)}")
matches = sum(1 for a, b in zip(model_i, mem_i, strict=False) if a == b)
if matches > len(model_i) * 0.9:
print(" -> .mem files MATCH Python model")
pass
else:
warn(".mem files do NOT match Python model. They likely have different provenance.")
# Try to detect scaling
if mem_max > 0:
ratio = model_max / mem_max
print(f" Scale factor (model/mem): {ratio:.2f}")
print(f" This suggests the .mem files used ~{1.0/ratio:.4f} scaling instead of 0.9")
model_max / mem_max
# Check phase correlation (shape match regardless of scaling)
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q)]
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q)]
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
# Compute phase differences
phase_diffs = []
for mp, fp in zip(model_phases, mem_phases):
for mp, fp in zip(model_phases, mem_phases, strict=False):
d = mp - fp
while d > math.pi:
d -= 2 * math.pi
@@ -413,12 +366,9 @@ def test_chirp_vs_model():
d += 2 * math.pi
phase_diffs.append(d)
avg_phase_diff = sum(phase_diffs) / len(phase_diffs)
sum(phase_diffs) / len(phase_diffs)
max_phase_diff = max(abs(d) for d in phase_diffs)
print("\n Phase comparison (shape regardless of amplitude):")
print(f" Avg phase diff: {avg_phase_diff:.4f} rad ({math.degrees(avg_phase_diff):.2f} deg)")
print(f" Max phase diff: {max_phase_diff:.4f} rad ({math.degrees(max_phase_diff):.2f} deg)")
phase_match = max_phase_diff < 0.5 # within 0.5 rad
check(
@@ -432,7 +382,6 @@ def test_chirp_vs_model():
# TEST 6: Latency Buffer LATENCY=3187 Validation
# ============================================================================
def test_latency_buffer():
print("\n=== TEST 6: Latency Buffer LATENCY=3187 Validation ===")
# The latency buffer delays the reference chirp data to align with
# the matched filter processing chain output.
@@ -491,16 +440,10 @@ def test_latency_buffer():
f"LATENCY={LATENCY} in reasonable range [1000, 4095]")
# Check that the module name vs parameter is consistent
print(f" LATENCY parameter: {LATENCY}")
print(f" Module name: latency_buffer (parameterized, LATENCY={LATENCY})")
# Module name was renamed from latency_buffer_2159 to latency_buffer
# to match the actual parameterized LATENCY value. No warning needed.
# Validate address arithmetic won't overflow
# read_ptr = (write_ptr - LATENCY) mod 4096
# With 12-bit address, max write_ptr = 4095
# When write_ptr < LATENCY: read_ptr = 4096 + write_ptr - LATENCY
# Minimum: 4096 + 0 - 3187 = 909 (valid)
min_read_ptr = 4096 + 0 - LATENCY
check(min_read_ptr >= 0 and min_read_ptr < 4096,
f"Min read_ptr after wrap = {min_read_ptr} (valid: 0..4095)")
@@ -508,14 +451,12 @@ def test_latency_buffer():
# The latency buffer uses valid_in gated reads, so it only counts
# valid samples. The number of valid_in pulses between first write
# and first read is LATENCY.
print(f" Buffer primes after {LATENCY} valid_in pulses, then outputs continuously")
# ============================================================================
# TEST 7: Cross-check chirp memory loader addressing
# ============================================================================
def test_memory_addressing():
print("\n=== TEST 7: Chirp Memory Loader Addressing ===")
# chirp_memory_loader_param uses: long_addr = {segment_select[1:0], sample_addr[9:0]}
# This creates a 12-bit address: seg[1:0] ++ addr[9:0]
@@ -541,15 +482,12 @@ def test_memory_addressing():
# Memory is declared as: reg [15:0] long_chirp_i [0:4095]
# $readmemh loads seg0 to [0:1023], seg1 to [1024:2047], etc.
# Addressing via {segment_select, sample_addr} maps correctly.
print(" Addressing scheme: {segment_select[1:0], sample_addr[9:0]} -> 12-bit address")
print(" Memory size: [0:4095] (4096 entries) — matches 4 segments x 1024 samples")
# ============================================================================
# TEST 8: Seg3 zero-padding analysis
# ============================================================================
def test_seg3_padding():
print("\n=== TEST 8: Segment 3 Data Analysis ===")
# The long chirp has 3000 samples (30 us at 100 MHz).
# With 4 segments of 1024 samples = 4096 total memory slots.
@@ -578,7 +516,7 @@ def test_seg3_padding():
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
mags = [math.sqrt(i*i + q*q) for i, q in zip(seg3_i, seg3_q)]
mags = [math.sqrt(i*i + q*q) for i, q in zip(seg3_i, seg3_q, strict=False)]
# Count trailing zeros (samples after chirp ends)
trailing_zeros = 0
@@ -590,14 +528,8 @@ def test_seg3_padding():
nonzero = sum(1 for m in mags if m > 2)
print(f" Seg3 non-zero samples: {nonzero}/{len(seg3_i)}")
print(f" Seg3 trailing near-zeros: {trailing_zeros}")
print(f" Seg3 max magnitude: {max(mags):.1f}")
print(f" Seg3 first 5 magnitudes: {[f'{m:.1f}' for m in mags[:5]]}")
print(f" Seg3 last 5 magnitudes: {[f'{m:.1f}' for m in mags[-5:]]}")
if nonzero == 1024:
print(" -> Seg3 has data throughout (chirp extends beyond 3072 samples or is padded)")
# This means the .mem files encode 4096 chirp samples, not 3000
# The chirp duration used for .mem generation was different from T_LONG_CHIRP
actual_chirp_samples = 4 * 1024 # = 4096
@@ -607,17 +539,13 @@ def test_seg3_padding():
f"({T_LONG_CHIRP*1e6:.1f} us)")
elif trailing_zeros > 100:
# Some padding at end
actual_valid = 3072 + (1024 - trailing_zeros)
print(f" -> Estimated valid chirp samples in .mem: ~{actual_valid}")
3072 + (1024 - trailing_zeros)
# ============================================================================
# MAIN
# ============================================================================
def main():
print("=" * 70)
print("AERIS-10 .mem File Validation")
print("=" * 70)
test_structural()
test_twiddle_1024()
@@ -629,13 +557,10 @@ def main():
test_memory_addressing()
test_seg3_padding()
print("\n" + "=" * 70)
print(f"SUMMARY: {pass_count} PASS, {fail_count} FAIL, {warn_count} WARN")
if fail_count == 0:
print("ALL CHECKS PASSED")
pass
else:
print("SOME CHECKS FAILED")
print("=" * 70)
pass
return 0 if fail_count == 0 else 1
+6 -25
View File
@@ -28,8 +28,7 @@ N = 1024 # FFT length
def to_q15(value):
"""Clamp a floating-point value to 16-bit signed range [-32768, 32767]."""
v = int(np.round(value))
v = max(-32768, min(32767, v))
return v
return max(-32768, min(32767, v))
def to_hex16(value):
@@ -108,7 +107,7 @@ def generate_case(case_num, sig_i, sig_q, ref_i, ref_q, description, outdir):
f"mf_golden_out_q_case{case_num}.hex",
]
summary = {
return {
"case": case_num,
"description": description,
"peak_bin": peak_bin,
@@ -119,7 +118,6 @@ def generate_case(case_num, sig_i, sig_q, ref_i, ref_q, description, outdir):
"peak_q_quant": peak_q_q,
"files": files,
}
return summary
def main():
@@ -149,7 +147,6 @@ def main():
# =========================================================================
# Case 2: Tone autocorrelation at bin 5
# Signal and reference: complex tone at bin 5, amplitude 8000 (Q15)
# sig[n] = 8000 * exp(j * 2*pi*5*n/N)
# Autocorrelation of a tone => peak at bin 0 (lag 0)
# =========================================================================
amp = 8000.0
@@ -243,28 +240,12 @@ def main():
# =========================================================================
# Print summary to stdout
# =========================================================================
print("=" * 72)
print("Matched Filter Golden Reference Generator")
print(f"Output directory: {outdir}")
print(f"FFT length: {N}")
print("=" * 72)
for s in summaries:
print()
print(f"Case {s['case']}: {s['description']}")
print(f" Peak bin: {s['peak_bin']}")
print(f" Peak magnitude (float):{s['peak_mag_float']:.6f}")
print(f" Peak I (float): {s['peak_i_float']:.6f}")
print(f" Peak Q (float): {s['peak_q_float']:.6f}")
print(f" Peak I (quantized): {s['peak_i_quant']}")
print(f" Peak Q (quantized): {s['peak_q_quant']}")
for _ in summaries:
pass
print()
print(f"Generated {len(all_files)} files:")
for fname in all_files:
print(f" {fname}")
print()
print("Done.")
for _ in all_files:
pass
if __name__ == "__main__":
+9 -10
View File
@@ -26,7 +26,6 @@ import time
import random
import logging
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from enum import Enum
# PyQt6 imports
@@ -198,12 +197,12 @@ class RadarMapWidget(QWidget):
altitude=100.0,
pitch=0.0
)
self._targets: List[RadarTarget] = []
self._targets: list[RadarTarget] = []
self._coverage_radius = 50000 # meters
self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True
self._show_trails = False
self._target_history: Dict[int, List[Tuple[float, float]]] = {}
self._target_history: dict[int, list[tuple[float, float]]] = {}
# Setup UI
self._setup_ui()
@@ -908,7 +907,7 @@ class RadarMapWidget(QWidget):
"""Handle marker click events"""
self.targetSelected.emit(target_id)
def _on_tile_server_changed(self, index: int):
def _on_tile_server_changed(self, _index: int):
"""Handle tile server change"""
server = self._tile_combo.currentData()
self._tile_server = server
@@ -947,7 +946,7 @@ class RadarMapWidget(QWidget):
f"{gps_data.altitude}, {gps_data.pitch}, {gps_data.heading})"
)
def set_targets(self, targets: List[RadarTarget]):
def set_targets(self, targets: list[RadarTarget]):
"""Update all targets on the map"""
self._targets = targets
@@ -980,7 +979,7 @@ def polar_to_geographic(
radar_lon: float,
range_m: float,
azimuth_deg: float
) -> Tuple[float, float]:
) -> tuple[float, float]:
"""
Convert polar coordinates (range, azimuth) relative to radar
to geographic coordinates (latitude, longitude).
@@ -1028,7 +1027,7 @@ class TargetSimulator(QObject):
super().__init__(parent)
self._radar_position = radar_position
self._targets: List[RadarTarget] = []
self._targets: list[RadarTarget] = []
self._next_id = 1
self._timer = QTimer()
self._timer.timeout.connect(self._update_targets)
@@ -1164,7 +1163,7 @@ class RadarDashboard(QMainWindow):
timestamp=time.time()
)
self._settings = RadarSettings()
self._simulator: Optional[TargetSimulator] = None
self._simulator: TargetSimulator | None = None
self._demo_mode = True
# Setup UI
@@ -1571,7 +1570,7 @@ class RadarDashboard(QMainWindow):
self._simulator._add_random_target()
logger.info("Added random target")
def _on_targets_updated(self, targets: List[RadarTarget]):
def _on_targets_updated(self, targets: list[RadarTarget]):
"""Handle updated target list from simulator"""
# Update map
self._map_widget.set_targets(targets)
@@ -1582,7 +1581,7 @@ class RadarDashboard(QMainWindow):
# Update table
self._update_targets_table(targets)
def _update_targets_table(self, targets: List[RadarTarget]):
def _update_targets_table(self, targets: list[RadarTarget]):
"""Update the targets table"""
self._targets_table.setRowCount(len(targets))
-56
View File
@@ -1,56 +0,0 @@
import logging
import queue
import tkinter as tk
from tkinter import messagebox
class RadarGUI:
def update_gps_display(self):
"""Step 18: Update GPS display and center map"""
try:
while not self.gps_data_queue.empty():
gps_data = self.gps_data_queue.get_nowait()
self.current_gps = gps_data
# Update GPS label
self.gps_label.config(
text=(
f"GPS: Lat {gps_data.latitude:.6f}, "
f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m"
)
)
# Update map
self.update_map_display(gps_data)
except queue.Empty:
pass
def update_map_display(self, gps_data):
"""Step 18: Update map display with current GPS position"""
try:
self.map_label.config(
text=(
f"Radar Position: {gps_data.latitude:.6f}, {gps_data.longitude:.6f}\n"
f"Altitude: {gps_data.altitude:.1f}m\n"
f"Coverage: 50km radius\n"
f"Map centered on GPS coordinates"
)
)
except Exception as e:
logging.error(f"Error updating map display: {e}")
def main():
"""Main application entry point"""
try:
root = tk.Tk()
_app = RadarGUI(root)
root.mainloop()
except Exception as e:
logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-715
View File
@@ -1,715 +0,0 @@
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pandas as pd
import numpy as np
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from scipy.fft import fft, fftshift
import logging
from dataclasses import dataclass
from typing import List, Dict, Tuple
import threading
import queue
import time
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
@dataclass
class RadarTarget:
range: float
velocity: float
azimuth: int
elevation: int
snr: float
chirp_type: str
timestamp: float
class SignalProcessor:
def __init__(self):
self.range_resolution = 1.0 # meters
self.velocity_resolution = 0.1 # m/s
self.cfar_threshold = 15.0 # dB
def doppler_fft(self, iq_data: np.ndarray, fs: float = 100e6) -> Tuple[np.ndarray, np.ndarray]:
"""
Perform Doppler FFT on IQ data
Returns Doppler frequencies and spectrum
"""
# Window function for FFT
window = np.hanning(len(iq_data))
windowed_data = (iq_data["I_value"].values + 1j * iq_data["Q_value"].values) * window
# Perform FFT
doppler_fft = fft(windowed_data)
doppler_fft = fftshift(doppler_fft)
# Frequency axis
N = len(iq_data)
freq_axis = np.linspace(-fs / 2, fs / 2, N)
# Convert to velocity (assuming radar frequency = 10 GHz)
radar_freq = 10e9
wavelength = 3e8 / radar_freq
velocity_axis = freq_axis * wavelength / 2
return velocity_axis, np.abs(doppler_fft)
def mti_filter(self, iq_data: np.ndarray, filter_type: str = "single_canceler") -> np.ndarray:
"""
Moving Target Indicator filter
Removes stationary clutter with better shape handling
"""
if iq_data is None or len(iq_data) < 2:
return np.array([], dtype=complex)
try:
# Ensure we're working with complex data
complex_data = iq_data.astype(complex)
if filter_type == "single_canceler":
# Single delay line canceler
if len(complex_data) < 2:
return np.array([], dtype=complex)
filtered = np.zeros(len(complex_data) - 1, dtype=complex)
for i in range(1, len(complex_data)):
filtered[i - 1] = complex_data[i] - complex_data[i - 1]
return filtered
elif filter_type == "double_canceler":
# Double delay line canceler
if len(complex_data) < 3:
return np.array([], dtype=complex)
filtered = np.zeros(len(complex_data) - 2, dtype=complex)
for i in range(2, len(complex_data)):
filtered[i - 2] = (
complex_data[i] - 2 * complex_data[i - 1] + complex_data[i - 2]
)
return filtered
else:
return complex_data
except Exception as e:
logging.error(f"MTI filter error: {e}")
return np.array([], dtype=complex)
def cfar_detection(
self,
range_profile: np.ndarray,
guard_cells: int = 2,
training_cells: int = 10,
threshold_factor: float = 3.0,
) -> List[Tuple[int, float]]:
detections = []
N = len(range_profile)
# Ensure guard_cells and training_cells are integers
guard_cells = int(guard_cells)
training_cells = int(training_cells)
for i in range(N):
# Convert to integer indices
i_int = int(i)
if i_int < guard_cells + training_cells or i_int >= N - guard_cells - training_cells:
continue
# Leading window - ensure integer indices
lead_start = i_int - guard_cells - training_cells
lead_end = i_int - guard_cells
lead_cells = range_profile[lead_start:lead_end]
# Lagging window - ensure integer indices
lag_start = i_int + guard_cells + 1
lag_end = i_int + guard_cells + training_cells + 1
lag_cells = range_profile[lag_start:lag_end]
# Combine training cells
training_cells_combined = np.concatenate([lead_cells, lag_cells])
# Calculate noise floor (mean of training cells)
if len(training_cells_combined) > 0:
noise_floor = np.mean(training_cells_combined)
# Apply threshold
threshold = noise_floor * threshold_factor
if range_profile[i_int] > threshold:
detections.append(
(i_int, float(range_profile[i_int]))
) # Ensure float magnitude
return detections
def range_fft(
self, iq_data: np.ndarray, fs: float = 100e6, bw: float = 20e6
) -> Tuple[np.ndarray, np.ndarray]:
"""
Perform range FFT on IQ data
Returns range profile
"""
# Window function
window = np.hanning(len(iq_data))
windowed_data = np.abs(iq_data) * window
# Perform FFT
range_fft = fft(windowed_data)
# Range calculation
N = len(iq_data)
range_max = (3e8 * N) / (2 * bw)
range_axis = np.linspace(0, range_max, N)
return range_axis, np.abs(range_fft)
def process_chirp_sequence(self, df: pd.DataFrame, chirp_type: str = "LONG") -> Dict:
try:
# Filter data by chirp type
chirp_data = df[df["chirp_type"] == chirp_type]
if len(chirp_data) == 0:
return {}
# Group by chirp number
chirp_numbers = chirp_data["chirp_number"].unique()
num_chirps = len(chirp_numbers)
if num_chirps == 0:
return {}
# Get samples per chirp and ensure consistency
samples_per_chirp_list = [
len(chirp_data[chirp_data["chirp_number"] == num]) for num in chirp_numbers
]
# Use minimum samples to ensure consistent shape
samples_per_chirp = min(samples_per_chirp_list)
# Create range-Doppler matrix with consistent shape
range_doppler_matrix = np.zeros((samples_per_chirp, num_chirps), dtype=complex)
for i, chirp_num in enumerate(chirp_numbers):
chirp_samples = chirp_data[chirp_data["chirp_number"] == chirp_num]
# Take only the first samples_per_chirp samples to ensure consistent shape
chirp_samples = chirp_samples.head(samples_per_chirp)
# Create complex IQ data
iq_data = chirp_samples["I_value"].values + 1j * chirp_samples["Q_value"].values
# Ensure the shape matches
if len(iq_data) == samples_per_chirp:
range_doppler_matrix[:, i] = iq_data
# Apply MTI filter along slow-time (chirp-to-chirp)
mti_filtered = np.zeros_like(range_doppler_matrix)
for i in range(samples_per_chirp):
slow_time_data = range_doppler_matrix[i, :]
filtered = self.mti_filter(slow_time_data)
# Ensure filtered data matches expected shape
if len(filtered) == num_chirps:
mti_filtered[i, :] = filtered
else:
# Handle shape mismatch by padding or truncating
if len(filtered) < num_chirps:
padded = np.zeros(num_chirps, dtype=complex)
padded[: len(filtered)] = filtered
mti_filtered[i, :] = padded
else:
mti_filtered[i, :] = filtered[:num_chirps]
# Perform Doppler FFT along slow-time dimension
doppler_fft_result = np.zeros((samples_per_chirp, num_chirps), dtype=complex)
for i in range(samples_per_chirp):
doppler_fft_result[i, :] = fft(mti_filtered[i, :])
return {
"range_doppler_matrix": np.abs(doppler_fft_result),
"chirp_type": chirp_type,
"num_chirps": num_chirps,
"samples_per_chirp": samples_per_chirp,
}
except Exception as e:
logging.error(f"Error in process_chirp_sequence: {e}")
return {}
class RadarGUI:
def __init__(self, root):
self.root = root
self.root.title("Radar Signal Processor - CSV Analysis")
self.root.geometry("1400x900")
# Initialize processor
self.processor = SignalProcessor()
# Data storage
self.df = None
self.processed_data = {}
self.detected_targets = []
# Create GUI
self.create_gui()
# Start background processing
self.processing_queue = queue.Queue()
self.processing_thread = threading.Thread(target=self.background_processing, daemon=True)
self.processing_thread.start()
# Update GUI periodically
self.root.after(100, self.update_gui)
def create_gui(self):
"""Create the main GUI layout"""
# Main frame
main_frame = ttk.Frame(self.root)
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
# Control panel
control_frame = ttk.LabelFrame(main_frame, text="File Controls")
control_frame.pack(fill="x", pady=5)
# File selection
ttk.Button(control_frame, text="Load CSV File", command=self.load_csv_file).pack(
side="left", padx=5, pady=5
)
self.file_label = ttk.Label(control_frame, text="No file loaded")
self.file_label.pack(side="left", padx=10, pady=5)
# Processing controls
ttk.Button(control_frame, text="Process Data", command=self.process_data).pack(
side="left", padx=5, pady=5
)
ttk.Button(control_frame, text="Run CFAR Detection", command=self.run_cfar_detection).pack(
side="left", padx=5, pady=5
)
# Status
self.status_label = ttk.Label(control_frame, text="Status: Ready")
self.status_label.pack(side="right", padx=10, pady=5)
# Display area
display_frame = ttk.Frame(main_frame)
display_frame.pack(fill="both", expand=True, pady=5)
# Create matplotlib figures
self.create_plots(display_frame)
# Targets list
targets_frame = ttk.LabelFrame(main_frame, text="Detected Targets")
targets_frame.pack(fill="x", pady=5)
self.targets_tree = ttk.Treeview(
targets_frame,
columns=("Range", "Velocity", "Azimuth", "Elevation", "SNR", "Chirp Type"),
show="headings",
height=8,
)
self.targets_tree.heading("Range", text="Range (m)")
self.targets_tree.heading("Velocity", text="Velocity (m/s)")
self.targets_tree.heading("Azimuth", text="Azimuth (°)")
self.targets_tree.heading("Elevation", text="Elevation (°)")
self.targets_tree.heading("SNR", text="SNR (dB)")
self.targets_tree.heading("Chirp Type", text="Chirp Type")
self.targets_tree.column("Range", width=100)
self.targets_tree.column("Velocity", width=100)
self.targets_tree.column("Azimuth", width=80)
self.targets_tree.column("Elevation", width=80)
self.targets_tree.column("SNR", width=80)
self.targets_tree.column("Chirp Type", width=100)
self.targets_tree.pack(fill="x", padx=5, pady=5)
def create_plots(self, parent):
"""Create matplotlib plots"""
# Create figure with subplots
self.fig = Figure(figsize=(12, 8))
self.canvas = FigureCanvasTkAgg(self.fig, parent)
self.canvas.get_tk_widget().pack(fill="both", expand=True)
# Create subplots
self.ax1 = self.fig.add_subplot(221) # Range profile
self.ax2 = self.fig.add_subplot(222) # Doppler spectrum
self.ax3 = self.fig.add_subplot(223) # Range-Doppler map
self.ax4 = self.fig.add_subplot(224) # MTI filtered data
# Set titles
self.ax1.set_title("Range Profile")
self.ax1.set_xlabel("Range (m)")
self.ax1.set_ylabel("Magnitude")
self.ax1.grid(True)
self.ax2.set_title("Doppler Spectrum")
self.ax2.set_xlabel("Velocity (m/s)")
self.ax2.set_ylabel("Magnitude")
self.ax2.grid(True)
self.ax3.set_title("Range-Doppler Map")
self.ax3.set_xlabel("Doppler Bin")
self.ax3.set_ylabel("Range Bin")
self.ax4.set_title("MTI Filtered Data")
self.ax4.set_xlabel("Sample")
self.ax4.set_ylabel("Magnitude")
self.ax4.grid(True)
self.fig.tight_layout()
def load_csv_file(self):
"""Load CSV file generated by testbench"""
filename = filedialog.askopenfilename(
title="Select CSV file", filetypes=[("CSV files", "*.csv"), ("All files", "*.*")]
)
# Add magnitude and phase calculations after loading CSV
if self.df is not None:
# Calculate magnitude from I/Q values
self.df["magnitude"] = np.sqrt(self.df["I_value"] ** 2 + self.df["Q_value"] ** 2)
# Calculate phase from I/Q values
self.df["phase_rad"] = np.arctan2(self.df["Q_value"], self.df["I_value"])
# If you used magnitude_squared in CSV, calculate actual magnitude
if "magnitude_squared" in self.df.columns:
self.df["magnitude"] = np.sqrt(self.df["magnitude_squared"])
if filename:
try:
self.status_label.config(text="Status: Loading CSV file...")
self.df = pd.read_csv(filename)
self.file_label.config(text=f"Loaded: {filename.split('/')[-1]}")
self.status_label.config(text=f"Status: Loaded {len(self.df)} samples")
# Show basic info
self.show_file_info()
except Exception as e:
messagebox.showerror("Error", f"Failed to load CSV file: {e}")
self.status_label.config(text="Status: Error loading file")
def show_file_info(self):
"""Display basic information about loaded data"""
if self.df is not None:
info_text = f"Samples: {len(self.df)} | "
info_text += f"Chirps: {self.df['chirp_number'].nunique()} | "
info_text += f"Long: {len(self.df[self.df['chirp_type'] == 'LONG'])} | "
info_text += f"Short: {len(self.df[self.df['chirp_type'] == 'SHORT'])}"
self.file_label.config(text=info_text)
def process_data(self):
"""Process loaded CSV data"""
if self.df is None:
messagebox.showwarning("Warning", "Please load a CSV file first")
return
self.status_label.config(text="Status: Processing data...")
# Add to processing queue
self.processing_queue.put(("process", self.df))
def run_cfar_detection(self):
"""Run CFAR detection on processed data"""
if self.df is None:
messagebox.showwarning("Warning", "Please load and process data first")
return
self.status_label.config(text="Status: Running CFAR detection...")
self.processing_queue.put(("cfar", self.df))
def background_processing(self):
while True:
try:
task_type, data = self.processing_queue.get(timeout=1.0)
if task_type == "process":
self._process_data_background(data)
elif task_type == "cfar":
self._run_cfar_background(data)
else:
logging.warning(f"Unknown task type: {task_type}")
self.processing_queue.task_done()
except queue.Empty:
continue
except Exception as e:
logging.error(f"Background processing error: {e}")
# Update GUI to show error state
self.root.after(
0,
lambda: self.status_label.config(
text=f"Status: Processing error - {e}" # noqa: F821
),
)
def _process_data_background(self, df):
try:
# Process long chirps
long_chirp_data = self.processor.process_chirp_sequence(df, "LONG")
# Process short chirps
short_chirp_data = self.processor.process_chirp_sequence(df, "SHORT")
# Store results
self.processed_data = {"long": long_chirp_data, "short": short_chirp_data}
# Update GUI in main thread
self.root.after(0, self._update_plots_after_processing)
except Exception as e:
logging.error(f"Processing error: {e}")
error_msg = str(e)
self.root.after(
0,
lambda msg=error_msg: self.status_label.config(
text=f"Status: Processing error - {msg}"
),
)
def _run_cfar_background(self, df):
try:
# Get first chirp for CFAR demonstration
first_chirp = df[df["chirp_number"] == df["chirp_number"].min()]
if len(first_chirp) == 0:
return
# Create IQ data - FIXED TYPO: first_chirp not first_chip
iq_data = first_chirp["I_value"].values + 1j * first_chirp["Q_value"].values
# Perform range FFT
range_axis, range_profile = self.processor.range_fft(iq_data)
# Run CFAR detection
detections = self.processor.cfar_detection(range_profile)
# Convert to target objects
self.detected_targets = []
for range_bin, magnitude in detections:
target = RadarTarget(
range=range_axis[range_bin],
velocity=0, # Would need Doppler processing for velocity
azimuth=0, # From actual data
elevation=0, # From actual data
snr=20 * np.log10(magnitude + 1e-9), # Convert to dB
chirp_type="LONG",
timestamp=time.time(),
)
self.detected_targets.append(target)
# Update GUI in main thread
self.root.after(
0, lambda: self._update_cfar_results(range_axis, range_profile, detections)
)
except Exception as e:
logging.error(f"CFAR detection error: {e}")
error_msg = str(e)
self.root.after(
0,
lambda msg=error_msg: self.status_label.config(text=f"Status: CFAR error - {msg}"),
)
def _update_plots_after_processing(self):
try:
# Clear all plots
for ax in [self.ax1, self.ax2, self.ax3, self.ax4]:
ax.clear()
# Plot 1: Range profile from first chirp
if self.df is not None and len(self.df) > 0:
try:
first_chirp_num = self.df["chirp_number"].min()
first_chirp = self.df[self.df["chirp_number"] == first_chirp_num]
if len(first_chirp) > 0:
iq_data = first_chirp["I_value"].values + 1j * first_chirp["Q_value"].values
range_axis, range_profile = self.processor.range_fft(iq_data)
if len(range_axis) > 0 and len(range_profile) > 0:
self.ax1.plot(range_axis, range_profile, "b-")
self.ax1.set_title("Range Profile - First Chirp")
self.ax1.set_xlabel("Range (m)")
self.ax1.set_ylabel("Magnitude")
self.ax1.grid(True)
except Exception as e:
logging.warning(f"Range profile plot error: {e}")
self.ax1.set_title("Range Profile - Error")
# Plot 2: Doppler spectrum
if self.df is not None and len(self.df) > 0:
try:
sample_data = self.df.head(1024)
if len(sample_data) > 10:
iq_data = sample_data["I_value"].values + 1j * sample_data["Q_value"].values
velocity_axis, doppler_spectrum = self.processor.doppler_fft(iq_data)
if len(velocity_axis) > 0 and len(doppler_spectrum) > 0:
self.ax2.plot(velocity_axis, doppler_spectrum, "g-")
self.ax2.set_title("Doppler Spectrum")
self.ax2.set_xlabel("Velocity (m/s)")
self.ax2.set_ylabel("Magnitude")
self.ax2.grid(True)
except Exception as e:
logging.warning(f"Doppler spectrum plot error: {e}")
self.ax2.set_title("Doppler Spectrum - Error")
# Plot 3: Range-Doppler map
if (
self.processed_data.get("long")
and "range_doppler_matrix" in self.processed_data["long"]
and self.processed_data["long"]["range_doppler_matrix"].size > 0
):
try:
rd_matrix = self.processed_data["long"]["range_doppler_matrix"]
# Use integer indices for extent
extent = [0, int(rd_matrix.shape[1]), 0, int(rd_matrix.shape[0])]
im = self.ax3.imshow(
10 * np.log10(rd_matrix + 1e-9), aspect="auto", cmap="hot", extent=extent
)
self.ax3.set_title("Range-Doppler Map (Long Chirps)")
self.ax3.set_xlabel("Doppler Bin")
self.ax3.set_ylabel("Range Bin")
self.fig.colorbar(im, ax=self.ax3, label="dB")
except Exception as e:
logging.warning(f"Range-Doppler map plot error: {e}")
self.ax3.set_title("Range-Doppler Map - Error")
# Plot 4: MTI filtered data
if self.df is not None and len(self.df) > 0:
try:
sample_data = self.df.head(100)
if len(sample_data) > 10:
iq_data = sample_data["I_value"].values + 1j * sample_data["Q_value"].values
# Original data
original_mag = np.abs(iq_data)
# MTI filtered
mti_filtered = self.processor.mti_filter(iq_data)
if mti_filtered is not None and len(mti_filtered) > 0:
mti_mag = np.abs(mti_filtered)
# Use integer indices for plotting
x_original = np.arange(len(original_mag))
x_mti = np.arange(len(mti_mag))
self.ax4.plot(
x_original, original_mag, "b-", label="Original", alpha=0.7
)
self.ax4.plot(x_mti, mti_mag, "r-", label="MTI Filtered", alpha=0.7)
self.ax4.set_title("MTI Filter Comparison")
self.ax4.set_xlabel("Sample Index")
self.ax4.set_ylabel("Magnitude")
self.ax4.legend()
self.ax4.grid(True)
except Exception as e:
logging.warning(f"MTI filter plot error: {e}")
self.ax4.set_title("MTI Filter - Error")
# Adjust layout and draw
self.fig.tight_layout()
self.canvas.draw()
self.status_label.config(text="Status: Processing complete")
except Exception as e:
logging.error(f"Plot update error: {e}")
error_msg = str(e)
self.status_label.config(text=f"Status: Plot error - {error_msg}")
def _update_cfar_results(self, range_axis, range_profile, detections):
try:
# Clear the plot
self.ax1.clear()
# Plot range profile
self.ax1.plot(range_axis, range_profile, "b-", label="Range Profile")
# Plot detections - ensure we use integer indices
if detections and len(range_axis) > 0:
detection_ranges = []
detection_mags = []
for bin_idx, mag in detections:
# Convert bin_idx to integer and ensure it's within bounds
bin_idx_int = int(bin_idx)
if 0 <= bin_idx_int < len(range_axis):
detection_ranges.append(range_axis[bin_idx_int])
detection_mags.append(mag)
if detection_ranges: # Only plot if we have valid detections
self.ax1.plot(
detection_ranges,
detection_mags,
"ro",
markersize=8,
label="CFAR Detections",
)
self.ax1.set_title("Range Profile with CFAR Detections")
self.ax1.set_xlabel("Range (m)")
self.ax1.set_ylabel("Magnitude")
self.ax1.legend()
self.ax1.grid(True)
# Update targets list
self.update_targets_list()
self.canvas.draw()
self.status_label.config(
text=f"Status: CFAR complete - {len(detections)} targets detected"
)
except Exception as e:
logging.error(f"CFAR results update error: {e}")
error_msg = str(e)
self.status_label.config(text=f"Status: CFAR results error - {error_msg}")
def update_targets_list(self):
"""Update the targets list display"""
# Clear current list
for item in self.targets_tree.get_children():
self.targets_tree.delete(item)
# Add detected targets
for i, target in enumerate(self.detected_targets):
self.targets_tree.insert(
"",
"end",
values=(
f"{target.range:.1f}",
f"{target.velocity:.1f}",
f"{target.azimuth}",
f"{target.elevation}",
f"{target.snr:.1f}",
target.chirp_type,
),
)
def update_gui(self):
"""Periodic GUI update"""
# You can add any periodic updates here
self.root.after(100, self.update_gui)
def main():
"""Main application entry point"""
try:
root = tk.Tk()
_app = RadarGUI(root)
root.mainloop()
except Exception as e:
logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
if __name__ == "__main__":
main()
+48 -57
View File
@@ -27,9 +27,9 @@ except ImportError:
USB_AVAILABLE = False
logging.warning("pyusb not available. USB CDC functionality will be disabled.")
try:
from pyftdi.ftdi import Ftdi
from pyftdi.usbtools import UsbTools
try:
from pyftdi.ftdi import Ftdi, FtdiError
from pyftdi.usbtools import UsbTools
FTDI_AVAILABLE = True
except ImportError:
@@ -288,18 +288,16 @@ class MapGenerator:
targets_json = str(map_targets).replace("'", '"')
targets_script = f"updateTargets({targets_json});"
# Fill template
map_html = self.map_html_template.format(
lat=gps_data.latitude,
lon=gps_data.longitude,
alt=gps_data.altitude,
pitch=gps_data.pitch,
coverage_radius=coverage_radius,
targets_script=targets_script,
api_key=api_key,
)
return map_html
# Fill template
return self.map_html_template.format(
lat=gps_data.latitude,
lon=gps_data.longitude,
alt=gps_data.altitude,
pitch=gps_data.pitch,
coverage_radius=coverage_radius,
targets_script=targets_script,
api_key=api_key,
)
def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
"""
@@ -369,7 +367,7 @@ class STM32USBInterface:
"device": dev,
}
)
except Exception:
except (usb.core.USBError, ValueError):
devices.append(
{
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
@@ -380,7 +378,7 @@ class STM32USBInterface:
)
return devices
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing
return [
@@ -430,7 +428,7 @@ class STM32USBInterface:
logging.info(f"STM32 USB device opened: {device_info['description']}")
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error opening USB device: {e}")
return False
@@ -446,7 +444,7 @@ class STM32USBInterface:
packet = self._create_settings_packet(settings)
logging.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet)
except Exception as e:
except (usb.core.USBError, struct.error) as e:
logging.error(f"Error sending settings via USB: {e}")
return False
@@ -463,9 +461,6 @@ class STM32USBInterface:
return None
logging.error(f"USB read error: {e}")
return None
except Exception as e:
logging.error(f"Error reading from USB: {e}")
return None
def _send_data(self, data):
"""Send data to STM32 via USB"""
@@ -483,7 +478,7 @@ class STM32USBInterface:
self.ep_out.write(chunk)
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error sending data via USB: {e}")
return False
@@ -509,7 +504,7 @@ class STM32USBInterface:
try:
usb.util.dispose_resources(self.device)
self.is_open = False
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error closing USB device: {e}")
@@ -524,16 +519,14 @@ class FTDIInterface:
logging.warning("FTDI not available - please install pyftdi")
return []
try:
devices = []
# Get list of all FTDI devices
for device in UsbTools.find_all([(0x0403, 0x6010)]): # FT2232H vendor/product ID
devices.append(
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
)
return devices
except Exception as e:
logging.error(f"Error listing FTDI devices: {e}")
try:
# Get list of all FTDI devices
return [
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
for device in UsbTools.find_all([(0x0403, 0x6010)])
] # FT2232H vendor/product ID
except usb.core.USBError as e:
logging.error(f"Error listing FTDI devices: {e}")
# Return mock devices for testing
return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}]
@@ -560,7 +553,7 @@ class FTDIInterface:
logging.info(f"FTDI device opened: {device_url}")
return True
except Exception as e:
except FtdiError as e:
logging.error(f"Error opening FTDI device: {e}")
return False
@@ -574,7 +567,7 @@ class FTDIInterface:
if data:
return bytes(data)
return None
except Exception as e:
except FtdiError as e:
logging.error(f"Error reading from FTDI: {e}")
return None
@@ -595,8 +588,7 @@ class RadarProcessor:
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
"""Dual-CPI fusion for better detection"""
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
return fused_profile
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
"""Multi-PRF velocity unwrapping"""
@@ -643,7 +635,7 @@ class RadarProcessor:
return clusters
def association(self, detections, clusters):
def association(self, detections, _clusters):
"""Association of detections to tracks"""
associated_detections = []
@@ -737,7 +729,7 @@ class USBPacketParser:
if len(data) >= 30 and data[0:4] == b"GPSB":
return self._parse_binary_gps_with_pitch(data)
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing GPS data: {e}")
return None
@@ -789,7 +781,7 @@ class USBPacketParser:
timestamp=time.time(),
)
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing binary GPS with pitch: {e}")
return None
@@ -831,13 +823,12 @@ class RadarPacketParser:
if packet_type == 0x01:
return self.parse_range_packet(payload)
elif packet_type == 0x02:
if packet_type == 0x02:
return self.parse_doppler_packet(payload)
elif packet_type == 0x03:
if packet_type == 0x03:
return self.parse_detection_packet(payload)
else:
logging.warning(f"Unknown packet type: {packet_type:02X}")
return None
logging.warning(f"Unknown packet type: {packet_type:02X}")
return None
def calculate_crc(self, data):
return self.crc16_func(data)
@@ -860,7 +851,7 @@ class RadarPacketParser:
"chirp": chirp_counter,
"timestamp": time.time(),
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing range packet: {e}")
return None
@@ -884,7 +875,7 @@ class RadarPacketParser:
"chirp": chirp_counter,
"timestamp": time.time(),
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing Doppler packet: {e}")
return None
@@ -906,7 +897,7 @@ class RadarPacketParser:
"chirp": chirp_counter,
"timestamp": time.time(),
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing detection packet: {e}")
return None
@@ -1345,7 +1336,7 @@ class RadarGUI:
logging.info("Radar system started successfully via USB CDC")
except Exception as e:
except (usb.core.USBError, FtdiError, ValueError) as e:
messagebox.showerror("Error", f"Failed to start radar: {e}")
logging.error(f"Start radar error: {e}")
@@ -1414,7 +1405,7 @@ class RadarGUI:
else:
break
except Exception as e:
except FtdiError as e:
logging.error(f"Error processing radar data: {e}")
time.sleep(0.1)
else:
@@ -1438,7 +1429,7 @@ class RadarGUI:
f"Alt {gps_data.altitude:.1f}m, "
f"Pitch {gps_data.pitch:.1f}°"
)
except Exception as e:
except (usb.core.USBError, ValueError, struct.error) as e:
logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1)
@@ -1501,7 +1492,7 @@ class RadarGUI:
f"Pitch {self.current_gps.pitch:.1f}°"
)
except Exception as e:
except (ValueError, KeyError) as e:
logging.error(f"Error processing radar packet: {e}")
def update_range_doppler_map(self, target):
@@ -1568,9 +1559,9 @@ class RadarGUI:
)
logging.info(f"Map generated: {self.map_file_path}")
except Exception as e:
except (OSError, ValueError) as e:
logging.error(f"Error generating map: {e}")
self.map_status_label.config(text=f"Map: Error - {str(e)}")
self.map_status_label.config(text=f"Map: Error - {e!s}")
def update_gps_display(self):
"""Step 18: Update GPS and pitch display"""
@@ -1657,7 +1648,7 @@ class RadarGUI:
# Update GPS and pitch display
self.update_gps_display()
except Exception as e:
except (tk.TclError, RuntimeError) as e:
logging.error(f"Error updating GUI: {e}")
self.root.after(100, self.update_gui)
@@ -1669,7 +1660,7 @@ def main():
root = tk.Tk()
_app = RadarGUI(root)
root.mainloop()
except Exception as e:
except Exception as e: # noqa: BLE001
logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
+60 -68
View File
@@ -36,9 +36,9 @@ except ImportError:
USB_AVAILABLE = False
logging.warning("pyusb not available. USB CDC functionality will be disabled.")
try:
from pyftdi.ftdi import Ftdi
from pyftdi.usbtools import UsbTools
try:
from pyftdi.ftdi import Ftdi, FtdiError
from pyftdi.usbtools import UsbTools
FTDI_AVAILABLE = True
except ImportError:
@@ -108,8 +108,7 @@ class RadarProcessor:
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
"""Dual-CPI fusion for better detection"""
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
return fused_profile
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
"""Multi-PRF velocity unwrapping"""
@@ -156,7 +155,7 @@ class RadarProcessor:
return clusters
def association(self, detections, clusters):
def association(self, detections, _clusters):
"""Association of detections to tracks"""
associated_detections = []
@@ -250,7 +249,7 @@ class USBPacketParser:
if len(data) >= 30 and data[0:4] == b"GPSB":
return self._parse_binary_gps_with_pitch(data)
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing GPS data: {e}")
return None
@@ -302,7 +301,7 @@ class USBPacketParser:
timestamp=time.time(),
)
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing binary GPS with pitch: {e}")
return None
@@ -344,13 +343,12 @@ class RadarPacketParser:
if packet_type == 0x01:
return self.parse_range_packet(payload)
elif packet_type == 0x02:
if packet_type == 0x02:
return self.parse_doppler_packet(payload)
elif packet_type == 0x03:
if packet_type == 0x03:
return self.parse_detection_packet(payload)
else:
logging.warning(f"Unknown packet type: {packet_type:02X}")
return None
logging.warning(f"Unknown packet type: {packet_type:02X}")
return None
def calculate_crc(self, data):
return self.crc16_func(data)
@@ -373,7 +371,7 @@ class RadarPacketParser:
"chirp": chirp_counter,
"timestamp": time.time(),
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing range packet: {e}")
return None
@@ -397,7 +395,7 @@ class RadarPacketParser:
"chirp": chirp_counter,
"timestamp": time.time(),
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing Doppler packet: {e}")
return None
@@ -419,7 +417,7 @@ class RadarPacketParser:
"chirp": chirp_counter,
"timestamp": time.time(),
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing detection packet: {e}")
return None
@@ -687,23 +685,22 @@ class MapGenerator:
# Calculate coverage radius in km
coverage_radius_km = coverage_radius / 1000.0
# Generate HTML content
map_html = self.map_html_template.replace("{lat}", str(gps_data.latitude))
map_html = map_html.replace("{lon}", str(gps_data.longitude))
map_html = map_html.replace("{alt:.1f}", f"{gps_data.altitude:.1f}")
map_html = map_html.replace("{pitch:+.1f}", f"{gps_data.pitch:+.1f}")
map_html = map_html.replace("{coverage_radius}", str(coverage_radius))
map_html = map_html.replace("{coverage_radius_km:.1f}", f"{coverage_radius_km:.1f}")
map_html = map_html.replace("{target_count}", str(len(map_targets)))
# Inject initial targets as JavaScript variable
targets_json = json.dumps(map_targets)
map_html = map_html.replace(
"// Display initial targets if any",
f"window.initialTargets = {targets_json};\n // Display initial targets if any",
)
return map_html
# Generate HTML content
targets_json = json.dumps(map_targets)
return (
self.map_html_template.replace("{lat}", str(gps_data.latitude))
.replace("{lon}", str(gps_data.longitude))
.replace("{alt:.1f}", f"{gps_data.altitude:.1f}")
.replace("{pitch:+.1f}", f"{gps_data.pitch:+.1f}")
.replace("{coverage_radius}", str(coverage_radius))
.replace("{coverage_radius_km:.1f}", f"{coverage_radius_km:.1f}")
.replace("{target_count}", str(len(map_targets)))
.replace(
"// Display initial targets if any",
"window.initialTargets = "
f"{targets_json};\n // Display initial targets if any",
)
)
def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
"""
@@ -775,7 +772,7 @@ class STM32USBInterface:
"device": dev,
}
)
except Exception:
except (usb.core.USBError, ValueError):
devices.append(
{
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
@@ -786,7 +783,7 @@ class STM32USBInterface:
)
return devices
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing
return [
@@ -836,7 +833,7 @@ class STM32USBInterface:
logging.info(f"STM32 USB device opened: {device_info['description']}")
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error opening USB device: {e}")
return False
@@ -852,7 +849,7 @@ class STM32USBInterface:
packet = self._create_settings_packet(settings)
logging.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet)
except Exception as e:
except (usb.core.USBError, struct.error) as e:
logging.error(f"Error sending settings via USB: {e}")
return False
@@ -869,9 +866,6 @@ class STM32USBInterface:
return None
logging.error(f"USB read error: {e}")
return None
except Exception as e:
logging.error(f"Error reading from USB: {e}")
return None
def _send_data(self, data):
"""Send data to STM32 via USB"""
@@ -889,7 +883,7 @@ class STM32USBInterface:
self.ep_out.write(chunk)
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error sending data via USB: {e}")
return False
@@ -915,7 +909,7 @@ class STM32USBInterface:
try:
usb.util.dispose_resources(self.device)
self.is_open = False
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error closing USB device: {e}")
@@ -930,16 +924,14 @@ class FTDIInterface:
logging.warning("FTDI not available - please install pyftdi")
return []
try:
devices = []
# Get list of all FTDI devices
for device in UsbTools.find_all([(0x0403, 0x6010)]): # FT2232H vendor/product ID
devices.append(
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
)
return devices
except Exception as e:
logging.error(f"Error listing FTDI devices: {e}")
try:
# Get list of all FTDI devices
return [
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
for device in UsbTools.find_all([(0x0403, 0x6010)])
] # FT2232H vendor/product ID
except usb.core.USBError as e:
logging.error(f"Error listing FTDI devices: {e}")
# Return mock devices for testing
return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}]
@@ -966,7 +958,7 @@ class FTDIInterface:
logging.info(f"FTDI device opened: {device_url}")
return True
except Exception as e:
except FtdiError as e:
logging.error(f"Error opening FTDI device: {e}")
return False
@@ -980,7 +972,7 @@ class FTDIInterface:
if data:
return bytes(data)
return None
except Exception as e:
except FtdiError as e:
logging.error(f"Error reading from FTDI: {e}")
return None
@@ -1242,7 +1234,7 @@ class RadarGUI:
"""
self.browser.load_html(placeholder_html)
except Exception as e:
except (tk.TclError, RuntimeError) as e:
logging.error(f"Failed to create embedded browser: {e}")
self.create_browser_fallback()
else:
@@ -1340,7 +1332,7 @@ Map HTML will appear here when generated.
self.fallback_text.configure(state="disabled")
self.fallback_text.see("1.0") # Scroll to top
logging.info("Fallback text widget updated with map HTML")
except Exception as e:
except (tk.TclError, RuntimeError) as e:
logging.error(f"Error updating embedded browser: {e}")
def generate_map(self):
@@ -1386,7 +1378,7 @@ Map HTML will appear here when generated.
logging.info(f"Map generated with {len(targets)} targets")
except Exception as e:
except (OSError, ValueError) as e:
logging.error(f"Error generating map: {e}")
self.map_status_label.config(text=f"Map: Error - {str(e)[:50]}")
@@ -1400,19 +1392,19 @@ Map HTML will appear here when generated.
# Create temporary HTML file
import tempfile
temp_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".html", delete=False, encoding="utf-8"
)
temp_file.write(self.current_map_html)
temp_file.close()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".html", delete=False, encoding="utf-8"
) as temp_file:
temp_file.write(self.current_map_html)
temp_file_path = temp_file.name
# Open in default browser
webbrowser.open("file://" + os.path.abspath(temp_file.name))
logging.info(f"Map opened in external browser: {temp_file.name}")
webbrowser.open("file://" + os.path.abspath(temp_file_path))
logging.info(f"Map opened in external browser: {temp_file_path}")
except Exception as e:
logging.error(f"Error opening external browser: {e}")
messagebox.showerror("Error", f"Failed to open browser: {e}")
except (OSError, ValueError) as e:
logging.error(f"Error opening external browser: {e}")
messagebox.showerror("Error", f"Failed to open browser: {e}")
# ... [Rest of the methods remain the same - demo mode, radar processing, etc.] ...
@@ -1427,7 +1419,7 @@ def main():
root = tk.Tk()
_app = RadarGUI(root)
root.mainloop()
except Exception as e:
except Exception as e: # noqa: BLE001
logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
+47 -50
View File
@@ -26,9 +26,9 @@ except ImportError:
logging.warning("pyusb not available. USB functionality will be disabled.")
try:
from pyftdi.ftdi import Ftdi # noqa: F401
from pyftdi.usbtools import UsbTools # noqa: F401
from pyftdi.ftdi import FtdiError # noqa: F401
from pyftdi.ftdi import Ftdi
from pyftdi.usbtools import UsbTools # noqa: F401
from pyftdi.ftdi import FtdiError # noqa: F401
FTDI_AVAILABLE = True
except ImportError:
FTDI_AVAILABLE = False
@@ -242,7 +242,6 @@ class MapGenerator:
</body>
</html>
"""
pass
class FT601Interface:
"""
@@ -298,7 +297,7 @@ class FT601Interface:
'device': dev,
'serial': serial
})
except Exception:
except (usb.core.USBError, ValueError):
devices.append({
'description': f"FT601 USB3.0 (VID:{vid:04X}, PID:{pid:04X})",
'vendor_id': vid,
@@ -308,7 +307,7 @@ class FT601Interface:
})
return devices
except Exception as e:
except (usb.core.USBError, ValueError) as e:
logging.error(f"Error listing FT601 devices: {e}")
# Return mock devices for testing
return [
@@ -350,7 +349,7 @@ class FT601Interface:
logging.info(f"FT601 device opened: {device_url}")
return True
except Exception as e:
except OSError as e:
logging.error(f"Error opening FT601 device: {e}")
return False
@@ -403,7 +402,7 @@ class FT601Interface:
logging.info(f"FT601 device opened: {device_info['description']}")
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error opening FT601 device: {e}")
return False
@@ -427,7 +426,7 @@ class FT601Interface:
return bytes(data)
return None
elif self.device and self.ep_in:
if self.device and self.ep_in:
# Direct USB access
if bytes_to_read is None:
bytes_to_read = 512
@@ -448,7 +447,7 @@ class FT601Interface:
return bytes(data) if data else None
except Exception as e:
except (usb.core.USBError, OSError) as e:
logging.error(f"Error reading from FT601: {e}")
return None
@@ -468,7 +467,7 @@ class FT601Interface:
self.ftdi.write_data(data)
return True
elif self.device and self.ep_out:
if self.device and self.ep_out:
# Direct USB access
# FT601 supports large transfers
max_packet = 512
@@ -479,7 +478,7 @@ class FT601Interface:
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error writing to FT601: {e}")
return False
@@ -498,7 +497,7 @@ class FT601Interface:
self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.RESET)
logging.info("FT601 burst mode disabled")
return True
except Exception as e:
except OSError as e:
logging.error(f"Error configuring burst mode: {e}")
return False
return False
@@ -510,14 +509,14 @@ class FT601Interface:
self.ftdi.close()
self.is_open = False
logging.info("FT601 device closed")
except Exception as e:
except OSError as e:
logging.error(f"Error closing FT601 device: {e}")
if self.device and self.is_open:
try:
usb.util.dispose_resources(self.device)
self.is_open = False
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error closing FT601 device: {e}")
class STM32USBInterface:
@@ -563,7 +562,7 @@ class STM32USBInterface:
'product_id': pid,
'device': dev
})
except Exception:
except (usb.core.USBError, ValueError):
devices.append({
'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
'vendor_id': vid,
@@ -572,7 +571,7 @@ class STM32USBInterface:
})
return devices
except Exception as e:
except (usb.core.USBError, ValueError) as e:
logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing
return [{
@@ -626,7 +625,7 @@ class STM32USBInterface:
logging.info(f"STM32 USB device opened: {device_info['description']}")
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error opening USB device: {e}")
return False
@@ -642,7 +641,7 @@ class STM32USBInterface:
packet = self._create_settings_packet(settings)
logging.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet)
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error sending settings via USB: {e}")
return False
@@ -659,7 +658,7 @@ class STM32USBInterface:
return None
logging.error(f"USB read error: {e}")
return None
except Exception as e:
except ValueError as e:
logging.error(f"Error reading from USB: {e}")
return None
@@ -679,7 +678,7 @@ class STM32USBInterface:
self.ep_out.write(chunk)
return True
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error sending data via USB: {e}")
return False
@@ -705,7 +704,7 @@ class STM32USBInterface:
try:
usb.util.dispose_resources(self.device)
self.is_open = False
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error closing USB device: {e}")
@@ -720,8 +719,7 @@ class RadarProcessor:
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
"""Dual-CPI fusion for better detection"""
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
return fused_profile
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
"""Multi-PRF velocity unwrapping"""
@@ -766,7 +764,7 @@ class RadarProcessor:
return clusters
def association(self, detections, clusters):
def association(self, detections, _clusters):
"""Association of detections to tracks"""
associated_detections = []
@@ -862,7 +860,7 @@ class USBPacketParser:
if len(data) >= 30 and data[0:4] == b'GPSB':
return self._parse_binary_gps_with_pitch(data)
except Exception as e:
except ValueError as e:
logging.error(f"Error parsing GPS data: {e}")
return None
@@ -914,7 +912,7 @@ class USBPacketParser:
timestamp=time.time()
)
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing binary GPS with pitch: {e}")
return None
@@ -936,7 +934,7 @@ class RadarPacketParser:
if len(packet) < 6:
return None
_sync = packet[0:2] # noqa: F841
_sync = packet[0:2]
packet_type = packet[2]
length = packet[3]
@@ -956,13 +954,12 @@ class RadarPacketParser:
if packet_type == 0x01:
return self.parse_range_packet(payload)
elif packet_type == 0x02:
if packet_type == 0x02:
return self.parse_doppler_packet(payload)
elif packet_type == 0x03:
if packet_type == 0x03:
return self.parse_detection_packet(payload)
else:
logging.warning(f"Unknown packet type: {packet_type:02X}")
return None
logging.warning(f"Unknown packet type: {packet_type:02X}")
return None
def calculate_crc(self, data):
return self.crc16_func(data)
@@ -985,7 +982,7 @@ class RadarPacketParser:
'chirp': chirp_counter,
'timestamp': time.time()
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing range packet: {e}")
return None
@@ -1009,7 +1006,7 @@ class RadarPacketParser:
'chirp': chirp_counter,
'timestamp': time.time()
}
except Exception as e:
except (ValueError, struct.error) as e:
logging.error(f"Error parsing Doppler packet: {e}")
return None
@@ -1031,7 +1028,7 @@ class RadarPacketParser:
'chirp': chirp_counter,
'timestamp': time.time()
}
except Exception as e:
except (usb.core.USBError, ValueError) as e:
logging.error(f"Error parsing detection packet: {e}")
return None
@@ -1371,9 +1368,9 @@ class RadarGUI:
logging.info("Radar system started successfully with FT601 USB 3.0")
except Exception as e:
messagebox.showerror("Error", f"Failed to start radar: {e}")
logging.error(f"Start radar error: {e}")
except usb.core.USBError as e:
messagebox.showerror("Error", f"Failed to start radar: {e}")
logging.error(f"Start radar error: {e}")
def stop_radar(self):
"""Stop radar operation"""
@@ -1416,13 +1413,13 @@ class RadarGUI:
else:
break
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error processing radar data: {e}")
time.sleep(0.1)
else:
time.sleep(0.1)
def get_packet_length(self, packet):
def get_packet_length(self, _packet):
"""Calculate packet length including header and footer"""
# This should match your packet structure
return 64 # Example: 64-byte packets
@@ -1443,7 +1440,7 @@ class RadarGUI:
f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°"
)
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1)
@@ -1506,7 +1503,7 @@ class RadarGUI:
f"Pitch {self.current_gps.pitch:.1f}°"
)
except Exception as e:
except (ValueError, IndexError) as e:
logging.error(f"Error processing radar packet: {e}")
def update_range_doppler_map(self, target):
@@ -1604,9 +1601,9 @@ class RadarGUI:
)
logging.info(f"Map generated: {self.map_file_path}")
except Exception as e:
except OSError as e:
logging.error(f"Error generating map: {e}")
self.map_status_label.config(text=f"Map: Error - {str(e)}")
self.map_status_label.config(text=f"Map: Error - {e!s}")
def update_gps_display(self):
"""Step 18: Update GPS and pitch display"""
@@ -1753,7 +1750,7 @@ class RadarGUI:
else:
break
except Exception as e:
except (usb.core.USBError, ValueError, struct.error) as e:
logging.error(f"Error processing radar data: {e}")
time.sleep(0.1)
else:
@@ -1775,7 +1772,7 @@ class RadarGUI:
f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°"
)
except Exception as e:
except usb.core.USBError as e:
logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1)
@@ -1803,7 +1800,7 @@ class RadarGUI:
# Update GPS and pitch display
self.update_gps_display()
except Exception as e:
except (ValueError, IndexError) as e:
logging.error(f"Error updating GUI: {e}")
self.root.after(100, self.update_gui)
@@ -1812,9 +1809,9 @@ def main():
"""Main application entry point"""
try:
root = tk.Tk()
_app = RadarGUI(root) # noqa: F841 must stay alive for mainloop
_app = RadarGUI(root) # must stay alive for mainloop
root.mainloop()
except Exception as e:
except Exception as e: # noqa: BLE001
logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
+24 -28
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Radar System GUI - Fully Functional Demo Version
@@ -15,7 +14,6 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
import logging
from dataclasses import dataclass
from typing import List, Dict
import random
import json
from datetime import datetime
@@ -65,7 +63,7 @@ class SimulatedRadarProcessor:
self.noise_floor = 10
self.clutter_level = 5
def _create_targets(self) -> List[Dict]:
def _create_targets(self) -> list[dict]:
"""Create moving targets"""
return [
{
@@ -210,22 +208,20 @@ class SimulatedRadarProcessor:
return rd_map
def _detect_targets(self) -> List[RadarTarget]:
def _detect_targets(self) -> list[RadarTarget]:
"""Detect targets from current state"""
detected = []
for t in self.targets:
# Random detection based on SNR
if random.random() < (t['snr'] / 35):
# Add some measurement noise
detected.append(RadarTarget(
id=t['id'],
range=t['range'] + random.gauss(0, 10),
velocity=t['velocity'] + random.gauss(0, 2),
azimuth=t['azimuth'] + random.gauss(0, 1),
elevation=t['elevation'] + random.gauss(0, 0.5),
snr=t['snr'] + random.gauss(0, 2)
))
return detected
return [
RadarTarget(
id=t['id'],
range=t['range'] + random.gauss(0, 10),
velocity=t['velocity'] + random.gauss(0, 2),
azimuth=t['azimuth'] + random.gauss(0, 1),
elevation=t['elevation'] + random.gauss(0, 0.5),
snr=t['snr'] + random.gauss(0, 2)
)
for t in self.targets
if random.random() < (t['snr'] / 35)
]
# ============================================================================
# MAIN GUI APPLICATION
@@ -566,7 +562,7 @@ class RadarDemoGUI:
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
lambda _e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
@@ -586,7 +582,7 @@ class RadarDemoGUI:
('CFAR Threshold (dB):', 'cfar', 13.0, 5.0, 30.0)
]
for i, (label, key, default, minv, maxv) in enumerate(settings):
for _i, (label, key, default, minv, maxv) in enumerate(settings):
frame = ttk.Frame(scrollable_frame)
frame.pack(fill='x', padx=10, pady=5)
@@ -745,7 +741,7 @@ class RadarDemoGUI:
# Update time
self.time_label.config(text=time.strftime("%H:%M:%S"))
except Exception as e:
except (ValueError, IndexError) as e:
logger.error(f"Animation error: {e}")
# Schedule next update
@@ -940,7 +936,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", "Settings applied")
logger.info("Settings updated")
except Exception as e:
except (ValueError, tk.TclError) as e:
messagebox.showerror("Error", f"Invalid settings: {e}")
def apply_display_settings(self):
@@ -981,7 +977,7 @@ class RadarDemoGUI:
)
if filename:
try:
with open(filename, 'r') as f:
with open(filename) as f:
config = json.load(f)
# Apply settings
@@ -1004,7 +1000,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", f"Loaded configuration from {filename}")
logger.info(f"Configuration loaded from {filename}")
except Exception as e:
except (OSError, json.JSONDecodeError, ValueError, tk.TclError) as e:
messagebox.showerror("Error", f"Failed to load: {e}")
def save_config(self):
@@ -1031,7 +1027,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", f"Saved configuration to {filename}")
logger.info(f"Configuration saved to {filename}")
except Exception as e:
except (OSError, TypeError, ValueError) as e:
messagebox.showerror("Error", f"Failed to save: {e}")
def export_data(self):
@@ -1061,7 +1057,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", f"Exported {len(frames)} frames to {filename}")
logger.info(f"Data exported to {filename}")
except Exception as e:
except (OSError, ValueError) as e:
messagebox.showerror("Error", f"Failed to export: {e}")
def show_calibration(self):
@@ -1205,7 +1201,7 @@ def main():
root = tk.Tk()
# Create application
_app = RadarDemoGUI(root) # noqa: F841 — keeps reference alive
_app = RadarDemoGUI(root) # keeps reference alive
# Center window
root.update_idletasks()
@@ -1218,7 +1214,7 @@ def main():
# Start main loop
root.mainloop()
except Exception as e:
except Exception as e: # noqa: BLE001
logger.error(f"Fatal error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start:\n{e}")
+184 -83
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
AERIS-10 Radar Dashboard — Board Bring-Up Edition
AERIS-10 Radar Dashboard
===================================================
Real-time visualization and control for the AERIS-10 phased-array radar
via FT2232H USB 2.0 interface.
@@ -10,7 +10,8 @@ Features:
- Real-time range-Doppler magnitude heatmap (64x32)
- CFAR detection overlay (flagged cells highlighted)
- Range profile waterfall plot (range vs. time)
- Host command sender (opcodes 0x01-0x27, 0x30, 0xFF)
- Host command sender (opcodes per radar_system_top.v:
0x01-0x04, 0x10-0x16, 0x20-0x27, 0x30-0x31, 0xFF)
- Configuration panel for all radar parameters
- HDF5 data recording for offline analysis
- Mock mode for development/testing without hardware
@@ -27,7 +28,7 @@ import queue
import logging
import argparse
import threading
from typing import Optional, Dict
import contextlib
from collections import deque
import numpy as np
@@ -82,18 +83,19 @@ class RadarDashboard:
C = 3e8 # m/s — speed of light
def __init__(self, root: tk.Tk, connection: FT2232HConnection,
recorder: DataRecorder):
recorder: DataRecorder, device_index: int = 0):
self.root = root
self.conn = connection
self.recorder = recorder
self.device_index = device_index
self.root.title("AERIS-10 Radar Dashboard — Bring-Up Edition")
self.root.title("AERIS-10 Radar Dashboard")
self.root.geometry("1600x950")
self.root.configure(bg=BG)
# Frame queue (acquisition → display)
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
self._acq_thread: Optional[RadarAcquisition] = None
self._acq_thread: RadarAcquisition | None = None
# Display state
self._current_frame = RadarFrame()
@@ -154,7 +156,7 @@ class RadarDashboard:
self.btn_record = ttk.Button(top, text="Record", command=self._on_record)
self.btn_record.pack(side="right", padx=4)
# Notebook (tabs)
# -- Tabbed notebook layout --
nb = ttk.Notebook(self.root)
nb.pack(fill="both", expand=True, padx=8, pady=8)
@@ -173,9 +175,8 @@ class RadarDashboard:
# Compute physical axis limits
# Range resolution: dR = c / (2 * BW) per range bin
# But we decimate 1024→64 bins, so each bin spans 16 FFT bins.
# Range per FFT bin = c / (2 * BW) * (Fs / FFT_SIZE) — simplified:
# max_range = c * Fs / (4 * BW) for Fs-sampled baseband
# range_per_bin = max_range / NUM_RANGE_BINS
# Range resolution derivation: c/(2*BW) gives ~0.3 m per FFT bin.
# After 1024-to-64 decimation each displayed range bin spans 16 FFT bins.
range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin
# After decimation 1024→64, each range bin = 16 FFT bins
range_per_bin = range_res * 16
@@ -232,39 +233,92 @@ class RadarDashboard:
self._canvas = canvas
def _build_control_tab(self, parent):
"""Host command sender and configuration panel."""
outer = ttk.Frame(parent)
outer.pack(fill="both", expand=True, padx=16, pady=16)
"""Host command sender — organized by FPGA register groups.
# Left column: Quick actions
left = ttk.LabelFrame(outer, text="Quick Actions", padding=12)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
Layout: scrollable canvas with three columns:
Left: Quick Actions + Diagnostics (self-test)
Center: Waveform Timing + Signal Processing
Right: Detection (CFAR) + Custom Command
"""
# Scrollable wrapper for small screens
canvas = tk.Canvas(parent, bg=BG, highlightthickness=0)
scrollbar = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
outer = ttk.Frame(canvas)
outer.bind("<Configure>",
lambda _e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=outer, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
canvas.pack(side="left", fill="both", expand=True, padx=8, pady=8)
scrollbar.pack(side="right", fill="y")
ttk.Button(left, text="Trigger Chirp (0x01)",
command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Enable MTI (0x26)",
command=lambda: self._send_cmd(0x26, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Disable MTI (0x26)",
command=lambda: self._send_cmd(0x26, 0)).pack(fill="x", pady=3)
ttk.Button(left, text="Enable CFAR (0x25)",
command=lambda: self._send_cmd(0x25, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Disable CFAR (0x25)",
command=lambda: self._send_cmd(0x25, 0)).pack(fill="x", pady=3)
ttk.Button(left, text="Request Status (0xFF)",
command=lambda: self._send_cmd(0xFF, 0)).pack(fill="x", pady=3)
self._param_vars: dict[str, tk.StringVar] = {}
ttk.Separator(left, orient="horizontal").pack(fill="x", pady=6)
# ── Left column: Quick Actions + Diagnostics ──────────────────
left = ttk.Frame(outer)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
ttk.Label(left, text="FPGA Self-Test", font=("Menlo", 10, "bold")).pack(
anchor="w", pady=(2, 0))
ttk.Button(left, text="Run Self-Test (0x30)",
command=lambda: self._send_cmd(0x30, 1)).pack(fill="x", pady=3)
ttk.Button(left, text="Read Self-Test Result (0x31)",
command=lambda: self._send_cmd(0x31, 0)).pack(fill="x", pady=3)
# -- Radar Operation --
grp_op = ttk.LabelFrame(left, text="Radar Operation", padding=10)
grp_op.pack(fill="x", pady=(0, 8))
# Self-test result display
st_frame = ttk.LabelFrame(left, text="Self-Test Results", padding=6)
st_frame.pack(fill="x", pady=(6, 0))
ttk.Button(grp_op, text="Radar Mode On",
command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=2)
ttk.Button(grp_op, text="Radar Mode Off",
command=lambda: self._send_cmd(0x01, 0)).pack(fill="x", pady=2)
ttk.Button(grp_op, text="Trigger Chirp",
command=lambda: self._send_cmd(0x02, 1)).pack(fill="x", pady=2)
# Stream Control (3-bit mask)
sc_row = ttk.Frame(grp_op)
sc_row.pack(fill="x", pady=2)
ttk.Label(sc_row, text="Stream Control").pack(side="left")
var_sc = tk.StringVar(value="7")
self._param_vars["4"] = var_sc
ttk.Entry(sc_row, textvariable=var_sc, width=6).pack(side="left", padx=6)
ttk.Label(sc_row, text="0-7", foreground=ACCENT,
font=("Menlo", 9)).pack(side="left")
ttk.Button(sc_row, text="Set",
command=lambda: self._send_validated(
0x04, var_sc, bits=3)).pack(side="right")
ttk.Button(grp_op, text="Request Status",
command=lambda: self._send_cmd(0xFF, 0)).pack(fill="x", pady=2)
# -- Signal Processing --
grp_sp = ttk.LabelFrame(left, text="Signal Processing", padding=10)
grp_sp.pack(fill="x", pady=(0, 8))
sp_params = [
# Format: label, opcode, default, bits, hint
("Detect Threshold", 0x03, "10000", 16, "0-65535"),
("Gain Shift", 0x16, "0", 4, "0-15, dir+shift"),
("MTI Enable", 0x26, "0", 1, "0=off, 1=on"),
("DC Notch Width", 0x27, "0", 3, "0-7 bins"),
]
for label, opcode, default, bits, hint in sp_params:
self._add_param_row(grp_sp, label, opcode, default, bits, hint)
# MTI quick toggle
mti_row = ttk.Frame(grp_sp)
mti_row.pack(fill="x", pady=2)
ttk.Button(mti_row, text="Enable MTI",
command=lambda: self._send_cmd(0x26, 1)).pack(
side="left", expand=True, fill="x", padx=(0, 2))
ttk.Button(mti_row, text="Disable MTI",
command=lambda: self._send_cmd(0x26, 0)).pack(
side="left", expand=True, fill="x", padx=(2, 0))
# -- Diagnostics --
grp_diag = ttk.LabelFrame(left, text="Diagnostics", padding=10)
grp_diag.pack(fill="x", pady=(0, 8))
ttk.Button(grp_diag, text="Run Self-Test",
command=lambda: self._send_cmd(0x30, 1)).pack(fill="x", pady=2)
ttk.Button(grp_diag, text="Read Self-Test Result",
command=lambda: self._send_cmd(0x31, 0)).pack(fill="x", pady=2)
st_frame = ttk.LabelFrame(grp_diag, text="Self-Test Results", padding=6)
st_frame.pack(fill="x", pady=(4, 0))
self._st_labels = {}
for name, default_text in [
("busy", "Busy: --"),
@@ -280,59 +334,108 @@ class RadarDashboard:
lbl.pack(anchor="w")
self._st_labels[name] = lbl
# Right column: Parameter configuration
right = ttk.LabelFrame(outer, text="Parameter Configuration", padding=12)
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
# ── Center column: Waveform Timing ────────────────────────────
center = ttk.Frame(outer)
center.grid(row=0, column=1, sticky="nsew", padx=6)
self._param_vars: Dict[str, tk.StringVar] = {}
params = [
("CFAR Guard (0x21)", 0x21, "2"),
("CFAR Train (0x22)", 0x22, "8"),
("CFAR Alpha Q4.4 (0x23)", 0x23, "48"),
("CFAR Mode (0x24)", 0x24, "0"),
("Threshold (0x10)", 0x10, "500"),
("Gain Shift (0x06)", 0x06, "0"),
("DC Notch Width (0x27)", 0x27, "0"),
("Range Mode (0x20)", 0x20, "0"),
("Stream Enable (0x05)", 0x05, "7"),
grp_wf = ttk.LabelFrame(center, text="Waveform Timing", padding=10)
grp_wf.pack(fill="x", pady=(0, 8))
wf_params = [
("Long Chirp Cycles", 0x10, "3000", 16, "0-65535, rst=3000"),
("Long Listen Cycles", 0x11, "13700", 16, "0-65535, rst=13700"),
("Guard Cycles", 0x12, "17540", 16, "0-65535, rst=17540"),
("Short Chirp Cycles", 0x13, "50", 16, "0-65535, rst=50"),
("Short Listen Cycles", 0x14, "17450", 16, "0-65535, rst=17450"),
("Chirps Per Elevation", 0x15, "32", 6, "1-32, clamped"),
]
for label, opcode, default, bits, hint in wf_params:
self._add_param_row(grp_wf, label, opcode, default, bits, hint)
for row_idx, (label, opcode, default) in enumerate(params):
ttk.Label(right, text=label).grid(row=row_idx, column=0,
sticky="w", pady=2)
var = tk.StringVar(value=default)
self._param_vars[str(opcode)] = var
ent = ttk.Entry(right, textvariable=var, width=10)
ent.grid(row=row_idx, column=1, padx=8, pady=2)
ttk.Button(
right, text="Set",
command=lambda op=opcode, v=var: self._send_cmd(op, int(v.get()))
).grid(row=row_idx, column=2, pady=2)
# ── Right column: Detection (CFAR) + Custom ───────────────────
right = ttk.Frame(outer)
right.grid(row=0, column=2, sticky="nsew", padx=(6, 0))
# Custom command
ttk.Separator(right, orient="horizontal").grid(
row=len(params), column=0, columnspan=3, sticky="ew", pady=8)
grp_cfar = ttk.LabelFrame(right, text="Detection (CFAR)", padding=10)
grp_cfar.pack(fill="x", pady=(0, 8))
ttk.Label(right, text="Custom Opcode (hex)").grid(
row=len(params) + 1, column=0, sticky="w")
cfar_params = [
("CFAR Enable", 0x25, "0", 1, "0=off, 1=on"),
("CFAR Guard Cells", 0x21, "2", 4, "0-15, rst=2"),
("CFAR Train Cells", 0x22, "8", 5, "1-31, rst=8"),
("CFAR Alpha (Q4.4)", 0x23, "48", 8, "0-255, rst=0x30=3.0"),
("CFAR Mode", 0x24, "0", 2, "0=CA 1=GO 2=SO"),
]
for label, opcode, default, bits, hint in cfar_params:
self._add_param_row(grp_cfar, label, opcode, default, bits, hint)
# CFAR quick toggle
cfar_row = ttk.Frame(grp_cfar)
cfar_row.pack(fill="x", pady=2)
ttk.Button(cfar_row, text="Enable CFAR",
command=lambda: self._send_cmd(0x25, 1)).pack(
side="left", expand=True, fill="x", padx=(0, 2))
ttk.Button(cfar_row, text="Disable CFAR",
command=lambda: self._send_cmd(0x25, 0)).pack(
side="left", expand=True, fill="x", padx=(2, 0))
# ── Custom Command (advanced / debug) ─────────────────────────
grp_cust = ttk.LabelFrame(right, text="Custom Command", padding=10)
grp_cust.pack(fill="x", pady=(0, 8))
r0 = ttk.Frame(grp_cust)
r0.pack(fill="x", pady=2)
ttk.Label(r0, text="Opcode (hex)").pack(side="left")
self._custom_op = tk.StringVar(value="01")
ttk.Entry(right, textvariable=self._custom_op, width=10).grid(
row=len(params) + 1, column=1, padx=8)
ttk.Entry(r0, textvariable=self._custom_op, width=8).pack(
side="left", padx=6)
ttk.Label(right, text="Value (dec)").grid(
row=len(params) + 2, column=0, sticky="w")
r1 = ttk.Frame(grp_cust)
r1.pack(fill="x", pady=2)
ttk.Label(r1, text="Value (dec)").pack(side="left")
self._custom_val = tk.StringVar(value="0")
ttk.Entry(right, textvariable=self._custom_val, width=10).grid(
row=len(params) + 2, column=1, padx=8)
ttk.Entry(r1, textvariable=self._custom_val, width=8).pack(
side="left", padx=6)
ttk.Button(right, text="Send Custom",
command=self._send_custom).grid(
row=len(params) + 2, column=2, pady=2)
ttk.Button(grp_cust, text="Send",
command=self._send_custom).pack(fill="x", pady=2)
# Column weights
outer.columnconfigure(0, weight=1)
outer.columnconfigure(1, weight=2)
outer.columnconfigure(1, weight=1)
outer.columnconfigure(2, weight=1)
outer.rowconfigure(0, weight=1)
def _add_param_row(self, parent, label: str, opcode: int,
default: str, bits: int, hint: str):
"""Add a single parameter row: label, entry, hint, Set button with validation."""
row = ttk.Frame(parent)
row.pack(fill="x", pady=2)
ttk.Label(row, text=label).pack(side="left")
var = tk.StringVar(value=default)
self._param_vars[str(opcode)] = var
ttk.Entry(row, textvariable=var, width=8).pack(side="left", padx=6)
ttk.Label(row, text=hint, foreground=ACCENT,
font=("Menlo", 9)).pack(side="left")
ttk.Button(row, text="Set",
command=lambda: self._send_validated(
opcode, var, bits=bits)).pack(side="right")
def _send_validated(self, opcode: int, var: tk.StringVar, bits: int):
"""Parse, clamp to bit-width, send command, and update the entry."""
try:
raw = int(var.get())
except ValueError:
log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}")
return
max_val = (1 << bits) - 1
clamped = max(0, min(raw, max_val))
if clamped != raw:
log.warning(f"Value {raw} clamped to {clamped} "
f"({bits}-bit max={max_val}) for opcode 0x{opcode:02X}")
var.set(str(clamped))
self._send_cmd(opcode, clamped)
def _build_log_tab(self, parent):
self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10),
insertbackground=FG, wrap="word")
@@ -364,7 +467,7 @@ class RadarDashboard:
self.root.update_idletasks()
def _do_connect():
ok = self.conn.open()
ok = self.conn.open(self.device_index)
# Schedule UI update back on the main thread
self.root.after(0, lambda: self._on_connect_done(ok))
@@ -530,10 +633,8 @@ class _TextHandler(logging.Handler):
def emit(self, record):
msg = self.format(record)
try:
with contextlib.suppress(Exception):
self._text.after(0, self._append, msg)
except Exception:
pass
def _append(self, msg: str):
self._text.insert("end", msg + "\n")
@@ -578,7 +679,7 @@ def main():
root = tk.Tk()
dashboard = RadarDashboard(root, conn, recorder)
dashboard = RadarDashboard(root, conn, recorder, device_index=args.device)
if args.record:
filepath = os.path.join(
+107 -86
View File
@@ -10,7 +10,7 @@ USB Interface: FT2232H USB 2.0 (8-bit, 50T production board) via pyftdi
USB Packet Protocol (11-byte):
TX (FPGA→Host):
Data packet: [0xAA] [range_q 2B] [range_i 2B] [dop_re 2B] [dop_im 2B] [det 1B] [0x55]
Status packet: [0xBB] [status 6×32b] [0x55]
Status packet: [0xBB] [status 6x32b] [0x55]
RX (Host→FPGA):
Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo}
"""
@@ -21,8 +21,9 @@ import time
import threading
import queue
import logging
import contextlib
from dataclasses import dataclass, field
from typing import Optional, List, Tuple, Dict, Any
from typing import Any
from enum import IntEnum
@@ -50,20 +51,36 @@ WATERFALL_DEPTH = 64
class Opcode(IntEnum):
"""Host register opcodes (matches radar_system_top.v command decode)."""
TRIGGER = 0x01
PRF_DIV = 0x02
NUM_CHIRPS = 0x03
CHIRP_TIMER = 0x04
STREAM_ENABLE = 0x05
GAIN_SHIFT = 0x06
THRESHOLD = 0x10
"""Host register opcodes — must match radar_system_top.v case(usb_cmd_opcode).
FPGA truth table (from radar_system_top.v lines 902-944):
0x01 host_radar_mode 0x14 host_short_listen_cycles
0x02 host_trigger_pulse 0x15 host_chirps_per_elev
0x03 host_detect_threshold 0x16 host_gain_shift
0x04 host_stream_control 0x20 host_range_mode
0x10 host_long_chirp_cycles 0x21-0x27 CFAR / MTI / DC-notch
0x11 host_long_listen_cycles 0x30 host_self_test_trigger
0x12 host_guard_cycles 0x31 host_status_request
0x13 host_short_chirp_cycles 0xFF host_status_request
"""
# --- Basic control (0x01-0x04) ---
RADAR_MODE = 0x01 # 2-bit mode select
TRIGGER_PULSE = 0x02 # self-clearing one-shot trigger
DETECT_THRESHOLD = 0x03 # 16-bit detection threshold value
STREAM_CONTROL = 0x04 # 3-bit stream enable mask
# --- Digital gain (0x16) ---
GAIN_SHIFT = 0x16 # 4-bit digital gain shift
# --- Chirp timing (0x10-0x15) ---
LONG_CHIRP = 0x10
LONG_LISTEN = 0x11
GUARD = 0x12
SHORT_CHIRP = 0x13
SHORT_LISTEN = 0x14
CHIRPS_PER_ELEV = 0x15
# --- Signal processing (0x20-0x27) ---
RANGE_MODE = 0x20
CFAR_GUARD = 0x21
CFAR_TRAIN = 0x22
@@ -72,6 +89,8 @@ class Opcode(IntEnum):
CFAR_ENABLE = 0x25
MTI_ENABLE = 0x26
DC_NOTCH_WIDTH = 0x27
# --- Board self-test / status (0x30-0x31, 0xFF) ---
SELF_TEST_TRIGGER = 0x30
SELF_TEST_STATUS = 0x31
STATUS_REQUEST = 0xFF
@@ -83,7 +102,7 @@ class Opcode(IntEnum):
@dataclass
class RadarFrame:
"""One complete radar frame (64 range × 32 Doppler)."""
"""One complete radar frame (64 range x 32 Doppler)."""
timestamp: float = 0.0
range_doppler_i: np.ndarray = field(
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.int16))
@@ -101,7 +120,7 @@ class RadarFrame:
@dataclass
class StatusResponse:
"""Parsed status response from FPGA (8-word packet as of Build 26)."""
"""Parsed status response from FPGA (6-word / 26-byte packet)."""
radar_mode: int = 0
stream_ctrl: int = 0
cfar_threshold: int = 0
@@ -144,7 +163,7 @@ class RadarProtocol:
return struct.pack(">I", word)
@staticmethod
def parse_data_packet(raw: bytes) -> Optional[Dict[str, Any]]:
def parse_data_packet(raw: bytes) -> dict[str, Any] | None:
"""
Parse an 11-byte data packet from the FT2232H byte stream.
Returns dict with keys: 'range_i', 'range_q', 'doppler_i', 'doppler_q',
@@ -181,10 +200,10 @@ class RadarProtocol:
}
@staticmethod
def parse_status_packet(raw: bytes) -> Optional[StatusResponse]:
def parse_status_packet(raw: bytes) -> StatusResponse | None:
"""
Parse a status response packet.
Format: [0xBB] [6×4B status words] [0x55] = 1 + 24 + 1 = 26 bytes
Format: [0xBB] [6x4B status words] [0x55] = 1 + 24 + 1 = 26 bytes
"""
if len(raw) < 26:
return None
@@ -223,7 +242,7 @@ class RadarProtocol:
return sr
@staticmethod
def find_packet_boundaries(buf: bytes) -> List[Tuple[int, int, str]]:
def find_packet_boundaries(buf: bytes) -> list[tuple[int, int, str]]:
"""
Scan buffer for packet start markers (0xAA data, 0xBB status).
Returns list of (start_idx, expected_end_idx, packet_type).
@@ -233,19 +252,22 @@ class RadarProtocol:
while i < len(buf):
if buf[i] == HEADER_BYTE:
end = i + DATA_PACKET_SIZE
if end <= len(buf):
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
packets.append((i, end, "data"))
i = end
else:
break
if end > len(buf):
break # partial packet at end — leave for residual
i += 1 # footer mismatch — skip this false header
elif buf[i] == STATUS_HEADER_BYTE:
# Status packet: 26 bytes (same for both interfaces)
end = i + STATUS_PACKET_SIZE
if end <= len(buf):
if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
packets.append((i, end, "status"))
i = end
else:
break
if end > len(buf):
break # partial status packet — leave for residual
i += 1 # footer mismatch — skip
else:
i += 1
return packets
@@ -257,9 +279,13 @@ class RadarProtocol:
# Optional pyftdi import
try:
from pyftdi.ftdi import Ftdi as PyFtdi
from pyftdi.ftdi import Ftdi, FtdiError
PyFtdi = Ftdi
PYFTDI_AVAILABLE = True
except ImportError:
class FtdiError(Exception):
"""Fallback FTDI error type when pyftdi is unavailable."""
PYFTDI_AVAILABLE = False
@@ -306,20 +332,18 @@ class FT2232HConnection:
self.is_open = True
log.info(f"FT2232H device opened: {url}")
return True
except Exception as e:
except FtdiError as e:
log.error(f"FT2232H open failed: {e}")
return False
def close(self):
if self._ftdi is not None:
try:
with contextlib.suppress(Exception):
self._ftdi.close()
except Exception:
pass
self._ftdi = None
self.is_open = False
def read(self, size: int = 4096) -> Optional[bytes]:
def read(self, size: int = 4096) -> bytes | None:
"""Read raw bytes from FT2232H. Returns None on error/timeout."""
if not self.is_open:
return None
@@ -331,7 +355,7 @@ class FT2232HConnection:
try:
data = self._ftdi.read_data(size)
return bytes(data) if data else None
except Exception as e:
except FtdiError as e:
log.error(f"FT2232H read error: {e}")
return None
@@ -348,24 +372,29 @@ class FT2232HConnection:
try:
written = self._ftdi.write_data(data)
return written == len(data)
except Exception as e:
except FtdiError as e:
log.error(f"FT2232H write error: {e}")
return False
def _mock_read(self, size: int) -> bytes:
"""
Generate synthetic compact radar data packets (11-byte) for testing.
Generate synthetic 11-byte radar data packets for testing.
Simulates a batch of packets with a target near range bin 20, Doppler bin 8.
Emits packets in sequential FPGA order (range_bin 0..63, doppler_bin
0..31 within each range bin) so that RadarAcquisition._ingest_sample()
places them correctly. A target is injected near range bin 20,
Doppler bin 8.
"""
time.sleep(0.05)
self._mock_frame_num += 1
buf = bytearray()
num_packets = min(32, size // DATA_PACKET_SIZE)
for _ in range(num_packets):
rbin = self._mock_rng.randint(0, NUM_RANGE_BINS)
dbin = self._mock_rng.randint(0, NUM_DOPPLER_BINS)
num_packets = min(NUM_CELLS, size // DATA_PACKET_SIZE)
start_idx = getattr(self, '_mock_seq_idx', 0)
for n in range(num_packets):
idx = (start_idx + n) % NUM_CELLS
rbin = idx // NUM_DOPPLER_BINS
dbin = idx % NUM_DOPPLER_BINS
range_i = int(self._mock_rng.normal(0, 100))
range_q = int(self._mock_rng.normal(0, 100))
@@ -393,6 +422,7 @@ class FT2232HConnection:
buf += pkt
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
return bytes(buf)
@@ -401,19 +431,19 @@ class FT2232HConnection:
# ============================================================================
# Hardware-only opcodes that cannot be adjusted in replay mode
# Values must match radar_system_top.v case(usb_cmd_opcode).
_HARDWARE_ONLY_OPCODES = {
0x01, # TRIGGER
0x02, # PRF_DIV
0x03, # NUM_CHIRPS
0x04, # CHIRP_TIMER
0x05, # STREAM_ENABLE
0x06, # GAIN_SHIFT
0x10, # THRESHOLD / LONG_CHIRP
0x01, # RADAR_MODE
0x02, # TRIGGER_PULSE
0x03, # DETECT_THRESHOLD
0x04, # STREAM_CONTROL
0x10, # LONG_CHIRP
0x11, # LONG_LISTEN
0x12, # GUARD
0x13, # SHORT_CHIRP
0x14, # SHORT_LISTEN
0x15, # CHIRPS_PER_ELEV
0x16, # GAIN_SHIFT
0x20, # RANGE_MODE
0x30, # SELF_TEST_TRIGGER
0x31, # SELF_TEST_STATUS
@@ -439,26 +469,8 @@ def _saturate(val: int, bits: int) -> int:
return max(max_neg, min(max_pos, int(val)))
def _replay_mti(decim_i: np.ndarray, decim_q: np.ndarray,
enable: bool) -> Tuple[np.ndarray, np.ndarray]:
"""Bit-accurate 2-pulse MTI canceller (matches mti_canceller.v)."""
n_chirps, n_bins = decim_i.shape
mti_i = np.zeros_like(decim_i)
mti_q = np.zeros_like(decim_q)
if not enable:
return decim_i.copy(), decim_q.copy()
for c in range(n_chirps):
if c == 0:
pass # muted
else:
for r in range(n_bins):
mti_i[c, r] = _saturate(int(decim_i[c, r]) - int(decim_i[c - 1, r]), 16)
mti_q[c, r] = _saturate(int(decim_q[c, r]) - int(decim_q[c - 1, r]), 16)
return mti_i, mti_q
def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray,
width: int) -> Tuple[np.ndarray, np.ndarray]:
width: int) -> tuple[np.ndarray, np.ndarray]:
"""Bit-accurate DC notch filter (matches radar_system_top.v inline).
Dual sub-frame notch: doppler_bin[4:0] = {sub_frame, bin[3:0]}.
@@ -480,7 +492,7 @@ def _replay_dc_notch(doppler_i: np.ndarray, doppler_q: np.ndarray,
def _replay_cfar(doppler_i: np.ndarray, doppler_q: np.ndarray,
guard: int, train: int, alpha_q44: int,
mode: int) -> Tuple[np.ndarray, np.ndarray]:
mode: int) -> tuple[np.ndarray, np.ndarray]:
"""
Bit-accurate CA-CFAR detector (matches cfar_ca.v).
Returns (detect_flags, magnitudes) both (64, 32).
@@ -584,16 +596,16 @@ class ReplayConnection:
self._cfar_mode: int = 0 # 0=CA, 1=GO, 2=SO
self._cfar_enable: bool = True
# Raw source arrays (loaded once, reprocessed on param change)
self._dop_mti_i: Optional[np.ndarray] = None
self._dop_mti_q: Optional[np.ndarray] = None
self._dop_nomti_i: Optional[np.ndarray] = None
self._dop_nomti_q: Optional[np.ndarray] = None
self._range_i_vec: Optional[np.ndarray] = None
self._range_q_vec: Optional[np.ndarray] = None
self._dop_mti_i: np.ndarray | None = None
self._dop_mti_q: np.ndarray | None = None
self._dop_nomti_i: np.ndarray | None = None
self._dop_nomti_q: np.ndarray | None = None
self._range_i_vec: np.ndarray | None = None
self._range_q_vec: np.ndarray | None = None
# Rebuild flag
self._needs_rebuild = False
def open(self, device_index: int = 0) -> bool:
def open(self, _device_index: int = 0) -> bool:
try:
self._load_arrays()
self._packets = self._build_packets()
@@ -604,14 +616,14 @@ class ReplayConnection:
f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
f"{self._frame_len} bytes/frame)")
return True
except Exception as e:
except (OSError, ValueError, struct.error) as e:
log.error(f"Replay open failed: {e}")
return False
def close(self):
self.is_open = False
def read(self, size: int = 4096) -> Optional[bytes]:
def read(self, size: int = 4096) -> bytes | None:
if not self.is_open:
return None
# Pace reads to target FPS (spread across ~64 reads per frame)
@@ -673,10 +685,9 @@ class ReplayConnection:
if self._mti_enable != new_en:
self._mti_enable = new_en
changed = True
elif opcode == 0x27: # DC_NOTCH_WIDTH
if self._dc_notch_width != value:
self._dc_notch_width = value
changed = True
elif opcode == 0x27 and self._dc_notch_width != value: # DC_NOTCH_WIDTH
self._dc_notch_width = value
changed = True
if changed:
self._needs_rebuild = True
if changed:
@@ -827,7 +838,7 @@ class DataRecorder:
self._frame_count = 0
self._recording = True
log.info(f"Recording started: {filepath}")
except Exception as e:
except (OSError, ValueError) as e:
log.error(f"Failed to start recording: {e}")
def record_frame(self, frame: RadarFrame):
@@ -844,7 +855,7 @@ class DataRecorder:
fg.create_dataset("detections", data=frame.detections, compression="gzip")
fg.create_dataset("range_profile", data=frame.range_profile, compression="gzip")
self._frame_count += 1
except Exception as e:
except (OSError, ValueError, TypeError) as e:
log.error(f"Recording error: {e}")
def stop(self):
@@ -853,7 +864,7 @@ class DataRecorder:
self._file.attrs["end_time"] = time.time()
self._file.attrs["total_frames"] = self._frame_count
self._file.close()
except Exception:
except (OSError, ValueError, RuntimeError):
pass
self._file = None
self._recording = False
@@ -871,7 +882,7 @@ class RadarAcquisition(threading.Thread):
"""
def __init__(self, connection, frame_queue: queue.Queue,
recorder: Optional[DataRecorder] = None,
recorder: DataRecorder | None = None,
status_callback=None):
super().__init__(daemon=True)
self.conn = connection
@@ -888,13 +899,25 @@ class RadarAcquisition(threading.Thread):
def run(self):
log.info("Acquisition thread started")
residual = b""
while not self._stop_event.is_set():
raw = self.conn.read(4096)
if raw is None or len(raw) == 0:
chunk = self.conn.read(4096)
if chunk is None or len(chunk) == 0:
time.sleep(0.01)
continue
raw = residual + chunk
packets = RadarProtocol.find_packet_boundaries(raw)
# Keep unparsed tail bytes for next iteration
if packets:
last_end = packets[-1][1]
residual = raw[last_end:]
else:
# No packets found — keep entire buffer as residual
# but cap at 2x max packet size to avoid unbounded growth
max_residual = 2 * max(DATA_PACKET_SIZE, STATUS_PACKET_SIZE)
residual = raw[-max_residual:] if len(raw) > max_residual else raw
for start, end, ptype in packets:
if ptype == "data":
parsed = RadarProtocol.parse_data_packet(
@@ -913,12 +936,12 @@ class RadarAcquisition(threading.Thread):
if self._status_callback is not None:
try:
self._status_callback(status)
except Exception as e:
except Exception as e: # noqa: BLE001
log.error(f"Status callback error: {e}")
log.info("Acquisition thread stopped")
def _ingest_sample(self, sample: Dict):
def _ingest_sample(self, sample: dict):
"""Place sample into current frame and emit when complete."""
rbin = self._sample_idx // NUM_DOPPLER_BINS
dbin = self._sample_idx % NUM_DOPPLER_BINS
@@ -948,10 +971,8 @@ class RadarAcquisition(threading.Thread):
try:
self.frame_queue.put_nowait(self._frame)
except queue.Full:
try:
with contextlib.suppress(queue.Empty):
self.frame_queue.get_nowait()
except queue.Empty:
pass
self.frame_queue.put_nowait(self._frame)
if self.recorder and self.recorder.recording:
@@ -0,0 +1,20 @@
# Requirements for PLFM Radar Dashboard - PyQt6 Edition
# ======================================================
# Install with: pip install -r requirements_pyqt_gui.txt
# Core PyQt6 framework
PyQt6>=6.5.0
# Web engine for embedded Leaflet maps
PyQt6-WebEngine>=6.5.0
# Optional: Additional dependencies from existing radar code
# (uncomment if integrating with existing radar processing)
# numpy>=1.24
# scipy>=1.10
# scikit-learn>=1.2
# filterpy>=1.4
# matplotlib>=3.7
# Note: The GUI uses Leaflet.js (loaded from CDN) for maps
# No additional Python map libraries required
+22
View File
@@ -0,0 +1,22 @@
# PLFM Radar GUI V7 — Python dependencies
# Install with: pip install -r requirements_v7.txt
# Core (required)
PyQt6>=6.5
PyQt6-WebEngine>=6.5
numpy>=1.24
matplotlib>=3.7
# Hardware interfaces (optional — GUI degrades gracefully)
pyusb>=1.2
pyftdi>=0.54
# Signal processing (optional)
scipy>=1.10
# Tracking / clustering (optional)
scikit-learn>=1.2
filterpy>=1.4
# CRC validation (optional)
crcmod>=1.7
+6 -8
View File
@@ -66,7 +66,7 @@ TEST_NAMES = {
class SmokeTest:
"""Host-side smoke test controller."""
def __init__(self, connection: FT2232HConnection, adc_dump_path: str = None):
def __init__(self, connection: FT2232HConnection, adc_dump_path: str | None = None):
self.conn = connection
self.adc_dump_path = adc_dump_path
self._adc_samples = []
@@ -82,10 +82,9 @@ class SmokeTest:
log.info("")
# Step 1: Connect
if not self.conn.is_open:
if not self.conn.open():
log.error("Failed to open FT2232H connection")
return False
if not self.conn.is_open and not self.conn.open():
log.error("Failed to open FT2232H connection")
return False
# Step 2: Send self-test trigger (opcode 0x30)
log.info("Sending self-test trigger (opcode 0x30)...")
@@ -188,10 +187,9 @@ class SmokeTest:
def _save_adc_dump(self):
"""Save captured ADC samples to numpy file."""
if not self._adc_samples:
if not self._adc_samples and self.conn._mock:
# In mock mode, generate synthetic ADC data
if self.conn._mock:
self._adc_samples = list(np.random.randint(0, 65536, 256, dtype=np.uint16))
self._adc_samples = list(np.random.randint(0, 65536, 256, dtype=np.uint16))
if self._adc_samples:
arr = np.array(self._adc_samples, dtype=np.uint16)
+39 -20
View File
@@ -368,7 +368,7 @@ class TestRadarAcquisition(unittest.TestCase):
# Wait for at least one frame (mock produces ~32 samples per read,
# need 2048 for a full frame, so may take a few seconds)
frame = None
try:
try: # noqa: SIM105
frame = fq.get(timeout=10)
except queue.Empty:
pass
@@ -421,8 +421,8 @@ class TestEndToEnd(unittest.TestCase):
def test_command_roundtrip_all_opcodes(self):
"""Verify all opcodes produce valid 4-byte commands."""
opcodes = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x10, 0x11, 0x12,
0x13, 0x14, 0x15, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25,
opcodes = [0x01, 0x02, 0x03, 0x04, 0x10, 0x11, 0x12,
0x13, 0x14, 0x15, 0x16, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25,
0x26, 0x27, 0x30, 0x31, 0xFF]
for op in opcodes:
cmd = RadarProtocol.build_command(op, 42)
@@ -630,8 +630,8 @@ class TestReplayConnection(unittest.TestCase):
cmd = RadarProtocol.build_command(0x01, 1)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
# Send STREAM_ENABLE (hardware-only)
cmd = RadarProtocol.build_command(0x05, 7)
# Send STREAM_CONTROL (hardware-only, opcode 0x04)
cmd = RadarProtocol.build_command(0x04, 7)
conn.write(cmd)
self.assertFalse(conn._needs_rebuild)
conn.close()
@@ -668,14 +668,14 @@ class TestReplayConnection(unittest.TestCase):
class TestOpcodeEnum(unittest.TestCase):
"""Verify Opcode enum matches RTL host register map."""
"""Verify Opcode enum matches RTL host register map (radar_system_top.v)."""
def test_gain_shift_is_0x06(self):
"""GAIN_SHIFT opcode must be 0x06 (not 0x16)."""
self.assertEqual(Opcode.GAIN_SHIFT, 0x06)
def test_gain_shift_is_0x16(self):
"""GAIN_SHIFT opcode must be 0x16 (matches radar_system_top.v:928)."""
self.assertEqual(Opcode.GAIN_SHIFT, 0x16)
def test_no_digital_gain_alias(self):
"""DIGITAL_GAIN should NOT exist (was bogus 0x16 alias)."""
"""DIGITAL_GAIN should NOT exist (use GAIN_SHIFT)."""
self.assertFalse(hasattr(Opcode, 'DIGITAL_GAIN'))
def test_self_test_trigger(self):
@@ -691,21 +691,40 @@ class TestOpcodeEnum(unittest.TestCase):
self.assertIn(0x30, _HARDWARE_ONLY_OPCODES)
self.assertIn(0x31, _HARDWARE_ONLY_OPCODES)
def test_0x16_not_in_hardware_only(self):
"""Bogus 0x16 must not be in _HARDWARE_ONLY_OPCODES."""
self.assertNotIn(0x16, _HARDWARE_ONLY_OPCODES)
def test_0x16_in_hardware_only(self):
"""GAIN_SHIFT 0x16 must be in _HARDWARE_ONLY_OPCODES."""
self.assertIn(0x16, _HARDWARE_ONLY_OPCODES)
def test_stream_enable_is_0x05(self):
"""STREAM_ENABLE must be 0x05 (not 0x04)."""
self.assertEqual(Opcode.STREAM_ENABLE, 0x05)
def test_stream_control_is_0x04(self):
"""STREAM_CONTROL must be 0x04 (matches radar_system_top.v:906)."""
self.assertEqual(Opcode.STREAM_CONTROL, 0x04)
def test_legacy_aliases_removed(self):
"""Legacy aliases must NOT exist in production Opcode enum."""
for name in ("TRIGGER", "PRF_DIV", "NUM_CHIRPS", "CHIRP_TIMER",
"STREAM_ENABLE", "THRESHOLD"):
self.assertFalse(hasattr(Opcode, name),
f"Legacy alias Opcode.{name} should not exist")
def test_radar_mode_names(self):
"""New canonical names must exist and match FPGA opcodes."""
self.assertEqual(Opcode.RADAR_MODE, 0x01)
self.assertEqual(Opcode.TRIGGER_PULSE, 0x02)
self.assertEqual(Opcode.DETECT_THRESHOLD, 0x03)
self.assertEqual(Opcode.STREAM_CONTROL, 0x04)
def test_stale_opcodes_not_in_hardware_only(self):
"""Old wrong opcode values must not be in _HARDWARE_ONLY_OPCODES."""
self.assertNotIn(0x05, _HARDWARE_ONLY_OPCODES) # was wrong STREAM_ENABLE
self.assertNotIn(0x06, _HARDWARE_ONLY_OPCODES) # was wrong GAIN_SHIFT
def test_all_rtl_opcodes_present(self):
"""Every RTL opcode has a matching Opcode enum member."""
expected = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15,
"""Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member."""
expected = {0x01, 0x02, 0x03, 0x04,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x30, 0x31, 0xFF}
enum_values = set(int(m) for m in Opcode)
enum_values = {int(m) for m in Opcode}
for op in expected:
self.assertIn(op, enum_values, f"0x{op:02X} missing from Opcode enum")
+347
View File
@@ -0,0 +1,347 @@
"""
V7-specific unit tests for the PLFM Radar GUI V7 modules.
Tests cover:
- v7.models: RadarTarget, RadarSettings, GPSData, ProcessingConfig
- v7.processing: RadarProcessor, USBPacketParser, apply_pitch_correction
- v7.workers: polar_to_geographic
- v7.hardware: STM32USBInterface (basic), production protocol re-exports
Does NOT require a running Qt event loop — only unit-testable components.
Run with: python -m unittest test_v7 -v
"""
import struct
import unittest
from dataclasses import asdict
import numpy as np
# =============================================================================
# Test: v7.models
# =============================================================================
class TestRadarTarget(unittest.TestCase):
"""RadarTarget dataclass."""
def test_defaults(self):
t = _models().RadarTarget(id=1, range=1000.0, velocity=5.0,
azimuth=45.0, elevation=2.0)
self.assertEqual(t.id, 1)
self.assertEqual(t.range, 1000.0)
self.assertEqual(t.snr, 0.0)
self.assertEqual(t.track_id, -1)
self.assertEqual(t.classification, "unknown")
def test_to_dict(self):
t = _models().RadarTarget(id=1, range=500.0, velocity=-10.0,
azimuth=0.0, elevation=0.0, snr=15.0)
d = t.to_dict()
self.assertIsInstance(d, dict)
self.assertEqual(d["range"], 500.0)
self.assertEqual(d["snr"], 15.0)
class TestRadarSettings(unittest.TestCase):
"""RadarSettings — verify stale STM32 fields are removed."""
def test_no_stale_fields(self):
"""chirp_duration, freq_min/max, prf1/2 must NOT exist."""
s = _models().RadarSettings()
d = asdict(s)
for stale in ["chirp_duration_1", "chirp_duration_2",
"freq_min", "freq_max", "prf1", "prf2",
"chirps_per_position"]:
self.assertNotIn(stale, d, f"Stale field '{stale}' still present")
def test_has_physical_conversion_fields(self):
s = _models().RadarSettings()
self.assertIsInstance(s.range_resolution, float)
self.assertIsInstance(s.velocity_resolution, float)
self.assertGreater(s.range_resolution, 0)
self.assertGreater(s.velocity_resolution, 0)
def test_defaults(self):
s = _models().RadarSettings()
self.assertEqual(s.system_frequency, 10e9)
self.assertEqual(s.coverage_radius, 50000)
self.assertEqual(s.max_distance, 50000)
class TestGPSData(unittest.TestCase):
def test_to_dict(self):
g = _models().GPSData(latitude=41.9, longitude=12.5,
altitude=100.0, pitch=2.5)
d = g.to_dict()
self.assertAlmostEqual(d["latitude"], 41.9)
self.assertAlmostEqual(d["pitch"], 2.5)
class TestProcessingConfig(unittest.TestCase):
def test_defaults(self):
cfg = _models().ProcessingConfig()
self.assertTrue(cfg.clustering_enabled)
self.assertTrue(cfg.tracking_enabled)
self.assertFalse(cfg.mti_enabled)
self.assertFalse(cfg.cfar_enabled)
class TestNoCrcmodDependency(unittest.TestCase):
"""crcmod was removed — verify it's not exported."""
def test_no_crcmod_available(self):
models = _models()
self.assertFalse(hasattr(models, "CRCMOD_AVAILABLE"),
"CRCMOD_AVAILABLE should be removed from models")
# =============================================================================
# Test: v7.processing
# =============================================================================
class TestApplyPitchCorrection(unittest.TestCase):
def test_positive_pitch(self):
from v7.processing import apply_pitch_correction
self.assertAlmostEqual(apply_pitch_correction(10.0, 3.0), 7.0)
def test_zero_pitch(self):
from v7.processing import apply_pitch_correction
self.assertAlmostEqual(apply_pitch_correction(5.0, 0.0), 5.0)
class TestRadarProcessorMTI(unittest.TestCase):
def test_mti_order1(self):
from v7.processing import RadarProcessor
from v7.models import ProcessingConfig
proc = RadarProcessor()
proc.set_config(ProcessingConfig(mti_enabled=True, mti_order=1))
frame1 = np.ones((64, 32))
frame2 = np.ones((64, 32)) * 3
result1 = proc.mti_filter(frame1)
np.testing.assert_array_equal(result1, np.zeros((64, 32)),
err_msg="First frame should be zeros (no history)")
result2 = proc.mti_filter(frame2)
expected = frame2 - frame1
np.testing.assert_array_almost_equal(result2, expected)
def test_mti_order2(self):
from v7.processing import RadarProcessor
from v7.models import ProcessingConfig
proc = RadarProcessor()
proc.set_config(ProcessingConfig(mti_enabled=True, mti_order=2))
f1 = np.ones((4, 4))
f2 = np.ones((4, 4)) * 2
f3 = np.ones((4, 4)) * 5
proc.mti_filter(f1) # zeros (need 3 frames)
proc.mti_filter(f2) # zeros
result = proc.mti_filter(f3)
# Order 2: x[n] - 2*x[n-1] + x[n-2] = 5 - 4 + 1 = 2
np.testing.assert_array_almost_equal(result, np.ones((4, 4)) * 2)
class TestRadarProcessorCFAR(unittest.TestCase):
def test_cfar_1d_detects_peak(self):
from v7.processing import RadarProcessor
signal = np.ones(64) * 10
signal[32] = 500 # inject a strong target
det = RadarProcessor.cfar_1d(signal, guard=2, train=4,
threshold_factor=3.0, cfar_type="CA-CFAR")
self.assertTrue(det[32], "Should detect strong peak at bin 32")
def test_cfar_1d_no_false_alarm(self):
from v7.processing import RadarProcessor
signal = np.ones(64) * 10 # uniform — no target
det = RadarProcessor.cfar_1d(signal, guard=2, train=4,
threshold_factor=3.0)
self.assertEqual(det.sum(), 0, "Should have no detections in flat noise")
class TestRadarProcessorProcessFrame(unittest.TestCase):
def test_process_frame_returns_shapes(self):
from v7.processing import RadarProcessor
proc = RadarProcessor()
frame = np.random.randn(64, 32) * 10
frame[20, 8] = 5000 # inject a target
power, mask = proc.process_frame(frame)
self.assertEqual(power.shape, (64, 32))
self.assertEqual(mask.shape, (64, 32))
self.assertEqual(mask.dtype, bool)
class TestRadarProcessorWindowing(unittest.TestCase):
def test_hann_window(self):
from v7.processing import RadarProcessor
data = np.ones((4, 32))
windowed = RadarProcessor.apply_window(data, "Hann")
# Hann window tapers to ~0 at edges
self.assertLess(windowed[0, 0], 0.1)
self.assertGreater(windowed[0, 16], 0.5)
def test_none_window(self):
from v7.processing import RadarProcessor
data = np.ones((4, 32))
result = RadarProcessor.apply_window(data, "None")
np.testing.assert_array_equal(result, data)
class TestRadarProcessorDCNotch(unittest.TestCase):
def test_dc_removal(self):
from v7.processing import RadarProcessor
data = np.ones((4, 8)) * 100
data[0, :] += 50 # DC offset in range bin 0
result = RadarProcessor.dc_notch(data)
# Mean along axis=1 should be ~0
row_means = np.mean(result, axis=1)
for m in row_means:
self.assertAlmostEqual(m, 0, places=10)
class TestRadarProcessorClustering(unittest.TestCase):
def test_clustering_empty(self):
from v7.processing import RadarProcessor
result = RadarProcessor.clustering([], eps=100, min_samples=2)
self.assertEqual(result, [])
class TestUSBPacketParser(unittest.TestCase):
def test_parse_gps_text(self):
from v7.processing import USBPacketParser
parser = USBPacketParser()
data = b"GPS:41.9028,12.4964,100.0,2.5\r\n"
gps = parser.parse_gps_data(data)
self.assertIsNotNone(gps)
self.assertAlmostEqual(gps.latitude, 41.9028, places=3)
self.assertAlmostEqual(gps.longitude, 12.4964, places=3)
self.assertAlmostEqual(gps.altitude, 100.0)
self.assertAlmostEqual(gps.pitch, 2.5)
def test_parse_gps_text_invalid(self):
from v7.processing import USBPacketParser
parser = USBPacketParser()
self.assertIsNone(parser.parse_gps_data(b"NOT_GPS_DATA"))
self.assertIsNone(parser.parse_gps_data(b""))
self.assertIsNone(parser.parse_gps_data(None))
def test_parse_binary_gps(self):
from v7.processing import USBPacketParser
parser = USBPacketParser()
# Build a valid binary GPS packet
pkt = bytearray(b"GPSB")
pkt += struct.pack(">d", 41.9028) # lat
pkt += struct.pack(">d", 12.4964) # lon
pkt += struct.pack(">f", 100.0) # alt
pkt += struct.pack(">f", 2.5) # pitch
# Simple checksum
cksum = sum(pkt) & 0xFFFF
pkt += struct.pack(">H", cksum)
self.assertEqual(len(pkt), 30)
gps = parser.parse_gps_data(bytes(pkt))
self.assertIsNotNone(gps)
self.assertAlmostEqual(gps.latitude, 41.9028, places=3)
def test_no_crc16_func_attribute(self):
"""crcmod was removed — USBPacketParser should not have crc16_func."""
from v7.processing import USBPacketParser
parser = USBPacketParser()
self.assertFalse(hasattr(parser, "crc16_func"),
"crc16_func should be removed (crcmod dead code)")
def test_no_multi_prf_unwrap(self):
"""multi_prf_unwrap was removed (never called, prf fields removed)."""
from v7.processing import RadarProcessor
self.assertFalse(hasattr(RadarProcessor, "multi_prf_unwrap"),
"multi_prf_unwrap should be removed")
# =============================================================================
# Test: v7.workers — polar_to_geographic
# =============================================================================
class TestPolarToGeographic(unittest.TestCase):
def test_north_bearing(self):
from v7.workers import polar_to_geographic
lat, lon = polar_to_geographic(0.0, 0.0, 1000.0, 0.0)
# Moving 1km north from equator
self.assertGreater(lat, 0.0)
self.assertAlmostEqual(lon, 0.0, places=4)
def test_east_bearing(self):
from v7.workers import polar_to_geographic
lat, lon = polar_to_geographic(0.0, 0.0, 1000.0, 90.0)
self.assertAlmostEqual(lat, 0.0, places=4)
self.assertGreater(lon, 0.0)
def test_zero_range(self):
from v7.workers import polar_to_geographic
lat, lon = polar_to_geographic(41.9, 12.5, 0.0, 0.0)
self.assertAlmostEqual(lat, 41.9, places=6)
self.assertAlmostEqual(lon, 12.5, places=6)
# =============================================================================
# Test: v7.hardware — production protocol re-exports
# =============================================================================
class TestHardwareReExports(unittest.TestCase):
"""Verify hardware.py re-exports all production protocol classes."""
def test_exports(self):
from v7.hardware import (
FT2232HConnection,
RadarProtocol,
STM32USBInterface,
)
# Verify these are actual classes/types, not None
self.assertTrue(callable(FT2232HConnection))
self.assertTrue(callable(RadarProtocol))
self.assertTrue(callable(STM32USBInterface))
def test_stm32_list_devices_no_crash(self):
from v7.hardware import STM32USBInterface
stm = STM32USBInterface()
self.assertFalse(stm.is_open)
# list_devices should return empty list (no USB in test env), not crash
devs = stm.list_devices()
self.assertIsInstance(devs, list)
# =============================================================================
# Test: v7.__init__ — clean exports
# =============================================================================
class TestV7Init(unittest.TestCase):
"""Verify top-level v7 package exports."""
def test_no_crcmod_export(self):
import v7
self.assertFalse(hasattr(v7, "CRCMOD_AVAILABLE"),
"CRCMOD_AVAILABLE should not be in v7.__all__")
def test_key_exports(self):
import v7
for name in ["RadarTarget", "RadarSettings", "GPSData",
"ProcessingConfig", "FT2232HConnection",
"RadarProtocol", "RadarProcessor",
"RadarDataWorker", "RadarMapWidget",
"RadarDashboard"]:
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
# =============================================================================
# Helper: lazy import of v7.models
# =============================================================================
def _models():
import v7.models
return v7.models
if __name__ == "__main__":
unittest.main()
+17 -9
View File
@@ -19,19 +19,25 @@ from .models import (
DARK_TREEVIEW, DARK_TREEVIEW_ALT,
DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO,
USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE,
SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
)
# Hardware interfaces
# Hardware interfaces — production protocol via radar_protocol.py
from .hardware import (
FT2232HQInterface,
FT2232HConnection,
ReplayConnection,
RadarProtocol,
Opcode,
RadarAcquisition,
RadarFrame,
StatusResponse,
DataRecorder,
STM32USBInterface,
)
# Processing pipeline
from .processing import (
RadarProcessor,
RadarPacketParser,
USBPacketParser,
apply_pitch_correction,
)
@@ -56,7 +62,7 @@ from .dashboard import (
RangeDopplerCanvas,
)
__all__ = [
__all__ = [ # noqa: RUF022
# models
"RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
"DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
@@ -64,11 +70,13 @@ __all__ = [
"DARK_TREEVIEW", "DARK_TREEVIEW_ALT",
"DARK_SUCCESS", "DARK_WARNING", "DARK_ERROR", "DARK_INFO",
"USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE",
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", "CRCMOD_AVAILABLE",
# hardware
"FT2232HQInterface", "STM32USBInterface",
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE",
# hardware — production FPGA protocol
"FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode",
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
"STM32USBInterface",
# processing
"RadarProcessor", "RadarPacketParser", "USBPacketParser",
"RadarProcessor", "USBPacketParser",
"apply_pitch_correction",
# workers
"RadarDataWorker", "GPSDataWorker", "TargetSimulator",
File diff suppressed because it is too large Load Diff
+44 -175
View File
@@ -1,141 +1,62 @@
"""
v7.hardware Hardware interface classes for the PLFM Radar GUI V7.
Provides two USB hardware interfaces:
- FT2232HQInterface (PRIMARY USB 2.0, VID 0x0403 / PID 0x6010)
- STM32USBInterface (USB CDC for commands and GPS)
Provides:
- FT2232H radar data + command interface via production radar_protocol module
- ReplayConnection for offline .npy replay via production radar_protocol module
- STM32USBInterface for GPS data only (USB CDC)
The FT2232H interface uses the production protocol layer (radar_protocol.py)
which sends 4-byte {opcode, addr, value_hi, value_lo} register commands and
parses 0xAA data / 0xBB status packets from the FPGA. The old magic-packet
and 'SET'...'END' binary settings protocol has been removed it was
incompatible with the FPGA register interface.
"""
import struct
import sys
import os
import logging
from typing import List, Dict, Optional
from typing import ClassVar
from .models import (
USB_AVAILABLE, FTDI_AVAILABLE,
RadarSettings,
)
from .models import USB_AVAILABLE
if USB_AVAILABLE:
import usb.core
import usb.util
if FTDI_AVAILABLE:
from pyftdi.ftdi import Ftdi
from pyftdi.usbtools import UsbTools
# Import production protocol layer — single source of truth for FPGA comms
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from radar_protocol import ( # noqa: F401 — re-exported for v7 package
FT2232HConnection,
ReplayConnection,
RadarProtocol,
Opcode,
RadarAcquisition,
RadarFrame,
StatusResponse,
DataRecorder,
)
logger = logging.getLogger(__name__)
# =============================================================================
# FT2232HQ Interface — PRIMARY data path (USB 2.0)
# =============================================================================
class FT2232HQInterface:
"""
Interface for FT2232HQ (USB 2.0 Hi-Speed) in synchronous FIFO mode.
This is the **primary** radar data interface.
VID/PID: 0x0403 / 0x6010
"""
VID = 0x0403
PID = 0x6010
def __init__(self):
self.ftdi: Optional[object] = None
self.is_open: bool = False
# ---- enumeration -------------------------------------------------------
def list_devices(self) -> List[Dict]:
"""List available FT2232H devices using pyftdi."""
if not FTDI_AVAILABLE:
logger.warning("pyftdi not available — cannot enumerate FT2232H devices")
return []
try:
devices = []
for device_desc in UsbTools.find_all([(self.VID, self.PID)]):
devices.append({
"description": f"FT2232H Device {device_desc}",
"url": f"ftdi://{device_desc}/1",
})
return devices
except Exception as e:
logger.error(f"Error listing FT2232H devices: {e}")
return []
# ---- open / close ------------------------------------------------------
def open_device(self, device_url: str) -> bool:
"""Open FT2232H device in synchronous FIFO mode."""
if not FTDI_AVAILABLE:
logger.error("pyftdi not available — cannot open device")
return False
try:
self.ftdi = Ftdi()
self.ftdi.open_from_url(device_url)
# Synchronous FIFO mode
self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.SYNCFF)
# Low-latency timer (2 ms)
self.ftdi.set_latency_timer(2)
# Purge stale data
self.ftdi.purge_buffers()
self.is_open = True
logger.info(f"FT2232H device opened: {device_url}")
return True
except Exception as e:
logger.error(f"Error opening FT2232H device: {e}")
self.ftdi = None
return False
def close(self):
"""Close FT2232H device."""
if self.ftdi and self.is_open:
try:
self.ftdi.close()
except Exception as e:
logger.error(f"Error closing FT2232H device: {e}")
finally:
self.is_open = False
self.ftdi = None
# ---- data I/O ----------------------------------------------------------
def read_data(self, bytes_to_read: int = 4096) -> Optional[bytes]:
"""Read data from FT2232H."""
if not self.is_open or self.ftdi is None:
return None
try:
data = self.ftdi.read_data(bytes_to_read)
if data:
return bytes(data)
return None
except Exception as e:
logger.error(f"Error reading from FT2232H: {e}")
return None
# =============================================================================
# STM32 USB CDC Interface — commands & GPS data
# STM32 USB CDC Interface — GPS data ONLY
# =============================================================================
class STM32USBInterface:
"""
Interface for STM32 USB CDC (Virtual COM Port).
Used to:
- Send start flag and radar settings to the MCU
- Receive GPS data from the MCU
Used ONLY for receiving GPS data from the MCU.
FPGA register commands are sent via FT2232H (see FT2232HConnection
from radar_protocol.py). The old send_start_flag() / send_settings()
methods have been removed they used an incompatible magic-packet
protocol that the FPGA does not understand.
"""
STM32_VID_PIDS = [
STM32_VID_PIDS: ClassVar[list[tuple[int, int]]] = [
(0x0483, 0x5740), # STM32 Virtual COM Port
(0x0483, 0x3748), # STM32 Discovery
(0x0483, 0x374B),
@@ -152,7 +73,7 @@ class STM32USBInterface:
# ---- enumeration -------------------------------------------------------
def list_devices(self) -> List[Dict]:
def list_devices(self) -> list[dict]:
"""List available STM32 USB CDC devices."""
if not USB_AVAILABLE:
logger.warning("pyusb not available — cannot enumerate STM32 devices")
@@ -174,20 +95,20 @@ class STM32USBInterface:
"product_id": pid,
"device": dev,
})
except Exception:
except (usb.core.USBError, ValueError):
devices.append({
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
"vendor_id": vid,
"product_id": pid,
"device": dev,
})
except Exception as e:
except (usb.core.USBError, ValueError) as e:
logger.error(f"Error listing STM32 devices: {e}")
return devices
# ---- open / close ------------------------------------------------------
def open_device(self, device_info: Dict) -> bool:
def open_device(self, device_info: dict) -> bool:
"""Open STM32 USB CDC device."""
if not USB_AVAILABLE:
logger.error("pyusb not available — cannot open STM32 device")
@@ -225,7 +146,7 @@ class STM32USBInterface:
self.is_open = True
logger.info(f"STM32 USB device opened: {device_info.get('description', '')}")
return True
except Exception as e:
except (usb.core.USBError, ValueError) as e:
logger.error(f"Error opening STM32 device: {e}")
return False
@@ -234,74 +155,22 @@ class STM32USBInterface:
if self.device and self.is_open:
try:
usb.util.dispose_resources(self.device)
except Exception as e:
except usb.core.USBError as e:
logger.error(f"Error closing STM32 device: {e}")
self.is_open = False
self.device = None
self.ep_in = None
self.ep_out = None
# ---- commands ----------------------------------------------------------
# ---- GPS data I/O ------------------------------------------------------
def send_start_flag(self) -> bool:
"""Send start flag to STM32 (4-byte magic)."""
start_packet = bytes([23, 46, 158, 237])
logger.info("Sending start flag to STM32 via USB...")
return self._send_data(start_packet)
def send_settings(self, settings: RadarSettings) -> bool:
"""Send radar settings binary packet to STM32."""
try:
packet = self._create_settings_packet(settings)
logger.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet)
except Exception as e:
logger.error(f"Error sending settings via USB: {e}")
return False
# ---- data I/O ----------------------------------------------------------
def read_data(self, size: int = 64, timeout: int = 1000) -> Optional[bytes]:
"""Read data from STM32 via USB CDC."""
def read_data(self, size: int = 64, timeout: int = 1000) -> bytes | None:
"""Read GPS data from STM32 via USB CDC."""
if not self.is_open or self.ep_in is None:
return None
try:
data = self.ep_in.read(size, timeout=timeout)
return bytes(data)
except Exception:
except usb.core.USBError:
# Timeout or other USB error
return None
# ---- internal helpers --------------------------------------------------
def _send_data(self, data: bytes) -> bool:
if not self.is_open or self.ep_out is None:
return False
try:
packet_size = 64
for i in range(0, len(data), packet_size):
chunk = data[i : i + packet_size]
if len(chunk) < packet_size:
chunk += b"\x00" * (packet_size - len(chunk))
self.ep_out.write(chunk)
return True
except Exception as e:
logger.error(f"Error sending data via USB: {e}")
return False
@staticmethod
def _create_settings_packet(settings: RadarSettings) -> bytes:
"""Create binary settings packet: 'SET' ... 'END'."""
packet = b"SET"
packet += struct.pack(">d", settings.system_frequency)
packet += struct.pack(">d", settings.chirp_duration_1)
packet += struct.pack(">d", settings.chirp_duration_2)
packet += struct.pack(">I", settings.chirps_per_position)
packet += struct.pack(">d", settings.freq_min)
packet += struct.pack(">d", settings.freq_max)
packet += struct.pack(">d", settings.prf1)
packet += struct.pack(">d", settings.prf2)
packet += struct.pack(">d", settings.max_distance)
packet += struct.pack(">d", settings.map_size)
packet += b"END"
return packet
+102 -95
View File
@@ -12,7 +12,6 @@ coverage circle, target trails, velocity-based color coding, popups, legend.
import json
import logging
from typing import List
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFrame,
@@ -65,7 +64,7 @@ class MapBridge(QObject):
@pyqtSlot(str)
def logFromJS(self, message: str):
logger.debug(f"[JS] {message}")
logger.info(f"[JS] {message}")
@property
def is_ready(self) -> bool:
@@ -96,7 +95,8 @@ class RadarMapWidget(QWidget):
latitude=radar_lat, longitude=radar_lon,
altitude=0.0, pitch=0.0, heading=0.0,
)
self._targets: List[RadarTarget] = []
self._targets: list[RadarTarget] = []
self._pending_targets: list[RadarTarget] | None = None
self._coverage_radius = 50_000 # metres
self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True
@@ -282,15 +282,10 @@ function initMap() {{
.setView([{lat}, {lon}], 10);
setTileServer('osm');
var radarIcon = L.divIcon({{
className:'radar-icon',
html:'<div style="background:radial-gradient(circle,#FF5252 0%,#D32F2F 100%);'+
'width:24px;height:24px;border-radius:50%;border:3px solid white;'+
'box-shadow:0 2px 8px rgba(0,0,0,0.5);"></div>',
iconSize:[24,24], iconAnchor:[12,12]
}});
radarMarker = L.marker([{lat},{lon}], {{ icon:radarIcon, zIndexOffset:1000 }}).addTo(map);
radarMarker = L.circleMarker([{lat},{lon}], {{
radius:12, fillColor:'#FF5252', color:'white',
weight:3, opacity:1, fillOpacity:1
}}).addTo(map);
updateRadarPopup();
coverageCircle = L.circle([{lat},{lon}], {{
@@ -366,102 +361,99 @@ function updateRadarPosition(lat,lon,alt,pitch,heading) {{
}}
function updateTargets(targetsJson) {{
var targets = JSON.parse(targetsJson);
var currentIds = {{}};
try {{
if(!map) {{
if(bridge) bridge.logFromJS('updateTargets: map not ready yet');
return;
}}
var targets = JSON.parse(targetsJson);
if(bridge) bridge.logFromJS('updateTargets: parsed '+targets.length+' targets');
var currentIds = {{}};
targets.forEach(function(t) {{
currentIds[t.id] = true;
var lat=t.latitude, lon=t.longitude;
var color = getTargetColor(t.velocity);
var sz = Math.max(10, Math.min(20, 10+t.snr/3));
targets.forEach(function(t) {{
currentIds[t.id] = true;
var lat=t.latitude, lon=t.longitude;
var color = getTargetColor(t.velocity);
var radius = Math.max(5, Math.min(12, 5+(t.snr||0)/5));
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
targetTrailHistory[t.id].push([lat,lon]);
if(targetTrailHistory[t.id].length > maxTrailLength)
targetTrailHistory[t.id].shift();
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
targetTrailHistory[t.id].push([lat,lon]);
if(targetTrailHistory[t.id].length > maxTrailLength)
targetTrailHistory[t.id].shift();
if(targetMarkers[t.id]) {{
targetMarkers[t.id].setLatLng([lat,lon]);
targetMarkers[t.id].setIcon(makeIcon(color,sz));
if(targetTrails[t.id]) {{
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
targetTrails[t.id].setStyle({{ color:color }});
}}
}} else {{
var marker = L.marker([lat,lon], {{ icon:makeIcon(color,sz) }}).addTo(map);
marker.on(
'click',
(function(id){{
return function(){{ if(bridge) bridge.onMarkerClick(id); }};
}})(t.id)
);
targetMarkers[t.id] = marker;
if(showTrails) {{
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
color:color, weight:3, opacity:0.7, lineCap:'round', lineJoin:'round'
if(targetMarkers[t.id]) {{
targetMarkers[t.id].setLatLng([lat,lon]);
targetMarkers[t.id].setStyle({{
fillColor:color, color:'white', radius:radius
}});
if(targetTrails[t.id]) {{
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
targetTrails[t.id].setStyle({{ color:color }});
}}
}} else {{
var marker = L.circleMarker([lat,lon], {{
radius:radius, fillColor:color, color:'white',
weight:2, opacity:1, fillOpacity:0.9
}}).addTo(map);
marker.on(
'click',
(function(id){{
return function(){{ if(bridge) bridge.onMarkerClick(id); }};
}})(t.id)
);
targetMarkers[t.id] = marker;
if(showTrails) {{
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
color:color, weight:3, opacity:0.7,
lineCap:'round', lineJoin:'round'
}}).addTo(map);
}}
}}
updateTargetPopup(t);
}});
for(var id in targetMarkers) {{
if(!currentIds[id]) {{
map.removeLayer(targetMarkers[id]); delete targetMarkers[id];
if(targetTrails[id]) {{
map.removeLayer(targetTrails[id]);
delete targetTrails[id];
}}
delete targetTrailHistory[id];
}}
}}
updateTargetPopup(t);
}});
for(var id in targetMarkers) {{
if(!currentIds[id]) {{
map.removeLayer(targetMarkers[id]); delete targetMarkers[id];
if(targetTrails[id]) {{ map.removeLayer(targetTrails[id]); delete targetTrails[id]; }}
delete targetTrailHistory[id];
}}
}} catch(e) {{
if(bridge) bridge.logFromJS('updateTargets ERROR: '+e.message);
}}
}}
function makeIcon(color,sz) {{
return L.divIcon({{
className:'target-icon',
html:'<div style="background-color:'+color+';width:'+sz+'px;height:'+sz+'px;'+
(
'border-radius:50%;border:2px solid white;'+
'box-shadow:0 2px 6px rgba(0,0,0,0.4);'
)+'</div>',
iconSize:[sz,sz], iconAnchor:[sz/2,sz/2]
}});
}}
function updateTargetPopup(t) {{
if(!targetMarkers[t.id]) return;
var sc = t.velocity>1
? 'status-approaching'
: (t.velocity<-1 ? 'status-receding' : 'status-stationary');
var st = t.velocity>1?'Approaching':(t.velocity<-1?'Receding':'Stationary');
var rng = (typeof t.range === 'number') ? t.range.toFixed(1) : '?';
var vel = (typeof t.velocity === 'number') ? t.velocity.toFixed(1) : '?';
var az = (typeof t.azimuth === 'number') ? t.azimuth.toFixed(1) : '?';
var el = (typeof t.elevation === 'number') ? t.elevation.toFixed(1) : '?';
var snr = (typeof t.snr === 'number') ? t.snr.toFixed(1) : '?';
targetMarkers[t.id].bindPopup(
'<div class="popup-title">Target #'+t.id+'</div>'+
(
'<div class="popup-row"><span class="popup-label">Range:</span>'+
'<span class="popup-value">'+t.range.toFixed(1)+' m</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
'<span class="popup-value">'+t.velocity.toFixed(1)+' m/s</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
'<span class="popup-value">'+t.azimuth.toFixed(1)+'&deg;</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
'<span class="popup-value">'+t.elevation.toFixed(1)+'&deg;</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
'<span class="popup-value">'+t.snr.toFixed(1)+' dB</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Track:</span>'+
'<span class="popup-value">'+t.track_id+'</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Status:</span>'+
'<span class="popup-value '+sc+'">'+st+'</span></div>'
)
'<div class="popup-row"><span class="popup-label">Range:</span>'+
'<span class="popup-value">'+rng+' m</span></div>'+
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
'<span class="popup-value">'+vel+' m/s</span></div>'+
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
'<span class="popup-value">'+az+'&deg;</span></div>'+
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
'<span class="popup-value">'+el+'&deg;</span></div>'+
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
'<span class="popup-value">'+snr+' dB</span></div>'+
'<div class="popup-row"><span class="popup-label">Track:</span>'+
'<span class="popup-value">'+t.track_id+'</span></div>'+
'<div class="popup-row"><span class="popup-label">Status:</span>'+
'<span class="popup-value '+sc+'">'+st+'</span></div>'
);
}}
@@ -531,12 +523,19 @@ document.addEventListener('DOMContentLoaded', function() {{
def _on_map_ready(self):
self._status_label.setText(f"Map ready - {len(self._targets)} targets")
self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
# Flush any targets that arrived before the map was ready
if self._pending_targets is not None:
self.set_targets(self._pending_targets)
self._pending_targets = None
def _on_marker_clicked(self, tid: int):
self.targetSelected.emit(tid)
def _run_js(self, script: str):
self._web_view.page().runJavaScript(script)
def _js_callback(result):
if result is not None:
logger.info("JS result: %s", result)
self._web_view.page().runJavaScript(script, 0, _js_callback)
# ---- control bar callbacks ---------------------------------------------
@@ -571,12 +570,20 @@ document.addEventListener('DOMContentLoaded', function() {{
f"{gps.altitude},{gps.pitch},{gps.heading})"
)
def set_targets(self, targets: List[RadarTarget]):
def set_targets(self, targets: list[RadarTarget]):
self._targets = targets
if not self._bridge.is_ready:
logger.info("Map not ready yet — queuing %d targets", len(targets))
self._pending_targets = targets
return
data = [t.to_dict() for t in targets]
js = json.dumps(data).replace("'", "\\'")
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
logger.info(
"set_targets: %d targets, JSON len=%d, first 200 chars: %s",
len(targets), len(js_payload), js_payload[:200],
)
self._status_label.setText(f"{len(targets)} targets tracked")
self._run_js(f"updateTargets('{js}')")
self._run_js(f"updateTargets('{js_payload}')")
def set_coverage_radius(self, radius_m: float):
self._coverage_radius = radius_m
+20 -19
View File
@@ -54,13 +54,6 @@ except ImportError:
FILTERPY_AVAILABLE = False
logging.warning("filterpy not available. Kalman tracking will be disabled.")
try:
import crcmod as _crcmod # noqa: F401 — availability check
CRCMOD_AVAILABLE = True
except ImportError:
CRCMOD_AVAILABLE = False
logging.warning("crcmod not available. CRC validation will use fallback.")
# ---------------------------------------------------------------------------
# Dark theme color constants (shared by all modules)
# ---------------------------------------------------------------------------
@@ -105,15 +98,19 @@ class RadarTarget:
@dataclass
class RadarSettings:
"""Radar system configuration parameters."""
system_frequency: float = 10e9 # Hz
chirp_duration_1: float = 30e-6 # Long chirp duration (s)
chirp_duration_2: float = 0.5e-6 # Short chirp duration (s)
chirps_per_position: int = 32
freq_min: float = 10e6 # Hz
freq_max: float = 30e6 # Hz
prf1: float = 1000 # PRF 1 (Hz)
prf2: float = 2000 # PRF 2 (Hz)
"""Radar system display/map configuration.
FPGA register parameters (chirp timing, CFAR, MTI, gain, etc.) are
controlled directly via 4-byte opcode commands see the FPGA Control
tab and Opcode enum in radar_protocol.py. This dataclass holds only
host-side display/map settings and physical-unit conversion factors.
range_resolution and velocity_resolution should be calibrated to
the actual waveform parameters.
"""
system_frequency: float = 10e9 # Hz (carrier, used for velocity calc)
range_resolution: float = 781.25 # Meters per range bin (default: 50km/64)
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform)
max_distance: float = 50000 # Max detection range (m)
map_size: float = 50000 # Map display size (m)
coverage_radius: float = 50000 # Map coverage radius (m)
@@ -139,10 +136,14 @@ class GPSData:
@dataclass
class ProcessingConfig:
"""Signal processing pipeline configuration.
"""Host-side signal processing pipeline configuration.
Controls: MTI filter, CFAR detector, DC notch removal,
windowing, detection threshold, DBSCAN clustering, and Kalman tracking.
These control host-side DSP that runs AFTER the FPGA processing
pipeline. FPGA-side MTI, CFAR, and DC notch are controlled via
register opcodes from the FPGA Control tab.
Controls: DBSCAN clustering, Kalman tracking, and optional
host-side reprocessing (MTI, CFAR, windowing, DC notch).
"""
# MTI (Moving Target Indication)
+21 -210
View File
@@ -1,30 +1,26 @@
"""
v7.processing Radar signal processing, packet parsing, and GPS parsing.
v7.processing Radar signal processing and GPS parsing.
Classes:
- RadarProcessor dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering,
association, Kalman tracking
- RadarPacketParser parse raw byte streams into typed radar packets
(FIX: returns (parsed_dict, bytes_consumed) tuple)
- USBPacketParser parse GPS text/binary frames from STM32 CDC
Bug fixes vs V6:
1. RadarPacketParser.parse_packet() now returns (dict, bytes_consumed) tuple
so the caller knows exactly how many bytes to strip from the buffer.
2. apply_pitch_correction() is a proper standalone function.
Note: RadarPacketParser (old A5/C3 sync + CRC16 format) was removed.
All packet parsing now uses production RadarProtocol (0xAA/0xBB format)
from radar_protocol.py.
"""
import struct
import time
import logging
import math
from typing import Optional, Tuple, List, Dict
import numpy as np
from .models import (
RadarTarget, GPSData, ProcessingConfig,
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
)
if SKLEARN_AVAILABLE:
@@ -33,9 +29,6 @@ if SKLEARN_AVAILABLE:
if FILTERPY_AVAILABLE:
from filterpy.kalman import KalmanFilter
if CRCMOD_AVAILABLE:
import crcmod
if SCIPY_AVAILABLE:
from scipy.signal import windows as scipy_windows
@@ -64,14 +57,14 @@ class RadarProcessor:
def __init__(self):
self.range_doppler_map = np.zeros((1024, 32))
self.detected_targets: List[RadarTarget] = []
self.detected_targets: list[RadarTarget] = []
self.track_id_counter: int = 0
self.tracks: Dict[int, dict] = {}
self.tracks: dict[int, dict] = {}
self.frame_count: int = 0
self.config = ProcessingConfig()
# MTI state: store previous frames for cancellation
self._mti_history: List[np.ndarray] = []
self._mti_history: list[np.ndarray] = []
# ---- Configuration -----------------------------------------------------
@@ -160,12 +153,11 @@ class RadarProcessor:
h = self._mti_history
if order == 1:
return h[-1] - h[-2]
elif order == 2:
if order == 2:
return h[-1] - 2.0 * h[-2] + h[-3]
elif order == 3:
if order == 3:
return h[-1] - 3.0 * h[-2] + 3.0 * h[-3] - h[-4]
else:
return h[-1] - h[-2]
return h[-1] - h[-2]
# ---- CFAR (Constant False Alarm Rate) -----------------------------------
@@ -234,7 +226,7 @@ class RadarProcessor:
# ---- Full processing pipeline -------------------------------------------
def process_frame(self, raw_frame: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
def process_frame(self, raw_frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Run the full signal processing chain on a Range x Doppler frame.
Parameters
@@ -289,34 +281,10 @@ class RadarProcessor:
"""Dual-CPI fusion for better detection."""
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
# ---- Multi-PRF velocity unwrapping -------------------------------------
def multi_prf_unwrap(self, doppler_measurements, prf1: float, prf2: float):
"""Multi-PRF velocity unwrapping (Chinese Remainder Theorem)."""
lam = 3e8 / 10e9
v_max1 = prf1 * lam / 2
v_max2 = prf2 * lam / 2
unwrapped = []
for doppler in doppler_measurements:
v1 = doppler * lam / 2
v2 = doppler * lam / 2
velocity = self._solve_chinese_remainder(v1, v2, v_max1, v_max2)
unwrapped.append(velocity)
return unwrapped
@staticmethod
def _solve_chinese_remainder(v1, v2, max1, max2):
for k in range(-5, 6):
candidate = v1 + k * max1
if abs(candidate - v2) < max2 / 2:
return candidate
return v1
# ---- DBSCAN clustering -------------------------------------------------
@staticmethod
def clustering(detections: List[RadarTarget],
def clustering(detections: list[RadarTarget],
eps: float = 100, min_samples: int = 2) -> list:
"""DBSCAN clustering of detections (requires sklearn)."""
if not SKLEARN_AVAILABLE or len(detections) == 0:
@@ -339,8 +307,8 @@ class RadarProcessor:
# ---- Association -------------------------------------------------------
def association(self, detections: List[RadarTarget],
clusters: list) -> List[RadarTarget]:
def association(self, detections: list[RadarTarget],
_clusters: list) -> list[RadarTarget]:
"""Associate detections to existing tracks (nearest-neighbour)."""
associated = []
for det in detections:
@@ -366,7 +334,7 @@ class RadarProcessor:
# ---- Kalman tracking ---------------------------------------------------
def tracking(self, associated_detections: List[RadarTarget]):
def tracking(self, associated_detections: list[RadarTarget]):
"""Kalman filter tracking (requires filterpy)."""
if not FILTERPY_AVAILABLE:
return
@@ -412,158 +380,6 @@ class RadarProcessor:
del self.tracks[tid]
# =============================================================================
# Radar Packet Parser
# =============================================================================
class RadarPacketParser:
"""
Parse binary radar packets from the raw byte stream.
Packet format:
[Sync 2][Type 1][Length 1][Payload N][CRC16 2]
Sync pattern: 0xA5 0xC3
Bug fix vs V6:
parse_packet() now returns ``(parsed_dict, bytes_consumed)`` so the
caller can correctly advance the read pointer in the buffer.
"""
SYNC = b"\xA5\xC3"
def __init__(self):
if CRCMOD_AVAILABLE:
self.crc16_func = crcmod.mkCrcFun(
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
)
else:
self.crc16_func = None
# ---- main entry point --------------------------------------------------
def parse_packet(self, data: bytes) -> Optional[Tuple[dict, int]]:
"""
Attempt to parse one radar packet from *data*.
Returns
-------
(parsed_dict, bytes_consumed) on success, or None if no valid packet.
"""
if len(data) < 6:
return None
idx = data.find(self.SYNC)
if idx == -1:
return None
pkt = data[idx:]
if len(pkt) < 6:
return None
pkt_type = pkt[2]
length = pkt[3]
total_len = 4 + length + 2 # sync(2) + type(1) + len(1) + payload + crc(2)
if len(pkt) < total_len:
return None
payload = pkt[4 : 4 + length]
crc_received = struct.unpack("<H", pkt[4 + length : 4 + length + 2])[0]
# CRC check
if self.crc16_func is not None:
crc_calc = self.crc16_func(pkt[0 : 4 + length])
if crc_calc != crc_received:
logger.warning(
f"CRC mismatch: got {crc_received:04X}, calc {crc_calc:04X}"
)
return None
# Bytes consumed = offset to sync + total packet length
consumed = idx + total_len
parsed = None
if pkt_type == 0x01:
parsed = self._parse_range(payload)
elif pkt_type == 0x02:
parsed = self._parse_doppler(payload)
elif pkt_type == 0x03:
parsed = self._parse_detection(payload)
else:
logger.warning(f"Unknown packet type: {pkt_type:02X}")
if parsed is None:
return None
return (parsed, consumed)
# ---- sub-parsers -------------------------------------------------------
@staticmethod
def _parse_range(payload: bytes) -> Optional[dict]:
if len(payload) < 12:
return None
try:
range_val = struct.unpack(">I", payload[0:4])[0]
elevation = payload[4] & 0x1F
azimuth = payload[5] & 0x3F
chirp = payload[6] & 0x1F
return {
"type": "range",
"range": range_val,
"elevation": elevation,
"azimuth": azimuth,
"chirp": chirp,
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"Error parsing range packet: {e}")
return None
@staticmethod
def _parse_doppler(payload: bytes) -> Optional[dict]:
if len(payload) < 12:
return None
try:
real = struct.unpack(">h", payload[0:2])[0]
imag = struct.unpack(">h", payload[2:4])[0]
elevation = payload[4] & 0x1F
azimuth = payload[5] & 0x3F
chirp = payload[6] & 0x1F
return {
"type": "doppler",
"doppler_real": real,
"doppler_imag": imag,
"elevation": elevation,
"azimuth": azimuth,
"chirp": chirp,
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"Error parsing doppler packet: {e}")
return None
@staticmethod
def _parse_detection(payload: bytes) -> Optional[dict]:
if len(payload) < 8:
return None
try:
detected = (payload[0] & 0x01) != 0
elevation = payload[1] & 0x1F
azimuth = payload[2] & 0x3F
chirp = payload[3] & 0x1F
return {
"type": "detection",
"detected": detected,
"elevation": elevation,
"azimuth": azimuth,
"chirp": chirp,
"timestamp": time.time(),
}
except Exception as e:
logger.error(f"Error parsing detection packet: {e}")
return None
# =============================================================================
# USB / GPS Packet Parser
# =============================================================================
@@ -578,14 +394,9 @@ class USBPacketParser:
"""
def __init__(self):
if CRCMOD_AVAILABLE:
self.crc16_func = crcmod.mkCrcFun(
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
)
else:
self.crc16_func = None
pass
def parse_gps_data(self, data: bytes) -> Optional[GPSData]:
def parse_gps_data(self, data: bytes) -> GPSData | None:
"""Attempt to parse GPS data from a raw USB CDC frame."""
if not data:
return None
@@ -607,12 +418,12 @@ class USBPacketParser:
# Binary format: [GPSB 4][lat 8][lon 8][alt 4][pitch 4][CRC 2] = 30 bytes
if len(data) >= 30 and data[0:4] == b"GPSB":
return self._parse_binary_gps(data)
except Exception as e:
except (ValueError, struct.error) as e:
logger.error(f"Error parsing GPS data: {e}")
return None
@staticmethod
def _parse_binary_gps(data: bytes) -> Optional[GPSData]:
def _parse_binary_gps(data: bytes) -> GPSData | None:
"""Parse 30-byte binary GPS frame."""
try:
if len(data) < 30:
@@ -637,6 +448,6 @@ class USBPacketParser:
pitch=pitch,
timestamp=time.time(),
)
except Exception as e:
except (ValueError, struct.error) as e:
logger.error(f"Error parsing binary GPS: {e}")
return None
+168 -119
View File
@@ -2,24 +2,39 @@
v7.workers QThread-based workers and demo target simulator.
Classes:
- RadarDataWorker reads from FT2232HQ, parses packets,
emits signals with processed data.
- RadarDataWorker reads from FT2232H via production RadarAcquisition,
parses 0xAA/0xBB packets, assembles 64x32 frames,
runs host-side DSP, emits PyQt signals.
- GPSDataWorker reads GPS frames from STM32 CDC, emits GPSData signals.
- TargetSimulator QTimer-based demo target generator (from GUI_PyQt_Map.py).
- TargetSimulator QTimer-based demo target generator.
The old V6/V7 packet parsing (sync A5 C3 + type + CRC16) has been removed.
All packet parsing now uses the production radar_protocol.py which matches
the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte).
"""
import math
import time
import random
import queue
import struct
import logging
from typing import List
import numpy as np
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
from .models import RadarTarget, RadarSettings, GPSData
from .hardware import FT2232HQInterface, STM32USBInterface
from .models import RadarTarget, GPSData, RadarSettings
from .hardware import (
RadarAcquisition,
RadarFrame,
StatusResponse,
DataRecorder,
STM32USBInterface,
)
from .processing import (
RadarProcessor, RadarPacketParser, USBPacketParser,
RadarProcessor,
USBPacketParser,
apply_pitch_correction,
)
@@ -61,162 +76,196 @@ def polar_to_geographic(
# =============================================================================
# Radar Data Worker (QThread)
# Radar Data Worker (QThread) — production protocol
# =============================================================================
class RadarDataWorker(QThread):
"""
Background worker that continuously reads radar data from the primary
FT2232HQ interface, parses packets, runs the processing pipeline, and
emits signals with results.
Background worker that reads radar data from FT2232H (or ReplayConnection),
parses 0xAA/0xBB packets via production RadarAcquisition, runs optional
host-side DSP, and emits PyQt signals with results.
This replaces the old V7 worker which used an incompatible packet format.
Now uses production radar_protocol.py for all packet parsing and frame
assembly (11-byte 0xAA data packets 64x32 RadarFrame).
Signals:
packetReceived(dict) a single parsed packet dict
targetsUpdated(list) list of RadarTarget after processing
errorOccurred(str) error message
statsUpdated(dict) packet/byte counters
frameReady(RadarFrame) a complete 64x32 radar frame
statusReceived(object) StatusResponse from FPGA
targetsUpdated(list) list of RadarTarget after host-side DSP
errorOccurred(str) error message
statsUpdated(dict) frame/byte counters
"""
packetReceived = pyqtSignal(dict)
targetsUpdated = pyqtSignal(list)
frameReady = pyqtSignal(object) # RadarFrame
statusReceived = pyqtSignal(object) # StatusResponse
targetsUpdated = pyqtSignal(list) # List[RadarTarget]
errorOccurred = pyqtSignal(str)
statsUpdated = pyqtSignal(dict)
def __init__(
self,
ft2232hq: FT2232HQInterface,
processor: RadarProcessor,
packet_parser: RadarPacketParser,
settings: RadarSettings,
gps_data_ref: GPSData,
connection, # FT2232HConnection or ReplayConnection
processor: RadarProcessor | None = None,
recorder: DataRecorder | None = None,
gps_data_ref: GPSData | None = None,
settings: RadarSettings | None = None,
parent=None,
):
super().__init__(parent)
self._ft2232hq = ft2232hq
self._connection = connection
self._processor = processor
self._parser = packet_parser
self._settings = settings
self._recorder = recorder
self._gps = gps_data_ref
self._settings = settings or RadarSettings()
self._running = False
# Frame queue for production RadarAcquisition → this thread
self._frame_queue: queue.Queue = queue.Queue(maxsize=4)
# Production acquisition thread (does the actual parsing)
self._acquisition: RadarAcquisition | None = None
# Counters
self._packet_count = 0
self._frame_count = 0
self._byte_count = 0
self._error_count = 0
def stop(self):
self._running = False
if self._acquisition:
self._acquisition.stop()
def run(self):
"""Main loop: read → parse → process → emit."""
"""
Start production RadarAcquisition thread, then poll its frame queue
and emit PyQt signals for each complete frame.
"""
self._running = True
buffer = bytearray()
# Create and start the production acquisition thread
self._acquisition = RadarAcquisition(
connection=self._connection,
frame_queue=self._frame_queue,
recorder=self._recorder,
status_callback=self._on_status,
)
self._acquisition.start()
logger.info("RadarDataWorker started (production protocol)")
while self._running:
# Use FT2232HQ interface
iface = None
if self._ft2232hq and self._ft2232hq.is_open:
iface = self._ft2232hq
if iface is None:
self.msleep(100)
continue
try:
data = iface.read_data(4096)
if data:
buffer.extend(data)
self._byte_count += len(data)
# Poll for complete frames from production acquisition
frame: RadarFrame = self._frame_queue.get(timeout=0.1)
self._frame_count += 1
# Parse as many packets as possible
while len(buffer) >= 6:
result = self._parser.parse_packet(bytes(buffer))
if result is None:
# No valid packet at current position — skip one byte
if len(buffer) > 1:
buffer = buffer[1:]
else:
break
continue
# Emit raw frame
self.frameReady.emit(frame)
pkt, consumed = result
buffer = buffer[consumed:]
self._packet_count += 1
# Run host-side DSP if processor is configured
if self._processor is not None:
targets = self._run_host_dsp(frame)
if targets:
self.targetsUpdated.emit(targets)
# Process the packet
self._process_packet(pkt)
self.packetReceived.emit(pkt)
# Emit stats
self.statsUpdated.emit({
"frames": self._frame_count,
"detection_count": frame.detection_count,
"errors": self._error_count,
})
# Emit stats periodically
self.statsUpdated.emit({
"packets": self._packet_count,
"bytes": self._byte_count,
"errors": self._error_count,
"active_tracks": len(self._processor.tracks),
"targets": len(self._processor.detected_targets),
})
else:
self.msleep(10)
except Exception as e:
except queue.Empty:
continue
except (ValueError, IndexError) as e:
self._error_count += 1
self.errorOccurred.emit(str(e))
logger.error(f"RadarDataWorker error: {e}")
self.msleep(100)
# ---- internal packet handling ------------------------------------------
# Stop acquisition thread
if self._acquisition:
self._acquisition.stop()
self._acquisition.join(timeout=2.0)
self._acquisition = None
def _process_packet(self, pkt: dict):
"""Route a parsed packet through the processing pipeline."""
try:
if pkt["type"] == "range":
range_m = pkt["range"] * 0.1
raw_elev = pkt["elevation"]
logger.info("RadarDataWorker stopped")
def _on_status(self, status: StatusResponse):
"""Callback from production RadarAcquisition on status packet."""
self.statusReceived.emit(status)
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.
Bin-to-physical conversion uses RadarSettings.range_resolution
and velocity_resolution (should be calibrated to actual waveform).
"""
targets: list[RadarTarget] = []
cfg = self._processor.config
if not (cfg.clustering_enabled or cfg.tracking_enabled):
return targets
# Extract detections from FPGA CFAR flags
det_indices = np.argwhere(frame.detections > 0)
r_res = self._settings.range_resolution
v_res = self._settings.velocity_resolution
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
# 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
# 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)
target = RadarTarget(
id=pkt["chirp"],
range=range_m,
velocity=0,
azimuth=pkt["azimuth"],
elevation=corr_elev,
snr=20.0,
timestamp=pkt["timestamp"],
# 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,
)
self._update_rdm(target)
elif pkt["type"] == "doppler":
lam = 3e8 / self._settings.system_frequency
velocity = (pkt["doppler_real"] / 32767.0) * (
self._settings.prf1 * lam / 2
)
self._update_velocity(pkt, velocity)
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)
elif pkt["type"] == "detection":
if pkt["detected"]:
raw_elev = pkt["elevation"]
corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
logger.info(
f"CFAR Detection: raw={raw_elev}, corr={corr_elev:.1f}, "
f"pitch={self._gps.pitch:.1f}"
)
except Exception as e:
logger.error(f"Error processing packet: {e}")
# DBSCAN clustering
if cfg.clustering_enabled and len(targets) > 0:
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)
def _update_rdm(self, target: RadarTarget):
range_bin = min(int(target.range / 50), 1023)
doppler_bin = min(abs(int(target.velocity)), 31)
self._processor.range_doppler_map[range_bin, doppler_bin] += 1
self._processor.detected_targets.append(target)
if len(self._processor.detected_targets) > 100:
self._processor.detected_targets = self._processor.detected_targets[-100:]
def _update_velocity(self, pkt: dict, velocity: float):
for t in self._processor.detected_targets:
if (t.azimuth == pkt["azimuth"]
and t.elevation == pkt["elevation"]
and t.id == pkt["chirp"]):
t.velocity = velocity
break
return targets
# =============================================================================
@@ -269,7 +318,7 @@ class GPSDataWorker(QThread):
if gps:
self._gps_count += 1
self.gpsReceived.emit(gps)
except Exception as e:
except (ValueError, struct.error) as e:
self.errorOccurred.emit(str(e))
logger.error(f"GPSDataWorker error: {e}")
self.msleep(100)
@@ -292,7 +341,7 @@ class TargetSimulator(QObject):
def __init__(self, radar_position: GPSData, parent=None):
super().__init__(parent)
self._radar_pos = radar_position
self._targets: List[RadarTarget] = []
self._targets: list[RadarTarget] = []
self._next_id = 1
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
@@ -349,7 +398,7 @@ class TargetSimulator(QObject):
def _tick(self):
"""Update all simulated targets and emit."""
updated: List[RadarTarget] = []
updated: list[RadarTarget] = []
for t in self._targets:
new_range = t.range - t.velocity * 0.5
+52 -59
View File
@@ -26,6 +26,7 @@ Usage:
"""
import argparse
from contextlib import nullcontext
import datetime
import glob
import os
@@ -38,7 +39,6 @@ try:
import serial
import serial.tools.list_ports
except ImportError:
print("ERROR: pyserial not installed. Run: pip install pyserial")
sys.exit(1)
# ---------------------------------------------------------------------------
@@ -94,12 +94,9 @@ def list_ports():
"""Print available serial ports."""
ports = serial.tools.list_ports.comports()
if not ports:
print("No serial ports found.")
return
print(f"{'Port':<30} {'Description':<40} {'HWID'}")
print("-" * 100)
for p in sorted(ports, key=lambda x: x.device):
print(f"{p.device:<30} {p.description:<40} {p.hwid}")
for _p in sorted(ports, key=lambda x: x.device):
pass
def auto_detect_port():
@@ -172,10 +169,7 @@ def should_display(line, filter_subsys=None, errors_only=False):
return False
# Subsystem filter
if filter_subsys and subsys not in filter_subsys:
return False
return True
return not (filter_subsys and subsys not in filter_subsys)
# ---------------------------------------------------------------------------
@@ -219,8 +213,10 @@ class CaptureStats:
]
if self.by_subsys:
lines.append("By subsystem:")
for tag in sorted(self.by_subsys, key=self.by_subsys.get, reverse=True):
lines.append(f" {tag:<8} {self.by_subsys[tag]}")
lines.extend(
f" {tag:<8} {self.by_subsys[tag]}"
for tag in sorted(self.by_subsys, key=self.by_subsys.get, reverse=True)
)
return "\n".join(lines)
@@ -228,12 +224,12 @@ class CaptureStats:
# Main capture loop
# ---------------------------------------------------------------------------
def capture(port, baud, log_file, filter_subsys, errors_only, use_color):
def capture(port, baud, log_file, filter_subsys, errors_only, _use_color):
"""Open serial port and capture DIAG output."""
stats = CaptureStats()
running = True
def handle_signal(sig, frame):
def handle_signal(_sig, _frame):
nonlocal running
running = False
@@ -249,69 +245,68 @@ def capture(port, baud, log_file, filter_subsys, errors_only, use_color):
stopbits=serial.STOPBITS_ONE,
timeout=0.1, # 100ms read timeout for responsive Ctrl-C
)
except serial.SerialException as e:
print(f"ERROR: Could not open {port}: {e}")
except serial.SerialException:
sys.exit(1)
print(f"Connected to {port} at {baud} baud")
if log_file:
print(f"Logging to {log_file}")
pass
if filter_subsys:
print(f"Filter: {', '.join(sorted(filter_subsys))}")
pass
if errors_only:
print("Mode: errors/warnings only")
print("Press Ctrl-C to stop.\n")
pass
flog = None
if log_file:
os.makedirs(os.path.dirname(log_file), exist_ok=True)
flog = open(log_file, "w", encoding=ENCODING)
flog.write(f"# AERIS-10 UART capture — {datetime.datetime.now().isoformat()}\n")
flog.write(f"# Port: {port} Baud: {baud}\n")
flog.write(f"# Host: {os.uname().nodename}\n\n")
flog.flush()
log_context = open(log_file, "w", encoding=ENCODING) # noqa: SIM115
else:
log_context = nullcontext(None)
line_buf = b""
try:
while running:
try:
chunk = ser.read(256)
except serial.SerialException as e:
print(f"\nSerial error: {e}")
break
with log_context as flog:
if flog:
flog.write(f"# AERIS-10 UART capture — {datetime.datetime.now().isoformat()}\n")
flog.write(f"# Port: {port} Baud: {baud}\n")
flog.write(f"# Host: {os.uname().nodename}\n\n")
flog.flush()
if not chunk:
continue
while running:
try:
chunk = ser.read(256)
except serial.SerialException:
break
line_buf += chunk
# Process complete lines
while b"\n" in line_buf:
raw_line, line_buf = line_buf.split(b"\n", 1)
line = raw_line.decode(ENCODING, errors="replace").rstrip("\r")
if not line:
if not chunk:
continue
stats.update(line)
line_buf += chunk
# Log file always gets everything (unfiltered, no color)
if flog:
wall_ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
flog.write(f"{wall_ts} {line}\n")
flog.flush()
# Process complete lines
while b"\n" in line_buf:
raw_line, line_buf = line_buf.split(b"\n", 1)
line = raw_line.decode(ENCODING, errors="replace").rstrip("\r")
# Terminal display respects filters
if should_display(line, filter_subsys, errors_only):
print(colorize(line, use_color))
if not line:
continue
stats.update(line)
# Log file always gets everything (unfiltered, no color)
if flog:
wall_ts = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3]
flog.write(f"{wall_ts} {line}\n")
flog.flush()
# Terminal display respects filters
if should_display(line, filter_subsys, errors_only):
pass
if flog:
flog.write(f"\n{stats.summary()}\n")
finally:
ser.close()
if flog:
flog.write(f"\n{stats.summary()}\n")
flog.close()
print(stats.summary())
# ---------------------------------------------------------------------------
@@ -374,9 +369,7 @@ def main():
if not port:
port = auto_detect_port()
if not port:
print("ERROR: No serial port detected. Use -p to specify, or --list to see ports.")
sys.exit(1)
print(f"Auto-detected port: {port}")
# Resolve log file
log_file = None
@@ -390,7 +383,7 @@ def main():
# Parse filter
filter_subsys = None
if args.filter:
filter_subsys = set(t.strip().upper() for t in args.filter.split(","))
filter_subsys = {t.strip().upper() for t in args.filter.split(",")}
# Color detection
use_color = not args.no_color and sys.stdout.isatty()