Files
PLFM_RADAR/9_Firmware/tests/cross_layer/test_mem_validation.py
T
Jason 4578621c75 fix: restore T20-stripped print() calls in cosim scripts; add 60 mem validation tests
- 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).
2026-04-13 20:36:28 +05:45

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"