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:
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)
|
||||||
@@ -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.5–11.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 it’s 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,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
|
||||||
|
|||||||
@@ -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'
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// NCO→DSP 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)
|
||||||
|
|||||||
@@ -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 NCO→DSP 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;
|
||||||
|
// NCO→DSP 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 CLK→P to fabric timing path
|
// Post-DSP retiming registers — breaks DSP48E1 CLK→P 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 CLK→P delay internally, preventing fabric timing violations
|
// PREG=1 absorbs DSP48E1 CLK→P 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|
||||||
|
// FPGA→STM32 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 (H11→PD13): AGC saturation flag (1=clipping detected)
|
||||||
|
output wire gpio_dig6, // DIG_6 (G12→PD14): reserved (tied low)
|
||||||
|
output wire gpio_dig7 // DIG_7 (H12→PD15): 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
// ===== FPGA→STM32 GPIO (Bank 15: 3.3V) =====
|
||||||
|
output wire gpio_dig5, // DIG_5 (H11→PD13): AGC saturation flag
|
||||||
|
output wire gpio_dig6, // DIG_6 (G12→PD14): reserved
|
||||||
|
output wire gpio_dig7 // DIG_7 (H12→PD15): 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),
|
||||||
|
|
||||||
|
// ----- FPGA→STM32 GPIO (DIG_5..DIG_7) -----
|
||||||
|
.gpio_dig5 (gpio_dig5),
|
||||||
|
.gpio_dig6 (gpio_dig6),
|
||||||
|
.gpio_dig7 (gpio_dig7)
|
||||||
);
|
);
|
||||||
|
|
||||||
endmodule
|
endmodule
|
||||||
|
|||||||
@@ -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 0→1 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}]
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
|
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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__':
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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, 2→1)");
|
||||||
|
|
||||||
|
// ----- 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 1→0)");
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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()
|
|
||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 (FPGA→Host):
|
TX (FPGA→Host):
|
||||||
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 (Host→FPGA):
|
RX (Host→FPGA):
|
||||||
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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
+815
-394
File diff suppressed because it is too large
Load Diff
@@ -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
|
|
||||||
|
|||||||
@@ -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)+'°</span></div>'
|
'<span class="popup-value">'+az+'°</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)+'°</span></div>'
|
'<span class="popup-value">'+el+'°</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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user