4578621c75
- Restored print() output in 6 generator/cosim scripts that ruff T20 had silently stripped, leaving dead 'for _var: pass' stubs and orphaned expressions. Files restored from pre-ruff commit and re-linted with T20/ERA/ARG/E501 per-file-ignores. - Removed 5 dead/self-blessing scripts (compare.py, compare_doppler.py, compare_mf.py, validate_mem_files.py, LUT.py). - Added test_mem_validation.py: 60 pytest tests validating .mem files against independently-derived ground truth (twiddle factors, chirp waveforms, memory addressing, segment padding). - Updated CI cross-layer-tests job to include test_mem_validation.py. - All 150 tests pass (61 GUI + 29 cross-layer + 60 mem validation).
445 lines
18 KiB
Python
445 lines
18 KiB
Python
"""
|
|
test_mem_validation.py — Validate FPGA .mem files against AERIS-10 radar parameters.
|
|
|
|
Migrated from tb/cosim/validate_mem_files.py into CI-friendly pytest tests.
|
|
|
|
Checks:
|
|
1. Structural: line counts, hex format, value ranges for all 12+ .mem files
|
|
2. FFT twiddle files: bit-exact match against cos(2*pi*k/N) in Q15
|
|
3. Long chirp .mem files: frequency sweep, magnitude envelope, segment count
|
|
4. Short chirp .mem files: length, value range, non-zero content
|
|
5. Chirp vs independent model: phase shape agreement
|
|
6. Latency buffer LATENCY=3187 parameter validation
|
|
7. Chirp memory loader addressing: {segment_select, sample_addr} arithmetic
|
|
8. Seg3 zero-padding analysis
|
|
"""
|
|
|
|
import math
|
|
import os
|
|
import warnings
|
|
|
|
import pytest
|
|
|
|
# ============================================================================
|
|
# AERIS-10 System Parameters (independently derived from hardware specs)
|
|
# ============================================================================
|
|
F_CARRIER = 10.5e9 # 10.5 GHz carrier
|
|
C_LIGHT = 3.0e8
|
|
F_IF = 120e6 # IF frequency
|
|
CHIRP_BW = 20e6 # 20 MHz sweep bandwidth
|
|
FS_ADC = 400e6 # ADC sample rate
|
|
FS_SYS = 100e6 # System clock (100 MHz, after CIC 4x decimation)
|
|
T_LONG_CHIRP = 30e-6 # 30 us long chirp
|
|
T_SHORT_CHIRP = 0.5e-6 # 0.5 us short chirp
|
|
CIC_DECIMATION = 4
|
|
FFT_SIZE = 1024
|
|
DOPPLER_FFT_SIZE = 16
|
|
LONG_CHIRP_SAMPLES = int(T_LONG_CHIRP * FS_SYS) # 3000 at 100 MHz
|
|
|
|
# Overlap-save parameters
|
|
OVERLAP_SAMPLES = 128
|
|
SEGMENT_ADVANCE = FFT_SIZE - OVERLAP_SAMPLES # 896
|
|
LONG_SEGMENTS = 4
|
|
|
|
# Path to FPGA RTL directory containing .mem files
|
|
MEM_DIR = os.path.normpath(os.path.join(os.path.dirname(__file__), '..', '..', '9_2_FPGA'))
|
|
|
|
# Expected .mem file inventory
|
|
EXPECTED_MEM_FILES = {
|
|
'fft_twiddle_1024.mem': {'lines': 256, 'desc': '1024-pt FFT quarter-wave cos ROM'},
|
|
'fft_twiddle_16.mem': {'lines': 4, 'desc': '16-pt FFT quarter-wave cos ROM'},
|
|
'long_chirp_seg0_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 I'},
|
|
'long_chirp_seg0_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 0 Q'},
|
|
'long_chirp_seg1_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 I'},
|
|
'long_chirp_seg1_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 1 Q'},
|
|
'long_chirp_seg2_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 I'},
|
|
'long_chirp_seg2_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 2 Q'},
|
|
'long_chirp_seg3_i.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 I'},
|
|
'long_chirp_seg3_q.mem': {'lines': 1024, 'desc': 'Long chirp seg 3 Q'},
|
|
'short_chirp_i.mem': {'lines': 50, 'desc': 'Short chirp I'},
|
|
'short_chirp_q.mem': {'lines': 50, 'desc': 'Short chirp Q'},
|
|
}
|
|
|
|
|
|
def read_mem_hex(filename: str) -> list[int]:
|
|
"""Read a .mem file, return list of integer values (16-bit signed)."""
|
|
path = os.path.join(MEM_DIR, filename)
|
|
values = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('//'):
|
|
continue
|
|
val = int(line, 16)
|
|
if val >= 0x8000:
|
|
val -= 0x10000
|
|
values.append(val)
|
|
return values
|
|
|
|
|
|
def compute_magnitudes(i_vals: list[int], q_vals: list[int]) -> list[float]:
|
|
"""Compute magnitude envelope from I/Q sample lists."""
|
|
return [math.sqrt(i * i + q * q) for i, q in zip(i_vals, q_vals, strict=False)]
|
|
|
|
|
|
def compute_inst_freq(i_vals: list[int], q_vals: list[int],
|
|
fs: float, mag_thresh: float = 5.0) -> list[float]:
|
|
"""Compute instantaneous frequency from I/Q via phase differencing."""
|
|
phases = []
|
|
for i_val, q_val in zip(i_vals, q_vals, strict=False):
|
|
if abs(i_val) > mag_thresh or abs(q_val) > mag_thresh:
|
|
phases.append(math.atan2(q_val, i_val))
|
|
else:
|
|
phases.append(None)
|
|
|
|
freq_estimates = []
|
|
for n in range(1, len(phases)):
|
|
if phases[n] is not None and phases[n - 1] is not None:
|
|
dp = phases[n] - phases[n - 1]
|
|
while dp > math.pi:
|
|
dp -= 2 * math.pi
|
|
while dp < -math.pi:
|
|
dp += 2 * math.pi
|
|
freq_estimates.append(dp * fs / (2 * math.pi))
|
|
return freq_estimates
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 1: Structural validation — all .mem files exist with correct sizes
|
|
# ============================================================================
|
|
class TestStructural:
|
|
"""Verify every expected .mem file exists, has the right line count, and valid values."""
|
|
|
|
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
|
|
ids=EXPECTED_MEM_FILES.keys())
|
|
def test_file_exists(self, fname, info):
|
|
path = os.path.join(MEM_DIR, fname)
|
|
assert os.path.isfile(path), f"{fname} missing from {MEM_DIR}"
|
|
|
|
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
|
|
ids=EXPECTED_MEM_FILES.keys())
|
|
def test_line_count(self, fname, info):
|
|
vals = read_mem_hex(fname)
|
|
assert len(vals) == info['lines'], (
|
|
f"{fname}: got {len(vals)} data lines, expected {info['lines']}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("fname,info", EXPECTED_MEM_FILES.items(),
|
|
ids=EXPECTED_MEM_FILES.keys())
|
|
def test_value_range(self, fname, info):
|
|
vals = read_mem_hex(fname)
|
|
for i, v in enumerate(vals):
|
|
assert -32768 <= v <= 32767, (
|
|
f"{fname}[{i}]: value {v} out of 16-bit signed range"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 2: FFT Twiddle Factor Validation (bit-exact against cos formula)
|
|
# ============================================================================
|
|
class TestTwiddle:
|
|
"""Verify FFT twiddle .mem files match cos(2*pi*k/N) in Q15 to <=1 LSB."""
|
|
|
|
def test_twiddle_1024_bit_exact(self):
|
|
vals = read_mem_hex('fft_twiddle_1024.mem')
|
|
assert len(vals) == 256, f"Expected 256 quarter-wave entries, got {len(vals)}"
|
|
|
|
max_err = 0
|
|
worst_k = -1
|
|
for k in range(256):
|
|
angle = 2.0 * math.pi * k / 1024.0
|
|
expected = max(-32768, min(32767, round(math.cos(angle) * 32767.0)))
|
|
err = abs(vals[k] - expected)
|
|
if err > max_err:
|
|
max_err = err
|
|
worst_k = k
|
|
|
|
assert max_err <= 1, (
|
|
f"fft_twiddle_1024.mem: max error {max_err} LSB at k={worst_k} "
|
|
f"(got {vals[worst_k]}, expected "
|
|
f"{max(-32768, min(32767, round(math.cos(2*math.pi*worst_k/1024)*32767)))})"
|
|
)
|
|
|
|
def test_twiddle_16_bit_exact(self):
|
|
vals = read_mem_hex('fft_twiddle_16.mem')
|
|
assert len(vals) == 4, f"Expected 4 quarter-wave entries, got {len(vals)}"
|
|
|
|
max_err = 0
|
|
for k in range(4):
|
|
angle = 2.0 * math.pi * k / 16.0
|
|
expected = max(-32768, min(32767, round(math.cos(angle) * 32767.0)))
|
|
err = abs(vals[k] - expected)
|
|
if err > max_err:
|
|
max_err = err
|
|
|
|
assert max_err <= 1, f"fft_twiddle_16.mem: max error {max_err} LSB (tolerance: 1)"
|
|
|
|
def test_twiddle_1024_known_values(self):
|
|
"""Spot-check specific twiddle values against hand-calculated results."""
|
|
vals = read_mem_hex('fft_twiddle_1024.mem')
|
|
# k=0: cos(0) = 1.0 -> 32767
|
|
assert vals[0] == 32767, f"k=0: expected 32767, got {vals[0]}"
|
|
# k=128: cos(pi/4) = sqrt(2)/2 -> round(32767 * 0.7071) = 23170
|
|
expected_128 = round(math.cos(2 * math.pi * 128 / 1024) * 32767)
|
|
assert abs(vals[128] - expected_128) <= 1, (
|
|
f"k=128: expected ~{expected_128}, got {vals[128]}"
|
|
)
|
|
# k=255: last entry in quarter-wave table
|
|
expected_255 = round(math.cos(2 * math.pi * 255 / 1024) * 32767)
|
|
assert abs(vals[255] - expected_255) <= 1, (
|
|
f"k=255: expected ~{expected_255}, got {vals[255]}"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 3: Long Chirp .mem File Analysis
|
|
# ============================================================================
|
|
class TestLongChirp:
|
|
"""Validate long chirp .mem files show correct chirp characteristics."""
|
|
|
|
def test_total_sample_count(self):
|
|
"""4 segments x 1024 samples = 4096 total."""
|
|
all_i, all_q = [], []
|
|
for seg in range(4):
|
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
|
assert len(all_i) == 4096, f"Total I samples: {len(all_i)}, expected 4096"
|
|
assert len(all_q) == 4096, f"Total Q samples: {len(all_q)}, expected 4096"
|
|
|
|
def test_nonzero_magnitude(self):
|
|
"""Chirp should have significant non-zero content."""
|
|
all_i, all_q = [], []
|
|
for seg in range(4):
|
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
|
mags = compute_magnitudes(all_i, all_q)
|
|
max_mag = max(mags)
|
|
# Should use substantial dynamic range (at least 1000 out of 32767)
|
|
assert max_mag > 1000, f"Max magnitude {max_mag:.0f} is suspiciously low"
|
|
|
|
def test_frequency_sweep(self):
|
|
"""Chirp should show at least 0.5 MHz frequency sweep."""
|
|
all_i, all_q = [], []
|
|
for seg in range(4):
|
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
|
|
|
freq_est = compute_inst_freq(all_i, all_q, FS_SYS)
|
|
assert len(freq_est) > 100, "Not enough valid phase samples for frequency analysis"
|
|
|
|
f_range = max(freq_est) - min(freq_est)
|
|
assert f_range > 0.5e6, (
|
|
f"Frequency sweep {f_range / 1e6:.2f} MHz is too narrow "
|
|
f"(expected > 0.5 MHz for a chirp)"
|
|
)
|
|
|
|
def test_bandwidth_reasonable(self):
|
|
"""Chirp bandwidth should be within 50% of expected 20 MHz."""
|
|
all_i, all_q = [], []
|
|
for seg in range(4):
|
|
all_i.extend(read_mem_hex(f'long_chirp_seg{seg}_i.mem'))
|
|
all_q.extend(read_mem_hex(f'long_chirp_seg{seg}_q.mem'))
|
|
|
|
freq_est = compute_inst_freq(all_i, all_q, FS_SYS)
|
|
if not freq_est:
|
|
pytest.skip("No valid frequency estimates")
|
|
|
|
f_range = max(freq_est) - min(freq_est)
|
|
bw_error = abs(f_range - CHIRP_BW) / CHIRP_BW
|
|
if bw_error >= 0.5:
|
|
warnings.warn(
|
|
f"Bandwidth {f_range / 1e6:.2f} MHz differs from expected "
|
|
f"{CHIRP_BW / 1e6:.2f} MHz by {bw_error:.0%}",
|
|
stacklevel=1,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 4: Short Chirp .mem File Analysis
|
|
# ============================================================================
|
|
class TestShortChirp:
|
|
"""Validate short chirp .mem files."""
|
|
|
|
def test_sample_count_matches_duration(self):
|
|
"""0.5 us at 100 MHz = 50 samples."""
|
|
short_i = read_mem_hex('short_chirp_i.mem')
|
|
short_q = read_mem_hex('short_chirp_q.mem')
|
|
expected = int(T_SHORT_CHIRP * FS_SYS)
|
|
assert len(short_i) == expected, f"Short chirp I: {len(short_i)} != {expected}"
|
|
assert len(short_q) == expected, f"Short chirp Q: {len(short_q)} != {expected}"
|
|
|
|
def test_all_samples_nonzero(self):
|
|
"""Every sample in the short chirp should have non-trivial magnitude."""
|
|
short_i = read_mem_hex('short_chirp_i.mem')
|
|
short_q = read_mem_hex('short_chirp_q.mem')
|
|
mags = compute_magnitudes(short_i, short_q)
|
|
nonzero = sum(1 for m in mags if m > 1)
|
|
assert nonzero == len(short_i), (
|
|
f"Only {nonzero}/{len(short_i)} samples are non-zero"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 5: Chirp vs Independent Model (phase shape agreement)
|
|
# ============================================================================
|
|
class TestChirpVsModel:
|
|
"""Compare seg0 against independently generated chirp reference."""
|
|
|
|
def test_phase_shape_match(self):
|
|
"""Phase trajectory of .mem seg0 should match model within 0.5 rad."""
|
|
# Generate reference chirp independently from first principles
|
|
chirp_rate = CHIRP_BW / T_LONG_CHIRP # Hz/s
|
|
n_samples = FFT_SIZE # 1024
|
|
|
|
model_i, model_q = [], []
|
|
for n in range(n_samples):
|
|
t = n / FS_SYS
|
|
phase = math.pi * chirp_rate * t * t
|
|
re_val = max(-32768, min(32767, round(32767 * 0.9 * math.cos(phase))))
|
|
im_val = max(-32768, min(32767, round(32767 * 0.9 * math.sin(phase))))
|
|
model_i.append(re_val)
|
|
model_q.append(im_val)
|
|
|
|
# Read seg0 from .mem
|
|
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
|
|
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
|
|
|
|
# Compare phase trajectories (shape match regardless of scaling)
|
|
model_phases = [math.atan2(q, i) for i, q in zip(model_i, model_q, strict=False)]
|
|
mem_phases = [math.atan2(q, i) for i, q in zip(mem_i, mem_q, strict=False)]
|
|
|
|
phase_diffs = []
|
|
for mp, fp in zip(model_phases, mem_phases, strict=False):
|
|
d = mp - fp
|
|
while d > math.pi:
|
|
d -= 2 * math.pi
|
|
while d < -math.pi:
|
|
d += 2 * math.pi
|
|
phase_diffs.append(d)
|
|
|
|
max_phase_diff = max(abs(d) for d in phase_diffs)
|
|
assert max_phase_diff < 0.5, (
|
|
f"Max phase difference {math.degrees(max_phase_diff):.1f} deg "
|
|
f"exceeds 28.6 deg tolerance"
|
|
)
|
|
|
|
def test_magnitude_scaling(self):
|
|
"""Seg0 magnitude should be consistent with Q15 * 0.9 scaling."""
|
|
mem_i = read_mem_hex('long_chirp_seg0_i.mem')
|
|
mem_q = read_mem_hex('long_chirp_seg0_q.mem')
|
|
mags = compute_magnitudes(mem_i, mem_q)
|
|
max_mag = max(mags)
|
|
|
|
# Expected from 32767 * 0.9 scaling = ~29490
|
|
expected_max = 32767 * 0.9
|
|
# Should be at least 80% of expected (allows for different provenance)
|
|
if max_mag < expected_max * 0.8:
|
|
warnings.warn(
|
|
f"Seg0 max magnitude {max_mag:.0f} is below expected "
|
|
f"{expected_max:.0f} * 0.8 = {expected_max * 0.8:.0f}. "
|
|
f"The .mem files may have different provenance.",
|
|
stacklevel=1,
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 6: Latency Buffer LATENCY=3187 Validation
|
|
# ============================================================================
|
|
class TestLatencyBuffer:
|
|
"""Validate latency buffer parameter constraints."""
|
|
|
|
LATENCY = 3187
|
|
BRAM_SIZE = 4096
|
|
|
|
def test_latency_within_bram(self):
|
|
assert self.LATENCY < self.BRAM_SIZE, (
|
|
f"LATENCY ({self.LATENCY}) must be < BRAM size ({self.BRAM_SIZE})"
|
|
)
|
|
|
|
def test_latency_in_reasonable_range(self):
|
|
"""LATENCY should be between 1000 and 4095 (empirically determined)."""
|
|
assert 1000 < self.LATENCY < 4095, (
|
|
f"LATENCY={self.LATENCY} outside reasonable range [1000, 4095]"
|
|
)
|
|
|
|
def test_read_ptr_no_overflow(self):
|
|
"""Address arithmetic for read_ptr after initial wrap must stay valid."""
|
|
min_read_ptr = self.BRAM_SIZE + 0 - self.LATENCY
|
|
assert 0 <= min_read_ptr < self.BRAM_SIZE, (
|
|
f"min_read_ptr after wrap = {min_read_ptr}, must be in [0, {self.BRAM_SIZE})"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 7: Chirp Memory Loader Addressing
|
|
# ============================================================================
|
|
class TestMemoryAddressing:
|
|
"""Validate {segment_select[1:0], sample_addr[9:0]} address mapping."""
|
|
|
|
@pytest.mark.parametrize("seg", range(4), ids=[f"seg{s}" for s in range(4)])
|
|
def test_segment_base_address(self, seg):
|
|
"""Concatenated address {seg, 10'b0} should equal seg * 1024."""
|
|
addr = (seg << 10) | 0
|
|
expected = seg * 1024
|
|
assert addr == expected, (
|
|
f"Seg {seg}: {{seg[1:0], 10'b0}} = {addr}, expected {expected}"
|
|
)
|
|
|
|
@pytest.mark.parametrize("seg", range(4), ids=[f"seg{s}" for s in range(4)])
|
|
def test_segment_end_address(self, seg):
|
|
"""Concatenated address {seg, 10'h3FF} should equal seg * 1024 + 1023."""
|
|
addr = (seg << 10) | 1023
|
|
expected = seg * 1024 + 1023
|
|
assert addr == expected, (
|
|
f"Seg {seg}: {{seg[1:0], 10'h3FF}} = {addr}, expected {expected}"
|
|
)
|
|
|
|
def test_full_address_space(self):
|
|
"""4 segments x 1024 = 4096 addresses, covering full 12-bit range."""
|
|
all_addrs = set()
|
|
for seg in range(4):
|
|
for sample in range(1024):
|
|
all_addrs.add((seg << 10) | sample)
|
|
assert len(all_addrs) == 4096
|
|
assert min(all_addrs) == 0
|
|
assert max(all_addrs) == 4095
|
|
|
|
|
|
# ============================================================================
|
|
# TEST 8: Seg3 Zero-Padding Analysis
|
|
# ============================================================================
|
|
class TestSeg3Padding:
|
|
"""Analyze seg3 content — chirp is 3000 samples but 4 segs x 1024 = 4096 slots."""
|
|
|
|
def test_seg3_content_analysis(self):
|
|
"""Seg3 should either be full (4096-sample chirp) or have trailing zeros."""
|
|
seg3_i = read_mem_hex('long_chirp_seg3_i.mem')
|
|
seg3_q = read_mem_hex('long_chirp_seg3_q.mem')
|
|
mags = compute_magnitudes(seg3_i, seg3_q)
|
|
|
|
# Count trailing zeros
|
|
trailing_zeros = 0
|
|
for m in reversed(mags):
|
|
if m < 2:
|
|
trailing_zeros += 1
|
|
else:
|
|
break
|
|
|
|
nonzero = sum(1 for m in mags if m > 2)
|
|
|
|
if nonzero == 1024:
|
|
# .mem files encode 4096 chirp samples, not 3000
|
|
# This means the chirp duration used for .mem generation differs
|
|
actual_samples = 4 * 1024
|
|
actual_us = actual_samples / FS_SYS * 1e6
|
|
warnings.warn(
|
|
f"Chirp in .mem files is {actual_samples} samples ({actual_us:.1f} us), "
|
|
f"not {LONG_CHIRP_SAMPLES} samples ({T_LONG_CHIRP * 1e6:.1f} us). "
|
|
f"The .mem files use a different chirp duration than the system parameter.",
|
|
stacklevel=1,
|
|
)
|
|
elif trailing_zeros > 100:
|
|
# Some zero-padding at end — chirp ends partway through seg3
|
|
effective_chirp_end = 3072 + (1024 - trailing_zeros)
|
|
assert effective_chirp_end <= 4096, "Chirp end calculation overflow"
|