1459 lines
53 KiB
Python
1459 lines
53 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Bit-accurate Python model of the AERIS-10 FPGA signal processing chain.
|
|
|
|
Mirrors the RTL fixed-point arithmetic exactly:
|
|
NCO -> Mixer -> CIC -> FIR -> Matched Filter -> Range Decimation -> Doppler
|
|
|
|
All operations use Python integers to match Verilog's exact bit-level behavior.
|
|
No floating point is used in signal processing (only for verification/display).
|
|
|
|
Usage:
|
|
from fpga_model import SignalChain
|
|
chain = SignalChain()
|
|
# Feed ADC samples one at a time or in batches
|
|
chain.nco_step(ftw, phase_offset=0)
|
|
...
|
|
|
|
Author: Phase 0.5 co-simulation suite for PLFM_RADAR
|
|
"""
|
|
|
|
import os
|
|
import struct
|
|
|
|
# =============================================================================
|
|
# Fixed-point utility functions
|
|
# =============================================================================
|
|
|
|
def sign_extend(value, bits):
|
|
"""Sign-extend a `bits`-wide integer to full Python int."""
|
|
mask = (1 << bits) - 1
|
|
value = value & mask
|
|
if value & (1 << (bits - 1)):
|
|
return value - (1 << bits)
|
|
return value
|
|
|
|
|
|
def to_unsigned(value, bits):
|
|
"""Convert signed Python int to unsigned representation in `bits` width."""
|
|
mask = (1 << bits) - 1
|
|
return value & mask
|
|
|
|
|
|
def saturate(value, bits):
|
|
"""Saturate a value to signed `bits`-wide range [-2^(bits-1), 2^(bits-1)-1]."""
|
|
max_pos = (1 << (bits - 1)) - 1
|
|
max_neg = -(1 << (bits - 1))
|
|
if value > max_pos:
|
|
return max_pos
|
|
if value < max_neg:
|
|
return max_neg
|
|
return value
|
|
|
|
|
|
def arith_rshift(value, shift, width=None):
|
|
"""Arithmetic right shift. Python >> on signed int is already arithmetic."""
|
|
return value >> shift
|
|
|
|
|
|
# =============================================================================
|
|
# NCO: Numerically Controlled Oscillator (6-stage pipeline)
|
|
# =============================================================================
|
|
|
|
# Quarter-wave sine LUT (64 entries, 16-bit unsigned)
|
|
# Matches nco_400m_enhanced.v exactly
|
|
NCO_SINE_LUT = [
|
|
0x0000, 0x0324, 0x0648, 0x096A, 0x0C8C, 0x0FAB, 0x12C8, 0x15E2,
|
|
0x18F9, 0x1C0B, 0x1F1A, 0x2223, 0x2528, 0x2826, 0x2B1F, 0x2E11,
|
|
0x30FB, 0x33DF, 0x36BA, 0x398C, 0x3C56, 0x3F17, 0x41CE, 0x447A,
|
|
0x471C, 0x49B4, 0x4C3F, 0x4EBF, 0x5133, 0x539B, 0x55F5, 0x5842,
|
|
0x5A82, 0x5CB3, 0x5ED7, 0x60EB, 0x62F1, 0x64E8, 0x66CF, 0x68A6,
|
|
0x6A6D, 0x6C23, 0x6DC9, 0x6F5E, 0x70E2, 0x7254, 0x73B5, 0x7504,
|
|
0x7641, 0x776B, 0x7884, 0x7989, 0x7A7C, 0x7B5C, 0x7C29, 0x7CE3,
|
|
0x7D89, 0x7E1D, 0x7E9C, 0x7F09, 0x7F61, 0x7FA6, 0x7FD8, 0x7FF5,
|
|
]
|
|
|
|
|
|
class NCO:
|
|
"""
|
|
Bit-accurate model of nco_400m_enhanced.v
|
|
|
|
6-stage pipeline:
|
|
Stage 1: phase_accumulator += ftw; phase_accum_reg = prev accumulator
|
|
Stage 2: phase_with_offset = phase_accum_reg + {phase_offset, 16'b0}
|
|
Stage 3a: Register LUT index + quadrant from phase_with_offset
|
|
Stage 3b: LUT read using registered index -> register abs + quadrant
|
|
Stage 4: Compute negations
|
|
Stage 5: Quadrant sign MUX -> sin_out, cos_out
|
|
|
|
CREG=1 on DSP48E1: The behavioral sim model captures the accumulator
|
|
BEFORE adding the new FTW (phase_accum_reg = old accumulator), then
|
|
uses that old value for offset addition next cycle. This introduces
|
|
a 2-cycle pipeline delay from input to phase_with_offset.
|
|
|
|
Latency: 6 cycles from phase_valid to dds_ready
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.phase_accumulator = 0 # 32-bit
|
|
self.phase_accum_reg = 0 # Stage 1 output
|
|
self.phase_with_offset = 0 # Stage 2 output
|
|
|
|
# Stage 3a
|
|
self.lut_index_pipe = 0 # 6-bit
|
|
self.quadrant_pipe = 0 # 2-bit
|
|
|
|
# Stage 3b
|
|
self.sin_abs_reg = 0 # 16-bit unsigned
|
|
self.cos_abs_reg = 0x7FFF # 16-bit unsigned
|
|
self.quadrant_reg = 0 # 2-bit
|
|
|
|
# Stage 4
|
|
self.sin_neg_reg = 0 # 16-bit signed
|
|
self.cos_neg_reg = sign_extend((-0x7FFF) & 0xFFFF, 16)
|
|
self.sin_abs_reg2 = 0
|
|
self.cos_abs_reg2 = 0x7FFF
|
|
self.quadrant_reg2 = 0
|
|
|
|
# Stage 5 outputs
|
|
self.sin_out = 0 # 16-bit signed
|
|
self.cos_out = 0x7FFF # 16-bit signed
|
|
|
|
# Valid pipeline
|
|
self.valid_pipe = 0 # 6-bit shift register
|
|
self.dds_ready = False
|
|
|
|
def _quadrant_and_index(self, phase_with_offset):
|
|
"""Compute quadrant and LUT index from phase_with_offset[31:24]."""
|
|
lut_address = (phase_with_offset >> 24) & 0xFF
|
|
quadrant = (lut_address >> 6) & 0x3
|
|
raw_index = lut_address & 0x3F
|
|
|
|
# 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) & 0x3F
|
|
else:
|
|
lut_index = raw_index
|
|
|
|
return quadrant, lut_index
|
|
|
|
def step(self, ftw, phase_offset=0, phase_valid=True):
|
|
"""
|
|
Advance one clock cycle.
|
|
|
|
Args:
|
|
ftw: 32-bit frequency tuning word
|
|
phase_offset: 16-bit phase offset
|
|
phase_valid: enable signal
|
|
|
|
Returns:
|
|
(sin_out, cos_out, dds_ready) as signed 16-bit integers + bool
|
|
"""
|
|
ftw = ftw & 0xFFFFFFFF
|
|
phase_offset = phase_offset & 0xFFFF
|
|
|
|
if phase_valid:
|
|
# ---- Stage 1: Phase accumulator (behavioral sim model) ----
|
|
# phase_accum_reg captures OLD accumulator value BEFORE update
|
|
old_accum = self.phase_accumulator
|
|
self.phase_accumulator = (self.phase_accumulator + ftw) & 0xFFFFFFFF
|
|
self.phase_accum_reg = old_accum
|
|
|
|
# ---- Stage 2: Offset addition (uses PREVIOUS cycle's phase_accum_reg) ----
|
|
# The RTL does: phase_with_offset <= phase_accum_reg + {phase_offset, 16'b0}
|
|
# But in blocking assignment order, phase_accum_reg was just updated above.
|
|
# In Verilog NBA semantics: both happen simultaneously, so phase_with_offset
|
|
# uses the OLD phase_accum_reg (from previous cycle), not the one just assigned.
|
|
# We need to capture the PREVIOUS phase_accum_reg for this:
|
|
# Actually re-reading the RTL more carefully:
|
|
# phase_accumulator <= phase_accumulator + ftw; (new accum)
|
|
# phase_accum_reg <= phase_accumulator; (NBA: uses OLD accum)
|
|
# phase_with_offset <= phase_accum_reg + offset; (NBA: uses OLD phase_accum_reg)
|
|
# So we need to track the pre-update values.
|
|
# Let's fix by computing in proper NBA order:
|
|
|
|
# We already captured old_accum above. But phase_with_offset uses the
|
|
# OLD phase_accum_reg (the value from the PREVIOUS call).
|
|
# We stored self.phase_accum_reg at the start of this call as the
|
|
# value from last cycle. So:
|
|
pass # phase_with_offset computed below from OLD values
|
|
|
|
# Compute all NBA assignments from OLD state:
|
|
# Save old state for NBA evaluation
|
|
old_phase_accum_reg = self.phase_accum_reg
|
|
old_phase_with_offset = self.phase_with_offset
|
|
old_lut_index_pipe = self.lut_index_pipe
|
|
old_quadrant_pipe = self.quadrant_pipe
|
|
old_sin_abs_reg = self.sin_abs_reg
|
|
old_cos_abs_reg = self.cos_abs_reg
|
|
old_quadrant_reg = self.quadrant_reg
|
|
old_sin_neg_reg = self.sin_neg_reg
|
|
old_cos_neg_reg = self.cos_neg_reg
|
|
old_sin_abs_reg2 = self.sin_abs_reg2
|
|
old_cos_abs_reg2 = self.cos_abs_reg2
|
|
old_quadrant_reg2 = self.quadrant_reg2
|
|
old_valid_pipe = self.valid_pipe
|
|
|
|
if phase_valid:
|
|
# Stage 1 NBA: phase_accum_reg <= phase_accumulator (old value)
|
|
new_phase_accum_reg = (self.phase_accumulator - ftw) & 0xFFFFFFFF # old accum before add
|
|
# 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
|
|
self.phase_accum_reg = old_phase_accumulator
|
|
self.phase_with_offset = (old_phase_accum_reg + ((phase_offset << 16) & 0xFFFFFFFF)) & 0xFFFFFFFF
|
|
# phase_accumulator was already updated above
|
|
|
|
# ---- Stage 3a: Register LUT address + quadrant ----
|
|
# Gated by valid_pipe[1]
|
|
if (old_valid_pipe >> 1) & 1:
|
|
quadrant_w, lut_index_w = self._quadrant_and_index(old_phase_with_offset)
|
|
self.lut_index_pipe = lut_index_w
|
|
self.quadrant_pipe = quadrant_w
|
|
|
|
# ---- Stage 3b: LUT read + register ----
|
|
# Gated by valid_pipe[2]
|
|
if (old_valid_pipe >> 2) & 1:
|
|
self.sin_abs_reg = NCO_SINE_LUT[old_lut_index_pipe]
|
|
self.cos_abs_reg = NCO_SINE_LUT[63 - old_lut_index_pipe]
|
|
self.quadrant_reg = old_quadrant_pipe
|
|
|
|
# ---- Stage 4: Negation ----
|
|
# Gated by valid_pipe[3]
|
|
if (old_valid_pipe >> 3) & 1:
|
|
self.sin_neg_reg = sign_extend((-old_sin_abs_reg) & 0xFFFF, 16)
|
|
self.cos_neg_reg = sign_extend((-old_cos_abs_reg) & 0xFFFF, 16)
|
|
self.sin_abs_reg2 = old_sin_abs_reg
|
|
self.cos_abs_reg2 = old_cos_abs_reg
|
|
self.quadrant_reg2 = old_quadrant_reg
|
|
|
|
# ---- Stage 5: Quadrant MUX ----
|
|
# Gated by valid_pipe[4]
|
|
if (old_valid_pipe >> 4) & 1:
|
|
q = old_quadrant_reg2
|
|
if q == 0: # Q1: sin+, cos+
|
|
self.sin_out = sign_extend(old_sin_abs_reg2, 16)
|
|
self.cos_out = sign_extend(old_cos_abs_reg2, 16)
|
|
elif q == 1: # Q2: sin+, cos-
|
|
self.sin_out = sign_extend(old_sin_abs_reg2, 16)
|
|
self.cos_out = old_cos_neg_reg
|
|
elif q == 2: # Q3: sin-, cos-
|
|
self.sin_out = old_sin_neg_reg
|
|
self.cos_out = old_cos_neg_reg
|
|
elif q == 3: # Q4: sin-, cos+
|
|
self.sin_out = old_sin_neg_reg
|
|
self.cos_out = sign_extend(old_cos_abs_reg2, 16)
|
|
|
|
# ---- Valid pipeline ----
|
|
self.valid_pipe = ((old_valid_pipe << 1) | (1 if phase_valid else 0)) & 0x3F
|
|
self.dds_ready = bool((old_valid_pipe >> 5) & 1)
|
|
|
|
return self.sin_out, self.cos_out, self.dds_ready
|
|
|
|
|
|
# =============================================================================
|
|
# Mixer: DSP48E1 multiply with 3-cycle pipeline (AREG+MREG+PREG)
|
|
# =============================================================================
|
|
|
|
class Mixer:
|
|
"""
|
|
Bit-accurate model of ddc_400m mixer.
|
|
|
|
ADC 8-bit unsigned -> 18-bit signed conversion:
|
|
adc_signed = {1'b0, adc_data, 9'b0} - {1'b0, 8'hFF, 9'b0} / 2
|
|
This is effectively: adc_signed = (adc_data << 9) - (0xFF << 9) / 2
|
|
But the Verilog expression is:
|
|
{1'b0, adc_data, {9{1'b0}}} - {1'b0, {8{1'b1}}, {9{1'b0}}} / 2
|
|
|
|
Then mixed_i = adc_signed * cos_out (18-bit * 16-bit = 34-bit product)
|
|
CIC input = mixed_i[33:16] (top 18 bits of 34-bit product)
|
|
|
|
3-cycle DSP48E1 pipeline: AREG -> MREG -> PREG
|
|
"""
|
|
|
|
def __init__(self):
|
|
# Stage 1 (AREG/BREG)
|
|
self.adc_signed_reg = 0 # 18-bit signed
|
|
self.cos_pipe_reg = 0 # 16-bit signed
|
|
self.sin_pipe_reg = 0 # 16-bit signed
|
|
|
|
# Stage 2 (MREG)
|
|
self.mult_i_internal = 0 # 34-bit signed
|
|
self.mult_q_internal = 0 # 34-bit signed
|
|
|
|
# Stage 3 (PREG)
|
|
self.mult_i_reg = 0 # 34-bit signed
|
|
self.mult_q_reg = 0 # 34-bit signed
|
|
|
|
# Valid pipeline
|
|
self.valid_pipe = 0 # 3-bit
|
|
|
|
@staticmethod
|
|
def adc_to_signed(adc_data_8bit):
|
|
"""
|
|
Convert 8-bit unsigned ADC to 18-bit signed.
|
|
RTL: adc_signed_w = {1'b0, adc_data, {9{1'b0}}} -
|
|
{1'b0, {8{1'b1}}, {9{1'b0}}} / 2
|
|
= (adc_data << 9) - (0xFF << 9) / 2
|
|
= (adc_data << 9) - (0xFF << 8) [integer division]
|
|
= (adc_data << 9) - 0x7F80
|
|
"""
|
|
adc_data_8bit = adc_data_8bit & 0xFF
|
|
# {1'b0, adc_data, 9'b0} = adc_data << 9, zero-padded to 18 bits
|
|
term1 = adc_data_8bit << 9
|
|
# {1'b0, 8'hFF, 9'b0} / 2 = (0xFF << 9) / 2 = 0xFF << 8 (integer division)
|
|
# But actually in Verilog: {1'b0, {8{1'b1}}, {9{1'b0}}} = 17'b0_11111111_000000000
|
|
# = 0x1FE00 ... / 2 = 0xFF00
|
|
# Wait: {1'b0, 8'hFF, 9'b0} = 0_11111111_000000000 = 0x1FE00 (18 bits)
|
|
# Divided by 2 = 0xFF00 = 65280
|
|
term2 = 0xFF00
|
|
result = (term1 - term2) & 0x3FFFF # 18-bit mask
|
|
return sign_extend(result, 18)
|
|
|
|
def step(self, adc_data, cos_out, sin_out, nco_ready, adc_valid):
|
|
"""
|
|
Advance one clock cycle.
|
|
|
|
Returns:
|
|
(mixed_i_top18, mixed_q_top18, mixed_valid)
|
|
where mixed_i_top18 = mixed_i[33:16]
|
|
"""
|
|
adc_signed_w = self.adc_to_signed(adc_data)
|
|
|
|
# Save old state for NBA
|
|
old_adc_signed_reg = self.adc_signed_reg
|
|
old_cos_pipe_reg = self.cos_pipe_reg
|
|
old_sin_pipe_reg = self.sin_pipe_reg
|
|
old_mult_i_internal = self.mult_i_internal
|
|
old_mult_q_internal = self.mult_q_internal
|
|
old_valid_pipe = self.valid_pipe
|
|
|
|
# Stage 1: AREG/BREG (always clocked, no valid gating)
|
|
self.adc_signed_reg = adc_signed_w
|
|
self.cos_pipe_reg = sign_extend(cos_out & 0xFFFF, 16)
|
|
self.sin_pipe_reg = sign_extend(sin_out & 0xFFFF, 16)
|
|
|
|
# Stage 2: MREG
|
|
self.mult_i_internal = old_adc_signed_reg * old_cos_pipe_reg
|
|
self.mult_q_internal = old_adc_signed_reg * old_sin_pipe_reg
|
|
|
|
# Stage 3: PREG
|
|
self.mult_i_reg = old_mult_i_internal
|
|
self.mult_q_reg = old_mult_q_internal
|
|
|
|
# Valid pipeline
|
|
valid_in = 1 if (nco_ready and adc_valid) else 0
|
|
self.valid_pipe = ((old_valid_pipe << 1) | valid_in) & 0x7
|
|
|
|
mixed_valid = bool((old_valid_pipe >> 2) & 1)
|
|
|
|
# CIC gets mixed_i[33:16] — top 18 bits of 34-bit product
|
|
# This is equivalent to arithmetic right shift by 16, then take 18 LSBs
|
|
mixed_i_top18 = sign_extend((self.mult_i_reg >> 16) & 0x3FFFF, 18) if mixed_valid else 0
|
|
mixed_q_top18 = sign_extend((self.mult_q_reg >> 16) & 0x3FFFF, 18) if mixed_valid else 0
|
|
|
|
return mixed_i_top18, mixed_q_top18, mixed_valid
|
|
|
|
|
|
# =============================================================================
|
|
# CIC Decimator (5-stage, 4x decimation, DSP48E1 PCOUT cascade)
|
|
# =============================================================================
|
|
|
|
class CICDecimator:
|
|
"""
|
|
Bit-accurate model of cic_decimator_4x_enhanced.v
|
|
|
|
5-stage CIC with 4x decimation.
|
|
Integrators: 48-bit wrapping arithmetic (modular).
|
|
CREG=1 on integrator_0: data_in_c_delayed lags by 1 cycle.
|
|
Comb: 28-bit, COMB_DELAY=1, 5 stages.
|
|
Output: >>10 scaling, saturate to 18-bit [-131072, +131071].
|
|
|
|
The comb section has a 3-stage pipeline:
|
|
Stage 1: comb computations + temp_scaled_output = comb[4] >>> 10
|
|
Stage 2: saturation comparison flags (sat_pos, sat_neg) + temp_output
|
|
Stage 3: MUX from flags -> data_out
|
|
"""
|
|
|
|
STAGES = 5
|
|
DECIMATION = 4
|
|
COMB_DELAY = 1
|
|
ACC_WIDTH = 48
|
|
COMB_WIDTH = 28
|
|
ACC_MASK = (1 << 48) - 1
|
|
COMB_MASK = (1 << 28) - 1
|
|
|
|
def __init__(self):
|
|
# Integrators (48-bit wrapping)
|
|
self.int_stages = [0] * self.STAGES
|
|
self.data_in_c_delayed = 0 # Models CREG=1
|
|
|
|
# Comb section (28-bit signed)
|
|
self.comb = [0] * self.STAGES
|
|
self.comb_delay = [[0] * self.COMB_DELAY for _ in range(self.STAGES)]
|
|
|
|
# Decimation control
|
|
self.decimation_counter = 0
|
|
self.data_valid_delayed = False
|
|
self.data_valid_comb = False
|
|
self.integrator_sampled = 0 # 28-bit
|
|
|
|
# Comb output pipeline (3 stages)
|
|
self.temp_scaled_output = 0
|
|
self.temp_output = 0
|
|
self.sat_pos = False
|
|
self.sat_neg = False
|
|
self.temp_output_pipe = 0
|
|
self.data_out_valid_pipe = False
|
|
|
|
# Outputs
|
|
self.data_out = 0
|
|
self.data_out_valid = False
|
|
|
|
def step(self, data_in_18, data_valid):
|
|
"""
|
|
Advance one clock cycle.
|
|
|
|
Args:
|
|
data_in_18: 18-bit signed input
|
|
data_valid: input valid flag
|
|
|
|
Returns:
|
|
(data_out, data_out_valid) as signed 18-bit + bool
|
|
"""
|
|
data_in_18 = sign_extend(data_in_18 & 0x3FFFF, 18)
|
|
# Sign-extend to 48 bits
|
|
data_in_c = data_in_18 & self.ACC_MASK
|
|
|
|
# Save old state for NBA semantics
|
|
old_int = list(self.int_stages)
|
|
old_data_in_c_delayed = self.data_in_c_delayed
|
|
old_decimation_counter = self.decimation_counter
|
|
old_integrator_sampled = self.integrator_sampled
|
|
old_data_valid_delayed = self.data_valid_delayed
|
|
old_data_valid_comb = self.data_valid_comb
|
|
old_comb = list(self.comb)
|
|
old_comb_delay = [list(d) for d in self.comb_delay]
|
|
old_temp_scaled_output = self.temp_scaled_output
|
|
old_sat_pos = self.sat_pos
|
|
old_sat_neg = self.sat_neg
|
|
old_temp_output_pipe = self.temp_output_pipe
|
|
old_data_out_valid_pipe = self.data_out_valid_pipe
|
|
|
|
# ---- Integrator chain (DSP48E1 behavioral sim) ----
|
|
if data_valid:
|
|
# CREG pipeline: capture current data, use previous
|
|
self.data_in_c_delayed = sign_extend(data_in_c, 48)
|
|
self.int_stages[0] = (old_int[0] + old_data_in_c_delayed) & self.ACC_MASK
|
|
for i in range(1, self.STAGES):
|
|
self.int_stages[i] = (old_int[i] + old_int[i-1]) & self.ACC_MASK
|
|
|
|
# ---- Decimation control ----
|
|
if data_valid:
|
|
if old_decimation_counter == self.DECIMATION - 1:
|
|
self.decimation_counter = 0
|
|
self.data_valid_delayed = True
|
|
# Capture integrator_4 output, truncate to COMB_WIDTH
|
|
int4_val = self.int_stages[4] # Use NEW value (from NBA above)
|
|
# Actually in RTL, p_out_4 is read as a wire from the DSP/behavioral model
|
|
# The NBA order means we read the just-updated value
|
|
self.integrator_sampled = sign_extend(int4_val & self.COMB_MASK, self.COMB_WIDTH)
|
|
else:
|
|
self.decimation_counter = old_decimation_counter + 1
|
|
self.data_valid_delayed = False
|
|
else:
|
|
self.data_valid_delayed = False
|
|
|
|
# ---- Pipeline valid for comb section ----
|
|
self.data_valid_comb = old_data_valid_delayed
|
|
|
|
# ---- Comb section ----
|
|
if old_data_valid_comb:
|
|
for i in range(self.STAGES):
|
|
if i == 0:
|
|
inp = old_integrator_sampled
|
|
self.comb[0] = sign_extend(
|
|
(inp - old_comb_delay[0][self.COMB_DELAY - 1]) & self.COMB_MASK,
|
|
self.COMB_WIDTH
|
|
)
|
|
# Shift delay line
|
|
for j in range(self.COMB_DELAY - 1, 0, -1):
|
|
self.comb_delay[0][j] = old_comb_delay[0][j-1]
|
|
self.comb_delay[0][0] = inp
|
|
else:
|
|
inp = old_comb[i-1]
|
|
self.comb[i] = sign_extend(
|
|
(inp - old_comb_delay[i][self.COMB_DELAY - 1]) & self.COMB_MASK,
|
|
self.COMB_WIDTH
|
|
)
|
|
for j in range(self.COMB_DELAY - 1, 0, -1):
|
|
self.comb_delay[i][j] = old_comb_delay[i][j-1]
|
|
self.comb_delay[i][0] = inp
|
|
|
|
# Scale by >>>10 (CIC gain = 4^5 = 1024 = 2^10)
|
|
self.temp_scaled_output = sign_extend(old_comb[self.STAGES - 1], self.COMB_WIDTH) >> 10
|
|
self.temp_output = sign_extend(self.temp_scaled_output & 0x3FFFF, 18)
|
|
|
|
# Pipeline Stage 2: saturation flags
|
|
self.sat_pos = (old_temp_scaled_output > 131071)
|
|
self.sat_neg = (old_temp_scaled_output < -131072)
|
|
self.temp_output_pipe = sign_extend(old_temp_scaled_output & 0x3FFFF, 18)
|
|
self.data_out_valid_pipe = True
|
|
else:
|
|
self.data_out_valid_pipe = False
|
|
|
|
# ---- Pipeline Stage 3: Output MUX ----
|
|
if old_data_out_valid_pipe:
|
|
if old_sat_pos:
|
|
self.data_out = 131071
|
|
elif old_sat_neg:
|
|
self.data_out = -131072
|
|
else:
|
|
self.data_out = old_temp_output_pipe
|
|
self.data_out_valid = True
|
|
else:
|
|
self.data_out_valid = False
|
|
|
|
return self.data_out, self.data_out_valid
|
|
|
|
|
|
# =============================================================================
|
|
# FIR Lowpass Filter (32-tap, 5-stage binary adder tree, 7-cycle latency)
|
|
# =============================================================================
|
|
|
|
# FIR coefficients (18-bit signed hex from fir_lowpass.v)
|
|
# These are 18-bit signed values stored in Verilog as 18'sh...
|
|
FIR_COEFFICIENTS_HEX = [
|
|
0x000AD, 0x000CE, 0x3FD87, 0x002A6, 0x000E0, 0x3F8C0, 0x00A45, 0x3FD82,
|
|
0x3F0B5, 0x01CAD, 0x3EE59, 0x3E821, 0x04841, 0x3B340, 0x3E299, 0x1FFFF,
|
|
0x1FFFF, 0x3E299, 0x3B340, 0x04841, 0x3E821, 0x3EE59, 0x01CAD, 0x3F0B5,
|
|
0x3FD82, 0x00A45, 0x3F8C0, 0x000E0, 0x002A6, 0x3FD87, 0x000CE, 0x000AD,
|
|
]
|
|
|
|
# Convert to signed Python ints
|
|
FIR_COEFFICIENTS = [sign_extend(c, 18) for c in FIR_COEFFICIENTS_HEX]
|
|
|
|
|
|
class FIRFilter:
|
|
"""
|
|
Bit-accurate model of fir_lowpass_parallel_enhanced.v
|
|
|
|
32-tap FIR with 5-stage pipelined binary adder tree.
|
|
Input: 18-bit signed. Output: 18-bit signed.
|
|
Accumulator: 36-bit signed.
|
|
|
|
Pipeline (7 cycles total):
|
|
Cycle 0: data_valid -> shift delay line (combinational multiply)
|
|
Cycle 1: L0: 16 pairwise sums of 32 products
|
|
Cycle 2: L1: 8 pairwise sums
|
|
Cycle 3: L2: 4 pairwise sums
|
|
Cycle 4: L3: 2 pairwise sums
|
|
Cycle 5: L4: final sum -> accumulator_reg
|
|
Cycle 6: output saturation/rounding
|
|
"""
|
|
|
|
TAPS = 32
|
|
DATA_WIDTH = 18
|
|
COEFF_WIDTH = 18
|
|
ACCUM_WIDTH = 36
|
|
PRODUCT_WIDTH = DATA_WIDTH + COEFF_WIDTH # 36 bits
|
|
|
|
def __init__(self):
|
|
self.delay_line = [0] * self.TAPS
|
|
self.add_l0 = [0] * 16
|
|
self.add_l1 = [0] * 8
|
|
self.add_l2 = [0] * 4
|
|
self.add_l3 = [0, 0]
|
|
self.accumulator_reg = 0
|
|
self.data_out = 0
|
|
self.data_out_valid = False
|
|
self.valid_pipe = 0 # 7-bit
|
|
|
|
def step(self, data_in_18, data_valid):
|
|
"""
|
|
Advance one clock cycle.
|
|
|
|
Returns:
|
|
(data_out, data_out_valid) as signed 18-bit + bool
|
|
"""
|
|
data_in_18 = sign_extend(data_in_18 & 0x3FFFF, 18)
|
|
old_valid_pipe = self.valid_pipe
|
|
|
|
# ---- Stage 0: Shift delay line ----
|
|
if data_valid:
|
|
for i in range(self.TAPS - 1, 0, -1):
|
|
self.delay_line[i] = self.delay_line[i - 1]
|
|
self.delay_line[0] = data_in_18
|
|
|
|
# Combinational multiply (uses current delay_line)
|
|
mult_results = []
|
|
for k in range(self.TAPS):
|
|
prod = self.delay_line[k] * FIR_COEFFICIENTS[k]
|
|
mult_results.append(prod)
|
|
|
|
# Save old adder tree state
|
|
old_l0 = list(self.add_l0)
|
|
old_l1 = list(self.add_l1)
|
|
old_l2 = list(self.add_l2)
|
|
old_l3 = list(self.add_l3)
|
|
old_accum = self.accumulator_reg
|
|
|
|
# ---- Stage 1 (Level 0): 16 pairwise sums ----
|
|
if (old_valid_pipe >> 0) & 1:
|
|
for i in range(16):
|
|
# Sign-extend products to ACCUM_WIDTH
|
|
a = sign_extend(mult_results[2*i] & ((1 << self.PRODUCT_WIDTH) - 1), self.PRODUCT_WIDTH)
|
|
b = sign_extend(mult_results[2*i+1] & ((1 << self.PRODUCT_WIDTH) - 1), self.PRODUCT_WIDTH)
|
|
self.add_l0[i] = a + b
|
|
|
|
# ---- Stage 2 (Level 1): 8 pairwise sums ----
|
|
if (old_valid_pipe >> 1) & 1:
|
|
for i in range(8):
|
|
self.add_l1[i] = old_l0[2*i] + old_l0[2*i+1]
|
|
|
|
# ---- Stage 3 (Level 2): 4 pairwise sums ----
|
|
if (old_valid_pipe >> 2) & 1:
|
|
for i in range(4):
|
|
self.add_l2[i] = old_l1[2*i] + old_l1[2*i+1]
|
|
|
|
# ---- Stage 4 (Level 3): 2 pairwise sums ----
|
|
if (old_valid_pipe >> 3) & 1:
|
|
self.add_l3[0] = old_l2[0] + old_l2[1]
|
|
self.add_l3[1] = old_l2[2] + old_l2[3]
|
|
|
|
# ---- Stage 5 (Level 4): Final sum ----
|
|
if (old_valid_pipe >> 4) & 1:
|
|
self.accumulator_reg = old_l3[0] + old_l3[1]
|
|
|
|
# ---- Stage 6: Output saturation/rounding ----
|
|
if (old_valid_pipe >> 5) & 1:
|
|
accum = old_accum
|
|
max_pos = (1 << (self.ACCUM_WIDTH - 2)) - 1 # 2^34 - 1
|
|
min_neg = -(1 << (self.ACCUM_WIDTH - 2)) # -2^34
|
|
|
|
if accum > max_pos:
|
|
self.data_out = (1 << (self.DATA_WIDTH - 1)) - 1 # 131071
|
|
elif accum < min_neg:
|
|
self.data_out = -(1 << (self.DATA_WIDTH - 1)) # -131072
|
|
else:
|
|
# Round and truncate: accumulator_reg[ACCUM_WIDTH-2 : DATA_WIDTH-1]
|
|
# = accum[34:17] = bits 34 down to 17
|
|
# This is equivalent to: (accum >> 17) masked to 18 bits
|
|
self.data_out = sign_extend((accum >> (self.DATA_WIDTH - 1)) & 0x3FFFF, 18)
|
|
self.data_out_valid = True
|
|
else:
|
|
self.data_out_valid = (old_valid_pipe >> 5) & 1
|
|
|
|
# Update valid pipeline
|
|
self.valid_pipe = ((old_valid_pipe << 1) | (1 if data_valid else 0)) & 0x7F
|
|
self.data_out_valid = bool((old_valid_pipe >> 5) & 1)
|
|
|
|
return self.data_out, self.data_out_valid
|
|
|
|
|
|
# =============================================================================
|
|
# DDC Input Interface (18 -> 16 bit rounding)
|
|
# =============================================================================
|
|
|
|
class DDCInputInterface:
|
|
"""
|
|
Bit-accurate model of ddc_input_interface.v
|
|
|
|
Converts 18-bit FIR output to 16-bit with rounding:
|
|
adc_i = ddc_i[17:2] + ddc_i[1]
|
|
|
|
2-cycle valid pipeline:
|
|
Cycle 1: valid_sync = valid_i_reg && valid_q_reg
|
|
Cycle 2: adc_valid = valid_sync (data computed during valid_sync)
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.valid_i_reg = False
|
|
self.valid_q_reg = False
|
|
self.valid_sync = False
|
|
self.adc_valid = False
|
|
self.adc_i = 0
|
|
self.adc_q = 0
|
|
|
|
def step(self, ddc_i_18, ddc_q_18, valid_i, valid_q):
|
|
"""
|
|
Returns:
|
|
(adc_i, adc_q, adc_valid) as signed 16-bit, signed 16-bit, bool
|
|
"""
|
|
old_valid_sync = self.valid_sync
|
|
|
|
# Pipeline valid
|
|
self.valid_sync = self.valid_i_reg and self.valid_q_reg
|
|
self.adc_valid = old_valid_sync
|
|
self.valid_i_reg = valid_i
|
|
self.valid_q_reg = valid_q
|
|
|
|
# Data path (clocked on valid_sync)
|
|
if old_valid_sync:
|
|
ddc_i = sign_extend(ddc_i_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]
|
|
round_i = (ddc_i >> 1) & 1 # bit [1]
|
|
trunc_q = (ddc_q >> 2) & 0xFFFF
|
|
round_q = (ddc_q >> 1) & 1
|
|
self.adc_i = sign_extend((trunc_i + round_i) & 0xFFFF, 16)
|
|
self.adc_q = sign_extend((trunc_q + round_q) & 0xFFFF, 16)
|
|
|
|
return self.adc_i, self.adc_q, self.adc_valid
|
|
|
|
|
|
# =============================================================================
|
|
# FFT Engine (1024-point radix-2 DIT, in-place, 32-bit internal)
|
|
# =============================================================================
|
|
|
|
def load_twiddle_rom(filepath=None):
|
|
"""
|
|
Load 256-entry quarter-wave cosine ROM from hex file.
|
|
Returns list of 256 signed 16-bit integers.
|
|
"""
|
|
if filepath is None:
|
|
# Default path relative to this file
|
|
base = os.path.dirname(os.path.abspath(__file__))
|
|
filepath = os.path.join(base, '..', '..', 'fft_twiddle_1024.mem')
|
|
|
|
values = []
|
|
with open(filepath, 'r') as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line or line.startswith('//'):
|
|
continue
|
|
val = int(line, 16)
|
|
values.append(sign_extend(val, 16))
|
|
return values
|
|
|
|
|
|
def _twiddle_lookup(k, n, cos_rom):
|
|
"""
|
|
Quarter-wave twiddle reconstruction from cos_rom (N/4 entries).
|
|
Returns (cos_val, sin_val) as signed 16-bit.
|
|
|
|
Matches fft_engine.v logic exactly:
|
|
k=0: cos=rom[0], sin=0
|
|
k=N/4: cos=0, sin=rom[0]
|
|
k<N/4: cos=rom[k], sin=rom[N/4-k]
|
|
k>N/4: cos=-rom[N/2-k], sin=rom[k-N/4]
|
|
"""
|
|
n4 = n // 4
|
|
n2 = n // 2
|
|
|
|
k = k % n2 # twiddle indices are modulo N/2
|
|
|
|
if k == 0:
|
|
return cos_rom[0], 0
|
|
elif k == n4:
|
|
return 0, cos_rom[0]
|
|
elif k < n4:
|
|
return cos_rom[k], cos_rom[n4 - k]
|
|
else:
|
|
return sign_extend((-cos_rom[n2 - k]) & 0xFFFF, 16), cos_rom[k - n4]
|
|
|
|
|
|
class FFTEngine:
|
|
"""
|
|
Bit-accurate model of fft_engine.v
|
|
|
|
1024-point radix-2 DIT FFT/IFFT.
|
|
Internal: 32-bit signed working data.
|
|
Twiddle: 16-bit Q15 from quarter-wave cosine ROM.
|
|
Butterfly: multiply 32x16->49 bits, >>>15, add/subtract.
|
|
Output: saturate 32->16 bits. IFFT also >>>LOG2N before saturate.
|
|
"""
|
|
|
|
def __init__(self, n=1024, twiddle_file=None):
|
|
self.N = n
|
|
self.LOG2N = n.bit_length() - 1
|
|
self.cos_rom = load_twiddle_rom(twiddle_file)
|
|
# Working memory (32-bit signed I/Q pairs)
|
|
self.mem_re = [0] * n
|
|
self.mem_im = [0] * n
|
|
|
|
@staticmethod
|
|
def _bit_reverse(val, bits):
|
|
"""Bit-reverse an integer."""
|
|
result = 0
|
|
for _ in range(bits):
|
|
result = (result << 1) | (val & 1)
|
|
val >>= 1
|
|
return result
|
|
|
|
def compute(self, in_re, in_im, inverse=False):
|
|
"""
|
|
Run full FFT or IFFT.
|
|
|
|
Args:
|
|
in_re: list of N signed 16-bit real inputs
|
|
in_im: list of N signed 16-bit imag inputs
|
|
inverse: True for IFFT
|
|
|
|
Returns:
|
|
(out_re, out_im): lists of N signed 16-bit outputs
|
|
"""
|
|
n = self.N
|
|
log2n = self.LOG2N
|
|
|
|
# LOAD: sign-extend 16->32 and store at bit-reversed addresses
|
|
for i in range(n):
|
|
br = self._bit_reverse(i, log2n)
|
|
self.mem_re[br] = sign_extend(in_re[i] & 0xFFFF, 16)
|
|
self.mem_im[br] = sign_extend(in_im[i] & 0xFFFF, 16)
|
|
|
|
# COMPUTE: LOG2N stages of butterflies
|
|
for stage in range(log2n):
|
|
half = 1 << stage
|
|
span = half << 1
|
|
tw_stride = (n >> 1) >> stage
|
|
|
|
for bfly in range(n // 2):
|
|
idx = bfly & (half - 1)
|
|
grp = bfly - idx
|
|
even = (grp << 1) | idx
|
|
odd = even + half
|
|
tw_idx = idx * tw_stride
|
|
|
|
# Read
|
|
a_re = self.mem_re[even]
|
|
a_im = self.mem_im[even]
|
|
b_re = self.mem_re[odd]
|
|
b_im = self.mem_im[odd]
|
|
|
|
# Twiddle lookup
|
|
tw_cos, tw_sin = _twiddle_lookup(tw_idx, n, self.cos_rom)
|
|
|
|
# Multiply (49-bit products)
|
|
if not inverse:
|
|
# Forward: t = b * (cos + j*sin)
|
|
prod_re = b_re * tw_cos + b_im * tw_sin
|
|
prod_im = b_im * tw_cos - b_re * tw_sin
|
|
else:
|
|
# Inverse: t = b * (cos - j*sin)
|
|
prod_re = b_re * tw_cos - b_im * tw_sin
|
|
prod_im = b_im * tw_cos + b_re * tw_sin
|
|
|
|
# Shift >>>15 (arithmetic, 49->32)
|
|
t_re = prod_re >> 15
|
|
t_im = prod_im >> 15
|
|
|
|
# Add/subtract
|
|
self.mem_re[even] = a_re + t_re
|
|
self.mem_im[even] = a_im + t_im
|
|
self.mem_re[odd] = a_re - t_re
|
|
self.mem_im[odd] = a_im - t_im
|
|
|
|
# OUTPUT: read in linear order, saturate to 16 bits
|
|
out_re = []
|
|
out_im = []
|
|
for i in range(n):
|
|
re_val = self.mem_re[i]
|
|
im_val = self.mem_im[i]
|
|
|
|
if inverse:
|
|
# IFFT: >>>LOG2N before saturate
|
|
re_val = re_val >> log2n
|
|
im_val = im_val >> log2n
|
|
|
|
out_re.append(saturate(re_val, 16))
|
|
out_im.append(saturate(im_val, 16))
|
|
|
|
return out_re, out_im
|
|
|
|
|
|
# =============================================================================
|
|
# Frequency Matched Filter (conjugate multiply, 4-stage pipeline)
|
|
# =============================================================================
|
|
|
|
class FreqMatchedFilter:
|
|
"""
|
|
Bit-accurate model of frequency_matched_filter.v
|
|
|
|
Conjugate multiply: (a + jb) * conj(c + jd) = (ac+bd) + j(bc-ad)
|
|
|
|
4-stage pipeline:
|
|
P1: Register inputs
|
|
P2: Four 16x16 multiplies -> 32-bit products
|
|
P3: Add: real_sum = ac + bd, imag_sum = bc - ad (32-bit Q30)
|
|
P4: Round (+ 1<<14), saturate, extract [30:15] -> 16-bit Q15
|
|
|
|
For batch processing, we compute all samples directly.
|
|
"""
|
|
|
|
@staticmethod
|
|
def conjugate_multiply_sample(sig_re, sig_im, ref_re, ref_im):
|
|
"""
|
|
Compute one conjugate multiply with exact RTL arithmetic.
|
|
|
|
Returns (out_re, out_im) as signed 16-bit.
|
|
"""
|
|
a = sign_extend(sig_re & 0xFFFF, 16)
|
|
b = sign_extend(sig_im & 0xFFFF, 16)
|
|
c = sign_extend(ref_re & 0xFFFF, 16)
|
|
d = sign_extend(ref_im & 0xFFFF, 16)
|
|
|
|
# Stage 2: 16x16 multiplies -> 32-bit signed
|
|
ac = a * c
|
|
bd = b * d
|
|
bc = b * c
|
|
ad = a * d
|
|
|
|
# Stage 3: accumulate (Q30)
|
|
real_sum = ac + bd
|
|
imag_sum = bc - ad
|
|
|
|
# Stage 4: round + saturate + extract [30:15]
|
|
def round_sat_extract(q30_val):
|
|
rounded = q30_val + (1 << 14)
|
|
# Saturation check
|
|
if rounded > 0x3FFF8000:
|
|
return 0x7FFF
|
|
elif rounded < -0x3FFF8000:
|
|
return sign_extend(0x8000, 16)
|
|
else:
|
|
return sign_extend((rounded >> 15) & 0xFFFF, 16)
|
|
|
|
out_re = round_sat_extract(real_sum)
|
|
out_im = round_sat_extract(imag_sum)
|
|
return out_re, out_im
|
|
|
|
@staticmethod
|
|
def process_block(sig_re, sig_im, ref_re, ref_im):
|
|
"""Process N samples of conjugate multiply."""
|
|
n = len(sig_re)
|
|
out_re = []
|
|
out_im = []
|
|
for i in range(n):
|
|
r, m = FreqMatchedFilter.conjugate_multiply_sample(
|
|
sig_re[i], sig_im[i], ref_re[i], ref_im[i]
|
|
)
|
|
out_re.append(r)
|
|
out_im.append(m)
|
|
return out_re, out_im
|
|
|
|
|
|
# =============================================================================
|
|
# Matched Filter Processing Chain
|
|
# =============================================================================
|
|
|
|
class MatchedFilterChain:
|
|
"""
|
|
Complete matched filter: FFT(signal) * conj(FFT(ref)) -> IFFT
|
|
|
|
Uses a single FFTEngine instance (as in RTL, engine is reused).
|
|
"""
|
|
|
|
def __init__(self, fft_size=1024, twiddle_file=None):
|
|
self.fft_size = fft_size
|
|
self.fft = FFTEngine(n=fft_size, twiddle_file=twiddle_file)
|
|
self.conj_mult = FreqMatchedFilter()
|
|
|
|
def process(self, sig_re, sig_im, ref_re, ref_im):
|
|
"""
|
|
Run matched filter on 1024-sample signal + reference.
|
|
|
|
Args:
|
|
sig_re/im: signal I/Q (16-bit signed, 1024 samples)
|
|
ref_re/im: reference chirp I/Q (16-bit signed, 1024 samples)
|
|
|
|
Returns:
|
|
(range_profile_re, range_profile_im): 1024 x 16-bit signed
|
|
"""
|
|
# Forward FFT of signal
|
|
sig_fft_re, sig_fft_im = self.fft.compute(sig_re, sig_im, inverse=False)
|
|
|
|
# Forward FFT of reference (same engine, reused)
|
|
ref_fft_re, ref_fft_im = self.fft.compute(ref_re, ref_im, inverse=False)
|
|
|
|
# Conjugate multiply
|
|
prod_re, prod_im = self.conj_mult.process_block(
|
|
sig_fft_re, sig_fft_im, ref_fft_re, ref_fft_im
|
|
)
|
|
|
|
# Inverse FFT
|
|
range_re, range_im = self.fft.compute(prod_re, prod_im, inverse=True)
|
|
|
|
return range_re, range_im
|
|
|
|
|
|
# =============================================================================
|
|
# Range Bin Decimator (1024 -> 64, factor 16)
|
|
# =============================================================================
|
|
|
|
class RangeBinDecimator:
|
|
"""
|
|
Bit-accurate model of range_bin_decimator.v
|
|
|
|
Three modes:
|
|
00: Simple decimation (take center sample at index 8)
|
|
01: Peak detection (max |I|+|Q|)
|
|
10: Averaging (sum >> 4, truncation)
|
|
11: Reserved (output 0)
|
|
"""
|
|
|
|
DECIMATION_FACTOR = 16
|
|
OUTPUT_BINS = 64
|
|
|
|
@staticmethod
|
|
def decimate(range_re, range_im, mode=1, start_bin=0):
|
|
"""
|
|
Decimate 1024 range bins to 64.
|
|
|
|
Args:
|
|
range_re/im: 1024 x signed 16-bit
|
|
mode: 0=center, 1=peak, 2=average, 3=zero
|
|
start_bin: first input bin to process (0-1023)
|
|
|
|
Returns:
|
|
(out_re, out_im): 64 x signed 16-bit
|
|
"""
|
|
out_re = []
|
|
out_im = []
|
|
df = RangeBinDecimator.DECIMATION_FACTOR
|
|
|
|
for b in range(RangeBinDecimator.OUTPUT_BINS):
|
|
base = start_bin + b * df
|
|
|
|
if mode == 0:
|
|
# Simple decimation: take center sample
|
|
idx = base + df // 2
|
|
if idx < len(range_re):
|
|
out_re.append(range_re[idx])
|
|
out_im.append(range_im[idx])
|
|
else:
|
|
out_re.append(0)
|
|
out_im.append(0)
|
|
|
|
elif mode == 1:
|
|
# Peak detection: max |I| + |Q|
|
|
best_mag = -1
|
|
best_re = 0
|
|
best_im = 0
|
|
for s in range(df):
|
|
idx = base + s
|
|
if idx < len(range_re):
|
|
re_val = sign_extend(range_re[idx] & 0xFFFF, 16)
|
|
im_val = sign_extend(range_im[idx] & 0xFFFF, 16)
|
|
# abs via 2's complement (matches RTL)
|
|
abs_re = (-re_val) if re_val < 0 else re_val
|
|
abs_im = (-im_val) if im_val < 0 else im_val
|
|
mag = abs_re + abs_im # 17-bit unsigned
|
|
if mag > best_mag:
|
|
best_mag = mag
|
|
best_re = re_val
|
|
best_im = im_val
|
|
out_re.append(best_re)
|
|
out_im.append(best_im)
|
|
|
|
elif mode == 2:
|
|
# Averaging: sum >> 4
|
|
sum_re = 0
|
|
sum_im = 0
|
|
for s in range(df):
|
|
idx = base + s
|
|
if idx < len(range_re):
|
|
sum_re += sign_extend(range_re[idx] & 0xFFFF, 16)
|
|
sum_im += sign_extend(range_im[idx] & 0xFFFF, 16)
|
|
# Truncate (arithmetic right shift by 4), take 16 bits
|
|
out_re.append(sign_extend((sum_re >> 4) & 0xFFFF, 16))
|
|
out_im.append(sign_extend((sum_im >> 4) & 0xFFFF, 16))
|
|
|
|
else:
|
|
# Mode 3: reserved, output 0
|
|
out_re.append(0)
|
|
out_im.append(0)
|
|
|
|
return out_re, out_im
|
|
|
|
|
|
# =============================================================================
|
|
# Doppler Processor (Hamming window + dual 16-point FFT)
|
|
# =============================================================================
|
|
|
|
# Hamming window LUT (16 entries, 16-bit unsigned Q15)
|
|
# Matches doppler_processor.v window_coeff[0:15]
|
|
# w[n] = 0.54 - 0.46 * cos(2*pi*n/15), n=0..15, symmetric
|
|
HAMMING_WINDOW = [
|
|
0x0A3D, 0x0E5C, 0x1B6D, 0x3088, 0x4B33, 0x6573, 0x7642, 0x7F62,
|
|
0x7F62, 0x7642, 0x6573, 0x4B33, 0x3088, 0x1B6D, 0x0E5C, 0x0A3D,
|
|
]
|
|
|
|
|
|
class DopplerProcessor:
|
|
"""
|
|
Bit-accurate model of doppler_processor_optimized.v (dual 16-pt FFT architecture).
|
|
|
|
The staggered-PRF frame has 32 chirps total:
|
|
- Sub-frame 0 (long PRI): chirps 0-15 -> 16-pt Hamming -> 16-pt FFT -> bins 0-15
|
|
- Sub-frame 1 (short PRI): chirps 16-31 -> 16-pt Hamming -> 16-pt FFT -> bins 16-31
|
|
|
|
Output: doppler_bin[4:0] = {sub_frame_id, bin_in_subframe[3:0]}
|
|
Total output per range bin: 32 bins (16 + 16), same interface as before.
|
|
"""
|
|
|
|
DOPPLER_FFT_SIZE = 16 # Per sub-frame
|
|
RANGE_BINS = 64
|
|
CHIRPS_PER_FRAME = 32
|
|
CHIRPS_PER_SUBFRAME = 16
|
|
|
|
def __init__(self, twiddle_file_16=None):
|
|
"""
|
|
For 16-point FFT, we need the 16-point twiddle file.
|
|
If not provided, we generate twiddle factors mathematically
|
|
(cos(2*pi*k/16) for k=0..3, quarter-wave ROM with 4 entries).
|
|
"""
|
|
self.fft16 = None
|
|
self._twiddle_file_16 = twiddle_file_16
|
|
|
|
@staticmethod
|
|
def window_multiply(data_16, window_16):
|
|
"""
|
|
Hamming window multiply matching RTL:
|
|
product = data * window (16x16 -> 32-bit signed)
|
|
rounded = product + (1 << 14)
|
|
result = rounded >> 15 (arithmetic right shift)
|
|
"""
|
|
d = sign_extend(data_16 & 0xFFFF, 16)
|
|
# Window values are unsigned Q15, but multiply is $signed * $signed
|
|
# in the RTL. The window values are all positive (max 0x7FFF), so
|
|
# treating as signed 16-bit is fine (MSB is always 0).
|
|
w = sign_extend(window_16 & 0xFFFF, 16)
|
|
product = d * w # 32-bit signed
|
|
rounded = product + (1 << 14)
|
|
result = rounded >> 15 # arithmetic right shift
|
|
return sign_extend(result & 0xFFFF, 16)
|
|
|
|
def process_frame(self, chirp_data_i, chirp_data_q):
|
|
"""
|
|
Process one complete Doppler frame using dual 16-pt FFTs.
|
|
|
|
Args:
|
|
chirp_data_i: 2D array [32 chirps][64 range bins] of signed 16-bit I
|
|
chirp_data_q: 2D array [32 chirps][64 range bins] of signed 16-bit Q
|
|
|
|
Returns:
|
|
(doppler_map_i, doppler_map_q): 2D arrays [64 range bins][32 doppler bins]
|
|
of signed 16-bit
|
|
Bins 0-15 = sub-frame 0 (long PRI)
|
|
Bins 16-31 = sub-frame 1 (short PRI)
|
|
"""
|
|
doppler_map_i = []
|
|
doppler_map_q = []
|
|
|
|
# Generate 16-pt twiddle factors (quarter-wave cos, 4 entries)
|
|
# cos(2*pi*k/16) for k=0..3
|
|
# Matches fft_twiddle_16.mem: 7FFF, 7641, 5A82, 30FB
|
|
import math
|
|
cos_rom_16 = []
|
|
for k in range(4):
|
|
val = round(32767.0 * math.cos(2.0 * math.pi * k / 16.0))
|
|
cos_rom_16.append(sign_extend(val & 0xFFFF, 16))
|
|
|
|
fft16 = FFTEngine.__new__(FFTEngine)
|
|
fft16.N = 16
|
|
fft16.LOG2N = 4
|
|
fft16.cos_rom = cos_rom_16
|
|
fft16.mem_re = [0] * 16
|
|
fft16.mem_im = [0] * 16
|
|
|
|
for rbin in range(self.RANGE_BINS):
|
|
# Output bins for this range bin: 32 total (16 from each sub-frame)
|
|
out_re = [0] * 32
|
|
out_im = [0] * 32
|
|
|
|
# Process each sub-frame independently
|
|
for sf in range(2):
|
|
chirp_start = sf * self.CHIRPS_PER_SUBFRAME
|
|
bin_offset = sf * self.DOPPLER_FFT_SIZE
|
|
|
|
fft_in_re = []
|
|
fft_in_im = []
|
|
|
|
for c in range(self.CHIRPS_PER_SUBFRAME):
|
|
chirp = chirp_start + c
|
|
re_val = sign_extend(chirp_data_i[chirp][rbin] & 0xFFFF, 16)
|
|
im_val = sign_extend(chirp_data_q[chirp][rbin] & 0xFFFF, 16)
|
|
|
|
# Apply 16-pt Hamming window (index = c within sub-frame)
|
|
win_re = self.window_multiply(re_val, HAMMING_WINDOW[c])
|
|
win_im = self.window_multiply(im_val, HAMMING_WINDOW[c])
|
|
|
|
fft_in_re.append(win_re)
|
|
fft_in_im.append(win_im)
|
|
|
|
# 16-point forward FFT
|
|
fft_out_re, fft_out_im = fft16.compute(fft_in_re, fft_in_im, inverse=False)
|
|
|
|
# Pack into output: sub-frame 0 -> bins 0-15, sub-frame 1 -> bins 16-31
|
|
for b in range(self.DOPPLER_FFT_SIZE):
|
|
out_re[bin_offset + b] = fft_out_re[b]
|
|
out_im[bin_offset + b] = fft_out_im[b]
|
|
|
|
doppler_map_i.append(out_re)
|
|
doppler_map_q.append(out_im)
|
|
|
|
return doppler_map_i, doppler_map_q
|
|
|
|
|
|
# =============================================================================
|
|
# Complete Signal Chain (DDC through Doppler)
|
|
# =============================================================================
|
|
|
|
class SignalChain:
|
|
"""
|
|
Full AERIS-10 signal processing chain.
|
|
|
|
For sample-by-sample co-simulation with RTL, use the step-based API
|
|
(nco, mixer, cic, fir individually).
|
|
|
|
For block-level validation, use process_chirp() or process_frame().
|
|
"""
|
|
|
|
# System parameters
|
|
FS_ADC = 400_000_000 # ADC sample rate
|
|
FS_SYS = 100_000_000 # System clock
|
|
IF_FREQ = 120_000_000 # IF frequency
|
|
FTW_120MHZ = 0x4CCCCCCD # Phase increment for 120 MHz at 400 MSPS
|
|
|
|
def __init__(self, twiddle_file_1024=None, twiddle_file_16=None):
|
|
self.nco = NCO()
|
|
self.mixer = Mixer()
|
|
self.cic_i = CICDecimator()
|
|
self.cic_q = CICDecimator()
|
|
self.fir_i = FIRFilter()
|
|
self.fir_q = FIRFilter()
|
|
self.ddc_interface = DDCInputInterface()
|
|
self.matched_filter = MatchedFilterChain(fft_size=1024, twiddle_file=twiddle_file_1024)
|
|
self.range_decimator = RangeBinDecimator()
|
|
self.doppler = DopplerProcessor(twiddle_file_16=twiddle_file_16)
|
|
|
|
def ddc_step(self, adc_data_8bit, ftw=None):
|
|
"""
|
|
Process one ADC sample through the DDC (NCO + Mixer + CIC).
|
|
Runs at 400 MHz rate.
|
|
|
|
Returns dict with intermediate and output values.
|
|
"""
|
|
if ftw is None:
|
|
ftw = self.FTW_120MHZ
|
|
|
|
# NCO
|
|
sin_val, cos_val, nco_ready = self.nco.step(ftw, phase_offset=0, phase_valid=True)
|
|
|
|
# Mixer
|
|
mix_i, mix_q, mix_valid = self.mixer.step(
|
|
adc_data_8bit, cos_val, sin_val, nco_ready, True
|
|
)
|
|
|
|
# CIC (both channels)
|
|
cic_i_out, cic_i_valid = self.cic_i.step(mix_i, mix_valid)
|
|
cic_q_out, cic_q_valid = self.cic_q.step(mix_q, mix_valid)
|
|
|
|
return {
|
|
'sin': sin_val, 'cos': cos_val, 'nco_ready': nco_ready,
|
|
'mix_i': mix_i, 'mix_q': mix_q, 'mix_valid': mix_valid,
|
|
'cic_i': cic_i_out, 'cic_q': cic_q_out,
|
|
'cic_valid': cic_i_valid and cic_q_valid,
|
|
}
|
|
|
|
def process_adc_block(self, adc_samples, ftw=None):
|
|
"""
|
|
Process a block of ADC samples through DDC (NCO->Mixer->CIC->FIR).
|
|
|
|
Args:
|
|
adc_samples: list of 8-bit unsigned ADC values at 400 MSPS
|
|
|
|
Returns:
|
|
dict with:
|
|
'baseband_i': list of 16-bit signed I samples (at 100 MHz)
|
|
'baseband_q': list of 16-bit signed Q samples (at 100 MHz)
|
|
'cic_i_raw': list of raw CIC outputs for debugging
|
|
'fir_i_raw': list of raw FIR outputs for debugging
|
|
"""
|
|
if ftw is None:
|
|
ftw = self.FTW_120MHZ
|
|
|
|
cic_outputs_i = []
|
|
cic_outputs_q = []
|
|
fir_outputs_i = []
|
|
fir_outputs_q = []
|
|
baseband_i = []
|
|
baseband_q = []
|
|
|
|
# In the RTL, the DDC runs at 400 MHz (NCO, mixer, CIC), while the
|
|
# FIR and DDC input interface run at 100 MHz. After CIC decimation (4x),
|
|
# the FIR sees one valid sample every 4 ADC clocks. The CDC crossing
|
|
# from 400->100 MHz is modeled here by only clocking FIR when CIC
|
|
# produces valid output — the FIR runs at the decimated rate.
|
|
#
|
|
# Between CIC outputs, the FIR is NOT clocked at all (unlike the RTL
|
|
# where it idles at 100 MHz with data_valid=0). This is equivalent
|
|
# because the FIR's internal pipeline only advances when data_valid=1.
|
|
|
|
for sample in adc_samples:
|
|
result = self.ddc_step(sample, ftw)
|
|
|
|
if result['cic_valid']:
|
|
cic_outputs_i.append(result['cic_i'])
|
|
cic_outputs_q.append(result['cic_q'])
|
|
|
|
# FIR (runs at decimated rate, ~100 MHz)
|
|
# Only clock FIR when CIC has valid output — models the
|
|
# CDC crossing + FIR data_valid gating
|
|
fir_i_out, fir_i_valid = self.fir_i.step(result['cic_i'], True)
|
|
fir_q_out, fir_q_valid = self.fir_q.step(result['cic_q'], True)
|
|
|
|
if fir_i_valid and fir_q_valid:
|
|
fir_outputs_i.append(fir_i_out)
|
|
fir_outputs_q.append(fir_q_out)
|
|
|
|
# DDC input interface (18->16 bit rounding)
|
|
bb_i, bb_q, bb_valid = self.ddc_interface.step(
|
|
fir_i_out, fir_q_out, True, True
|
|
)
|
|
if bb_valid:
|
|
baseband_i.append(bb_i)
|
|
baseband_q.append(bb_q)
|
|
else:
|
|
# Clock DDC interface with invalid to advance its pipeline
|
|
self.ddc_interface.step(0, 0, False, False)
|
|
|
|
return {
|
|
'baseband_i': baseband_i,
|
|
'baseband_q': baseband_q,
|
|
'cic_i_raw': cic_outputs_i,
|
|
'cic_q_raw': cic_outputs_q,
|
|
'fir_i_raw': fir_outputs_i,
|
|
'fir_q_raw': fir_outputs_q,
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Self-test / Validation
|
|
# =============================================================================
|
|
|
|
def _self_test():
|
|
"""Quick sanity checks for each module."""
|
|
import math
|
|
|
|
print("=" * 60)
|
|
print("FPGA Model Self-Test")
|
|
print("=" * 60)
|
|
|
|
# --- NCO test ---
|
|
print("\n--- NCO Test ---")
|
|
nco = NCO()
|
|
ftw = 0x4CCCCCCD # 120 MHz at 400 MSPS
|
|
# Run 20 cycles to fill pipeline
|
|
results = []
|
|
for i in range(20):
|
|
s, c, ready = nco.step(ftw)
|
|
if ready:
|
|
results.append((s, c))
|
|
|
|
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
|
|
s, c = results[-1]
|
|
mag_sq = s * s + c * c
|
|
expected = 32767 * 32767
|
|
error_pct = abs(mag_sq - expected) / expected * 100
|
|
print(f" Quadrature check: sin^2+cos^2={mag_sq}, expected~{expected}, error={error_pct:.2f}%")
|
|
print(" NCO: OK")
|
|
|
|
# --- Mixer test ---
|
|
print("\n--- Mixer Test ---")
|
|
mixer = Mixer()
|
|
# Test with mid-scale ADC (128) and known cos/sin
|
|
for i in range(5):
|
|
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 ---
|
|
print("\n--- CIC Test ---")
|
|
cic = CICDecimator()
|
|
dc_val = sign_extend(0x1000, 18) # Small positive DC
|
|
out_count = 0
|
|
for i in range(100):
|
|
out, valid = cic.step(dc_val, True)
|
|
if valid:
|
|
out_count += 1
|
|
print(f" CIC: {out_count} outputs from 100 inputs (expect ~25 with 4x decimation + pipeline)")
|
|
print(" CIC: OK")
|
|
|
|
# --- FIR test ---
|
|
print("\n--- FIR Test ---")
|
|
fir = FIRFilter()
|
|
out_count = 0
|
|
for i in range(50):
|
|
out, valid = fir.step(1000, True)
|
|
if valid:
|
|
out_count += 1
|
|
print(f" FIR: {out_count} outputs from 50 inputs (expect ~43 with 7-cycle latency)")
|
|
print(" FIR: OK")
|
|
|
|
# --- FFT test ---
|
|
print("\n--- FFT Test (1024-pt) ---")
|
|
try:
|
|
fft = FFTEngine(n=1024)
|
|
# Single tone at bin 10
|
|
in_re = [0] * 1024
|
|
in_im = [0] * 1024
|
|
for i in range(1024):
|
|
in_re[i] = int(32767 * 0.5 * math.cos(2 * math.pi * 10 * i / 1024))
|
|
in_re[i] = saturate(in_re[i], 16)
|
|
out_re, out_im = fft.compute(in_re, in_im, inverse=False)
|
|
# Find peak bin
|
|
max_mag = 0
|
|
peak_bin = 0
|
|
for i in range(512):
|
|
mag = abs(out_re[i]) + abs(out_im[i])
|
|
if mag > max_mag:
|
|
max_mag = mag
|
|
peak_bin = i
|
|
print(f" FFT peak at bin {peak_bin} (expected 10), magnitude={max_mag}")
|
|
# IFFT roundtrip
|
|
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))
|
|
print(f" FFT->IFFT roundtrip max error: {max_err} LSBs")
|
|
print(" FFT: OK")
|
|
except FileNotFoundError:
|
|
print(" FFT: SKIPPED (twiddle file not found)")
|
|
|
|
# --- Conjugate multiply test ---
|
|
print("\n--- Conjugate Multiply Test ---")
|
|
# (1+j0) * conj(1+j0) = 1+j0
|
|
# In Q15: 32767 * 32767 -> should get close to 32767
|
|
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
|
|
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 ---
|
|
print("\n--- Range Bin Decimator Test ---")
|
|
test_re = list(range(1024))
|
|
test_im = [0] * 1024
|
|
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__':
|
|
_self_test()
|