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
+438
View File
@@ -0,0 +1,438 @@
#!/usr/bin/env python3
"""
AERIS-10 FMC Anti-Alias Filter — openEMS 3D EM Simulation
==========================================================
5th-order differential Butterworth LC LPF, fc ≈ 195 MHz
All components are 0402 (1.0 x 0.5 mm) on FR4 4-layer stackup.
Filter topology (each half of differential):
IN → R_series(49.9Ω) → L1(24nH) → C1(27pF)↓GND → L2(82nH) → C2(27pF)↓GND → L3(24nH) → OUT
Plus R_diff(100Ω) across input and output differential pairs.
PCB stackup:
L1: F.Cu (signal + components) — 35µm copper
Prepreg: 0.2104 mm
L2: In1.Cu (GND plane) — 35µm copper
Core: 1.0 mm
L3: In2.Cu (Power plane) — 35µm copper
Prepreg: 0.2104 mm
L4: B.Cu (signal) — 35µm copper
Total board thickness ≈ 1.6 mm
Differential trace: W=0.23mm, S=0.12mm gap → Zdiff≈100Ω
All 0402 pads: 0.5mm x 0.55mm with 0.5mm gap between pads
Simulation extracts 4-port S-parameters (differential in → differential out)
then converts to mixed-mode (Sdd11, Sdd21, Scc21) for analysis.
"""
import os
import sys
import numpy as np
sys.path.insert(0, '/Users/ganeshpanth/openEMS-Project/CSXCAD/python')
sys.path.insert(0, '/Users/ganeshpanth/openEMS-Project/openEMS/python')
os.environ['PATH'] = '/Users/ganeshpanth/opt/openEMS/bin:' + os.environ.get('PATH', '')
from CSXCAD import ContinuousStructure
from openEMS import openEMS
from openEMS.physical_constants import C0, EPS0
unit = 1e-3
f_start = 1e6
f_stop = 1e9
f_center = 150e6
f_IF_low = 120e6
f_IF_high = 180e6
max_res = C0 / f_stop / unit / 20
copper_t = 0.035
prepreg_t = 0.2104
core_t = 1.0
sub_er = 4.3
sub_tand = 0.02
cu_cond = 5.8e7
z_L4_bot = 0.0
z_L4_top = z_L4_bot + copper_t
z_pre2_top = z_L4_top + prepreg_t
z_L3_top = z_pre2_top + copper_t
z_core_top = z_L3_top + core_t
z_L2_top = z_core_top + copper_t
z_pre1_top = z_L2_top + prepreg_t
z_L1_bot = z_pre1_top
z_L1_top = z_L1_bot + copper_t
pad_w = 0.50
pad_l = 0.55
pad_gap = 0.50
comp_pitch = 1.5
trace_w = 0.23
trace_s = 0.12
pair_pitch = trace_w + trace_s
R_series = 49.9
R_diff_in = 100.0
R_diff_out = 100.0
L1_val = 24e-9
L2_val = 82e-9
L3_val = 24e-9
C1_val = 27e-12
C2_val = 27e-12
FDTD = openEMS(NrTS=50000, EndCriteria=1e-5)
FDTD.SetGaussExcite(0.5 * (f_start + f_stop), 0.5 * (f_stop - f_start))
FDTD.SetBoundaryCond(['PML_8'] * 6)
CSX = ContinuousStructure()
FDTD.SetCSX(CSX)
copper = CSX.AddMetal('copper')
gnd_metal = CSX.AddMetal('gnd_plane')
fr4_pre1 = CSX.AddMaterial(
'prepreg1', epsilon=sub_er, kappa=sub_tand * 2 * np.pi * f_center * EPS0 * sub_er
)
fr4_core = CSX.AddMaterial(
'core', epsilon=sub_er, kappa=sub_tand * 2 * np.pi * f_center * EPS0 * sub_er
)
fr4_pre2 = CSX.AddMaterial(
'prepreg2', epsilon=sub_er, kappa=sub_tand * 2 * np.pi * f_center * EPS0 * sub_er
)
y_P = +pair_pitch / 2
y_N = -pair_pitch / 2
x_port_in = -1.0
x_R_series = 0.0
x_L1 = x_R_series + comp_pitch
x_C1 = x_L1 + comp_pitch
x_L2 = x_C1 + comp_pitch
x_C2 = x_L2 + comp_pitch
x_L3 = x_C2 + comp_pitch
x_port_out = x_L3 + comp_pitch + 1.0
x_Rdiff_in = x_port_in - 0.5
x_Rdiff_out = x_port_out + 0.5
margin = 3.0
x_min = x_Rdiff_in - margin
x_max = x_Rdiff_out + margin
y_min = y_N - margin
y_max = y_P + margin
z_min = z_L4_bot - margin
z_max = z_L1_top + margin
fr4_pre1.AddBox([x_min, y_min, z_L2_top], [x_max, y_max, z_L1_bot], priority=1)
fr4_core.AddBox([x_min, y_min, z_L3_top], [x_max, y_max, z_core_top], priority=1)
fr4_pre2.AddBox([x_min, y_min, z_L4_top], [x_max, y_max, z_pre2_top], priority=1)
gnd_metal.AddBox(
[x_min + 0.5, y_min + 0.5, z_core_top], [x_max - 0.5, y_max - 0.5, z_L2_top], priority=10
)
def add_trace_segment(x_start, x_end, y_center, z_bot, z_top, w, metal, priority=20):
metal.AddBox(
[x_start, y_center - w / 2, z_bot], [x_end, y_center + w / 2, z_top], priority=priority
)
def add_0402_pads(x_center, y_center, z_bot, z_top, metal, priority=20):
x_left = x_center - pad_gap / 2 - pad_w / 2
metal.AddBox(
[x_left - pad_w / 2, y_center - pad_l / 2, z_bot],
[x_left + pad_w / 2, y_center + pad_l / 2, z_top],
priority=priority,
)
x_right = x_center + pad_gap / 2 + pad_w / 2
metal.AddBox(
[x_right - pad_w / 2, y_center - pad_l / 2, z_bot],
[x_right + pad_w / 2, y_center + pad_l / 2, z_top],
priority=priority,
)
return (x_left, x_right)
def add_lumped_element(
CSX, name, element_type, value, x_center, y_center, z_bot, z_top, direction='x'
):
x_left = x_center - pad_gap / 2 - pad_w / 2
x_right = x_center + pad_gap / 2 + pad_w / 2
if direction == 'x':
start = [x_left, y_center - pad_l / 4, z_bot]
stop = [x_right, y_center + pad_l / 4, z_top]
edir = 'x'
elif direction == 'y':
start = [x_center - pad_l / 4, y_center - pad_gap / 2 - pad_w / 2, z_bot]
stop = [x_center + pad_l / 4, y_center + pad_gap / 2 + pad_w / 2, z_top]
edir = 'y'
if element_type == 'R':
elem = CSX.AddLumpedElement(name, ny=edir, caps=True, R=value)
elif element_type == 'L':
elem = CSX.AddLumpedElement(name, ny=edir, caps=True, L=value)
elif element_type == 'C':
elem = CSX.AddLumpedElement(name, ny=edir, caps=True, C=value)
elem.AddBox(start, stop, priority=30)
return elem
def add_shunt_cap(
CSX, name, value, x_center, y_trace, _z_top_signal, _z_gnd_top, metal, priority=20,
):
metal.AddBox(
[x_center - pad_w / 2, y_trace - pad_l / 2, z_L1_bot],
[x_center + pad_w / 2, y_trace + pad_l / 2, z_L1_top],
priority=priority,
)
via_drill = 0.15
cap = CSX.AddLumpedElement(name, ny='z', caps=True, C=value)
cap.AddBox(
[x_center - via_drill, y_trace - via_drill, z_L2_top],
[x_center + via_drill, y_trace + via_drill, z_L1_bot],
priority=30,
)
via_metal = CSX.AddMetal(name + '_via')
via_metal.AddBox(
[x_center - via_drill, y_trace - via_drill, z_L2_top],
[x_center + via_drill, y_trace + via_drill, z_L1_bot],
priority=25,
)
add_trace_segment(
x_port_in, x_R_series - pad_gap / 2 - pad_w, y_P, z_L1_bot, z_L1_top, trace_w, copper
)
add_0402_pads(x_R_series, y_P, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'R10', 'R', R_series, x_R_series, y_P, z_L1_bot, z_L1_top)
add_trace_segment(
x_R_series + pad_gap / 2 + pad_w,
x_L1 - pad_gap / 2 - pad_w,
y_P,
z_L1_bot,
z_L1_top,
trace_w,
copper,
)
add_0402_pads(x_L1, y_P, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'L5', 'L', L1_val, x_L1, y_P, z_L1_bot, z_L1_top)
add_trace_segment(x_L1 + pad_gap / 2 + pad_w, x_C1, y_P, z_L1_bot, z_L1_top, trace_w, copper)
add_shunt_cap(CSX, 'C53', C1_val, x_C1, y_P, z_L1_top, z_L2_top, copper)
add_trace_segment(x_C1, x_L2 - pad_gap / 2 - pad_w, y_P, z_L1_bot, z_L1_top, trace_w, copper)
add_0402_pads(x_L2, y_P, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'L8', 'L', L2_val, x_L2, y_P, z_L1_bot, z_L1_top)
add_trace_segment(x_L2 + pad_gap / 2 + pad_w, x_C2, y_P, z_L1_bot, z_L1_top, trace_w, copper)
add_shunt_cap(CSX, 'C55', C2_val, x_C2, y_P, z_L1_top, z_L2_top, copper)
add_trace_segment(x_C2, x_L3 - pad_gap / 2 - pad_w, y_P, z_L1_bot, z_L1_top, trace_w, copper)
add_0402_pads(x_L3, y_P, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'L10', 'L', L3_val, x_L3, y_P, z_L1_bot, z_L1_top)
add_trace_segment(x_L3 + pad_gap / 2 + pad_w, x_port_out, y_P, z_L1_bot, z_L1_top, trace_w, copper)
add_trace_segment(
x_port_in, x_R_series - pad_gap / 2 - pad_w, y_N, z_L1_bot, z_L1_top, trace_w, copper
)
add_0402_pads(x_R_series, y_N, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'R11', 'R', R_series, x_R_series, y_N, z_L1_bot, z_L1_top)
add_trace_segment(
x_R_series + pad_gap / 2 + pad_w,
x_L1 - pad_gap / 2 - pad_w,
y_N,
z_L1_bot,
z_L1_top,
trace_w,
copper,
)
add_0402_pads(x_L1, y_N, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'L6', 'L', L1_val, x_L1, y_N, z_L1_bot, z_L1_top)
add_trace_segment(x_L1 + pad_gap / 2 + pad_w, x_C1, y_N, z_L1_bot, z_L1_top, trace_w, copper)
add_shunt_cap(CSX, 'C54', C1_val, x_C1, y_N, z_L1_top, z_L2_top, copper)
add_trace_segment(x_C1, x_L2 - pad_gap / 2 - pad_w, y_N, z_L1_bot, z_L1_top, trace_w, copper)
add_0402_pads(x_L2, y_N, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'L7', 'L', L2_val, x_L2, y_N, z_L1_bot, z_L1_top)
add_trace_segment(x_L2 + pad_gap / 2 + pad_w, x_C2, y_N, z_L1_bot, z_L1_top, trace_w, copper)
add_shunt_cap(CSX, 'C56', C2_val, x_C2, y_N, z_L1_top, z_L2_top, copper)
add_trace_segment(x_C2, x_L3 - pad_gap / 2 - pad_w, y_N, z_L1_bot, z_L1_top, trace_w, copper)
add_0402_pads(x_L3, y_N, z_L1_bot, z_L1_top, copper)
add_lumped_element(CSX, 'L9', 'L', L3_val, x_L3, y_N, z_L1_bot, z_L1_top)
add_trace_segment(x_L3 + pad_gap / 2 + pad_w, x_port_out, y_N, z_L1_bot, z_L1_top, trace_w, copper)
R4_x = x_port_in - 0.3
copper.AddBox(
[R4_x - pad_l / 2, y_P - pad_w / 2, z_L1_bot],
[R4_x + pad_l / 2, y_P + pad_w / 2, z_L1_top],
priority=20,
)
copper.AddBox(
[R4_x - pad_l / 2, y_N - pad_w / 2, z_L1_bot],
[R4_x + pad_l / 2, y_N + pad_w / 2, z_L1_top],
priority=20,
)
R4_elem = CSX.AddLumpedElement('R4', ny='y', caps=True, R=R_diff_in)
R4_elem.AddBox([R4_x - pad_l / 4, y_N, z_L1_bot], [R4_x + pad_l / 4, y_P, z_L1_top], priority=30)
R18_x = x_port_out + 0.3
copper.AddBox(
[R18_x - pad_l / 2, y_P - pad_w / 2, z_L1_bot],
[R18_x + pad_l / 2, y_P + pad_w / 2, z_L1_top],
priority=20,
)
copper.AddBox(
[R18_x - pad_l / 2, y_N - pad_w / 2, z_L1_bot],
[R18_x + pad_l / 2, y_N + pad_w / 2, z_L1_top],
priority=20,
)
R18_elem = CSX.AddLumpedElement('R18', ny='y', caps=True, R=R_diff_out)
R18_elem.AddBox([R18_x - pad_l / 4, y_N, z_L1_bot], [R18_x + pad_l / 4, y_P, z_L1_top], priority=30)
port1 = FDTD.AddLumpedPort(
1,
50,
[x_port_in, y_P - trace_w / 2, z_L2_top],
[x_port_in, y_P + trace_w / 2, z_L1_bot],
'z',
excite=1.0,
)
port2 = FDTD.AddLumpedPort(
2,
50,
[x_port_in, y_N - trace_w / 2, z_L2_top],
[x_port_in, y_N + trace_w / 2, z_L1_bot],
'z',
excite=-1.0,
)
port3 = FDTD.AddLumpedPort(
3,
50,
[x_port_out, y_P - trace_w / 2, z_L2_top],
[x_port_out, y_P + trace_w / 2, z_L1_bot],
'z',
excite=0,
)
port4 = FDTD.AddLumpedPort(
4,
50,
[x_port_out, y_N - trace_w / 2, z_L2_top],
[x_port_out, y_N + trace_w / 2, z_L1_bot],
'z',
excite=0,
)
mesh = CSX.GetGrid()
mesh.SetDeltaUnit(unit)
mesh.AddLine('x', [x_min, x_max])
for x_comp in [x_R_series, x_L1, x_C1, x_L2, x_C2, x_L3]:
mesh.AddLine('x', np.linspace(x_comp - 1.0, x_comp + 1.0, 15))
mesh.AddLine('x', [x_port_in, x_port_out])
mesh.AddLine('x', [R4_x, R18_x])
mesh.AddLine('y', [y_min, y_max])
for y_trace in [y_P, y_N]:
mesh.AddLine('y', np.linspace(y_trace - 0.5, y_trace + 0.5, 10))
mesh.AddLine('z', [z_min, z_max])
mesh.AddLine('z', np.linspace(z_L4_bot - 0.1, z_L1_top + 0.1, 25))
mesh.SmoothMeshLines('x', max_res, ratio=1.4)
mesh.SmoothMeshLines('y', max_res, ratio=1.4)
mesh.SmoothMeshLines('z', max_res / 3, ratio=1.3)
sim_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
if not os.path.exists(sim_path):
os.makedirs(sim_path)
CSX_file = os.path.join(sim_path, 'aaf_filter.xml')
CSX.Write2XML(CSX_file)
FDTD.Run(sim_path, cleanup=True, verbose=3)
freq = np.linspace(f_start, f_stop, 1001)
port1.CalcPort(sim_path, freq)
port2.CalcPort(sim_path, freq)
port3.CalcPort(sim_path, freq)
port4.CalcPort(sim_path, freq)
inc1 = port1.uf_inc
ref1 = port1.uf_ref
inc2 = port2.uf_inc
ref2 = port2.uf_ref
inc3 = port3.uf_inc
ref3 = port3.uf_ref
inc4 = port4.uf_inc
ref4 = port4.uf_ref
a_diff = (inc1 - inc2) / np.sqrt(2)
b_diff_in = (ref1 - ref2) / np.sqrt(2)
b_diff_out = (ref3 - ref4) / np.sqrt(2)
Sdd11 = b_diff_in / a_diff
Sdd21 = b_diff_out / a_diff
b_comm_out = (ref3 + ref4) / np.sqrt(2)
Scd21 = b_comm_out / a_diff
import matplotlib # noqa: E402
matplotlib.use('Agg')
import matplotlib.pyplot as plt # noqa: E402
fig, axes = plt.subplots(3, 1, figsize=(12, 14))
ax = axes[0]
Sdd21_dB = 20 * np.log10(np.abs(Sdd21) + 1e-15)
ax.plot(freq / 1e6, Sdd21_dB, 'b-', linewidth=2, label='|Sdd21| (Insertion Loss)')
ax.axvspan(
f_IF_low / 1e6, f_IF_high / 1e6, alpha=0.15, color='green', label='IF Band (120-180 MHz)'
)
ax.axhline(-3, color='r', linestyle='--', alpha=0.5, label='-3 dB')
ax.set_xlabel('Frequency (MHz)')
ax.set_ylabel('|Sdd21| (dB)')
ax.set_title('Anti-Alias Filter — Differential Insertion Loss')
ax.set_xlim([0, 1000])
ax.set_ylim([-60, 5])
ax.grid(True, alpha=0.3)
ax.legend()
ax = axes[1]
Sdd11_dB = 20 * np.log10(np.abs(Sdd11) + 1e-15)
ax.plot(freq / 1e6, Sdd11_dB, 'r-', linewidth=2, label='|Sdd11| (Return Loss)')
ax.axvspan(f_IF_low / 1e6, f_IF_high / 1e6, alpha=0.15, color='green', label='IF Band')
ax.axhline(-10, color='orange', linestyle='--', alpha=0.5, label='-10 dB')
ax.set_xlabel('Frequency (MHz)')
ax.set_ylabel('|Sdd11| (dB)')
ax.set_title('Anti-Alias Filter — Differential Return Loss')
ax.set_xlim([0, 1000])
ax.set_ylim([-40, 0])
ax.grid(True, alpha=0.3)
ax.legend()
ax = axes[2]
phase_Sdd21 = np.unwrap(np.angle(Sdd21))
group_delay = -np.diff(phase_Sdd21) / np.diff(2 * np.pi * freq) * 1e9
ax.plot(freq[1:] / 1e6, group_delay, 'g-', linewidth=2, label='Group Delay')
ax.axvspan(f_IF_low / 1e6, f_IF_high / 1e6, alpha=0.15, color='green', label='IF Band')
ax.set_xlabel('Frequency (MHz)')
ax.set_ylabel('Group Delay (ns)')
ax.set_title('Anti-Alias Filter — Group Delay')
ax.set_xlim([0, 500])
ax.grid(True, alpha=0.3)
ax.legend()
plt.tight_layout()
plot_file = os.path.join(sim_path, 'aaf_filter_response.png')
plt.savefig(plot_file, dpi=150)
idx_120 = np.argmin(np.abs(freq - f_IF_low))
idx_150 = np.argmin(np.abs(freq - f_center))
idx_180 = np.argmin(np.abs(freq - f_IF_high))
idx_200 = np.argmin(np.abs(freq - 200e6))
idx_400 = np.argmin(np.abs(freq - 400e6))
csv_file = os.path.join(sim_path, 'aaf_sparams.csv')
np.savetxt(
csv_file,
np.column_stack([freq / 1e6, Sdd21_dB, Sdd11_dB, 20 * np.log10(np.abs(Scd21) + 1e-15)]),
header='Freq_MHz, Sdd21_dB, Sdd11_dB, Scd21_dB',
delimiter=',', fmt='%.6f'
)
+5 -8
View File
@@ -91,9 +91,9 @@ z_edges = np.concatenate([z_centers - slot_L/2.0, z_centers + slot_L/2.0])
# ------------------------- # -------------------------
# Mesh lines — EXPLICIT (no GetLine calls) # Mesh lines — EXPLICIT (no GetLine calls)
# ------------------------- # -------------------------
x_lines = sorted(set([x_min, -t_metal, 0.0, a, a+t_metal, x_max] + list(x_edges))) x_lines = sorted({x_min, -t_metal, 0.0, a, a + t_metal, x_max, *list(x_edges)})
y_lines = [y_min, 0.0, b, b+t_metal, y_max] y_lines = [y_min, 0.0, b, b+t_metal, y_max]
z_lines = sorted(set([z_min, 0.0, L, z_max] + list(z_edges))) z_lines = sorted({z_min, 0.0, L, z_max, *list(z_edges)})
mesh.AddLine('x', x_lines) mesh.AddLine('x', x_lines)
mesh.AddLine('y', y_lines) mesh.AddLine('y', y_lines)
@@ -123,7 +123,7 @@ pec.AddBox([-t_metal,-t_metal,0],[a+t_metal,0, L]) # bottom
pec.AddBox([-t_metal, b, 0], [a+t_metal,b+t_metal,L]) # top pec.AddBox([-t_metal, b, 0], [a+t_metal,b+t_metal,L]) # top
# Slots = AIR boxes overriding the top metal # Slots = AIR boxes overriding the top metal
for zc, xc in zip(z_centers, x_centers): for zc, xc in zip(z_centers, x_centers, strict=False):
x1, x2 = xc - slot_w/2.0, xc + slot_w/2.0 x1, x2 = xc - slot_w/2.0, xc + slot_w/2.0
z1, z2 = zc - slot_L/2.0, zc + slot_L/2.0 z1, z2 = zc - slot_L/2.0, zc + slot_L/2.0
prim = air.AddBox([x1, b, z1], [x2, b+t_metal, z2]) prim = air.AddBox([x1, b, z1], [x2, b+t_metal, z2])
@@ -181,7 +181,7 @@ if simulate:
# Post-processing: S-params & impedance # Post-processing: S-params & impedance
# ------------------------- # -------------------------
freq = np.linspace(f_start, f_stop, 401) freq = np.linspace(f_start, f_stop, 401)
ports = [p for p in FDTD.ports] # Port 1 & Port 2 in creation order ports = list(FDTD.ports) # Port 1 & Port 2 in creation order
for p in ports: for p in ports:
p.CalcPort(Sim_Path, freq) p.CalcPort(Sim_Path, freq)
@@ -226,9 +226,6 @@ mismatch = 1.0 - np.abs(S11[idx_f0])**2 # (1 - |S11|^2)
Gmax_lin = Dmax_lin * float(mismatch) Gmax_lin = Dmax_lin * float(mismatch)
Gmax_dBi = 10*np.log10(Gmax_lin) Gmax_dBi = 10*np.log10(Gmax_lin)
print(f"Max directivity @ {f0/1e9:.3f} GHz: {10*np.log10(Dmax_lin):.2f} dBi")
print(f"Mismatch term (1-|S11|^2) : {float(mismatch):.3f}")
print(f"Estimated max realized gain : {Gmax_dBi:.2f} dBi")
# 3D normalized pattern # 3D normalized pattern
E = np.squeeze(res.E_norm) # shape [f, th, ph] -> [th, ph] E = np.squeeze(res.E_norm) # shape [f, th, ph] -> [th, ph]
@@ -254,7 +251,7 @@ plt.figure(figsize=(8.4,2.8))
plt.fill_between( plt.fill_between(
[0, a], [0, 0], [L, L], color='#dddddd', alpha=0.5, step='pre', label='WG aperture (top)' [0, a], [0, 0], [L, L], color='#dddddd', alpha=0.5, step='pre', label='WG aperture (top)'
) )
for zc, xc in zip(z_centers, x_centers): for zc, xc in zip(z_centers, x_centers, strict=False):
plt.gca().add_patch(plt.Rectangle((xc - slot_w/2.0, zc - slot_L/2.0), plt.gca().add_patch(plt.Rectangle((xc - slot_w/2.0, zc - slot_L/2.0),
slot_w, slot_L, fc='#3355ff', ec='k')) slot_w, slot_L, fc='#3355ff', ec='k'))
plt.xlim(-2, a + 2) plt.xlim(-2, a + 2)
@@ -1,6 +1,6 @@
# openems_quartz_slotted_wg_10p5GHz.py # openems_quartz_slotted_wg_10p5GHz.py
# Slotted rectangular waveguide (quartz-filled, εr=3.8) tuned to 10.5 GHz. # Slotted rectangular waveguide (quartz-filled, εr=3.8) tuned to 10.5 GHz.
# Builds geometry, meshes (no GetLine calls), sweeps S-params/impedance over 9.511.5 GHz, # Builds geometry, meshes (no GetLine calls), sweeps S-params/impedance over 9.5-11.5 GHz,
# computes 3D far-field, and reports estimated max realized gain. # computes 3D far-field, and reports estimated max realized gain.
import os import os
@@ -15,14 +15,14 @@ from openEMS.physical_constants import C0
try: try:
from CSXCAD import ContinuousStructure, AppCSXCAD_BIN from CSXCAD import ContinuousStructure, AppCSXCAD_BIN
HAVE_APP = True HAVE_APP = True
except Exception: except ImportError:
from CSXCAD import ContinuousStructure from CSXCAD import ContinuousStructure
AppCSXCAD_BIN = None AppCSXCAD_BIN = None
HAVE_APP = False HAVE_APP = False
#Set PROFILE to "sanity" first; run and check [mesh] cells: stays reasonable. #Set PROFILE to "sanity" first; run and check [mesh] cells: stays reasonable.
#If its small, move to "balanced"; once happy, go "full". #If it's small, move to "balanced"; once happy, go "full".
#Toggle VIEW_GEOM=True if you want the 3D viewer (requires AppCSXCAD_BIN available). #Toggle VIEW_GEOM=True if you want the 3D viewer (requires AppCSXCAD_BIN available).
@@ -123,9 +123,9 @@ x_edges = np.concatenate([x_centers - slot_w/2.0, x_centers + slot_w/2.0])
z_edges = np.concatenate([z_centers - slot_L/2.0, z_centers + slot_L/2.0]) z_edges = np.concatenate([z_centers - slot_L/2.0, z_centers + slot_L/2.0])
# Mesh lines: explicit (NO GetLine calls) # Mesh lines: explicit (NO GetLine calls)
x_lines = sorted(set([x_min, -t_metal, 0.0, a, a+t_metal, x_max] + list(x_edges))) x_lines = sorted({x_min, -t_metal, 0.0, a, a + t_metal, x_max, *list(x_edges)})
y_lines = [y_min, 0.0, b, b+t_metal, y_max] y_lines = [y_min, 0.0, b, b+t_metal, y_max]
z_lines = sorted(set([z_min, 0.0, guide_length_mm, z_max] + list(z_edges))) z_lines = sorted({z_min, 0.0, guide_length_mm, z_max, *list(z_edges)})
mesh.AddLine('x', x_lines) mesh.AddLine('x', x_lines)
mesh.AddLine('y', y_lines) mesh.AddLine('y', y_lines)
@@ -134,13 +134,10 @@ mesh.AddLine('z', z_lines)
# Print complexity and rough memory (to help stay inside 16 GB) # Print complexity and rough memory (to help stay inside 16 GB)
Nx, Ny, Nz = len(x_lines)-1, len(y_lines)-1, len(z_lines)-1 Nx, Ny, Nz = len(x_lines)-1, len(y_lines)-1, len(z_lines)-1
Ncells = Nx*Ny*Nz Ncells = Nx*Ny*Nz
print(f"[mesh] cells: {Nx} × {Ny} × {Nz} = {Ncells:,}")
mem_fields_bytes = Ncells * 6 * 8 # rough ~ (Ex,Ey,Ez,Hx,Hy,Hz) doubles mem_fields_bytes = Ncells * 6 * 8 # rough ~ (Ex,Ey,Ez,Hx,Hy,Hz) doubles
print(f"[mesh] rough field memory: ~{mem_fields_bytes/1e9:.2f} GB (solver overhead extra)")
dx_min = min(np.diff(x_lines)) dx_min = min(np.diff(x_lines))
dy_min = min(np.diff(y_lines)) dy_min = min(np.diff(y_lines))
dz_min = min(np.diff(z_lines)) dz_min = min(np.diff(z_lines))
print(f"[mesh] min steps (mm): dx={dx_min:.3f}, dy={dy_min:.3f}, dz={dz_min:.3f}")
# Optional smoothing to limit max cell size # Optional smoothing to limit max cell size
mesh.SmoothMeshLines('all', mesh_res, ratio=1.4) mesh.SmoothMeshLines('all', mesh_res, ratio=1.4)
@@ -165,7 +162,7 @@ pec.AddBox(
) # top (slots will pierce) ) # top (slots will pierce)
# Slots (AIR) overriding top metal # Slots (AIR) overriding top metal
for zc, xc in zip(z_centers, x_centers): for zc, xc in zip(z_centers, x_centers, strict=False):
x1, x2 = xc - slot_w/2.0, xc + slot_w/2.0 x1, x2 = xc - slot_w/2.0, xc + slot_w/2.0
z1, z2 = zc - slot_L/2.0, zc + slot_L/2.0 z1, z2 = zc - slot_L/2.0, zc + slot_L/2.0
prim = airM.AddBox([x1, b, z1], [x2, b+t_metal, z2]) prim = airM.AddBox([x1, b, z1], [x2, b+t_metal, z2])
@@ -215,7 +212,6 @@ if VIEW_GEOM and HAVE_APP and AppCSXCAD_BIN:
t0 = time.time() t0 = time.time()
FDTD.Run(Sim_Path, cleanup=True, verbose=2, numThreads=THREADS) FDTD.Run(Sim_Path, cleanup=True, verbose=2, numThreads=THREADS)
t1 = time.time() t1 = time.time()
print(f"[timing] FDTD solve elapsed: {t1 - t0:.2f} s")
# ... right before NF2FF (far-field): # ... right before NF2FF (far-field):
t2 = time.time() t2 = time.time()
@@ -224,14 +220,12 @@ try:
except AttributeError: except AttributeError:
res = FDTD.CalcNF2FF(nf2ff, Sim_Path, [f0], theta, phi) # noqa: F821 res = FDTD.CalcNF2FF(nf2ff, Sim_Path, [f0], theta, phi) # noqa: F821
t3 = time.time() t3 = time.time()
print(f"[timing] NF2FF (far-field) elapsed: {t3 - t2:.2f} s")
# ... S-parameters postproc timing (optional): # ... S-parameters postproc timing (optional):
t4 = time.time() t4 = time.time()
for p in ports: # noqa: F821 for p in ports: # noqa: F821
p.CalcPort(Sim_Path, freq) # noqa: F821 p.CalcPort(Sim_Path, freq) # noqa: F821
t5 = time.time() t5 = time.time()
print(f"[timing] Port/S-params postproc elapsed: {t5 - t4:.2f} s")
# ======= # =======
@@ -240,11 +234,8 @@ print(f"[timing] Port/S-params postproc elapsed: {t5 - t4:.2f} s")
if SIMULATE: if SIMULATE:
FDTD.Run(Sim_Path, cleanup=True, verbose=2, numThreads=THREADS) FDTD.Run(Sim_Path, cleanup=True, verbose=2, numThreads=THREADS)
# ==========================
# POST: S-PARAMS / IMPEDANCE
# ==========================
freq = np.linspace(f_start, f_stop, profiles[PROFILE]["freq_pts"]) freq = np.linspace(f_start, f_stop, profiles[PROFILE]["freq_pts"])
ports = [p for p in FDTD.ports] # Port 1 & 2 in creation order ports = list(FDTD.ports) # Port 1 & 2 in creation order
for p in ports: for p in ports:
p.CalcPort(Sim_Path, freq) p.CalcPort(Sim_Path, freq)
@@ -288,9 +279,6 @@ mismatch = 1.0 - np.abs(S11[idx_f0])**2
Gmax_lin = Dmax_lin * float(mismatch) Gmax_lin = Dmax_lin * float(mismatch)
Gmax_dBi = 10*np.log10(Gmax_lin) Gmax_dBi = 10*np.log10(Gmax_lin)
print(f"[far-field] Dmax @ {f0/1e9:.3f} GHz: {10*np.log10(Dmax_lin):.2f} dBi")
print(f"[far-field] mismatch (1-|S11|^2): {float(mismatch):.3f}")
print(f"[far-field] est. max realized gain: {Gmax_dBi:.2f} dBi")
# Normalized 3D pattern # Normalized 3D pattern
E = np.squeeze(res.E_norm) # [th, ph] E = np.squeeze(res.E_norm) # [th, ph]
@@ -324,7 +312,7 @@ plt.fill_between(
step='pre', step='pre',
label='WG top aperture', label='WG top aperture',
) )
for zc, xc in zip(z_centers, x_centers): for zc, xc in zip(z_centers, x_centers, strict=False):
plt.gca().add_patch(plt.Rectangle((xc - slot_w/2.0, zc - slot_L/2.0), plt.gca().add_patch(plt.Rectangle((xc - slot_w/2.0, zc - slot_L/2.0),
slot_w, slot_L, fc='#3355ff', ec='k')) slot_w, slot_L, fc='#3355ff', ec='k'))
plt.xlim(-2, a + 2) plt.xlim(-2, a + 2)
@@ -68,13 +68,7 @@ def generate_multi_ramp_csv(Fs=125e6, Tb=1e-6, Tau=2e-6, fmax=30e6, fmin=10e6,
# --- Save CSV (no header) # --- Save CSV (no header)
df = pd.DataFrame({"time(s)": t_csv, "voltage(V)": y_csv}) df = pd.DataFrame({"time(s)": t_csv, "voltage(V)": y_csv})
df.to_csv(filename, index=False, header=False) df.to_csv(filename, index=False, header=False)
print(f"CSV saved: {filename}")
print(
f"Total raw samples: {total_samples} | Ramps inserted: {ramps_inserted} "
f"| CSV points: {len(y_csv)}"
)
# --- Plot (staircase)
if show_plot or save_plot_png: if show_plot or save_plot_png:
# Choose plotting vectors (use raw DAC samples to keep lines crisp) # Choose plotting vectors (use raw DAC samples to keep lines crisp)
t_plot = t t_plot = t
@@ -111,7 +105,6 @@ def generate_multi_ramp_csv(Fs=125e6, Tb=1e-6, Tau=2e-6, fmax=30e6, fmin=10e6,
if save_plot_png: if save_plot_png:
plt.savefig(save_plot_png, dpi=150) plt.savefig(save_plot_png, dpi=150)
print(f"Plot saved: {save_plot_png}")
if show_plot: if show_plot:
plt.show() plt.show()
else: else:
-1
View File
@@ -1,6 +1,5 @@
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
# Dimensions (all in mm)
line_width = 0.204 line_width = 0.204
substrate_height = 0.102 substrate_height = 0.102
via_drill = 0.20 via_drill = 0.20
+2 -3
View File
@@ -1,6 +1,5 @@
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
# Dimensions (all in mm)
line_width = 0.204 line_width = 0.204
via_pad_A = 0.20 via_pad_A = 0.20
via_pad_B = 0.45 via_pad_B = 0.45
@@ -50,14 +49,14 @@ ax.text(-2, polygon_y1 + 0.5, "Via B Ø0.45 mm pad", color="red")
# Add pitch dimension (horizontal between vias) # Add pitch dimension (horizontal between vias)
ax.annotate("", xy=(2, polygon_y1 + 0.2), xytext=(2 + via_pitch, polygon_y1 + 0.2), ax.annotate("", xy=(2, polygon_y1 + 0.2), xytext=(2 + via_pitch, polygon_y1 + 0.2),
arrowprops=dict(arrowstyle="<->", color="purple")) arrowprops={"arrowstyle": "<->", "color": "purple"})
ax.text(2 + via_pitch/2, polygon_y1 + 0.3, f"{via_pitch:.2f} mm pitch", color="purple", ha="center") ax.text(2 + via_pitch/2, polygon_y1 + 0.3, f"{via_pitch:.2f} mm pitch", color="purple", ha="center")
# Add distance from RF line edge to via center # Add distance from RF line edge to via center
line_edge_y = rf_line_y + line_width/2 line_edge_y = rf_line_y + line_width/2
via_center_y = polygon_y1 via_center_y = polygon_y1
ax.annotate("", xy=(2.4, line_edge_y), xytext=(2.4, via_center_y), ax.annotate("", xy=(2.4, line_edge_y), xytext=(2.4, via_center_y),
arrowprops=dict(arrowstyle="<->", color="brown")) arrowprops={"arrowstyle": "<->", "color": "brown"})
ax.text( ax.text(
2.5, (line_edge_y + via_center_y) / 2, f"{via_center_offset:.2f} mm", color="brown", va="center" 2.5, (line_edge_y + via_center_y) / 2, f"{via_center_offset:.2f} mm", color="brown", va="center"
) )
@@ -27,7 +27,7 @@ n_idx = np.arange(N) - (N-1)/2
y_positions = m_idx * dy y_positions = m_idx * dy
z_positions = n_idx * dz z_positions = n_idx * dz
def element_factor(theta_rad, phi_rad): def element_factor(theta_rad, _phi_rad):
return np.abs(np.cos(theta_rad)) return np.abs(np.cos(theta_rad))
def array_factor(theta_rad, phi_rad, y_positions, z_positions, wy, wz, theta0_rad, phi0_rad): def array_factor(theta_rad, phi_rad, y_positions, z_positions, wy, wz, theta0_rad, phi0_rad):
@@ -105,8 +105,3 @@ plt.title('Array Pattern Heatmap (|AF·EF|, dB) — Kaiser ~-25 dB')
plt.tight_layout() plt.tight_layout()
plt.savefig('Heatmap_Kaiser25dB_like.png', bbox_inches='tight') plt.savefig('Heatmap_Kaiser25dB_like.png', bbox_inches='tight')
plt.show() plt.show()
print(
'Saved: E_plane_Kaiser25dB_like.png, H_plane_Kaiser25dB_like.png, '
'Heatmap_Kaiser25dB_like.png'
)
+4 -26
View File
@@ -38,7 +38,6 @@ def generate_radar_csv(filename="pulse_compression_output.csv"):
chirp_number = 0 chirp_number = 0
# Generate Long Chirps (30µs duration equivalent) # Generate Long Chirps (30µs duration equivalent)
print("Generating Long Chirps...")
for chirp in range(num_long_chirps): for chirp in range(num_long_chirps):
for sample in range(samples_per_chirp): for sample in range(samples_per_chirp):
# Base noise # Base noise
@@ -90,7 +89,6 @@ def generate_radar_csv(filename="pulse_compression_output.csv"):
timestamp_ns += 175400 # 175.4µs guard time timestamp_ns += 175400 # 175.4µs guard time
# Generate Short Chirps (0.5µs duration equivalent) # Generate Short Chirps (0.5µs duration equivalent)
print("Generating Short Chirps...")
for chirp in range(num_short_chirps): for chirp in range(num_short_chirps):
for sample in range(samples_per_chirp): for sample in range(samples_per_chirp):
# Base noise # Base noise
@@ -142,11 +140,6 @@ def generate_radar_csv(filename="pulse_compression_output.csv"):
# Save to CSV # Save to CSV
df.to_csv(filename, index=False) df.to_csv(filename, index=False)
print(f"Generated CSV file: {filename}")
print(f"Total samples: {len(df)}")
print(f"Long chirps: {num_long_chirps}, Short chirps: {num_short_chirps}")
print(f"Samples per chirp: {samples_per_chirp}")
print(f"File size: {len(df) // 1000}K samples")
return df return df
@@ -154,15 +147,11 @@ def analyze_generated_data(df):
""" """
Analyze the generated data to verify target detection Analyze the generated data to verify target detection
""" """
print("\n=== Data Analysis ===")
# Basic statistics # Basic statistics
long_chirps = df[df['chirp_type'] == 'LONG'] df[df['chirp_type'] == 'LONG']
short_chirps = df[df['chirp_type'] == 'SHORT'] df[df['chirp_type'] == 'SHORT']
print(f"Long chirp samples: {len(long_chirps)}")
print(f"Short chirp samples: {len(short_chirps)}")
print(f"Unique chirp numbers: {df['chirp_number'].nunique()}")
# Calculate actual magnitude and phase for analysis # Calculate actual magnitude and phase for analysis
df['magnitude'] = np.sqrt(df['I_value']**2 + df['Q_value']**2) df['magnitude'] = np.sqrt(df['I_value']**2 + df['Q_value']**2)
@@ -172,15 +161,11 @@ def analyze_generated_data(df):
high_mag_threshold = df['magnitude'].quantile(0.95) # Top 5% high_mag_threshold = df['magnitude'].quantile(0.95) # Top 5%
targets_detected = df[df['magnitude'] > high_mag_threshold] targets_detected = df[df['magnitude'] > high_mag_threshold]
print(f"\nTarget detection threshold: {high_mag_threshold:.2f}")
print(f"High magnitude samples: {len(targets_detected)}")
# Group by chirp type # Group by chirp type
long_targets = targets_detected[targets_detected['chirp_type'] == 'LONG'] targets_detected[targets_detected['chirp_type'] == 'LONG']
short_targets = targets_detected[targets_detected['chirp_type'] == 'SHORT'] targets_detected[targets_detected['chirp_type'] == 'SHORT']
print(f"Targets in long chirps: {len(long_targets)}")
print(f"Targets in short chirps: {len(short_targets)}")
return df return df
@@ -191,10 +176,3 @@ if __name__ == "__main__":
# Analyze the generated data # Analyze the generated data
analyze_generated_data(df) analyze_generated_data(df)
print("\n=== CSV File Ready ===")
print("You can now test the Python GUI with this CSV file!")
print("The file contains:")
print("- 16 Long chirps + 16 Short chirps")
print("- 4 simulated targets at different ranges and velocities")
print("- Realistic noise and clutter")
print("- Proper I/Q data for Doppler processing")
-2
View File
@@ -90,8 +90,6 @@ def generate_small_radar_csv(filename="small_test_radar_data.csv"):
df = pd.DataFrame(data) df = pd.DataFrame(data)
df.to_csv(filename, index=False) df.to_csv(filename, index=False)
print(f"Generated small CSV: {filename}")
print(f"Total samples: {len(df)}")
return df return df
generate_small_radar_csv() generate_small_radar_csv()
@@ -31,7 +31,6 @@ freq_indices = np.arange(L)
T = L*Ts T = L*Ts
freq = freq_indices/T freq = freq_indices/T
print("The Array is: ", x) #printing the array
plt.figure(figsize = (12, 6)) plt.figure(figsize = (12, 6))
plt.subplot(121) plt.subplot(121)
+2 -2
View File
@@ -20,5 +20,5 @@ y = 1 + np.sin(theta_n) # Normalize from 0 to 2
y_scaled = np.round(y * 127.5).astype(int) # Scale to 8-bit range (0-255) y_scaled = np.round(y * 127.5).astype(int) # Scale to 8-bit range (0-255)
# Print values in Verilog-friendly format # Print values in Verilog-friendly format
for i in range(n): for _i in range(n):
print(f"waveform_LUT[{i}] = 8'h{y_scaled[i]:02X};") pass
+12 -12
View File
@@ -60,7 +60,7 @@ class RadarCalculatorGUI:
scrollable_frame.bind( scrollable_frame.bind(
"<Configure>", "<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") canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
@@ -83,7 +83,7 @@ class RadarCalculatorGUI:
self.entries = {} self.entries = {}
for i, (label, default) in enumerate(inputs): for _i, (label, default) in enumerate(inputs):
# Create a frame for each input row # Create a frame for each input row
row_frame = ttk.Frame(scrollable_frame) row_frame = ttk.Frame(scrollable_frame)
row_frame.pack(fill=tk.X, pady=5) row_frame.pack(fill=tk.X, pady=5)
@@ -119,8 +119,8 @@ class RadarCalculatorGUI:
calculate_btn.pack() calculate_btn.pack()
# Bind hover effect # Bind hover effect
calculate_btn.bind("<Enter>", lambda e: calculate_btn.config(bg='#45a049')) calculate_btn.bind("<Enter>", lambda _e: calculate_btn.config(bg='#45a049'))
calculate_btn.bind("<Leave>", lambda e: calculate_btn.config(bg='#4CAF50')) calculate_btn.bind("<Leave>", lambda _e: calculate_btn.config(bg='#4CAF50'))
def create_results_display(self): def create_results_display(self):
"""Create the results display area""" """Create the results display area"""
@@ -137,7 +137,7 @@ class RadarCalculatorGUI:
scrollable_frame.bind( scrollable_frame.bind(
"<Configure>", "<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") canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
@@ -158,7 +158,7 @@ class RadarCalculatorGUI:
self.results_labels = {} self.results_labels = {}
for i, (label, key) in enumerate(results): for _i, (label, key) in enumerate(results):
# Create a frame for each result row # Create a frame for each result row
row_frame = ttk.Frame(scrollable_frame) row_frame = ttk.Frame(scrollable_frame)
row_frame.pack(fill=tk.X, pady=10, padx=20) row_frame.pack(fill=tk.X, pady=10, padx=20)
@@ -180,10 +180,10 @@ class RadarCalculatorGUI:
note_text = """ note_text = """
NOTES: NOTES:
• Maximum detectable range is calculated using the radar equation • Maximum detectable range is calculated using the radar equation
• Range resolution = c × τ / 2, where τ is pulse duration • Range resolution = c x τ / 2, where τ is pulse duration
• Maximum unambiguous range = c / (2 × PRF) • Maximum unambiguous range = c / (2 x PRF)
• Maximum detectable speed = λ × PRF / 4 • Maximum detectable speed = λ x PRF / 4
• Speed resolution = λ × PRF / (2 × N) where N is number of pulses (assumed 1) • Speed resolution = λ x PRF / (2 x N) where N is number of pulses (assumed 1)
• λ (wavelength) = c / f • λ (wavelength) = c / f
""" """
@@ -300,10 +300,10 @@ class RadarCalculatorGUI:
# Show success message # Show success message
messagebox.showinfo("Success", "Calculation completed successfully!") messagebox.showinfo("Success", "Calculation completed successfully!")
except Exception as e: except (ValueError, ZeroDivisionError) as e:
messagebox.showerror( messagebox.showerror(
"Calculation Error", "Calculation Error",
f"An error occurred during calculation:\n{str(e)}", f"An error occurred during calculation:\n{e!s}",
) )
def main(): def main():
-5
View File
@@ -66,8 +66,3 @@ W_mm, L_mm, dx_mm, dy_mm, W_feed_mm = calculate_patch_antenna_parameters(
frequency, epsilon_r, h_sub, h_cu, array frequency, epsilon_r, h_sub, h_cu, array
) )
print(f"Width of the patch: {W_mm:.4f} mm")
print(f"Length of the patch: {L_mm:.4f} mm")
print(f"Separation distance in horizontal axis: {dx_mm:.4f} mm")
print(f"Separation distance in vertical axis: {dy_mm:.4f} mm")
print(f"Feeding line width: {W_feed_mm:.2f} mm")
+14 -71
View File
@@ -93,7 +93,7 @@ SCENARIOS = {
def load_adc_hex(filepath): def load_adc_hex(filepath):
"""Load 8-bit unsigned ADC samples from hex file.""" """Load 8-bit unsigned ADC samples from hex file."""
samples = [] samples = []
with open(filepath, 'r') as f: with open(filepath) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith('//'): 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).""" """Load RTL baseband output CSV (sample_idx, baseband_i, baseband_q)."""
bb_i = [] bb_i = []
bb_q = [] bb_q = []
with open(filepath, 'r') as f: with open(filepath) as f:
f.readline() # Skip header f.readline() # Skip header
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -125,7 +125,6 @@ def run_python_model(adc_samples):
because the RTL testbench captures the FIR output directly because the RTL testbench captures the FIR output directly
(baseband_i_reg <= fir_i_out in ddc_400m.v). (baseband_i_reg <= fir_i_out in ddc_400m.v).
""" """
print(" Running Python model...")
chain = SignalChain() chain = SignalChain()
result = chain.process_adc_block(adc_samples) result = chain.process_adc_block(adc_samples)
@@ -135,7 +134,6 @@ def run_python_model(adc_samples):
bb_i = result['fir_i_raw'] bb_i = result['fir_i_raw']
bb_q = result['fir_q_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 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)}") raise ValueError(f"Length mismatch: {len(a)} vs {len(b)}")
if len(a) == 0: if len(a) == 0:
return 0.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)) 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.""" """Compute maximum absolute error between two equal-length lists."""
if len(a) != len(b) or len(a) == 0: if len(a) != len(b) or len(a) == 0:
return 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): def compute_correlation(a, b):
@@ -235,44 +233,29 @@ def compute_signal_stats(samples):
def compare_scenario(scenario_name): def compare_scenario(scenario_name):
"""Run comparison for one scenario. Returns True if passed.""" """Run comparison for one scenario. Returns True if passed."""
if scenario_name not in SCENARIOS: if scenario_name not in SCENARIOS:
print(f"ERROR: Unknown scenario '{scenario_name}'")
print(f"Available: {', '.join(SCENARIOS.keys())}")
return False return False
cfg = SCENARIOS[scenario_name] cfg = SCENARIOS[scenario_name]
base_dir = os.path.dirname(os.path.abspath(__file__)) 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 ---- # ---- Load ADC data ----
adc_path = os.path.join(base_dir, cfg['adc_hex']) adc_path = os.path.join(base_dir, cfg['adc_hex'])
if not os.path.exists(adc_path): 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 return False
adc_samples = load_adc_hex(adc_path) adc_samples = load_adc_hex(adc_path)
print(f"\nADC samples loaded: {len(adc_samples)}")
# ---- Load RTL output ---- # ---- Load RTL output ----
rtl_path = os.path.join(base_dir, cfg['rtl_csv']) rtl_path = os.path.join(base_dir, cfg['rtl_csv'])
if not os.path.exists(rtl_path): 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 return False
rtl_i, rtl_q = load_rtl_csv(rtl_path) 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 ---- # ---- Run Python model ----
py_i, py_q = run_python_model(adc_samples) py_i, py_q = run_python_model(adc_samples)
# ---- Length comparison ---- # ---- Length comparison ----
print(f"\nOutput lengths: RTL={len(rtl_i)}, Python={len(py_i)}")
len_diff = abs(len(rtl_i) - len(py_i)) len_diff = abs(len(rtl_i) - len(py_i))
print(f"Length difference: {len_diff} samples")
# ---- Signal statistics ---- # ---- Signal statistics ----
rtl_i_stats = compute_signal_stats(rtl_i) 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_i_stats = compute_signal_stats(py_i)
py_q_stats = compute_signal_stats(py_q) 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 ---- # ---- Trim to common length ----
common_len = min(len(rtl_i), len(py_i)) common_len = min(len(rtl_i), len(py_i))
if common_len < 10: if common_len < 10:
print(f"ERROR: Too few common samples ({common_len})")
return False return False
rtl_i_trim = rtl_i[:common_len] rtl_i_trim = rtl_i[:common_len]
@@ -302,18 +275,14 @@ def compare_scenario(scenario_name):
py_q_trim = py_q[:common_len] py_q_trim = py_q[:common_len]
# ---- Cross-correlation to find latency offset ---- # ---- 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) 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) 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 ---- # ---- Apply latency correction ----
best_lag = lag_i # Use I-channel lag (should be same as Q) best_lag = lag_i # Use I-channel lag (should be same as Q)
if abs(lag_i - lag_q) > 1: if abs(lag_i - lag_q) > 1:
print(f" WARNING: I and Q latency offsets differ ({lag_i} vs {lag_q})")
# Use the average # Use the average
best_lag = (lag_i + lag_q) // 2 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_i = aligned_py_i[:aligned_len]
aligned_py_q = aligned_py_q[: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) ---- # ---- Error metrics (after alignment) ----
rms_i = compute_rms_error(aligned_rtl_i, aligned_py_i) rms_i = compute_rms_error(aligned_rtl_i, aligned_py_i)
rms_q = compute_rms_error(aligned_rtl_q, aligned_py_q) rms_q = compute_rms_error(aligned_rtl_q, aligned_py_q)
max_err_i = compute_max_abs_error(aligned_rtl_i, aligned_py_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_q, aligned_py_q)
corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i) corr_i_aligned = compute_correlation(aligned_rtl_i, aligned_py_i)
corr_q_aligned = compute_correlation(aligned_rtl_q, aligned_py_q) 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 ---- # ---- 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)): for k in range(min(10, aligned_len)):
ei = aligned_rtl_i[k] - aligned_py_i[k] ei = aligned_rtl_i[k] - aligned_py_i[k]
eq = aligned_rtl_q[k] - aligned_py_q[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 ---- # ---- Write detailed comparison CSV ----
compare_csv_path = os.path.join(base_dir, f"compare_{scenario_name}.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] eq = aligned_rtl_q[k] - aligned_py_q[k]
f.write(f"{k},{aligned_rtl_i[k]},{aligned_py_i[k]},{ei}," f.write(f"{k},{aligned_rtl_i[k]},{aligned_py_i[k]},{ei},"
f"{aligned_rtl_q[k]},{aligned_py_q[k]},{eq}\n") f"{aligned_rtl_q[k]},{aligned_py_q[k]},{eq}\n")
print(f"\nDetailed comparison written to: {compare_csv_path}")
# ---- Pass/Fail ---- # ---- Pass/Fail ----
max_rms = cfg.get('max_rms', MAX_RMS_ERROR_LSB) 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}")) f"|{best_lag}| <= {MAX_LATENCY_DRIFT}"))
# ---- Report ---- # ---- Report ----
print(f"\n{'' * 60}")
print("PASS/FAIL Results:")
all_pass = True all_pass = True
for name, ok, detail in results: for _name, ok, _detail in results:
mark = "[PASS]" if ok else "[FAIL]"
print(f" {mark} {name}: {detail}")
if not ok: if not ok:
all_pass = False all_pass = False
print(f"\n{'=' * 60}")
if all_pass: if all_pass:
print(f"SCENARIO {scenario_name.upper()}: ALL CHECKS PASSED") pass
else: else:
print(f"SCENARIO {scenario_name.upper()}: SOME CHECKS FAILED") pass
print(f"{'=' * 60}")
return all_pass return all_pass
@@ -481,23 +431,16 @@ def main():
pass_count += 1 pass_count += 1
else: else:
overall_pass = False overall_pass = False
print()
else: 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: if overall_pass:
print("ALL SCENARIOS PASSED") pass
else: else:
print("SOME SCENARIOS FAILED") pass
print("=" * 60)
return 0 if overall_pass else 1 return 0 if overall_pass else 1
else:
ok = compare_scenario(scenario) ok = compare_scenario(scenario)
return 0 if ok else 1 return 0 if ok else 1
else:
# Default: DC
ok = compare_scenario('dc') ok = compare_scenario('dc')
return 0 if ok else 1 return 0 if ok else 1
@@ -4085,4 +4085,3 @@ idx,rtl_i,py_i,err_i,rtl_q,py_q,err_q
4083,21,20,1,-6,-6,0 4083,21,20,1,-6,-6,0
4084,20,21,-1,-6,-6,0 4084,20,21,-1,-6,-6,0
4085,20,20,0,-5,-6,1 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), ...]} Returns dict: {rbin: [(dbin, i, q), ...]}
""" """
data = {} data = {}
with open(filepath, 'r') as f: with open(filepath) as f:
f.readline() # Skip header f.readline() # Skip header
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -117,7 +117,7 @@ def pearson_correlation(a, b):
def magnitude_l1(i_arr, q_arr): def magnitude_l1(i_arr, q_arr):
"""L1 magnitude: |I| + |Q|.""" """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): 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.""" """Sum of I^2 + Q^2 across all range bins and Doppler bins."""
total = 0 total = 0
for rbin in data_dict: 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 total += i_val * i_val + q_val * q_val
return total return total
@@ -154,44 +154,30 @@ def total_energy(data_dict):
def compare_scenario(name, config, base_dir): def compare_scenario(name, config, base_dir):
"""Compare one Doppler scenario. Returns (passed, result_dict).""" """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']) golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv']) rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path): 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, {} return False, {}
if not os.path.exists(rtl_path): if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
print(" Run the Verilog testbench first")
return False, {} return False, {}
py_data = load_doppler_csv(golden_path) py_data = load_doppler_csv(golden_path)
rtl_data = load_doppler_csv(rtl_path) rtl_data = load_doppler_csv(rtl_path)
py_rbins = sorted(py_data.keys()) sorted(py_data.keys())
rtl_rbins = sorted(rtl_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 ---- # ---- Check 1: Both have data ----
py_total = sum(len(v) for v in py_data.values()) py_total = sum(len(v) for v in py_data.values())
rtl_total = sum(len(v) for v in rtl_data.values()) rtl_total = sum(len(v) for v in rtl_data.values())
if py_total == 0 or rtl_total == 0: if py_total == 0 or rtl_total == 0:
print(" ERROR: One or both outputs are empty")
return False, {} return False, {}
# ---- Check 2: Output count ---- # ---- Check 2: Output count ----
count_ok = (rtl_total == TOTAL_OUTPUTS) 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 ---- # ---- Check 3: Global energy ----
py_energy = total_energy(py_data) py_energy = total_energy(py_data)
@@ -201,10 +187,6 @@ def compare_scenario(name, config, base_dir):
else: else:
energy_ratio = 1.0 if rtl_energy == 0 else float('inf') 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 ---- # ---- Check 4: Per-range-bin analysis ----
peak_agreements = 0 peak_agreements = 0
@@ -236,8 +218,8 @@ def compare_scenario(name, config, base_dir):
i_correlations.append(corr_i) i_correlations.append(corr_i)
q_correlations.append(corr_q) q_correlations.append(corr_q)
py_rbin_energy = sum(i*i + q*q for i, q in zip(py_i, py_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)) rtl_rbin_energy = sum(i*i + q*q for i, q in zip(rtl_i, rtl_q, strict=False))
peak_details.append({ peak_details.append({
'rbin': rbin, 'rbin': rbin,
@@ -255,20 +237,11 @@ def compare_scenario(name, config, base_dir):
avg_corr_i = sum(i_correlations) / len(i_correlations) avg_corr_i = sum(i_correlations) / len(i_correlations)
avg_corr_q = sum(q_correlations) / len(q_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 # 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] top_rbins = sorted(peak_details, key=lambda x: -x['py_energy'])[:5]
for d in top_rbins: for _d in top_rbins:
print(f" rbin={d['rbin']:2d}: py_peak={d['py_peak']:2d}, " pass
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}")
# ---- Pass/Fail ---- # ---- Pass/Fail ----
checks = [] 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} ' checks.append((f'High-energy rbin avg mag_corr >= {MAG_CORR_MIN:.2f} '
f'(actual={he_mag_corr:.3f})', he_ok)) f'(actual={he_mag_corr:.3f})', he_ok))
print("\n Pass/Fail Checks:")
all_pass = True all_pass = True
for check_name, passed in checks: for _check_name, passed in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {check_name}")
if not passed: if not passed:
all_pass = False 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.write(f'{rbin},{dbin},{py_i[dbin]},{py_q[dbin]},'
f'{rtl_i[dbin]},{rtl_q[dbin]},' f'{rtl_i[dbin]},{rtl_q[dbin]},'
f'{rtl_i[dbin]-py_i[dbin]},{rtl_q[dbin]-py_q[dbin]}\n') f'{rtl_i[dbin]-py_i[dbin]},{rtl_q[dbin]-py_q[dbin]}\n')
print(f"\n Detailed comparison: {compare_csv}")
result = { result = {
'scenario': name, 'scenario': name,
@@ -333,25 +302,15 @@ def compare_scenario(name, config, base_dir):
def main(): def main():
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
if len(sys.argv) > 1: arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'stationary'
arg = sys.argv[1].lower()
else:
arg = 'stationary'
if arg == 'all': if arg == 'all':
run_scenarios = list(SCENARIOS.keys()) run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS: elif arg in SCENARIOS:
run_scenarios = [arg] run_scenarios = [arg]
else: else:
print(f"Unknown scenario: {arg}")
print(f"Valid: {', '.join(SCENARIOS.keys())}, all")
sys.exit(1) 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 = [] results = []
for name in run_scenarios: for name in run_scenarios:
@@ -359,37 +318,20 @@ def main():
results.append((name, passed, result)) results.append((name, passed, result))
# Summary # 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 all_pass = True
for name, passed, result in results: for _name, passed, result in results:
if not result: if not result:
print(f" {name:<15} {'ERROR':>13} {'':>10} {'':>11} "
f"{'':>8} {'':>8} {'FAIL':>8}")
all_pass = False all_pass = False
else: 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: if not passed:
all_pass = False all_pass = False
print()
if all_pass: if all_pass:
print("ALL TESTS PASSED") pass
else: else:
print("SOME TESTS FAILED") pass
print(f"{'='*60}")
sys.exit(0 if all_pass else 1) 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).""" """Load CSV with columns (bin, out_i/range_profile_i, out_q/range_profile_q)."""
vals_i = [] vals_i = []
vals_q = [] vals_q = []
with open(filepath, 'r') as f: with open(filepath) as f:
f.readline() # Skip header f.readline() # Skip header
for line in f: for line in f:
line = line.strip() line = line.strip()
@@ -93,17 +93,17 @@ def load_csv(filepath):
def magnitude_spectrum(vals_i, vals_q): def magnitude_spectrum(vals_i, vals_q):
"""Compute magnitude = |I| + |Q| for each bin (L1 norm, matches RTL).""" """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): def magnitude_l2(vals_i, vals_q):
"""Compute magnitude = sqrt(I^2 + Q^2) for each bin.""" """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): def total_energy(vals_i, vals_q):
"""Compute total energy (sum of I^2 + Q^2).""" """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): def rms_magnitude(vals_i, vals_q):
@@ -111,7 +111,7 @@ def rms_magnitude(vals_i, vals_q):
n = len(vals_i) n = len(vals_i)
if n == 0: if n == 0:
return 0.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): def pearson_correlation(a, b):
@@ -144,7 +144,7 @@ def find_peak(vals_i, vals_q):
def top_n_peaks(mags, n=10): def top_n_peaks(mags, n=10):
"""Find the top-N peak bins by magnitude. Returns set of bin indices.""" """Find the top-N peak bins by magnitude. Returns set of bin indices."""
indexed = sorted(enumerate(mags), key=lambda x: -x[1]) 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): 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): def compare_scenario(scenario_name, config, base_dir):
"""Compare one scenario. Returns (pass/fail, result_dict).""" """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']) golden_path = os.path.join(base_dir, config['golden_csv'])
rtl_path = os.path.join(base_dir, config['rtl_csv']) rtl_path = os.path.join(base_dir, config['rtl_csv'])
if not os.path.exists(golden_path): 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, {} return False, {}
if not os.path.exists(rtl_path): if not os.path.exists(rtl_path):
print(f" ERROR: RTL CSV not found: {rtl_path}")
print(" Run the RTL testbench first")
return False, {} return False, {}
py_i, py_q = load_csv(golden_path) py_i, py_q = load_csv(golden_path)
rtl_i, rtl_q = load_csv(rtl_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: if len(py_i) != FFT_SIZE or len(rtl_i) != FFT_SIZE:
print(f" ERROR: Expected {FFT_SIZE} samples from each")
return False, {} return False, {}
# ---- Metric 1: Energy ---- # ---- 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 energy_ratio = float('inf') if py_energy == 0 else 0.0
rms_ratio = float('inf') if py_rms == 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 ---- # ---- Metric 2: Peak location ----
py_peak_bin, py_peak_mag = find_peak(py_i, py_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) 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 ---- # ---- Metric 3: Magnitude spectrum correlation ----
py_mag = magnitude_l2(py_i, py_q) py_mag = magnitude_l2(py_i, py_q)
rtl_mag = magnitude_l2(rtl_i, rtl_q) rtl_mag = magnitude_l2(rtl_i, rtl_q)
mag_corr = pearson_correlation(py_mag, rtl_mag) mag_corr = pearson_correlation(py_mag, rtl_mag)
print(f"\n Magnitude spectrum correlation: {mag_corr:.6f}")
# ---- Metric 4: Top-N peak overlap ---- # ---- Metric 4: Top-N peak overlap ----
# Use L1 magnitudes for peak finding (matches RTL) # 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_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) 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 ---- # ---- Metric 5: I and Q channel correlation ----
corr_i = pearson_correlation(py_i, rtl_i) corr_i = pearson_correlation(py_i, rtl_i)
corr_q = pearson_correlation(py_q, rtl_q) 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 ---- # ---- Pass/Fail Decision ----
# The SIMULATION branch uses floating-point twiddles ($cos/$sin) while # The SIMULATION branch uses floating-point twiddles ($cos/$sin) while
@@ -278,11 +252,8 @@ def compare_scenario(scenario_name, config, base_dir):
energy_ok)) energy_ok))
# Print checks # Print checks
print("\n Pass/Fail Checks:")
all_pass = True all_pass = True
for name, passed in checks: for _name, passed in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {name}")
if not passed: if not passed:
all_pass = False 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.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'{py_mag_l1[k]},{rtl_mag_l1[k]},'
f'{rtl_i[k]-py_i[k]},{rtl_q[k]-py_q[k]}\n') 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 return all_pass, result
@@ -322,25 +292,15 @@ def compare_scenario(scenario_name, config, base_dir):
def main(): def main():
base_dir = os.path.dirname(os.path.abspath(__file__)) base_dir = os.path.dirname(os.path.abspath(__file__))
if len(sys.argv) > 1: arg = sys.argv[1].lower() if len(sys.argv) > 1 else 'chirp'
arg = sys.argv[1].lower()
else:
arg = 'chirp'
if arg == 'all': if arg == 'all':
run_scenarios = list(SCENARIOS.keys()) run_scenarios = list(SCENARIOS.keys())
elif arg in SCENARIOS: elif arg in SCENARIOS:
run_scenarios = [arg] run_scenarios = [arg]
else: else:
print(f"Unknown scenario: {arg}")
print(f"Valid: {', '.join(SCENARIOS.keys())}, all")
sys.exit(1) 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 = [] results = []
for name in run_scenarios: for name in run_scenarios:
@@ -348,37 +308,20 @@ def main():
results.append((name, passed, result)) results.append((name, passed, result))
# Summary # 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 all_pass = True
for name, passed, result in results: for _name, passed, result in results:
if not result: if not result:
print(f" {name:<12} {'ERROR':>13} {'':>10} {'':>10} "
f"{'':>8} {'':>9} {'FAIL':>8}")
all_pass = False all_pass = False
else: 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: if not passed:
all_pass = False all_pass = False
print()
if all_pass: if all_pass:
print("ALL TESTS PASSED") pass
else: else:
print("SOME TESTS FAILED") pass
print(f"{'='*60}")
sys.exit(0 if all_pass else 1) sys.exit(0 if all_pass else 1)
+21 -74
View File
@@ -50,7 +50,7 @@ def saturate(value, bits):
return value 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.""" """Arithmetic right shift. Python >> on signed int is already arithmetic."""
return value >> shift return value >> shift
@@ -129,10 +129,7 @@ class NCO:
raw_index = lut_address & 0x3F raw_index = lut_address & 0x3F
# RTL: lut_index = (quadrant[0] ^ quadrant[1]) ? ~lut_address[5:0] : lut_address[5:0] # 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 & 63 if quadrant & 1 ^ quadrant >> 1 & 1 else raw_index
lut_index = (~raw_index) & 0x3F
else:
lut_index = raw_index
return quadrant, lut_index return quadrant, lut_index
@@ -175,7 +172,7 @@ class NCO:
# OLD phase_accum_reg (the value from the PREVIOUS call). # OLD phase_accum_reg (the value from the PREVIOUS call).
# We stored self.phase_accum_reg at the start of this call as the # We stored self.phase_accum_reg at the start of this call as the
# value from last cycle. So: # 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: # Compute all NBA assignments from OLD state:
# Save old state for NBA evaluation # Save old state for NBA evaluation
@@ -195,16 +192,8 @@ class NCO:
if phase_valid: if phase_valid:
# Stage 1 NBA: phase_accum_reg <= phase_accumulator (old value) # 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: # 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 old_phase_accumulator = (self.phase_accumulator - ftw) & 0xFFFFFFFF # reconstruct
self.phase_accum_reg = old_phase_accumulator self.phase_accum_reg = old_phase_accumulator
self.phase_with_offset = ( self.phase_with_offset = (
@@ -706,7 +695,6 @@ class DDCInputInterface:
if old_valid_sync: if old_valid_sync:
ddc_i = sign_extend(ddc_i_18 & 0x3FFFF, 18) ddc_i = sign_extend(ddc_i_18 & 0x3FFFF, 18)
ddc_q = sign_extend(ddc_q_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] trunc_i = (ddc_i >> 2) & 0xFFFF # bits [17:2]
round_i = (ddc_i >> 1) & 1 # bit [1] round_i = (ddc_i >> 1) & 1 # bit [1]
trunc_q = (ddc_q >> 2) & 0xFFFF 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') filepath = os.path.join(base, '..', '..', 'fft_twiddle_1024.mem')
values = [] values = []
with open(filepath, 'r') as f: with open(filepath) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith('//'): if not line or line.startswith('//'):
@@ -760,11 +748,10 @@ def _twiddle_lookup(k, n, cos_rom):
if k == 0: if k == 0:
return cos_rom[0], 0 return cos_rom[0], 0
elif k == n4: if k == n4:
return 0, cos_rom[0] return 0, cos_rom[0]
elif k < n4: if k < n4:
return cos_rom[k], cos_rom[n4 - k] 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]
@@ -840,11 +827,9 @@ class FFTEngine:
# Multiply (49-bit products) # Multiply (49-bit products)
if not inverse: if not inverse:
# Forward: t = b * (cos + j*sin)
prod_re = b_re * tw_cos + b_im * tw_sin prod_re = b_re * tw_cos + b_im * tw_sin
prod_im = b_im * tw_cos - b_re * tw_sin prod_im = b_im * tw_cos - b_re * tw_sin
else: else:
# Inverse: t = b * (cos - j*sin)
prod_re = b_re * tw_cos - b_im * tw_sin prod_re = b_re * tw_cos - b_im * tw_sin
prod_im = b_im * tw_cos + b_re * tw_sin prod_im = b_im * tw_cos + b_re * tw_sin
@@ -923,9 +908,8 @@ class FreqMatchedFilter:
# Saturation check # Saturation check
if rounded > 0x3FFF8000: if rounded > 0x3FFF8000:
return 0x7FFF return 0x7FFF
elif rounded < -0x3FFF8000: if rounded < -0x3FFF8000:
return sign_extend(0x8000, 16) 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_re = round_sat_extract(real_sum)
@@ -1061,7 +1045,6 @@ class RangeBinDecimator:
out_im.append(best_im) out_im.append(best_im)
elif mode == 2: elif mode == 2:
# Averaging: sum >> 4
sum_re = 0 sum_re = 0
sum_im = 0 sum_im = 0
for s in range(df): for s in range(df):
@@ -1351,69 +1334,48 @@ def _self_test():
"""Quick sanity checks for each module.""" """Quick sanity checks for each module."""
import math import math
print("=" * 60)
print("FPGA Model Self-Test")
print("=" * 60)
# --- NCO test --- # --- NCO test ---
print("\n--- NCO Test ---")
nco = NCO() nco = NCO()
ftw = 0x4CCCCCCD # 120 MHz at 400 MSPS ftw = 0x4CCCCCCD # 120 MHz at 400 MSPS
# Run 20 cycles to fill pipeline # Run 20 cycles to fill pipeline
results = [] results = []
for i in range(20): for _ in range(20):
s, c, ready = nco.step(ftw) s, c, ready = nco.step(ftw)
if ready: if ready:
results.append((s, c)) results.append((s, c))
if results: 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 # Check quadrature: sin^2 + cos^2 should be approximately 32767^2
s, c = results[-1] s, c = results[-1]
mag_sq = s * s + c * c mag_sq = s * s + c * c
expected = 32767 * 32767 expected = 32767 * 32767
error_pct = abs(mag_sq - expected) / expected * 100 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")
# --- Mixer test --- # --- Mixer test ---
print("\n--- Mixer Test ---")
mixer = Mixer() mixer = Mixer()
# Test with mid-scale ADC (128) and known cos/sin # Test with mid-scale ADC (128) and known cos/sin
for i in range(5): for _ in range(5):
mi, mq, mv = mixer.step(128, 0x7FFF, 0, True, True) _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")
# --- CIC test --- # --- CIC test ---
print("\n--- CIC Test ---")
cic = CICDecimator() cic = CICDecimator()
dc_val = sign_extend(0x1000, 18) # Small positive DC dc_val = sign_extend(0x1000, 18) # Small positive DC
out_count = 0 out_count = 0
for i in range(100): for _ in range(100):
out, valid = cic.step(dc_val, True) _, valid = cic.step(dc_val, True)
if valid: if valid:
out_count += 1 out_count += 1
print(f" CIC: {out_count} outputs from 100 inputs (expect ~25 with 4x decimation + pipeline)")
print(" CIC: OK")
# --- FIR test --- # --- FIR test ---
print("\n--- FIR Test ---")
fir = FIRFilter() fir = FIRFilter()
out_count = 0 out_count = 0
for i in range(50): for _ in range(50):
out, valid = fir.step(1000, True) _out, valid = fir.step(1000, True)
if valid: if valid:
out_count += 1 out_count += 1
print(f" FIR: {out_count} outputs from 50 inputs (expect ~43 with 7-cycle latency)")
print(" FIR: OK")
# --- FFT test --- # --- FFT test ---
print("\n--- FFT Test (1024-pt) ---")
try: try:
fft = FFTEngine(n=1024) fft = FFTEngine(n=1024)
# Single tone at bin 10 # Single tone at bin 10
@@ -1425,43 +1387,28 @@ def _self_test():
out_re, out_im = fft.compute(in_re, in_im, inverse=False) out_re, out_im = fft.compute(in_re, in_im, inverse=False)
# Find peak bin # Find peak bin
max_mag = 0 max_mag = 0
peak_bin = 0
for i in range(512): for i in range(512):
mag = abs(out_re[i]) + abs(out_im[i]) mag = abs(out_re[i]) + abs(out_im[i])
if mag > max_mag: if mag > max_mag:
max_mag = mag max_mag = mag
peak_bin = i
print(f" FFT peak at bin {peak_bin} (expected 10), magnitude={max_mag}")
# IFFT roundtrip # IFFT roundtrip
rt_re, rt_im = fft.compute(out_re, out_im, inverse=True) 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)) 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")
except FileNotFoundError: except FileNotFoundError:
print(" FFT: SKIPPED (twiddle file not found)") pass
# --- Conjugate multiply test --- # --- Conjugate multiply test ---
print("\n--- Conjugate Multiply Test ---")
# (1+j0) * conj(1+j0) = 1+j0 # (1+j0) * conj(1+j0) = 1+j0
# In Q15: 32767 * 32767 -> should get close to 32767 # In Q15: 32767 * 32767 -> should get close to 32767
r, m = FreqMatchedFilter.conjugate_multiply_sample(0x7FFF, 0, 0x7FFF, 0) _r, _m = FreqMatchedFilter.conjugate_multiply_sample(0x7FFF, 0, 0x7FFF, 0)
print(f" (32767+j0) * conj(32767+j0) = {r}+j{m} (expect ~32767+j0)")
# (0+j32767) * conj(0+j32767) = (0+j32767)(0-j32767) = 32767^2 -> ~32767 # (0+j32767) * conj(0+j32767) = (0+j32767)(0-j32767) = 32767^2 -> ~32767
r2, m2 = FreqMatchedFilter.conjugate_multiply_sample(0, 0x7FFF, 0, 0x7FFF) _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")
# --- Range decimator test --- # --- Range decimator test ---
print("\n--- Range Bin Decimator Test ---")
test_re = list(range(1024)) test_re = list(range(1024))
test_im = [0] * 1024 test_im = [0] * 1024
out_re, out_im = RangeBinDecimator.decimate(test_re, test_im, mode=0) 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__': if __name__ == '__main__':
+12 -53
View File
@@ -82,8 +82,8 @@ def generate_full_long_chirp():
for n in range(LONG_CHIRP_SAMPLES): for n in range(LONG_CHIRP_SAMPLES):
t = n / FS_SYS t = n / FS_SYS
phase = math.pi * chirp_rate * t * t phase = math.pi * chirp_rate * t * t
re_val = int(round(Q15_MAX * SCALE * math.cos(phase))) re_val = round(Q15_MAX * SCALE * math.cos(phase))
im_val = int(round(Q15_MAX * SCALE * math.sin(phase))) im_val = round(Q15_MAX * SCALE * math.sin(phase))
chirp_i.append(max(-32768, min(32767, re_val))) chirp_i.append(max(-32768, min(32767, re_val)))
chirp_q.append(max(-32768, min(32767, im_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): for n in range(SHORT_CHIRP_SAMPLES):
t = n / FS_SYS t = n / FS_SYS
phase = math.pi * chirp_rate * t * t phase = math.pi * chirp_rate * t * t
re_val = int(round(Q15_MAX * SCALE * math.cos(phase))) re_val = round(Q15_MAX * SCALE * math.cos(phase))
im_val = int(round(Q15_MAX * SCALE * math.sin(phase))) im_val = round(Q15_MAX * SCALE * math.sin(phase))
chirp_i.append(max(-32768, min(32767, re_val))) chirp_i.append(max(-32768, min(32767, re_val)))
chirp_q.append(max(-32768, min(32767, im_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: with open(path, 'w') as f:
for v in values: for v in values:
f.write(to_hex16(v) + '\n') f.write(to_hex16(v) + '\n')
print(f" Wrote {filename}: {len(values)} entries")
def main(): 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 ---- # ---- Long chirp ----
print("Generating full long chirp (3000 samples)...")
long_i, long_q = generate_full_long_chirp() long_i, long_q = generate_full_long_chirp()
# Verify first sample matches generate_reference_chirp_q15() from radar_scene.py # Verify first sample matches generate_reference_chirp_q15() from radar_scene.py
# (which only generates the first 1024 samples) # (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 # Segment into 4 x 1024 blocks
print()
print("Segmenting into 4 x 1024 blocks...")
for seg in range(LONG_SEGMENTS): for seg in range(LONG_SEGMENTS):
start = seg * FFT_SIZE start = seg * FFT_SIZE
end = start + FFT_SIZE end = start + FFT_SIZE
@@ -177,27 +154,18 @@ def main():
seg_i.append(0) seg_i.append(0)
seg_q.append(0) seg_q.append(0)
zero_count = FFT_SIZE - valid_count FFT_SIZE - valid_count
print(f" Seg {seg}: indices [{start}:{end-1}], "
f"valid={valid_count}, zeros={zero_count}")
write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i) write_mem_file(f"long_chirp_seg{seg}_i.mem", seg_i)
write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q) write_mem_file(f"long_chirp_seg{seg}_q.mem", seg_q)
# ---- Short chirp ---- # ---- Short chirp ----
print()
print("Generating short chirp (50 samples)...")
short_i, short_q = generate_short_chirp() 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_i.mem", short_i)
write_mem_file("short_chirp_q.mem", short_q) write_mem_file("short_chirp_q.mem", short_q)
# ---- Verification summary ---- # ---- Verification summary ----
print()
print("=" * 60)
print("Verification:")
# Cross-check seg0 against radar_scene.py generate_reference_chirp_q15() # Cross-check seg0 against radar_scene.py generate_reference_chirp_q15()
# That function generates exactly the first 1024 samples of the chirp # That function generates exactly the first 1024 samples of the chirp
@@ -206,39 +174,30 @@ def main():
for n in range(FFT_SIZE): for n in range(FFT_SIZE):
t = n / FS_SYS t = n / FS_SYS
phase = math.pi * chirp_rate * t * t phase = math.pi * chirp_rate * t * t
expected_i = max(-32768, min(32767, int(round(Q15_MAX * SCALE * math.cos(phase))))) expected_i = max(-32768, min(32767, round(Q15_MAX * SCALE * math.cos(phase))))
expected_q = max(-32768, min(32767, int(round(Q15_MAX * SCALE * math.sin(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: if long_i[n] != expected_i or long_q[n] != expected_q:
mismatches += 1 mismatches += 1
if mismatches == 0: if mismatches == 0:
print(" [PASS] Seg0 matches radar_scene.py generate_reference_chirp_q15()") pass
else: else:
print(f" [FAIL] Seg0 has {mismatches} mismatches vs generate_reference_chirp_q15()")
return 1 return 1
# Check magnitude envelope # Check magnitude envelope
max_mag = max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q)) max(math.sqrt(i*i + q*q) for i, q in zip(long_i, long_q, strict=False))
print(f" Max magnitude: {max_mag:.1f} (expected ~{Q15_MAX * SCALE:.1f})")
print(f" Magnitude ratio: {max_mag / (Q15_MAX * SCALE):.6f}")
# Check seg3 zero padding # Check seg3 zero padding
seg3_i_path = os.path.join(MEM_DIR, 'long_chirp_seg3_i.mem') 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()] seg3_lines = [line.strip() for line in f if line.strip()]
nonzero_seg3 = sum(1 for line in seg3_lines if line != '0000') 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: if nonzero_seg3 == 0:
print(" [PASS] Seg3 is all zeros (chirp 3000 samples < seg3 start 3072)") pass
else: 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 return 0
@@ -51,7 +51,6 @@ def write_hex_32bit(filepath, samples):
for (i_val, q_val) in samples: for (i_val, q_val) in samples:
packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF) packed = ((q_val & 0xFFFF) << 16) | (i_val & 0xFFFF)
f.write(f"{packed:08X}\n") f.write(f"{packed:08X}\n")
print(f" Wrote {len(samples)} packed samples to {filepath}")
def write_csv(filepath, headers, *columns): def write_csv(filepath, headers, *columns):
@@ -61,7 +60,6 @@ def write_csv(filepath, headers, *columns):
for i in range(len(columns[0])): for i in range(len(columns[0])):
row = ','.join(str(col[i]) for col in columns) row = ','.join(str(col[i]) for col in columns)
f.write(row + '\n') f.write(row + '\n')
print(f" Wrote {len(columns[0])} rows to {filepath}")
def write_hex_16bit(filepath, data): def write_hex_16bit(filepath, data):
@@ -118,22 +116,19 @@ SCENARIOS = {
def generate_scenario(name, targets, description, base_dir): def generate_scenario(name, targets, description, base_dir):
"""Generate input hex + golden output for one scenario.""" """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) # Generate Doppler frame (32 chirps x 64 range bins)
frame_i, frame_q = generate_doppler_frame(targets, seed=42) 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}) ---- # ---- Write input hex file (packed 32-bit: {Q, I}) ----
# RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ... # RTL expects data streamed chirp-by-chirp: chirp0[rb0..rb63], chirp1[rb0..rb63], ...
packed_samples = [] packed_samples = []
for chirp in range(CHIRPS_PER_FRAME): for chirp in range(CHIRPS_PER_FRAME):
for rb in range(RANGE_BINS): packed_samples.extend(
packed_samples.append((frame_i[chirp][rb], frame_q[chirp][rb])) (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") input_hex = os.path.join(base_dir, f"doppler_input_{name}.hex")
write_hex_32bit(input_hex, packed_samples) write_hex_32bit(input_hex, packed_samples)
@@ -142,8 +137,6 @@ def generate_scenario(name, targets, description, base_dir):
dp = DopplerProcessor() dp = DopplerProcessor()
doppler_i, doppler_q = dp.process_frame(frame_i, frame_q) 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 ---- # ---- Write golden output CSV ----
# Format: range_bin, doppler_bin, out_i, out_q # 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) ---- # ---- Write golden hex (for optional RTL $readmemh comparison) ----
golden_hex = os.path.join(base_dir, f"doppler_golden_py_{name}.hex") 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 ---- # ---- Find peak per range bin ----
print("\n Peak Doppler bins per range bin (top 5 by magnitude):")
peak_info = [] peak_info = []
for rbin in range(RANGE_BINS): for rbin in range(RANGE_BINS):
mags = [abs(doppler_i[rbin][d]) + abs(doppler_q[rbin][d]) 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 # Sort by magnitude descending, show top 5
peak_info.sort(key=lambda x: -x[2]) peak_info.sort(key=lambda x: -x[2])
for rbin, dbin, mag in peak_info[:5]: for rbin, dbin, _mag in peak_info[:5]:
i_val = doppler_i[rbin][dbin] doppler_i[rbin][dbin]
q_val = doppler_q[rbin][dbin] doppler_q[rbin][dbin]
sf = dbin // DOPPLER_FFT_SIZE dbin // DOPPLER_FFT_SIZE
bin_in_sf = dbin % DOPPLER_FFT_SIZE 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}")
return { return {
'name': name, 'name': name,
@@ -200,10 +190,6 @@ def generate_scenario(name, targets, description, base_dir):
def main(): def main():
base_dir = os.path.dirname(os.path.abspath(__file__)) 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()) scenarios_to_run = list(SCENARIOS.keys())
@@ -221,17 +207,9 @@ def main():
r = generate_scenario(name, targets, description, base_dir) r = generate_scenario(name, targets, description, base_dir)
results.append(r) results.append(r)
print(f"\n{'='*60}") for _ in results:
print("Summary:") pass
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]}")
print(f"\nGenerated {len(results)} scenarios.")
print(f"Files written to: {base_dir}")
print("=" * 60)
if __name__ == '__main__': if __name__ == '__main__':
@@ -36,7 +36,7 @@ FFT_SIZE = 1024
def load_hex_16bit(filepath): def load_hex_16bit(filepath):
"""Load 16-bit hex file (one value per line, with optional // comments).""" """Load 16-bit hex file (one value per line, with optional // comments)."""
values = [] values = []
with open(filepath, 'r') as f: with open(filepath) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith('//'): 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. 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_i) == FFT_SIZE, f"sig_i length {len(sig_i)} != {FFT_SIZE}"
assert len(sig_q) == 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_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}_i.hex"), ref_i)
write_hex_16bit(os.path.join(outdir, f"mf_ref_{case_name}_q.hex"), ref_q) 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 # Run through bit-accurate Python model
mf = MatchedFilterChain(fft_size=FFT_SIZE) 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_mag = mag
peak_bin = k 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 # Save golden output hex
write_hex_16bit(os.path.join(outdir, f"mf_golden_py_i_{case_name}.hex"), out_i) 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(): def main():
base_dir = os.path.dirname(os.path.abspath(__file__)) 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 = [] results = []
@@ -158,8 +148,7 @@ def main():
base_dir) base_dir)
results.append(r) results.append(r)
else: else:
print("\nWARNING: bb_mf_test / ref_chirp hex files not found.") pass
print("Run radar_scene.py first.")
# ---- Case 2: DC autocorrelation ---- # ---- Case 2: DC autocorrelation ----
dc_val = 0x1000 # 4096 dc_val = 0x1000 # 4096
@@ -191,8 +180,8 @@ def main():
sig_q = [] sig_q = []
for n in range(FFT_SIZE): for n in range(FFT_SIZE):
angle = 2.0 * math.pi * k * n / FFT_SIZE angle = 2.0 * math.pi * k * n / FFT_SIZE
sig_i.append(saturate(int(round(amp * math.cos(angle))), 16)) sig_i.append(saturate(round(amp * math.cos(angle)), 16))
sig_q.append(saturate(int(round(amp * math.sin(angle))), 16)) sig_q.append(saturate(round(amp * math.sin(angle)), 16))
ref_i = list(sig_i) ref_i = list(sig_i)
ref_q = list(sig_q) ref_q = list(sig_q)
r = generate_case("tone5", sig_i, sig_q, ref_i, ref_q, r = generate_case("tone5", sig_i, sig_q, ref_i, ref_q,
@@ -201,16 +190,9 @@ def main():
results.append(r) results.append(r)
# ---- Summary ---- # ---- Summary ----
print("\n" + "=" * 60) for _ in results:
print("Summary:") pass
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']}")
print(f"\nGenerated {len(results)} golden reference cases.")
print("Files written to:", base_dir)
print("=" * 60)
if __name__ == '__main__': if __name__ == '__main__':
@@ -5,7 +5,7 @@ gen_multiseg_golden.py
Generate golden reference data for matched_filter_multi_segment co-simulation. Generate golden reference data for matched_filter_multi_segment co-simulation.
Tests the overlap-save segmented convolution wrapper: 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) - Short chirp: 50 samples zero-padded to 1024 (1 segment)
The matched_filter_processing_chain is already verified bit-perfect. 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: # In radar_receiver_final.v, the DDC output is sign-extended:
# .ddc_i({{2{adc_i_scaled[15]}}, adc_i_scaled}) # .ddc_i({{2{adc_i_scaled[15]}}, adc_i_scaled})
# So 16-bit -> 18-bit sign-extend -> then multi_segment does: # 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: # For sign-extended 18-bit from 16-bit:
# ddc_i[17:2] = original 16-bit value (since bits [17:16] = sign extension) # ddc_i[17:2] = original 16-bit value (since bits [17:16] = sign extension)
# ddc_i[1] = bit 1 of original value # 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) out_re, out_im = mf_chain.process(seg_data_i, seg_data_q, ref_i, ref_q)
segment_results.append((out_re, out_im)) 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 # Write hex files for the testbench
out_dir = os.path.dirname(os.path.abspath(__file__)) out_dir = os.path.dirname(os.path.abspath(__file__))
@@ -317,7 +313,6 @@ def generate_long_chirp_test():
for b in range(1024): for b in range(1024):
f.write(f'{seg},{b},{out_re[b]},{out_im[b]}\n') 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 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) # 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 # 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_i = list(input_i) + [0] * (BUFFER_SIZE - SHORT_SAMPLES)
_padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES) # noqa: F841 _padded_q = list(input_q) + [0] * (BUFFER_SIZE - SHORT_SAMPLES)
# The buffer truncation: ddc_i[17:2] + ddc_i[1] # The buffer truncation: ddc_i[17:2] + ddc_i[1]
# For data already 16-bit sign-extended to 18: result is (val >> 2) + bit1 # 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 # Write hex files
out_dir = os.path.dirname(os.path.abspath(__file__)) out_dir = os.path.dirname(os.path.abspath(__file__))
# Input (18-bit)
all_input_i_18 = [] all_input_i_18 = []
all_input_q_18 = [] all_input_q_18 = []
for n in range(SHORT_SAMPLES): for n in range(SHORT_SAMPLES):
@@ -403,19 +397,12 @@ def generate_short_chirp_test():
for b in range(1024): for b in range(1024):
f.write(f'{b},{out_re[b]},{out_im[b]}\n') 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 return out_re, out_im
if __name__ == '__main__': 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() 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): for seg in range(num_segs):
out_re, out_im = seg_results[seg] out_re, out_im = seg_results[seg]
@@ -427,9 +414,7 @@ if __name__ == '__main__':
if mag > max_mag: if mag > max_mag:
max_mag = mag max_mag = mag
peak_bin = b 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() short_re, short_im = generate_short_chirp_test()
max_mag = 0 max_mag = 0
peak_bin = 0 peak_bin = 0
@@ -438,8 +423,3 @@ if __name__ == '__main__':
if mag > max_mag: if mag > max_mag:
max_mag = mag max_mag = mag
peak_bin = b 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 t = n / fs
# Instantaneous frequency: f_if - chirp_bw/2 + chirp_rate * t # Instantaneous frequency: f_if - chirp_bw/2 + chirp_rate * t
# Phase: integral of 2*pi*f(t)*dt # 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 phase = 2 * math.pi * (f_if - chirp_bw / 2) * t + math.pi * chirp_rate * t * t
chirp_i.append(math.cos(phase)) chirp_i.append(math.cos(phase))
chirp_q.append(math.sin(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 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. 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 # 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) # Reference chirp is the TX chirp at baseband (zero delay)
phase = math.pi * chirp_rate * t * t phase = math.pi * chirp_rate * t * t
re_val = int(round(32767 * 0.9 * math.cos(phase))) re_val = round(32767 * 0.9 * math.cos(phase))
im_val = int(round(32767 * 0.9 * math.sin(phase))) im_val = round(32767 * 0.9 * math.sin(phase))
ref_re[n] = max(-32768, min(32767, re_val)) ref_re[n] = max(-32768, min(32767, re_val))
ref_im[n] = max(-32768, min(32767, im_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 # Quantize to 8-bit unsigned (0-255), centered at 128
adc_samples = [] adc_samples = []
for val in adc_float: for val in adc_float:
quantized = int(round(val + 128)) quantized = round(val + 128)
quantized = max(0, min(255, quantized)) quantized = max(0, min(255, quantized))
adc_samples.append(quantized) adc_samples.append(quantized)
@@ -346,8 +346,8 @@ def generate_baseband_samples(targets, n_samples_baseband, noise_stddev=0.5,
bb_i = [] bb_i = []
bb_q = [] bb_q = []
for n in range(n_samples_baseband): for n in range(n_samples_baseband):
i_val = int(round(bb_i_float[n] + noise_stddev * rand_gaussian())) i_val = round(bb_i_float[n] + noise_stddev * rand_gaussian())
q_val = int(round(bb_q_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_i.append(max(-32768, min(32767, i_val)))
bb_q.append(max(-32768, min(32767, q_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: for target in targets:
# Which range bin does this target fall in? # Which range bin does this target fall in?
# After matched filter + range decimation: # After matched filter + range decimation:
# range_bin = target_delay_in_baseband_samples / decimation_factor
delay_baseband_samples = target.delay_s * FS_SYS delay_baseband_samples = target.delay_s * FS_SYS
range_bin_float = delay_baseband_samples * n_range_bins / FFT_SIZE 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: if range_bin < 0 or range_bin >= n_range_bins:
continue continue
# Amplitude (simplified)
amp = target.amplitude / 4.0 amp = target.amplitude / 4.0
# Doppler phase for this chirp. # Doppler phase for this chirp.
@@ -426,10 +424,7 @@ def generate_doppler_frame(targets, n_chirps=CHIRPS_PER_FRAME,
rb = range_bin + delta rb = range_bin + delta
if 0 <= rb < n_range_bins: if 0 <= rb < n_range_bins:
# sinc-like weighting # sinc-like weighting
if delta == 0: weight = 1.0 if delta == 0 else 0.2 / abs(delta)
weight = 1.0
else:
weight = 0.2 / abs(delta)
chirp_i[rb] += amp * weight * math.cos(total_phase) chirp_i[rb] += amp * weight * math.cos(total_phase)
chirp_q[rb] += amp * weight * math.sin(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_i = []
row_q = [] row_q = []
for rb in range(n_range_bins): for rb in range(n_range_bins):
i_val = int(round(chirp_i[rb] + noise_stddev * rand_gaussian())) i_val = round(chirp_i[rb] + noise_stddev * rand_gaussian())
q_val = int(round(chirp_q[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_i.append(max(-32768, min(32767, i_val)))
row_q.append(max(-32768, min(32767, q_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: with open(filepath, 'w') as f:
f.write(f"// {len(samples)} samples, {bits}-bit, hex format for $readmemh\n") 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: if bits <= 8:
val = s & 0xFF val = s & 0xFF
elif bits <= 16: elif bits <= 16:
@@ -477,7 +472,6 @@ def write_hex_file(filepath, samples, bits=8):
val = s & ((1 << bits) - 1) val = s & ((1 << bits) - 1)
f.write(fmt.format(val) + "\n") f.write(fmt.format(val) + "\n")
print(f" Wrote {len(samples)} samples to {filepath}")
def write_csv_file(filepath, columns, headers=None): 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] row = [str(col[i]) for col in columns]
f.write(",".join(row) + "\n") 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. Good for validating matched filter range response.
""" """
target = Target(range_m=range_m, velocity_mps=velocity, rcs_dbsm=rcs) 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) adc = generate_adc_samples([target], n_adc_samples, noise_stddev=2.0)
return adc, [target] 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=300, velocity_mps=0, rcs_dbsm=10, phase_deg=0),
Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45), Target(range_m=315, velocity_mps=0, rcs_dbsm=10, phase_deg=45),
] ]
print("Scenario: Two targets (range resolution test)") for _t in targets:
for t in targets: pass
print(f" {t}")
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0) adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=2.0)
return adc, targets 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=2000, velocity_mps=50, rcs_dbsm=0, phase_deg=45),
Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270), Target(range_m=5000, velocity_mps=-5, rcs_dbsm=-5, phase_deg=270),
] ]
print("Scenario: Multi-target (5 targets)") for _t in targets:
for t in targets: pass
print(f" {t}")
adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0) adc = generate_adc_samples(targets, n_adc_samples, noise_stddev=3.0)
return adc, targets 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. 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) adc = generate_adc_samples([], n_adc_samples, noise_stddev=noise_stddev)
return adc, [] 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. DC input — validates CIC decimation and DC response.
""" """
print(f"Scenario: DC tone (ADC value={adc_value})")
return [adc_value] * n_adc_samples, [] 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. 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 = [] adc = []
for n in range(n_adc_samples): for n in range(n_adc_samples):
t = n / FS_ADC 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))) adc.append(max(0, min(255, val)))
return adc, [] return adc, []
@@ -606,46 +590,35 @@ def generate_all_test_vectors(output_dir=None):
if output_dir is None: if output_dir is None:
output_dir = os.path.dirname(os.path.abspath(__file__)) 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 n_adc = 16384 # ~41 us of ADC data
# --- Scenario 1: Single target --- # --- Scenario 1: Single target ---
print("\n--- Scenario 1: Single Target ---")
adc1, targets1 = scenario_single_target(range_m=500, n_adc_samples=n_adc) 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) write_hex_file(os.path.join(output_dir, "adc_single_target.hex"), adc1, bits=8)
# --- Scenario 2: Multi-target --- # --- Scenario 2: Multi-target ---
print("\n--- Scenario 2: Multi-Target ---")
adc2, targets2 = scenario_multi_target(n_adc_samples=n_adc) 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) write_hex_file(os.path.join(output_dir, "adc_multi_target.hex"), adc2, bits=8)
# --- Scenario 3: Noise only --- # --- Scenario 3: Noise only ---
print("\n--- Scenario 3: Noise Only ---")
adc3, _ = scenario_noise_only(n_adc_samples=n_adc) adc3, _ = scenario_noise_only(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8) write_hex_file(os.path.join(output_dir, "adc_noise_only.hex"), adc3, bits=8)
# --- Scenario 4: DC --- # --- Scenario 4: DC ---
print("\n--- Scenario 4: DC Input ---")
adc4, _ = scenario_dc_tone(n_adc_samples=n_adc) adc4, _ = scenario_dc_tone(n_adc_samples=n_adc)
write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8) write_hex_file(os.path.join(output_dir, "adc_dc.hex"), adc4, bits=8)
# --- Scenario 5: Sine wave --- # --- 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) 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) write_hex_file(os.path.join(output_dir, "adc_sine_1mhz.hex"), adc5, bits=8)
# --- Reference chirp for matched filter --- # --- Reference chirp for matched filter ---
print("\n--- Reference Chirp ---")
ref_re, ref_im = generate_reference_chirp_q15() 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_i.hex"), ref_re, bits=16)
write_hex_file(os.path.join(output_dir, "ref_chirp_q.hex"), ref_im, 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) --- # --- Baseband samples for matched filter test (bypass DDC) ---
print("\n--- Baseband Samples (bypass DDC) ---")
bb_targets = [ bb_targets = [
Target(range_m=500, velocity_mps=0, rcs_dbsm=10), Target(range_m=500, velocity_mps=0, rcs_dbsm=10),
Target(range_m=1500, velocity_mps=20, rcs_dbsm=5), 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) write_hex_file(os.path.join(output_dir, "bb_mf_test_q.hex"), bb_q, bits=16)
# --- Scenario info CSV --- # --- Scenario info CSV ---
print("\n--- Scenario Info ---")
with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f: with open(os.path.join(output_dir, "scenario_info.txt"), 'w') as f:
f.write("AERIS-10 Test Vector Scenarios\n") f.write("AERIS-10 Test Vector Scenarios\n")
f.write("=" * 60 + "\n\n") f.write("=" * 60 + "\n\n")
@@ -685,11 +657,7 @@ def generate_all_test_vectors(output_dir=None):
for t in bb_targets: for t in bb_targets:
f.write(f" {t}\n") 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 { return {
'adc_single': adc1, 'adc_single': adc1,
@@ -69,7 +69,6 @@ FIR_COEFFS_HEX = [
# DDC output interface # DDC output interface
DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation DDC_OUT_BITS = 16 # 18 → 16 bit with rounding + saturation
# FFT (Range)
FFT_SIZE = 1024 FFT_SIZE = 1024
FFT_DATA_W = 16 FFT_DATA_W = 16
FFT_INTERNAL_W = 32 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 4. Upconvert to 120 MHz IF (add I*cos - Q*sin) to create real signal
5. Quantize to 8-bit unsigned (matching AD9484) 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) data = np.load(data_path, allow_pickle=True)
config = np.load(config_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 # Extract one frame
frame = data[frame_idx] # (256, 1079) complex frame = data[frame_idx] # (256, 1079) complex
# Use first 32 chirps, first 1024 samples # Use first 32 chirps, first 1024 samples
iq_block = frame[:DOPPLER_CHIRPS, :FFT_SIZE] # (32, 1024) complex 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. # The ADI data is baseband complex IQ at 4 MSPS.
# AERIS-10 sees a real signal at 400 MSPS with 120 MHz IF. # 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_i = np.clip(iq_i, -32768, 32767)
iq_q = np.clip(iq_q, -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 # Also create 8-bit ADC stimulus for DDC validation
# Use just one chirp of real-valued data (I channel only, shifted to unsigned) # 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 quadrant = (lut_address >> 6) & 0x3
# Mirror index for odd quadrants # Mirror index for odd quadrants
if (quadrant & 1) ^ ((quadrant >> 1) & 1): lut_idx = ~lut_address & 63 if quadrant & 1 ^ quadrant >> 1 & 1 else lut_address & 63
lut_idx = (~lut_address) & 0x3F
else:
lut_idx = lut_address & 0x3F
sin_abs = int(sin_lut[lut_idx]) sin_abs = int(sin_lut[lut_idx])
cos_abs = int(sin_lut[63 - 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 # Build FIR coefficients as signed integers
fir_coeffs = np.array([hex_to_signed(c, 18) for c in FIR_COEFFS_HEX], dtype=np.int64) 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 --- # --- NCO + Mixer ---
phase_accum = np.int64(0) phase_accum = np.int64(0)
@@ -327,7 +313,6 @@ def run_ddc(adc_samples):
# Phase accumulator update (ignore dithering for bit-accuracy) # Phase accumulator update (ignore dithering for bit-accuracy)
phase_accum = (phase_accum + NCO_PHASE_INC) & 0xFFFFFFFF 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) --- # --- CIC Decimator (5-stage, decimate-by-4) ---
# Integrator section (at 400 MHz rate) # Integrator section (at 400 MHz rate)
@@ -371,7 +356,6 @@ def run_ddc(adc_samples):
scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT scaled = comb[CIC_STAGES - 1][k] >> CIC_GAIN_SHIFT
cic_output[k] = saturate(scaled, CIC_OUT_BITS) 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) --- # --- FIR Filter (32-tap) ---
delay_line = np.zeros(FIR_TAPS, dtype=np.int64) delay_line = np.zeros(FIR_TAPS, dtype=np.int64)
@@ -393,7 +377,6 @@ def run_ddc(adc_samples):
if fir_output[k] >= (1 << 17): if fir_output[k] >= (1 << 17):
fir_output[k] -= (1 << 18) fir_output[k] -= (1 << 18)
print(f" FIR output: range [{fir_output.min()}, {fir_output.max()}]")
# --- DDC Interface (18 → 16 bit) --- # --- DDC Interface (18 → 16 bit) ---
ddc_output = np.zeros(n_decimated, dtype=np.int64) ddc_output = np.zeros(n_decimated, dtype=np.int64)
@@ -410,7 +393,6 @@ def run_ddc(adc_samples):
else: else:
ddc_output[k] = saturate(trunc + round_bit, 16) ddc_output[k] = saturate(trunc + round_bit, 16)
print(f" DDC output (16-bit): range [{ddc_output.min()}, {ddc_output.max()}]")
return ddc_output return ddc_output
@@ -421,7 +403,7 @@ def run_ddc(adc_samples):
def load_twiddle_rom(twiddle_file): def load_twiddle_rom(twiddle_file):
"""Load the quarter-wave cosine ROM from .mem file.""" """Load the quarter-wave cosine ROM from .mem file."""
rom = [] rom = []
with open(twiddle_file, 'r') as f: with open(twiddle_file) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith('//'): 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 # 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) 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 # Bit-reverse and sign-extend to 32-bit internal width
def bit_reverse(val, bits): 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_re = mem_re[addr_odd]
b_im = mem_im[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_re = b_re * tw_cos + b_im * tw_sin
prod_im = b_im * tw_cos - b_re * 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_re[n] = saturate(mem_re[n], FFT_DATA_W)
out_im[n] = saturate(mem_im[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 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_i = np.zeros((n_chirps, output_bins), dtype=np.int64)
decimated_q = 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): for c in range(n_chirps):
# Index into input, skip start_bin # 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) # Averaging: sum group, then >> 4 (divide by 16)
sum_i = np.int64(0) sum_i = np.int64(0)
sum_q = np.int64(0) sum_q = np.int64(0)
for s in range(decimation_factor): for _ in range(decimation_factor):
if in_idx >= input_bins: if in_idx >= input_bins:
break break
sum_i += int(range_fft_i[c, in_idx]) 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_i[c, obin] = int(sum_i) >> 4
decimated_q[c, obin] = int(sum_q) >> 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 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_total = DOPPLER_TOTAL_BINS
n_sf = CHIRPS_PER_SUBFRAME 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 # Build 16-point Hamming window as signed 16-bit
hamming = np.array([int(v) for v in HAMMING_Q15], dtype=np.int64) 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_i[rbin, bin_offset + n] = saturate(mem_re[n], 16)
doppler_map_q[rbin, bin_offset + n] = saturate(mem_im[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 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_i = np.zeros_like(decim_i)
mti_q = np.zeros_like(decim_q) 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: if not enable:
mti_i[:] = decim_i mti_i[:] = decim_i
mti_q[:] = decim_q mti_q[:] = decim_q
print(" Pass-through mode (MTI disabled)")
return mti_i, mti_q return mti_i, mti_q
for c in range(n_chirps): 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_i[c, r] = saturate(diff_i, 16)
mti_q[c, r] = saturate(diff_q, 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 return mti_i, mti_q
@@ -838,17 +798,12 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
dc_notch_active = (width != 0) && dc_notch_active = (width != 0) &&
(bin_within_sf < width || bin_within_sf > (15 - width + 1)) (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_i = doppler_i.copy()
notched_q = doppler_q.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: if width == 0:
print(" Pass-through (width=0)")
return notched_i, notched_q return notched_i, notched_q
zeroed_count = 0 zeroed_count = 0
@@ -860,7 +815,6 @@ def run_dc_notch(doppler_i, doppler_q, width=2):
notched_q[:, dbin] = 0 notched_q[:, dbin] = 0
zeroed_count += 1 zeroed_count += 1
print(f" Zeroed {zeroed_count} Doppler bin columns")
return notched_i, notched_q 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) # Stage 3e: CA-CFAR Detector (bit-accurate)
# =========================================================================== # ===========================================================================
def run_cfar_ca(doppler_i, doppler_q, guard=2, train=8, 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. 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: if train == 0:
train = 1 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) # 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 # 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: else:
noise_sum = leading_sum + lagging_sum # Default to CA 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 noise_product = alpha_q44 * noise_sum
threshold_raw = noise_product >> ALPHA_FRAC_BITS threshold_raw = noise_product >> ALPHA_FRAC_BITS
# Saturate to MAG_WIDTH=17 bits # Saturate to MAG_WIDTH=17 bits
MAX_MAG = (1 << 17) - 1 # 131071 MAX_MAG = (1 << 17) - 1 # 131071
if threshold_raw > MAX_MAG: threshold_val = MAX_MAG if threshold_raw > MAX_MAG else int(threshold_raw)
threshold_val = MAX_MAG
else:
threshold_val = int(threshold_raw)
# Detection: magnitude > threshold
if int(col[cut_idx]) > threshold_val: if int(col[cut_idx]) > threshold_val:
detect_flags[cut_idx, dbin] = True detect_flags[cut_idx, dbin] = True
total_detections += 1 total_detections += 1
thresholds[cut_idx, dbin] = threshold_val 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 return detect_flags, magnitudes, thresholds
@@ -1012,19 +953,16 @@ def run_detection(doppler_i, doppler_q, threshold=10000):
cfar_mag = |I| + |Q| (17-bit) cfar_mag = |I| + |Q| (17-bit)
detection if cfar_mag > threshold 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|) mag = np.abs(doppler_i) + np.abs(doppler_q) # L1 norm (|I| + |Q|)
detections = np.argwhere(mag > threshold) detections = np.argwhere(mag > threshold)
print(f" {len(detections)} detections found")
for d in detections[:20]: # Print first 20 for d in detections[:20]: # Print first 20
rbin, dbin = d rbin, dbin = d
m = mag[rbin, dbin] mag[rbin, dbin]
print(f" Range bin {rbin}, Doppler bin {dbin}: magnitude {m}")
if len(detections) > 20: if len(detections) > 20:
print(f" ... and {len(detections) - 20} more") pass
return mag, detections 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 Uses the exact same RTL Hamming window coefficients (Q15) to isolate
only the FFT fixed-point quantization error. 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) 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') fi.write(signed_to_hex(int(iq_i[n]), 16) + '\n')
fq.write(signed_to_hex(int(iq_q[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: elif iq_i.ndim == 2:
n_rows, n_cols = iq_i.shape 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') fi.write(signed_to_hex(int(iq_i[r, c]), 16) + '\n')
fq.write(signed_to_hex(int(iq_q[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"): 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)): for n in range(len(adc_data)):
f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n') f.write(format(int(adc_data[n]) & 0xFF, '02X') + '\n')
print(f" Wrote {fn} ({len(adc_data)} samples)")
# =========================================================================== # ===========================================================================
# Comparison metrics # 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. """Compare fixed-point outputs against floating-point reference.
Reports two metrics: Reports two metrics:
@@ -1136,7 +1068,7 @@ def compare_outputs(name, fixed_i, fixed_q, float_i, float_q):
# Count saturated bins # Count saturated bins
sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767) sat_mask = (np.abs(fi) >= 32767) | (np.abs(fq) >= 32767)
n_saturated = np.sum(sat_mask) np.sum(sat_mask)
# Complex error — overall # Complex error — overall
fixed_complex = fi + 1j * fq 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 signal_power = np.mean(np.abs(ref_complex) ** 2) + 1e-30
noise_power = np.mean(np.abs(error) ** 2) + 1e-30 noise_power = np.mean(np.abs(error) ** 2) + 1e-30
snr_db = 10 * np.log10(signal_power / noise_power) 10 * np.log10(signal_power / noise_power)
max_error = np.max(np.abs(error)) np.max(np.abs(error))
# Non-saturated comparison # Non-saturated comparison
non_sat = ~sat_mask 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 sig_ns = np.mean(np.abs(ref_complex[non_sat]) ** 2) + 1e-30
noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30 noise_ns = np.mean(np.abs(error_ns) ** 2) + 1e-30
snr_ns = 10 * np.log10(sig_ns / noise_ns) 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: else:
snr_ns = 0.0 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 return snr_ns # Return the meaningful metric
@@ -1198,29 +1123,19 @@ def main():
twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem") twiddle_1024 = os.path.join(fpga_dir, "fft_twiddle_1024.mem")
output_dir = os.path.join(script_dir, "hex") 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 # 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 amp_data, amp_config, frame_idx=args.frame
) )
# iq_i, iq_q: (32, 1024) int64, 16-bit range — post-DDC equivalent # 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 # 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) # Post-DDC IQ for each chirp (for FFT + Doppler validation)
write_hex_files(output_dir, iq_i, iq_q, "post_ddc") 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) # 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) 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") 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_i = np.zeros((DOPPLER_CHIRPS, FFT_SIZE), dtype=np.int64)
all_range_q = 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): for c in range(DOPPLER_CHIRPS):
ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024) ri, rq = run_range_fft(iq_i[c], iq_q[c], twiddle_1024)
all_range_i[c] = ri all_range_i[c] = ri
all_range_q[c] = rq all_range_q[c] = rq
if (c + 1) % 8 == 0: if (c + 1) % 8 == 0:
print(f" Chirp {c + 1}/{DOPPLER_CHIRPS} done") pass
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Run Doppler FFT (bit-accurate) — "direct" path (first 64 bins) # 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") 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) 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") write_hex_files(output_dir, doppler_i, doppler_q, "doppler_map")
@@ -1266,8 +1175,6 @@ def main():
# This models the actual RTL data flow: # This models the actual RTL data flow:
# range FFT → range_bin_decimator (peak detection) → Doppler # 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( decim_i, decim_q = run_range_bin_decimator(
all_range_i, all_range_q, all_range_i, all_range_q,
@@ -1287,14 +1194,11 @@ def main():
q_val = int(all_range_q[c, b]) & 0xFFFF q_val = int(all_range_q[c, b]) & 0xFFFF
packed = (q_val << 16) | i_val packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n") 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 decimated output reference for standalone decimator test
write_hex_files(output_dir, decim_i, decim_q, "decimated_range") write_hex_files(output_dir, decim_i, decim_q, "decimated_range")
# Now run Doppler on the decimated data — this is the full-chain reference # 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( fc_doppler_i, fc_doppler_q = run_doppler_fft(
decim_i, decim_q, twiddle_file_16=twiddle_16 decim_i, decim_q, twiddle_file_16=twiddle_16
) )
@@ -1309,10 +1213,6 @@ def main():
q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF q_val = int(fc_doppler_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n") 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 # Save numpy arrays for the full-chain path
np.save(os.path.join(output_dir, "decimated_range_i.npy"), decim_i) 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: # This models the complete RTL data flow:
# range FFT → decimator → MTI canceller → Doppler → DC notch → CFAR # 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) 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") 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_i.npy"), mti_i)
np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q) np.save(os.path.join(output_dir, "fullchain_mti_q.npy"), mti_q)
# Doppler on MTI-filtered data # 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_doppler_i, mti_doppler_q = run_doppler_fft(
mti_i, mti_q, twiddle_file_16=twiddle_16 mti_i, mti_q, twiddle_file_16=twiddle_16
) )
@@ -1344,8 +1240,6 @@ def main():
# DC notch on MTI-Doppler data # DC notch on MTI-Doppler data
DC_NOTCH_WIDTH = 2 # Default test value: zero bins {0, 1, 31} 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) 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") 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 q_val = int(notched_q[rbin, dbin]) & 0xFFFF
packed = (q_val << 16) | i_val packed = (q_val << 16) | i_val
f.write(f"{packed:08X}\n") 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 on DC-notched data
CFAR_GUARD = 2 CFAR_GUARD = 2
CFAR_TRAIN = 8 CFAR_TRAIN = 8
CFAR_ALPHA = 0x30 # Q4.4 = 3.0 CFAR_ALPHA = 0x30 # Q4.4 = 3.0
CFAR_MODE = 'CA' 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( cfar_flags, cfar_mag, cfar_thr = run_cfar_ca(
notched_i, notched_q, notched_i, notched_q,
guard=CFAR_GUARD, train=CFAR_TRAIN, guard=CFAR_GUARD, train=CFAR_TRAIN,
@@ -1384,7 +1272,6 @@ def main():
for dbin in range(DOPPLER_TOTAL_BINS): for dbin in range(DOPPLER_TOTAL_BINS):
m = int(cfar_mag[rbin, dbin]) & 0x1FFFF m = int(cfar_mag[rbin, dbin]) & 0x1FFFF
f.write(f"{m:05X}\n") 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) # 2. Threshold map (17-bit unsigned)
cfar_thr_file = os.path.join(output_dir, "fullchain_cfar_thr.hex") 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): for dbin in range(DOPPLER_TOTAL_BINS):
t = int(cfar_thr[rbin, dbin]) & 0x1FFFF t = int(cfar_thr[rbin, dbin]) & 0x1FFFF
f.write(f"{t:05X}\n") 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) # 3. Detection flags (1-bit per cell)
cfar_det_file = os.path.join(output_dir, "fullchain_cfar_det.hex") 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): for dbin in range(DOPPLER_TOTAL_BINS):
d = 1 if cfar_flags[rbin, dbin] else 0 d = 1 if cfar_flags[rbin, dbin] else 0
f.write(f"{d:01X}\n") f.write(f"{d:01X}\n")
print(f" Wrote {cfar_det_file} ({DOPPLER_RANGE_BINS * DOPPLER_TOTAL_BINS} detection flags)")
# 4. Detection list (text) # 4. Detection list (text)
cfar_detections = np.argwhere(cfar_flags) cfar_detections = np.argwhere(cfar_flags)
@@ -1418,7 +1303,6 @@ def main():
for det in cfar_detections: for det in cfar_detections:
r, d = det r, d = det
f.write(f"{r} {d} {cfar_mag[r, d]} {cfar_thr[r, d]}\n") 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 # Save numpy arrays
np.save(os.path.join(output_dir, "fullchain_cfar_mag.npy"), cfar_mag) 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) np.save(os.path.join(output_dir, "fullchain_cfar_flags.npy"), cfar_flags)
# Run detection on full-chain Doppler map # 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) fc_mag, fc_detections = run_detection(fc_doppler_i, fc_doppler_q, threshold=args.threshold)
# Save full-chain detection reference # Save full-chain detection reference
@@ -1439,7 +1321,6 @@ def main():
for d in fc_detections: for d in fc_detections:
rbin, dbin = d rbin, dbin = d
f.write(f"{rbin} {dbin} {fc_mag[rbin, dbin]}\n") 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 # Also write detection reference as hex for RTL comparison
fc_det_mag_file = os.path.join(output_dir, "fullchain_detection_mag.hex") 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): for dbin in range(DOPPLER_TOTAL_BINS):
m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned m = int(fc_mag[rbin, dbin]) & 0x1FFFF # 17-bit unsigned
f.write(f"{m:05X}\n") 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) # 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) mag, detections = run_detection(doppler_i, doppler_q, threshold=args.threshold)
# Save detection list # Save detection list
@@ -1466,26 +1344,23 @@ def main():
for d in detections: for d in detections:
rbin, dbin = d rbin, dbin = d
f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n") f.write(f"{rbin} {dbin} {mag[rbin, dbin]}\n")
print(f" Wrote {det_file} ({len(detections)} detections)")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Float reference and comparison # 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) range_fft_float, doppler_float = run_float_reference(iq_i, iq_q)
# Compare range FFT (chirp 0) # Compare range FFT (chirp 0)
float_range_i = np.real(range_fft_float[0, :]).astype(np.float64) float_range_i = np.real(range_fft_float[0, :]).astype(np.float64)
float_range_q = np.imag(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) float_range_i, float_range_q)
# Compare Doppler map # Compare Doppler map
float_doppler_i = np.real(doppler_float).flatten().astype(np.float64) float_doppler_i = np.real(doppler_float).flatten().astype(np.float64)
float_doppler_q = np.imag(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(), doppler_i.flatten(), doppler_q.flatten(),
float_doppler_i, float_doppler_q) 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_i.npy"), doppler_i)
np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q) np.save(os.path.join(output_dir, "doppler_map_q.npy"), doppler_q)
np.save(os.path.join(output_dir, "detection_mag.npy"), mag) np.save(os.path.join(output_dir, "detection_mag.npy"), mag)
print(f"\n Saved numpy reference files to {output_dir}/")
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
# Summary # 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 # Optional plots
@@ -1531,7 +1384,7 @@ def main():
try: try:
import matplotlib.pyplot as plt 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 FFT magnitude (chirp 0)
range_mag = np.sqrt(range_fft_i.astype(float)**2 + range_fft_q.astype(float)**2) 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() plt.tight_layout()
plot_file = os.path.join(output_dir, "golden_reference_plots.png") plot_file = os.path.join(output_dir, "golden_reference_plots.png")
plt.savefig(plot_file, dpi=150) plt.savefig(plot_file, dpi=150)
print(f"\n Saved plots to {plot_file}")
plt.show() plt.show()
except ImportError: except ImportError:
print("\n [WARN] matplotlib not available, skipping plots") pass
if __name__ == "__main__": if __name__ == "__main__":
File diff suppressed because it is too large Load Diff
@@ -44,25 +44,22 @@ pass_count = 0
fail_count = 0 fail_count = 0
warn_count = 0 warn_count = 0
def check(condition, label): def check(condition, _label):
global pass_count, fail_count global pass_count, fail_count
if condition: if condition:
print(f" [PASS] {label}")
pass_count += 1 pass_count += 1
else: else:
print(f" [FAIL] {label}")
fail_count += 1 fail_count += 1
def warn(label): def warn(_label):
global warn_count global warn_count
print(f" [WARN] {label}")
warn_count += 1 warn_count += 1
def read_mem_hex(filename): def read_mem_hex(filename):
"""Read a .mem file, return list of integer values (16-bit signed).""" """Read a .mem file, return list of integer values (16-bit signed)."""
path = os.path.join(MEM_DIR, filename) path = os.path.join(MEM_DIR, filename)
values = [] values = []
with open(path, 'r') as f: with open(path) as f:
for line in f: for line in f:
line = line.strip() line = line.strip()
if not line or line.startswith('//'): if not line or line.startswith('//'):
@@ -79,7 +76,6 @@ def read_mem_hex(filename):
# TEST 1: Structural validation of all .mem files # TEST 1: Structural validation of all .mem files
# ============================================================================ # ============================================================================
def test_structural(): def test_structural():
print("\n=== TEST 1: Structural Validation ===")
expected = { expected = {
# FFT twiddle files (quarter-wave cosine ROMs) # FFT twiddle files (quarter-wave cosine ROMs)
@@ -119,16 +115,13 @@ def test_structural():
# TEST 2: FFT Twiddle Factor Validation # TEST 2: FFT Twiddle Factor Validation
# ============================================================================ # ============================================================================
def test_twiddle_1024(): def test_twiddle_1024():
print("\n=== TEST 2a: FFT Twiddle 1024 Validation ===")
vals = read_mem_hex('fft_twiddle_1024.mem') 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 max_err = 0
err_details = [] err_details = []
for k in range(min(256, len(vals))): for k in range(min(256, len(vals))):
angle = 2.0 * math.pi * k / 1024.0 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)) expected = max(-32768, min(32767, expected))
actual = vals[k] actual = vals[k]
err = abs(actual - expected) err = abs(actual - expected)
@@ -140,19 +133,17 @@ def test_twiddle_1024():
check(max_err <= 1, check(max_err <= 1,
f"fft_twiddle_1024.mem: max twiddle error = {max_err} LSB (tolerance: 1)") f"fft_twiddle_1024.mem: max twiddle error = {max_err} LSB (tolerance: 1)")
if err_details: if err_details:
for k, act, exp, e in err_details[:5]: for _, _act, _exp, _e in err_details[:5]:
print(f" k={k}: got {act} (0x{act & 0xFFFF:04x}), expected {exp}, err={e}") pass
print(f" Max twiddle error: {max_err} LSB across {len(vals)} entries")
def test_twiddle_16(): def test_twiddle_16():
print("\n=== TEST 2b: FFT Twiddle 16 Validation ===")
vals = read_mem_hex('fft_twiddle_16.mem') vals = read_mem_hex('fft_twiddle_16.mem')
max_err = 0 max_err = 0
for k in range(min(4, len(vals))): for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0 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)) expected = max(-32768, min(32767, expected))
actual = vals[k] actual = vals[k]
err = abs(actual - expected) err = abs(actual - expected)
@@ -161,23 +152,17 @@ def test_twiddle_16():
check(max_err <= 1, check(max_err <= 1,
f"fft_twiddle_16.mem: max twiddle error = {max_err} LSB (tolerance: 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 all 4 entries for reference
print(" Twiddle 16 entries:")
for k in range(min(4, len(vals))): for k in range(min(4, len(vals))):
angle = 2.0 * math.pi * k / 16.0 angle = 2.0 * math.pi * k / 16.0
expected = int(round(math.cos(angle) * 32767.0)) expected = 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)}")
# ============================================================================ # ============================================================================
# TEST 3: Long Chirp .mem File Analysis # TEST 3: Long Chirp .mem File Analysis
# ============================================================================ # ============================================================================
def test_long_chirp(): def test_long_chirp():
print("\n=== TEST 3: Long Chirp .mem File Analysis ===")
# Load all 4 segments # Load all 4 segments
all_i = [] all_i = []
@@ -193,36 +178,29 @@ def test_long_chirp():
f"Total long chirp samples: {total_samples} (expected 4096 = 4 segs x 1024)") f"Total long chirp samples: {total_samples} (expected 4096 = 4 segs x 1024)")
# Compute magnitude envelope # 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) max_mag = max(magnitudes)
min_mag = min(magnitudes) min(magnitudes)
avg_mag = sum(magnitudes) / len(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 # Check if this looks like it came from generate_reference_chirp_q15
# That function uses 32767 * 0.9 scaling => max magnitude ~29490 # That function uses 32767 * 0.9 scaling => max magnitude ~29490
expected_max_from_model = 32767 * 0.9 expected_max_from_model = 32767 * 0.9
uses_model_scaling = max_mag > expected_max_from_model * 0.8 uses_model_scaling = max_mag > expected_max_from_model * 0.8
if uses_model_scaling: if uses_model_scaling:
print(" Scaling: CONSISTENT with radar_scene.py model (0.9 * Q15)") pass
else: else:
warn(f"Magnitude ({max_mag:.0f}) is much lower than expected from Python model " 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.") f"({expected_max_from_model:.0f}). .mem files may have unknown provenance.")
# Check non-zero content: how many samples are non-zero? # Check non-zero content: how many samples are non-zero?
nonzero_i = sum(1 for v in all_i if v != 0) sum(1 for v in all_i if v != 0)
nonzero_q = sum(1 for v in all_q if v != 0) 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}")
# Analyze instantaneous frequency via phase differences # Analyze instantaneous frequency via phase differences
# Phase = atan2(Q, I)
phases = [] 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 if abs(i_val) > 5 or abs(q_val) > 5: # Skip near-zero samples
phases.append(math.atan2(q_val, i_val)) phases.append(math.atan2(q_val, i_val))
else: else:
@@ -243,19 +221,12 @@ def test_long_chirp():
freq_estimates.append(f_inst) freq_estimates.append(f_inst)
if freq_estimates: if freq_estimates:
f_start = 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[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[-1]
f_min = min(freq_estimates) f_min = min(freq_estimates)
f_max = max(freq_estimates) f_max = max(freq_estimates)
f_range = f_max - f_min 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 # A chirp should show frequency sweep
is_chirp = f_range > 0.5e6 # At least 0.5 MHz 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 # Check if bandwidth roughly matches expected
bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50% bw_match = abs(f_range - CHIRP_BW) / CHIRP_BW < 0.5 # within 50%
if bw_match: if bw_match:
print( pass
f" Bandwidth {f_range/1e6:.2f} MHz roughly matches expected "
f"{CHIRP_BW/1e6:.2f} MHz"
)
else: else:
warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz") warn(f"Bandwidth {f_range/1e6:.2f} MHz does NOT match expected {CHIRP_BW/1e6:.2f} MHz")
# Compare segment boundaries for overlap-save consistency # Compare segment boundaries for overlap-save consistency
# In proper overlap-save, the chirp data should be segmented at 896-sample boundaries # In proper overlap-save, the chirp data should be segmented at 896-sample boundaries
# with segments being 1024-sample FFT blocks # with segments being 1024-sample FFT blocks
print("\n Segment boundary analysis:")
for seg in range(4): for seg in range(4):
seg_i = read_mem_hex(f'long_chirp_seg{seg}_i.mem') 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_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_mags = [math.sqrt(i*i + q*q) for i, q in zip(seg_i, seg_q, strict=False)]
seg_avg = sum(seg_mags) / len(seg_mags) sum(seg_mags) / len(seg_mags)
seg_max = max(seg_mags) max(seg_mags)
# Check segment 3 zero-padding (chirp is 3000 samples, seg3 starts at 3072) # 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 # 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... # Wait, but the .mem files have 1024 lines with non-trivial data...
# Let's check if seg3 has significant data # Let's check if seg3 has significant data
zero_count = sum(1 for m in seg_mags if m < 2) 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: if zero_count > 500:
print(" -> Seg 3 mostly zeros (chirp shorter than 4096 samples)") pass
else: else:
print(" -> Seg 3 has significant data throughout") pass
else: else:
print(f" Seg {seg}: avg_mag={seg_avg:.1f}, max_mag={seg_max:.1f}") pass
# ============================================================================ # ============================================================================
# TEST 4: Short Chirp .mem File Analysis # TEST 4: Short Chirp .mem File Analysis
# ============================================================================ # ============================================================================
def test_short_chirp(): def test_short_chirp():
print("\n=== TEST 4: Short Chirp .mem File Analysis ===")
short_i = read_mem_hex('short_chirp_i.mem') short_i = read_mem_hex('short_chirp_i.mem')
short_q = read_mem_hex('short_chirp_q.mem') short_q = read_mem_hex('short_chirp_q.mem')
@@ -320,19 +284,17 @@ def test_short_chirp():
check(len(short_i) == expected_samples, check(len(short_i) == expected_samples,
f"Short chirp length matches T_SHORT_CHIRP * FS_SYS = {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)] magnitudes = [math.sqrt(i*i + q*q) for i, q in zip(short_i, short_q, strict=False)]
max_mag = max(magnitudes) max(magnitudes)
avg_mag = sum(magnitudes) / len(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 # Check non-zero
nonzero = sum(1 for m in magnitudes if m > 1) 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(nonzero == len(short_i), f"All {nonzero}/{len(short_i)} samples non-zero")
# Check it looks like a chirp (phase should be quadratic) # 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 = [] freq_est = []
for n in range(1, len(phases)): for n in range(1, len(phases)):
dp = phases[n] - phases[n-1] dp = phases[n] - phases[n-1]
@@ -343,17 +305,14 @@ def test_short_chirp():
freq_est.append(dp * FS_SYS / (2 * math.pi)) freq_est.append(dp * FS_SYS / (2 * math.pi))
if freq_est: if freq_est:
f_start = freq_est[0] freq_est[0]
f_end = freq_est[-1] 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")
# ============================================================================ # ============================================================================
# TEST 5: Generate Expected Chirp .mem and Compare # TEST 5: Generate Expected Chirp .mem and Compare
# ============================================================================ # ============================================================================
def test_chirp_vs_model(): 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 # Generate reference using the same method as radar_scene.py
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
@@ -365,8 +324,8 @@ def test_chirp_vs_model():
for n in range(n_chirp): for n in range(n_chirp):
t = n / FS_SYS t = n / FS_SYS
phase = math.pi * chirp_rate * t * t phase = math.pi * chirp_rate * t * t
re_val = int(round(32767 * 0.9 * math.cos(phase))) re_val = round(32767 * 0.9 * math.cos(phase))
im_val = int(round(32767 * 0.9 * math.sin(phase))) im_val = round(32767 * 0.9 * math.sin(phase))
model_i.append(max(-32768, min(32767, re_val))) model_i.append(max(-32768, min(32767, re_val)))
model_q.append(max(-32768, min(32767, im_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') mem_q = read_mem_hex('long_chirp_seg0_q.mem')
# Compare magnitudes # Compare magnitudes
model_mags = [math.sqrt(i*i + q*q) for i, q in zip(model_i, model_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)] mem_mags = [math.sqrt(i*i + q*q) for i, q in zip(mem_i, mem_q, strict=False)]
model_max = max(model_mags) model_max = max(model_mags)
mem_max = max(mem_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) # 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) matches = sum(1 for a, b in zip(model_i, mem_i, strict=False) if a == b)
print(f" Exact I matches: {matches}/{len(model_i)}")
if matches > len(model_i) * 0.9: if matches > len(model_i) * 0.9:
print(" -> .mem files MATCH Python model") pass
else: else:
warn(".mem files do NOT match Python model. They likely have different provenance.") warn(".mem files do NOT match Python model. They likely have different provenance.")
# Try to detect scaling # Try to detect scaling
if mem_max > 0: if mem_max > 0:
ratio = model_max / mem_max 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")
# Check phase correlation (shape match regardless of scaling) # Check phase correlation (shape match regardless of scaling)
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_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)] mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
# Compute phase differences # Compute phase differences
phase_diffs = [] 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 d = mp - fp
while d > math.pi: while d > math.pi:
d -= 2 * math.pi d -= 2 * math.pi
@@ -413,12 +366,9 @@ def test_chirp_vs_model():
d += 2 * math.pi d += 2 * math.pi
phase_diffs.append(d) 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) 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 phase_match = max_phase_diff < 0.5 # within 0.5 rad
check( check(
@@ -432,7 +382,6 @@ def test_chirp_vs_model():
# TEST 6: Latency Buffer LATENCY=3187 Validation # TEST 6: Latency Buffer LATENCY=3187 Validation
# ============================================================================ # ============================================================================
def test_latency_buffer(): 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 latency buffer delays the reference chirp data to align with
# the matched filter processing chain output. # the matched filter processing chain output.
@@ -491,16 +440,10 @@ def test_latency_buffer():
f"LATENCY={LATENCY} in reasonable range [1000, 4095]") f"LATENCY={LATENCY} in reasonable range [1000, 4095]")
# Check that the module name vs parameter is consistent # 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 # Module name was renamed from latency_buffer_2159 to latency_buffer
# to match the actual parameterized LATENCY value. No warning needed. # to match the actual parameterized LATENCY value. No warning needed.
# Validate address arithmetic won't overflow # 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 min_read_ptr = 4096 + 0 - LATENCY
check(min_read_ptr >= 0 and min_read_ptr < 4096, check(min_read_ptr >= 0 and min_read_ptr < 4096,
f"Min read_ptr after wrap = {min_read_ptr} (valid: 0..4095)") 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 # The latency buffer uses valid_in gated reads, so it only counts
# valid samples. The number of valid_in pulses between first write # valid samples. The number of valid_in pulses between first write
# and first read is LATENCY. # 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 # TEST 7: Cross-check chirp memory loader addressing
# ============================================================================ # ============================================================================
def test_memory_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]} # 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] # 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] # Memory is declared as: reg [15:0] long_chirp_i [0:4095]
# $readmemh loads seg0 to [0:1023], seg1 to [1024:2047], etc. # $readmemh loads seg0 to [0:1023], seg1 to [1024:2047], etc.
# Addressing via {segment_select, sample_addr} maps correctly. # 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 # TEST 8: Seg3 zero-padding analysis
# ============================================================================ # ============================================================================
def test_seg3_padding(): def test_seg3_padding():
print("\n=== TEST 8: Segment 3 Data Analysis ===")
# The long chirp has 3000 samples (30 us at 100 MHz). # The long chirp has 3000 samples (30 us at 100 MHz).
# With 4 segments of 1024 samples = 4096 total memory slots. # 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_i = read_mem_hex('long_chirp_seg3_i.mem')
seg3_q = read_mem_hex('long_chirp_seg3_q.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) # Count trailing zeros (samples after chirp ends)
trailing_zeros = 0 trailing_zeros = 0
@@ -590,14 +528,8 @@ def test_seg3_padding():
nonzero = sum(1 for m in mags if m > 2) 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: 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 # This means the .mem files encode 4096 chirp samples, not 3000
# The chirp duration used for .mem generation was different from T_LONG_CHIRP # The chirp duration used for .mem generation was different from T_LONG_CHIRP
actual_chirp_samples = 4 * 1024 # = 4096 actual_chirp_samples = 4 * 1024 # = 4096
@@ -607,17 +539,13 @@ def test_seg3_padding():
f"({T_LONG_CHIRP*1e6:.1f} us)") f"({T_LONG_CHIRP*1e6:.1f} us)")
elif trailing_zeros > 100: elif trailing_zeros > 100:
# Some padding at end # Some padding at end
actual_valid = 3072 + (1024 - trailing_zeros) 3072 + (1024 - trailing_zeros)
print(f" -> Estimated valid chirp samples in .mem: ~{actual_valid}")
# ============================================================================ # ============================================================================
# MAIN # MAIN
# ============================================================================ # ============================================================================
def main(): def main():
print("=" * 70)
print("AERIS-10 .mem File Validation")
print("=" * 70)
test_structural() test_structural()
test_twiddle_1024() test_twiddle_1024()
@@ -629,13 +557,10 @@ def main():
test_memory_addressing() test_memory_addressing()
test_seg3_padding() test_seg3_padding()
print("\n" + "=" * 70)
print(f"SUMMARY: {pass_count} PASS, {fail_count} FAIL, {warn_count} WARN")
if fail_count == 0: if fail_count == 0:
print("ALL CHECKS PASSED") pass
else: else:
print("SOME CHECKS FAILED") pass
print("=" * 70)
return 0 if fail_count == 0 else 1 return 0 if fail_count == 0 else 1
+6 -25
View File
@@ -28,8 +28,7 @@ N = 1024 # FFT length
def to_q15(value): def to_q15(value):
"""Clamp a floating-point value to 16-bit signed range [-32768, 32767].""" """Clamp a floating-point value to 16-bit signed range [-32768, 32767]."""
v = int(np.round(value)) v = int(np.round(value))
v = max(-32768, min(32767, v)) return max(-32768, min(32767, v))
return v
def to_hex16(value): 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", f"mf_golden_out_q_case{case_num}.hex",
] ]
summary = { return {
"case": case_num, "case": case_num,
"description": description, "description": description,
"peak_bin": peak_bin, "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, "peak_q_quant": peak_q_q,
"files": files, "files": files,
} }
return summary
def main(): def main():
@@ -149,7 +147,6 @@ def main():
# ========================================================================= # =========================================================================
# Case 2: Tone autocorrelation at bin 5 # Case 2: Tone autocorrelation at bin 5
# Signal and reference: complex tone at bin 5, amplitude 8000 (Q15) # 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) # Autocorrelation of a tone => peak at bin 0 (lag 0)
# ========================================================================= # =========================================================================
amp = 8000.0 amp = 8000.0
@@ -243,28 +240,12 @@ def main():
# ========================================================================= # =========================================================================
# Print summary to stdout # 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: for _ in summaries:
print() pass
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']}")
print() for _ in all_files:
print(f"Generated {len(all_files)} files:") pass
for fname in all_files:
print(f" {fname}")
print()
print("Done.")
if __name__ == "__main__": if __name__ == "__main__":
+9 -10
View File
@@ -26,7 +26,6 @@ import time
import random import random
import logging import logging
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from enum import Enum from enum import Enum
# PyQt6 imports # PyQt6 imports
@@ -198,12 +197,12 @@ class RadarMapWidget(QWidget):
altitude=100.0, altitude=100.0,
pitch=0.0 pitch=0.0
) )
self._targets: List[RadarTarget] = [] self._targets: list[RadarTarget] = []
self._coverage_radius = 50000 # meters self._coverage_radius = 50000 # meters
self._tile_server = TileServer.OPENSTREETMAP self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True self._show_coverage = True
self._show_trails = False self._show_trails = False
self._target_history: Dict[int, List[Tuple[float, float]]] = {} self._target_history: dict[int, list[tuple[float, float]]] = {}
# Setup UI # Setup UI
self._setup_ui() self._setup_ui()
@@ -908,7 +907,7 @@ class RadarMapWidget(QWidget):
"""Handle marker click events""" """Handle marker click events"""
self.targetSelected.emit(target_id) 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""" """Handle tile server change"""
server = self._tile_combo.currentData() server = self._tile_combo.currentData()
self._tile_server = server self._tile_server = server
@@ -947,7 +946,7 @@ class RadarMapWidget(QWidget):
f"{gps_data.altitude}, {gps_data.pitch}, {gps_data.heading})" 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""" """Update all targets on the map"""
self._targets = targets self._targets = targets
@@ -980,7 +979,7 @@ def polar_to_geographic(
radar_lon: float, radar_lon: float,
range_m: float, range_m: float,
azimuth_deg: float azimuth_deg: float
) -> Tuple[float, float]: ) -> tuple[float, float]:
""" """
Convert polar coordinates (range, azimuth) relative to radar Convert polar coordinates (range, azimuth) relative to radar
to geographic coordinates (latitude, longitude). to geographic coordinates (latitude, longitude).
@@ -1028,7 +1027,7 @@ class TargetSimulator(QObject):
super().__init__(parent) super().__init__(parent)
self._radar_position = radar_position self._radar_position = radar_position
self._targets: List[RadarTarget] = [] self._targets: list[RadarTarget] = []
self._next_id = 1 self._next_id = 1
self._timer = QTimer() self._timer = QTimer()
self._timer.timeout.connect(self._update_targets) self._timer.timeout.connect(self._update_targets)
@@ -1164,7 +1163,7 @@ class RadarDashboard(QMainWindow):
timestamp=time.time() timestamp=time.time()
) )
self._settings = RadarSettings() self._settings = RadarSettings()
self._simulator: Optional[TargetSimulator] = None self._simulator: TargetSimulator | None = None
self._demo_mode = True self._demo_mode = True
# Setup UI # Setup UI
@@ -1571,7 +1570,7 @@ class RadarDashboard(QMainWindow):
self._simulator._add_random_target() self._simulator._add_random_target()
logger.info("Added 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""" """Handle updated target list from simulator"""
# Update map # Update map
self._map_widget.set_targets(targets) self._map_widget.set_targets(targets)
@@ -1582,7 +1581,7 @@ class RadarDashboard(QMainWindow):
# Update table # Update table
self._update_targets_table(targets) 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""" """Update the targets table"""
self._targets_table.setRowCount(len(targets)) 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()
+31 -40
View File
@@ -28,7 +28,7 @@ except ImportError:
logging.warning("pyusb not available. USB CDC functionality will be disabled.") logging.warning("pyusb not available. USB CDC functionality will be disabled.")
try: try:
from pyftdi.ftdi import Ftdi from pyftdi.ftdi import Ftdi, FtdiError
from pyftdi.usbtools import UsbTools from pyftdi.usbtools import UsbTools
FTDI_AVAILABLE = True FTDI_AVAILABLE = True
@@ -289,7 +289,7 @@ class MapGenerator:
targets_script = f"updateTargets({targets_json});" targets_script = f"updateTargets({targets_json});"
# Fill template # Fill template
map_html = self.map_html_template.format( return self.map_html_template.format(
lat=gps_data.latitude, lat=gps_data.latitude,
lon=gps_data.longitude, lon=gps_data.longitude,
alt=gps_data.altitude, alt=gps_data.altitude,
@@ -299,8 +299,6 @@ class MapGenerator:
api_key=api_key, api_key=api_key,
) )
return map_html
def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg): def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
""" """
Convert polar coordinates (range, azimuth) to geographic coordinates Convert polar coordinates (range, azimuth) to geographic coordinates
@@ -369,7 +367,7 @@ class STM32USBInterface:
"device": dev, "device": dev,
} }
) )
except Exception: except (usb.core.USBError, ValueError):
devices.append( devices.append(
{ {
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})", "description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
@@ -380,7 +378,7 @@ class STM32USBInterface:
) )
return devices return devices
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error listing USB devices: {e}") logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing # Return mock devices for testing
return [ return [
@@ -430,7 +428,7 @@ class STM32USBInterface:
logging.info(f"STM32 USB device opened: {device_info['description']}") logging.info(f"STM32 USB device opened: {device_info['description']}")
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error opening USB device: {e}") logging.error(f"Error opening USB device: {e}")
return False return False
@@ -446,7 +444,7 @@ class STM32USBInterface:
packet = self._create_settings_packet(settings) packet = self._create_settings_packet(settings)
logging.info("Sending radar settings to STM32 via USB...") logging.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet) 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}") logging.error(f"Error sending settings via USB: {e}")
return False return False
@@ -463,9 +461,6 @@ class STM32USBInterface:
return None return None
logging.error(f"USB read error: {e}") logging.error(f"USB read error: {e}")
return None return None
except Exception as e:
logging.error(f"Error reading from USB: {e}")
return None
def _send_data(self, data): def _send_data(self, data):
"""Send data to STM32 via USB""" """Send data to STM32 via USB"""
@@ -483,7 +478,7 @@ class STM32USBInterface:
self.ep_out.write(chunk) self.ep_out.write(chunk)
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error sending data via USB: {e}") logging.error(f"Error sending data via USB: {e}")
return False return False
@@ -509,7 +504,7 @@ class STM32USBInterface:
try: try:
usb.util.dispose_resources(self.device) usb.util.dispose_resources(self.device)
self.is_open = False self.is_open = False
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error closing USB device: {e}") logging.error(f"Error closing USB device: {e}")
@@ -525,14 +520,12 @@ class FTDIInterface:
return [] return []
try: try:
devices = []
# Get list of all FTDI devices # Get list of all FTDI devices
for device in UsbTools.find_all([(0x0403, 0x6010)]): # FT2232H vendor/product ID return [
devices.append(
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"} {"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
) for device in UsbTools.find_all([(0x0403, 0x6010)])
return devices ] # FT2232H vendor/product ID
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error listing FTDI devices: {e}") logging.error(f"Error listing FTDI devices: {e}")
# Return mock devices for testing # Return mock devices for testing
return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}] return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}]
@@ -560,7 +553,7 @@ class FTDIInterface:
logging.info(f"FTDI device opened: {device_url}") logging.info(f"FTDI device opened: {device_url}")
return True return True
except Exception as e: except FtdiError as e:
logging.error(f"Error opening FTDI device: {e}") logging.error(f"Error opening FTDI device: {e}")
return False return False
@@ -574,7 +567,7 @@ class FTDIInterface:
if data: if data:
return bytes(data) return bytes(data)
return None return None
except Exception as e: except FtdiError as e:
logging.error(f"Error reading from FTDI: {e}") logging.error(f"Error reading from FTDI: {e}")
return None return None
@@ -595,8 +588,7 @@ class RadarProcessor:
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2): def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
"""Dual-CPI fusion for better detection""" """Dual-CPI fusion for better detection"""
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0) return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
return fused_profile
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2): def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
"""Multi-PRF velocity unwrapping""" """Multi-PRF velocity unwrapping"""
@@ -643,7 +635,7 @@ class RadarProcessor:
return clusters return clusters
def association(self, detections, clusters): def association(self, detections, _clusters):
"""Association of detections to tracks""" """Association of detections to tracks"""
associated_detections = [] associated_detections = []
@@ -737,7 +729,7 @@ class USBPacketParser:
if len(data) >= 30 and data[0:4] == b"GPSB": if len(data) >= 30 and data[0:4] == b"GPSB":
return self._parse_binary_gps_with_pitch(data) 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}") logging.error(f"Error parsing GPS data: {e}")
return None return None
@@ -789,7 +781,7 @@ class USBPacketParser:
timestamp=time.time(), timestamp=time.time(),
) )
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing binary GPS with pitch: {e}") logging.error(f"Error parsing binary GPS with pitch: {e}")
return None return None
@@ -831,11 +823,10 @@ class RadarPacketParser:
if packet_type == 0x01: if packet_type == 0x01:
return self.parse_range_packet(payload) return self.parse_range_packet(payload)
elif packet_type == 0x02: if packet_type == 0x02:
return self.parse_doppler_packet(payload) return self.parse_doppler_packet(payload)
elif packet_type == 0x03: if packet_type == 0x03:
return self.parse_detection_packet(payload) return self.parse_detection_packet(payload)
else:
logging.warning(f"Unknown packet type: {packet_type:02X}") logging.warning(f"Unknown packet type: {packet_type:02X}")
return None return None
@@ -860,7 +851,7 @@ class RadarPacketParser:
"chirp": chirp_counter, "chirp": chirp_counter,
"timestamp": time.time(), "timestamp": time.time(),
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing range packet: {e}") logging.error(f"Error parsing range packet: {e}")
return None return None
@@ -884,7 +875,7 @@ class RadarPacketParser:
"chirp": chirp_counter, "chirp": chirp_counter,
"timestamp": time.time(), "timestamp": time.time(),
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing Doppler packet: {e}") logging.error(f"Error parsing Doppler packet: {e}")
return None return None
@@ -906,7 +897,7 @@ class RadarPacketParser:
"chirp": chirp_counter, "chirp": chirp_counter,
"timestamp": time.time(), "timestamp": time.time(),
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing detection packet: {e}") logging.error(f"Error parsing detection packet: {e}")
return None return None
@@ -1345,7 +1336,7 @@ class RadarGUI:
logging.info("Radar system started successfully via USB CDC") 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}") messagebox.showerror("Error", f"Failed to start radar: {e}")
logging.error(f"Start radar error: {e}") logging.error(f"Start radar error: {e}")
@@ -1414,7 +1405,7 @@ class RadarGUI:
else: else:
break break
except Exception as e: except FtdiError as e:
logging.error(f"Error processing radar data: {e}") logging.error(f"Error processing radar data: {e}")
time.sleep(0.1) time.sleep(0.1)
else: else:
@@ -1438,7 +1429,7 @@ class RadarGUI:
f"Alt {gps_data.altitude:.1f}m, " f"Alt {gps_data.altitude:.1f}m, "
f"Pitch {gps_data.pitch:.1f}°" 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}") logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1) time.sleep(0.1)
@@ -1501,7 +1492,7 @@ class RadarGUI:
f"Pitch {self.current_gps.pitch:.1f}°" f"Pitch {self.current_gps.pitch:.1f}°"
) )
except Exception as e: except (ValueError, KeyError) as e:
logging.error(f"Error processing radar packet: {e}") logging.error(f"Error processing radar packet: {e}")
def update_range_doppler_map(self, target): def update_range_doppler_map(self, target):
@@ -1568,9 +1559,9 @@ class RadarGUI:
) )
logging.info(f"Map generated: {self.map_file_path}") 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}") 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): def update_gps_display(self):
"""Step 18: Update GPS and pitch display""" """Step 18: Update GPS and pitch display"""
@@ -1657,7 +1648,7 @@ class RadarGUI:
# Update GPS and pitch display # Update GPS and pitch display
self.update_gps_display() self.update_gps_display()
except Exception as e: except (tk.TclError, RuntimeError) as e:
logging.error(f"Error updating GUI: {e}") logging.error(f"Error updating GUI: {e}")
self.root.after(100, self.update_gui) self.root.after(100, self.update_gui)
@@ -1669,7 +1660,7 @@ def main():
root = tk.Tk() root = tk.Tk()
_app = RadarGUI(root) _app = RadarGUI(root)
root.mainloop() root.mainloop()
except Exception as e: except Exception as e: # noqa: BLE001
logging.error(f"Application error: {e}") logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}") messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
+44 -52
View File
@@ -37,7 +37,7 @@ except ImportError:
logging.warning("pyusb not available. USB CDC functionality will be disabled.") logging.warning("pyusb not available. USB CDC functionality will be disabled.")
try: try:
from pyftdi.ftdi import Ftdi from pyftdi.ftdi import Ftdi, FtdiError
from pyftdi.usbtools import UsbTools from pyftdi.usbtools import UsbTools
FTDI_AVAILABLE = True FTDI_AVAILABLE = True
@@ -108,8 +108,7 @@ class RadarProcessor:
def dual_cpi_fusion(self, range_profiles_1, range_profiles_2): def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
"""Dual-CPI fusion for better detection""" """Dual-CPI fusion for better detection"""
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0) return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
return fused_profile
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2): def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
"""Multi-PRF velocity unwrapping""" """Multi-PRF velocity unwrapping"""
@@ -156,7 +155,7 @@ class RadarProcessor:
return clusters return clusters
def association(self, detections, clusters): def association(self, detections, _clusters):
"""Association of detections to tracks""" """Association of detections to tracks"""
associated_detections = [] associated_detections = []
@@ -250,7 +249,7 @@ class USBPacketParser:
if len(data) >= 30 and data[0:4] == b"GPSB": if len(data) >= 30 and data[0:4] == b"GPSB":
return self._parse_binary_gps_with_pitch(data) 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}") logging.error(f"Error parsing GPS data: {e}")
return None return None
@@ -302,7 +301,7 @@ class USBPacketParser:
timestamp=time.time(), timestamp=time.time(),
) )
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing binary GPS with pitch: {e}") logging.error(f"Error parsing binary GPS with pitch: {e}")
return None return None
@@ -344,11 +343,10 @@ class RadarPacketParser:
if packet_type == 0x01: if packet_type == 0x01:
return self.parse_range_packet(payload) return self.parse_range_packet(payload)
elif packet_type == 0x02: if packet_type == 0x02:
return self.parse_doppler_packet(payload) return self.parse_doppler_packet(payload)
elif packet_type == 0x03: if packet_type == 0x03:
return self.parse_detection_packet(payload) return self.parse_detection_packet(payload)
else:
logging.warning(f"Unknown packet type: {packet_type:02X}") logging.warning(f"Unknown packet type: {packet_type:02X}")
return None return None
@@ -373,7 +371,7 @@ class RadarPacketParser:
"chirp": chirp_counter, "chirp": chirp_counter,
"timestamp": time.time(), "timestamp": time.time(),
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing range packet: {e}") logging.error(f"Error parsing range packet: {e}")
return None return None
@@ -397,7 +395,7 @@ class RadarPacketParser:
"chirp": chirp_counter, "chirp": chirp_counter,
"timestamp": time.time(), "timestamp": time.time(),
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing Doppler packet: {e}") logging.error(f"Error parsing Doppler packet: {e}")
return None return None
@@ -419,7 +417,7 @@ class RadarPacketParser:
"chirp": chirp_counter, "chirp": chirp_counter,
"timestamp": time.time(), "timestamp": time.time(),
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing detection packet: {e}") logging.error(f"Error parsing detection packet: {e}")
return None return None
@@ -688,22 +686,21 @@ class MapGenerator:
coverage_radius_km = coverage_radius / 1000.0 coverage_radius_km = coverage_radius / 1000.0
# Generate HTML content # 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) targets_json = json.dumps(map_targets)
map_html = map_html.replace( 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", "// Display initial targets if any",
f"window.initialTargets = {targets_json};\n // Display initial targets if any", "window.initialTargets = "
f"{targets_json};\n // Display initial targets if any",
)
) )
return map_html
def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg): def polar_to_geographic(self, radar_lat, radar_lon, range_m, azimuth_deg):
""" """
@@ -775,7 +772,7 @@ class STM32USBInterface:
"device": dev, "device": dev,
} }
) )
except Exception: except (usb.core.USBError, ValueError):
devices.append( devices.append(
{ {
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})", "description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
@@ -786,7 +783,7 @@ class STM32USBInterface:
) )
return devices return devices
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error listing USB devices: {e}") logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing # Return mock devices for testing
return [ return [
@@ -836,7 +833,7 @@ class STM32USBInterface:
logging.info(f"STM32 USB device opened: {device_info['description']}") logging.info(f"STM32 USB device opened: {device_info['description']}")
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error opening USB device: {e}") logging.error(f"Error opening USB device: {e}")
return False return False
@@ -852,7 +849,7 @@ class STM32USBInterface:
packet = self._create_settings_packet(settings) packet = self._create_settings_packet(settings)
logging.info("Sending radar settings to STM32 via USB...") logging.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet) 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}") logging.error(f"Error sending settings via USB: {e}")
return False return False
@@ -869,9 +866,6 @@ class STM32USBInterface:
return None return None
logging.error(f"USB read error: {e}") logging.error(f"USB read error: {e}")
return None return None
except Exception as e:
logging.error(f"Error reading from USB: {e}")
return None
def _send_data(self, data): def _send_data(self, data):
"""Send data to STM32 via USB""" """Send data to STM32 via USB"""
@@ -889,7 +883,7 @@ class STM32USBInterface:
self.ep_out.write(chunk) self.ep_out.write(chunk)
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error sending data via USB: {e}") logging.error(f"Error sending data via USB: {e}")
return False return False
@@ -915,7 +909,7 @@ class STM32USBInterface:
try: try:
usb.util.dispose_resources(self.device) usb.util.dispose_resources(self.device)
self.is_open = False self.is_open = False
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error closing USB device: {e}") logging.error(f"Error closing USB device: {e}")
@@ -931,14 +925,12 @@ class FTDIInterface:
return [] return []
try: try:
devices = []
# Get list of all FTDI devices # Get list of all FTDI devices
for device in UsbTools.find_all([(0x0403, 0x6010)]): # FT2232H vendor/product ID return [
devices.append(
{"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"} {"description": f"FTDI Device {device}", "url": f"ftdi://{device}/1"}
) for device in UsbTools.find_all([(0x0403, 0x6010)])
return devices ] # FT2232H vendor/product ID
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error listing FTDI devices: {e}") logging.error(f"Error listing FTDI devices: {e}")
# Return mock devices for testing # Return mock devices for testing
return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}] return [{"description": "FT2232H Device A", "url": "ftdi://device/1"}]
@@ -966,7 +958,7 @@ class FTDIInterface:
logging.info(f"FTDI device opened: {device_url}") logging.info(f"FTDI device opened: {device_url}")
return True return True
except Exception as e: except FtdiError as e:
logging.error(f"Error opening FTDI device: {e}") logging.error(f"Error opening FTDI device: {e}")
return False return False
@@ -980,7 +972,7 @@ class FTDIInterface:
if data: if data:
return bytes(data) return bytes(data)
return None return None
except Exception as e: except FtdiError as e:
logging.error(f"Error reading from FTDI: {e}") logging.error(f"Error reading from FTDI: {e}")
return None return None
@@ -1242,7 +1234,7 @@ class RadarGUI:
""" """
self.browser.load_html(placeholder_html) 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}") logging.error(f"Failed to create embedded browser: {e}")
self.create_browser_fallback() self.create_browser_fallback()
else: else:
@@ -1340,7 +1332,7 @@ Map HTML will appear here when generated.
self.fallback_text.configure(state="disabled") self.fallback_text.configure(state="disabled")
self.fallback_text.see("1.0") # Scroll to top self.fallback_text.see("1.0") # Scroll to top
logging.info("Fallback text widget updated with map HTML") 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}") logging.error(f"Error updating embedded browser: {e}")
def generate_map(self): def generate_map(self):
@@ -1386,7 +1378,7 @@ Map HTML will appear here when generated.
logging.info(f"Map generated with {len(targets)} targets") 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}") logging.error(f"Error generating map: {e}")
self.map_status_label.config(text=f"Map: Error - {str(e)[:50]}") self.map_status_label.config(text=f"Map: Error - {str(e)[:50]}")
@@ -1400,17 +1392,17 @@ Map HTML will appear here when generated.
# Create temporary HTML file # Create temporary HTML file
import tempfile import tempfile
temp_file = tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(
mode="w", suffix=".html", delete=False, encoding="utf-8" mode="w", suffix=".html", delete=False, encoding="utf-8"
) ) as temp_file:
temp_file.write(self.current_map_html) temp_file.write(self.current_map_html)
temp_file.close() temp_file_path = temp_file.name
# Open in default browser # Open in default browser
webbrowser.open("file://" + os.path.abspath(temp_file.name)) webbrowser.open("file://" + os.path.abspath(temp_file_path))
logging.info(f"Map opened in external browser: {temp_file.name}") logging.info(f"Map opened in external browser: {temp_file_path}")
except Exception as e: except (OSError, ValueError) as e:
logging.error(f"Error opening external browser: {e}") logging.error(f"Error opening external browser: {e}")
messagebox.showerror("Error", f"Failed to open browser: {e}") messagebox.showerror("Error", f"Failed to open browser: {e}")
@@ -1427,7 +1419,7 @@ def main():
root = tk.Tk() root = tk.Tk()
_app = RadarGUI(root) _app = RadarGUI(root)
root.mainloop() root.mainloop()
except Exception as e: except Exception as e: # noqa: BLE001
logging.error(f"Application error: {e}") logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}") messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
+41 -44
View File
@@ -26,7 +26,7 @@ except ImportError:
logging.warning("pyusb not available. USB functionality will be disabled.") logging.warning("pyusb not available. USB functionality will be disabled.")
try: try:
from pyftdi.ftdi import Ftdi # noqa: F401 from pyftdi.ftdi import Ftdi
from pyftdi.usbtools import UsbTools # noqa: F401 from pyftdi.usbtools import UsbTools # noqa: F401
from pyftdi.ftdi import FtdiError # noqa: F401 from pyftdi.ftdi import FtdiError # noqa: F401
FTDI_AVAILABLE = True FTDI_AVAILABLE = True
@@ -242,7 +242,6 @@ class MapGenerator:
</body> </body>
</html> </html>
""" """
pass
class FT601Interface: class FT601Interface:
""" """
@@ -298,7 +297,7 @@ class FT601Interface:
'device': dev, 'device': dev,
'serial': serial 'serial': serial
}) })
except Exception: except (usb.core.USBError, ValueError):
devices.append({ devices.append({
'description': f"FT601 USB3.0 (VID:{vid:04X}, PID:{pid:04X})", 'description': f"FT601 USB3.0 (VID:{vid:04X}, PID:{pid:04X})",
'vendor_id': vid, 'vendor_id': vid,
@@ -308,7 +307,7 @@ class FT601Interface:
}) })
return devices return devices
except Exception as e: except (usb.core.USBError, ValueError) as e:
logging.error(f"Error listing FT601 devices: {e}") logging.error(f"Error listing FT601 devices: {e}")
# Return mock devices for testing # Return mock devices for testing
return [ return [
@@ -350,7 +349,7 @@ class FT601Interface:
logging.info(f"FT601 device opened: {device_url}") logging.info(f"FT601 device opened: {device_url}")
return True return True
except Exception as e: except OSError as e:
logging.error(f"Error opening FT601 device: {e}") logging.error(f"Error opening FT601 device: {e}")
return False return False
@@ -403,7 +402,7 @@ class FT601Interface:
logging.info(f"FT601 device opened: {device_info['description']}") logging.info(f"FT601 device opened: {device_info['description']}")
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error opening FT601 device: {e}") logging.error(f"Error opening FT601 device: {e}")
return False return False
@@ -427,7 +426,7 @@ class FT601Interface:
return bytes(data) return bytes(data)
return None return None
elif self.device and self.ep_in: if self.device and self.ep_in:
# Direct USB access # Direct USB access
if bytes_to_read is None: if bytes_to_read is None:
bytes_to_read = 512 bytes_to_read = 512
@@ -448,7 +447,7 @@ class FT601Interface:
return bytes(data) if data else None 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}") logging.error(f"Error reading from FT601: {e}")
return None return None
@@ -468,7 +467,7 @@ class FT601Interface:
self.ftdi.write_data(data) self.ftdi.write_data(data)
return True return True
elif self.device and self.ep_out: if self.device and self.ep_out:
# Direct USB access # Direct USB access
# FT601 supports large transfers # FT601 supports large transfers
max_packet = 512 max_packet = 512
@@ -479,7 +478,7 @@ class FT601Interface:
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error writing to FT601: {e}") logging.error(f"Error writing to FT601: {e}")
return False return False
@@ -498,7 +497,7 @@ class FT601Interface:
self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.RESET) self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.RESET)
logging.info("FT601 burst mode disabled") logging.info("FT601 burst mode disabled")
return True return True
except Exception as e: except OSError as e:
logging.error(f"Error configuring burst mode: {e}") logging.error(f"Error configuring burst mode: {e}")
return False return False
return False return False
@@ -510,14 +509,14 @@ class FT601Interface:
self.ftdi.close() self.ftdi.close()
self.is_open = False self.is_open = False
logging.info("FT601 device closed") logging.info("FT601 device closed")
except Exception as e: except OSError as e:
logging.error(f"Error closing FT601 device: {e}") logging.error(f"Error closing FT601 device: {e}")
if self.device and self.is_open: if self.device and self.is_open:
try: try:
usb.util.dispose_resources(self.device) usb.util.dispose_resources(self.device)
self.is_open = False self.is_open = False
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error closing FT601 device: {e}") logging.error(f"Error closing FT601 device: {e}")
class STM32USBInterface: class STM32USBInterface:
@@ -563,7 +562,7 @@ class STM32USBInterface:
'product_id': pid, 'product_id': pid,
'device': dev 'device': dev
}) })
except Exception: except (usb.core.USBError, ValueError):
devices.append({ devices.append({
'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})", 'description': f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
'vendor_id': vid, 'vendor_id': vid,
@@ -572,7 +571,7 @@ class STM32USBInterface:
}) })
return devices return devices
except Exception as e: except (usb.core.USBError, ValueError) as e:
logging.error(f"Error listing USB devices: {e}") logging.error(f"Error listing USB devices: {e}")
# Return mock devices for testing # Return mock devices for testing
return [{ return [{
@@ -626,7 +625,7 @@ class STM32USBInterface:
logging.info(f"STM32 USB device opened: {device_info['description']}") logging.info(f"STM32 USB device opened: {device_info['description']}")
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error opening USB device: {e}") logging.error(f"Error opening USB device: {e}")
return False return False
@@ -642,7 +641,7 @@ class STM32USBInterface:
packet = self._create_settings_packet(settings) packet = self._create_settings_packet(settings)
logging.info("Sending radar settings to STM32 via USB...") logging.info("Sending radar settings to STM32 via USB...")
return self._send_data(packet) return self._send_data(packet)
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error sending settings via USB: {e}") logging.error(f"Error sending settings via USB: {e}")
return False return False
@@ -659,7 +658,7 @@ class STM32USBInterface:
return None return None
logging.error(f"USB read error: {e}") logging.error(f"USB read error: {e}")
return None return None
except Exception as e: except ValueError as e:
logging.error(f"Error reading from USB: {e}") logging.error(f"Error reading from USB: {e}")
return None return None
@@ -679,7 +678,7 @@ class STM32USBInterface:
self.ep_out.write(chunk) self.ep_out.write(chunk)
return True return True
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error sending data via USB: {e}") logging.error(f"Error sending data via USB: {e}")
return False return False
@@ -705,7 +704,7 @@ class STM32USBInterface:
try: try:
usb.util.dispose_resources(self.device) usb.util.dispose_resources(self.device)
self.is_open = False self.is_open = False
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error closing USB device: {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): def dual_cpi_fusion(self, range_profiles_1, range_profiles_2):
"""Dual-CPI fusion for better detection""" """Dual-CPI fusion for better detection"""
fused_profile = np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0) return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
return fused_profile
def multi_prf_unwrap(self, doppler_measurements, prf1, prf2): def multi_prf_unwrap(self, doppler_measurements, prf1, prf2):
"""Multi-PRF velocity unwrapping""" """Multi-PRF velocity unwrapping"""
@@ -766,7 +764,7 @@ class RadarProcessor:
return clusters return clusters
def association(self, detections, clusters): def association(self, detections, _clusters):
"""Association of detections to tracks""" """Association of detections to tracks"""
associated_detections = [] associated_detections = []
@@ -862,7 +860,7 @@ class USBPacketParser:
if len(data) >= 30 and data[0:4] == b'GPSB': if len(data) >= 30 and data[0:4] == b'GPSB':
return self._parse_binary_gps_with_pitch(data) return self._parse_binary_gps_with_pitch(data)
except Exception as e: except ValueError as e:
logging.error(f"Error parsing GPS data: {e}") logging.error(f"Error parsing GPS data: {e}")
return None return None
@@ -914,7 +912,7 @@ class USBPacketParser:
timestamp=time.time() timestamp=time.time()
) )
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing binary GPS with pitch: {e}") logging.error(f"Error parsing binary GPS with pitch: {e}")
return None return None
@@ -936,7 +934,7 @@ class RadarPacketParser:
if len(packet) < 6: if len(packet) < 6:
return None return None
_sync = packet[0:2] # noqa: F841 _sync = packet[0:2]
packet_type = packet[2] packet_type = packet[2]
length = packet[3] length = packet[3]
@@ -956,11 +954,10 @@ class RadarPacketParser:
if packet_type == 0x01: if packet_type == 0x01:
return self.parse_range_packet(payload) return self.parse_range_packet(payload)
elif packet_type == 0x02: if packet_type == 0x02:
return self.parse_doppler_packet(payload) return self.parse_doppler_packet(payload)
elif packet_type == 0x03: if packet_type == 0x03:
return self.parse_detection_packet(payload) return self.parse_detection_packet(payload)
else:
logging.warning(f"Unknown packet type: {packet_type:02X}") logging.warning(f"Unknown packet type: {packet_type:02X}")
return None return None
@@ -985,7 +982,7 @@ class RadarPacketParser:
'chirp': chirp_counter, 'chirp': chirp_counter,
'timestamp': time.time() 'timestamp': time.time()
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing range packet: {e}") logging.error(f"Error parsing range packet: {e}")
return None return None
@@ -1009,7 +1006,7 @@ class RadarPacketParser:
'chirp': chirp_counter, 'chirp': chirp_counter,
'timestamp': time.time() 'timestamp': time.time()
} }
except Exception as e: except (ValueError, struct.error) as e:
logging.error(f"Error parsing Doppler packet: {e}") logging.error(f"Error parsing Doppler packet: {e}")
return None return None
@@ -1031,7 +1028,7 @@ class RadarPacketParser:
'chirp': chirp_counter, 'chirp': chirp_counter,
'timestamp': time.time() 'timestamp': time.time()
} }
except Exception as e: except (usb.core.USBError, ValueError) as e:
logging.error(f"Error parsing detection packet: {e}") logging.error(f"Error parsing detection packet: {e}")
return None return None
@@ -1371,7 +1368,7 @@ class RadarGUI:
logging.info("Radar system started successfully with FT601 USB 3.0") logging.info("Radar system started successfully with FT601 USB 3.0")
except Exception as e: except usb.core.USBError as e:
messagebox.showerror("Error", f"Failed to start radar: {e}") messagebox.showerror("Error", f"Failed to start radar: {e}")
logging.error(f"Start radar error: {e}") logging.error(f"Start radar error: {e}")
@@ -1416,13 +1413,13 @@ class RadarGUI:
else: else:
break break
except Exception as e: except usb.core.USBError as e:
logging.error(f"Error processing radar data: {e}") logging.error(f"Error processing radar data: {e}")
time.sleep(0.1) time.sleep(0.1)
else: else:
time.sleep(0.1) time.sleep(0.1)
def get_packet_length(self, packet): def get_packet_length(self, _packet):
"""Calculate packet length including header and footer""" """Calculate packet length including header and footer"""
# This should match your packet structure # This should match your packet structure
return 64 # Example: 64-byte packets return 64 # Example: 64-byte packets
@@ -1443,7 +1440,7 @@ class RadarGUI:
f"Lon {gps_data.longitude:.6f}, " f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°" 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}") logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1) time.sleep(0.1)
@@ -1506,7 +1503,7 @@ class RadarGUI:
f"Pitch {self.current_gps.pitch:.1f}°" f"Pitch {self.current_gps.pitch:.1f}°"
) )
except Exception as e: except (ValueError, IndexError) as e:
logging.error(f"Error processing radar packet: {e}") logging.error(f"Error processing radar packet: {e}")
def update_range_doppler_map(self, target): def update_range_doppler_map(self, target):
@@ -1604,9 +1601,9 @@ class RadarGUI:
) )
logging.info(f"Map generated: {self.map_file_path}") logging.info(f"Map generated: {self.map_file_path}")
except Exception as e: except OSError as e:
logging.error(f"Error generating map: {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): def update_gps_display(self):
"""Step 18: Update GPS and pitch display""" """Step 18: Update GPS and pitch display"""
@@ -1753,7 +1750,7 @@ class RadarGUI:
else: else:
break break
except Exception as e: except (usb.core.USBError, ValueError, struct.error) as e:
logging.error(f"Error processing radar data: {e}") logging.error(f"Error processing radar data: {e}")
time.sleep(0.1) time.sleep(0.1)
else: else:
@@ -1775,7 +1772,7 @@ class RadarGUI:
f"Lon {gps_data.longitude:.6f}, " f"Lon {gps_data.longitude:.6f}, "
f"Alt {gps_data.altitude:.1f}m, Pitch {gps_data.pitch:.1f}°" 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}") logging.error(f"Error processing GPS data via USB: {e}")
time.sleep(0.1) time.sleep(0.1)
@@ -1803,7 +1800,7 @@ class RadarGUI:
# Update GPS and pitch display # Update GPS and pitch display
self.update_gps_display() self.update_gps_display()
except Exception as e: except (ValueError, IndexError) as e:
logging.error(f"Error updating GUI: {e}") logging.error(f"Error updating GUI: {e}")
self.root.after(100, self.update_gui) self.root.after(100, self.update_gui)
@@ -1812,9 +1809,9 @@ def main():
"""Main application entry point""" """Main application entry point"""
try: try:
root = tk.Tk() root = tk.Tk()
_app = RadarGUI(root) # noqa: F841 must stay alive for mainloop _app = RadarGUI(root) # must stay alive for mainloop
root.mainloop() root.mainloop()
except Exception as e: except Exception as e: # noqa: BLE001
logging.error(f"Application error: {e}") logging.error(f"Application error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start: {e}") messagebox.showerror("Fatal Error", f"Application failed to start: {e}")
+18 -22
View File
@@ -1,5 +1,4 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*-
""" """
Radar System GUI - Fully Functional Demo Version Radar System GUI - Fully Functional Demo Version
@@ -15,7 +14,6 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure from matplotlib.figure import Figure
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Dict
import random import random
import json import json
from datetime import datetime from datetime import datetime
@@ -65,7 +63,7 @@ class SimulatedRadarProcessor:
self.noise_floor = 10 self.noise_floor = 10
self.clutter_level = 5 self.clutter_level = 5
def _create_targets(self) -> List[Dict]: def _create_targets(self) -> list[dict]:
"""Create moving targets""" """Create moving targets"""
return [ return [
{ {
@@ -210,22 +208,20 @@ class SimulatedRadarProcessor:
return rd_map return rd_map
def _detect_targets(self) -> List[RadarTarget]: def _detect_targets(self) -> list[RadarTarget]:
"""Detect targets from current state""" """Detect targets from current state"""
detected = [] return [
for t in self.targets: RadarTarget(
# Random detection based on SNR
if random.random() < (t['snr'] / 35):
# Add some measurement noise
detected.append(RadarTarget(
id=t['id'], id=t['id'],
range=t['range'] + random.gauss(0, 10), range=t['range'] + random.gauss(0, 10),
velocity=t['velocity'] + random.gauss(0, 2), velocity=t['velocity'] + random.gauss(0, 2),
azimuth=t['azimuth'] + random.gauss(0, 1), azimuth=t['azimuth'] + random.gauss(0, 1),
elevation=t['elevation'] + random.gauss(0, 0.5), elevation=t['elevation'] + random.gauss(0, 0.5),
snr=t['snr'] + random.gauss(0, 2) snr=t['snr'] + random.gauss(0, 2)
)) )
return detected for t in self.targets
if random.random() < (t['snr'] / 35)
]
# ============================================================================ # ============================================================================
# MAIN GUI APPLICATION # MAIN GUI APPLICATION
@@ -566,7 +562,7 @@ class RadarDemoGUI:
scrollable_frame.bind( scrollable_frame.bind(
"<Configure>", "<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") 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) ('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 = ttk.Frame(scrollable_frame)
frame.pack(fill='x', padx=10, pady=5) frame.pack(fill='x', padx=10, pady=5)
@@ -745,7 +741,7 @@ class RadarDemoGUI:
# Update time # Update time
self.time_label.config(text=time.strftime("%H:%M:%S")) 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}") logger.error(f"Animation error: {e}")
# Schedule next update # Schedule next update
@@ -940,7 +936,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", "Settings applied") messagebox.showinfo("Success", "Settings applied")
logger.info("Settings updated") logger.info("Settings updated")
except Exception as e: except (ValueError, tk.TclError) as e:
messagebox.showerror("Error", f"Invalid settings: {e}") messagebox.showerror("Error", f"Invalid settings: {e}")
def apply_display_settings(self): def apply_display_settings(self):
@@ -981,7 +977,7 @@ class RadarDemoGUI:
) )
if filename: if filename:
try: try:
with open(filename, 'r') as f: with open(filename) as f:
config = json.load(f) config = json.load(f)
# Apply settings # Apply settings
@@ -1004,7 +1000,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", f"Loaded configuration from {filename}") messagebox.showinfo("Success", f"Loaded configuration from {filename}")
logger.info(f"Configuration loaded 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}") messagebox.showerror("Error", f"Failed to load: {e}")
def save_config(self): def save_config(self):
@@ -1031,7 +1027,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", f"Saved configuration to {filename}") messagebox.showinfo("Success", f"Saved configuration to {filename}")
logger.info(f"Configuration saved 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}") messagebox.showerror("Error", f"Failed to save: {e}")
def export_data(self): def export_data(self):
@@ -1061,7 +1057,7 @@ class RadarDemoGUI:
messagebox.showinfo("Success", f"Exported {len(frames)} frames to {filename}") messagebox.showinfo("Success", f"Exported {len(frames)} frames to {filename}")
logger.info(f"Data exported 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}") messagebox.showerror("Error", f"Failed to export: {e}")
def show_calibration(self): def show_calibration(self):
@@ -1205,7 +1201,7 @@ def main():
root = tk.Tk() root = tk.Tk()
# Create application # Create application
_app = RadarDemoGUI(root) # noqa: F841 — keeps reference alive _app = RadarDemoGUI(root) # keeps reference alive
# Center window # Center window
root.update_idletasks() root.update_idletasks()
@@ -1218,7 +1214,7 @@ def main():
# Start main loop # Start main loop
root.mainloop() root.mainloop()
except Exception as e: except Exception as e: # noqa: BLE001
logger.error(f"Fatal error: {e}") logger.error(f"Fatal error: {e}")
messagebox.showerror("Fatal Error", f"Application failed to start:\n{e}") messagebox.showerror("Fatal Error", f"Application failed to start:\n{e}")
+190 -89
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/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 Real-time visualization and control for the AERIS-10 phased-array radar
via FT2232H USB 2.0 interface. via FT2232H USB 2.0 interface.
@@ -10,7 +10,8 @@ Features:
- Real-time range-Doppler magnitude heatmap (64x32) - Real-time range-Doppler magnitude heatmap (64x32)
- CFAR detection overlay (flagged cells highlighted) - CFAR detection overlay (flagged cells highlighted)
- Range profile waterfall plot (range vs. time) - 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 - Configuration panel for all radar parameters
- HDF5 data recording for offline analysis - HDF5 data recording for offline analysis
- Mock mode for development/testing without hardware - Mock mode for development/testing without hardware
@@ -27,7 +28,7 @@ import queue
import logging import logging
import argparse import argparse
import threading import threading
from typing import Optional, Dict import contextlib
from collections import deque from collections import deque
import numpy as np import numpy as np
@@ -82,18 +83,19 @@ class RadarDashboard:
C = 3e8 # m/s — speed of light C = 3e8 # m/s — speed of light
def __init__(self, root: tk.Tk, connection: FT2232HConnection, def __init__(self, root: tk.Tk, connection: FT2232HConnection,
recorder: DataRecorder): recorder: DataRecorder, device_index: int = 0):
self.root = root self.root = root
self.conn = connection self.conn = connection
self.recorder = recorder 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.geometry("1600x950")
self.root.configure(bg=BG) self.root.configure(bg=BG)
# Frame queue (acquisition → display) # Frame queue (acquisition → display)
self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8) self.frame_queue: queue.Queue[RadarFrame] = queue.Queue(maxsize=8)
self._acq_thread: Optional[RadarAcquisition] = None self._acq_thread: RadarAcquisition | None = None
# Display state # Display state
self._current_frame = RadarFrame() 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 = ttk.Button(top, text="Record", command=self._on_record)
self.btn_record.pack(side="right", padx=4) self.btn_record.pack(side="right", padx=4)
# Notebook (tabs) # -- Tabbed notebook layout --
nb = ttk.Notebook(self.root) nb = ttk.Notebook(self.root)
nb.pack(fill="both", expand=True, padx=8, pady=8) nb.pack(fill="both", expand=True, padx=8, pady=8)
@@ -173,9 +175,8 @@ class RadarDashboard:
# Compute physical axis limits # Compute physical axis limits
# Range resolution: dR = c / (2 * BW) per range bin # Range resolution: dR = c / (2 * BW) per range bin
# But we decimate 1024→64 bins, so each bin spans 16 FFT bins. # But we decimate 1024→64 bins, so each bin spans 16 FFT bins.
# Range per FFT bin = c / (2 * BW) * (Fs / FFT_SIZE) — simplified: # Range resolution derivation: c/(2*BW) gives ~0.3 m per FFT bin.
# max_range = c * Fs / (4 * BW) for Fs-sampled baseband # After 1024-to-64 decimation each displayed range bin spans 16 FFT bins.
# range_per_bin = max_range / NUM_RANGE_BINS
range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin range_res = self.C / (2.0 * self.BANDWIDTH) # ~0.3 m per FFT bin
# After decimation 1024→64, each range bin = 16 FFT bins # After decimation 1024→64, each range bin = 16 FFT bins
range_per_bin = range_res * 16 range_per_bin = range_res * 16
@@ -232,39 +233,92 @@ class RadarDashboard:
self._canvas = canvas self._canvas = canvas
def _build_control_tab(self, parent): def _build_control_tab(self, parent):
"""Host command sender and configuration panel.""" """Host command sender — organized by FPGA register groups.
outer = ttk.Frame(parent)
outer.pack(fill="both", expand=True, padx=16, pady=16)
# Left column: Quick actions Layout: scrollable canvas with three columns:
left = ttk.LabelFrame(outer, text="Quick Actions", padding=12) Left: Quick Actions + Diagnostics (self-test)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 8)) 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)", self._param_vars: dict[str, tk.StringVar] = {}
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)
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( # -- Radar Operation --
anchor="w", pady=(2, 0)) grp_op = ttk.LabelFrame(left, text="Radar Operation", padding=10)
ttk.Button(left, text="Run Self-Test (0x30)", grp_op.pack(fill="x", pady=(0, 8))
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)
# Self-test result display ttk.Button(grp_op, text="Radar Mode On",
st_frame = ttk.LabelFrame(left, text="Self-Test Results", padding=6) command=lambda: self._send_cmd(0x01, 1)).pack(fill="x", pady=2)
st_frame.pack(fill="x", pady=(6, 0)) 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 = {} self._st_labels = {}
for name, default_text in [ for name, default_text in [
("busy", "Busy: --"), ("busy", "Busy: --"),
@@ -280,58 +334,107 @@ class RadarDashboard:
lbl.pack(anchor="w") lbl.pack(anchor="w")
self._st_labels[name] = lbl self._st_labels[name] = lbl
# Right column: Parameter configuration # ── Center column: Waveform Timing ────────────────────────────
right = ttk.LabelFrame(outer, text="Parameter Configuration", padding=12) center = ttk.Frame(outer)
right.grid(row=0, column=1, sticky="nsew", padx=(8, 0)) center.grid(row=0, column=1, sticky="nsew", padx=6)
self._param_vars: Dict[str, tk.StringVar] = {} grp_wf = ttk.LabelFrame(center, text="Waveform Timing", padding=10)
params = [ grp_wf.pack(fill="x", pady=(0, 8))
("CFAR Guard (0x21)", 0x21, "2"),
("CFAR Train (0x22)", 0x22, "8"), wf_params = [
("CFAR Alpha Q4.4 (0x23)", 0x23, "48"), ("Long Chirp Cycles", 0x10, "3000", 16, "0-65535, rst=3000"),
("CFAR Mode (0x24)", 0x24, "0"), ("Long Listen Cycles", 0x11, "13700", 16, "0-65535, rst=13700"),
("Threshold (0x10)", 0x10, "500"), ("Guard Cycles", 0x12, "17540", 16, "0-65535, rst=17540"),
("Gain Shift (0x06)", 0x06, "0"), ("Short Chirp Cycles", 0x13, "50", 16, "0-65535, rst=50"),
("DC Notch Width (0x27)", 0x27, "0"), ("Short Listen Cycles", 0x14, "17450", 16, "0-65535, rst=17450"),
("Range Mode (0x20)", 0x20, "0"), ("Chirps Per Elevation", 0x15, "32", 6, "1-32, clamped"),
("Stream Enable (0x05)", 0x05, "7"),
] ]
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): # ── Right column: Detection (CFAR) + Custom ───────────────────
ttk.Label(right, text=label).grid(row=row_idx, column=0, right = ttk.Frame(outer)
sticky="w", pady=2) right.grid(row=0, column=2, sticky="nsew", padx=(6, 0))
grp_cfar = ttk.LabelFrame(right, text="Detection (CFAR)", padding=10)
grp_cfar.pack(fill="x", pady=(0, 8))
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(r0, textvariable=self._custom_op, width=8).pack(
side="left", padx=6)
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(r1, textvariable=self._custom_val, width=8).pack(
side="left", padx=6)
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=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) var = tk.StringVar(value=default)
self._param_vars[str(opcode)] = var self._param_vars[str(opcode)] = var
ent = ttk.Entry(right, textvariable=var, width=10) ttk.Entry(row, textvariable=var, width=8).pack(side="left", padx=6)
ent.grid(row=row_idx, column=1, padx=8, pady=2) ttk.Label(row, text=hint, foreground=ACCENT,
ttk.Button( font=("Menlo", 9)).pack(side="left")
right, text="Set", ttk.Button(row, text="Set",
command=lambda op=opcode, v=var: self._send_cmd(op, int(v.get())) command=lambda: self._send_validated(
).grid(row=row_idx, column=2, pady=2) opcode, var, bits=bits)).pack(side="right")
# Custom command def _send_validated(self, opcode: int, var: tk.StringVar, bits: int):
ttk.Separator(right, orient="horizontal").grid( """Parse, clamp to bit-width, send command, and update the entry."""
row=len(params), column=0, columnspan=3, sticky="ew", pady=8) try:
raw = int(var.get())
ttk.Label(right, text="Custom Opcode (hex)").grid( except ValueError:
row=len(params) + 1, column=0, sticky="w") log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}")
self._custom_op = tk.StringVar(value="01") return
ttk.Entry(right, textvariable=self._custom_op, width=10).grid( max_val = (1 << bits) - 1
row=len(params) + 1, column=1, padx=8) clamped = max(0, min(raw, max_val))
if clamped != raw:
ttk.Label(right, text="Value (dec)").grid( log.warning(f"Value {raw} clamped to {clamped} "
row=len(params) + 2, column=0, sticky="w") f"({bits}-bit max={max_val}) for opcode 0x{opcode:02X}")
self._custom_val = tk.StringVar(value="0") var.set(str(clamped))
ttk.Entry(right, textvariable=self._custom_val, width=10).grid( self._send_cmd(opcode, clamped)
row=len(params) + 2, column=1, padx=8)
ttk.Button(right, text="Send Custom",
command=self._send_custom).grid(
row=len(params) + 2, column=2, pady=2)
outer.columnconfigure(0, weight=1)
outer.columnconfigure(1, weight=2)
outer.rowconfigure(0, weight=1)
def _build_log_tab(self, parent): def _build_log_tab(self, parent):
self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10), self.log_text = tk.Text(parent, bg=BG2, fg=FG, font=("Menlo", 10),
@@ -364,7 +467,7 @@ class RadarDashboard:
self.root.update_idletasks() self.root.update_idletasks()
def _do_connect(): def _do_connect():
ok = self.conn.open() ok = self.conn.open(self.device_index)
# Schedule UI update back on the main thread # Schedule UI update back on the main thread
self.root.after(0, lambda: self._on_connect_done(ok)) self.root.after(0, lambda: self._on_connect_done(ok))
@@ -530,10 +633,8 @@ class _TextHandler(logging.Handler):
def emit(self, record): def emit(self, record):
msg = self.format(record) msg = self.format(record)
try: with contextlib.suppress(Exception):
self._text.after(0, self._append, msg) self._text.after(0, self._append, msg)
except Exception:
pass
def _append(self, msg: str): def _append(self, msg: str):
self._text.insert("end", msg + "\n") self._text.insert("end", msg + "\n")
@@ -578,7 +679,7 @@ def main():
root = tk.Tk() root = tk.Tk()
dashboard = RadarDashboard(root, conn, recorder) dashboard = RadarDashboard(root, conn, recorder, device_index=args.device)
if args.record: if args.record:
filepath = os.path.join( filepath = os.path.join(
+105 -84
View File
@@ -10,7 +10,7 @@ USB Interface: FT2232H USB 2.0 (8-bit, 50T production board) via pyftdi
USB Packet Protocol (11-byte): USB Packet Protocol (11-byte):
TX (FPGAHost): TX (FPGAHost):
Data packet: [0xAA] [range_q 2B] [range_i 2B] [dop_re 2B] [dop_im 2B] [det 1B] [0x55] 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 (HostFPGA): RX (HostFPGA):
Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo} Command: 4 bytes received sequentially {opcode, addr, value_hi, value_lo}
""" """
@@ -21,8 +21,9 @@ import time
import threading import threading
import queue import queue
import logging import logging
import contextlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional, List, Tuple, Dict, Any from typing import Any
from enum import IntEnum from enum import IntEnum
@@ -50,20 +51,36 @@ WATERFALL_DEPTH = 64
class Opcode(IntEnum): class Opcode(IntEnum):
"""Host register opcodes (matches radar_system_top.v command decode).""" """Host register opcodes — must match radar_system_top.v case(usb_cmd_opcode).
TRIGGER = 0x01
PRF_DIV = 0x02 FPGA truth table (from radar_system_top.v lines 902-944):
NUM_CHIRPS = 0x03 0x01 host_radar_mode 0x14 host_short_listen_cycles
CHIRP_TIMER = 0x04 0x02 host_trigger_pulse 0x15 host_chirps_per_elev
STREAM_ENABLE = 0x05 0x03 host_detect_threshold 0x16 host_gain_shift
GAIN_SHIFT = 0x06 0x04 host_stream_control 0x20 host_range_mode
THRESHOLD = 0x10 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_CHIRP = 0x10
LONG_LISTEN = 0x11 LONG_LISTEN = 0x11
GUARD = 0x12 GUARD = 0x12
SHORT_CHIRP = 0x13 SHORT_CHIRP = 0x13
SHORT_LISTEN = 0x14 SHORT_LISTEN = 0x14
CHIRPS_PER_ELEV = 0x15 CHIRPS_PER_ELEV = 0x15
# --- Signal processing (0x20-0x27) ---
RANGE_MODE = 0x20 RANGE_MODE = 0x20
CFAR_GUARD = 0x21 CFAR_GUARD = 0x21
CFAR_TRAIN = 0x22 CFAR_TRAIN = 0x22
@@ -72,6 +89,8 @@ class Opcode(IntEnum):
CFAR_ENABLE = 0x25 CFAR_ENABLE = 0x25
MTI_ENABLE = 0x26 MTI_ENABLE = 0x26
DC_NOTCH_WIDTH = 0x27 DC_NOTCH_WIDTH = 0x27
# --- Board self-test / status (0x30-0x31, 0xFF) ---
SELF_TEST_TRIGGER = 0x30 SELF_TEST_TRIGGER = 0x30
SELF_TEST_STATUS = 0x31 SELF_TEST_STATUS = 0x31
STATUS_REQUEST = 0xFF STATUS_REQUEST = 0xFF
@@ -83,7 +102,7 @@ class Opcode(IntEnum):
@dataclass @dataclass
class RadarFrame: class RadarFrame:
"""One complete radar frame (64 range × 32 Doppler).""" """One complete radar frame (64 range x 32 Doppler)."""
timestamp: float = 0.0 timestamp: float = 0.0
range_doppler_i: np.ndarray = field( range_doppler_i: np.ndarray = field(
default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.int16)) default_factory=lambda: np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=np.int16))
@@ -101,7 +120,7 @@ class RadarFrame:
@dataclass @dataclass
class StatusResponse: 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 radar_mode: int = 0
stream_ctrl: int = 0 stream_ctrl: int = 0
cfar_threshold: int = 0 cfar_threshold: int = 0
@@ -144,7 +163,7 @@ class RadarProtocol:
return struct.pack(">I", word) return struct.pack(">I", word)
@staticmethod @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. Parse an 11-byte data packet from the FT2232H byte stream.
Returns dict with keys: 'range_i', 'range_q', 'doppler_i', 'doppler_q', Returns dict with keys: 'range_i', 'range_q', 'doppler_i', 'doppler_q',
@@ -181,10 +200,10 @@ class RadarProtocol:
} }
@staticmethod @staticmethod
def parse_status_packet(raw: bytes) -> Optional[StatusResponse]: def parse_status_packet(raw: bytes) -> StatusResponse | None:
""" """
Parse a status response packet. 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: if len(raw) < 26:
return None return None
@@ -223,7 +242,7 @@ class RadarProtocol:
return sr return sr
@staticmethod @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). Scan buffer for packet start markers (0xAA data, 0xBB status).
Returns list of (start_idx, expected_end_idx, packet_type). Returns list of (start_idx, expected_end_idx, packet_type).
@@ -233,19 +252,22 @@ class RadarProtocol:
while i < len(buf): while i < len(buf):
if buf[i] == HEADER_BYTE: if buf[i] == HEADER_BYTE:
end = i + DATA_PACKET_SIZE end = i + DATA_PACKET_SIZE
if end <= len(buf): if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
packets.append((i, end, "data")) packets.append((i, end, "data"))
i = end i = end
else: 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: elif buf[i] == STATUS_HEADER_BYTE:
# Status packet: 26 bytes (same for both interfaces)
end = i + STATUS_PACKET_SIZE end = i + STATUS_PACKET_SIZE
if end <= len(buf): if end <= len(buf) and buf[end - 1] == FOOTER_BYTE:
packets.append((i, end, "status")) packets.append((i, end, "status"))
i = end i = end
else: else:
break if end > len(buf):
break # partial status packet — leave for residual
i += 1 # footer mismatch — skip
else: else:
i += 1 i += 1
return packets return packets
@@ -257,9 +279,13 @@ class RadarProtocol:
# Optional pyftdi import # Optional pyftdi import
try: try:
from pyftdi.ftdi import Ftdi as PyFtdi from pyftdi.ftdi import Ftdi, FtdiError
PyFtdi = Ftdi
PYFTDI_AVAILABLE = True PYFTDI_AVAILABLE = True
except ImportError: except ImportError:
class FtdiError(Exception):
"""Fallback FTDI error type when pyftdi is unavailable."""
PYFTDI_AVAILABLE = False PYFTDI_AVAILABLE = False
@@ -306,20 +332,18 @@ class FT2232HConnection:
self.is_open = True self.is_open = True
log.info(f"FT2232H device opened: {url}") log.info(f"FT2232H device opened: {url}")
return True return True
except Exception as e: except FtdiError as e:
log.error(f"FT2232H open failed: {e}") log.error(f"FT2232H open failed: {e}")
return False return False
def close(self): def close(self):
if self._ftdi is not None: if self._ftdi is not None:
try: with contextlib.suppress(Exception):
self._ftdi.close() self._ftdi.close()
except Exception:
pass
self._ftdi = None self._ftdi = None
self.is_open = False 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.""" """Read raw bytes from FT2232H. Returns None on error/timeout."""
if not self.is_open: if not self.is_open:
return None return None
@@ -331,7 +355,7 @@ class FT2232HConnection:
try: try:
data = self._ftdi.read_data(size) data = self._ftdi.read_data(size)
return bytes(data) if data else None return bytes(data) if data else None
except Exception as e: except FtdiError as e:
log.error(f"FT2232H read error: {e}") log.error(f"FT2232H read error: {e}")
return None return None
@@ -348,24 +372,29 @@ class FT2232HConnection:
try: try:
written = self._ftdi.write_data(data) written = self._ftdi.write_data(data)
return written == len(data) return written == len(data)
except Exception as e: except FtdiError as e:
log.error(f"FT2232H write error: {e}") log.error(f"FT2232H write error: {e}")
return False return False
def _mock_read(self, size: int) -> bytes: 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. 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) time.sleep(0.05)
self._mock_frame_num += 1 self._mock_frame_num += 1
buf = bytearray() buf = bytearray()
num_packets = min(32, size // DATA_PACKET_SIZE) num_packets = min(NUM_CELLS, size // DATA_PACKET_SIZE)
for _ in range(num_packets): start_idx = getattr(self, '_mock_seq_idx', 0)
rbin = self._mock_rng.randint(0, NUM_RANGE_BINS)
dbin = self._mock_rng.randint(0, NUM_DOPPLER_BINS) 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_i = int(self._mock_rng.normal(0, 100))
range_q = int(self._mock_rng.normal(0, 100)) range_q = int(self._mock_rng.normal(0, 100))
@@ -393,6 +422,7 @@ class FT2232HConnection:
buf += pkt buf += pkt
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
return bytes(buf) return bytes(buf)
@@ -401,19 +431,19 @@ class FT2232HConnection:
# ============================================================================ # ============================================================================
# Hardware-only opcodes that cannot be adjusted in replay mode # Hardware-only opcodes that cannot be adjusted in replay mode
# Values must match radar_system_top.v case(usb_cmd_opcode).
_HARDWARE_ONLY_OPCODES = { _HARDWARE_ONLY_OPCODES = {
0x01, # TRIGGER 0x01, # RADAR_MODE
0x02, # PRF_DIV 0x02, # TRIGGER_PULSE
0x03, # NUM_CHIRPS 0x03, # DETECT_THRESHOLD
0x04, # CHIRP_TIMER 0x04, # STREAM_CONTROL
0x05, # STREAM_ENABLE 0x10, # LONG_CHIRP
0x06, # GAIN_SHIFT
0x10, # THRESHOLD / LONG_CHIRP
0x11, # LONG_LISTEN 0x11, # LONG_LISTEN
0x12, # GUARD 0x12, # GUARD
0x13, # SHORT_CHIRP 0x13, # SHORT_CHIRP
0x14, # SHORT_LISTEN 0x14, # SHORT_LISTEN
0x15, # CHIRPS_PER_ELEV 0x15, # CHIRPS_PER_ELEV
0x16, # GAIN_SHIFT
0x20, # RANGE_MODE 0x20, # RANGE_MODE
0x30, # SELF_TEST_TRIGGER 0x30, # SELF_TEST_TRIGGER
0x31, # SELF_TEST_STATUS 0x31, # SELF_TEST_STATUS
@@ -439,26 +469,8 @@ def _saturate(val: int, bits: int) -> int:
return max(max_neg, min(max_pos, int(val))) 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, 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). """Bit-accurate DC notch filter (matches radar_system_top.v inline).
Dual sub-frame notch: doppler_bin[4:0] = {sub_frame, bin[3:0]}. 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, def _replay_cfar(doppler_i: np.ndarray, doppler_q: np.ndarray,
guard: int, train: int, alpha_q44: int, 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). Bit-accurate CA-CFAR detector (matches cfar_ca.v).
Returns (detect_flags, magnitudes) both (64, 32). 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_mode: int = 0 # 0=CA, 1=GO, 2=SO
self._cfar_enable: bool = True self._cfar_enable: bool = True
# Raw source arrays (loaded once, reprocessed on param change) # Raw source arrays (loaded once, reprocessed on param change)
self._dop_mti_i: Optional[np.ndarray] = None self._dop_mti_i: np.ndarray | None = None
self._dop_mti_q: Optional[np.ndarray] = None self._dop_mti_q: np.ndarray | None = None
self._dop_nomti_i: Optional[np.ndarray] = None self._dop_nomti_i: np.ndarray | None = None
self._dop_nomti_q: Optional[np.ndarray] = None self._dop_nomti_q: np.ndarray | None = None
self._range_i_vec: Optional[np.ndarray] = None self._range_i_vec: np.ndarray | None = None
self._range_q_vec: Optional[np.ndarray] = None self._range_q_vec: np.ndarray | None = None
# Rebuild flag # Rebuild flag
self._needs_rebuild = False self._needs_rebuild = False
def open(self, device_index: int = 0) -> bool: def open(self, _device_index: int = 0) -> bool:
try: try:
self._load_arrays() self._load_arrays()
self._packets = self._build_packets() self._packets = self._build_packets()
@@ -604,14 +616,14 @@ class ReplayConnection:
f"(MTI={'ON' if self._mti_enable else 'OFF'}, " f"(MTI={'ON' if self._mti_enable else 'OFF'}, "
f"{self._frame_len} bytes/frame)") f"{self._frame_len} bytes/frame)")
return True return True
except Exception as e: except (OSError, ValueError, struct.error) as e:
log.error(f"Replay open failed: {e}") log.error(f"Replay open failed: {e}")
return False return False
def close(self): def close(self):
self.is_open = False 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: if not self.is_open:
return None return None
# Pace reads to target FPS (spread across ~64 reads per frame) # Pace reads to target FPS (spread across ~64 reads per frame)
@@ -673,8 +685,7 @@ class ReplayConnection:
if self._mti_enable != new_en: if self._mti_enable != new_en:
self._mti_enable = new_en self._mti_enable = new_en
changed = True changed = True
elif opcode == 0x27: # DC_NOTCH_WIDTH elif opcode == 0x27 and self._dc_notch_width != value: # DC_NOTCH_WIDTH
if self._dc_notch_width != value:
self._dc_notch_width = value self._dc_notch_width = value
changed = True changed = True
if changed: if changed:
@@ -827,7 +838,7 @@ class DataRecorder:
self._frame_count = 0 self._frame_count = 0
self._recording = True self._recording = True
log.info(f"Recording started: {filepath}") log.info(f"Recording started: {filepath}")
except Exception as e: except (OSError, ValueError) as e:
log.error(f"Failed to start recording: {e}") log.error(f"Failed to start recording: {e}")
def record_frame(self, frame: RadarFrame): def record_frame(self, frame: RadarFrame):
@@ -844,7 +855,7 @@ class DataRecorder:
fg.create_dataset("detections", data=frame.detections, compression="gzip") fg.create_dataset("detections", data=frame.detections, compression="gzip")
fg.create_dataset("range_profile", data=frame.range_profile, compression="gzip") fg.create_dataset("range_profile", data=frame.range_profile, compression="gzip")
self._frame_count += 1 self._frame_count += 1
except Exception as e: except (OSError, ValueError, TypeError) as e:
log.error(f"Recording error: {e}") log.error(f"Recording error: {e}")
def stop(self): def stop(self):
@@ -853,7 +864,7 @@ class DataRecorder:
self._file.attrs["end_time"] = time.time() self._file.attrs["end_time"] = time.time()
self._file.attrs["total_frames"] = self._frame_count self._file.attrs["total_frames"] = self._frame_count
self._file.close() self._file.close()
except Exception: except (OSError, ValueError, RuntimeError):
pass pass
self._file = None self._file = None
self._recording = False self._recording = False
@@ -871,7 +882,7 @@ class RadarAcquisition(threading.Thread):
""" """
def __init__(self, connection, frame_queue: queue.Queue, def __init__(self, connection, frame_queue: queue.Queue,
recorder: Optional[DataRecorder] = None, recorder: DataRecorder | None = None,
status_callback=None): status_callback=None):
super().__init__(daemon=True) super().__init__(daemon=True)
self.conn = connection self.conn = connection
@@ -888,13 +899,25 @@ class RadarAcquisition(threading.Thread):
def run(self): def run(self):
log.info("Acquisition thread started") log.info("Acquisition thread started")
residual = b""
while not self._stop_event.is_set(): while not self._stop_event.is_set():
raw = self.conn.read(4096) chunk = self.conn.read(4096)
if raw is None or len(raw) == 0: if chunk is None or len(chunk) == 0:
time.sleep(0.01) time.sleep(0.01)
continue continue
raw = residual + chunk
packets = RadarProtocol.find_packet_boundaries(raw) 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: for start, end, ptype in packets:
if ptype == "data": if ptype == "data":
parsed = RadarProtocol.parse_data_packet( parsed = RadarProtocol.parse_data_packet(
@@ -913,12 +936,12 @@ class RadarAcquisition(threading.Thread):
if self._status_callback is not None: if self._status_callback is not None:
try: try:
self._status_callback(status) self._status_callback(status)
except Exception as e: except Exception as e: # noqa: BLE001
log.error(f"Status callback error: {e}") log.error(f"Status callback error: {e}")
log.info("Acquisition thread stopped") 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.""" """Place sample into current frame and emit when complete."""
rbin = self._sample_idx // NUM_DOPPLER_BINS rbin = self._sample_idx // NUM_DOPPLER_BINS
dbin = self._sample_idx % NUM_DOPPLER_BINS dbin = self._sample_idx % NUM_DOPPLER_BINS
@@ -948,10 +971,8 @@ class RadarAcquisition(threading.Thread):
try: try:
self.frame_queue.put_nowait(self._frame) self.frame_queue.put_nowait(self._frame)
except queue.Full: except queue.Full:
try: with contextlib.suppress(queue.Empty):
self.frame_queue.get_nowait() self.frame_queue.get_nowait()
except queue.Empty:
pass
self.frame_queue.put_nowait(self._frame) self.frame_queue.put_nowait(self._frame)
if self.recorder and self.recorder.recording: 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
+3 -5
View File
@@ -66,7 +66,7 @@ TEST_NAMES = {
class SmokeTest: class SmokeTest:
"""Host-side smoke test controller.""" """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.conn = connection
self.adc_dump_path = adc_dump_path self.adc_dump_path = adc_dump_path
self._adc_samples = [] self._adc_samples = []
@@ -82,8 +82,7 @@ class SmokeTest:
log.info("") log.info("")
# Step 1: Connect # Step 1: Connect
if not self.conn.is_open: if not self.conn.is_open and not self.conn.open():
if not self.conn.open():
log.error("Failed to open FT2232H connection") log.error("Failed to open FT2232H connection")
return False return False
@@ -188,9 +187,8 @@ class SmokeTest:
def _save_adc_dump(self): def _save_adc_dump(self):
"""Save captured ADC samples to numpy file.""" """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 # 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: if self._adc_samples:
+39 -20
View File
@@ -368,7 +368,7 @@ class TestRadarAcquisition(unittest.TestCase):
# Wait for at least one frame (mock produces ~32 samples per read, # Wait for at least one frame (mock produces ~32 samples per read,
# need 2048 for a full frame, so may take a few seconds) # need 2048 for a full frame, so may take a few seconds)
frame = None frame = None
try: try: # noqa: SIM105
frame = fq.get(timeout=10) frame = fq.get(timeout=10)
except queue.Empty: except queue.Empty:
pass pass
@@ -421,8 +421,8 @@ class TestEndToEnd(unittest.TestCase):
def test_command_roundtrip_all_opcodes(self): def test_command_roundtrip_all_opcodes(self):
"""Verify all opcodes produce valid 4-byte commands.""" """Verify all opcodes produce valid 4-byte commands."""
opcodes = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x10, 0x11, 0x12, opcodes = [0x01, 0x02, 0x03, 0x04, 0x10, 0x11, 0x12,
0x13, 0x14, 0x15, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x13, 0x14, 0x15, 0x16, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25,
0x26, 0x27, 0x30, 0x31, 0xFF] 0x26, 0x27, 0x30, 0x31, 0xFF]
for op in opcodes: for op in opcodes:
cmd = RadarProtocol.build_command(op, 42) cmd = RadarProtocol.build_command(op, 42)
@@ -630,8 +630,8 @@ class TestReplayConnection(unittest.TestCase):
cmd = RadarProtocol.build_command(0x01, 1) cmd = RadarProtocol.build_command(0x01, 1)
conn.write(cmd) conn.write(cmd)
self.assertFalse(conn._needs_rebuild) self.assertFalse(conn._needs_rebuild)
# Send STREAM_ENABLE (hardware-only) # Send STREAM_CONTROL (hardware-only, opcode 0x04)
cmd = RadarProtocol.build_command(0x05, 7) cmd = RadarProtocol.build_command(0x04, 7)
conn.write(cmd) conn.write(cmd)
self.assertFalse(conn._needs_rebuild) self.assertFalse(conn._needs_rebuild)
conn.close() conn.close()
@@ -668,14 +668,14 @@ class TestReplayConnection(unittest.TestCase):
class TestOpcodeEnum(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): def test_gain_shift_is_0x16(self):
"""GAIN_SHIFT opcode must be 0x06 (not 0x16).""" """GAIN_SHIFT opcode must be 0x16 (matches radar_system_top.v:928)."""
self.assertEqual(Opcode.GAIN_SHIFT, 0x06) self.assertEqual(Opcode.GAIN_SHIFT, 0x16)
def test_no_digital_gain_alias(self): 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')) self.assertFalse(hasattr(Opcode, 'DIGITAL_GAIN'))
def test_self_test_trigger(self): def test_self_test_trigger(self):
@@ -691,21 +691,40 @@ class TestOpcodeEnum(unittest.TestCase):
self.assertIn(0x30, _HARDWARE_ONLY_OPCODES) self.assertIn(0x30, _HARDWARE_ONLY_OPCODES)
self.assertIn(0x31, _HARDWARE_ONLY_OPCODES) self.assertIn(0x31, _HARDWARE_ONLY_OPCODES)
def test_0x16_not_in_hardware_only(self): def test_0x16_in_hardware_only(self):
"""Bogus 0x16 must not be in _HARDWARE_ONLY_OPCODES.""" """GAIN_SHIFT 0x16 must be in _HARDWARE_ONLY_OPCODES."""
self.assertNotIn(0x16, _HARDWARE_ONLY_OPCODES) self.assertIn(0x16, _HARDWARE_ONLY_OPCODES)
def test_stream_enable_is_0x05(self): def test_stream_control_is_0x04(self):
"""STREAM_ENABLE must be 0x05 (not 0x04).""" """STREAM_CONTROL must be 0x04 (matches radar_system_top.v:906)."""
self.assertEqual(Opcode.STREAM_ENABLE, 0x05) 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): def test_all_rtl_opcodes_present(self):
"""Every RTL opcode has a matching Opcode enum member.""" """Every RTL opcode (from radar_system_top.v) has a matching Opcode enum member."""
expected = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, expected = {0x01, 0x02, 0x03, 0x04,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27,
0x30, 0x31, 0xFF} 0x30, 0x31, 0xFF}
enum_values = set(int(m) for m in Opcode) enum_values = {int(m) for m in Opcode}
for op in expected: for op in expected:
self.assertIn(op, enum_values, f"0x{op:02X} missing from Opcode enum") 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_TREEVIEW, DARK_TREEVIEW_ALT,
DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO, DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO,
USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE, 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 ( from .hardware import (
FT2232HQInterface, FT2232HConnection,
ReplayConnection,
RadarProtocol,
Opcode,
RadarAcquisition,
RadarFrame,
StatusResponse,
DataRecorder,
STM32USBInterface, STM32USBInterface,
) )
# Processing pipeline # Processing pipeline
from .processing import ( from .processing import (
RadarProcessor, RadarProcessor,
RadarPacketParser,
USBPacketParser, USBPacketParser,
apply_pitch_correction, apply_pitch_correction,
) )
@@ -56,7 +62,7 @@ from .dashboard import (
RangeDopplerCanvas, RangeDopplerCanvas,
) )
__all__ = [ __all__ = [ # noqa: RUF022
# models # models
"RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer", "RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
"DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER", "DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
@@ -64,11 +70,13 @@ __all__ = [
"DARK_TREEVIEW", "DARK_TREEVIEW_ALT", "DARK_TREEVIEW", "DARK_TREEVIEW_ALT",
"DARK_SUCCESS", "DARK_WARNING", "DARK_ERROR", "DARK_INFO", "DARK_SUCCESS", "DARK_WARNING", "DARK_ERROR", "DARK_INFO",
"USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE", "USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE",
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", "CRCMOD_AVAILABLE", "SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE",
# hardware # hardware — production FPGA protocol
"FT2232HQInterface", "STM32USBInterface", "FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode",
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
"STM32USBInterface",
# processing # processing
"RadarProcessor", "RadarPacketParser", "USBPacketParser", "RadarProcessor", "USBPacketParser",
"apply_pitch_correction", "apply_pitch_correction",
# workers # workers
"RadarDataWorker", "GPSDataWorker", "TargetSimulator", "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. v7.hardware Hardware interface classes for the PLFM Radar GUI V7.
Provides two USB hardware interfaces: Provides:
- FT2232HQInterface (PRIMARY USB 2.0, VID 0x0403 / PID 0x6010) - FT2232H radar data + command interface via production radar_protocol module
- STM32USBInterface (USB CDC for commands and GPS) - 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 import logging
from typing import List, Dict, Optional from typing import ClassVar
from .models import ( from .models import USB_AVAILABLE
USB_AVAILABLE, FTDI_AVAILABLE,
RadarSettings,
)
if USB_AVAILABLE: if USB_AVAILABLE:
import usb.core import usb.core
import usb.util import usb.util
if FTDI_AVAILABLE: # Import production protocol layer — single source of truth for FPGA comms
from pyftdi.ftdi import Ftdi sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pyftdi.usbtools import UsbTools from radar_protocol import ( # noqa: F401 — re-exported for v7 package
FT2232HConnection,
ReplayConnection,
RadarProtocol,
Opcode,
RadarAcquisition,
RadarFrame,
StatusResponse,
DataRecorder,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ============================================================================= # =============================================================================
# FT2232HQ Interface — PRIMARY data path (USB 2.0) # STM32 USB CDC Interface — GPS data ONLY
# =============================================================================
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
# ============================================================================= # =============================================================================
class STM32USBInterface: class STM32USBInterface:
""" """
Interface for STM32 USB CDC (Virtual COM Port). Interface for STM32 USB CDC (Virtual COM Port).
Used to: Used ONLY for receiving GPS data from the MCU.
- Send start flag and radar settings to the MCU
- Receive 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, 0x5740), # STM32 Virtual COM Port
(0x0483, 0x3748), # STM32 Discovery (0x0483, 0x3748), # STM32 Discovery
(0x0483, 0x374B), (0x0483, 0x374B),
@@ -152,7 +73,7 @@ class STM32USBInterface:
# ---- enumeration ------------------------------------------------------- # ---- enumeration -------------------------------------------------------
def list_devices(self) -> List[Dict]: def list_devices(self) -> list[dict]:
"""List available STM32 USB CDC devices.""" """List available STM32 USB CDC devices."""
if not USB_AVAILABLE: if not USB_AVAILABLE:
logger.warning("pyusb not available — cannot enumerate STM32 devices") logger.warning("pyusb not available — cannot enumerate STM32 devices")
@@ -174,20 +95,20 @@ class STM32USBInterface:
"product_id": pid, "product_id": pid,
"device": dev, "device": dev,
}) })
except Exception: except (usb.core.USBError, ValueError):
devices.append({ devices.append({
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})", "description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
"vendor_id": vid, "vendor_id": vid,
"product_id": pid, "product_id": pid,
"device": dev, "device": dev,
}) })
except Exception as e: except (usb.core.USBError, ValueError) as e:
logger.error(f"Error listing STM32 devices: {e}") logger.error(f"Error listing STM32 devices: {e}")
return devices return devices
# ---- open / close ------------------------------------------------------ # ---- open / close ------------------------------------------------------
def open_device(self, device_info: Dict) -> bool: def open_device(self, device_info: dict) -> bool:
"""Open STM32 USB CDC device.""" """Open STM32 USB CDC device."""
if not USB_AVAILABLE: if not USB_AVAILABLE:
logger.error("pyusb not available — cannot open STM32 device") logger.error("pyusb not available — cannot open STM32 device")
@@ -225,7 +146,7 @@ class STM32USBInterface:
self.is_open = True self.is_open = True
logger.info(f"STM32 USB device opened: {device_info.get('description', '')}") logger.info(f"STM32 USB device opened: {device_info.get('description', '')}")
return True return True
except Exception as e: except (usb.core.USBError, ValueError) as e:
logger.error(f"Error opening STM32 device: {e}") logger.error(f"Error opening STM32 device: {e}")
return False return False
@@ -234,74 +155,22 @@ class STM32USBInterface:
if self.device and self.is_open: if self.device and self.is_open:
try: try:
usb.util.dispose_resources(self.device) usb.util.dispose_resources(self.device)
except Exception as e: except usb.core.USBError as e:
logger.error(f"Error closing STM32 device: {e}") logger.error(f"Error closing STM32 device: {e}")
self.is_open = False self.is_open = False
self.device = None self.device = None
self.ep_in = None self.ep_in = None
self.ep_out = None self.ep_out = None
# ---- commands ---------------------------------------------------------- # ---- GPS data I/O ------------------------------------------------------
def send_start_flag(self) -> bool: def read_data(self, size: int = 64, timeout: int = 1000) -> bytes | None:
"""Send start flag to STM32 (4-byte magic).""" """Read GPS data from STM32 via USB CDC."""
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."""
if not self.is_open or self.ep_in is None: if not self.is_open or self.ep_in is None:
return None return None
try: try:
data = self.ep_in.read(size, timeout=timeout) data = self.ep_in.read(size, timeout=timeout)
return bytes(data) return bytes(data)
except Exception: except usb.core.USBError:
# Timeout or other USB error # Timeout or other USB error
return None 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
+60 -53
View File
@@ -12,7 +12,6 @@ coverage circle, target trails, velocity-based color coding, popups, legend.
import json import json
import logging import logging
from typing import List
from PyQt6.QtWidgets import ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFrame, QWidget, QVBoxLayout, QHBoxLayout, QFrame,
@@ -65,7 +64,7 @@ class MapBridge(QObject):
@pyqtSlot(str) @pyqtSlot(str)
def logFromJS(self, message: str): def logFromJS(self, message: str):
logger.debug(f"[JS] {message}") logger.info(f"[JS] {message}")
@property @property
def is_ready(self) -> bool: def is_ready(self) -> bool:
@@ -96,7 +95,8 @@ class RadarMapWidget(QWidget):
latitude=radar_lat, longitude=radar_lon, latitude=radar_lat, longitude=radar_lon,
altitude=0.0, pitch=0.0, heading=0.0, 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._coverage_radius = 50_000 # metres
self._tile_server = TileServer.OPENSTREETMAP self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True self._show_coverage = True
@@ -282,15 +282,10 @@ function initMap() {{
.setView([{lat}, {lon}], 10); .setView([{lat}, {lon}], 10);
setTileServer('osm'); setTileServer('osm');
var radarIcon = L.divIcon({{ radarMarker = L.circleMarker([{lat},{lon}], {{
className:'radar-icon', radius:12, fillColor:'#FF5252', color:'white',
html:'<div style="background:radial-gradient(circle,#FF5252 0%,#D32F2F 100%);'+ weight:3, opacity:1, fillOpacity:1
'width:24px;height:24px;border-radius:50%;border:3px solid white;'+ }}).addTo(map);
'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);
updateRadarPopup(); updateRadarPopup();
coverageCircle = L.circle([{lat},{lon}], {{ coverageCircle = L.circle([{lat},{lon}], {{
@@ -366,14 +361,20 @@ function updateRadarPosition(lat,lon,alt,pitch,heading) {{
}} }}
function updateTargets(targetsJson) {{ function updateTargets(targetsJson) {{
try {{
if(!map) {{
if(bridge) bridge.logFromJS('updateTargets: map not ready yet');
return;
}}
var targets = JSON.parse(targetsJson); var targets = JSON.parse(targetsJson);
if(bridge) bridge.logFromJS('updateTargets: parsed '+targets.length+' targets');
var currentIds = {{}}; var currentIds = {{}};
targets.forEach(function(t) {{ targets.forEach(function(t) {{
currentIds[t.id] = true; currentIds[t.id] = true;
var lat=t.latitude, lon=t.longitude; var lat=t.latitude, lon=t.longitude;
var color = getTargetColor(t.velocity); var color = getTargetColor(t.velocity);
var sz = Math.max(10, Math.min(20, 10+t.snr/3)); var radius = Math.max(5, Math.min(12, 5+(t.snr||0)/5));
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = []; if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
targetTrailHistory[t.id].push([lat,lon]); targetTrailHistory[t.id].push([lat,lon]);
@@ -382,13 +383,18 @@ function updateTargets(targetsJson) {{
if(targetMarkers[t.id]) {{ if(targetMarkers[t.id]) {{
targetMarkers[t.id].setLatLng([lat,lon]); targetMarkers[t.id].setLatLng([lat,lon]);
targetMarkers[t.id].setIcon(makeIcon(color,sz)); targetMarkers[t.id].setStyle({{
fillColor:color, color:'white', radius:radius
}});
if(targetTrails[t.id]) {{ if(targetTrails[t.id]) {{
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]); targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
targetTrails[t.id].setStyle({{ color:color }}); targetTrails[t.id].setStyle({{ color:color }});
}} }}
}} else {{ }} else {{
var marker = L.marker([lat,lon], {{ icon:makeIcon(color,sz) }}).addTo(map); var marker = L.circleMarker([lat,lon], {{
radius:radius, fillColor:color, color:'white',
weight:2, opacity:1, fillOpacity:0.9
}}).addTo(map);
marker.on( marker.on(
'click', 'click',
(function(id){{ (function(id){{
@@ -398,7 +404,8 @@ function updateTargets(targetsJson) {{
targetMarkers[t.id] = marker; targetMarkers[t.id] = marker;
if(showTrails) {{ if(showTrails) {{
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{ targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
color:color, weight:3, opacity:0.7, lineCap:'round', lineJoin:'round' color:color, weight:3, opacity:0.7,
lineCap:'round', lineJoin:'round'
}}).addTo(map); }}).addTo(map);
}} }}
}} }}
@@ -408,22 +415,16 @@ function updateTargets(targetsJson) {{
for(var id in targetMarkers) {{ for(var id in targetMarkers) {{
if(!currentIds[id]) {{ if(!currentIds[id]) {{
map.removeLayer(targetMarkers[id]); delete targetMarkers[id]; map.removeLayer(targetMarkers[id]); delete targetMarkers[id];
if(targetTrails[id]) {{ map.removeLayer(targetTrails[id]); delete targetTrails[id]; }} if(targetTrails[id]) {{
map.removeLayer(targetTrails[id]);
delete targetTrails[id];
}}
delete targetTrailHistory[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) {{ function updateTargetPopup(t) {{
@@ -432,36 +433,27 @@ function updateTargetPopup(t) {{
? 'status-approaching' ? 'status-approaching'
: (t.velocity<-1 ? 'status-receding' : 'status-stationary'); : (t.velocity<-1 ? 'status-receding' : 'status-stationary');
var st = t.velocity>1?'Approaching':(t.velocity<-1?'Receding':'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( targetMarkers[t.id].bindPopup(
'<div class="popup-title">Target #'+t.id+'</div>'+ '<div class="popup-title">Target #'+t.id+'</div>'+
(
'<div class="popup-row"><span class="popup-label">Range:</span>'+ '<div class="popup-row"><span class="popup-label">Range:</span>'+
'<span class="popup-value">'+t.range.toFixed(1)+' m</span></div>' '<span class="popup-value">'+rng+' m</span></div>'+
)+
(
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+ '<div class="popup-row"><span class="popup-label">Velocity:</span>'+
'<span class="popup-value">'+t.velocity.toFixed(1)+' m/s</span></div>' '<span class="popup-value">'+vel+' m/s</span></div>'+
)+
(
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+ '<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
'<span class="popup-value">'+t.azimuth.toFixed(1)+'&deg;</span></div>' '<span class="popup-value">'+az+'&deg;</span></div>'+
)+
(
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+ '<div class="popup-row"><span class="popup-label">Elevation:</span>'+
'<span class="popup-value">'+t.elevation.toFixed(1)+'&deg;</span></div>' '<span class="popup-value">'+el+'&deg;</span></div>'+
)+
(
'<div class="popup-row"><span class="popup-label">SNR:</span>'+ '<div class="popup-row"><span class="popup-label">SNR:</span>'+
'<span class="popup-value">'+t.snr.toFixed(1)+' dB</span></div>' '<span class="popup-value">'+snr+' dB</span></div>'+
)+
(
'<div class="popup-row"><span class="popup-label">Track:</span>'+ '<div class="popup-row"><span class="popup-label">Track:</span>'+
'<span class="popup-value">'+t.track_id+'</span></div>' '<span class="popup-value">'+t.track_id+'</span></div>'+
)+
(
'<div class="popup-row"><span class="popup-label">Status:</span>'+ '<div class="popup-row"><span class="popup-label">Status:</span>'+
'<span class="popup-value '+sc+'">'+st+'</span></div>' '<span class="popup-value '+sc+'">'+st+'</span></div>'
)
); );
}} }}
@@ -531,12 +523,19 @@ document.addEventListener('DOMContentLoaded', function() {{
def _on_map_ready(self): def _on_map_ready(self):
self._status_label.setText(f"Map ready - {len(self._targets)} targets") self._status_label.setText(f"Map ready - {len(self._targets)} targets")
self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};") 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): def _on_marker_clicked(self, tid: int):
self.targetSelected.emit(tid) self.targetSelected.emit(tid)
def _run_js(self, script: str): 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 --------------------------------------------- # ---- control bar callbacks ---------------------------------------------
@@ -571,12 +570,20 @@ document.addEventListener('DOMContentLoaded', function() {{
f"{gps.altitude},{gps.pitch},{gps.heading})" f"{gps.altitude},{gps.pitch},{gps.heading})"
) )
def set_targets(self, targets: List[RadarTarget]): def set_targets(self, targets: list[RadarTarget]):
self._targets = targets 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] 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._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): def set_coverage_radius(self, radius_m: float):
self._coverage_radius = radius_m self._coverage_radius = radius_m
+20 -19
View File
@@ -54,13 +54,6 @@ except ImportError:
FILTERPY_AVAILABLE = False FILTERPY_AVAILABLE = False
logging.warning("filterpy not available. Kalman tracking will be disabled.") 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) # Dark theme color constants (shared by all modules)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -105,15 +98,19 @@ class RadarTarget:
@dataclass @dataclass
class RadarSettings: class RadarSettings:
"""Radar system configuration parameters.""" """Radar system display/map configuration.
system_frequency: float = 10e9 # Hz
chirp_duration_1: float = 30e-6 # Long chirp duration (s) FPGA register parameters (chirp timing, CFAR, MTI, gain, etc.) are
chirp_duration_2: float = 0.5e-6 # Short chirp duration (s) controlled directly via 4-byte opcode commands see the FPGA Control
chirps_per_position: int = 32 tab and Opcode enum in radar_protocol.py. This dataclass holds only
freq_min: float = 10e6 # Hz host-side display/map settings and physical-unit conversion factors.
freq_max: float = 30e6 # Hz
prf1: float = 1000 # PRF 1 (Hz) range_resolution and velocity_resolution should be calibrated to
prf2: float = 2000 # PRF 2 (Hz) 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) max_distance: float = 50000 # Max detection range (m)
map_size: float = 50000 # Map display size (m) map_size: float = 50000 # Map display size (m)
coverage_radius: float = 50000 # Map coverage radius (m) coverage_radius: float = 50000 # Map coverage radius (m)
@@ -139,10 +136,14 @@ class GPSData:
@dataclass @dataclass
class ProcessingConfig: class ProcessingConfig:
"""Signal processing pipeline configuration. """Host-side signal processing pipeline configuration.
Controls: MTI filter, CFAR detector, DC notch removal, These control host-side DSP that runs AFTER the FPGA processing
windowing, detection threshold, DBSCAN clustering, and Kalman tracking. 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) # MTI (Moving Target Indication)
+20 -209
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: Classes:
- RadarProcessor dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering, - RadarProcessor dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering,
association, Kalman tracking 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 - USBPacketParser parse GPS text/binary frames from STM32 CDC
Bug fixes vs V6: Note: RadarPacketParser (old A5/C3 sync + CRC16 format) was removed.
1. RadarPacketParser.parse_packet() now returns (dict, bytes_consumed) tuple All packet parsing now uses production RadarProtocol (0xAA/0xBB format)
so the caller knows exactly how many bytes to strip from the buffer. from radar_protocol.py.
2. apply_pitch_correction() is a proper standalone function.
""" """
import struct import struct
import time import time
import logging import logging
import math import math
from typing import Optional, Tuple, List, Dict
import numpy as np import numpy as np
from .models import ( from .models import (
RadarTarget, GPSData, ProcessingConfig, RadarTarget, GPSData, ProcessingConfig,
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE, SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
) )
if SKLEARN_AVAILABLE: if SKLEARN_AVAILABLE:
@@ -33,9 +29,6 @@ if SKLEARN_AVAILABLE:
if FILTERPY_AVAILABLE: if FILTERPY_AVAILABLE:
from filterpy.kalman import KalmanFilter from filterpy.kalman import KalmanFilter
if CRCMOD_AVAILABLE:
import crcmod
if SCIPY_AVAILABLE: if SCIPY_AVAILABLE:
from scipy.signal import windows as scipy_windows from scipy.signal import windows as scipy_windows
@@ -64,14 +57,14 @@ class RadarProcessor:
def __init__(self): def __init__(self):
self.range_doppler_map = np.zeros((1024, 32)) 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.track_id_counter: int = 0
self.tracks: Dict[int, dict] = {} self.tracks: dict[int, dict] = {}
self.frame_count: int = 0 self.frame_count: int = 0
self.config = ProcessingConfig() self.config = ProcessingConfig()
# MTI state: store previous frames for cancellation # MTI state: store previous frames for cancellation
self._mti_history: List[np.ndarray] = [] self._mti_history: list[np.ndarray] = []
# ---- Configuration ----------------------------------------------------- # ---- Configuration -----------------------------------------------------
@@ -160,11 +153,10 @@ class RadarProcessor:
h = self._mti_history h = self._mti_history
if order == 1: if order == 1:
return h[-1] - h[-2] return h[-1] - h[-2]
elif order == 2: if order == 2:
return h[-1] - 2.0 * h[-2] + h[-3] 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] 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) ----------------------------------- # ---- CFAR (Constant False Alarm Rate) -----------------------------------
@@ -234,7 +226,7 @@ class RadarProcessor:
# ---- Full processing pipeline ------------------------------------------- # ---- 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. """Run the full signal processing chain on a Range x Doppler frame.
Parameters Parameters
@@ -289,34 +281,10 @@ class RadarProcessor:
"""Dual-CPI fusion for better detection.""" """Dual-CPI fusion for better detection."""
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0) 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 ------------------------------------------------- # ---- DBSCAN clustering -------------------------------------------------
@staticmethod @staticmethod
def clustering(detections: List[RadarTarget], def clustering(detections: list[RadarTarget],
eps: float = 100, min_samples: int = 2) -> list: eps: float = 100, min_samples: int = 2) -> list:
"""DBSCAN clustering of detections (requires sklearn).""" """DBSCAN clustering of detections (requires sklearn)."""
if not SKLEARN_AVAILABLE or len(detections) == 0: if not SKLEARN_AVAILABLE or len(detections) == 0:
@@ -339,8 +307,8 @@ class RadarProcessor:
# ---- Association ------------------------------------------------------- # ---- Association -------------------------------------------------------
def association(self, detections: List[RadarTarget], def association(self, detections: list[RadarTarget],
clusters: list) -> List[RadarTarget]: _clusters: list) -> list[RadarTarget]:
"""Associate detections to existing tracks (nearest-neighbour).""" """Associate detections to existing tracks (nearest-neighbour)."""
associated = [] associated = []
for det in detections: for det in detections:
@@ -366,7 +334,7 @@ class RadarProcessor:
# ---- Kalman tracking --------------------------------------------------- # ---- Kalman tracking ---------------------------------------------------
def tracking(self, associated_detections: List[RadarTarget]): def tracking(self, associated_detections: list[RadarTarget]):
"""Kalman filter tracking (requires filterpy).""" """Kalman filter tracking (requires filterpy)."""
if not FILTERPY_AVAILABLE: if not FILTERPY_AVAILABLE:
return return
@@ -412,158 +380,6 @@ class RadarProcessor:
del self.tracks[tid] 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 # USB / GPS Packet Parser
# ============================================================================= # =============================================================================
@@ -578,14 +394,9 @@ class USBPacketParser:
""" """
def __init__(self): def __init__(self):
if CRCMOD_AVAILABLE: pass
self.crc16_func = crcmod.mkCrcFun(
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
)
else:
self.crc16_func = None
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.""" """Attempt to parse GPS data from a raw USB CDC frame."""
if not data: if not data:
return None return None
@@ -607,12 +418,12 @@ class USBPacketParser:
# Binary format: [GPSB 4][lat 8][lon 8][alt 4][pitch 4][CRC 2] = 30 bytes # 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": if len(data) >= 30 and data[0:4] == b"GPSB":
return self._parse_binary_gps(data) return self._parse_binary_gps(data)
except Exception as e: except (ValueError, struct.error) as e:
logger.error(f"Error parsing GPS data: {e}") logger.error(f"Error parsing GPS data: {e}")
return None return None
@staticmethod @staticmethod
def _parse_binary_gps(data: bytes) -> Optional[GPSData]: def _parse_binary_gps(data: bytes) -> GPSData | None:
"""Parse 30-byte binary GPS frame.""" """Parse 30-byte binary GPS frame."""
try: try:
if len(data) < 30: if len(data) < 30:
@@ -637,6 +448,6 @@ class USBPacketParser:
pitch=pitch, pitch=pitch,
timestamp=time.time(), timestamp=time.time(),
) )
except Exception as e: except (ValueError, struct.error) as e:
logger.error(f"Error parsing binary GPS: {e}") logger.error(f"Error parsing binary GPS: {e}")
return None return None
+163 -114
View File
@@ -2,24 +2,39 @@
v7.workers QThread-based workers and demo target simulator. v7.workers QThread-based workers and demo target simulator.
Classes: Classes:
- RadarDataWorker reads from FT2232HQ, parses packets, - RadarDataWorker reads from FT2232H via production RadarAcquisition,
emits signals with processed data. parses 0xAA/0xBB packets, assembles 64x32 frames,
runs host-side DSP, emits PyQt signals.
- GPSDataWorker reads GPS frames from STM32 CDC, emits GPSData 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 math
import time import time
import random import random
import queue
import struct
import logging import logging
from typing import List
import numpy as np
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
from .models import RadarTarget, RadarSettings, GPSData from .models import RadarTarget, GPSData, RadarSettings
from .hardware import FT2232HQInterface, STM32USBInterface from .hardware import (
RadarAcquisition,
RadarFrame,
StatusResponse,
DataRecorder,
STM32USBInterface,
)
from .processing import ( from .processing import (
RadarProcessor, RadarPacketParser, USBPacketParser, RadarProcessor,
USBPacketParser,
apply_pitch_correction, apply_pitch_correction,
) )
@@ -61,162 +76,196 @@ def polar_to_geographic(
# ============================================================================= # =============================================================================
# Radar Data Worker (QThread) # Radar Data Worker (QThread) — production protocol
# ============================================================================= # =============================================================================
class RadarDataWorker(QThread): class RadarDataWorker(QThread):
""" """
Background worker that continuously reads radar data from the primary Background worker that reads radar data from FT2232H (or ReplayConnection),
FT2232HQ interface, parses packets, runs the processing pipeline, and parses 0xAA/0xBB packets via production RadarAcquisition, runs optional
emits signals with results. 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: Signals:
packetReceived(dict) a single parsed packet dict frameReady(RadarFrame) a complete 64x32 radar frame
targetsUpdated(list) list of RadarTarget after processing statusReceived(object) StatusResponse from FPGA
targetsUpdated(list) list of RadarTarget after host-side DSP
errorOccurred(str) error message errorOccurred(str) error message
statsUpdated(dict) packet/byte counters statsUpdated(dict) frame/byte counters
""" """
packetReceived = pyqtSignal(dict) frameReady = pyqtSignal(object) # RadarFrame
targetsUpdated = pyqtSignal(list) statusReceived = pyqtSignal(object) # StatusResponse
targetsUpdated = pyqtSignal(list) # List[RadarTarget]
errorOccurred = pyqtSignal(str) errorOccurred = pyqtSignal(str)
statsUpdated = pyqtSignal(dict) statsUpdated = pyqtSignal(dict)
def __init__( def __init__(
self, self,
ft2232hq: FT2232HQInterface, connection, # FT2232HConnection or ReplayConnection
processor: RadarProcessor, processor: RadarProcessor | None = None,
packet_parser: RadarPacketParser, recorder: DataRecorder | None = None,
settings: RadarSettings, gps_data_ref: GPSData | None = None,
gps_data_ref: GPSData, settings: RadarSettings | None = None,
parent=None, parent=None,
): ):
super().__init__(parent) super().__init__(parent)
self._ft2232hq = ft2232hq self._connection = connection
self._processor = processor self._processor = processor
self._parser = packet_parser self._recorder = recorder
self._settings = settings
self._gps = gps_data_ref self._gps = gps_data_ref
self._settings = settings or RadarSettings()
self._running = False 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 # Counters
self._packet_count = 0 self._frame_count = 0
self._byte_count = 0 self._byte_count = 0
self._error_count = 0 self._error_count = 0
def stop(self): def stop(self):
self._running = False self._running = False
if self._acquisition:
self._acquisition.stop()
def run(self): 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 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: 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: try:
data = iface.read_data(4096) # Poll for complete frames from production acquisition
if data: frame: RadarFrame = self._frame_queue.get(timeout=0.1)
buffer.extend(data) self._frame_count += 1
self._byte_count += len(data)
# Parse as many packets as possible # Emit raw frame
while len(buffer) >= 6: self.frameReady.emit(frame)
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
pkt, consumed = result # Run host-side DSP if processor is configured
buffer = buffer[consumed:] if self._processor is not None:
self._packet_count += 1 targets = self._run_host_dsp(frame)
if targets:
self.targetsUpdated.emit(targets)
# Process the packet # Emit stats
self._process_packet(pkt)
self.packetReceived.emit(pkt)
# Emit stats periodically
self.statsUpdated.emit({ self.statsUpdated.emit({
"packets": self._packet_count, "frames": self._frame_count,
"bytes": self._byte_count, "detection_count": frame.detection_count,
"errors": self._error_count, "errors": self._error_count,
"active_tracks": len(self._processor.tracks),
"targets": len(self._processor.detected_targets),
}) })
else:
self.msleep(10) except queue.Empty:
except Exception as e: continue
except (ValueError, IndexError) as e:
self._error_count += 1 self._error_count += 1
self.errorOccurred.emit(str(e)) self.errorOccurred.emit(str(e))
logger.error(f"RadarDataWorker error: {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): logger.info("RadarDataWorker stopped")
"""Route a parsed packet through the processing pipeline."""
try: def _on_status(self, status: StatusResponse):
if pkt["type"] == "range": """Callback from production RadarAcquisition on status packet."""
range_m = pkt["range"] * 0.1 self.statusReceived.emit(status)
raw_elev = pkt["elevation"]
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) corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
# 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,
)
target = RadarTarget( target = RadarTarget(
id=pkt["chirp"], id=len(targets),
range=range_m, range=range_m,
velocity=0, velocity=velocity_ms,
azimuth=pkt["azimuth"], azimuth=azimuth,
elevation=corr_elev, elevation=corr_elev,
snr=20.0, latitude=lat,
timestamp=pkt["timestamp"], longitude=lon,
snr=snr,
timestamp=frame.timestamp,
) )
self._update_rdm(target) targets.append(target)
elif pkt["type"] == "doppler": # DBSCAN clustering
lam = 3e8 / self._settings.system_frequency if cfg.clustering_enabled and len(targets) > 0:
velocity = (pkt["doppler_real"] / 32767.0) * ( clusters = self._processor.clustering(
self._settings.prf1 * lam / 2 targets, cfg.clustering_eps, cfg.clustering_min_samples)
) # Associate and track
self._update_velocity(pkt, velocity) if cfg.tracking_enabled:
targets = self._processor.association(targets, clusters)
self._processor.tracking(targets)
elif pkt["type"] == "detection": return targets
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}")
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
# ============================================================================= # =============================================================================
@@ -269,7 +318,7 @@ class GPSDataWorker(QThread):
if gps: if gps:
self._gps_count += 1 self._gps_count += 1
self.gpsReceived.emit(gps) self.gpsReceived.emit(gps)
except Exception as e: except (ValueError, struct.error) as e:
self.errorOccurred.emit(str(e)) self.errorOccurred.emit(str(e))
logger.error(f"GPSDataWorker error: {e}") logger.error(f"GPSDataWorker error: {e}")
self.msleep(100) self.msleep(100)
@@ -292,7 +341,7 @@ class TargetSimulator(QObject):
def __init__(self, radar_position: GPSData, parent=None): def __init__(self, radar_position: GPSData, parent=None):
super().__init__(parent) super().__init__(parent)
self._radar_pos = radar_position self._radar_pos = radar_position
self._targets: List[RadarTarget] = [] self._targets: list[RadarTarget] = []
self._next_id = 1 self._next_id = 1
self._timer = QTimer(self) self._timer = QTimer(self)
self._timer.timeout.connect(self._tick) self._timer.timeout.connect(self._tick)
@@ -349,7 +398,7 @@ class TargetSimulator(QObject):
def _tick(self): def _tick(self):
"""Update all simulated targets and emit.""" """Update all simulated targets and emit."""
updated: List[RadarTarget] = [] updated: list[RadarTarget] = []
for t in self._targets: for t in self._targets:
new_range = t.range - t.velocity * 0.5 new_range = t.range - t.velocity * 0.5
+29 -36
View File
@@ -26,6 +26,7 @@ Usage:
""" """
import argparse import argparse
from contextlib import nullcontext
import datetime import datetime
import glob import glob
import os import os
@@ -38,7 +39,6 @@ try:
import serial import serial
import serial.tools.list_ports import serial.tools.list_ports
except ImportError: except ImportError:
print("ERROR: pyserial not installed. Run: pip install pyserial")
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -94,12 +94,9 @@ def list_ports():
"""Print available serial ports.""" """Print available serial ports."""
ports = serial.tools.list_ports.comports() ports = serial.tools.list_ports.comports()
if not ports: if not ports:
print("No serial ports found.")
return return
print(f"{'Port':<30} {'Description':<40} {'HWID'}") for _p in sorted(ports, key=lambda x: x.device):
print("-" * 100) pass
for p in sorted(ports, key=lambda x: x.device):
print(f"{p.device:<30} {p.description:<40} {p.hwid}")
def auto_detect_port(): def auto_detect_port():
@@ -172,10 +169,7 @@ def should_display(line, filter_subsys=None, errors_only=False):
return False return False
# Subsystem filter # Subsystem filter
if filter_subsys and subsys not in filter_subsys: return not (filter_subsys and subsys not in filter_subsys)
return False
return True
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -219,8 +213,10 @@ class CaptureStats:
] ]
if self.by_subsys: if self.by_subsys:
lines.append("By subsystem:") lines.append("By subsystem:")
for tag in sorted(self.by_subsys, key=self.by_subsys.get, reverse=True): lines.extend(
lines.append(f" {tag:<8} {self.by_subsys[tag]}") 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) return "\n".join(lines)
@@ -228,12 +224,12 @@ class CaptureStats:
# Main capture loop # 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.""" """Open serial port and capture DIAG output."""
stats = CaptureStats() stats = CaptureStats()
running = True running = True
def handle_signal(sig, frame): def handle_signal(_sig, _frame):
nonlocal running nonlocal running
running = False running = False
@@ -249,36 +245,36 @@ def capture(port, baud, log_file, filter_subsys, errors_only, use_color):
stopbits=serial.STOPBITS_ONE, stopbits=serial.STOPBITS_ONE,
timeout=0.1, # 100ms read timeout for responsive Ctrl-C timeout=0.1, # 100ms read timeout for responsive Ctrl-C
) )
except serial.SerialException as e: except serial.SerialException:
print(f"ERROR: Could not open {port}: {e}")
sys.exit(1) sys.exit(1)
print(f"Connected to {port} at {baud} baud")
if log_file: if log_file:
print(f"Logging to {log_file}") pass
if filter_subsys: if filter_subsys:
print(f"Filter: {', '.join(sorted(filter_subsys))}") pass
if errors_only: if errors_only:
print("Mode: errors/warnings only") pass
print("Press Ctrl-C to stop.\n")
flog = None
if log_file: if log_file:
os.makedirs(os.path.dirname(log_file), exist_ok=True) os.makedirs(os.path.dirname(log_file), exist_ok=True)
flog = open(log_file, "w", encoding=ENCODING) log_context = open(log_file, "w", encoding=ENCODING) # noqa: SIM115
else:
log_context = nullcontext(None)
line_buf = b""
try:
with log_context as flog:
if flog:
flog.write(f"# AERIS-10 UART capture — {datetime.datetime.now().isoformat()}\n") flog.write(f"# AERIS-10 UART capture — {datetime.datetime.now().isoformat()}\n")
flog.write(f"# Port: {port} Baud: {baud}\n") flog.write(f"# Port: {port} Baud: {baud}\n")
flog.write(f"# Host: {os.uname().nodename}\n\n") flog.write(f"# Host: {os.uname().nodename}\n\n")
flog.flush() flog.flush()
line_buf = b""
try:
while running: while running:
try: try:
chunk = ser.read(256) chunk = ser.read(256)
except serial.SerialException as e: except serial.SerialException:
print(f"\nSerial error: {e}")
break break
if not chunk: if not chunk:
@@ -304,14 +300,13 @@ def capture(port, baud, log_file, filter_subsys, errors_only, use_color):
# Terminal display respects filters # Terminal display respects filters
if should_display(line, filter_subsys, errors_only): if should_display(line, filter_subsys, errors_only):
print(colorize(line, use_color)) pass
if flog:
flog.write(f"\n{stats.summary()}\n")
finally: finally:
ser.close() 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: if not port:
port = auto_detect_port() port = auto_detect_port()
if not port: if not port:
print("ERROR: No serial port detected. Use -p to specify, or --list to see ports.")
sys.exit(1) sys.exit(1)
print(f"Auto-detected port: {port}")
# Resolve log file # Resolve log file
log_file = None log_file = None
@@ -390,7 +383,7 @@ def main():
# Parse filter # Parse filter
filter_subsys = None filter_subsys = None
if args.filter: 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 # Color detection
use_color = not args.no_color and sys.stdout.isatty() use_color = not args.no_color and sys.stdout.isatty()
+1 -1
View File
@@ -53,7 +53,7 @@ The AERIS-10 main sub-systems are:
- **XC7A50T FPGA** - Handles RADAR Signal Processing on the upstream FTG256 board: - **XC7A50T FPGA** - Handles RADAR Signal Processing on the upstream FTG256 board:
- PLFM Chirps generation via the DAC - PLFM Chirps generation via the DAC
- Raw ADC data read - Raw ADC data read
- Automatic Gain Control (AGC) - Digital Gain Control (host-configurable gain shift)
- I/Q Baseband Down-Conversion - I/Q Baseband Down-Conversion
- Decimation - Decimation
- Filtering - Filtering
+25 -1
View File
@@ -24,4 +24,28 @@ target-version = "py312"
line-length = 100 line-length = 100
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F"] select = [
"E", # pycodestyle errors
"F", # pyflakes (unused imports, undefined names, duplicate keys, assert-tuple)
"B", # flake8-bugbear (mutable defaults, unreachable code, raise-without-from)
"RUF", # ruff-specific (unused noqa, ambiguous chars, implicit Optional)
"SIM", # flake8-simplify (dead branches, collapsible ifs, unnecessary pass)
"PIE", # flake8-pie (no-op expressions, unnecessary spread)
"T20", # flake8-print (stray print() calls — LLMs leave debug prints)
"ARG", # flake8-unused-arguments (LLMs generate params they never use)
"ERA", # eradicate (commented-out code — LLMs leave "alternatives" as comments)
"A", # flake8-builtins (LLMs shadow id, type, list, dict, input, map)
"BLE", # flake8-blind-except (bare except / overly broad except)
"RET", # flake8-return (unreachable code after return, unnecessary else-after-return)
"ISC", # flake8-implicit-str-concat (missing comma in list of strings)
"TCH", # flake8-type-checking (imports only used in type hints — move behind TYPE_CHECKING)
"UP", # pyupgrade (outdated syntax for target Python version)
"C4", # flake8-comprehensions (unnecessary list/dict calls around generators)
"PERF", # perflint (performance anti-patterns: unnecessary list() in for loops, etc.)
]
[tool.ruff.lint.per-file-ignores]
# Tests: allow unused args (fixtures), prints (debugging), commented code (examples)
"test_*.py" = ["ARG", "T20", "ERA"]
# Re-export modules: unused imports are intentional
"v7/hardware.py" = ["F401"]