Merge pull request #59 from NawfalMotii79/feat/agc-fpga-gui

feat: Hybrid AGC system (FPGA+STM32+GUI) + timing hardening + 20 bug fixes
This commit is contained in:
Jason
2026-04-13 21:51:25 +03:00
committed by GitHub
88 changed files with 12238 additions and 11397 deletions
+33 -1
View File
@@ -46,7 +46,9 @@ jobs:
- name: Unit tests - name: Unit tests
run: > run: >
uv run pytest uv run pytest
9_Firmware/9_3_GUI/test_radar_dashboard.py -v --tb=short 9_Firmware/9_3_GUI/test_radar_dashboard.py
9_Firmware/9_3_GUI/test_v7.py
-v --tb=short
# =========================================================================== # ===========================================================================
# MCU Firmware Unit Tests (20 tests) # MCU Firmware Unit Tests (20 tests)
@@ -82,3 +84,33 @@ jobs:
- name: Run full FPGA regression - name: Run full FPGA regression
run: bash run_regression.sh run: bash run_regression.sh
working-directory: 9_Firmware/9_2_FPGA working-directory: 9_Firmware/9_2_FPGA
# ===========================================================================
# Cross-Layer Contract Tests (Python ↔ Verilog ↔ C)
# Validates opcode maps, bit widths, packet layouts, and round-trip
# correctness across FPGA RTL, Python GUI, and STM32 firmware.
# ===========================================================================
cross-layer-tests:
name: Cross-Layer Contract Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: astral-sh/setup-uv@v5
- name: Install dependencies
run: uv sync --group dev
- name: Install Icarus Verilog
run: sudo apt-get update && sudo apt-get install -y iverilog
- name: Run cross-layer contract tests
run: >
uv run pytest
9_Firmware/tests/cross_layer/test_cross_layer_contract.py
-v --tb=short
+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")
@@ -0,0 +1,116 @@
// ADAR1000_AGC.cpp -- STM32 outer-loop AGC implementation
//
// See ADAR1000_AGC.h for architecture overview.
#include "ADAR1000_AGC.h"
#include "ADAR1000_Manager.h"
#include "diag_log.h"
#include <cstring>
// ---------------------------------------------------------------------------
// Constructor -- set all config fields to safe defaults
// ---------------------------------------------------------------------------
ADAR1000_AGC::ADAR1000_AGC()
: agc_base_gain(ADAR1000Manager::kDefaultRxVgaGain) // 30
, gain_step_down(4)
, gain_step_up(1)
, min_gain(0)
, max_gain(127)
, holdoff_frames(4)
, enabled(true)
, holdoff_counter(0)
, last_saturated(false)
, saturation_event_count(0)
{
memset(cal_offset, 0, sizeof(cal_offset));
}
// ---------------------------------------------------------------------------
// update -- called once per frame with the FPGA DIG_5 saturation flag
//
// Returns true if agc_base_gain changed (caller should then applyGain).
// ---------------------------------------------------------------------------
void ADAR1000_AGC::update(bool fpga_saturation)
{
if (!enabled)
return;
last_saturated = fpga_saturation;
if (fpga_saturation) {
// Attack: reduce gain immediately
saturation_event_count++;
holdoff_counter = 0;
if (agc_base_gain >= gain_step_down + min_gain) {
agc_base_gain -= gain_step_down;
} else {
agc_base_gain = min_gain;
}
DIAG("AGC", "SAT detected -- gain_base -> %u (events=%lu)",
(unsigned)agc_base_gain, (unsigned long)saturation_event_count);
} else {
// Recovery: wait for holdoff, then increase gain
holdoff_counter++;
if (holdoff_counter >= holdoff_frames) {
holdoff_counter = 0;
if (agc_base_gain + gain_step_up <= max_gain) {
agc_base_gain += gain_step_up;
} else {
agc_base_gain = max_gain;
}
DIAG("AGC", "Recovery step -- gain_base -> %u", (unsigned)agc_base_gain);
}
}
}
// ---------------------------------------------------------------------------
// applyGain -- write effective gain to all 16 RX VGA channels
//
// Uses the Manager's adarSetRxVgaGain which takes 1-based channel indices
// (matching the convention in setBeamAngle).
// ---------------------------------------------------------------------------
void ADAR1000_AGC::applyGain(ADAR1000Manager &mgr)
{
for (uint8_t dev = 0; dev < AGC_NUM_DEVICES; ++dev) {
for (uint8_t ch = 0; ch < AGC_NUM_CHANNELS; ++ch) {
uint8_t gain = effectiveGain(dev * AGC_NUM_CHANNELS + ch);
// Channel parameter is 1-based per Manager convention
mgr.adarSetRxVgaGain(dev, ch + 1, gain, BROADCAST_OFF);
}
}
}
// ---------------------------------------------------------------------------
// resetState -- clear runtime counters, preserve configuration
// ---------------------------------------------------------------------------
void ADAR1000_AGC::resetState()
{
holdoff_counter = 0;
last_saturated = false;
saturation_event_count = 0;
}
// ---------------------------------------------------------------------------
// effectiveGain -- compute clamped per-channel gain
// ---------------------------------------------------------------------------
uint8_t ADAR1000_AGC::effectiveGain(uint8_t channel_index) const
{
if (channel_index >= AGC_TOTAL_CHANNELS)
return min_gain; // safety fallback — OOB channels get minimum gain
int16_t raw = static_cast<int16_t>(agc_base_gain) + cal_offset[channel_index];
if (raw < static_cast<int16_t>(min_gain))
return min_gain;
if (raw > static_cast<int16_t>(max_gain))
return max_gain;
return static_cast<uint8_t>(raw);
}
@@ -0,0 +1,97 @@
// ADAR1000_AGC.h -- STM32 outer-loop AGC for ADAR1000 RX VGA gain
//
// Adjusts the analog VGA common-mode gain on each ADAR1000 RX channel based on
// the FPGA's saturation flag (DIG_5 / PD13). Runs once per radar frame
// (~258 ms) in the main loop, after runRadarPulseSequence().
//
// Architecture:
// - Inner loop (FPGA, per-sample): rx_gain_control auto-adjusts digital
// gain_shift based on peak magnitude / saturation. Range ±42 dB.
// - Outer loop (THIS MODULE, per-frame): reads FPGA DIG_5 GPIO. If
// saturation detected, reduces agc_base_gain immediately (attack). If no
// saturation for holdoff_frames, increases agc_base_gain (decay/recovery).
//
// Per-channel gain formula:
// VGA[dev][ch] = clamp(agc_base_gain + cal_offset[dev*4+ch], min_gain, max_gain)
//
// The cal_offset array allows per-element calibration to correct inter-channel
// gain imbalance. Default is all zeros (uniform gain).
#ifndef ADAR1000_AGC_H
#define ADAR1000_AGC_H
#include <stdint.h>
// Forward-declare to avoid pulling in the full ADAR1000_Manager header here.
// The .cpp includes the real header.
class ADAR1000Manager;
// Number of ADAR1000 devices
#define AGC_NUM_DEVICES 4
// Number of channels per ADAR1000
#define AGC_NUM_CHANNELS 4
// Total RX channels
#define AGC_TOTAL_CHANNELS (AGC_NUM_DEVICES * AGC_NUM_CHANNELS)
class ADAR1000_AGC {
public:
// --- Configuration (public for easy field-testing / GUI override) ---
// Common-mode base gain (raw ADAR1000 register value, 0-255).
// Default matches ADAR1000Manager::kDefaultRxVgaGain = 30.
uint8_t agc_base_gain;
// Per-channel calibration offset (signed, added to agc_base_gain).
// Index = device*4 + channel. Default: all 0.
int8_t cal_offset[AGC_TOTAL_CHANNELS];
// How much to decrease agc_base_gain per frame when saturated (attack).
uint8_t gain_step_down;
// How much to increase agc_base_gain per frame when recovering (decay).
uint8_t gain_step_up;
// Minimum allowed agc_base_gain (floor).
uint8_t min_gain;
// Maximum allowed agc_base_gain (ceiling).
uint8_t max_gain;
// Number of consecutive non-saturated frames required before gain-up.
uint8_t holdoff_frames;
// Master enable. When false, update() is a no-op.
bool enabled;
// --- Runtime state (read-only for diagnostics) ---
// Consecutive non-saturated frame counter (resets on saturation).
uint8_t holdoff_counter;
// True if the last update() saw saturation.
bool last_saturated;
// Total saturation events since reset/construction.
uint32_t saturation_event_count;
// --- Methods ---
ADAR1000_AGC();
// Call once per frame after runRadarPulseSequence().
// fpga_saturation: result of HAL_GPIO_ReadPin(GPIOD, GPIO_PIN_13) == GPIO_PIN_SET
void update(bool fpga_saturation);
// Apply the current gain to all 16 RX VGA channels via the Manager.
void applyGain(ADAR1000Manager &mgr);
// Reset runtime state (holdoff counter, saturation count) without
// changing configuration.
void resetState();
// Compute the effective gain for a specific channel index (0-15),
// clamped to [min_gain, max_gain]. Useful for diagnostics.
uint8_t effectiveGain(uint8_t channel_index) const;
};
#endif // ADAR1000_AGC_H
@@ -7,8 +7,8 @@ RadarSettings::RadarSettings() {
void RadarSettings::resetToDefaults() { void RadarSettings::resetToDefaults() {
system_frequency = 10.0e9; // 10 GHz system_frequency = 10.0e9; // 10 GHz
chirp_duration_1 = 30.0e-6; // 30 µs chirp_duration_1 = 30.0e-6; // 30 s
chirp_duration_2 = 0.5e-6; // 0.5 µs chirp_duration_2 = 0.5e-6; // 0.5 s
chirps_per_position = 32; chirps_per_position = 32;
freq_min = 10.0e6; // 10 MHz freq_min = 10.0e6; // 10 MHz
freq_max = 30.0e6; // 30 MHz freq_max = 30.0e6; // 30 MHz
@@ -21,8 +21,8 @@ void RadarSettings::resetToDefaults() {
} }
bool RadarSettings::parseFromUSB(const uint8_t* data, uint32_t length) { bool RadarSettings::parseFromUSB(const uint8_t* data, uint32_t length) {
// Minimum packet size: "SET" + 8 doubles + 1 uint32_t + "END" = 3 + 8*8 + 4 + 3 = 74 bytes // Minimum packet size: "SET" + 9 doubles + 1 uint32_t + "END" = 3 + 9*8 + 4 + 3 = 82 bytes
if (data == nullptr || length < 74) { if (data == nullptr || length < 82) {
settings_valid = false; settings_valid = false;
return false; return false;
} }
@@ -43,6 +43,11 @@ void USBHandler::processStartFlag(const uint8_t* data, uint32_t length) {
// Start flag: bytes [23, 46, 158, 237] // Start flag: bytes [23, 46, 158, 237]
const uint8_t START_FLAG[] = {23, 46, 158, 237}; const uint8_t START_FLAG[] = {23, 46, 158, 237};
// Guard: need at least 4 bytes to contain a start flag.
// Without this, length - 4 wraps to ~4 billion (uint32_t unsigned underflow)
// and the loop reads far past the buffer boundary.
if (length < 4) return;
// Check if start flag is in the received data // Check if start flag is in the received data
for (uint32_t i = 0; i <= length - 4; i++) { for (uint32_t i = 0; i <= length - 4; i++) {
if (memcmp(data + i, START_FLAG, 4) == 0) { if (memcmp(data + i, START_FLAG, 4) == 0) {
@@ -23,6 +23,7 @@
#include "usbd_cdc_if.h" #include "usbd_cdc_if.h"
#include "adar1000.h" #include "adar1000.h"
#include "ADAR1000_Manager.h" #include "ADAR1000_Manager.h"
#include "ADAR1000_AGC.h"
extern "C" { extern "C" {
#include "ad9523.h" #include "ad9523.h"
} }
@@ -224,6 +225,7 @@ extern SPI_HandleTypeDef hspi4;
//ADAR1000 //ADAR1000
ADAR1000Manager adarManager; ADAR1000Manager adarManager;
ADAR1000_AGC outerAgc;
static uint8_t matrix1[15][16]; static uint8_t matrix1[15][16];
static uint8_t matrix2[15][16]; static uint8_t matrix2[15][16];
static uint8_t vector_0[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0}; static uint8_t vector_0[16] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};
@@ -639,6 +641,7 @@ SystemError_t checkSystemHealth(void) {
if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) { if (s0 == GPIO_PIN_RESET || s1 == GPIO_PIN_RESET) {
current_error = ERROR_AD9523_CLOCK; current_error = ERROR_AD9523_CLOCK;
DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1); DIAG_ERR("CLK", "AD9523 clock health check FAILED (STATUS0=%d STATUS1=%d)", s0, s1);
return current_error;
} }
last_clock_check = HAL_GetTick(); last_clock_check = HAL_GetTick();
} }
@@ -649,10 +652,12 @@ SystemError_t checkSystemHealth(void) {
if (!tx_locked) { if (!tx_locked) {
current_error = ERROR_ADF4382_TX_UNLOCK; current_error = ERROR_ADF4382_TX_UNLOCK;
DIAG_ERR("LO", "Health check: TX LO UNLOCKED"); DIAG_ERR("LO", "Health check: TX LO UNLOCKED");
return current_error;
} }
if (!rx_locked) { if (!rx_locked) {
current_error = ERROR_ADF4382_RX_UNLOCK; current_error = ERROR_ADF4382_RX_UNLOCK;
DIAG_ERR("LO", "Health check: RX LO UNLOCKED"); DIAG_ERR("LO", "Health check: RX LO UNLOCKED");
return current_error;
} }
} }
@@ -661,14 +666,14 @@ SystemError_t checkSystemHealth(void) {
if (!adarManager.verifyDeviceCommunication(i)) { if (!adarManager.verifyDeviceCommunication(i)) {
current_error = ERROR_ADAR1000_COMM; current_error = ERROR_ADAR1000_COMM;
DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i); DIAG_ERR("BF", "Health check: ADAR1000 #%d comm FAILED", i);
break; return current_error;
} }
float temp = adarManager.readTemperature(i); float temp = adarManager.readTemperature(i);
if (temp > 85.0f) { if (temp > 85.0f) {
current_error = ERROR_ADAR1000_TEMP; current_error = ERROR_ADAR1000_TEMP;
DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp); DIAG_ERR("BF", "Health check: ADAR1000 #%d OVERTEMP %.1fC > 85C", i, temp);
break; return current_error;
} }
} }
@@ -678,6 +683,7 @@ SystemError_t checkSystemHealth(void) {
if (!GY85_Update(&imu)) { if (!GY85_Update(&imu)) {
current_error = ERROR_IMU_COMM; current_error = ERROR_IMU_COMM;
DIAG_ERR("IMU", "Health check: GY85_Update() FAILED"); DIAG_ERR("IMU", "Health check: GY85_Update() FAILED");
return current_error;
} }
last_imu_check = HAL_GetTick(); last_imu_check = HAL_GetTick();
} }
@@ -689,6 +695,7 @@ SystemError_t checkSystemHealth(void) {
if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) { if (pressure < 30000.0 || pressure > 110000.0 || isnan(pressure)) {
current_error = ERROR_BMP180_COMM; current_error = ERROR_BMP180_COMM;
DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure); DIAG_ERR("SYS", "Health check: BMP180 pressure out of range: %.0f", pressure);
return current_error;
} }
last_bmp_check = HAL_GetTick(); last_bmp_check = HAL_GetTick();
} }
@@ -701,6 +708,7 @@ SystemError_t checkSystemHealth(void) {
if (HAL_GetTick() - last_gps_fix > 30000) { if (HAL_GetTick() - last_gps_fix > 30000) {
current_error = ERROR_GPS_COMM; current_error = ERROR_GPS_COMM;
DIAG_WARN("SYS", "Health check: GPS no fix for >30s"); DIAG_WARN("SYS", "Health check: GPS no fix for >30s");
return current_error;
} }
// 7. Check RF Power Amplifier Current // 7. Check RF Power Amplifier Current
@@ -709,12 +717,12 @@ SystemError_t checkSystemHealth(void) {
if (Idq_reading[i] > 2.5f) { if (Idq_reading[i] > 2.5f) {
current_error = ERROR_RF_PA_OVERCURRENT; current_error = ERROR_RF_PA_OVERCURRENT;
DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]); DIAG_ERR("PA", "Health check: PA ch%d OVERCURRENT Idq=%.3fA > 2.5A", i, Idq_reading[i]);
break; return current_error;
} }
if (Idq_reading[i] < 0.1f) { if (Idq_reading[i] < 0.1f) {
current_error = ERROR_RF_PA_BIAS; current_error = ERROR_RF_PA_BIAS;
DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]); DIAG_ERR("PA", "Health check: PA ch%d BIAS FAULT Idq=%.3fA < 0.1A", i, Idq_reading[i]);
break; return current_error;
} }
} }
} }
@@ -723,6 +731,7 @@ SystemError_t checkSystemHealth(void) {
if (temperature > 75.0f) { if (temperature > 75.0f) {
current_error = ERROR_TEMPERATURE_HIGH; current_error = ERROR_TEMPERATURE_HIGH;
DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature); DIAG_ERR("SYS", "Health check: System OVERTEMP %.1fC > 75C", temperature);
return current_error;
} }
// 9. Simple watchdog check // 9. Simple watchdog check
@@ -730,6 +739,7 @@ SystemError_t checkSystemHealth(void) {
if (HAL_GetTick() - last_health_check > 60000) { if (HAL_GetTick() - last_health_check > 60000) {
current_error = ERROR_WATCHDOG_TIMEOUT; current_error = ERROR_WATCHDOG_TIMEOUT;
DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)"); DIAG_ERR("SYS", "Health check: Watchdog timeout (>60s since last check)");
return current_error;
} }
last_health_check = HAL_GetTick(); last_health_check = HAL_GetTick();
@@ -919,38 +929,41 @@ bool checkSystemHealthStatus(void) {
// Get system status for GUI // Get system status for GUI
// Get system status for GUI with 8 temperature variables // Get system status for GUI with 8 temperature variables
void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) { void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
char temp_buffer[200]; // Build status string directly in the output buffer using offset-tracked
char final_status[500] = "System Status: "; // snprintf. Each call returns the number of chars written (excluding NUL),
// so we advance 'off' and shrink 'rem' to guarantee we never overflow.
size_t off = 0;
size_t rem = buffer_size;
int w;
// Basic status // Basic status
if (system_emergency_state) { if (system_emergency_state) {
strcat(final_status, "EMERGENCY_STOP|"); w = snprintf(status_buffer + off, rem, "System Status: EMERGENCY_STOP|");
} else { } else {
strcat(final_status, "NORMAL|"); w = snprintf(status_buffer + off, rem, "System Status: NORMAL|");
} }
if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// Error information // Error information
snprintf(temp_buffer, sizeof(temp_buffer), "LastError:%d|ErrorCount:%lu|", w = snprintf(status_buffer + off, rem, "LastError:%d|ErrorCount:%lu|",
last_error, error_count); last_error, error_count);
strcat(final_status, temp_buffer); if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// Sensor status // Sensor status
snprintf(temp_buffer, sizeof(temp_buffer), "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|", w = snprintf(status_buffer + off, rem, "IMU:%.1f,%.1f,%.1f|GPS:%.6f,%.6f|ALT:%.1f|",
Pitch_Sensor, Roll_Sensor, Yaw_Sensor, Pitch_Sensor, Roll_Sensor, Yaw_Sensor,
RADAR_Latitude, RADAR_Longitude, RADAR_Altitude); RADAR_Latitude, RADAR_Longitude, RADAR_Altitude);
strcat(final_status, temp_buffer); if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// LO Status // LO Status
bool tx_locked, rx_locked; bool tx_locked, rx_locked;
ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked); ADF4382A_CheckLockStatus(&lo_manager, &tx_locked, &rx_locked);
snprintf(temp_buffer, sizeof(temp_buffer), "LO_TX:%s|LO_RX:%s|", w = snprintf(status_buffer + off, rem, "LO_TX:%s|LO_RX:%s|",
tx_locked ? "LOCKED" : "UNLOCKED", tx_locked ? "LOCKED" : "UNLOCKED",
rx_locked ? "LOCKED" : "UNLOCKED"); rx_locked ? "LOCKED" : "UNLOCKED");
strcat(final_status, temp_buffer); if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// Temperature readings (8 variables) // Temperature readings (8 variables)
// You'll need to populate these temperature values from your sensors
// For now, I'll show how to format them - replace with actual temperature readings
Temperature_1 = ADS7830_Measure_SingleEnded(&hadc3, 0); Temperature_1 = ADS7830_Measure_SingleEnded(&hadc3, 0);
Temperature_2 = ADS7830_Measure_SingleEnded(&hadc3, 1); Temperature_2 = ADS7830_Measure_SingleEnded(&hadc3, 1);
Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2); Temperature_3 = ADS7830_Measure_SingleEnded(&hadc3, 2);
@@ -961,11 +974,11 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7); Temperature_8 = ADS7830_Measure_SingleEnded(&hadc3, 7);
// Format all 8 temperature variables // Format all 8 temperature variables
snprintf(temp_buffer, sizeof(temp_buffer), w = snprintf(status_buffer + off, rem,
"T1:%.1f|T2:%.1f|T3:%.1f|T4:%.1f|T5:%.1f|T6:%.1f|T7:%.1f|T8:%.1f|", "T1:%.1f|T2:%.1f|T3:%.1f|T4:%.1f|T5:%.1f|T6:%.1f|T7:%.1f|T8:%.1f|",
Temperature_1, Temperature_2, Temperature_3, Temperature_4, Temperature_1, Temperature_2, Temperature_3, Temperature_4,
Temperature_5, Temperature_6, Temperature_7, Temperature_8); Temperature_5, Temperature_6, Temperature_7, Temperature_8);
strcat(final_status, temp_buffer); if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// RF Power Amplifier status (if enabled) // RF Power Amplifier status (if enabled)
if (PowerAmplifier) { if (PowerAmplifier) {
@@ -975,18 +988,17 @@ void getSystemStatusForGUI(char* status_buffer, size_t buffer_size) {
} }
avg_current /= 16.0f; avg_current /= 16.0f;
snprintf(temp_buffer, sizeof(temp_buffer), "PA_AvgCurrent:%.2f|PA_Enabled:%d|", w = snprintf(status_buffer + off, rem, "PA_AvgCurrent:%.2f|PA_Enabled:%d|",
avg_current, PowerAmplifier); avg_current, PowerAmplifier);
strcat(final_status, temp_buffer); if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
} }
// Radar operation status // Radar operation status
snprintf(temp_buffer, sizeof(temp_buffer), "BeamPos:%d|Azimuth:%d|ChirpCount:%d|", w = snprintf(status_buffer + off, rem, "BeamPos:%d|Azimuth:%d|ChirpCount:%d|",
n, y, m); n, y, m);
strcat(final_status, temp_buffer); if (w > 0 && (size_t)w < rem) { off += (size_t)w; rem -= (size_t)w; }
// Copy to output buffer // NUL termination guaranteed by snprintf, but be safe
strncpy(status_buffer, final_status, buffer_size - 1);
status_buffer[buffer_size - 1] = '\0'; status_buffer[buffer_size - 1] = '\0';
} }
@@ -1995,12 +2007,13 @@ int main(void)
HAL_UART_Transmit(&huart3, (uint8_t*)emergency_msg, strlen(emergency_msg), 1000); HAL_UART_Transmit(&huart3, (uint8_t*)emergency_msg, strlen(emergency_msg), 1000);
DIAG_ERR("SYS", "SAFE MODE ACTIVE -- blinking all LEDs, waiting for system_emergency_state clear"); DIAG_ERR("SYS", "SAFE MODE ACTIVE -- blinking all LEDs, waiting for system_emergency_state clear");
// Blink all LEDs to indicate safe mode // Blink all LEDs to indicate safe mode (500ms period, visible to operator)
while (system_emergency_state) { while (system_emergency_state) {
HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin); HAL_GPIO_TogglePin(LED_1_GPIO_Port, LED_1_Pin);
HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin); HAL_GPIO_TogglePin(LED_2_GPIO_Port, LED_2_Pin);
HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin); HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin); HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin);
HAL_Delay(250);
} }
DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared"); DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared");
} }
@@ -2114,6 +2127,16 @@ int main(void)
runRadarPulseSequence(); runRadarPulseSequence();
/* [AGC] Outer-loop AGC: read FPGA saturation flag (DIG_5 / PD13),
* adjust ADAR1000 VGA common gain once per radar frame (~258 ms).
* Only run when AGC is enabled — otherwise leave VGA gains untouched. */
if (outerAgc.enabled) {
bool sat = HAL_GPIO_ReadPin(FPGA_DIG5_SAT_GPIO_Port,
FPGA_DIG5_SAT_Pin) == GPIO_PIN_SET;
outerAgc.update(sat);
outerAgc.applyGain(adarManager);
}
/* [GAP-3 FIX 2] Kick hardware watchdog — if we don't reach here within /* [GAP-3 FIX 2] Kick hardware watchdog — if we don't reach here within
* ~4 s, the IWDG resets the MCU automatically. */ * ~4 s, the IWDG resets the MCU automatically. */
HAL_IWDG_Refresh(&hiwdg); HAL_IWDG_Refresh(&hiwdg);
@@ -141,6 +141,15 @@ void Error_Handler(void);
#define EN_DIS_RFPA_VDD_GPIO_Port GPIOD #define EN_DIS_RFPA_VDD_GPIO_Port GPIOD
#define EN_DIS_COOLING_Pin GPIO_PIN_7 #define EN_DIS_COOLING_Pin GPIO_PIN_7
#define EN_DIS_COOLING_GPIO_Port GPIOD #define EN_DIS_COOLING_GPIO_Port GPIOD
/* FPGA digital I/O (directly connected GPIOs) */
#define FPGA_DIG5_SAT_Pin GPIO_PIN_13
#define FPGA_DIG5_SAT_GPIO_Port GPIOD
#define FPGA_DIG6_Pin GPIO_PIN_14
#define FPGA_DIG6_GPIO_Port GPIOD
#define FPGA_DIG7_Pin GPIO_PIN_15
#define FPGA_DIG7_GPIO_Port GPIOD
#define ADF4382_RX_CE_Pin GPIO_PIN_9 #define ADF4382_RX_CE_Pin GPIO_PIN_9
#define ADF4382_RX_CE_GPIO_Port GPIOG #define ADF4382_RX_CE_GPIO_Port GPIOG
#define ADF4382_RX_CS_Pin GPIO_PIN_10 #define ADF4382_RX_CS_Pin GPIO_PIN_10
+29 -1
View File
@@ -16,10 +16,17 @@
################################################################################ ################################################################################
CC := cc CC := cc
CXX := c++
CFLAGS := -std=c11 -Wall -Wextra -Wno-unused-parameter -g -O0 CFLAGS := -std=c11 -Wall -Wextra -Wno-unused-parameter -g -O0
CXXFLAGS := -std=c++17 -Wall -Wextra -Wno-unused-parameter -g -O0
# Shim headers come FIRST so they override real headers # Shim headers come FIRST so they override real headers
INCLUDES := -Ishims -I. -I../9_1_1_C_Cpp_Libraries INCLUDES := -Ishims -I. -I../9_1_1_C_Cpp_Libraries
# C++ library directory (AGC, ADAR1000 Manager)
CXX_LIB_DIR := ../9_1_1_C_Cpp_Libraries
CXX_SRCS := $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.cpp
CXX_OBJS := ADAR1000_AGC.o ADAR1000_Manager.o
# Real source files compiled against mock headers # Real source files compiled against mock headers
REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c REAL_SRC := ../9_1_1_C_Cpp_Libraries/adf4382a_manager.c
@@ -62,7 +69,10 @@ TESTS_STANDALONE := test_bug12_pa_cal_loop_inverted \
# Tests that need platform_noos_stm32.o + mocks # Tests that need platform_noos_stm32.o + mocks
TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) # C++ tests (AGC outer loop)
TESTS_WITH_CXX := test_agc_outer_loop
ALL_TESTS := $(TESTS_WITH_REAL) $(TESTS_MOCK_ONLY) $(TESTS_STANDALONE) $(TESTS_WITH_PLATFORM) $(TESTS_WITH_CXX)
.PHONY: all build test clean \ .PHONY: all build test clean \
$(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \ $(addprefix test_,bug1 bug2 bug3 bug4 bug5 bug6 bug7 bug8 bug9 bug10 bug11 bug12 bug13 bug14 bug15) \
@@ -156,6 +166,24 @@ test_gap3_emergency_state_ordering: test_gap3_emergency_state_ordering.c
$(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ) $(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ)
$(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@ $(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@
# --- C++ object rules ---
ADAR1000_AGC.o: $(CXX_LIB_DIR)/ADAR1000_AGC.cpp $(CXX_LIB_DIR)/ADAR1000_AGC.h
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
ADAR1000_Manager.o: $(CXX_LIB_DIR)/ADAR1000_Manager.cpp $(CXX_LIB_DIR)/ADAR1000_Manager.h
$(CXX) $(CXXFLAGS) $(INCLUDES) -c $< -o $@
# --- C++ test binary rules ---
test_agc_outer_loop: test_agc_outer_loop.cpp $(CXX_OBJS) $(MOCK_OBJS)
$(CXX) $(CXXFLAGS) $(INCLUDES) $< $(CXX_OBJS) $(MOCK_OBJS) -o $@
# Convenience target
.PHONY: test_agc
test_agc: test_agc_outer_loop
./test_agc_outer_loop
# --- Individual test targets --- # --- Individual test targets ---
test_bug1: test_bug1_timed_sync_init_ordering test_bug1: test_bug1_timed_sync_init_ordering
@@ -129,6 +129,14 @@ void Error_Handler(void);
#define GYR_INT_Pin GPIO_PIN_8 #define GYR_INT_Pin GPIO_PIN_8
#define GYR_INT_GPIO_Port GPIOC #define GYR_INT_GPIO_Port GPIOC
/* FPGA digital I/O (directly connected GPIOs) */
#define FPGA_DIG5_SAT_Pin GPIO_PIN_13
#define FPGA_DIG5_SAT_GPIO_Port GPIOD
#define FPGA_DIG6_Pin GPIO_PIN_14
#define FPGA_DIG6_GPIO_Port GPIOD
#define FPGA_DIG7_Pin GPIO_PIN_15
#define FPGA_DIG7_GPIO_Port GPIOD
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
@@ -175,7 +175,7 @@ void HAL_Delay(uint32_t Delay)
mock_tick += Delay; mock_tick += Delay;
} }
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData,
uint16_t Size, uint32_t Timeout) uint16_t Size, uint32_t Timeout)
{ {
spy_push((SpyRecord){ spy_push((SpyRecord){
@@ -34,6 +34,10 @@ typedef uint32_t HAL_StatusTypeDef;
#define HAL_MAX_DELAY 0xFFFFFFFFU #define HAL_MAX_DELAY 0xFFFFFFFFU
#ifndef __NOP
#define __NOP() ((void)0)
#endif
/* ========================= GPIO Types ============================ */ /* ========================= GPIO Types ============================ */
typedef struct { typedef struct {
@@ -182,7 +186,7 @@ GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin); void HAL_GPIO_TogglePin(GPIO_TypeDef *GPIOx, uint16_t GPIO_Pin);
uint32_t HAL_GetTick(void); uint32_t HAL_GetTick(void);
void HAL_Delay(uint32_t Delay); void HAL_Delay(uint32_t Delay);
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout); HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout);
/* ========================= SPI stubs ============================== */ /* ========================= SPI stubs ============================== */
@@ -0,0 +1,361 @@
// test_agc_outer_loop.cpp -- C++ unit tests for ADAR1000_AGC outer-loop AGC
//
// Tests the STM32 outer-loop AGC class that adjusts ADAR1000 VGA gain based
// on the FPGA's saturation flag. Uses the existing HAL mock/spy framework.
//
// Build: c++ -std=c++17 ... (see Makefile TESTS_WITH_CXX rule)
#include <cassert>
#include <cstdio>
#include <cstring>
// Shim headers override real STM32/diag headers
#include "stm32_hal_mock.h"
#include "ADAR1000_AGC.h"
#include "ADAR1000_Manager.h"
// ---------------------------------------------------------------------------
// Linker symbols required by ADAR1000_Manager.cpp (pulled in via main.h shim)
// ---------------------------------------------------------------------------
uint8_t GUI_start_flag_received = 0;
uint8_t USB_Buffer[64] = {0};
extern "C" void Error_Handler(void) {}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static int tests_passed = 0;
static int tests_total = 0;
#define RUN_TEST(fn) \
do { \
tests_total++; \
printf(" [%2d] %-55s ", tests_total, #fn); \
fn(); \
tests_passed++; \
printf("PASS\n"); \
} while (0)
// ---------------------------------------------------------------------------
// Test 1: Default construction matches design spec
// ---------------------------------------------------------------------------
static void test_defaults()
{
ADAR1000_AGC agc;
assert(agc.agc_base_gain == 30); // kDefaultRxVgaGain
assert(agc.gain_step_down == 4);
assert(agc.gain_step_up == 1);
assert(agc.min_gain == 0);
assert(agc.max_gain == 127);
assert(agc.holdoff_frames == 4);
assert(agc.enabled == true);
assert(agc.holdoff_counter == 0);
assert(agc.last_saturated == false);
assert(agc.saturation_event_count == 0);
// All cal offsets zero
for (int i = 0; i < AGC_TOTAL_CHANNELS; ++i) {
assert(agc.cal_offset[i] == 0);
}
}
// ---------------------------------------------------------------------------
// Test 2: Saturation reduces gain by step_down
// ---------------------------------------------------------------------------
static void test_saturation_reduces_gain()
{
ADAR1000_AGC agc;
uint8_t initial = agc.agc_base_gain; // 30
agc.update(true); // saturation
assert(agc.agc_base_gain == initial - agc.gain_step_down); // 26
assert(agc.last_saturated == true);
assert(agc.holdoff_counter == 0);
}
// ---------------------------------------------------------------------------
// Test 3: Holdoff prevents premature gain-up
// ---------------------------------------------------------------------------
static void test_holdoff_prevents_early_gain_up()
{
ADAR1000_AGC agc;
agc.update(true); // saturate once -> gain = 26
uint8_t after_sat = agc.agc_base_gain;
// Feed (holdoff_frames - 1) clear frames — should NOT increase gain
for (uint8_t i = 0; i < agc.holdoff_frames - 1; ++i) {
agc.update(false);
assert(agc.agc_base_gain == after_sat);
}
// holdoff_counter should be holdoff_frames - 1
assert(agc.holdoff_counter == agc.holdoff_frames - 1);
}
// ---------------------------------------------------------------------------
// Test 4: Recovery after holdoff period
// ---------------------------------------------------------------------------
static void test_recovery_after_holdoff()
{
ADAR1000_AGC agc;
agc.update(true); // saturate -> gain = 26
uint8_t after_sat = agc.agc_base_gain;
// Feed exactly holdoff_frames clear frames
for (uint8_t i = 0; i < agc.holdoff_frames; ++i) {
agc.update(false);
}
assert(agc.agc_base_gain == after_sat + agc.gain_step_up); // 27
assert(agc.holdoff_counter == 0); // reset after recovery
}
// ---------------------------------------------------------------------------
// Test 5: Min gain clamping
// ---------------------------------------------------------------------------
static void test_min_gain_clamp()
{
ADAR1000_AGC agc;
agc.min_gain = 10;
agc.agc_base_gain = 12;
agc.gain_step_down = 4;
agc.update(true); // 12 - 4 = 8, but min = 10
assert(agc.agc_base_gain == 10);
agc.update(true); // already at min
assert(agc.agc_base_gain == 10);
}
// ---------------------------------------------------------------------------
// Test 6: Max gain clamping
// ---------------------------------------------------------------------------
static void test_max_gain_clamp()
{
ADAR1000_AGC agc;
agc.max_gain = 32;
agc.agc_base_gain = 31;
agc.gain_step_up = 2;
agc.holdoff_frames = 1; // immediate recovery
agc.update(false); // 31 + 2 = 33, but max = 32
assert(agc.agc_base_gain == 32);
agc.update(false); // already at max
assert(agc.agc_base_gain == 32);
}
// ---------------------------------------------------------------------------
// Test 7: Per-channel calibration offsets
// ---------------------------------------------------------------------------
static void test_calibration_offsets()
{
ADAR1000_AGC agc;
agc.agc_base_gain = 30;
agc.min_gain = 0;
agc.max_gain = 60;
agc.cal_offset[0] = 5; // 30 + 5 = 35
agc.cal_offset[1] = -10; // 30 - 10 = 20
agc.cal_offset[15] = 40; // 30 + 40 = 60 (clamped to max)
assert(agc.effectiveGain(0) == 35);
assert(agc.effectiveGain(1) == 20);
assert(agc.effectiveGain(15) == 60); // clamped to max_gain
// Negative clamp
agc.cal_offset[2] = -50; // 30 - 50 = -20, clamped to min_gain = 0
assert(agc.effectiveGain(2) == 0);
// Out-of-range index returns min_gain
assert(agc.effectiveGain(16) == agc.min_gain);
}
// ---------------------------------------------------------------------------
// Test 8: Disabled AGC is a no-op
// ---------------------------------------------------------------------------
static void test_disabled_noop()
{
ADAR1000_AGC agc;
agc.enabled = false;
uint8_t original = agc.agc_base_gain;
agc.update(true); // should be ignored
assert(agc.agc_base_gain == original);
assert(agc.last_saturated == false); // not updated when disabled
assert(agc.saturation_event_count == 0);
agc.update(false); // also ignored
assert(agc.agc_base_gain == original);
}
// ---------------------------------------------------------------------------
// Test 9: applyGain() produces correct SPI writes
// ---------------------------------------------------------------------------
static void test_apply_gain_spi()
{
spy_reset();
ADAR1000Manager mgr; // creates 4 devices
ADAR1000_AGC agc;
agc.agc_base_gain = 42;
agc.applyGain(mgr);
// Each channel: adarSetRxVgaGain -> adarWrite(gain) + adarWrite(LOAD_WORKING)
// Each adarWrite: CS_low (GPIO_WRITE) + SPI_TRANSMIT + CS_high (GPIO_WRITE)
// = 3 spy records per adarWrite
// = 6 spy records per channel
// = 16 channels * 6 = 96 total spy records
// Verify SPI transmit count: 2 SPI calls per channel * 16 channels = 32
int spi_count = spy_count_type(SPY_SPI_TRANSMIT);
assert(spi_count == 32);
// Verify GPIO write count: 4 GPIO writes per channel (CS low + CS high for each of 2 adarWrite calls)
int gpio_writes = spy_count_type(SPY_GPIO_WRITE);
assert(gpio_writes == 64); // 16 ch * 2 adarWrite * 2 GPIO each
}
// ---------------------------------------------------------------------------
// Test 10: resetState() clears counters but preserves config
// ---------------------------------------------------------------------------
static void test_reset_preserves_config()
{
ADAR1000_AGC agc;
agc.agc_base_gain = 42;
agc.gain_step_down = 8;
agc.cal_offset[3] = -5;
// Generate some state
agc.update(true);
agc.update(true);
assert(agc.saturation_event_count == 2);
assert(agc.last_saturated == true);
agc.resetState();
// State cleared
assert(agc.holdoff_counter == 0);
assert(agc.last_saturated == false);
assert(agc.saturation_event_count == 0);
// Config preserved
assert(agc.agc_base_gain == 42 - 8 - 8); // two saturations applied before reset
assert(agc.gain_step_down == 8);
assert(agc.cal_offset[3] == -5);
}
// ---------------------------------------------------------------------------
// Test 11: Saturation counter increments correctly
// ---------------------------------------------------------------------------
static void test_saturation_counter()
{
ADAR1000_AGC agc;
for (int i = 0; i < 10; ++i) {
agc.update(true);
}
assert(agc.saturation_event_count == 10);
// Clear frames don't increment saturation count
for (int i = 0; i < 5; ++i) {
agc.update(false);
}
assert(agc.saturation_event_count == 10);
}
// ---------------------------------------------------------------------------
// Test 12: Mixed saturation/clear sequence
// ---------------------------------------------------------------------------
static void test_mixed_sequence()
{
ADAR1000_AGC agc;
agc.agc_base_gain = 30;
agc.gain_step_down = 4;
agc.gain_step_up = 1;
agc.holdoff_frames = 3;
// Saturate: 30 -> 26
agc.update(true);
assert(agc.agc_base_gain == 26);
assert(agc.holdoff_counter == 0);
// 2 clear frames (not enough for recovery)
agc.update(false);
agc.update(false);
assert(agc.agc_base_gain == 26);
assert(agc.holdoff_counter == 2);
// Saturate again: 26 -> 22, counter resets
agc.update(true);
assert(agc.agc_base_gain == 22);
assert(agc.holdoff_counter == 0);
assert(agc.saturation_event_count == 2);
// 3 clear frames -> recovery: 22 -> 23
agc.update(false);
agc.update(false);
agc.update(false);
assert(agc.agc_base_gain == 23);
assert(agc.holdoff_counter == 0);
// 3 more clear -> 23 -> 24
agc.update(false);
agc.update(false);
agc.update(false);
assert(agc.agc_base_gain == 24);
}
// ---------------------------------------------------------------------------
// Test 13: Effective gain with edge-case base_gain values
// ---------------------------------------------------------------------------
static void test_effective_gain_edge_cases()
{
ADAR1000_AGC agc;
agc.min_gain = 5;
agc.max_gain = 250;
// Base gain at zero with positive offset
agc.agc_base_gain = 0;
agc.cal_offset[0] = 3;
assert(agc.effectiveGain(0) == 5); // 0 + 3 = 3, clamped to min_gain=5
// Base gain at max with zero offset
agc.agc_base_gain = 250;
agc.cal_offset[0] = 0;
assert(agc.effectiveGain(0) == 250);
// Base gain at max with positive offset -> clamped
agc.agc_base_gain = 250;
agc.cal_offset[0] = 10;
assert(agc.effectiveGain(0) == 250); // clamped to max_gain
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
int main()
{
printf("=== ADAR1000_AGC Outer-Loop Unit Tests ===\n");
RUN_TEST(test_defaults);
RUN_TEST(test_saturation_reduces_gain);
RUN_TEST(test_holdoff_prevents_early_gain_up);
RUN_TEST(test_recovery_after_holdoff);
RUN_TEST(test_min_gain_clamp);
RUN_TEST(test_max_gain_clamp);
RUN_TEST(test_calibration_offsets);
RUN_TEST(test_disabled_noop);
RUN_TEST(test_apply_gain_spi);
RUN_TEST(test_reset_preserves_config);
RUN_TEST(test_saturation_counter);
RUN_TEST(test_mixed_sequence);
RUN_TEST(test_effective_gain_edge_cases);
printf("=== Results: %d/%d passed ===\n", tests_passed, tests_total);
return (tests_passed == tests_total) ? 0 : 1;
}
+5
View File
@@ -212,6 +212,11 @@ BUFG bufg_feedback (
// ---- Output BUFG ---- // ---- Output BUFG ----
// Routes the jitter-cleaned 400 MHz CLKOUT0 onto a global clock network. // Routes the jitter-cleaned 400 MHz CLKOUT0 onto a global clock network.
// DONT_TOUCH prevents phys_opt_design AggressiveExplore from replicating this
// BUFG into a cascaded chain (4 BUFGs in series observed in Build 26), which
// added ~243ps of clock insertion delay and caused -187ps clock skew on the
// NCODSP mixer critical path.
(* DONT_TOUCH = "TRUE" *)
BUFG bufg_clk400m ( BUFG bufg_clk400m (
.I(clk_mmcm_out0), .I(clk_mmcm_out0),
.O(clk_400m_out) .O(clk_400m_out)
@@ -66,13 +66,13 @@ reg signed [COMB_WIDTH-1:0] comb_delay [0:STAGES-1][0:COMB_DELAY-1];
// Pipeline valid for comb stages 1-4: delayed by 1 cycle vs comb_pipe to // Pipeline valid for comb stages 1-4: delayed by 1 cycle vs comb_pipe to
// account for CREG+AREG+BREG pipeline inside comb_0_dsp (explicit DSP48E1). // account for CREG+AREG+BREG pipeline inside comb_0_dsp (explicit DSP48E1).
// Comb[0] result appears 1 cycle after data_valid_comb_pipe. // Comb[0] result appears 1 cycle after data_valid_comb_pipe.
(* keep = "true", max_fanout = 4 *) reg data_valid_comb_0_out; (* keep = "true", max_fanout = 16 *) reg data_valid_comb_0_out;
// Enhanced control and monitoring // Enhanced control and monitoring
reg [1:0] decimation_counter; reg [1:0] decimation_counter;
(* keep = "true", max_fanout = 4 *) reg data_valid_delayed; (* keep = "true", max_fanout = 16 *) reg data_valid_delayed;
(* keep = "true", max_fanout = 4 *) reg data_valid_comb; (* keep = "true", max_fanout = 16 *) reg data_valid_comb;
(* keep = "true", max_fanout = 4 *) reg data_valid_comb_pipe; (* keep = "true", max_fanout = 16 *) reg data_valid_comb_pipe;
reg [7:0] output_counter; reg [7:0] output_counter;
reg [ACC_WIDTH-1:0] max_integrator_value; reg [ACC_WIDTH-1:0] max_integrator_value;
reg overflow_detected; reg overflow_detected;
@@ -83,3 +83,13 @@ set_false_path -through [get_pins rx_inst/adc/mmcm_inst/mmcm_adc_400m/LOCKED]
# Waiving hold on these 8 paths (adc_d_p[0..7] → IDDR) is standard practice # Waiving hold on these 8 paths (adc_d_p[0..7] → IDDR) is standard practice
# for source-synchronous LVDS ADC interfaces using BUFIO capture. # for source-synchronous LVDS ADC interfaces using BUFIO capture.
set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p] set_false_path -hold -from [get_ports {adc_d_p[*]}] -to [get_clocks adc_dco_p]
# --------------------------------------------------------------------------
# Timing margin for 400 MHz critical paths
# --------------------------------------------------------------------------
# Extra setup uncertainty forces Vivado to leave margin for temperature/voltage/
# aging variation. Reduced from 200 ps to 100 ps after NCO→mixer pipeline
# register fix eliminated the dominant timing bottleneck (WNS went from +0.002ns
# to comfortable margin). 100 ps still provides ~4% guardband on the 2.5ns period.
# This is additive to the existing jitter-based uncertainty (~53 ps).
set_clock_uncertainty -setup -add 0.100 [get_clocks clk_mmcm_out0]
@@ -222,8 +222,16 @@ set_property IOSTANDARD LVCMOS33 [get_ports {stm32_new_*}]
set_property IOSTANDARD LVCMOS33 [get_ports {stm32_mixers_enable}] set_property IOSTANDARD LVCMOS33 [get_ports {stm32_mixers_enable}]
# reset_n is DIG_4 (PD12) — constrained above in the RESET section # reset_n is DIG_4 (PD12) — constrained above in the RESET section
# DIG_5 = H11, DIG_6 = G12, DIG_7 = H12 — available for FPGA→STM32 status # DIG_5 = H11, DIG_6 = G12, DIG_7 = H12 — FPGA→STM32 status outputs
# Currently unused in RTL. Could be connected to status outputs if needed. # DIG_5: AGC saturation flag (PD13 on STM32)
# DIG_6: reserved (PD14)
# DIG_7: reserved (PD15)
set_property PACKAGE_PIN H11 [get_ports {gpio_dig5}]
set_property PACKAGE_PIN G12 [get_ports {gpio_dig6}]
set_property PACKAGE_PIN H12 [get_ports {gpio_dig7}]
set_property IOSTANDARD LVCMOS33 [get_ports {gpio_dig*}]
set_property DRIVE 8 [get_ports {gpio_dig*}]
set_property SLEW SLOW [get_ports {gpio_dig*}]
# ============================================================================ # ============================================================================
# ADC INTERFACE (LVDS — Bank 14, VCCO=3.3V) # ADC INTERFACE (LVDS — Bank 14, VCCO=3.3V)
+46 -16
View File
@@ -102,14 +102,19 @@ wire signed [17:0] debug_mixed_q_trunc;
reg [7:0] signal_power_i, signal_power_q; reg [7:0] signal_power_i, signal_power_q;
// Internal mixing signals // Internal mixing signals
// DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 handles all internal pipelining // Pipeline: NCO fabric reg (1) + DSP48E1 AREG/BREG (1) + MREG (1) + PREG (1) + retiming (1) = 5 cycles
// Latency: 4 cycles (1 for AREG/BREG, 1 for MREG, 1 for PREG, 1 for post-DSP retiming) // The NCO fabric pipeline register was added to break the long NCODSP B-port route
// (1.505ns routing in Build 26, WNS=+0.002ns). With BREG=1 still active inside the DSP,
// total latency increases by 1 cycle (2.5ns at 400MHz negligible for radar).
wire signed [MIXER_WIDTH-1:0] adc_signed_w; wire signed [MIXER_WIDTH-1:0] adc_signed_w;
reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q; reg signed [MIXER_WIDTH + NCO_WIDTH -1:0] mixed_i, mixed_q;
reg mixed_valid; reg mixed_valid;
reg mixer_overflow_i, mixer_overflow_q; reg mixer_overflow_i, mixer_overflow_q;
// Pipeline valid tracking: 4-stage shift register (3 for DSP48E1 + 1 for post-DSP retiming) // Pipeline valid tracking: 5-stage shift register (1 NCO pipe + 3 DSP48E1 + 1 retiming)
reg [3:0] dsp_valid_pipe; reg [4:0] dsp_valid_pipe;
// NCODSP pipeline registers breaks the long NCO sin/cos DSP48E1 B-port route
// DONT_TOUCH prevents Vivado from absorbing these into the DSP or optimizing away
(* DONT_TOUCH = "TRUE" *) reg signed [15:0] cos_nco_pipe, sin_nco_pipe;
// Post-DSP retiming registers breaks DSP48E1 CLKP to fabric timing path // Post-DSP retiming registers breaks DSP48E1 CLKP to fabric timing path
// This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing, // This extra pipeline stage absorbs the 1.866ns DSP output prop delay + routing,
// ensuring WNS > 0 at 400 MHz regardless of placement seed // ensuring WNS > 0 at 400 MHz regardless of placement seed
@@ -210,11 +215,11 @@ nco_400m_enhanced nco_core (
// //
// Architecture: // Architecture:
// ADC data sign-extend to 18b DSP48E1 A-port (AREG=1 pipelines it) // ADC data sign-extend to 18b DSP48E1 A-port (AREG=1 pipelines it)
// NCO cos/sin sign-extend to 18b DSP48E1 B-port (BREG=1 pipelines it) // NCO cos/sin fabric pipeline reg DSP48E1 B-port (BREG=1 pipelines it)
// Multiply result captured by MREG=1, then output registered by PREG=1 // Multiply result captured by MREG=1, then output registered by PREG=1
// force_saturation override applied AFTER DSP48E1 output (not on input path) // force_saturation override applied AFTER DSP48E1 output (not on input path)
// //
// Latency: 3 clock cycles (AREG/BREG + MREG + PREG) // Latency: 4 clock cycles (1 NCO pipe + 1 AREG/BREG + 1 MREG + 1 PREG) + 1 retiming = 5 total
// PREG=1 absorbs DSP48E1 CLKP delay internally, preventing fabric timing violations // PREG=1 absorbs DSP48E1 CLKP delay internally, preventing fabric timing violations
// In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only // In simulation (Icarus), uses behavioral equivalent since DSP48E1 is Xilinx-only
// ============================================================================ // ============================================================================
@@ -223,24 +228,35 @@ nco_400m_enhanced nco_core (
assign adc_signed_w = {1'b0, adc_data, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} - assign adc_signed_w = {1'b0, adc_data, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} -
{1'b0, {ADC_WIDTH{1'b1}}, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} / 2; {1'b0, {ADC_WIDTH{1'b1}}, {(MIXER_WIDTH-ADC_WIDTH-1){1'b0}}} / 2;
// Valid pipeline: 4-stage shift register (3 for DSP48E1 AREG+MREG+PREG + 1 for retiming) // Valid pipeline: 5-stage shift register (1 NCO pipe + 3 DSP48E1 AREG+MREG+PREG + 1 retiming)
always @(posedge clk_400m or negedge reset_n_400m) begin always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin if (!reset_n_400m) begin
dsp_valid_pipe <= 4'b0000; dsp_valid_pipe <= 5'b00000;
end else begin end else begin
dsp_valid_pipe <= {dsp_valid_pipe[2:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)}; dsp_valid_pipe <= {dsp_valid_pipe[3:0], (nco_ready && adc_data_valid_i && adc_data_valid_q)};
end end
end end
`ifdef SIMULATION `ifdef SIMULATION
// ---- Behavioral model for Icarus Verilog simulation ---- // ---- Behavioral model for Icarus Verilog simulation ----
// Mimics DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (3-cycle latency) // Mimics NCO pipeline + DSP48E1 with AREG=1, BREG=1, MREG=1, PREG=1 (4-cycle DSP + 1 NCO pipe)
reg signed [MIXER_WIDTH-1:0] adc_signed_reg; // Models AREG reg signed [MIXER_WIDTH-1:0] adc_signed_reg; // Models AREG
reg signed [15:0] cos_pipe_reg, sin_pipe_reg; // Models BREG reg signed [15:0] cos_pipe_reg, sin_pipe_reg; // Models BREG
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_internal, mult_q_internal; // Models MREG reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_internal, mult_q_internal; // Models MREG
reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_reg, mult_q_reg; // Models PREG reg signed [MIXER_WIDTH+NCO_WIDTH-1:0] mult_i_reg, mult_q_reg; // Models PREG
// Stage 1: AREG/BREG equivalent // Stage 0: NCO pipeline — breaks long NCO→DSP route (matches synthesis fabric registers)
always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin
cos_nco_pipe <= 0;
sin_nco_pipe <= 0;
end else begin
cos_nco_pipe <= cos_out;
sin_nco_pipe <= sin_out;
end
end
// Stage 1: AREG/BREG equivalent (uses pipelined NCO outputs)
always @(posedge clk_400m or negedge reset_n_400m) begin always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin if (!reset_n_400m) begin
adc_signed_reg <= 0; adc_signed_reg <= 0;
@@ -248,8 +264,8 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
sin_pipe_reg <= 0; sin_pipe_reg <= 0;
end else begin end else begin
adc_signed_reg <= adc_signed_w; adc_signed_reg <= adc_signed_w;
cos_pipe_reg <= cos_out; cos_pipe_reg <= cos_nco_pipe;
sin_pipe_reg <= sin_out; sin_pipe_reg <= sin_nco_pipe;
end end
end end
@@ -291,6 +307,20 @@ end
// This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz // This guarantees AREG/BREG/MREG are used, achieving timing closure at 400 MHz
wire [47:0] dsp_p_i, dsp_p_q; wire [47:0] dsp_p_i, dsp_p_q;
// NCO pipeline stage breaks the long NCO sin/cos DSP48E1 B-port route
// (1.505ns routing observed in Build 26). These fabric registers are placed
// near the DSP by the placer, splitting the route into two shorter segments.
// DONT_TOUCH on the reg declaration (above) prevents absorption/retiming.
always @(posedge clk_400m or negedge reset_n_400m) begin
if (!reset_n_400m) begin
cos_nco_pipe <= 0;
sin_nco_pipe <= 0;
end else begin
cos_nco_pipe <= cos_out;
sin_nco_pipe <= sin_out;
end
end
// DSP48E1 for I-channel mixer (adc_signed * cos_out) // DSP48E1 for I-channel mixer (adc_signed * cos_out)
DSP48E1 #( DSP48E1 #(
// Feature control attributes // Feature control attributes
@@ -350,7 +380,7 @@ DSP48E1 #(
.CEINMODE(1'b0), .CEINMODE(1'b0),
// Data ports // Data ports
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b .A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), // Sign-extend 18b to 30b
.B({{2{cos_out[15]}}, cos_out}), // Sign-extend 16b to 18b .B({{2{cos_nco_pipe[15]}}, cos_nco_pipe}), // Sign-extend 16b to 18b (pipelined)
.C(48'b0), .C(48'b0),
.D(25'b0), .D(25'b0),
.CARRYIN(1'b0), .CARRYIN(1'b0),
@@ -432,7 +462,7 @@ DSP48E1 #(
.CED(1'b0), .CED(1'b0),
.CEINMODE(1'b0), .CEINMODE(1'b0),
.A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}), .A({{12{adc_signed_w[MIXER_WIDTH-1]}}, adc_signed_w}),
.B({{2{sin_out[15]}}, sin_out}), .B({{2{sin_nco_pipe[15]}}, sin_nco_pipe}),
.C(48'b0), .C(48'b0),
.D(25'b0), .D(25'b0),
.CARRYIN(1'b0), .CARRYIN(1'b0),
@@ -492,7 +522,7 @@ always @(posedge clk_400m or negedge reset_n_400m) begin
mixer_overflow_q <= 0; mixer_overflow_q <= 0;
saturation_count <= 0; saturation_count <= 0;
overflow_detected <= 0; overflow_detected <= 0;
end else if (dsp_valid_pipe[3]) begin end else if (dsp_valid_pipe[4]) begin
// Force saturation for testing (applied after DSP output, not on input path) // Force saturation for testing (applied after DSP output, not on input path)
if (force_saturation_sync) begin if (force_saturation_sync) begin
mixed_i <= 34'h1FFFFFFFF; mixed_i <= 34'h1FFFFFFFF;
+1 -1
View File
@@ -296,7 +296,7 @@ always @(posedge clk or negedge reset_n) begin
state <= ST_DONE; state <= ST_DONE;
end end
end end
// Timeout: if no ADC data after 10000 cycles, FAIL // Timeout: if no ADC data after 1000 cycles (10 us @ 100 MHz), FAIL
step_cnt <= step_cnt + 1; step_cnt <= step_cnt + 1;
if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin if (step_cnt >= 10'd1000 && adc_cap_cnt == 0) begin
result_flags[4] <= 1'b0; result_flags[4] <= 1'b0;
+37 -6
View File
@@ -42,6 +42,13 @@ module radar_receiver_final (
// [2:0]=shift amount: 0..7 bits. Default 0 = pass-through. // [2:0]=shift amount: 0..7 bits. Default 0 = pass-through.
input wire [3:0] host_gain_shift, input wire [3:0] host_gain_shift,
// AGC configuration (opcodes 0x28-0x2C, active only when agc_enable=1)
input wire host_agc_enable, // 0x28: 0=manual, 1=auto AGC
input wire [7:0] host_agc_target, // 0x29: target peak magnitude
input wire [3:0] host_agc_attack, // 0x2A: gain-down step on clipping
input wire [3:0] host_agc_decay, // 0x2B: gain-up step when weak
input wire [3:0] host_agc_holdoff, // 0x2C: frames before gain-up
// STM32 toggle signals for mode 00 (STM32-driven) pass-through. // STM32 toggle signals for mode 00 (STM32-driven) pass-through.
// These are CDC-synchronized in radar_system_top.v / radar_transmitter.v // These are CDC-synchronized in radar_system_top.v / radar_transmitter.v
// before reaching this module. In mode 00, the RX mode controller uses // before reaching this module. In mode 00, the RX mode controller uses
@@ -60,7 +67,12 @@ module radar_receiver_final (
// ADC raw data tap (clk_100m domain, post-DDC, for self-test / debug) // ADC raw data tap (clk_100m domain, post-DDC, for self-test / debug)
output wire [15:0] dbg_adc_i, // DDC output I (16-bit signed, 100 MHz) output wire [15:0] dbg_adc_i, // DDC output I (16-bit signed, 100 MHz)
output wire [15:0] dbg_adc_q, // DDC output Q (16-bit signed, 100 MHz) output wire [15:0] dbg_adc_q, // DDC output Q (16-bit signed, 100 MHz)
output wire dbg_adc_valid // DDC output valid (100 MHz) output wire dbg_adc_valid, // DDC output valid (100 MHz)
// AGC status outputs (for status readback / STM32 outer loop)
output wire [7:0] agc_saturation_count, // Per-frame clipped sample count
output wire [7:0] agc_peak_magnitude, // Per-frame peak (upper 8 bits)
output wire [3:0] agc_current_gain // Effective gain_shift encoding
); );
// ========== INTERNAL SIGNALS ========== // ========== INTERNAL SIGNALS ==========
@@ -86,7 +98,9 @@ wire adc_valid_sync;
// Gain-controlled signals (between DDC output and matched filter) // Gain-controlled signals (between DDC output and matched filter)
wire signed [15:0] gc_i, gc_q; wire signed [15:0] gc_i, gc_q;
wire gc_valid; wire gc_valid;
wire [7:0] gc_saturation_count; // Diagnostic: clipped sample counter wire [7:0] gc_saturation_count; // Diagnostic: per-frame clipped sample counter
wire [7:0] gc_peak_magnitude; // Diagnostic: per-frame peak magnitude
wire [3:0] gc_current_gain; // Diagnostic: effective gain_shift
// Reference signals for the processing chain // Reference signals for the processing chain
wire [15:0] long_chirp_real, long_chirp_imag; wire [15:0] long_chirp_real, long_chirp_imag;
@@ -160,7 +174,7 @@ wire clk_400m;
// the buffered 400MHz DCO clock via adc_dco_bufg, avoiding duplicate // the buffered 400MHz DCO clock via adc_dco_bufg, avoiding duplicate
// IBUFDS instantiations on the same LVDS clock pair. // IBUFDS instantiations on the same LVDS clock pair.
// 1. ADC + CDC + AGC // 1. ADC + CDC + Digital Gain
// CMOS Output Interface (400MHz Domain) // CMOS Output Interface (400MHz Domain)
wire [7:0] adc_data_cmos; // 8-bit ADC data (CMOS, from ad9484_interface_400m) wire [7:0] adc_data_cmos; // 8-bit ADC data (CMOS, from ad9484_interface_400m)
@@ -222,9 +236,10 @@ ddc_input_interface ddc_if (
.data_sync_error() .data_sync_error()
); );
// 2b. Digital Gain Control (Fix 3) // 2b. Digital Gain Control with AGC
// Host-configurable power-of-2 shift between DDC output and matched filter. // Host-configurable power-of-2 shift between DDC output and matched filter.
// Default gain_shift=0 pass-through (no behavioral change from baseline). // Default gain_shift=0, agc_enable=0 pass-through (no behavioral change).
// When agc_enable=1: auto-adjusts gain per frame based on peak/saturation.
rx_gain_control gain_ctrl ( rx_gain_control gain_ctrl (
.clk(clk), .clk(clk),
.reset_n(reset_n), .reset_n(reset_n),
@@ -232,10 +247,21 @@ rx_gain_control gain_ctrl (
.data_q_in(adc_q_scaled), .data_q_in(adc_q_scaled),
.valid_in(adc_valid_sync), .valid_in(adc_valid_sync),
.gain_shift(host_gain_shift), .gain_shift(host_gain_shift),
// AGC configuration
.agc_enable(host_agc_enable),
.agc_target(host_agc_target),
.agc_attack(host_agc_attack),
.agc_decay(host_agc_decay),
.agc_holdoff(host_agc_holdoff),
// Frame boundary from Doppler processor
.frame_boundary(doppler_frame_done),
// Outputs
.data_i_out(gc_i), .data_i_out(gc_i),
.data_q_out(gc_q), .data_q_out(gc_q),
.valid_out(gc_valid), .valid_out(gc_valid),
.saturation_count(gc_saturation_count) .saturation_count(gc_saturation_count),
.peak_magnitude(gc_peak_magnitude),
.current_gain(gc_current_gain)
); );
// 3. Dual Chirp Memory Loader // 3. Dual Chirp Memory Loader
@@ -474,4 +500,9 @@ assign dbg_adc_i = adc_i_scaled;
assign dbg_adc_q = adc_q_scaled; assign dbg_adc_q = adc_q_scaled;
assign dbg_adc_valid = adc_valid_sync; assign dbg_adc_valid = adc_valid_sync;
// ========== AGC STATUS OUTPUTS ==========
assign agc_saturation_count = gc_saturation_count;
assign agc_peak_magnitude = gc_peak_magnitude;
assign agc_current_gain = gc_current_gain;
endmodule endmodule
+66 -4
View File
@@ -125,7 +125,13 @@ module radar_system_top (
output wire [5:0] dbg_range_bin, output wire [5:0] dbg_range_bin,
// System status // System status
output wire [3:0] system_status output wire [3:0] system_status,
// FPGASTM32 GPIO outputs (DIG_5..DIG_7 on 50T board)
// Used by STM32 outer AGC loop to read saturation state without USB polling.
output wire gpio_dig5, // DIG_5 (H11PD13): AGC saturation flag (1=clipping detected)
output wire gpio_dig6, // DIG_6 (G12PD14): reserved (tied low)
output wire gpio_dig7 // DIG_7 (H12PD15): reserved (tied low)
); );
// ============================================================================ // ============================================================================
@@ -187,6 +193,11 @@ wire [15:0] rx_dbg_adc_i;
wire [15:0] rx_dbg_adc_q; wire [15:0] rx_dbg_adc_q;
wire rx_dbg_adc_valid; wire rx_dbg_adc_valid;
// AGC status from receiver (for status readback and GPIO)
wire [7:0] rx_agc_saturation_count;
wire [7:0] rx_agc_peak_magnitude;
wire [3:0] rx_agc_current_gain;
// Data packing for USB // Data packing for USB
wire [31:0] usb_range_profile; wire [31:0] usb_range_profile;
wire usb_range_valid; wire usb_range_valid;
@@ -259,6 +270,13 @@ reg host_cfar_enable; // Opcode 0x25: 1=CFAR, 0=simple threshold
reg host_mti_enable; // Opcode 0x26: 1=MTI active, 0=pass-through reg host_mti_enable; // Opcode 0x26: 1=MTI active, 0=pass-through
reg [2:0] host_dc_notch_width; // Opcode 0x27: DC notch ±width bins (0=off, 1..7) reg [2:0] host_dc_notch_width; // Opcode 0x27: DC notch ±width bins (0=off, 1..7)
// AGC configuration registers (host-configurable via USB, opcodes 0x28-0x2C)
reg host_agc_enable; // Opcode 0x28: 0=manual gain, 1=auto AGC
reg [7:0] host_agc_target; // Opcode 0x29: target peak magnitude (default 200)
reg [3:0] host_agc_attack; // Opcode 0x2A: gain-down step on clipping (default 1)
reg [3:0] host_agc_decay; // Opcode 0x2B: gain-up step when weak (default 1)
reg [3:0] host_agc_holdoff; // Opcode 0x2C: frames to wait before gain-up (default 4)
// Board bring-up self-test registers (opcode 0x30 trigger, 0x31 readback) // Board bring-up self-test registers (opcode 0x30 trigger, 0x31 readback)
reg host_self_test_trigger; // Opcode 0x30: self-clearing pulse reg host_self_test_trigger; // Opcode 0x30: self-clearing pulse
wire self_test_busy; wire self_test_busy;
@@ -518,6 +536,12 @@ radar_receiver_final rx_inst (
.host_chirps_per_elev(host_chirps_per_elev), .host_chirps_per_elev(host_chirps_per_elev),
// Fix 3: digital gain control // Fix 3: digital gain control
.host_gain_shift(host_gain_shift), .host_gain_shift(host_gain_shift),
// AGC configuration (opcodes 0x28-0x2C)
.host_agc_enable(host_agc_enable),
.host_agc_target(host_agc_target),
.host_agc_attack(host_agc_attack),
.host_agc_decay(host_agc_decay),
.host_agc_holdoff(host_agc_holdoff),
// STM32 toggle signals for RX mode controller (mode 00 pass-through). // STM32 toggle signals for RX mode controller (mode 00 pass-through).
// These are the raw GPIO inputs the RX mode controller's edge detectors // These are the raw GPIO inputs the RX mode controller's edge detectors
// (inside radar_mode_controller) handle debouncing/edge detection. // (inside radar_mode_controller) handle debouncing/edge detection.
@@ -532,7 +556,11 @@ radar_receiver_final rx_inst (
// ADC debug tap (for self-test / bring-up) // ADC debug tap (for self-test / bring-up)
.dbg_adc_i(rx_dbg_adc_i), .dbg_adc_i(rx_dbg_adc_i),
.dbg_adc_q(rx_dbg_adc_q), .dbg_adc_q(rx_dbg_adc_q),
.dbg_adc_valid(rx_dbg_adc_valid) .dbg_adc_valid(rx_dbg_adc_valid),
// AGC status outputs
.agc_saturation_count(rx_agc_saturation_count),
.agc_peak_magnitude(rx_agc_peak_magnitude),
.agc_current_gain(rx_agc_current_gain)
); );
// ============================================================================ // ============================================================================
@@ -744,7 +772,13 @@ if (USB_MODE == 0) begin : gen_ft601
// Self-test status readback // Self-test status readback
.status_self_test_flags(self_test_flags_latched), .status_self_test_flags(self_test_flags_latched),
.status_self_test_detail(self_test_detail_latched), .status_self_test_detail(self_test_detail_latched),
.status_self_test_busy(self_test_busy) .status_self_test_busy(self_test_busy),
// AGC status readback
.status_agc_current_gain(rx_agc_current_gain),
.status_agc_peak_magnitude(rx_agc_peak_magnitude),
.status_agc_saturation_count(rx_agc_saturation_count),
.status_agc_enable(host_agc_enable)
); );
// FT2232H ports unused in FT601 mode — tie off // FT2232H ports unused in FT601 mode — tie off
@@ -805,7 +839,13 @@ end else begin : gen_ft2232h
// Self-test status readback // Self-test status readback
.status_self_test_flags(self_test_flags_latched), .status_self_test_flags(self_test_flags_latched),
.status_self_test_detail(self_test_detail_latched), .status_self_test_detail(self_test_detail_latched),
.status_self_test_busy(self_test_busy) .status_self_test_busy(self_test_busy),
// AGC status readback
.status_agc_current_gain(rx_agc_current_gain),
.status_agc_peak_magnitude(rx_agc_peak_magnitude),
.status_agc_saturation_count(rx_agc_saturation_count),
.status_agc_enable(host_agc_enable)
); );
// FT601 ports unused in FT2232H mode — tie off // FT601 ports unused in FT2232H mode — tie off
@@ -892,6 +932,12 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
// Ground clutter removal defaults (disabled backward-compatible) // Ground clutter removal defaults (disabled backward-compatible)
host_mti_enable <= 1'b0; // MTI off host_mti_enable <= 1'b0; // MTI off
host_dc_notch_width <= 3'd0; // DC notch off host_dc_notch_width <= 3'd0; // DC notch off
// AGC defaults (disabled backward-compatible with manual gain)
host_agc_enable <= 1'b0; // AGC off (manual gain)
host_agc_target <= 8'd200; // Target peak magnitude
host_agc_attack <= 4'd1; // 1-step gain-down on clipping
host_agc_decay <= 4'd1; // 1-step gain-up when weak
host_agc_holdoff <= 4'd4; // 4 frames before gain-up
// Self-test defaults // Self-test defaults
host_self_test_trigger <= 1'b0; // Self-test idle host_self_test_trigger <= 1'b0; // Self-test idle
end else begin end else begin
@@ -936,6 +982,12 @@ always @(posedge clk_100m_buf or negedge sys_reset_n) begin
// Ground clutter removal opcodes // Ground clutter removal opcodes
8'h26: host_mti_enable <= usb_cmd_value[0]; 8'h26: host_mti_enable <= usb_cmd_value[0];
8'h27: host_dc_notch_width <= usb_cmd_value[2:0]; 8'h27: host_dc_notch_width <= usb_cmd_value[2:0];
// AGC configuration opcodes
8'h28: host_agc_enable <= usb_cmd_value[0];
8'h29: host_agc_target <= usb_cmd_value[7:0];
8'h2A: host_agc_attack <= usb_cmd_value[3:0];
8'h2B: host_agc_decay <= usb_cmd_value[3:0];
8'h2C: host_agc_holdoff <= usb_cmd_value[3:0];
// Board bring-up self-test opcodes // Board bring-up self-test opcodes
8'h30: host_self_test_trigger <= 1'b1; // Trigger self-test 8'h30: host_self_test_trigger <= 1'b1; // Trigger self-test
8'h31: host_status_request <= 1'b1; // Self-test readback (status alias) 8'h31: host_status_request <= 1'b1; // Self-test readback (status alias)
@@ -978,6 +1030,16 @@ end
assign system_status = status_reg; assign system_status = status_reg;
// ============================================================================
// FPGA→STM32 GPIO OUTPUTS (DIG_5, DIG_6, DIG_7)
// ============================================================================
// DIG_5: AGC saturation flag — high when per-frame saturation_count > 0.
// STM32 reads PD13 to detect clipping and adjust ADAR1000 VGA gain.
// DIG_6, DIG_7: Reserved (tied low for future use).
assign gpio_dig5 = (rx_agc_saturation_count != 8'd0);
assign gpio_dig6 = 1'b0;
assign gpio_dig7 = 1'b0;
// ============================================================================ // ============================================================================
// DEBUG AND VERIFICATION // DEBUG AND VERIFICATION
// ============================================================================ // ============================================================================
+12 -2
View File
@@ -76,7 +76,12 @@ module radar_system_top_50t (
output wire ft_rd_n, // Read strobe (active low) output wire ft_rd_n, // Read strobe (active low)
output wire ft_wr_n, // Write strobe (active low) output wire ft_wr_n, // Write strobe (active low)
output wire ft_oe_n, // Output enable / bus direction output wire ft_oe_n, // Output enable / bus direction
output wire ft_siwu // Send Immediate / WakeUp output wire ft_siwu, // Send Immediate / WakeUp
// ===== FPGASTM32 GPIO (Bank 15: 3.3V) =====
output wire gpio_dig5, // DIG_5 (H11PD13): AGC saturation flag
output wire gpio_dig6, // DIG_6 (G12PD14): reserved
output wire gpio_dig7 // DIG_7 (H12PD15): reserved
); );
// ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) ===== // ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) =====
@@ -207,7 +212,12 @@ module radar_system_top_50t (
.dbg_doppler_valid (dbg_doppler_valid_nc), .dbg_doppler_valid (dbg_doppler_valid_nc),
.dbg_doppler_bin (dbg_doppler_bin_nc), .dbg_doppler_bin (dbg_doppler_bin_nc),
.dbg_range_bin (dbg_range_bin_nc), .dbg_range_bin (dbg_range_bin_nc),
.system_status (system_status_nc) .system_status (system_status_nc),
// ----- FPGASTM32 GPIO (DIG_5..DIG_7) -----
.gpio_dig5 (gpio_dig5),
.gpio_dig6 (gpio_dig6),
.gpio_dig7 (gpio_dig7)
); );
endmodule endmodule
+215 -27
View File
@@ -3,19 +3,32 @@
/** /**
* rx_gain_control.v * rx_gain_control.v
* *
* Host-configurable digital gain control for the receive path. * Digital gain control with optional per-frame automatic gain control (AGC)
* Placed between DDC output (ddc_input_interface) and matched filter input. * for the receive path. Placed between DDC output and matched filter input.
* *
* Features: * Manual mode (agc_enable=0):
* - Bidirectional power-of-2 gain shift (arithmetic shift) * - Uses host_gain_shift directly (backward-compatible, no behavioral change)
* - gain_shift[3] = direction: 0 = left shift (amplify), 1 = right shift (attenuate) * - gain_shift[3] = direction: 0 = left shift (amplify), 1 = right shift (attenuate)
* - gain_shift[2:0] = amount: 0..7 bits * - gain_shift[2:0] = amount: 0..7 bits
* - Symmetric saturation to ±32767 on overflow (left shift only) * - Symmetric saturation to ±32767 on overflow
* - Saturation counter: 8-bit, counts samples that clipped (wraps at 255)
* - 1-cycle latency, valid-in/valid-out pipeline
* - Zero-overhead pass-through when gain_shift == 0
* *
* Intended insertion point in radar_receiver_final.v: * AGC mode (agc_enable=1):
* - Per-frame automatic gain adjustment based on peak/saturation metrics
* - Internal signed gain: -7 (max attenuation) to +7 (max amplification)
* - On frame_boundary:
* * If saturation detected: gain -= agc_attack (fast, immediate)
* * Else if peak < target after holdoff frames: gain += agc_decay (slow)
* * Else: hold current gain
* - host_gain_shift serves as initial gain when AGC first enabled
*
* Status outputs (for readback via status_words):
* - current_gain[3:0]: effective gain_shift encoding (manual or AGC)
* - peak_magnitude[7:0]: per-frame peak |sample| (upper 8 bits of 15-bit value)
* - saturation_count[7:0]: per-frame clipped sample count (capped at 255)
*
* Timing: 1-cycle data latency, valid-in/valid-out pipeline.
*
* Insertion point in radar_receiver_final.v:
* ddc_input_interface rx_gain_control matched_filter_multi_segment * ddc_input_interface rx_gain_control matched_filter_multi_segment
*/ */
@@ -28,27 +41,75 @@ module rx_gain_control (
input wire signed [15:0] data_q_in, input wire signed [15:0] data_q_in,
input wire valid_in, input wire valid_in,
// Gain configuration (from host via USB command) // Host gain configuration (from USB command opcode 0x16)
// [3] = direction: 0=amplify (left shift), 1=attenuate (right shift) // [3]=direction: 0=amplify (left shift), 1=attenuate (right shift)
// [2:0] = shift amount: 0..7 bits // [2:0]=shift amount: 0..7 bits. Default 0x00 = pass-through.
// In AGC mode: serves as initial gain on AGC enable transition.
input wire [3:0] gain_shift, input wire [3:0] gain_shift,
// AGC configuration inputs (from host via USB, opcodes 0x28-0x2C)
input wire agc_enable, // 0x28: 0=manual gain, 1=auto AGC
input wire [7:0] agc_target, // 0x29: target peak magnitude (unsigned, default 200)
input wire [3:0] agc_attack, // 0x2A: attenuation step on clipping (default 1)
input wire [3:0] agc_decay, // 0x2B: amplification step when weak (default 1)
input wire [3:0] agc_holdoff, // 0x2C: frames to wait before gain-up (default 4)
// Frame boundary pulse (1 clk cycle, from Doppler frame_complete)
input wire frame_boundary,
// Data output (to matched filter) // Data output (to matched filter)
output reg signed [15:0] data_i_out, output reg signed [15:0] data_i_out,
output reg signed [15:0] data_q_out, output reg signed [15:0] data_q_out,
output reg valid_out, output reg valid_out,
// Diagnostics // Diagnostics / status readback
output reg [7:0] saturation_count // Number of clipped samples (wraps at 255) output reg [7:0] saturation_count, // Per-frame clipped sample count (capped at 255)
output reg [7:0] peak_magnitude, // Per-frame peak |sample| (upper 8 bits of 15-bit)
output reg [3:0] current_gain // Current effective gain_shift (for status readback)
); );
// Decompose gain_shift // =========================================================================
wire shift_right = gain_shift[3]; // INTERNAL AGC STATE
wire [2:0] shift_amt = gain_shift[2:0]; // =========================================================================
// ------------------------------------------------------------------------- // Signed internal gain: -7 (max attenuation) to +7 (max amplification)
// Combinational shift + saturation // Stored as 4-bit signed (range -8..+7, clamped to -7..+7)
// ------------------------------------------------------------------------- reg signed [3:0] agc_gain;
// Holdoff counter: counts frames without saturation before allowing gain-up
reg [3:0] holdoff_counter;
// Per-frame accumulators (running, reset on frame_boundary)
reg [7:0] frame_sat_count; // Clipped samples this frame
reg [14:0] frame_peak; // Peak |sample| this frame (15-bit unsigned)
// Previous AGC enable state (for detecting 01 transition)
reg agc_enable_prev;
// Combinational helpers for inclusive frame-boundary snapshot
// (used when valid_in and frame_boundary coincide)
reg wire_frame_sat_incr;
reg wire_frame_peak_update;
// =========================================================================
// EFFECTIVE GAIN SELECTION
// =========================================================================
// Convert between signed internal gain and the gain_shift[3:0] encoding.
// gain_shift[3]=0, [2:0]=N amplify by N bits (internal gain = +N)
// gain_shift[3]=1, [2:0]=N attenuate by N bits (internal gain = -N)
// Effective gain_shift used for the actual shift operation
wire [3:0] effective_gain;
assign effective_gain = agc_enable ? current_gain : gain_shift;
// Decompose effective gain for shift logic
wire shift_right = effective_gain[3];
wire [2:0] shift_amt = effective_gain[2:0];
// =========================================================================
// COMBINATIONAL SHIFT + SATURATION
// =========================================================================
// Use wider intermediates to detect overflow on left shift. // Use wider intermediates to detect overflow on left shift.
// 24 bits is enough: 16 + 7 shift = 23 significant bits max. // 24 bits is enough: 16 + 7 shift = 23 significant bits max.
@@ -69,26 +130,153 @@ wire signed [15:0] sat_i = overflow_i ? (shifted_i[23] ? -16'sd32768 : 16'sd3276
wire signed [15:0] sat_q = overflow_q ? (shifted_q[23] ? -16'sd32768 : 16'sd32767) wire signed [15:0] sat_q = overflow_q ? (shifted_q[23] ? -16'sd32768 : 16'sd32767)
: shifted_q[15:0]; : shifted_q[15:0];
// ------------------------------------------------------------------------- // =========================================================================
// Registered output stage (1-cycle latency) // PEAK MAGNITUDE TRACKING (combinational)
// ------------------------------------------------------------------------- // =========================================================================
// Absolute value of signed 16-bit: flip sign bit if negative.
// Result is 15-bit unsigned [0, 32767]. (We ignore -32768 32767 edge case.)
wire [14:0] abs_i = data_i_in[15] ? (~data_i_in[14:0] + 15'd1) : data_i_in[14:0];
wire [14:0] abs_q = data_q_in[15] ? (~data_q_in[14:0] + 15'd1) : data_q_in[14:0];
wire [14:0] max_iq = (abs_i > abs_q) ? abs_i : abs_q;
// =========================================================================
// SIGNED GAIN GAIN_SHIFT ENCODING CONVERSION
// =========================================================================
// Convert signed agc_gain to gain_shift[3:0] encoding
function [3:0] signed_to_encoding;
input signed [3:0] g;
begin
if (g >= 0)
signed_to_encoding = {1'b0, g[2:0]}; // amplify
else
signed_to_encoding = {1'b1, (~g[2:0]) + 3'd1}; // attenuate: -g
end
endfunction
// Convert gain_shift[3:0] encoding to signed gain
function signed [3:0] encoding_to_signed;
input [3:0] enc;
begin
if (enc[3] == 1'b0)
encoding_to_signed = {1'b0, enc[2:0]}; // +0..+7
else
encoding_to_signed = -$signed({1'b0, enc[2:0]}); // -1..-7
end
endfunction
// =========================================================================
// CLAMPING HELPER
// =========================================================================
// Clamp a wider signed value to [-7, +7]
function signed [3:0] clamp_gain;
input signed [4:0] val; // 5-bit to handle overflow from add
begin
if (val > 5'sd7)
clamp_gain = 4'sd7;
else if (val < -5'sd7)
clamp_gain = -4'sd7;
else
clamp_gain = val[3:0];
end
endfunction
// =========================================================================
// REGISTERED OUTPUT + AGC STATE MACHINE
// =========================================================================
always @(posedge clk or negedge reset_n) begin always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin if (!reset_n) begin
// Data path
data_i_out <= 16'sd0; data_i_out <= 16'sd0;
data_q_out <= 16'sd0; data_q_out <= 16'sd0;
valid_out <= 1'b0; valid_out <= 1'b0;
// Status outputs
saturation_count <= 8'd0; saturation_count <= 8'd0;
peak_magnitude <= 8'd0;
current_gain <= 4'd0;
// AGC internal state
agc_gain <= 4'sd0;
holdoff_counter <= 4'd0;
frame_sat_count <= 8'd0;
frame_peak <= 15'd0;
agc_enable_prev <= 1'b0;
end else begin end else begin
valid_out <= valid_in; // Track AGC enable transitions
agc_enable_prev <= agc_enable;
// Compute inclusive metrics: if valid_in fires this cycle,
// include current sample in the snapshot taken at frame_boundary.
// This avoids losing the last sample when valid_in and
// frame_boundary coincide (NBA last-write-wins would otherwise
// snapshot stale values then reset, dropping the sample entirely).
wire_frame_sat_incr = (valid_in && (overflow_i || overflow_q)
&& (frame_sat_count != 8'hFF));
wire_frame_peak_update = (valid_in && (max_iq > frame_peak));
// ---- Data pipeline (1-cycle latency) ----
valid_out <= valid_in;
if (valid_in) begin if (valid_in) begin
data_i_out <= sat_i; data_i_out <= sat_i;
data_q_out <= sat_q; data_q_out <= sat_q;
// Count clipped samples (either channel clipping counts as 1) // Per-frame saturation counting
if ((overflow_i || overflow_q) && (saturation_count != 8'hFF)) if ((overflow_i || overflow_q) && (frame_sat_count != 8'hFF))
saturation_count <= saturation_count + 8'd1; frame_sat_count <= frame_sat_count + 8'd1;
// Per-frame peak tracking (pre-gain, measures input signal level)
if (max_iq > frame_peak)
frame_peak <= max_iq;
end end
// ---- Frame boundary: AGC update + metric snapshot ----
if (frame_boundary) begin
// Snapshot per-frame metrics INCLUDING current sample if valid_in
saturation_count <= wire_frame_sat_incr
? (frame_sat_count + 8'd1)
: frame_sat_count;
peak_magnitude <= wire_frame_peak_update
? max_iq[14:7]
: frame_peak[14:7];
// Reset per-frame accumulators for next frame
frame_sat_count <= 8'd0;
frame_peak <= 15'd0;
if (agc_enable) begin
// AGC auto-adjustment at frame boundary
// Use inclusive counts/peaks (accounting for simultaneous valid_in)
if (wire_frame_sat_incr || frame_sat_count > 8'd0) begin
// Clipping detected: reduce gain immediately (attack)
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) -
$signed({1'b0, agc_attack}));
holdoff_counter <= agc_holdoff; // Reset holdoff
end else if ((wire_frame_peak_update ? max_iq[14:7] : frame_peak[14:7])
< agc_target) begin
// Signal too weak: increase gain after holdoff expires
if (holdoff_counter == 4'd0) begin
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) +
$signed({1'b0, agc_decay}));
end else begin
holdoff_counter <= holdoff_counter - 4'd1;
end
end else begin
// Signal in good range, no saturation: hold gain
// Reset holdoff so next weak frame has to wait again
holdoff_counter <= agc_holdoff;
end
end
end
// ---- AGC enable transition: initialize from host gain ----
if (agc_enable && !agc_enable_prev) begin
agc_gain <= encoding_to_signed(gain_shift);
holdoff_counter <= agc_holdoff;
end
// ---- Update current_gain output ----
if (agc_enable)
current_gain <= signed_to_encoding(agc_gain);
else
current_gain <= gain_shift;
end end
end end
@@ -120,9 +120,10 @@ set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets {ft_clkout_IBUF}]
# ---- Run implementation steps ---- # ---- Run implementation steps ----
opt_design -directive Explore opt_design -directive Explore
place_design -directive Explore place_design -directive ExtraNetDelay_high
phys_opt_design -directive AggressiveExplore
route_design -directive AggressiveExplore
phys_opt_design -directive AggressiveExplore phys_opt_design -directive AggressiveExplore
route_design -directive Explore
phys_opt_design -directive AggressiveExplore phys_opt_design -directive AggressiveExplore
set impl_elapsed [expr {[clock seconds] - $impl_start}] set impl_elapsed [expr {[clock seconds] - $impl_start}]
+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__":
File diff suppressed because it is too large Load Diff
+515 -4
View File
@@ -38,10 +38,20 @@ reg signed [15:0] data_q_in;
reg valid_in; reg valid_in;
reg [3:0] gain_shift; reg [3:0] gain_shift;
// AGC configuration (default: AGC disabled — manual mode)
reg agc_enable;
reg [7:0] agc_target;
reg [3:0] agc_attack;
reg [3:0] agc_decay;
reg [3:0] agc_holdoff;
reg frame_boundary;
wire signed [15:0] data_i_out; wire signed [15:0] data_i_out;
wire signed [15:0] data_q_out; wire signed [15:0] data_q_out;
wire valid_out; wire valid_out;
wire [7:0] saturation_count; wire [7:0] saturation_count;
wire [7:0] peak_magnitude;
wire [3:0] current_gain;
rx_gain_control dut ( rx_gain_control dut (
.clk(clk), .clk(clk),
@@ -50,10 +60,18 @@ rx_gain_control dut (
.data_q_in(data_q_in), .data_q_in(data_q_in),
.valid_in(valid_in), .valid_in(valid_in),
.gain_shift(gain_shift), .gain_shift(gain_shift),
.agc_enable(agc_enable),
.agc_target(agc_target),
.agc_attack(agc_attack),
.agc_decay(agc_decay),
.agc_holdoff(agc_holdoff),
.frame_boundary(frame_boundary),
.data_i_out(data_i_out), .data_i_out(data_i_out),
.data_q_out(data_q_out), .data_q_out(data_q_out),
.valid_out(valid_out), .valid_out(valid_out),
.saturation_count(saturation_count) .saturation_count(saturation_count),
.peak_magnitude(peak_magnitude),
.current_gain(current_gain)
); );
// --------------------------------------------------------------- // ---------------------------------------------------------------
@@ -105,6 +123,13 @@ initial begin
data_q_in = 0; data_q_in = 0;
valid_in = 0; valid_in = 0;
gain_shift = 4'd0; gain_shift = 4'd0;
// AGC disabled for backward-compatible tests (Tests 1-12)
agc_enable = 0;
agc_target = 8'd200;
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd4;
frame_boundary = 0;
repeat (4) @(posedge clk); repeat (4) @(posedge clk);
reset_n = 1; reset_n = 1;
@@ -152,6 +177,9 @@ initial begin
"T3.1: I saturated to +32767"); "T3.1: I saturated to +32767");
check(data_q_out == -16'sd32768, check(data_q_out == -16'sd32768,
"T3.2: Q saturated to -32768"); "T3.2: Q saturated to -32768");
// Pulse frame_boundary to snapshot the per-frame saturation count
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd1, check(saturation_count == 8'd1,
"T3.3: Saturation counter = 1 (both channels clipped counts as 1)"); "T3.3: Saturation counter = 1 (both channels clipped counts as 1)");
@@ -173,6 +201,9 @@ initial begin
"T4.1: I attenuated 4000>>2 = 1000"); "T4.1: I attenuated 4000>>2 = 1000");
check(data_q_out == -16'sd500, check(data_q_out == -16'sd500,
"T4.2: Q attenuated -2000>>2 = -500"); "T4.2: Q attenuated -2000>>2 = -500");
// Pulse frame_boundary to snapshot (should be 0 — no clipping)
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd0, check(saturation_count == 8'd0,
"T4.3: No saturation on right shift"); "T4.3: No saturation on right shift");
@@ -315,13 +346,18 @@ initial begin
valid_in = 1'b0; valid_in = 1'b0;
@(posedge clk); #1; @(posedge clk); #1;
// Pulse frame_boundary to snapshot per-frame saturation count
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd255, check(saturation_count == 8'd255,
"T11.1: Counter capped at 255 after 256 saturating samples"); "T11.1: Counter capped at 255 after 256 saturating samples");
// One more sample — should stay at 255 // One more sample + frame boundary — should still be capped at 1 (new frame)
send_sample(16'sd20000, 16'sd20000); send_sample(16'sd20000, 16'sd20000);
check(saturation_count == 8'd255, @(negedge clk); frame_boundary = 1; @(posedge clk); #1;
"T11.2: Counter stays at 255 (no wrap)"); @(negedge clk); frame_boundary = 0; @(posedge clk); #1;
check(saturation_count == 8'd1,
"T11.2: New frame counter = 1 (single sample)");
// --------------------------------------------------------------- // ---------------------------------------------------------------
// TEST 12: Reset clears everything // TEST 12: Reset clears everything
@@ -329,6 +365,8 @@ initial begin
$display(""); $display("");
$display("--- Test 12: Reset clears all ---"); $display("--- Test 12: Reset clears all ---");
gain_shift = 4'd0; // Reset gain_shift to 0 so current_gain reads 0
agc_enable = 0;
reset_n = 0; reset_n = 0;
repeat (2) @(posedge clk); repeat (2) @(posedge clk);
reset_n = 1; reset_n = 1;
@@ -342,6 +380,479 @@ initial begin
"T12.3: valid_out cleared on reset"); "T12.3: valid_out cleared on reset");
check(saturation_count == 8'd0, check(saturation_count == 8'd0,
"T12.4: Saturation counter cleared on reset"); "T12.4: Saturation counter cleared on reset");
check(current_gain == 4'd0,
"T12.5: current_gain cleared on reset");
// ---------------------------------------------------------------
// TEST 13: current_gain reflects gain_shift in manual mode
// ---------------------------------------------------------------
$display("");
$display("--- Test 13: current_gain tracks gain_shift (manual) ---");
gain_shift = 4'b0_011; // amplify x8
@(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0011,
"T13.1: current_gain = 0x3 (amplify x8)");
gain_shift = 4'b1_010; // attenuate /4
@(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b1010,
"T13.2: current_gain = 0xA (attenuate /4)");
// ---------------------------------------------------------------
// TEST 14: Peak magnitude tracking
// ---------------------------------------------------------------
$display("");
$display("--- Test 14: Peak magnitude tracking ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_000; // pass-through
// Send samples with increasing magnitude
send_sample(16'sd100, 16'sd50);
send_sample(16'sd1000, 16'sd500);
send_sample(16'sd8000, 16'sd2000); // peak = 8000
send_sample(16'sd200, 16'sd100);
// Pulse frame_boundary to snapshot
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
// peak_magnitude = upper 8 bits of 15-bit peak (8000)
// 8000 = 0x1F40, 15-bit = 0x1F40, [14:7] = 0x3E = 62
check(peak_magnitude == 8'd62,
"T14.1: Peak magnitude = 62 (8000 >> 7)");
// ---------------------------------------------------------------
// TEST 15: AGC auto gain-down on saturation
// ---------------------------------------------------------------
$display("");
$display("--- Test 15: AGC gain-down on saturation ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
// Start with amplify x4 (gain_shift = 0x02), then enable AGC
gain_shift = 4'b0_010; // amplify x4, internal gain = +2
agc_enable = 0;
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100;
@(posedge clk); @(posedge clk);
// Enable AGC — should initialize from gain_shift
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0010,
"T15.1: AGC initialized from gain_shift (amplify x4)");
// Send saturating samples (will clip at x4 gain)
send_sample(16'sd20000, 16'sd20000);
send_sample(16'sd20000, 16'sd20000);
// Pulse frame_boundary — AGC should reduce gain by attack=1
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
// current_gain lags agc_gain by 1 cycle (NBA), wait one extra cycle
@(posedge clk); #1;
// Internal gain was +2, attack=1 → new gain = +1 (0x01)
check(current_gain == 4'b0001,
"T15.2: AGC reduced gain to x2 after saturation");
// Another frame with saturation (20000*2 = 40000 > 32767)
send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
// gain was +1, attack=1 → new gain = 0 (0x00)
check(current_gain == 4'b0000,
"T15.3: AGC reduced gain to x1 (pass-through)");
// At gain 0 (pass-through), 20000 does NOT overflow 16-bit range,
// so no saturation occurs. Signal peak = 20000 >> 7 = 156 > target(100),
// so AGC correctly holds gain at 0. This is expected behavior.
// To test crossing into attenuation: increase attack to 3.
agc_attack = 4'd3;
// Reset and start fresh with gain +2, attack=3
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_010; // amplify x4, internal gain = +2
agc_enable = 0;
@(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
// Send saturating samples
send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
// gain was +2, attack=3 → new gain = -1 → encoding 0x09
check(current_gain == 4'b1001,
"T15.4: Large attack step crosses to attenuation (gain +2 - 3 = -1 0x9)");
// ---------------------------------------------------------------
// TEST 16: AGC auto gain-up after holdoff
// ---------------------------------------------------------------
$display("");
$display("--- Test 16: AGC gain-up after holdoff ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
// Start with low gain, weak signal, holdoff=2
gain_shift = 4'b0_000; // pass-through (internal gain = 0)
agc_enable = 0;
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100; // target peak = 100 (in upper 8 bits = 12800 raw)
@(posedge clk); @(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); #1;
// Frame 1: send weak signal (peak < target), holdoff counter = 2
send_sample(16'sd100, 16'sd50); // peak=100, [14:7]=0 (very weak)
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0000,
"T16.1: Gain held during holdoff (frame 1, holdoff=2)");
// Frame 2: still weak, holdoff counter decrements to 1
send_sample(16'sd100, 16'sd50);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0000,
"T16.2: Gain held during holdoff (frame 2, holdoff=1)");
// Frame 3: holdoff expired (was 0 at start of frame) → gain up
send_sample(16'sd100, 16'sd50);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0001,
"T16.3: Gain increased after holdoff expired (gain 0->1)");
// ---------------------------------------------------------------
// TEST 17: Repeated attacks drive gain negative, clamp at -7,
// then decay recovers
// ---------------------------------------------------------------
$display("");
$display("--- Test 17: Repeated attack negative clamp decay recovery ---");
// ----- 17a: Walk gain from +7 down through zero via repeated attack -----
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_111; // amplify x128, internal gain = +7
agc_enable = 0;
agc_attack = 4'd2;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100;
@(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0_111,
"T17a.1: AGC initialized at gain +7 (0x7)");
// Frame 1: saturating at gain +7 → gain 7-2=5
send_sample(16'sd1000, 16'sd1000); // 1000<<7 = 128000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_101,
"T17a.2: After attack: gain +5 (0x5)");
// Frame 2: still saturating at gain +5 → gain 5-2=3
send_sample(16'sd1000, 16'sd1000); // 1000<<5 = 32000 → no overflow
send_sample(16'sd2000, 16'sd2000); // 2000<<5 = 64000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_011,
"T17a.3: After attack: gain +3 (0x3)");
// Frame 3: saturating at gain +3 → gain 3-2=1
send_sample(16'sd5000, 16'sd5000); // 5000<<3 = 40000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_001,
"T17a.4: After attack: gain +1 (0x1)");
// Frame 4: saturating at gain +1 → gain 1-2=-1 → encoding 0x9
send_sample(16'sd20000, 16'sd20000); // 20000<<1 = 40000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_001,
"T17a.5: Attack crossed zero: gain -1 (0x9)");
// Frame 5: at gain -1 (right shift 1), 20000>>>1=10000, NO overflow.
// peak = 20000 → [14:7]=156 > target(100) → HOLD, gain stays -1
send_sample(16'sd20000, 16'sd20000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_001,
"T17a.6: No overflow at -1, peak>target HOLD, gain stays -1");
// ----- 17b: Max attack step clamps at -7 -----
$display("");
$display("--- Test 17b: Max attack clamps at -7 ---");
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_011; // amplify x8, internal gain = +3
agc_attack = 4'd15; // max attack step
agc_enable = 0;
@(posedge clk);
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
check(current_gain == 4'b0_011,
"T17b.1: Initialized at gain +3");
// One saturating frame: gain = clamp(3 - 15) = clamp(-12) = -7 → 0xF
send_sample(16'sd5000, 16'sd5000); // 5000<<3 = 40000 → overflow
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_111,
"T17b.2: Gain clamped at -7 (0xF) after max attack");
// Another frame at gain -7: 5000>>>7 = 39, peak = 5000→[14:7]=39 < target(100)
// → decay path, but holdoff counter was reset to 2 by the attack above
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_111,
"T17b.3: Gain still -7 (holdoff active, 21)");
// ----- 17c: Decay recovery from -7 after holdoff -----
$display("");
$display("--- Test 17c: Decay recovery from deep negative ---");
// Holdoff was 2. After attack (frame above), holdoff=2.
// Frame after 17b.3: holdoff decrements to 0
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_111,
"T17c.1: Gain still -7 (holdoff 10)");
// Now holdoff=0, next weak frame should trigger decay: -7 + 1 = -6 → 0xE
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_110,
"T17c.2: Decay from -7 to -6 (0xE) after holdoff expired");
// One more decay: -6 + 1 = -5 → 0xD
send_sample(16'sd5000, 16'sd5000);
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b1_101,
"T17c.3: Decay from -6 to -5 (0xD)");
// Verify output is actually attenuated: at gain -5 (right shift 5),
// 5000 >>> 5 = 156
send_sample(16'sd5000, 16'sd0);
check(data_i_out == 16'sd156,
"T17c.4: Output correctly attenuated: 5000>>>5 = 156");
// =================================================================
// Test 18: valid_in + frame_boundary on the SAME cycle
// Verify the coincident sample is included in the frame snapshot
// (Bug #7 fix — previously lost due to NBA last-write-wins)
// =================================================================
$display("");
$display("--- Test 18: valid_in + frame_boundary simultaneous ---");
// ----- 18a: Coincident saturating sample included in sat count -----
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_011; // amplify x8 (shift left 3)
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd2;
agc_target = 8'd100;
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
// Send one normal sample first (establishes a non-zero frame)
send_sample(16'sd100, 16'sd100); // small, no overflow at gain +3
// Now: assert valid_in AND frame_boundary on the SAME posedge.
// The sample is large enough to overflow at gain +3: 5000<<3 = 40000 > 32767
@(negedge clk);
data_i_in = 16'sd5000;
data_q_in = 16'sd5000;
valid_in = 1'b1;
frame_boundary = 1'b1;
@(posedge clk); #1; // DUT samples both signals
@(negedge clk);
valid_in = 1'b0;
frame_boundary = 1'b0;
@(posedge clk); #1; // let NBA settle
@(posedge clk); #1;
// Saturation count should be 1 (the coincident sample overflowed)
check(saturation_count == 8'd1,
"T18a.1: Coincident saturating sample counted in snapshot (sat_count=1)");
// Peak should reflect pre-gain max(|5000|,|5000|) = 5000 → [14:7] = 39
// (or at least >= the first sample's peak of 100→[14:7]=0)
check(peak_magnitude == 8'd39,
"T18a.2: Coincident sample peak included in snapshot (peak=39)");
// AGC should have attacked (sat > 0): gain +3 → +3-1 = +2
check(current_gain == 4'b0_010,
"T18a.3: AGC attacked on coincident saturation (gain +3 +2)");
// ----- 18b: Coincident non-saturating peak updates snapshot -----
$display("");
$display("--- Test 18b: Coincident peak-only sample ---");
reset_n = 0;
agc_enable = 0; // deassert so transition fires with NEW gain_shift
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_000; // no amplification (shift 0)
agc_attack = 4'd1;
agc_decay = 4'd1;
agc_holdoff = 4'd0;
agc_target = 8'd200; // high target so signal is "weak"
agc_enable = 1;
@(posedge clk); @(posedge clk); @(posedge clk); #1;
// Send a small sample
send_sample(16'sd50, 16'sd50);
// Coincident frame_boundary + valid_in with a LARGER sample (not saturating)
@(negedge clk);
data_i_in = 16'sd10000;
data_q_in = 16'sd10000;
valid_in = 1'b1;
frame_boundary = 1'b1;
@(posedge clk); #1;
@(negedge clk);
valid_in = 1'b0;
frame_boundary = 1'b0;
@(posedge clk); #1;
@(posedge clk); #1;
// Peak should be max(|10000|,|10000|) = 10000 → [14:7] = 78
check(peak_magnitude == 8'd78,
"T18b.1: Coincident larger peak included (peak=78)");
// No saturation at gain 0
check(saturation_count == 8'd0,
"T18b.2: No saturation (gain=0, no overflow)");
// =================================================================
// Test 19: AGC enable toggle mid-frame
// Verify gain initializes from gain_shift and holdoff resets
// =================================================================
$display("");
$display("--- Test 19: AGC enable toggle mid-frame ---");
// ----- 19a: Enable AGC mid-frame, verify gain init -----
reset_n = 0;
repeat (2) @(posedge clk);
reset_n = 1;
repeat (2) @(posedge clk);
gain_shift = 4'b0_101; // amplify x32 (shift left 5), internal = +5
agc_attack = 4'd2;
agc_decay = 4'd1;
agc_holdoff = 4'd3;
agc_target = 8'd100;
agc_enable = 0; // start disabled
@(posedge clk); #1;
// With AGC off, current_gain should follow gain_shift directly
check(current_gain == 4'b0_101,
"T19a.1: AGC disabled current_gain = gain_shift (0x5)");
// Send a few samples (building up frame metrics)
send_sample(16'sd1000, 16'sd1000);
send_sample(16'sd2000, 16'sd2000);
// Toggle AGC enable ON mid-frame
@(negedge clk);
agc_enable = 1;
@(posedge clk); #1;
@(posedge clk); #1; // let enable transition register
// Gain should initialize from gain_shift encoding (0b0_101 → +5)
check(current_gain == 4'b0_101,
"T19a.2: AGC enabled mid-frame gain initialized from gain_shift (+5)");
// Send a saturating sample, then boundary
send_sample(16'sd5000, 16'sd5000); // 5000<<5 overflows
@(negedge clk); frame_boundary = 1; @(posedge clk); #1;
@(negedge clk); frame_boundary = 0; @(posedge clk); #1;
@(posedge clk); #1;
// AGC should attack: gain +5 → +5-2 = +3
check(current_gain == 4'b0_011,
"T19a.3: After boundary, AGC attacked (gain +5 +3)");
// ----- 19b: Disable AGC mid-frame, verify passthrough -----
$display("");
$display("--- Test 19b: Disable AGC mid-frame ---");
// Change gain_shift to a new value
@(negedge clk);
gain_shift = 4'b1_010; // attenuate by 2 (right shift 2)
agc_enable = 0;
@(posedge clk); #1;
@(posedge clk); #1;
// With AGC off, current_gain should follow gain_shift
check(current_gain == 4'b1_010,
"T19b.1: AGC disabled current_gain = gain_shift (0xA, atten 2)");
// Send sample: 1000 >> 2 = 250
send_sample(16'sd1000, 16'sd0);
check(data_i_out == 16'sd250,
"T19b.2: Output uses host gain_shift when AGC off: 1000>>2=250");
// ----- 19c: Re-enable, verify gain re-initializes -----
@(negedge clk);
gain_shift = 4'b0_010; // amplify by 4 (shift left 2), internal = +2
agc_enable = 1;
@(posedge clk); #1;
@(posedge clk); #1;
check(current_gain == 4'b0_010,
"T19c.1: AGC re-enabled gain re-initialized from gain_shift (+2)");
// --------------------------------------------------------------- // ---------------------------------------------------------------
// SUMMARY // SUMMARY
+24 -3
View File
@@ -79,6 +79,12 @@ module tb_usb_data_interface;
reg [7:0] status_self_test_detail; reg [7:0] status_self_test_detail;
reg status_self_test_busy; reg status_self_test_busy;
// AGC status readback inputs
reg [3:0] status_agc_current_gain;
reg [7:0] status_agc_peak_magnitude;
reg [7:0] status_agc_saturation_count;
reg status_agc_enable;
// ── Clock generators (asynchronous) ──────────────────────── // ── Clock generators (asynchronous) ────────────────────────
always #(CLK_PERIOD / 2) clk = ~clk; always #(CLK_PERIOD / 2) clk = ~clk;
always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in; always #(FT_CLK_PERIOD / 2) ft601_clk_in = ~ft601_clk_in;
@@ -134,7 +140,13 @@ module tb_usb_data_interface;
// Self-test status readback // Self-test status readback
.status_self_test_flags (status_self_test_flags), .status_self_test_flags (status_self_test_flags),
.status_self_test_detail(status_self_test_detail), .status_self_test_detail(status_self_test_detail),
.status_self_test_busy (status_self_test_busy) .status_self_test_busy (status_self_test_busy),
// AGC status readback
.status_agc_current_gain (status_agc_current_gain),
.status_agc_peak_magnitude (status_agc_peak_magnitude),
.status_agc_saturation_count(status_agc_saturation_count),
.status_agc_enable (status_agc_enable)
); );
// ── Test bookkeeping ─────────────────────────────────────── // ── Test bookkeeping ───────────────────────────────────────
@@ -194,6 +206,10 @@ module tb_usb_data_interface;
status_self_test_flags = 5'b00000; status_self_test_flags = 5'b00000;
status_self_test_detail = 8'd0; status_self_test_detail = 8'd0;
status_self_test_busy = 1'b0; status_self_test_busy = 1'b0;
status_agc_current_gain = 4'd0;
status_agc_peak_magnitude = 8'd0;
status_agc_saturation_count = 8'd0;
status_agc_enable = 1'b0;
repeat (6) @(posedge ft601_clk_in); repeat (6) @(posedge ft601_clk_in);
reset_n = 1; reset_n = 1;
// Wait enough cycles for stream_control CDC to propagate // Wait enough cycles for stream_control CDC to propagate
@@ -902,6 +918,11 @@ module tb_usb_data_interface;
status_self_test_flags = 5'b11111; status_self_test_flags = 5'b11111;
status_self_test_detail = 8'hA5; status_self_test_detail = 8'hA5;
status_self_test_busy = 1'b0; status_self_test_busy = 1'b0;
// AGC status: gain=5, peak=180, sat_count=12, enabled
status_agc_current_gain = 4'd5;
status_agc_peak_magnitude = 8'd180;
status_agc_saturation_count = 8'd12;
status_agc_enable = 1'b1;
// Pulse status_request (1 cycle in clk domain — toggles status_req_toggle_100m) // Pulse status_request (1 cycle in clk domain — toggles status_req_toggle_100m)
@(posedge clk); @(posedge clk);
@@ -958,8 +979,8 @@ module tb_usb_data_interface;
"Status readback: word 2 = {guard, short_chirp}"); "Status readback: word 2 = {guard, short_chirp}");
check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32}, check(uut.status_words[3] === {16'd17450, 10'd0, 6'd32},
"Status readback: word 3 = {short_listen, 0, chirps_per_elev}"); "Status readback: word 3 = {short_listen, 0, chirps_per_elev}");
check(uut.status_words[4] === {30'd0, 2'b10}, check(uut.status_words[4] === {4'd5, 8'd180, 8'd12, 1'b1, 9'd0, 2'b10},
"Status readback: word 4 = range_mode=2'b10"); "Status readback: word 4 = {agc_gain=5, peak=180, sat=12, en=1, range_mode=2}");
// status_words[5] = {7'd0, busy, 8'd0, detail[7:0], 3'd0, flags[4:0]} // status_words[5] = {7'd0, busy, 8'd0, detail[7:0], 3'd0, flags[4:0]}
// = {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111} // = {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111}
check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111}, check(uut.status_words[5] === {7'd0, 1'b0, 8'd0, 8'hA5, 3'd0, 5'b11111},
+19 -9
View File
@@ -20,8 +20,8 @@ module usb_data_interface (
// Control signals // Control signals
output reg ft601_txe_n, // Transmit enable (active low) output reg ft601_txe_n, // Transmit enable (active low)
output reg ft601_rxf_n, // Receive enable (active low) output reg ft601_rxf_n, // Receive enable (active low)
input wire ft601_txe, // Transmit FIFO empty input wire ft601_txe, // TXE: Transmit FIFO Not Full (high = space available to write)
input wire ft601_rxf, // Receive FIFO full input wire ft601_rxf, // RXF: Receive FIFO Not Empty (high = data available to read)
output reg ft601_wr_n, // Write strobe (active low) output reg ft601_wr_n, // Write strobe (active low)
output reg ft601_rd_n, // Read strobe (active low) output reg ft601_rd_n, // Read strobe (active low)
output reg ft601_oe_n, // Output enable (active low) output reg ft601_oe_n, // Output enable (active low)
@@ -77,7 +77,13 @@ module usb_data_interface (
// Self-test status readback (opcode 0x31 / included in 0xFF status packet) // Self-test status readback (opcode 0x31 / included in 0xFF status packet)
input wire [4:0] status_self_test_flags, // Per-test PASS(1)/FAIL(0) latched input wire [4:0] status_self_test_flags, // Per-test PASS(1)/FAIL(0) latched
input wire [7:0] status_self_test_detail, // Diagnostic detail byte latched input wire [7:0] status_self_test_detail, // Diagnostic detail byte latched
input wire status_self_test_busy // Self-test FSM still running input wire status_self_test_busy, // Self-test FSM still running
// AGC status readback
input wire [3:0] status_agc_current_gain,
input wire [7:0] status_agc_peak_magnitude,
input wire [7:0] status_agc_saturation_count,
input wire status_agc_enable
); );
// USB packet structure (same as before) // USB packet structure (same as before)
@@ -258,18 +264,22 @@ always @(posedge ft601_clk_in or negedge ft601_reset_n) begin
// Gap 2: Capture status snapshot when request arrives in ft601 domain // Gap 2: Capture status snapshot when request arrives in ft601 domain
if (status_req_ft601) begin if (status_req_ft601) begin
// Pack register values into 5x 32-bit status words // Pack register values into 5x 32-bit status words
// Word 0: {0xFF, mode[1:0], stream_ctrl[2:0], cfar_threshold[15:0]} // Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
status_words[0] <= {8'hFF, 3'b000, status_radar_mode, status_words[0] <= {8'hFF, status_radar_mode, status_stream_ctrl,
5'b00000, status_stream_ctrl, 3'b000, status_cfar_threshold};
status_cfar_threshold};
// Word 1: {long_chirp_cycles[15:0], long_listen_cycles[15:0]} // Word 1: {long_chirp_cycles[15:0], long_listen_cycles[15:0]}
status_words[1] <= {status_long_chirp, status_long_listen}; status_words[1] <= {status_long_chirp, status_long_listen};
// Word 2: {guard_cycles[15:0], short_chirp_cycles[15:0]} // Word 2: {guard_cycles[15:0], short_chirp_cycles[15:0]}
status_words[2] <= {status_guard, status_short_chirp}; status_words[2] <= {status_guard, status_short_chirp};
// Word 3: {short_listen_cycles[15:0], chirps_per_elev[5:0], 10'b0} // Word 3: {short_listen_cycles[15:0], chirps_per_elev[5:0], 10'b0}
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev}; status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
// Word 4: Fix 7 — range_mode in bits [1:0], rest reserved // Word 4: AGC metrics + range_mode
status_words[4] <= {30'd0, status_range_mode}; status_words[4] <= {status_agc_current_gain, // [31:28]
status_agc_peak_magnitude, // [27:20]
status_agc_saturation_count, // [19:12]
status_agc_enable, // [11]
9'd0, // [10:2] reserved
status_range_mode}; // [1:0]
// Word 5: Self-test results {reserved[6:0], busy, reserved[7:0], detail[7:0], reserved[2:0], flags[4:0]} // Word 5: Self-test results {reserved[6:0], busy, reserved[7:0], detail[7:0], reserved[2:0], flags[4:0]}
status_words[5] <= {7'd0, status_self_test_busy, status_words[5] <= {7'd0, status_self_test_busy,
8'd0, status_self_test_detail, 8'd0, status_self_test_detail,
@@ -90,7 +90,13 @@ module usb_data_interface_ft2232h (
// Self-test status readback // Self-test status readback
input wire [4:0] status_self_test_flags, input wire [4:0] status_self_test_flags,
input wire [7:0] status_self_test_detail, input wire [7:0] status_self_test_detail,
input wire status_self_test_busy input wire status_self_test_busy,
// AGC status readback
input wire [3:0] status_agc_current_gain,
input wire [7:0] status_agc_peak_magnitude,
input wire [7:0] status_agc_saturation_count,
input wire status_agc_enable
); );
// ============================================================================ // ============================================================================
@@ -275,13 +281,18 @@ always @(posedge ft_clk or negedge ft_reset_n) begin
// Status snapshot on request // Status snapshot on request
if (status_req_ft) begin if (status_req_ft) begin
status_words[0] <= {8'hFF, 3'b000, status_radar_mode, // Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
5'b00000, status_stream_ctrl, status_words[0] <= {8'hFF, status_radar_mode, status_stream_ctrl,
status_cfar_threshold}; 3'b000, status_cfar_threshold};
status_words[1] <= {status_long_chirp, status_long_listen}; status_words[1] <= {status_long_chirp, status_long_listen};
status_words[2] <= {status_guard, status_short_chirp}; status_words[2] <= {status_guard, status_short_chirp};
status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev}; status_words[3] <= {status_short_listen, 10'd0, status_chirps_per_elev};
status_words[4] <= {30'd0, status_range_mode}; status_words[4] <= {status_agc_current_gain, // [31:28]
status_agc_peak_magnitude, // [27:20]
status_agc_saturation_count, // [19:12]
status_agc_enable, // [11]
9'd0, // [10:2] reserved
status_range_mode}; // [1:0]
status_words[5] <= {7'd0, status_self_test_busy, status_words[5] <= {7'd0, status_self_test_busy,
8'd0, status_self_test_detail, 8'd0, status_self_test_detail,
3'd0, status_self_test_flags}; 3'd0, status_self_test_flags};
+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}")
+431
View File
@@ -0,0 +1,431 @@
# ruff: noqa: T201
#!/usr/bin/env python3
"""
One-off AGC saturation analysis for ADI CN0566 raw IQ captures.
Bit-accurate simulation of rx_gain_control.v AGC inner loop applied
to real captured IQ data. Three scenarios per dataset:
Row 1 AGC OFF: Fixed gain_shift=0 (pass-through). Shows raw clipping.
Row 2 AGC ON: Auto-adjusts from gain_shift=0. Clipping clears.
Row 3 AGC delayed: OFF for first half, ON at midpoint.
Shows the transition: clipping AGC activates clears.
Key RTL details modelled exactly:
- gain_shift[3]=direction (0=amplify/left, 1=attenuate/right), [2:0]=amount
- Internal agc_gain is signed -7..+7
- Peak is measured PRE-gain (raw input |sample|, upper 8 of 15 bits)
- Saturation is measured POST-gain (overflow from shift)
- Attack: gain -= agc_attack when any sample clips (immediate)
- Decay: gain += agc_decay when peak < target AND holdoff expired
- Hold: when peak >= target AND no saturation, hold gain, reset holdoff
Usage:
python adi_agc_analysis.py
python adi_agc_analysis.py --data /path/to/file.npy --label "my capture"
"""
import argparse
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
# ---------------------------------------------------------------------------
# FPGA AGC parameters (rx_gain_control.v reset defaults)
# ---------------------------------------------------------------------------
AGC_TARGET = 200 # host_agc_target (8-bit, default 200)
AGC_ATTACK = 1 # host_agc_attack (4-bit, default 1)
AGC_DECAY = 1 # host_agc_decay (4-bit, default 1)
AGC_HOLDOFF = 4 # host_agc_holdoff (4-bit, default 4)
ADC_RAIL = 4095 # 12-bit ADC max absolute value
# ---------------------------------------------------------------------------
# Gain encoding helpers (match RTL signed_to_encoding / encoding_to_signed)
# ---------------------------------------------------------------------------
def signed_to_encoding(g: int) -> int:
"""Convert signed gain (-7..+7) to gain_shift[3:0] encoding.
[3]=0, [2:0]=N amplify (left shift) by N
[3]=1, [2:0]=N attenuate (right shift) by N
"""
if g >= 0:
return g & 0x07
return 0x08 | ((-g) & 0x07)
def encoding_to_signed(enc: int) -> int:
"""Convert gain_shift[3:0] encoding to signed gain."""
if (enc & 0x08) == 0:
return enc & 0x07
return -(enc & 0x07)
def clamp_gain(val: int) -> int:
"""Clamp to [-7, +7] (matches RTL clamp_gain function)."""
return max(-7, min(7, val))
# ---------------------------------------------------------------------------
# Apply gain shift to IQ data (matches RTL combinational logic)
# ---------------------------------------------------------------------------
def apply_gain_shift(frame_i: np.ndarray, frame_q: np.ndarray,
gain_enc: int) -> tuple[np.ndarray, np.ndarray, int]:
"""Apply gain_shift encoding to 16-bit signed IQ arrays.
Returns (shifted_i, shifted_q, overflow_count).
Matches the RTL: left shift = amplify, right shift = attenuate,
saturate to ±32767 on overflow.
"""
direction = (gain_enc >> 3) & 1 # 0=amplify, 1=attenuate
amount = gain_enc & 0x07
if amount == 0:
return frame_i.copy(), frame_q.copy(), 0
if direction == 0:
# Left shift (amplify)
si = frame_i.astype(np.int64) * (1 << amount)
sq = frame_q.astype(np.int64) * (1 << amount)
else:
# Arithmetic right shift (attenuate)
si = frame_i.astype(np.int64) >> amount
sq = frame_q.astype(np.int64) >> amount
# Count overflows (post-shift values outside 16-bit signed range)
overflow_i = (si > 32767) | (si < -32768)
overflow_q = (sq > 32767) | (sq < -32768)
overflow_count = int((overflow_i | overflow_q).sum())
# Saturate to ±32767
si = np.clip(si, -32768, 32767).astype(np.int16)
sq = np.clip(sq, -32768, 32767).astype(np.int16)
return si, sq, overflow_count
# ---------------------------------------------------------------------------
# Per-frame AGC simulation (bit-accurate to rx_gain_control.v)
# ---------------------------------------------------------------------------
def simulate_agc(frames: np.ndarray, agc_enabled: bool = True,
enable_at_frame: int = 0,
initial_gain_enc: int = 0x00) -> dict:
"""Simulate FPGA inner-loop AGC across all frames.
Parameters
----------
frames : (N, chirps, samples) complex raw ADC captures (12-bit range)
agc_enabled : if False, gain stays fixed
enable_at_frame : frame index where AGC activates
initial_gain_enc : gain_shift[3:0] encoding when AGC enables (default 0x00 = pass-through)
"""
n_frames = frames.shape[0]
# Output arrays
out_gain_enc = np.zeros(n_frames, dtype=int) # gain_shift encoding [3:0]
out_gain_signed = np.zeros(n_frames, dtype=int) # signed gain for plotting
out_peak_mag = np.zeros(n_frames, dtype=int) # peak_magnitude[7:0]
out_sat_count = np.zeros(n_frames, dtype=int) # saturation_count[7:0]
out_sat_rate = np.zeros(n_frames, dtype=float)
out_rms_post = np.zeros(n_frames, dtype=float) # RMS after gain shift
# AGC internal state
agc_gain = 0 # signed, -7..+7
holdoff_counter = 0
agc_was_enabled = False
for i in range(n_frames):
frame = frames[i]
# Quantize to 16-bit signed (ADC is 12-bit, sign-extended to 16)
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
# --- PRE-gain peak measurement (RTL lines 133-135, 211-213) ---
abs_i = np.abs(frame_i.astype(np.int32))
abs_q = np.abs(frame_q.astype(np.int32))
max_iq = np.maximum(abs_i, abs_q)
frame_peak_15bit = int(max_iq.max()) # 15-bit unsigned
peak_8bit = (frame_peak_15bit >> 7) & 0xFF # Upper 8 bits
# --- Determine effective gain ---
agc_active = agc_enabled and (i >= enable_at_frame)
# AGC enable transition (RTL lines 250-253)
if agc_active and not agc_was_enabled:
agc_gain = encoding_to_signed(initial_gain_enc)
holdoff_counter = AGC_HOLDOFF
effective_enc = signed_to_encoding(agc_gain) if agc_active else initial_gain_enc
agc_was_enabled = agc_active
# --- Apply gain shift + count POST-gain overflow (RTL lines 114-126, 207-209) ---
shifted_i, shifted_q, frame_overflow = apply_gain_shift(
frame_i, frame_q, effective_enc)
frame_sat = min(255, frame_overflow)
# RMS of shifted signal
rms = float(np.sqrt(np.mean(
shifted_i.astype(np.float64)**2 + shifted_q.astype(np.float64)**2)))
total_samples = frame_i.size + frame_q.size
sat_rate = frame_overflow / total_samples if total_samples > 0 else 0.0
# --- Record outputs ---
out_gain_enc[i] = effective_enc
out_gain_signed[i] = agc_gain if agc_active else encoding_to_signed(initial_gain_enc)
out_peak_mag[i] = peak_8bit
out_sat_count[i] = frame_sat
out_sat_rate[i] = sat_rate
out_rms_post[i] = rms
# --- AGC update at frame boundary (RTL lines 226-246) ---
if agc_active:
if frame_sat > 0:
# Clipping: reduce gain immediately (attack)
agc_gain = clamp_gain(agc_gain - AGC_ATTACK)
holdoff_counter = AGC_HOLDOFF
elif peak_8bit < AGC_TARGET:
# Signal too weak: increase gain after holdoff
if holdoff_counter == 0:
agc_gain = clamp_gain(agc_gain + AGC_DECAY)
else:
holdoff_counter -= 1
else:
# Good range (peak >= target, no sat): hold, reset holdoff
holdoff_counter = AGC_HOLDOFF
return {
"gain_enc": out_gain_enc,
"gain_signed": out_gain_signed,
"peak_mag": out_peak_mag,
"sat_count": out_sat_count,
"sat_rate": out_sat_rate,
"rms_post": out_rms_post,
}
# ---------------------------------------------------------------------------
# Range-Doppler processing for heatmap display
# ---------------------------------------------------------------------------
def process_frame_rd(frame: np.ndarray, gain_enc: int,
n_range: int = 64,
n_doppler: int = 32) -> np.ndarray:
"""Range-Doppler magnitude for one frame with gain applied."""
frame_i = np.clip(np.round(frame.real), -32768, 32767).astype(np.int16)
frame_q = np.clip(np.round(frame.imag), -32768, 32767).astype(np.int16)
si, sq, _ = apply_gain_shift(frame_i, frame_q, gain_enc)
iq = si.astype(np.float64) + 1j * sq.astype(np.float64)
n_chirps, _ = iq.shape
range_fft = np.fft.fft(iq, axis=1)[:, :n_range]
doppler_fft = np.fft.fftshift(np.fft.fft(range_fft, axis=0), axes=0)
center = n_chirps // 2
half_d = n_doppler // 2
doppler_fft = doppler_fft[center - half_d:center + half_d, :]
rd_mag = np.abs(doppler_fft.real) + np.abs(doppler_fft.imag)
return rd_mag.T # (n_range, n_doppler)
# ---------------------------------------------------------------------------
# Plotting
# ---------------------------------------------------------------------------
def plot_scenario(axes, data: np.ndarray, agc: dict, title: str,
enable_frame: int = 0):
"""Plot one AGC scenario across 5 axes."""
n = data.shape[0]
xs = np.arange(n)
# Range-Doppler heatmap
if enable_frame > 0 and enable_frame < n:
f_before = max(0, enable_frame - 1)
f_after = min(n - 1, n - 2)
rd_before = process_frame_rd(data[f_before], int(agc["gain_enc"][f_before]))
rd_after = process_frame_rd(data[f_after], int(agc["gain_enc"][f_after]))
combined = np.hstack([rd_before, rd_after])
im = axes[0].imshow(
20 * np.log10(combined + 1), aspect="auto", origin="lower",
cmap="inferno", interpolation="nearest")
axes[0].axvline(x=rd_before.shape[1] - 0.5, color="cyan",
linewidth=2, linestyle="--")
axes[0].set_title(f"{title}\nL: f{f_before} (pre) | R: f{f_after} (post)")
else:
worst = int(np.argmax(agc["sat_count"]))
best = int(np.argmin(agc["sat_count"]))
f_show = worst if agc["sat_count"][worst] > 0 else best
rd = process_frame_rd(data[f_show], int(agc["gain_enc"][f_show]))
im = axes[0].imshow(
20 * np.log10(rd + 1), aspect="auto", origin="lower",
cmap="inferno", interpolation="nearest")
axes[0].set_title(f"{title}\nFrame {f_show}")
axes[0].set_xlabel("Doppler bin")
axes[0].set_ylabel("Range bin")
plt.colorbar(im, ax=axes[0], label="dB", shrink=0.8)
# Signed gain history (the real AGC state)
axes[1].plot(xs, agc["gain_signed"], color="#00ff88", linewidth=1.5)
axes[1].axhline(y=0, color="gray", linestyle=":", alpha=0.5,
label="Pass-through")
if enable_frame > 0:
axes[1].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", label="AGC ON")
axes[1].set_ylim(-8, 8)
axes[1].set_ylabel("Gain (signed)")
axes[1].set_title("AGC Internal Gain (-7=max atten, +7=max amp)")
axes[1].legend(fontsize=7, loc="upper right")
axes[1].grid(True, alpha=0.3)
# Peak magnitude (PRE-gain, 8-bit)
axes[2].plot(xs, agc["peak_mag"], color="#ffaa00", linewidth=1.0)
axes[2].axhline(y=AGC_TARGET, color="cyan", linestyle="--",
alpha=0.7, label=f"Target ({AGC_TARGET})")
axes[2].axhspan(240, 255, color="red", alpha=0.15, label="Clip zone")
if enable_frame > 0:
axes[2].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[2].set_ylim(0, 260)
axes[2].set_ylabel("Peak (8-bit)")
axes[2].set_title("Peak Magnitude (pre-gain, raw input)")
axes[2].legend(fontsize=7, loc="upper right")
axes[2].grid(True, alpha=0.3)
# Saturation count (POST-gain overflow)
axes[3].fill_between(xs, agc["sat_count"], color="red", alpha=0.4)
axes[3].plot(xs, agc["sat_count"], color="red", linewidth=0.8)
if enable_frame > 0:
axes[3].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[3].set_ylabel("Overflow Count")
total = int(agc["sat_count"].sum())
axes[3].set_title(f"Post-Gain Overflow (total={total})")
axes[3].grid(True, alpha=0.3)
# RMS signal level (post-gain)
axes[4].plot(xs, agc["rms_post"], color="#44aaff", linewidth=1.0)
if enable_frame > 0:
axes[4].axvline(x=enable_frame, color="yellow", linewidth=2,
linestyle="--", alpha=0.8)
axes[4].set_ylabel("RMS")
axes[4].set_xlabel("Frame")
axes[4].set_title("Post-Gain RMS Level")
axes[4].grid(True, alpha=0.3)
def analyze_dataset(data: np.ndarray, label: str):
"""Run 3-scenario analysis for one dataset."""
n_frames = data.shape[0]
mid = n_frames // 2
print(f"\n{'='*60}")
print(f" {label} — shape {data.shape}")
print(f"{'='*60}")
# Raw ADC stats
raw_sat = np.sum((np.abs(data.real) >= ADC_RAIL) |
(np.abs(data.imag) >= ADC_RAIL))
print(f" Raw ADC saturation: {raw_sat} samples "
f"({100*raw_sat/(2*data.size):.2f}%)")
# Scenario 1: AGC OFF — pass-through (gain_shift=0x00)
print(" [1/3] AGC OFF (gain=0, pass-through) ...")
agc_off = simulate_agc(data, agc_enabled=False, initial_gain_enc=0x00)
print(f" Post-gain overflow: {agc_off['sat_count'].sum()} "
f"(should be 0 — no amplification)")
# Scenario 2: AGC ON from frame 0
print(" [2/3] AGC ON (from start) ...")
agc_on = simulate_agc(data, agc_enabled=True, enable_at_frame=0,
initial_gain_enc=0x00)
print(f" Final gain: {agc_on['gain_signed'][-1]} "
f"(enc=0x{agc_on['gain_enc'][-1]:X})")
print(f" Post-gain overflow: {agc_on['sat_count'].sum()}")
# Scenario 3: AGC delayed
print(f" [3/3] AGC delayed (ON at frame {mid}) ...")
agc_delayed = simulate_agc(data, agc_enabled=True,
enable_at_frame=mid,
initial_gain_enc=0x00)
pre_sat = int(agc_delayed["sat_count"][:mid].sum())
post_sat = int(agc_delayed["sat_count"][mid:].sum())
print(f" Pre-AGC overflow: {pre_sat} "
f"Post-AGC overflow: {post_sat}")
# Plot
fig, axes = plt.subplots(3, 5, figsize=(28, 14))
fig.suptitle(f"AERIS-10 AGC Analysis — {label}\n"
f"({n_frames} frames, {data.shape[1]} chirps, "
f"{data.shape[2]} samples/chirp, "
f"raw ADC sat={100*raw_sat/(2*data.size):.2f}%)",
fontsize=13, fontweight="bold", y=0.99)
plot_scenario(axes[0], data, agc_off, "AGC OFF (pass-through)")
plot_scenario(axes[1], data, agc_on, "AGC ON (from start)")
plot_scenario(axes[2], data, agc_delayed,
f"AGC delayed (ON at frame {mid})", enable_frame=mid)
for ax, lbl in zip(axes[:, 0],
["AGC OFF", "AGC ON", "AGC DELAYED"],
strict=True):
ax.annotate(lbl, xy=(-0.35, 0.5), xycoords="axes fraction",
fontsize=13, fontweight="bold", color="white",
ha="center", va="center", rotation=90)
plt.tight_layout(rect=[0.03, 0, 1, 0.95])
return fig
def main():
parser = argparse.ArgumentParser(
description="AGC analysis for ADI raw IQ captures "
"(bit-accurate rx_gain_control.v simulation)")
parser.add_argument("--amp", type=str,
default=str(Path.home() / "Downloads/adi_radar_data"
"/amp_radar"
"/phaser_amp_4MSPS_500M_300u_256_m3dB.npy"),
help="Path to amplified radar .npy")
parser.add_argument("--noamp", type=str,
default=str(Path.home() / "Downloads/adi_radar_data"
"/no_amp_radar"
"/phaser_NOamp_4MSPS_500M_300u_256.npy"),
help="Path to non-amplified radar .npy")
parser.add_argument("--data", type=str, default=None,
help="Single dataset mode")
parser.add_argument("--label", type=str, default="Custom Data")
args = parser.parse_args()
plt.style.use("dark_background")
if args.data:
data = np.load(args.data)
analyze_dataset(data, args.label)
plt.show()
return
figs = []
for path, label in [(args.amp, "With Amplifier (-3 dB)"),
(args.noamp, "No Amplifier")]:
if not Path(path).exists():
print(f"WARNING: {path} not found, skipping")
continue
data = np.load(path)
fig = analyze_dataset(data, label)
figs.append(fig)
if not figs:
print("No data found. Use --amp/--noamp or --data.")
sys.exit(1)
plt.show()
if __name__ == "__main__":
main()
+456 -104
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,24 @@ 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
# Thread-safe UI message queue — avoids calling root.after() from
# background threads which crashes Python 3.12 (GIL state corruption).
# Entries are (tag, payload) tuples drained by _schedule_update().
self._ui_queue: queue.Queue[tuple[str, object]] = queue.Queue()
# Display state # Display state
self._current_frame = RadarFrame() self._current_frame = RadarFrame()
@@ -109,6 +116,16 @@ class RadarDashboard:
self._vmax_ema = 1000.0 self._vmax_ema = 1000.0
self._vmax_alpha = 0.15 # smoothing factor (lower = more stable) self._vmax_alpha = 0.15 # smoothing factor (lower = more stable)
# AGC visualization history (ring buffers, ~60s at 10 Hz)
self._agc_history_len = 256
self._agc_gain_history: deque[int] = deque(maxlen=self._agc_history_len)
self._agc_peak_history: deque[int] = deque(maxlen=self._agc_history_len)
self._agc_sat_history: deque[int] = deque(maxlen=self._agc_history_len)
self._agc_time_history: deque[float] = deque(maxlen=self._agc_history_len)
self._agc_t0: float = time.time()
self._agc_last_redraw: float = 0.0 # throttle chart redraws
self._AGC_REDRAW_INTERVAL: float = 0.5 # seconds between redraws
self._build_ui() self._build_ui()
self._schedule_update() self._schedule_update()
@@ -154,28 +171,30 @@ 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)
tab_display = ttk.Frame(nb) tab_display = ttk.Frame(nb)
tab_control = ttk.Frame(nb) tab_control = ttk.Frame(nb)
tab_agc = ttk.Frame(nb)
tab_log = ttk.Frame(nb) tab_log = ttk.Frame(nb)
nb.add(tab_display, text=" Display ") nb.add(tab_display, text=" Display ")
nb.add(tab_control, text=" Control ") nb.add(tab_control, text=" Control ")
nb.add(tab_agc, text=" AGC Monitor ")
nb.add(tab_log, text=" Log ") nb.add(tab_log, text=" Log ")
self._build_display_tab(tab_display) self._build_display_tab(tab_display)
self._build_control_tab(tab_control) self._build_control_tab(tab_control)
self._build_agc_tab(tab_agc)
self._build_log_tab(tab_log) self._build_log_tab(tab_log)
def _build_display_tab(self, parent): def _build_display_tab(self, parent):
# 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 +251,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,66 +352,238 @@ 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))
# ── AGC (Automatic Gain Control) ──────────────────────────────
grp_agc = ttk.LabelFrame(right, text="AGC (Auto Gain)", padding=10)
grp_agc.pack(fill="x", pady=(0, 8))
agc_params = [
("AGC Enable", 0x28, "0", 1, "0=manual, 1=auto"),
("AGC Target", 0x29, "200", 8, "0-255, peak target"),
("AGC Attack", 0x2A, "1", 4, "0-15, atten step"),
("AGC Decay", 0x2B, "1", 4, "0-15, gain-up step"),
("AGC Holdoff", 0x2C, "4", 4, "0-15, frames"),
]
for label, opcode, default, bits, hint in agc_params:
self._add_param_row(grp_agc, label, opcode, default, bits, hint)
# AGC quick toggle
agc_row = ttk.Frame(grp_agc)
agc_row.pack(fill="x", pady=2)
ttk.Button(agc_row, text="Enable AGC",
command=lambda: self._send_cmd(0x28, 1)).pack(
side="left", expand=True, fill="x", padx=(0, 2))
ttk.Button(agc_row, text="Disable AGC",
command=lambda: self._send_cmd(0x28, 0)).pack(
side="left", expand=True, fill="x", padx=(2, 0))
# AGC status readback labels
agc_st = ttk.LabelFrame(grp_agc, text="AGC Status", padding=6)
agc_st.pack(fill="x", pady=(4, 0))
self._agc_labels = {}
for name, default_text in [
("enable", "AGC: --"),
("gain", "Gain: --"),
("peak", "Peak: --"),
("sat", "Sat Count: --"),
]:
lbl = ttk.Label(agc_st, text=default_text, font=("Menlo", 9))
lbl.pack(anchor="w")
self._agc_labels[name] = lbl
# ── 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())
except ValueError:
log.error(f"Invalid value for opcode 0x{opcode:02X}: {var.get()!r}")
return
max_val = (1 << bits) - 1
clamped = max(0, min(raw, max_val))
if clamped != raw:
log.warning(f"Value {raw} clamped to {clamped} "
f"({bits}-bit max={max_val}) for opcode 0x{opcode:02X}")
var.set(str(clamped))
self._send_cmd(opcode, clamped)
ttk.Label(right, text="Custom Opcode (hex)").grid( def _build_agc_tab(self, parent):
row=len(params) + 1, column=0, sticky="w") """AGC Monitor tab — real-time strip charts for gain, peak, and saturation."""
self._custom_op = tk.StringVar(value="01") # Top row: AGC status badge + saturation indicator
ttk.Entry(right, textvariable=self._custom_op, width=10).grid( top = ttk.Frame(parent)
row=len(params) + 1, column=1, padx=8) top.pack(fill="x", padx=8, pady=(8, 0))
ttk.Label(right, text="Value (dec)").grid( self._agc_badge = ttk.Label(
row=len(params) + 2, column=0, sticky="w") top, text="AGC: --", font=("Menlo", 14, "bold"), foreground=FG)
self._custom_val = tk.StringVar(value="0") self._agc_badge.pack(side="left", padx=(0, 24))
ttk.Entry(right, textvariable=self._custom_val, width=10).grid(
row=len(params) + 2, column=1, padx=8)
ttk.Button(right, text="Send Custom", self._agc_sat_badge = ttk.Label(
command=self._send_custom).grid( top, text="Saturation: 0", font=("Menlo", 12), foreground=GREEN)
row=len(params) + 2, column=2, pady=2) self._agc_sat_badge.pack(side="left", padx=(0, 24))
outer.columnconfigure(0, weight=1) self._agc_gain_value = ttk.Label(
outer.columnconfigure(1, weight=2) top, text="Gain: --", font=("Menlo", 12), foreground=ACCENT)
outer.rowconfigure(0, weight=1) self._agc_gain_value.pack(side="left", padx=(0, 24))
self._agc_peak_value = ttk.Label(
top, text="Peak: --", font=("Menlo", 12), foreground=ACCENT)
self._agc_peak_value.pack(side="left")
# Matplotlib figure with 3 stacked subplots sharing x-axis (time)
self._agc_fig = Figure(figsize=(14, 7), facecolor=BG)
self._agc_fig.subplots_adjust(
left=0.07, right=0.98, top=0.95, bottom=0.08,
hspace=0.30)
# Subplot 1: FPGA inner-loop gain (4-bit, 0-15)
self._ax_gain = self._agc_fig.add_subplot(3, 1, 1)
self._ax_gain.set_facecolor(BG2)
self._ax_gain.set_title("FPGA AGC Gain (inner loop)", color=FG, fontsize=10)
self._ax_gain.set_ylabel("Gain Level", color=FG)
self._ax_gain.set_ylim(-0.5, 15.5)
self._ax_gain.tick_params(colors=FG)
self._ax_gain.set_xlim(0, self._agc_history_len)
self._gain_line, = self._ax_gain.plot(
[], [], color=ACCENT, linewidth=1.5, label="Gain")
self._ax_gain.axhline(y=0, color=RED, linewidth=0.5, alpha=0.5, linestyle="--")
self._ax_gain.axhline(y=15, color=RED, linewidth=0.5, alpha=0.5, linestyle="--")
for spine in self._ax_gain.spines.values():
spine.set_color(SURFACE)
# Subplot 2: Peak magnitude (8-bit, 0-255)
self._ax_peak = self._agc_fig.add_subplot(3, 1, 2)
self._ax_peak.set_facecolor(BG2)
self._ax_peak.set_title("Peak Magnitude", color=FG, fontsize=10)
self._ax_peak.set_ylabel("Peak (8-bit)", color=FG)
self._ax_peak.set_ylim(-5, 260)
self._ax_peak.tick_params(colors=FG)
self._ax_peak.set_xlim(0, self._agc_history_len)
self._peak_line, = self._ax_peak.plot(
[], [], color=YELLOW, linewidth=1.5, label="Peak")
# AGC target reference line (default 200)
self._agc_target_line = self._ax_peak.axhline(
y=200, color=GREEN, linewidth=1.0, alpha=0.7, linestyle="--",
label="Target (200)")
self._ax_peak.legend(loc="upper right", fontsize=8,
facecolor=BG2, edgecolor=SURFACE,
labelcolor=FG)
for spine in self._ax_peak.spines.values():
spine.set_color(SURFACE)
# Subplot 3: Saturation count (8-bit, 0-255) as bar-style fill
self._ax_sat = self._agc_fig.add_subplot(3, 1, 3)
self._ax_sat.set_facecolor(BG2)
self._ax_sat.set_title("Saturation Count", color=FG, fontsize=10)
self._ax_sat.set_ylabel("Sat Count", color=FG)
self._ax_sat.set_xlabel("Sample Index", color=FG)
self._ax_sat.set_ylim(-1, 40)
self._ax_sat.tick_params(colors=FG)
self._ax_sat.set_xlim(0, self._agc_history_len)
self._sat_fill = self._ax_sat.fill_between(
[], [], color=RED, alpha=0.6, label="Saturation")
self._sat_line, = self._ax_sat.plot(
[], [], color=RED, linewidth=1.0)
self._ax_sat.axhline(y=0, color=GREEN, linewidth=0.5, alpha=0.5, linestyle="--")
for spine in self._ax_sat.spines.values():
spine.set_color(SURFACE)
agc_canvas = FigureCanvasTkAgg(self._agc_fig, master=parent)
agc_canvas.draw()
agc_canvas.get_tk_widget().pack(fill="both", expand=True)
self._agc_canvas = agc_canvas
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),
insertbackground=FG, wrap="word") insertbackground=FG, wrap="word")
self.log_text.pack(fill="both", expand=True, padx=8, pady=8) self.log_text.pack(fill="both", expand=True, padx=8, pady=8)
# Redirect log handler to text widget # Redirect log handler to text widget (via UI queue for thread safety)
handler = _TextHandler(self.log_text) handler = _TextHandler(self._ui_queue)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s", handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S")) datefmt="%H:%M:%S"))
logging.getLogger().addHandler(handler) logging.getLogger().addHandler(handler)
@@ -364,9 +608,9 @@ 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 # Post result to UI queue (drained by _schedule_update)
self.root.after(0, lambda: self._on_connect_done(ok)) self._ui_queue.put(("connect", ok))
threading.Thread(target=_do_connect, daemon=True).start() threading.Thread(target=_do_connect, daemon=True).start()
@@ -414,11 +658,11 @@ class RadarDashboard:
log.error("Invalid custom command values") log.error("Invalid custom command values")
def _on_status_received(self, status: StatusResponse): def _on_status_received(self, status: StatusResponse):
"""Called from acquisition thread — schedule UI update on main thread.""" """Called from acquisition thread — post to UI queue for main thread."""
self.root.after(0, self._update_self_test_labels, status) self._ui_queue.put(("status", status))
def _update_self_test_labels(self, status: StatusResponse): def _update_self_test_labels(self, status: StatusResponse):
"""Update the self-test result labels from a StatusResponse.""" """Update the self-test result labels and AGC status from a StatusResponse."""
if not hasattr(self, '_st_labels'): if not hasattr(self, '_st_labels'):
return return
flags = status.self_test_flags flags = status.self_test_flags
@@ -453,11 +697,124 @@ class RadarDashboard:
self._st_labels[key].config( self._st_labels[key].config(
text=f"{name}: {result_str}", foreground=color) text=f"{name}: {result_str}", foreground=color)
# AGC status readback
if hasattr(self, '_agc_labels'):
agc_str = "AUTO" if status.agc_enable else "MANUAL"
agc_color = GREEN if status.agc_enable else FG
self._agc_labels["enable"].config(
text=f"AGC: {agc_str}", foreground=agc_color)
self._agc_labels["gain"].config(
text=f"Gain: {status.agc_current_gain}")
self._agc_labels["peak"].config(
text=f"Peak: {status.agc_peak_magnitude}")
sat_color = RED if status.agc_saturation_count > 0 else FG
self._agc_labels["sat"].config(
text=f"Sat Count: {status.agc_saturation_count}",
foreground=sat_color)
# AGC visualization update
self._update_agc_visualization(status)
def _update_agc_visualization(self, status: StatusResponse):
"""Push AGC metrics into ring buffers and redraw strip charts.
Data is always accumulated (cheap), but matplotlib redraws are
throttled to ``_AGC_REDRAW_INTERVAL`` seconds to avoid saturating
the GUI event-loop when status packets arrive at 20 Hz.
"""
if not hasattr(self, '_agc_canvas'):
return
# Append to ring buffers (always — this is O(1))
self._agc_gain_history.append(status.agc_current_gain)
self._agc_peak_history.append(status.agc_peak_magnitude)
self._agc_sat_history.append(status.agc_saturation_count)
# Update indicator labels (cheap Tk config calls)
mode_str = "AUTO" if status.agc_enable else "MANUAL"
mode_color = GREEN if status.agc_enable else FG
self._agc_badge.config(text=f"AGC: {mode_str}", foreground=mode_color)
self._agc_gain_value.config(
text=f"Gain: {status.agc_current_gain}")
self._agc_peak_value.config(
text=f"Peak: {status.agc_peak_magnitude}")
total_sat = sum(self._agc_sat_history)
if total_sat > 10:
sat_color = RED
elif total_sat > 0:
sat_color = YELLOW
else:
sat_color = GREEN
self._agc_sat_badge.config(
text=f"Saturation: {total_sat}", foreground=sat_color)
# ---- Throttle matplotlib redraws ---------------------------------
now = time.monotonic()
if now - self._agc_last_redraw < self._AGC_REDRAW_INTERVAL:
return
self._agc_last_redraw = now
n = len(self._agc_gain_history)
xs = list(range(n))
# Update line plots
gain_data = list(self._agc_gain_history)
peak_data = list(self._agc_peak_history)
sat_data = list(self._agc_sat_history)
self._gain_line.set_data(xs, gain_data)
self._peak_line.set_data(xs, peak_data)
# Saturation: redraw as filled area
self._sat_line.set_data(xs, sat_data)
if self._sat_fill is not None:
self._sat_fill.remove()
self._sat_fill = self._ax_sat.fill_between(
xs, sat_data, color=RED, alpha=0.4)
# Auto-scale saturation Y axis to data
max_sat = max(sat_data) if sat_data else 0
self._ax_sat.set_ylim(-1, max(max_sat * 1.5, 5))
# Scroll X axis to keep latest data visible
if n >= self._agc_history_len:
self._ax_gain.set_xlim(0, n)
self._ax_peak.set_xlim(0, n)
self._ax_sat.set_xlim(0, n)
self._agc_canvas.draw_idle()
# --------------------------------------------------------- Display loop # --------------------------------------------------------- Display loop
def _schedule_update(self): def _schedule_update(self):
self._drain_ui_queue()
self._update_display() self._update_display()
self.root.after(self.UPDATE_INTERVAL_MS, self._schedule_update) self.root.after(self.UPDATE_INTERVAL_MS, self._schedule_update)
def _drain_ui_queue(self):
"""Process all pending cross-thread messages on the main thread."""
while True:
try:
tag, payload = self._ui_queue.get_nowait()
except queue.Empty:
break
if tag == "connect":
self._on_connect_done(payload)
elif tag == "status":
self._update_self_test_labels(payload)
elif tag == "log":
self._log_handler_append(payload)
def _log_handler_append(self, msg: str):
"""Append a log message to the log Text widget (main thread only)."""
with contextlib.suppress(Exception):
self.log_text.insert("end", msg + "\n")
self.log_text.see("end")
# Keep last 500 lines
lines = int(self.log_text.index("end-1c").split(".")[0])
if lines > 500:
self.log_text.delete("1.0", f"{lines - 500}.0")
def _update_display(self): def _update_display(self):
"""Pull latest frame from queue and update plots.""" """Pull latest frame from queue and update plots."""
frame = None frame = None
@@ -522,26 +879,21 @@ class RadarDashboard:
class _TextHandler(logging.Handler): class _TextHandler(logging.Handler):
"""Logging handler that writes to a tkinter Text widget.""" """Logging handler that posts messages to a queue for main-thread append.
def __init__(self, text_widget: tk.Text): Using widget.after() from background threads crashes Python 3.12 due to
GIL state corruption. Instead we post to the dashboard's _ui_queue and
let _drain_ui_queue() append on the main thread.
"""
def __init__(self, ui_queue: queue.Queue[tuple[str, object]]):
super().__init__() super().__init__()
self._text = text_widget self._ui_queue = ui_queue
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._ui_queue.put(("log", msg))
except Exception:
pass
def _append(self, msg: str):
self._text.insert("end", msg + "\n")
self._text.see("end")
# Keep last 500 lines
lines = int(self._text.index("end-1c").split(".")[0])
if lines > 500:
self._text.delete("1.0", f"{lines - 500}.0")
# ============================================================================ # ============================================================================
@@ -578,7 +930,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(
+142 -90
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 0x28-0x2C AGC control
0x12 host_guard_cycles 0x30 host_self_test_trigger
0x13 host_short_chirp_cycles 0x31/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,15 @@ class Opcode(IntEnum):
CFAR_ENABLE = 0x25 CFAR_ENABLE = 0x25
MTI_ENABLE = 0x26 MTI_ENABLE = 0x26
DC_NOTCH_WIDTH = 0x27 DC_NOTCH_WIDTH = 0x27
# --- AGC (0x28-0x2C) ---
AGC_ENABLE = 0x28
AGC_TARGET = 0x29
AGC_ATTACK = 0x2A
AGC_DECAY = 0x2B
AGC_HOLDOFF = 0x2C
# --- 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 +109,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 +127,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
@@ -116,6 +142,11 @@ class StatusResponse:
self_test_flags: int = 0 # 5-bit result flags [4:0] self_test_flags: int = 0 # 5-bit result flags [4:0]
self_test_detail: int = 0 # 8-bit detail code [7:0] self_test_detail: int = 0 # 8-bit detail code [7:0]
self_test_busy: int = 0 # 1-bit busy flag self_test_busy: int = 0 # 1-bit busy flag
# AGC metrics (word 4, added for hybrid AGC)
agc_current_gain: int = 0 # 4-bit current gain encoding [3:0]
agc_peak_magnitude: int = 0 # 8-bit peak magnitude [7:0]
agc_saturation_count: int = 0 # 8-bit saturation count [7:0]
agc_enable: int = 0 # 1-bit AGC enable readback
# ============================================================================ # ============================================================================
@@ -144,7 +175,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 +212,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
@@ -200,10 +231,10 @@ class RadarProtocol:
return None return None
sr = StatusResponse() sr = StatusResponse()
# Word 0: {0xFF, 3'b0, mode[1:0], 5'b0, stream[2:0], threshold[15:0]} # Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
sr.cfar_threshold = words[0] & 0xFFFF sr.cfar_threshold = words[0] & 0xFFFF
sr.stream_ctrl = (words[0] >> 16) & 0x07 sr.stream_ctrl = (words[0] >> 19) & 0x07
sr.radar_mode = (words[0] >> 21) & 0x03 sr.radar_mode = (words[0] >> 22) & 0x03
# Word 1: {long_chirp[31:16], long_listen[15:0]} # Word 1: {long_chirp[31:16], long_listen[15:0]}
sr.long_listen = words[1] & 0xFFFF sr.long_listen = words[1] & 0xFFFF
sr.long_chirp = (words[1] >> 16) & 0xFFFF sr.long_chirp = (words[1] >> 16) & 0xFFFF
@@ -213,8 +244,13 @@ class RadarProtocol:
# Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]} # Word 3: {short_listen[31:16], 10'd0, chirps_per_elev[5:0]}
sr.chirps_per_elev = words[3] & 0x3F sr.chirps_per_elev = words[3] & 0x3F
sr.short_listen = (words[3] >> 16) & 0xFFFF sr.short_listen = (words[3] >> 16) & 0xFFFF
# Word 4: {30'd0, range_mode[1:0]} # Word 4: {agc_current_gain[31:28], agc_peak_magnitude[27:20],
# agc_saturation_count[19:12], agc_enable[11], 9'd0, range_mode[1:0]}
sr.range_mode = words[4] & 0x03 sr.range_mode = words[4] & 0x03
sr.agc_enable = (words[4] >> 11) & 0x01
sr.agc_saturation_count = (words[4] >> 12) & 0xFF
sr.agc_peak_magnitude = (words[4] >> 20) & 0xFF
sr.agc_current_gain = (words[4] >> 28) & 0x0F
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0], # Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
# 3'd0, self_test_flags[4:0]} # 3'd0, self_test_flags[4:0]}
sr.self_test_flags = words[5] & 0x1F sr.self_test_flags = words[5] & 0x1F
@@ -223,7 +259,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 +269,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 +296,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 +349,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 +372,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 +389,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 +439,7 @@ class FT2232HConnection:
buf += pkt buf += pkt
self._mock_seq_idx = (start_idx + num_packets) % NUM_CELLS
return bytes(buf) return bytes(buf)
@@ -401,20 +448,25 @@ 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) is NOT hardware-only — it's in _REPLAY_ADJUSTABLE_OPCODES
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
0x28, # AGC_ENABLE
0x29, # AGC_TARGET
0x2A, # AGC_ATTACK
0x2B, # AGC_DECAY
0x2C, # AGC_HOLDOFF
0x30, # SELF_TEST_TRIGGER 0x30, # SELF_TEST_TRIGGER
0x31, # SELF_TEST_STATUS 0x31, # SELF_TEST_STATUS
0xFF, # STATUS_REQUEST 0xFF, # STATUS_REQUEST
@@ -422,6 +474,7 @@ _HARDWARE_ONLY_OPCODES = {
# Replay-adjustable opcodes (re-run signal processing) # Replay-adjustable opcodes (re-run signal processing)
_REPLAY_ADJUSTABLE_OPCODES = { _REPLAY_ADJUSTABLE_OPCODES = {
0x03, # DETECT_THRESHOLD
0x21, # CFAR_GUARD 0x21, # CFAR_GUARD
0x22, # CFAR_TRAIN 0x22, # CFAR_TRAIN
0x23, # CFAR_ALPHA 0x23, # CFAR_ALPHA
@@ -439,26 +492,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 +515,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).
@@ -583,17 +618,18 @@ class ReplayConnection:
self._cfar_alpha: int = 0x30 self._cfar_alpha: int = 0x30
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
self._detect_threshold: int = 10000 # RTL default (host_detect_threshold)
# 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 +640,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, IndexError, 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)
@@ -647,7 +683,11 @@ class ReplayConnection:
if opcode in _REPLAY_ADJUSTABLE_OPCODES: if opcode in _REPLAY_ADJUSTABLE_OPCODES:
changed = False changed = False
with self._lock: with self._lock:
if opcode == 0x21: # CFAR_GUARD if opcode == 0x03: # DETECT_THRESHOLD
if self._detect_threshold != value:
self._detect_threshold = value
changed = True
elif opcode == 0x21: # CFAR_GUARD
if self._cfar_guard != value: if self._cfar_guard != value:
self._cfar_guard = value self._cfar_guard = value
changed = True changed = True
@@ -673,8 +713,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:
@@ -740,7 +779,10 @@ class ReplayConnection:
mode=self._cfar_mode, mode=self._cfar_mode,
) )
else: else:
det = np.zeros((NUM_RANGE_BINS, NUM_DOPPLER_BINS), dtype=bool) # Simple threshold fallback matching RTL cfar_ca.v:
# detect = (|I| + |Q|) > detect_threshold (L1 norm)
mag = np.abs(dop_i) + np.abs(dop_q)
det = mag > self._detect_threshold
det_count = int(det.sum()) det_count = int(det.sum())
log.info(f"Replay: rebuilt {NUM_CELLS} packets (" log.info(f"Replay: rebuilt {NUM_CELLS} packets ("
@@ -827,7 +869,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 +886,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 +895,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 +913,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 +930,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 +967,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 +1002,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:
+243 -25
View File
@@ -125,13 +125,14 @@ class TestRadarProtocol(unittest.TestCase):
long_chirp=3000, long_listen=13700, long_chirp=3000, long_listen=13700,
guard=17540, short_chirp=50, guard=17540, short_chirp=50,
short_listen=17450, chirps=32, range_mode=0, short_listen=17450, chirps=32, range_mode=0,
st_flags=0, st_detail=0, st_busy=0): st_flags=0, st_detail=0, st_busy=0,
agc_gain=0, agc_peak=0, agc_sat=0, agc_enable=0):
"""Build a 26-byte status response matching FPGA format (Build 26).""" """Build a 26-byte status response matching FPGA format (Build 26)."""
pkt = bytearray() pkt = bytearray()
pkt.append(STATUS_HEADER_BYTE) pkt.append(STATUS_HEADER_BYTE)
# Word 0: {0xFF, 3'b0, mode[1:0], 5'b0, stream[2:0], threshold[15:0]} # Word 0: {0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
w0 = (0xFF << 24) | ((mode & 0x03) << 21) | ((stream & 0x07) << 16) | (threshold & 0xFFFF) w0 = (0xFF << 24) | ((mode & 0x03) << 22) | ((stream & 0x07) << 19) | (threshold & 0xFFFF)
pkt += struct.pack(">I", w0) pkt += struct.pack(">I", w0)
# Word 1: {long_chirp, long_listen} # Word 1: {long_chirp, long_listen}
@@ -146,8 +147,11 @@ class TestRadarProtocol(unittest.TestCase):
w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F) w3 = ((short_listen & 0xFFFF) << 16) | (chirps & 0x3F)
pkt += struct.pack(">I", w3) pkt += struct.pack(">I", w3)
# Word 4: {30'd0, range_mode[1:0]} # Word 4: {agc_current_gain[3:0], agc_peak_magnitude[7:0],
w4 = range_mode & 0x03 # agc_saturation_count[7:0], agc_enable, 9'd0, range_mode[1:0]}
w4 = (((agc_gain & 0x0F) << 28) | ((agc_peak & 0xFF) << 20) |
((agc_sat & 0xFF) << 12) | ((agc_enable & 0x01) << 11) |
(range_mode & 0x03))
pkt += struct.pack(">I", w4) pkt += struct.pack(">I", w4)
# Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0], # Word 5: {7'd0, self_test_busy, 8'd0, self_test_detail[7:0],
@@ -368,7 +372,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 +425,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 +634,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 +672,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 +695,41 @@ 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,
0x28, 0x29, 0x2A, 0x2B, 0x2C,
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")
@@ -728,5 +752,199 @@ class TestStatusResponseDefaults(unittest.TestCase):
self.assertEqual(sr.self_test_busy, 1) self.assertEqual(sr.self_test_busy, 1)
class TestAGCOpcodes(unittest.TestCase):
"""Verify AGC opcode enum members match FPGA RTL (0x28-0x2C)."""
def test_agc_enable_opcode(self):
self.assertEqual(Opcode.AGC_ENABLE, 0x28)
def test_agc_target_opcode(self):
self.assertEqual(Opcode.AGC_TARGET, 0x29)
def test_agc_attack_opcode(self):
self.assertEqual(Opcode.AGC_ATTACK, 0x2A)
def test_agc_decay_opcode(self):
self.assertEqual(Opcode.AGC_DECAY, 0x2B)
def test_agc_holdoff_opcode(self):
self.assertEqual(Opcode.AGC_HOLDOFF, 0x2C)
class TestAGCStatusParsing(unittest.TestCase):
"""Verify AGC fields in status_words[4] are parsed correctly."""
def _make_status_packet(self, **kwargs):
"""Delegate to TestRadarProtocol helper."""
helper = TestRadarProtocol()
return helper._make_status_packet(**kwargs)
def test_agc_fields_default_zero(self):
"""With no AGC fields set, all should be 0."""
raw = self._make_status_packet()
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 0)
self.assertEqual(sr.agc_peak_magnitude, 0)
self.assertEqual(sr.agc_saturation_count, 0)
self.assertEqual(sr.agc_enable, 0)
def test_agc_fields_nonzero(self):
"""AGC fields round-trip through status packet."""
raw = self._make_status_packet(agc_gain=7, agc_peak=200,
agc_sat=15, agc_enable=1)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 7)
self.assertEqual(sr.agc_peak_magnitude, 200)
self.assertEqual(sr.agc_saturation_count, 15)
self.assertEqual(sr.agc_enable, 1)
def test_agc_max_values(self):
"""AGC fields at max values."""
raw = self._make_status_packet(agc_gain=15, agc_peak=255,
agc_sat=255, agc_enable=1)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 15)
self.assertEqual(sr.agc_peak_magnitude, 255)
self.assertEqual(sr.agc_saturation_count, 255)
self.assertEqual(sr.agc_enable, 1)
def test_agc_and_range_mode_coexist(self):
"""AGC fields and range_mode occupy the same word without conflict."""
raw = self._make_status_packet(agc_gain=5, agc_peak=128,
agc_sat=42, agc_enable=1,
range_mode=2)
sr = RadarProtocol.parse_status_packet(raw)
self.assertEqual(sr.agc_current_gain, 5)
self.assertEqual(sr.agc_peak_magnitude, 128)
self.assertEqual(sr.agc_saturation_count, 42)
self.assertEqual(sr.agc_enable, 1)
self.assertEqual(sr.range_mode, 2)
class TestAGCStatusResponseDefaults(unittest.TestCase):
"""Verify StatusResponse AGC field defaults."""
def test_default_agc_fields(self):
sr = StatusResponse()
self.assertEqual(sr.agc_current_gain, 0)
self.assertEqual(sr.agc_peak_magnitude, 0)
self.assertEqual(sr.agc_saturation_count, 0)
self.assertEqual(sr.agc_enable, 0)
def test_agc_fields_set(self):
sr = StatusResponse(agc_current_gain=7, agc_peak_magnitude=200,
agc_saturation_count=15, agc_enable=1)
self.assertEqual(sr.agc_current_gain, 7)
self.assertEqual(sr.agc_peak_magnitude, 200)
self.assertEqual(sr.agc_saturation_count, 15)
self.assertEqual(sr.agc_enable, 1)
# =============================================================================
# AGC Visualization — ring buffer / data model tests
# =============================================================================
class TestAGCVisualizationHistory(unittest.TestCase):
"""Test the AGC visualization ring buffer logic (no GUI required)."""
def _make_deque(self, maxlen=256):
from collections import deque
return deque(maxlen=maxlen)
def test_ring_buffer_maxlen(self):
"""Ring buffer should evict oldest when full."""
d = self._make_deque(maxlen=4)
for i in range(6):
d.append(i)
self.assertEqual(list(d), [2, 3, 4, 5])
self.assertEqual(len(d), 4)
def test_gain_history_accumulation(self):
"""Gain values accumulate correctly in a deque."""
gain_hist = self._make_deque(maxlen=256)
statuses = [
StatusResponse(agc_current_gain=g)
for g in [0, 3, 7, 15, 8, 2]
]
for st in statuses:
gain_hist.append(st.agc_current_gain)
self.assertEqual(list(gain_hist), [0, 3, 7, 15, 8, 2])
def test_peak_history_accumulation(self):
"""Peak magnitude values accumulate correctly."""
peak_hist = self._make_deque(maxlen=256)
for p in [0, 50, 200, 255, 128]:
peak_hist.append(p)
self.assertEqual(list(peak_hist), [0, 50, 200, 255, 128])
def test_saturation_total_computation(self):
"""Sum of saturation ring buffer gives running total."""
sat_hist = self._make_deque(maxlen=256)
for s in [0, 0, 5, 0, 12, 3]:
sat_hist.append(s)
self.assertEqual(sum(sat_hist), 20)
def test_saturation_color_thresholds(self):
"""Color logic: green=0, yellow=1-10, red>10."""
def sat_color(total):
if total > 10:
return "red"
if total > 0:
return "yellow"
return "green"
self.assertEqual(sat_color(0), "green")
self.assertEqual(sat_color(1), "yellow")
self.assertEqual(sat_color(10), "yellow")
self.assertEqual(sat_color(11), "red")
self.assertEqual(sat_color(255), "red")
def test_ring_buffer_eviction_preserves_latest(self):
"""After overflow, only the most recent values remain."""
d = self._make_deque(maxlen=8)
for i in range(20):
d.append(i)
self.assertEqual(list(d), [12, 13, 14, 15, 16, 17, 18, 19])
def test_empty_history_safe(self):
"""Empty ring buffer should be safe for max/sum."""
d = self._make_deque(maxlen=256)
self.assertEqual(sum(d), 0)
self.assertEqual(len(d), 0)
# max() on empty would raise — test the guard pattern used in viz code
max_sat = max(d) if d else 0
self.assertEqual(max_sat, 0)
def test_agc_mode_string(self):
"""AGC mode display string from enable flag."""
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=1).agc_enable else "MANUAL",
"AUTO")
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=0).agc_enable else "MANUAL",
"MANUAL")
def test_xlim_scroll_logic(self):
"""X-axis scroll: when n >= history_len, xlim should expand."""
history_len = 8
d = self._make_deque(maxlen=history_len)
for i in range(10):
d.append(i)
n = len(d)
# After 10 pushes into maxlen=8, n=8
self.assertEqual(n, history_len)
# xlim should be (0, n) for static or (n-history_len, n) for scrolling
self.assertEqual(max(0, n - history_len), 0)
self.assertEqual(n, 8)
def test_sat_autoscale_ylim(self):
"""Saturation y-axis auto-scale: max(max_sat * 1.5, 5)."""
# No saturation
self.assertEqual(max(0 * 1.5, 5), 5)
# Some saturation
self.assertAlmostEqual(max(10 * 1.5, 5), 15.0)
# High saturation
self.assertAlmostEqual(max(200 * 1.5, 5), 300.0)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)
+427
View File
@@ -0,0 +1,427 @@
"""
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
# =============================================================================
def _pyqt6_available():
try:
import PyQt6.QtCore # noqa: F401
return True
except ImportError:
return False
@unittest.skipUnless(_pyqt6_available(), "PyQt6 not installed")
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
# Core exports (no PyQt6 required)
for name in ["RadarTarget", "RadarSettings", "GPSData",
"ProcessingConfig", "FT2232HConnection",
"RadarProtocol", "RadarProcessor"]:
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
# PyQt6-dependent exports — only present when PyQt6 is installed
if _pyqt6_available():
for name in ["RadarDataWorker", "RadarMapWidget",
"RadarDashboard"]:
self.assertTrue(hasattr(v7, name), f"v7 missing export: {name}")
# =============================================================================
# Test: AGC Visualization data model
# =============================================================================
class TestAGCVisualizationV7(unittest.TestCase):
"""AGC visualization ring buffer and data model tests (no Qt required)."""
def _make_deque(self, maxlen=256):
from collections import deque
return deque(maxlen=maxlen)
def test_ring_buffer_basics(self):
d = self._make_deque(maxlen=4)
for i in range(6):
d.append(i)
self.assertEqual(list(d), [2, 3, 4, 5])
def test_gain_range_4bit(self):
"""AGC gain is 4-bit (0-15)."""
from radar_protocol import StatusResponse
for g in [0, 7, 15]:
sr = StatusResponse(agc_current_gain=g)
self.assertEqual(sr.agc_current_gain, g)
def test_peak_range_8bit(self):
"""Peak magnitude is 8-bit (0-255)."""
from radar_protocol import StatusResponse
for p in [0, 128, 255]:
sr = StatusResponse(agc_peak_magnitude=p)
self.assertEqual(sr.agc_peak_magnitude, p)
def test_saturation_accumulation(self):
"""Saturation ring buffer sum tracks total events."""
sat = self._make_deque(maxlen=256)
for s in [0, 5, 0, 10, 3]:
sat.append(s)
self.assertEqual(sum(sat), 18)
def test_mode_label_logic(self):
"""AGC mode string from enable field."""
from radar_protocol import StatusResponse
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=1).agc_enable else "MANUAL",
"AUTO")
self.assertEqual(
"AUTO" if StatusResponse(agc_enable=0).agc_enable else "MANUAL",
"MANUAL")
def test_history_len_default(self):
"""Default history length should be 256."""
d = self._make_deque(maxlen=256)
self.assertEqual(d.maxlen, 256)
def test_color_thresholds(self):
"""Saturation color: green=0, warning=1-10, error>10."""
from v7.models import DARK_SUCCESS, DARK_WARNING, DARK_ERROR
def pick_color(total):
if total > 10:
return DARK_ERROR
if total > 0:
return DARK_WARNING
return DARK_SUCCESS
self.assertEqual(pick_color(0), DARK_SUCCESS)
self.assertEqual(pick_color(5), DARK_WARNING)
self.assertEqual(pick_color(11), DARK_ERROR)
# =============================================================================
# Helper: lazy import of v7.models
# =============================================================================
def _models():
import v7.models
return v7.models
if __name__ == "__main__":
unittest.main()
+28 -18
View File
@@ -19,44 +19,52 @@ 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,
) )
# Workers and simulator # Workers, map widget, and dashboard require PyQt6 — import lazily so that
from .workers import ( # tests/CI environments without PyQt6 can still access models/hardware/processing.
try:
from .workers import (
RadarDataWorker, RadarDataWorker,
GPSDataWorker, GPSDataWorker,
TargetSimulator, TargetSimulator,
polar_to_geographic, polar_to_geographic,
) )
# Map widget from .map_widget import (
from .map_widget import (
MapBridge, MapBridge,
RadarMapWidget, RadarMapWidget,
) )
# Main dashboard from .dashboard import (
from .dashboard import (
RadarDashboard, RadarDashboard,
RangeDopplerCanvas, RangeDopplerCanvas,
) )
except ImportError: # PyQt6 not installed (e.g. CI headless runner)
pass
__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 +72,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
+12
View File
@@ -0,0 +1,12 @@
# Simulation outputs (generated by iverilog TB)
cmd_results.txt
data_packet.txt
status_packet.txt
*.vcd
*.vvp
# Compiled C stub
stm32_stub
# Python
__pycache__/
@@ -0,0 +1,795 @@
"""
Cross-layer contract parsers.
Extracts interface contracts (opcodes, bit widths, reset defaults, packet
layouts) directly from the source files of each layer:
- Python GUI: radar_protocol.py
- FPGA RTL: radar_system_top.v, usb_data_interface_ft2232h.v,
usb_data_interface.v
- STM32 MCU: RadarSettings.cpp, main.cpp
These parsers do NOT define the expected values they discover what each
layer actually implements, so the test can compare layers against ground
truth and find bugs where both sides are wrong (like the 0x06 phantom
opcode or the status_words[0] 37-bit truncation).
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
# ---------------------------------------------------------------------------
# Repository layout (relative to repo root)
# ---------------------------------------------------------------------------
REPO_ROOT = Path(__file__).resolve().parents[3]
GUI_DIR = REPO_ROOT / "9_Firmware" / "9_3_GUI"
FPGA_DIR = REPO_ROOT / "9_Firmware" / "9_2_FPGA"
MCU_DIR = REPO_ROOT / "9_Firmware" / "9_1_Microcontroller"
MCU_LIB_DIR = MCU_DIR / "9_1_1_C_Cpp_Libraries"
MCU_CODE_DIR = MCU_DIR / "9_1_3_C_Cpp_Code"
XDC_DIR = FPGA_DIR / "constraints"
# ===================================================================
# Data structures
# ===================================================================
@dataclass
class OpcodeEntry:
"""One opcode as declared in a single layer."""
name: str
value: int
register: str = "" # Verilog register name it writes to
bit_slice: str = "" # e.g. "[3:0]", "[15:0]", "[0]"
bit_width: int = 0 # derived from bit_slice
reset_default: int | None = None
is_pulse: bool = False # True for trigger/request opcodes
@dataclass
class StatusWordField:
"""One field inside a status_words[] entry."""
name: str
word_index: int
msb: int # bit position in the 32-bit word (0-indexed from LSB)
lsb: int
width: int
@dataclass
class DataPacketField:
"""One field in the 11-byte data packet."""
name: str
byte_start: int # first byte index (0 = header)
byte_end: int # last byte index (inclusive)
width_bits: int
@dataclass
class PacketConstants:
"""Header/footer/size constants for a packet type."""
header: int
footer: int
size: int
@dataclass
class SettingsField:
"""One field in the STM32 SET...END settings packet."""
name: str
offset: int # byte offset from start of payload (after "SET")
size: int # bytes
c_type: str # "double" or "uint32_t"
@dataclass
class GpioPin:
"""A GPIO pin with direction."""
name: str
pin_id: str # e.g. "PD8", "H11"
direction: str # "output" or "input"
layer: str # "stm32" or "fpga"
@dataclass
class ConcatWidth:
"""Result of counting bits in a Verilog concatenation."""
total_bits: int
target_bits: int # width of the register being assigned to
fragments: list[tuple[str, int]] = field(default_factory=list)
truncated: bool = False
# ===================================================================
# Python layer parser
# ===================================================================
def parse_python_opcodes(filepath: Path | None = None) -> dict[int, OpcodeEntry]:
"""Parse the Opcode enum from radar_protocol.py.
Returns {opcode_value: OpcodeEntry}.
"""
if filepath is None:
filepath = GUI_DIR / "radar_protocol.py"
text = filepath.read_text()
# Find the Opcode class body
match = re.search(r'class Opcode\b.*?(?=\nclass |\Z)', text, re.DOTALL)
if not match:
raise ValueError(f"Could not find 'class Opcode' in {filepath}")
opcodes: dict[int, OpcodeEntry] = {}
for m in re.finditer(r'(\w+)\s*=\s*(0x[0-9a-fA-F]+)', match.group()):
name = m.group(1)
value = int(m.group(2), 16)
opcodes[value] = OpcodeEntry(name=name, value=value)
return opcodes
def parse_python_packet_constants(filepath: Path | None = None) -> dict[str, PacketConstants]:
"""Extract HEADER_BYTE, FOOTER_BYTE, STATUS_HEADER_BYTE, packet sizes."""
if filepath is None:
filepath = GUI_DIR / "radar_protocol.py"
text = filepath.read_text()
def _find(pattern: str) -> int:
m = re.search(pattern, text)
if not m:
raise ValueError(f"Pattern not found: {pattern}")
val = m.group(1)
return int(val, 16) if val.startswith("0x") else int(val)
header = _find(r'HEADER_BYTE\s*=\s*(0x[0-9a-fA-F]+|\d+)')
footer = _find(r'FOOTER_BYTE\s*=\s*(0x[0-9a-fA-F]+|\d+)')
status_header = _find(r'STATUS_HEADER_BYTE\s*=\s*(0x[0-9a-fA-F]+|\d+)')
data_size = _find(r'DATA_PACKET_SIZE\s*=\s*(\d+)')
status_size = _find(r'STATUS_PACKET_SIZE\s*=\s*(\d+)')
return {
"data": PacketConstants(header=header, footer=footer, size=data_size),
"status": PacketConstants(header=status_header, footer=footer, size=status_size),
}
def parse_python_data_packet_fields(filepath: Path | None = None) -> list[DataPacketField]:
"""
Extract byte offsets from parse_data_packet() by finding struct.unpack_from calls.
Returns fields in byte order.
"""
if filepath is None:
filepath = GUI_DIR / "radar_protocol.py"
text = filepath.read_text()
# Find parse_data_packet method body
match = re.search(
r'def parse_data_packet\(.*?\).*?(?=\n @|\n def |\nclass |\Z)',
text, re.DOTALL
)
if not match:
raise ValueError("Could not find parse_data_packet()")
body = match.group()
fields: list[DataPacketField] = []
# Match patterns like: range_q = _to_signed16(struct.unpack_from(">H", raw, 1)[0])
for m in re.finditer(
r'(\w+)\s*=\s*_to_signed16\(struct\.unpack_from\("(>[HIBhib])", raw, (\d+)\)',
body
):
name = m.group(1)
fmt = m.group(2)
offset = int(m.group(3))
fmt_char = fmt[-1].upper()
size = {"H": 2, "I": 4, "B": 1}[fmt_char]
fields.append(DataPacketField(
name=name, byte_start=offset,
byte_end=offset + size - 1,
width_bits=size * 8
))
# Match detection = raw[9] & 0x01
for m in re.finditer(r'(\w+)\s*=\s*raw\[(\d+)\]\s*&\s*(0x[0-9a-fA-F]+|\d+)', body):
name = m.group(1)
offset = int(m.group(2))
fields.append(DataPacketField(
name=name, byte_start=offset, byte_end=offset, width_bits=1
))
fields.sort(key=lambda f: f.byte_start)
return fields
def parse_python_status_fields(filepath: Path | None = None) -> list[StatusWordField]:
"""
Extract bit shift/mask operations from parse_status_packet().
Returns the fields with word index and bit positions as Python sees them.
"""
if filepath is None:
filepath = GUI_DIR / "radar_protocol.py"
text = filepath.read_text()
match = re.search(
r'def parse_status_packet\(.*?\).*?(?=\n @|\n def |\nclass |\Z)',
text, re.DOTALL
)
if not match:
raise ValueError("Could not find parse_status_packet()")
body = match.group()
fields: list[StatusWordField] = []
# Pattern: sr.field = (words[N] >> S) & MASK # noqa: ERA001
for m in re.finditer(
r'sr\.(\w+)\s*=\s*\(words\[(\d+)\]\s*>>\s*(\d+)\)\s*&\s*(0x[0-9a-fA-F]+|\d+)',
body
):
name = m.group(1)
word_idx = int(m.group(2))
shift = int(m.group(3))
mask_str = m.group(4)
mask = int(mask_str, 16) if mask_str.startswith("0x") else int(mask_str)
width = mask.bit_length()
fields.append(StatusWordField(
name=name, word_index=word_idx,
msb=shift + width - 1, lsb=shift, width=width
))
# Pattern: sr.field = words[N] & MASK (no shift)
for m in re.finditer(
r'sr\.(\w+)\s*=\s*words\[(\d+)\]\s*&\s*(0x[0-9a-fA-F]+|\d+)',
body
):
name = m.group(1)
word_idx = int(m.group(2))
mask_str = m.group(3)
mask = int(mask_str, 16) if mask_str.startswith("0x") else int(mask_str)
width = mask.bit_length()
# Skip if already captured by the shift pattern
if not any(f.name == name and f.word_index == word_idx for f in fields):
fields.append(StatusWordField(
name=name, word_index=word_idx,
msb=width - 1, lsb=0, width=width
))
return fields
# ===================================================================
# Verilog layer parser
# ===================================================================
def _parse_bit_slice(s: str) -> int:
"""Parse '[15:0]' -> 16, '[0]' -> 1, '' -> 16 (full cmd_value)."""
m = re.match(r'\[(\d+):(\d+)\]', s)
if m:
return int(m.group(1)) - int(m.group(2)) + 1
m = re.match(r'\[(\d+)\]', s)
if m:
return 1
return 16 # default: full 16-bit cmd_value
def parse_verilog_opcodes(filepath: Path | None = None) -> dict[int, OpcodeEntry]:
"""
Parse the opcode case statement from radar_system_top.v.
Returns {opcode_value: OpcodeEntry}.
"""
if filepath is None:
filepath = FPGA_DIR / "radar_system_top.v"
text = filepath.read_text()
# Find the command decode case block
# Pattern: case statement with 8'hXX opcodes
opcodes: dict[int, OpcodeEntry] = {}
# Pattern 1: Simple assignment — 8'hXX: register <= rhs;
for m in re.finditer(
r"8'h([0-9a-fA-F]{2})\s*:\s*(\w+)\s*<=\s*(.*?)(?:;|$)",
text, re.MULTILINE
):
value = int(m.group(1), 16)
register = m.group(2)
rhs = m.group(3).strip()
# Determine if it's a pulse (assigned literal 1)
is_pulse = rhs in ("1", "1'b1")
# Extract bit slice from the RHS (e.g., usb_cmd_value[3:0])
bit_slice = ""
slice_m = re.search(r'usb_cmd_value(\[\d+(?::\d+)?\])', rhs)
if slice_m:
bit_slice = slice_m.group(1)
elif "usb_cmd_value" in rhs:
bit_slice = "[15:0]" # full width
bit_width = _parse_bit_slice(bit_slice) if bit_slice else 0
opcodes[value] = OpcodeEntry(
name=register,
value=value,
register=register,
bit_slice=bit_slice,
bit_width=bit_width,
is_pulse=is_pulse,
)
# Pattern 2: begin...end blocks — 8'hXX: begin ... register <= ... end
# These are used for opcodes with validation logic (e.g., 0x15 clamp)
for m in re.finditer(
r"8'h([0-9a-fA-F]{2})\s*:\s*begin\b(.*?)end\b",
text, re.DOTALL
):
value = int(m.group(1), 16)
if value in opcodes:
continue # Already captured by pattern 1
body = m.group(2)
# Find the first register assignment (host_xxx <=)
assign_m = re.search(r'(host_\w+)\s*<=\s*(.+?);', body)
if not assign_m:
continue
register = assign_m.group(1)
rhs = assign_m.group(2).strip()
bit_slice = ""
slice_m = re.search(r'usb_cmd_value(\[\d+(?::\d+)?\])', body)
if slice_m:
bit_slice = slice_m.group(1)
elif "usb_cmd_value" in body:
bit_slice = "[15:0]"
bit_width = _parse_bit_slice(bit_slice) if bit_slice else 0
opcodes[value] = OpcodeEntry(
name=register,
value=value,
register=register,
bit_slice=bit_slice,
bit_width=bit_width,
is_pulse=False,
)
return opcodes
def parse_verilog_reset_defaults(filepath: Path | None = None) -> dict[str, int]:
"""
Parse the reset block from radar_system_top.v.
Returns {register_name: reset_value}.
"""
if filepath is None:
filepath = FPGA_DIR / "radar_system_top.v"
text = filepath.read_text()
defaults: dict[str, int] = {}
# Match patterns like: host_radar_mode <= 2'b01;
# Also: host_detect_threshold <= 16'd10000;
for m in re.finditer(
r'(host_\w+)\s*<=\s*(\d+\'[bdho][0-9a-fA-F_]+|\d+)\s*;',
text
):
reg = m.group(1)
val_str = m.group(2)
# Parse Verilog literal
if "'" in val_str:
base_char = val_str.split("'")[1][0].lower()
digits = val_str.split("'")[1][1:].replace("_", "")
base = {"b": 2, "d": 10, "h": 16, "o": 8}[base_char]
value = int(digits, base)
else:
value = int(val_str)
# Only keep first occurrence (the reset block comes before the
# opcode decode which also has <= assignments)
if reg not in defaults:
defaults[reg] = value
return defaults
def parse_verilog_register_widths(filepath: Path | None = None) -> dict[str, int]:
"""
Parse register declarations from radar_system_top.v.
Returns {register_name: bit_width}.
"""
if filepath is None:
filepath = FPGA_DIR / "radar_system_top.v"
text = filepath.read_text()
widths: dict[str, int] = {}
# Match: reg [15:0] host_detect_threshold;
# Also: reg host_trigger_pulse;
for m in re.finditer(
r'reg\s+(?:\[\s*(\d+)\s*:\s*(\d+)\s*\]\s+)?(host_\w+)\s*;',
text
):
width = int(m.group(1)) - int(m.group(2)) + 1 if m.group(1) is not None else 1
widths[m.group(3)] = width
return widths
def parse_verilog_packet_constants(
filepath: Path | None = None,
) -> dict[str, PacketConstants]:
"""Extract HEADER, FOOTER, STATUS_HEADER, packet size localparams."""
if filepath is None:
filepath = FPGA_DIR / "usb_data_interface_ft2232h.v"
text = filepath.read_text()
def _find(pattern: str) -> int:
m = re.search(pattern, text)
if not m:
raise ValueError(f"Pattern not found in {filepath}: {pattern}")
val = m.group(1)
# Parse Verilog literals: 8'hAA → 0xAA, 5'd11 → 11
vlog_m = re.match(r"\d+'h([0-9a-fA-F]+)", val)
if vlog_m:
return int(vlog_m.group(1), 16)
vlog_m = re.match(r"\d+'d(\d+)", val)
if vlog_m:
return int(vlog_m.group(1))
return int(val, 16) if val.startswith("0x") else int(val)
header_val = _find(r"localparam\s+HEADER\s*=\s*(\d+'h[0-9a-fA-F]+)")
footer_val = _find(r"localparam\s+FOOTER\s*=\s*(\d+'h[0-9a-fA-F]+)")
status_hdr = _find(r"localparam\s+STATUS_HEADER\s*=\s*(\d+'h[0-9a-fA-F]+)")
data_size = _find(r"DATA_PKT_LEN\s*=\s*(\d+'d\d+)")
status_size = _find(r"STATUS_PKT_LEN\s*=\s*(\d+'d\d+)")
return {
"data": PacketConstants(header=header_val, footer=footer_val, size=data_size),
"status": PacketConstants(header=status_hdr, footer=footer_val, size=status_size),
}
def count_concat_bits(concat_expr: str, port_widths: dict[str, int]) -> ConcatWidth:
"""
Count total bits in a Verilog concatenation expression like:
{8'hFF, 3'b000, status_radar_mode, 5'b00000, status_stream_ctrl, status_cfar_threshold}
Uses port_widths to resolve signal widths. Returns ConcatWidth.
"""
# Remove outer braces
inner = concat_expr.strip().strip("{}")
fragments: list[tuple[str, int]] = []
total = 0
for part in re.split(r',\s*', inner):
part = part.strip()
if not part:
continue
# Literal: N'bXXX, N'dXXX, N'hXX, or just a decimal
lit_match = re.match(r"(\d+)'[bdhoBDHO]", part)
if lit_match:
w = int(lit_match.group(1))
fragments.append((part, w))
total += w
continue
# Signal with bit select: sig[M:N] or sig[N]
sel_match = re.match(r'(\w+)\[(\d+):(\d+)\]', part)
if sel_match:
w = int(sel_match.group(2)) - int(sel_match.group(3)) + 1
fragments.append((part, w))
total += w
continue
sel_match = re.match(r'(\w+)\[(\d+)\]', part)
if sel_match:
fragments.append((part, 1))
total += 1
continue
# Bare signal: look up in port_widths
if part in port_widths:
w = port_widths[part]
fragments.append((part, w))
total += w
else:
# Unknown width — flag it
fragments.append((part, -1))
total = -1 # Can't compute
return ConcatWidth(
total_bits=total,
target_bits=32,
fragments=fragments,
truncated=total > 32 if total > 0 else False,
)
def parse_verilog_status_word_concats(
filepath: Path | None = None,
) -> dict[int, str]:
"""
Extract the raw concatenation expression for each status_words[N] assignment.
Returns {word_index: concat_expression_string}.
"""
if filepath is None:
filepath = FPGA_DIR / "usb_data_interface_ft2232h.v"
text = filepath.read_text()
results: dict[int, str] = {}
# Multi-line concat: status_words[N] <= {... };
# We need to handle multi-line expressions
for m in re.finditer(
r'status_words\[(\d+)\]\s*<=\s*(\{[^;]+\})\s*;',
text, re.DOTALL
):
idx = int(m.group(1))
expr = m.group(2)
# Strip single-line comments before normalizing whitespace
expr = re.sub(r'//[^\n]*', '', expr)
# Normalize whitespace
expr = re.sub(r'\s+', ' ', expr).strip()
results[idx] = expr
return results
def get_usb_interface_port_widths(filepath: Path | None = None) -> dict[str, int]:
"""
Parse port declarations from usb_data_interface_ft2232h.v module header.
Returns {port_name: bit_width}.
"""
if filepath is None:
filepath = FPGA_DIR / "usb_data_interface_ft2232h.v"
text = filepath.read_text()
widths: dict[str, int] = {}
# Match: input wire [15:0] status_cfar_threshold,
# Also: input wire status_self_test_busy
for m in re.finditer(
r'(?:input|output)\s+(?:wire|reg)\s+(?:\[\s*(\d+)\s*:\s*(\d+)\s*\]\s+)?(\w+)',
text
):
width = int(m.group(1)) - int(m.group(2)) + 1 if m.group(1) is not None else 1
widths[m.group(3)] = width
return widths
def parse_verilog_data_mux(
filepath: Path | None = None,
) -> list[DataPacketField]:
"""
Parse the data_pkt_byte mux from usb_data_interface_ft2232h.v.
Returns fields with byte positions and signal names.
"""
if filepath is None:
filepath = FPGA_DIR / "usb_data_interface_ft2232h.v"
text = filepath.read_text()
# Find the data mux case block
match = re.search(
r'always\s+@\(\*\)\s+begin\s+case\s*\(wr_byte_idx\)(.*?)endcase',
text, re.DOTALL
)
if not match:
raise ValueError("Could not find data_pkt_byte mux")
mux_body = match.group(1)
entries: list[tuple[int, str]] = []
for m in re.finditer(
r"5'd(\d+)\s*:\s*data_pkt_byte\s*=\s*(.+?);",
mux_body
):
idx = int(m.group(1))
expr = m.group(2).strip()
entries.append((idx, expr))
# Group consecutive bytes by signal root name
fields: list[DataPacketField] = []
i = 0
while i < len(entries):
idx, expr = entries[i]
if expr == "HEADER" or expr == "FOOTER":
i += 1
continue
# Extract signal name (e.g., range_profile_cap from range_profile_cap[31:24])
sig_match = re.match(r'(\w+?)(?:\[|$)', expr)
if not sig_match:
i += 1
continue
signal = sig_match.group(1)
start_byte = idx
end_byte = idx
# Find consecutive bytes of the same signal
j = i + 1
while j < len(entries):
next_idx, next_expr = entries[j]
if next_expr.startswith(signal):
end_byte = next_idx
j += 1
else:
break
n_bytes = end_byte - start_byte + 1
fields.append(DataPacketField(
name=signal.replace("_cap", ""),
byte_start=start_byte,
byte_end=end_byte,
width_bits=n_bytes * 8,
))
i = j
return fields
# ===================================================================
# STM32 / C layer parser
# ===================================================================
def parse_stm32_settings_fields(
filepath: Path | None = None,
) -> list[SettingsField]:
"""
Parse RadarSettings::parseFromUSB to extract field order, offsets, types.
"""
if filepath is None:
filepath = MCU_LIB_DIR / "RadarSettings.cpp"
if not filepath.exists():
return [] # MCU code not available (CI might not have it)
text = filepath.read_text(encoding="latin-1")
fields: list[SettingsField] = []
# Look for memcpy + shift patterns that extract doubles and uint32s
# Pattern for doubles: loop reading 8 bytes big-endian
# Pattern for uint32: 4 bytes big-endian
# We'll parse the assignment targets in order
# Find the parseFromUSB function
match = re.search(
r'parseFromUSB\s*\(.*?\)\s*\{(.*?)^\}',
text, re.DOTALL | re.MULTILINE
)
if not match:
return fields
body = match.group(1)
# The fields are extracted sequentially from the payload.
# Look for variable assignments that follow the memcpy/extraction pattern.
# Based on known code: extractDouble / extractUint32 patterns
field_names = [
("system_frequency", 8, "double"),
("chirp_duration_1", 8, "double"),
("chirp_duration_2", 8, "double"),
("chirps_per_position", 4, "uint32_t"),
("freq_min", 8, "double"),
("freq_max", 8, "double"),
("prf1", 8, "double"),
("prf2", 8, "double"),
("max_distance", 8, "double"),
("map_size", 8, "double"),
]
offset = 0
for name, size, ctype in field_names:
# Verify the field name appears in the function body
if name in body or name.replace("_", "") in body.lower():
fields.append(SettingsField(
name=name, offset=offset, size=size, c_type=ctype
))
offset += size
return fields
def parse_stm32_start_flag(
filepath: Path | None = None,
) -> list[int]:
"""Parse the USB start flag bytes from USBHandler.cpp."""
if filepath is None:
filepath = MCU_LIB_DIR / "USBHandler.cpp"
if not filepath.exists():
return []
text = filepath.read_text()
# Look for the start flag array, e.g. {23, 46, 158, 237}
match = re.search(r'start_flag.*?=\s*\{([^}]+)\}', text, re.DOTALL)
if not match:
# Try alternate patterns
match = re.search(r'\{(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*\d+\s*)\}', text)
if not match:
return []
return [int(x.strip()) for x in match.group(1).split(",") if x.strip().isdigit()]
# ===================================================================
# GPIO parser
# ===================================================================
def parse_xdc_gpio_pins(filepath: Path | None = None) -> list[GpioPin]:
"""Parse XDC constraints for DIG_* pin assignments."""
if filepath is None:
filepath = XDC_DIR / "xc7a50t_ftg256.xdc"
if not filepath.exists():
return []
text = filepath.read_text()
pins: list[GpioPin] = []
# Match: set_property PACKAGE_PIN XX [get_ports {signal_name}]
for m in re.finditer(
r'set_property\s+PACKAGE_PIN\s+(\w+)\s+\[get_ports\s+\{?(\w+)\}?\]',
text
):
pin = m.group(1)
signal = m.group(2)
if any(kw in signal for kw in ("stm32_", "reset_n", "dig_")):
# Determine direction from signal name
if signal in ("stm32_new_chirp", "stm32_new_elevation",
"stm32_new_azimuth", "stm32_mixers_enable"):
direction = "input" # FPGA receives these
elif signal == "reset_n":
direction = "input"
else:
direction = "unknown"
pins.append(GpioPin(
name=signal, pin_id=pin, direction=direction, layer="fpga"
))
return pins
def parse_stm32_gpio_init(filepath: Path | None = None) -> list[GpioPin]:
"""Parse STM32 GPIO initialization for PD8-PD15 directions."""
if filepath is None:
filepath = MCU_CODE_DIR / "main.cpp"
if not filepath.exists():
return []
text = filepath.read_text()
pins: list[GpioPin] = []
# Look for GPIO_InitStruct.Pin and GPIO_InitStruct.Mode patterns
# This is approximate — STM32 HAL GPIO init is complex
# Look for PD8-PD15 configuration (output vs input)
# Pattern: GPIO_PIN_8 | GPIO_PIN_9 ... with Mode = OUTPUT
# We'll find blocks that configure GPIOD pins
for m in re.finditer(
r'GPIO_InitStruct\.Pin\s*=\s*([^;]+);.*?'
r'GPIO_InitStruct\.Mode\s*=\s*(\w+)',
text, re.DOTALL
):
pin_expr = m.group(1)
mode = m.group(2)
direction = "output" if "OUTPUT" in mode else "input"
# Extract individual pin numbers
for pin_m in re.finditer(r'GPIO_PIN_(\d+)', pin_expr):
pin_num = int(pin_m.group(1))
if 8 <= pin_num <= 15:
pins.append(GpioPin(
name=f"PD{pin_num}",
pin_id=f"PD{pin_num}",
direction=direction,
layer="stm32"
))
return pins
@@ -0,0 +1,86 @@
/**
* stm32_settings_stub.cpp
*
* Standalone stub that wraps the real RadarSettings class.
* Reads a binary settings packet from a file (argv[1]),
* parses it using RadarSettings::parseFromUSB(), and prints
* all parsed field=value pairs to stdout.
*
* Compile: c++ -std=c++11 -o stm32_settings_stub stm32_settings_stub.cpp \
* ../../9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/RadarSettings.cpp \
* -I../../9_Firmware/9_1_Microcontroller/9_1_1_C_Cpp_Libraries/
*
* Usage: ./stm32_settings_stub packet.bin
* Prints: field=value lines (one per field)
* Exit code: 0 if parse succeeded, 1 if failed
*/
#include "RadarSettings.h"
#include <cstdio>
#include <cstdlib>
int main(int argc, char* argv[]) {
if (argc != 2) {
fprintf(stderr, "Usage: %s <packet.bin>\n", argv[0]);
return 2;
}
// Read binary packet from file
FILE* f = fopen(argv[1], "rb");
if (!f) {
fprintf(stderr, "ERROR: Cannot open %s\n", argv[1]);
return 2;
}
fseek(f, 0, SEEK_END);
long file_size = ftell(f);
fseek(f, 0, SEEK_SET);
if (file_size <= 0 || file_size > 4096) {
fprintf(stderr, "ERROR: Invalid file size %ld\n", file_size);
fclose(f);
return 2;
}
uint8_t* buf = (uint8_t*)malloc(file_size);
if (!buf) {
fprintf(stderr, "ERROR: malloc failed\n");
fclose(f);
return 2;
}
size_t nread = fread(buf, 1, file_size, f);
fclose(f);
if ((long)nread != file_size) {
fprintf(stderr, "ERROR: Short read (%zu of %ld)\n", nread, file_size);
free(buf);
return 2;
}
// Parse using the real RadarSettings class
RadarSettings settings;
bool ok = settings.parseFromUSB(buf, (uint32_t)file_size);
free(buf);
if (!ok) {
printf("parse_ok=false\n");
return 1;
}
// Print all fields with full precision
// Python orchestrator will compare these against expected values
printf("parse_ok=true\n");
printf("system_frequency=%.17g\n", settings.getSystemFrequency());
printf("chirp_duration_1=%.17g\n", settings.getChirpDuration1());
printf("chirp_duration_2=%.17g\n", settings.getChirpDuration2());
printf("chirps_per_position=%u\n", settings.getChirpsPerPosition());
printf("freq_min=%.17g\n", settings.getFreqMin());
printf("freq_max=%.17g\n", settings.getFreqMax());
printf("prf1=%.17g\n", settings.getPRF1());
printf("prf2=%.17g\n", settings.getPRF2());
printf("max_distance=%.17g\n", settings.getMaxDistance());
printf("map_size=%.17g\n", settings.getMapSize());
return 0;
}
@@ -0,0 +1,714 @@
`timescale 1ns / 1ps
/**
* tb_cross_layer_ft2232h.v
*
* Cross-layer contract testbench for the FT2232H USB interface.
* Exercises three packet types with known distinctive values and dumps
* captured bytes to text files that the Python orchestrator can parse.
*
* Exercise A: Command round-trip (Host -> FPGA)
* - Send every opcode through the 4-byte read FSM
* - Dump cmd_opcode, cmd_addr, cmd_value to cmd_results.txt
*
* Exercise B: Data packet generation (FPGA -> Host)
* - Inject known range/doppler/cfar values
* - Capture all 11 output bytes
* - Dump to data_packet.txt
*
* Exercise C: Status packet generation (FPGA -> Host)
* - Set all status inputs to known non-zero values
* - Trigger status request
* - Capture all 26 output bytes
* - Dump to status_packet.txt
*/
module tb_cross_layer_ft2232h;
// Clock periods
localparam CLK_PERIOD = 10.0; // 100 MHz system clock
localparam FT_CLK_PERIOD = 16.67; // 60 MHz FT2232H clock
// ---- Signals ----
reg clk;
reg reset_n;
reg ft_reset_n;
// Radar data inputs
reg [31:0] range_profile;
reg range_valid;
reg [15:0] doppler_real;
reg [15:0] doppler_imag;
reg doppler_valid;
reg cfar_detection;
reg cfar_valid;
// FT2232H physical interface
wire [7:0] ft_data;
reg ft_rxf_n;
reg ft_txe_n;
wire ft_rd_n;
wire ft_wr_n;
wire ft_oe_n;
wire ft_siwu;
reg ft_clk;
// Host-side bus driver (for command injection)
reg [7:0] host_data_drive;
reg host_data_drive_en;
assign ft_data = host_data_drive_en ? host_data_drive : 8'hZZ;
// Pulldown to avoid X during idle
pulldown pd[7:0] (ft_data);
// DUT command outputs
wire [31:0] cmd_data;
wire cmd_valid;
wire [7:0] cmd_opcode;
wire [7:0] cmd_addr;
wire [15:0] cmd_value;
// Stream control
reg [2:0] stream_control;
// Status inputs
reg status_request;
reg [15:0] status_cfar_threshold;
reg [2:0] status_stream_ctrl;
reg [1:0] status_radar_mode;
reg [15:0] status_long_chirp;
reg [15:0] status_long_listen;
reg [15:0] status_guard;
reg [15:0] status_short_chirp;
reg [15:0] status_short_listen;
reg [5:0] status_chirps_per_elev;
reg [1:0] status_range_mode;
reg [4:0] status_self_test_flags;
reg [7:0] status_self_test_detail;
reg status_self_test_busy;
reg [3:0] status_agc_current_gain;
reg [7:0] status_agc_peak_magnitude;
reg [7:0] status_agc_saturation_count;
reg status_agc_enable;
// ---- Clock generators ----
always #(CLK_PERIOD / 2) clk = ~clk;
always #(FT_CLK_PERIOD / 2) ft_clk = ~ft_clk;
// ---- DUT instantiation ----
usb_data_interface_ft2232h uut (
.clk (clk),
.reset_n (reset_n),
.ft_reset_n (ft_reset_n),
.range_profile (range_profile),
.range_valid (range_valid),
.doppler_real (doppler_real),
.doppler_imag (doppler_imag),
.doppler_valid (doppler_valid),
.cfar_detection (cfar_detection),
.cfar_valid (cfar_valid),
.ft_data (ft_data),
.ft_rxf_n (ft_rxf_n),
.ft_txe_n (ft_txe_n),
.ft_rd_n (ft_rd_n),
.ft_wr_n (ft_wr_n),
.ft_oe_n (ft_oe_n),
.ft_siwu (ft_siwu),
.ft_clk (ft_clk),
.cmd_data (cmd_data),
.cmd_valid (cmd_valid),
.cmd_opcode (cmd_opcode),
.cmd_addr (cmd_addr),
.cmd_value (cmd_value),
.stream_control (stream_control),
.status_request (status_request),
.status_cfar_threshold (status_cfar_threshold),
.status_stream_ctrl (status_stream_ctrl),
.status_radar_mode (status_radar_mode),
.status_long_chirp (status_long_chirp),
.status_long_listen (status_long_listen),
.status_guard (status_guard),
.status_short_chirp (status_short_chirp),
.status_short_listen (status_short_listen),
.status_chirps_per_elev (status_chirps_per_elev),
.status_range_mode (status_range_mode),
.status_self_test_flags (status_self_test_flags),
.status_self_test_detail(status_self_test_detail),
.status_self_test_busy (status_self_test_busy),
.status_agc_current_gain (status_agc_current_gain),
.status_agc_peak_magnitude (status_agc_peak_magnitude),
.status_agc_saturation_count(status_agc_saturation_count),
.status_agc_enable (status_agc_enable)
);
// ---- Test bookkeeping ----
integer pass_count;
integer fail_count;
integer test_num;
integer cmd_file;
integer data_file;
integer status_file;
// ---- Check task ----
task check;
input cond;
input [511:0] label;
begin
test_num = test_num + 1;
if (cond) begin
$display("[PASS] Test %0d: %0s", test_num, label);
pass_count = pass_count + 1;
end else begin
$display("[FAIL] Test %0d: %0s", test_num, label);
fail_count = fail_count + 1;
end
end
endtask
// ---- Helper: apply reset ----
task apply_reset;
begin
reset_n = 0;
ft_reset_n = 0;
range_profile = 32'h0;
range_valid = 0;
doppler_real = 16'h0;
doppler_imag = 16'h0;
doppler_valid = 0;
cfar_detection = 0;
cfar_valid = 0;
ft_rxf_n = 1; // No host data available
ft_txe_n = 0; // TX FIFO ready
host_data_drive = 8'h0;
host_data_drive_en = 0;
stream_control = 3'b111;
status_request = 0;
status_cfar_threshold = 16'd0;
status_stream_ctrl = 3'b000;
status_radar_mode = 2'b00;
status_long_chirp = 16'd0;
status_long_listen = 16'd0;
status_guard = 16'd0;
status_short_chirp = 16'd0;
status_short_listen = 16'd0;
status_chirps_per_elev = 6'd0;
status_range_mode = 2'b00;
status_self_test_flags = 5'b00000;
status_self_test_detail = 8'd0;
status_self_test_busy = 1'b0;
status_agc_current_gain = 4'd0;
status_agc_peak_magnitude = 8'd0;
status_agc_saturation_count = 8'd0;
status_agc_enable = 1'b0;
repeat (6) @(posedge ft_clk);
reset_n = 1;
ft_reset_n = 1;
// Wait for stream_control CDC to propagate
repeat (8) @(posedge ft_clk);
end
endtask
// ---- Helper: send one 4-byte command via FT2232H read path ----
//
// FT2232H read FSM cycle-by-cycle:
// Cycle 0 (RD_IDLE): sees !ft_rxf_n → ft_oe_n<=0, → RD_OE_ASSERT
// Cycle 1 (RD_OE_ASSERT): sees !ft_rxf_n → ft_rd_n<=0, → RD_READING
// Cycle 2 (RD_READING): samples ft_data=byte0, cnt 0→1
// Cycle 3 (RD_READING): samples ft_data=byte1, cnt 1→2
// Cycle 4 (RD_READING): samples ft_data=byte2, cnt 2→3
// Cycle 5 (RD_READING): samples ft_data=byte3, cnt=3→0, → RD_DEASSERT
// Cycle 6 (RD_DEASSERT): ft_oe_n<=1, → RD_PROCESS
// Cycle 7 (RD_PROCESS): cmd_valid<=1, decode, → RD_IDLE
//
// Data must be stable BEFORE the sampling posedge. We use #1 after
// posedge to change data in the "delta after" region.
task send_command_ft2232h;
input [7:0] byte0; // opcode
input [7:0] byte1; // addr
input [7:0] byte2; // value_hi
input [7:0] byte3; // value_lo
begin
// Pre-drive byte0 and signal data available
@(posedge ft_clk); #1;
host_data_drive = byte0;
host_data_drive_en = 1;
ft_rxf_n = 0;
// Cycle 0: RD_IDLE sees !ft_rxf_n, goes to OE_ASSERT
@(posedge ft_clk); #1;
// Cycle 1: RD_OE_ASSERT, ft_rd_n goes low, goes to RD_READING
@(posedge ft_clk); #1;
// Cycle 2: RD_READING, byte0 is sampled, cnt 0→1
// Now change to byte1 for next sample
@(posedge ft_clk); #1;
host_data_drive = byte1;
// Cycle 3: RD_READING, byte1 is sampled, cnt 1→2
@(posedge ft_clk); #1;
host_data_drive = byte2;
// Cycle 4: RD_READING, byte2 is sampled, cnt 2→3
@(posedge ft_clk); #1;
host_data_drive = byte3;
// Cycle 5: RD_READING, byte3 is sampled, cnt=3, → RD_DEASSERT
@(posedge ft_clk); #1;
// Cycle 6: RD_DEASSERT, ft_oe_n←1, → RD_PROCESS
@(posedge ft_clk); #1;
// Cycle 7: RD_PROCESS, cmd decoded, cmd_valid←1, → RD_IDLE
@(posedge ft_clk); #1;
// cmd_valid was asserted at cycle 7's posedge. cmd_opcode/addr/value
// are now valid (registered outputs hold until next RD_PROCESS).
// Release bus
host_data_drive_en = 0;
host_data_drive = 8'h0;
ft_rxf_n = 1;
// Settle
repeat (2) @(posedge ft_clk);
end
endtask
// ---- Helper: capture N write bytes from the DUT ----
// Monitors ft_wr_n and ft_data_out, captures bytes into array.
// Used for data packets (11 bytes) and status packets (26 bytes).
reg [7:0] captured_bytes [0:31];
integer capture_count;
task capture_write_bytes;
input integer expected_count;
integer timeout;
begin
capture_count = 0;
timeout = 0;
while (capture_count < expected_count && timeout < 2000) begin
@(posedge ft_clk); #1;
timeout = timeout + 1;
// DUT drives byte when ft_wr_n=0 and ft_data_oe=1
// Sample AFTER posedge so registered outputs are settled
if (!ft_wr_n && uut.ft_data_oe) begin
captured_bytes[capture_count] = uut.ft_data_out;
capture_count = capture_count + 1;
end
end
end
endtask
// ---- Helper: pulse range_valid with CDC wait ----
// Toggle CDC needs 3 sync stages + edge detect = 4+ ft_clk cycles.
// Use 12 for safety margin.
task assert_range_valid;
input [31:0] data;
begin
@(posedge clk); #1;
range_profile = data;
range_valid = 1;
@(posedge clk); #1;
range_valid = 0;
// Wait for toggle CDC propagation
repeat (12) @(posedge ft_clk);
end
endtask
// ---- Helper: pulse doppler_valid ----
task pulse_doppler;
input [15:0] dr;
input [15:0] di;
begin
@(posedge clk); #1;
doppler_real = dr;
doppler_imag = di;
doppler_valid = 1;
@(posedge clk); #1;
doppler_valid = 0;
repeat (12) @(posedge ft_clk);
end
endtask
// ---- Helper: pulse cfar_valid ----
task pulse_cfar;
input det;
begin
@(posedge clk); #1;
cfar_detection = det;
cfar_valid = 1;
@(posedge clk); #1;
cfar_valid = 0;
repeat (12) @(posedge ft_clk);
end
endtask
// ---- Helper: pulse status_request ----
task pulse_status_request;
begin
@(posedge clk); #1;
status_request = 1;
@(posedge clk); #1;
status_request = 0;
// Wait for toggle CDC propagation
repeat (12) @(posedge ft_clk);
end
endtask
// ================================================================
// Main stimulus
// ================================================================
integer i;
initial begin
$dumpfile("tb_cross_layer_ft2232h.vcd");
$dumpvars(0, tb_cross_layer_ft2232h);
clk = 0;
ft_clk = 0;
pass_count = 0;
fail_count = 0;
test_num = 0;
// ============================================================
// EXERCISE A: Command Round-Trip
// Send commands with known opcode/addr/value, verify decoding.
// Dump results to cmd_results.txt for Python validation.
// ============================================================
$display("\n=== EXERCISE A: Command Round-Trip ===");
apply_reset;
cmd_file = $fopen("cmd_results.txt", "w");
$fwrite(cmd_file, "# opcode_sent addr_sent value_sent opcode_got addr_got value_got\n");
// Test all real opcodes from radar_system_top.v
// Format: opcode, addr=0x00, value
// Basic control
send_command_ft2232h(8'h01, 8'h00, 8'h00, 8'h02); // RADAR_MODE=2
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h01, 8'h00, 16'h0002, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h01 && cmd_value === 16'h0002,
"Cmd 0x01: RADAR_MODE=2");
send_command_ft2232h(8'h02, 8'h00, 8'h00, 8'h01); // TRIGGER_PULSE
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h02, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h02 && cmd_value === 16'h0001,
"Cmd 0x02: TRIGGER_PULSE");
send_command_ft2232h(8'h03, 8'h00, 8'h27, 8'h10); // DETECT_THRESHOLD=10000
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h03, 8'h00, 16'h2710, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h03 && cmd_value === 16'h2710,
"Cmd 0x03: DETECT_THRESHOLD=10000");
send_command_ft2232h(8'h04, 8'h00, 8'h00, 8'h07); // STREAM_CONTROL=7
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h04, 8'h00, 16'h0007, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h04 && cmd_value === 16'h0007,
"Cmd 0x04: STREAM_CONTROL=7");
// Chirp timing
send_command_ft2232h(8'h10, 8'h00, 8'h0B, 8'hB8); // LONG_CHIRP=3000
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h10, 8'h00, 16'h0BB8, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h10 && cmd_value === 16'h0BB8,
"Cmd 0x10: LONG_CHIRP=3000");
send_command_ft2232h(8'h11, 8'h00, 8'h35, 8'h84); // LONG_LISTEN=13700
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h11, 8'h00, 16'h3584, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h11 && cmd_value === 16'h3584,
"Cmd 0x11: LONG_LISTEN=13700");
send_command_ft2232h(8'h12, 8'h00, 8'h44, 8'h84); // GUARD=17540
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h12, 8'h00, 16'h4484, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h12 && cmd_value === 16'h4484,
"Cmd 0x12: GUARD=17540");
send_command_ft2232h(8'h13, 8'h00, 8'h00, 8'h32); // SHORT_CHIRP=50
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h13, 8'h00, 16'h0032, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h13 && cmd_value === 16'h0032,
"Cmd 0x13: SHORT_CHIRP=50");
send_command_ft2232h(8'h14, 8'h00, 8'h44, 8'h2A); // SHORT_LISTEN=17450
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h14, 8'h00, 16'h442A, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h14 && cmd_value === 16'h442A,
"Cmd 0x14: SHORT_LISTEN=17450");
send_command_ft2232h(8'h15, 8'h00, 8'h00, 8'h20); // CHIRPS_PER_ELEV=32
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h15, 8'h00, 16'h0020, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h15 && cmd_value === 16'h0020,
"Cmd 0x15: CHIRPS_PER_ELEV=32");
// Digital gain
send_command_ft2232h(8'h16, 8'h00, 8'h00, 8'h05); // GAIN_SHIFT=5
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h16, 8'h00, 16'h0005, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h16 && cmd_value === 16'h0005,
"Cmd 0x16: GAIN_SHIFT=5");
// Signal processing
send_command_ft2232h(8'h20, 8'h00, 8'h00, 8'h01); // RANGE_MODE=1
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h20, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h20 && cmd_value === 16'h0001,
"Cmd 0x20: RANGE_MODE=1");
send_command_ft2232h(8'h21, 8'h00, 8'h00, 8'h03); // CFAR_GUARD=3
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h21, 8'h00, 16'h0003, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h21 && cmd_value === 16'h0003,
"Cmd 0x21: CFAR_GUARD=3");
send_command_ft2232h(8'h22, 8'h00, 8'h00, 8'h0C); // CFAR_TRAIN=12
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h22, 8'h00, 16'h000C, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h22 && cmd_value === 16'h000C,
"Cmd 0x22: CFAR_TRAIN=12");
send_command_ft2232h(8'h23, 8'h00, 8'h00, 8'h30); // CFAR_ALPHA=0x30
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h23, 8'h00, 16'h0030, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h23 && cmd_value === 16'h0030,
"Cmd 0x23: CFAR_ALPHA=0x30");
send_command_ft2232h(8'h24, 8'h00, 8'h00, 8'h01); // CFAR_MODE=1 (GO)
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h24, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h24 && cmd_value === 16'h0001,
"Cmd 0x24: CFAR_MODE=1");
send_command_ft2232h(8'h25, 8'h00, 8'h00, 8'h01); // CFAR_ENABLE=1
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h25, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h25 && cmd_value === 16'h0001,
"Cmd 0x25: CFAR_ENABLE=1");
send_command_ft2232h(8'h26, 8'h00, 8'h00, 8'h01); // MTI_ENABLE=1
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h26, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h26 && cmd_value === 16'h0001,
"Cmd 0x26: MTI_ENABLE=1");
send_command_ft2232h(8'h27, 8'h00, 8'h00, 8'h03); // DC_NOTCH_WIDTH=3
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h27, 8'h00, 16'h0003, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h27 && cmd_value === 16'h0003,
"Cmd 0x27: DC_NOTCH_WIDTH=3");
// AGC registers (0x28-0x2C)
send_command_ft2232h(8'h28, 8'h00, 8'h00, 8'h01); // AGC_ENABLE=1
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h28, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h28 && cmd_value === 16'h0001,
"Cmd 0x28: AGC_ENABLE=1");
send_command_ft2232h(8'h29, 8'h00, 8'h00, 8'hC8); // AGC_TARGET=200
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h29, 8'h00, 16'h00C8, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h29 && cmd_value === 16'h00C8,
"Cmd 0x29: AGC_TARGET=200");
send_command_ft2232h(8'h2A, 8'h00, 8'h00, 8'h02); // AGC_ATTACK=2
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h2A, 8'h00, 16'h0002, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h2A && cmd_value === 16'h0002,
"Cmd 0x2A: AGC_ATTACK=2");
send_command_ft2232h(8'h2B, 8'h00, 8'h00, 8'h03); // AGC_DECAY=3
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h2B, 8'h00, 16'h0003, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h2B && cmd_value === 16'h0003,
"Cmd 0x2B: AGC_DECAY=3");
send_command_ft2232h(8'h2C, 8'h00, 8'h00, 8'h06); // AGC_HOLDOFF=6
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h2C, 8'h00, 16'h0006, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h2C && cmd_value === 16'h0006,
"Cmd 0x2C: AGC_HOLDOFF=6");
// Self-test / status
send_command_ft2232h(8'h30, 8'h00, 8'h00, 8'h01); // SELF_TEST_TRIGGER
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h30, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h30 && cmd_value === 16'h0001,
"Cmd 0x30: SELF_TEST_TRIGGER");
send_command_ft2232h(8'h31, 8'h00, 8'h00, 8'h01); // SELF_TEST_STATUS
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h31, 8'h00, 16'h0001, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h31 && cmd_value === 16'h0001,
"Cmd 0x31: SELF_TEST_STATUS");
send_command_ft2232h(8'hFF, 8'h00, 8'h00, 8'h00); // STATUS_REQUEST
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'hFF, 8'h00, 16'h0000, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'hFF && cmd_value === 16'h0000,
"Cmd 0xFF: STATUS_REQUEST");
// Non-zero addr test
send_command_ft2232h(8'h01, 8'hAB, 8'hCD, 8'hEF); // addr=0xAB, value=0xCDEF
$fwrite(cmd_file, "%02x %02x %04x %02x %02x %04x\n",
8'h01, 8'hAB, 16'hCDEF, cmd_opcode, cmd_addr, cmd_value);
check(cmd_opcode === 8'h01 && cmd_addr === 8'hAB && cmd_value === 16'hCDEF,
"Cmd 0x01 with addr=0xAB, value=0xCDEF");
$fclose(cmd_file);
// ============================================================
// EXERCISE B: Data Packet Generation
// Inject known values, capture 11-byte output.
// ============================================================
$display("\n=== EXERCISE B: Data Packet Generation ===");
apply_reset;
ft_txe_n = 0; // TX FIFO ready
// Use distinctive values that make truncation/swap bugs obvious
// range_profile = {Q[15:0], I[15:0]} = {0xCAFE, 0xBEEF}
// doppler_real = 0x1234, doppler_imag = 0x5678
// cfar_detection = 1
// First inject doppler and cfar so pending flags are set
pulse_doppler(16'h1234, 16'h5678);
pulse_cfar(1'b1);
// Now inject range_valid which triggers the write FSM.
// CRITICAL: Must capture bytes IN PARALLEL with the trigger,
// because the write FSM starts sending bytes ~3-4 ft_clk cycles
// after the toggle CDC propagates. If we wait for CDC propagation
// first, capture_write_bytes misses the early bytes.
fork
assert_range_valid(32'hCAFE_BEEF);
capture_write_bytes(11);
join
check(capture_count === 11,
"Data packet: captured 11 bytes");
// Dump captured bytes to file
data_file = $fopen("data_packet.txt", "w");
$fwrite(data_file, "# byte_index hex_value\n");
for (i = 0; i < capture_count; i = i + 1) begin
$fwrite(data_file, "%0d %02x\n", i, captured_bytes[i]);
end
$fclose(data_file);
// Verify locally too
check(captured_bytes[0] === 8'hAA,
"Data pkt: byte 0 = 0xAA (header)");
check(captured_bytes[1] === 8'hCA,
"Data pkt: byte 1 = 0xCA (range MSB = Q high)");
check(captured_bytes[2] === 8'hFE,
"Data pkt: byte 2 = 0xFE (range Q low)");
check(captured_bytes[3] === 8'hBE,
"Data pkt: byte 3 = 0xBE (range I high)");
check(captured_bytes[4] === 8'hEF,
"Data pkt: byte 4 = 0xEF (range I low)");
check(captured_bytes[5] === 8'h12,
"Data pkt: byte 5 = 0x12 (doppler_real MSB)");
check(captured_bytes[6] === 8'h34,
"Data pkt: byte 6 = 0x34 (doppler_real LSB)");
check(captured_bytes[7] === 8'h56,
"Data pkt: byte 7 = 0x56 (doppler_imag MSB)");
check(captured_bytes[8] === 8'h78,
"Data pkt: byte 8 = 0x78 (doppler_imag LSB)");
check(captured_bytes[9] === 8'h01,
"Data pkt: byte 9 = 0x01 (cfar_detection=1)");
check(captured_bytes[10] === 8'h55,
"Data pkt: byte 10 = 0x55 (footer)");
// ============================================================
// EXERCISE C: Status Packet Generation
// Set known status values, trigger readback, capture 26 bytes.
// Uses distinctive non-zero values to detect truncation/swap.
// ============================================================
$display("\n=== EXERCISE C: Status Packet Generation ===");
apply_reset;
ft_txe_n = 0;
// Set known distinctive status values
status_cfar_threshold = 16'hABCD;
status_stream_ctrl = 3'b101;
status_radar_mode = 2'b11; // Use 0b11 to test both bits
status_long_chirp = 16'h1234;
status_long_listen = 16'h5678;
status_guard = 16'h9ABC;
status_short_chirp = 16'hDEF0;
status_short_listen = 16'hFACE;
status_chirps_per_elev = 6'd42;
status_range_mode = 2'b10;
status_self_test_flags = 5'b10101;
status_self_test_detail = 8'hA5;
status_self_test_busy = 1'b1;
status_agc_current_gain = 4'd7;
status_agc_peak_magnitude = 8'd200;
status_agc_saturation_count = 8'd15;
status_agc_enable = 1'b1;
// Pulse status_request and capture bytes IN PARALLEL
// (same reason as Exercise B — write FSM starts before CDC wait ends)
fork
pulse_status_request;
capture_write_bytes(26);
join
check(capture_count === 26,
"Status packet: captured 26 bytes");
// Dump captured bytes to file
status_file = $fopen("status_packet.txt", "w");
$fwrite(status_file, "# byte_index hex_value\n");
for (i = 0; i < capture_count; i = i + 1) begin
$fwrite(status_file, "%0d %02x\n", i, captured_bytes[i]);
end
// Also dump the raw status_words for debugging
$fwrite(status_file, "# status_words (internal):\n");
for (i = 0; i < 6; i = i + 1) begin
$fwrite(status_file, "# word[%0d] = %08x\n", i, uut.status_words[i]);
end
$fclose(status_file);
// Verify header/footer locally
check(captured_bytes[0] === 8'hBB,
"Status pkt: byte 0 = 0xBB (status header)");
check(captured_bytes[25] === 8'h55,
"Status pkt: byte 25 = 0x55 (footer)");
// Verify status_words[1] = {long_chirp, long_listen} = {0x1234, 0x5678}
check(captured_bytes[5] === 8'h12 && captured_bytes[6] === 8'h34 &&
captured_bytes[7] === 8'h56 && captured_bytes[8] === 8'h78,
"Status pkt: word1 = {long_chirp=0x1234, long_listen=0x5678}");
// Verify status_words[2] = {guard, short_chirp} = {0x9ABC, 0xDEF0}
check(captured_bytes[9] === 8'h9A && captured_bytes[10] === 8'hBC &&
captured_bytes[11] === 8'hDE && captured_bytes[12] === 8'hF0,
"Status pkt: word2 = {guard=0x9ABC, short_chirp=0xDEF0}");
// ============================================================
// Summary
// ============================================================
$display("");
$display("========================================");
$display(" CROSS-LAYER FT2232H TB RESULTS");
$display(" PASSED: %0d / %0d", pass_count, test_num);
$display(" FAILED: %0d / %0d", fail_count, test_num);
if (fail_count == 0)
$display(" ** ALL TESTS PASSED **");
else
$display(" ** SOME TESTS FAILED **");
$display("========================================");
#100;
$finish;
end
endmodule
@@ -0,0 +1,828 @@
"""
Cross-Layer Contract Tests
==========================
Single pytest file orchestrating three tiers of verification:
Tier 1 Static Contract Parsing:
Compares Python, Verilog, and C source code at parse-time to catch
opcode mismatches, bit-width errors, packet constant drift, and
layout bugs like the status_words[0] 37-bit truncation.
Tier 2 Verilog Cosimulation (iverilog):
Compiles and runs tb_cross_layer_ft2232h.v, then parses its output
files (cmd_results.txt, data_packet.txt, status_packet.txt) and
runs Python parsers on the captured bytes to verify round-trip
correctness.
Tier 3 C Stub Execution:
Compiles stm32_settings_stub.cpp, generates a binary settings
packet from Python, runs the stub, and verifies all parsed field
values match.
The goal is to find UNKNOWN bugs by testing each layer against
independently-derived ground truth not just checking that two
layers agree (because both could be wrong).
"""
from __future__ import annotations
import os
import struct
import subprocess
import tempfile
from pathlib import Path
import pytest
# Import the contract parsers
import sys
THIS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(THIS_DIR))
import contract_parser as cp # noqa: E402
# Also add the GUI dir to import radar_protocol
sys.path.insert(0, str(cp.GUI_DIR))
# ===================================================================
# Helpers
# ===================================================================
IVERILOG = os.environ.get("IVERILOG", "/opt/homebrew/bin/iverilog")
VVP = os.environ.get("VVP", "/opt/homebrew/bin/vvp")
CXX = os.environ.get("CXX", "c++")
# Check tool availability for conditional skipping
_has_iverilog = Path(IVERILOG).exists() if "/" in IVERILOG else bool(
subprocess.run(["which", IVERILOG], capture_output=True).returncode == 0
)
_has_cxx = subprocess.run(
[CXX, "--version"], capture_output=True
).returncode == 0
def _parse_hex_results(text: str) -> list[dict[str, str]]:
"""Parse space-separated hex lines from TB output files."""
rows = []
for line in text.strip().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
rows.append(line.split())
return rows
# ===================================================================
# Ground Truth: FPGA register map (independently transcribed)
# ===================================================================
# This is the SINGLE SOURCE OF TRUTH, manually transcribed from
# radar_system_top.v lines 902-945. If any layer disagrees with
# this, it's a bug in that layer.
GROUND_TRUTH_OPCODES = {
0x01: ("host_radar_mode", 2),
0x02: ("host_trigger_pulse", 1), # pulse
0x03: ("host_detect_threshold", 16),
0x04: ("host_stream_control", 3),
0x10: ("host_long_chirp_cycles", 16),
0x11: ("host_long_listen_cycles", 16),
0x12: ("host_guard_cycles", 16),
0x13: ("host_short_chirp_cycles", 16),
0x14: ("host_short_listen_cycles", 16),
0x15: ("host_chirps_per_elev", 6),
0x16: ("host_gain_shift", 4),
0x20: ("host_range_mode", 2),
0x21: ("host_cfar_guard", 4),
0x22: ("host_cfar_train", 5),
0x23: ("host_cfar_alpha", 8),
0x24: ("host_cfar_mode", 2),
0x25: ("host_cfar_enable", 1),
0x26: ("host_mti_enable", 1),
0x27: ("host_dc_notch_width", 3),
0x28: ("host_agc_enable", 1),
0x29: ("host_agc_target", 8),
0x2A: ("host_agc_attack", 4),
0x2B: ("host_agc_decay", 4),
0x2C: ("host_agc_holdoff", 4),
0x30: ("host_self_test_trigger", 1), # pulse
0x31: ("host_status_request", 1), # pulse
0xFF: ("host_status_request", 1), # alias, pulse
}
GROUND_TRUTH_RESET_DEFAULTS = {
"host_radar_mode": 1, # 2'b01
"host_detect_threshold": 10000,
"host_stream_control": 7, # 3'b111
"host_long_chirp_cycles": 3000,
"host_long_listen_cycles": 13700,
"host_guard_cycles": 17540,
"host_short_chirp_cycles": 50,
"host_short_listen_cycles": 17450,
"host_chirps_per_elev": 32,
"host_gain_shift": 0,
"host_range_mode": 0,
"host_cfar_guard": 2,
"host_cfar_train": 8,
"host_cfar_alpha": 0x30,
"host_cfar_mode": 0,
"host_cfar_enable": 0,
"host_mti_enable": 0,
"host_dc_notch_width": 0,
"host_agc_enable": 0,
"host_agc_target": 200,
"host_agc_attack": 1,
"host_agc_decay": 1,
"host_agc_holdoff": 4,
}
GROUND_TRUTH_PACKET_CONSTANTS = {
"data": {"header": 0xAA, "footer": 0x55, "size": 11},
"status": {"header": 0xBB, "footer": 0x55, "size": 26},
}
# ===================================================================
# TIER 1: Static Contract Parsing
# ===================================================================
class TestTier1OpcodeContract:
"""Verify Python and Verilog opcode sets match ground truth."""
def test_python_opcodes_match_ground_truth(self):
"""Every Python Opcode must exist in ground truth with correct value."""
py_opcodes = cp.parse_python_opcodes()
for val, entry in py_opcodes.items():
assert val in GROUND_TRUTH_OPCODES, (
f"Python Opcode {entry.name}=0x{val:02X} not in ground truth! "
f"Possible phantom opcode (like the 0x06 incident)."
)
def test_ground_truth_opcodes_in_python(self):
"""Every ground truth opcode must have a Python enum entry."""
py_opcodes = cp.parse_python_opcodes()
for val, (reg, _width) in GROUND_TRUTH_OPCODES.items():
assert val in py_opcodes, (
f"Ground truth opcode 0x{val:02X} ({reg}) missing from Python Opcode enum."
)
def test_verilog_opcodes_match_ground_truth(self):
"""Every Verilog case entry must exist in ground truth."""
v_opcodes = cp.parse_verilog_opcodes()
for val, entry in v_opcodes.items():
assert val in GROUND_TRUTH_OPCODES, (
f"Verilog opcode 0x{val:02X} ({entry.register}) not in ground truth."
)
def test_ground_truth_opcodes_in_verilog(self):
"""Every ground truth opcode must have a Verilog case entry."""
v_opcodes = cp.parse_verilog_opcodes()
for val, (reg, _width) in GROUND_TRUTH_OPCODES.items():
assert val in v_opcodes, (
f"Ground truth opcode 0x{val:02X} ({reg}) missing from Verilog case statement."
)
def test_python_verilog_bidirectional_match(self):
"""Python and Verilog must have the same set of opcode values."""
py_set = set(cp.parse_python_opcodes().keys())
v_set = set(cp.parse_verilog_opcodes().keys())
py_only = py_set - v_set
v_only = v_set - py_set
assert not py_only, f"Opcodes in Python but not Verilog: {[hex(x) for x in py_only]}"
assert not v_only, f"Opcodes in Verilog but not Python: {[hex(x) for x in v_only]}"
def test_verilog_register_names_match(self):
"""Verilog case target registers must match ground truth names."""
v_opcodes = cp.parse_verilog_opcodes()
for val, (expected_reg, _) in GROUND_TRUTH_OPCODES.items():
if val in v_opcodes:
actual_reg = v_opcodes[val].register
assert actual_reg == expected_reg, (
f"Opcode 0x{val:02X}: Verilog writes to '{actual_reg}' "
f"but ground truth says '{expected_reg}'"
)
class TestTier1BitWidths:
"""Verify register widths and opcode bit slices match ground truth."""
def test_verilog_register_widths(self):
"""Register declarations must match ground truth bit widths."""
v_widths = cp.parse_verilog_register_widths()
for reg, expected_width in [
(name, w) for _, (name, w) in GROUND_TRUTH_OPCODES.items()
]:
if reg in v_widths:
actual = v_widths[reg]
assert actual >= expected_width, (
f"{reg}: declared {actual}-bit but ground truth says {expected_width}-bit"
)
def test_verilog_opcode_bit_slices(self):
"""Opcode case assignments must use correct bit widths from cmd_value."""
v_opcodes = cp.parse_verilog_opcodes()
for val, (reg, expected_width) in GROUND_TRUTH_OPCODES.items():
if val not in v_opcodes:
continue
entry = v_opcodes[val]
if entry.is_pulse:
continue # Pulse opcodes don't use cmd_value slicing
if entry.bit_width > 0:
assert entry.bit_width >= expected_width, (
f"Opcode 0x{val:02X} ({reg}): bit slice {entry.bit_slice} "
f"= {entry.bit_width}-bit, expected >= {expected_width}"
)
class TestTier1StatusWordTruncation:
"""Verify each status_words[] concatenation is exactly 32 bits."""
def test_status_words_concat_widths_ft2232h(self):
"""Each status_words[] concat must be EXACTLY 32 bits."""
port_widths = cp.get_usb_interface_port_widths(
cp.FPGA_DIR / "usb_data_interface_ft2232h.v"
)
concats = cp.parse_verilog_status_word_concats(
cp.FPGA_DIR / "usb_data_interface_ft2232h.v"
)
for idx, expr in concats.items():
result = cp.count_concat_bits(expr, port_widths)
if result.total_bits < 0:
pytest.skip(f"status_words[{idx}]: unknown signal width")
assert result.total_bits == 32, (
f"status_words[{idx}] is {result.total_bits} bits, not 32! "
f"{'TRUNCATION' if result.total_bits > 32 else 'UNDERFLOW'} BUG. "
f"Fragments: {result.fragments}"
)
def test_status_words_concat_widths_ft601(self):
"""Same check for the FT601 interface (same bug expected)."""
ft601_path = cp.FPGA_DIR / "usb_data_interface.v"
if not ft601_path.exists():
pytest.skip("FT601 interface file not found")
port_widths = cp.get_usb_interface_port_widths(ft601_path)
concats = cp.parse_verilog_status_word_concats(ft601_path)
for idx, expr in concats.items():
result = cp.count_concat_bits(expr, port_widths)
if result.total_bits < 0:
pytest.skip(f"status_words[{idx}]: unknown signal width")
assert result.total_bits == 32, (
f"FT601 status_words[{idx}] is {result.total_bits} bits, not 32! "
f"{'TRUNCATION' if result.total_bits > 32 else 'UNDERFLOW'} BUG. "
f"Fragments: {result.fragments}"
)
class TestTier1StatusFieldPositions:
"""Verify Python status parser bit positions match Verilog layout."""
def test_python_status_mode_position(self):
"""
Verify Python reads radar_mode at the correct bit position matching
the Verilog status_words[0] layout:
{0xFF[31:24], mode[23:22], stream[21:19], 3'b000[18:16], threshold[15:0]}
"""
# Get what Python thinks
py_fields = cp.parse_python_status_fields()
mode_field = next((f for f in py_fields if f.name == "radar_mode"), None)
assert mode_field is not None, "radar_mode not found in parse_status_packet"
# Ground truth: mode is at bits [23:22], so LSB = 22
expected_shift = 22
actual_shift = mode_field.lsb
assert actual_shift == expected_shift, (
f"Python reads radar_mode at bit {actual_shift} "
f"but Verilog status_words[0] has mode at bit {expected_shift}."
)
class TestTier1PacketConstants:
"""Verify packet header/footer/size constants match across layers."""
def test_python_packet_constants(self):
"""Python constants match ground truth."""
py = cp.parse_python_packet_constants()
for ptype, expected in GROUND_TRUTH_PACKET_CONSTANTS.items():
assert py[ptype].header == expected["header"], (
f"Python {ptype} header: 0x{py[ptype].header:02X} != 0x{expected['header']:02X}"
)
assert py[ptype].footer == expected["footer"], (
f"Python {ptype} footer: 0x{py[ptype].footer:02X} != 0x{expected['footer']:02X}"
)
assert py[ptype].size == expected["size"], (
f"Python {ptype} size: {py[ptype].size} != {expected['size']}"
)
def test_verilog_packet_constants(self):
"""Verilog localparams match ground truth."""
v = cp.parse_verilog_packet_constants()
for ptype, expected in GROUND_TRUTH_PACKET_CONSTANTS.items():
assert v[ptype].header == expected["header"], (
f"Verilog {ptype} header: 0x{v[ptype].header:02X} != 0x{expected['header']:02X}"
)
assert v[ptype].footer == expected["footer"], (
f"Verilog {ptype} footer: 0x{v[ptype].footer:02X} != 0x{expected['footer']:02X}"
)
assert v[ptype].size == expected["size"], (
f"Verilog {ptype} size: {v[ptype].size} != {expected['size']}"
)
def test_python_verilog_constants_agree(self):
"""Python and Verilog packet constants must match each other."""
py = cp.parse_python_packet_constants()
v = cp.parse_verilog_packet_constants()
for ptype in ("data", "status"):
assert py[ptype].header == v[ptype].header
assert py[ptype].footer == v[ptype].footer
assert py[ptype].size == v[ptype].size
class TestTier1ResetDefaults:
"""Verify Verilog reset defaults match ground truth."""
def test_verilog_reset_defaults(self):
"""Reset block values must match ground truth."""
v_defaults = cp.parse_verilog_reset_defaults()
for reg, expected in GROUND_TRUTH_RESET_DEFAULTS.items():
assert reg in v_defaults, f"{reg} not found in reset block"
actual = v_defaults[reg]
assert actual == expected, (
f"{reg}: reset default {actual} != expected {expected}"
)
class TestTier1DataPacketLayout:
"""Verify data packet byte layout matches between Python and Verilog."""
def test_verilog_data_mux_field_positions(self):
"""Verilog data_pkt_byte mux must have correct byte positions."""
v_fields = cp.parse_verilog_data_mux()
# Expected: range_profile at bytes 1-4 (32-bit), doppler_real 5-6,
# doppler_imag 7-8, cfar 9
field_map = {f.name: f for f in v_fields}
assert "range_profile" in field_map
rp = field_map["range_profile"]
assert rp.byte_start == 1 and rp.byte_end == 4 and rp.width_bits == 32
assert "doppler_real" in field_map
dr = field_map["doppler_real"]
assert dr.byte_start == 5 and dr.byte_end == 6 and dr.width_bits == 16
assert "doppler_imag" in field_map
di = field_map["doppler_imag"]
assert di.byte_start == 7 and di.byte_end == 8 and di.width_bits == 16
def test_python_data_packet_byte_positions(self):
"""Python parse_data_packet byte offsets must be correct."""
py_fields = cp.parse_python_data_packet_fields()
# range_q at offset 1 (2B), range_i at offset 3 (2B),
# doppler_i at offset 5 (2B), doppler_q at offset 7 (2B),
# detection at offset 9
field_map = {f.name: f for f in py_fields}
assert "range_q" in field_map
assert field_map["range_q"].byte_start == 1
assert "range_i" in field_map
assert field_map["range_i"].byte_start == 3
assert "doppler_i" in field_map
assert field_map["doppler_i"].byte_start == 5
assert "doppler_q" in field_map
assert field_map["doppler_q"].byte_start == 7
assert "detection" in field_map
assert field_map["detection"].byte_start == 9
class TestTier1STM32SettingsPacket:
"""Verify STM32 settings packet layout."""
def test_field_order_and_sizes(self):
"""STM32 settings fields must have correct offsets and sizes."""
fields = cp.parse_stm32_settings_fields()
if not fields:
pytest.skip("MCU source not available")
expected = [
("system_frequency", 0, 8, "double"),
("chirp_duration_1", 8, 8, "double"),
("chirp_duration_2", 16, 8, "double"),
("chirps_per_position", 24, 4, "uint32_t"),
("freq_min", 28, 8, "double"),
("freq_max", 36, 8, "double"),
("prf1", 44, 8, "double"),
("prf2", 52, 8, "double"),
("max_distance", 60, 8, "double"),
("map_size", 68, 8, "double"),
]
assert len(fields) == len(expected), (
f"Expected {len(expected)} fields, got {len(fields)}"
)
for f, (ename, eoff, esize, etype) in zip(fields, expected, strict=True):
assert f.name == ename, f"Field name: {f.name} != {ename}"
assert f.offset == eoff, f"{f.name}: offset {f.offset} != {eoff}"
assert f.size == esize, f"{f.name}: size {f.size} != {esize}"
assert f.c_type == etype, f"{f.name}: type {f.c_type} != {etype}"
def test_minimum_packet_size(self):
"""
RadarSettings.cpp says minimum is 74 bytes but actual payload is:
'SET'(3) + 9*8(doubles) + 4(uint32) + 'END'(3) = 82 bytes.
This test documents the bug.
"""
fields = cp.parse_stm32_settings_fields()
if not fields:
pytest.skip("MCU source not available")
# Calculate required payload size
total_field_bytes = sum(f.size for f in fields)
# Add markers: "SET"(3) + "END"(3)
required_size = 3 + total_field_bytes + 3
# Read the actual minimum check from the source
src = (cp.MCU_LIB_DIR / "RadarSettings.cpp").read_text(encoding="latin-1")
import re
m = re.search(r'length\s*<\s*(\d+)', src)
assert m, "Could not find minimum length check in parseFromUSB"
declared_min = int(m.group(1))
assert declared_min == required_size, (
f"BUFFER OVERREAD BUG: parseFromUSB minimum check is {declared_min} "
f"but actual required size is {required_size}. "
f"({total_field_bytes} bytes of fields + 6 bytes of markers). "
f"If exactly {declared_min} bytes are passed, extractDouble() reads "
f"past the buffer at offset {declared_min - 3} (needs 8 bytes, "
f"only {declared_min - 3 - fields[-1].offset} available)."
)
def test_stm32_usb_start_flag(self):
"""USB start flag must be [23, 46, 158, 237]."""
flag = cp.parse_stm32_start_flag()
if not flag:
pytest.skip("USBHandler.cpp not available")
assert flag == [23, 46, 158, 237], f"Start flag: {flag}"
# ===================================================================
# TIER 2: Verilog Cosimulation
# ===================================================================
@pytest.mark.skipif(not _has_iverilog, reason="iverilog not available")
class TestTier2VerilogCosim:
"""Compile and run the FT2232H TB, validate output against Python parsers."""
@pytest.fixture(scope="class")
def tb_results(self, tmp_path_factory):
"""Compile and run TB once, return output file contents."""
workdir = tmp_path_factory.mktemp("verilog_cosim")
tb_path = THIS_DIR / "tb_cross_layer_ft2232h.v"
rtl_path = cp.FPGA_DIR / "usb_data_interface_ft2232h.v"
out_bin = workdir / "tb_cross_layer_ft2232h"
# Compile
result = subprocess.run(
[IVERILOG, "-o", str(out_bin), "-I", str(cp.FPGA_DIR),
str(tb_path), str(rtl_path)],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"iverilog compile failed:\n{result.stderr}"
# Run
result = subprocess.run(
[VVP, str(out_bin)],
capture_output=True, text=True, timeout=60,
cwd=str(workdir),
)
assert result.returncode == 0, f"vvp failed:\n{result.stderr}"
# Parse output
return {
"stdout": result.stdout,
"cmd_results": (workdir / "cmd_results.txt").read_text(),
"data_packet": (workdir / "data_packet.txt").read_text(),
"status_packet": (workdir / "status_packet.txt").read_text(),
}
def test_all_tb_tests_pass(self, tb_results):
"""All Verilog TB internal checks must pass."""
stdout = tb_results["stdout"]
assert "ALL TESTS PASSED" in stdout, f"TB had failures:\n{stdout}"
def test_command_round_trip(self, tb_results):
"""Verify every command decoded correctly by matching sent vs received."""
rows = _parse_hex_results(tb_results["cmd_results"])
assert len(rows) >= 20, f"Expected >= 20 command results, got {len(rows)}"
for row in rows:
assert len(row) == 6, f"Bad row format: {row}"
sent_op, sent_addr, sent_val = row[0], row[1], row[2]
got_op, got_addr, got_val = row[3], row[4], row[5]
assert sent_op == got_op, (
f"Opcode mismatch: sent 0x{sent_op} got 0x{got_op}"
)
assert sent_addr == got_addr, (
f"Addr mismatch: sent 0x{sent_addr} got 0x{got_addr}"
)
assert sent_val == got_val, (
f"Value mismatch: sent 0x{sent_val} got 0x{got_val}"
)
def test_data_packet_python_round_trip(self, tb_results):
"""
Take the 11 bytes captured by the Verilog TB, run Python's
parse_data_packet() on them, verify the parsed values match
what was injected into the TB.
"""
from radar_protocol import RadarProtocol
rows = _parse_hex_results(tb_results["data_packet"])
assert len(rows) == 11, f"Expected 11 data packet bytes, got {len(rows)}"
# Reconstruct raw bytes
raw = bytes(int(row[1], 16) for row in rows)
assert len(raw) == 11
parsed = RadarProtocol.parse_data_packet(raw)
assert parsed is not None, "parse_data_packet returned None"
# The TB injected: range_profile = 0xCAFE_BEEF = {Q=0xCAFE, I=0xBEEF}
# doppler_real = 0x1234, doppler_imag = 0x5678
# cfar_detection = 1
#
# range_q = 0xCAFE → signed = 0xCAFE - 0x10000 = -13570
# range_i = 0xBEEF → signed = 0xBEEF - 0x10000 = -16657
# doppler_i = 0x1234 → signed = 4660
# doppler_q = 0x5678 → signed = 22136
assert parsed["range_q"] == (0xCAFE - 0x10000), (
f"range_q: {parsed['range_q']} != {0xCAFE - 0x10000}"
)
assert parsed["range_i"] == (0xBEEF - 0x10000), (
f"range_i: {parsed['range_i']} != {0xBEEF - 0x10000}"
)
assert parsed["doppler_i"] == 0x1234, (
f"doppler_i: {parsed['doppler_i']} != {0x1234}"
)
assert parsed["doppler_q"] == 0x5678, (
f"doppler_q: {parsed['doppler_q']} != {0x5678}"
)
assert parsed["detection"] == 1, (
f"detection: {parsed['detection']} != 1"
)
def test_status_packet_python_round_trip(self, tb_results):
"""
Take the 26 bytes captured by the Verilog TB, run Python's
parse_status_packet() on them, verify against injected values.
"""
from radar_protocol import RadarProtocol
lines = tb_results["status_packet"].strip().splitlines()
# Filter out comments and status_words debug lines
rows = []
for line in lines:
line = line.strip()
if not line or line.startswith("#"):
continue
rows.append(line.split())
assert len(rows) == 26, f"Expected 26 status bytes, got {len(rows)}"
raw = bytes(int(row[1], 16) for row in rows)
assert len(raw) == 26
sr = RadarProtocol.parse_status_packet(raw)
assert sr is not None, "parse_status_packet returned None"
# Injected values (from TB):
# status_cfar_threshold = 0xABCD
# status_stream_ctrl = 3'b101 = 5
# status_radar_mode = 2'b11 = 3
# status_long_chirp = 0x1234
# status_long_listen = 0x5678
# status_guard = 0x9ABC
# status_short_chirp = 0xDEF0
# status_short_listen = 0xFACE
# status_chirps_per_elev = 42
# status_range_mode = 2'b10 = 2
# status_self_test_flags = 5'b10101 = 21
# status_self_test_detail = 0xA5
# status_self_test_busy = 1
# status_agc_current_gain = 7
# status_agc_peak_magnitude = 200
# status_agc_saturation_count = 15
# status_agc_enable = 1
# Words 1-5 should be correct (no truncation bug)
assert sr.cfar_threshold == 0xABCD, f"cfar_threshold: 0x{sr.cfar_threshold:04X}"
assert sr.long_chirp == 0x1234, f"long_chirp: 0x{sr.long_chirp:04X}"
assert sr.long_listen == 0x5678, f"long_listen: 0x{sr.long_listen:04X}"
assert sr.guard == 0x9ABC, f"guard: 0x{sr.guard:04X}"
assert sr.short_chirp == 0xDEF0, f"short_chirp: 0x{sr.short_chirp:04X}"
assert sr.short_listen == 0xFACE, f"short_listen: 0x{sr.short_listen:04X}"
assert sr.chirps_per_elev == 42, f"chirps_per_elev: {sr.chirps_per_elev}"
assert sr.range_mode == 2, f"range_mode: {sr.range_mode}"
assert sr.self_test_flags == 21, f"self_test_flags: {sr.self_test_flags}"
assert sr.self_test_detail == 0xA5, f"self_test_detail: 0x{sr.self_test_detail:02X}"
assert sr.self_test_busy == 1, f"self_test_busy: {sr.self_test_busy}"
# AGC fields (word 4)
assert sr.agc_current_gain == 7, f"agc_current_gain: {sr.agc_current_gain}"
assert sr.agc_peak_magnitude == 200, f"agc_peak_magnitude: {sr.agc_peak_magnitude}"
assert sr.agc_saturation_count == 15, f"agc_saturation_count: {sr.agc_saturation_count}"
assert sr.agc_enable == 1, f"agc_enable: {sr.agc_enable}"
# Word 0: stream_ctrl should be 5 (3'b101)
assert sr.stream_ctrl == 5, (
f"stream_ctrl: {sr.stream_ctrl} != 5. "
f"Check status_words[0] bit positions."
)
# radar_mode should be 3 (2'b11)
assert sr.radar_mode == 3, (
f"radar_mode={sr.radar_mode} != 3. "
f"Check status_words[0] bit positions."
)
# ===================================================================
# TIER 3: C Stub Execution
# ===================================================================
@pytest.mark.skipif(not _has_cxx, reason="C++ compiler not available")
class TestTier3CStub:
"""Compile STM32 settings stub and verify field parsing."""
@pytest.fixture(scope="class")
def stub_binary(self, tmp_path_factory):
"""Compile the C++ stub once."""
workdir = tmp_path_factory.mktemp("c_stub")
stub_src = THIS_DIR / "stm32_settings_stub.cpp"
radar_settings_src = cp.MCU_LIB_DIR / "RadarSettings.cpp"
out_bin = workdir / "stm32_settings_stub"
result = subprocess.run(
[CXX, "-std=c++11", "-o", str(out_bin),
str(stub_src), str(radar_settings_src),
f"-I{cp.MCU_LIB_DIR}"],
capture_output=True, text=True, timeout=30,
)
assert result.returncode == 0, f"Compile failed:\n{result.stderr}"
return out_bin
def _build_settings_packet(self, values: dict) -> bytes:
"""Build a binary settings packet matching RadarSettings::parseFromUSB."""
pkt = b"SET"
for key in [
"system_frequency", "chirp_duration_1", "chirp_duration_2",
]:
pkt += struct.pack(">d", values[key])
pkt += struct.pack(">I", values["chirps_per_position"])
for key in [
"freq_min", "freq_max", "prf1", "prf2",
"max_distance", "map_size",
]:
pkt += struct.pack(">d", values[key])
pkt += b"END"
return pkt
def _run_stub(self, binary: Path, packet: bytes) -> dict[str, str]:
"""Run stub with packet file, parse stdout into field dict."""
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
f.write(packet)
pkt_path = f.name
try:
result = subprocess.run(
[str(binary), pkt_path],
capture_output=True, text=True, timeout=10,
)
finally:
os.unlink(pkt_path)
fields = {}
for line in result.stdout.strip().splitlines():
if "=" in line:
k, v = line.split("=", 1)
fields[k.strip()] = v.strip()
return fields
def test_default_values_round_trip(self, stub_binary):
"""Default settings must parse correctly through C stub."""
values = {
"system_frequency": 10.0e9,
"chirp_duration_1": 30.0e-6,
"chirp_duration_2": 0.5e-6,
"chirps_per_position": 32,
"freq_min": 10.0e6,
"freq_max": 30.0e6,
"prf1": 1000.0,
"prf2": 2000.0,
"max_distance": 50000.0,
"map_size": 50000.0,
}
pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "true", f"Parse failed: {result}"
for key, expected in values.items():
actual_str = result.get(key)
assert actual_str is not None, f"Missing field: {key}"
actual = int(actual_str) if key == "chirps_per_position" else float(actual_str)
if isinstance(expected, float):
assert abs(actual - expected) < expected * 1e-10, (
f"{key}: {actual} != {expected}"
)
else:
assert actual == expected, f"{key}: {actual} != {expected}"
def test_distinctive_values_round_trip(self, stub_binary):
"""Non-default distinctive values must parse correctly."""
values = {
"system_frequency": 24.125e9, # K-band
"chirp_duration_1": 100.0e-6,
"chirp_duration_2": 2.0e-6,
"chirps_per_position": 64,
"freq_min": 24.0e6,
"freq_max": 24.25e6,
"prf1": 5000.0,
"prf2": 3000.0,
"max_distance": 75000.0,
"map_size": 100000.0,
}
pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "true", f"Parse failed: {result}"
for key, expected in values.items():
actual_str = result.get(key)
assert actual_str is not None, f"Missing field: {key}"
actual = int(actual_str) if key == "chirps_per_position" else float(actual_str)
if isinstance(expected, float):
assert abs(actual - expected) < expected * 1e-10, (
f"{key}: {actual} != {expected}"
)
else:
assert actual == expected, f"{key}: {actual} != {expected}"
def test_truncated_packet_rejected(self, stub_binary):
"""Packet shorter than minimum must be rejected."""
pkt = b"SET" + b"\x00" * 40 + b"END" # Only 46 bytes, needs 82
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "false", (
f"Expected parse failure for truncated packet, got: {result}"
)
def test_bad_markers_rejected(self, stub_binary):
"""Packet with wrong start/end markers must be rejected."""
values = {
"system_frequency": 10.0e9, "chirp_duration_1": 30.0e-6,
"chirp_duration_2": 0.5e-6, "chirps_per_position": 32,
"freq_min": 10.0e6, "freq_max": 30.0e6,
"prf1": 1000.0, "prf2": 2000.0,
"max_distance": 50000.0, "map_size": 50000.0,
}
pkt = self._build_settings_packet(values)
# Wrong start marker
bad_pkt = b"BAD" + pkt[3:]
result = self._run_stub(stub_binary, bad_pkt)
assert result.get("parse_ok") == "false", "Should reject bad start marker"
# Wrong end marker
bad_pkt = pkt[:-3] + b"BAD"
result = self._run_stub(stub_binary, bad_pkt)
assert result.get("parse_ok") == "false", "Should reject bad end marker"
def test_python_c_packet_format_agreement(self, stub_binary):
"""
Python builds a settings packet, C stub parses it.
This tests that both sides agree on the packet format.
"""
# Use values right at validation boundaries to stress-test
values = {
"system_frequency": 1.0e9, # min valid
"chirp_duration_1": 1.0e-6, # min valid
"chirp_duration_2": 0.1e-6, # min valid
"chirps_per_position": 1, # min valid
"freq_min": 1.0e6, # min valid
"freq_max": 2.0e6, # just above freq_min
"prf1": 100.0, # min valid
"prf2": 100.0, # min valid
"max_distance": 100.0, # min valid
"map_size": 1000.0, # min valid
}
pkt = self._build_settings_packet(values)
result = self._run_stub(stub_binary, pkt)
assert result.get("parse_ok") == "true", (
f"Boundary values rejected: {result}"
)
+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"]